feat: Implement GPS reports functionality including vehicle trips, trip details, and abnormalities.
This commit is contained in:
@ -6,19 +6,15 @@ use App\Http\Controllers\Controller;
|
||||
use Illuminate\Http\Request;
|
||||
use Illuminate\Http\Response;
|
||||
use Illuminate\Support\Facades\DB;
|
||||
use Illuminate\Support\Facades\Storage;
|
||||
use Validator;
|
||||
use Auth;
|
||||
use App\Responses;
|
||||
use App\Helper;
|
||||
use Maatwebsite\Excel\Facades\Excel;
|
||||
use Maatwebsite\Excel\Concerns\FromArray;
|
||||
use Maatwebsite\Excel\Concerns\WithHeadings;
|
||||
use Maatwebsite\Excel\Concerns\WithStyles;
|
||||
use Maatwebsite\Excel\Concerns\WithCustomStartCell;
|
||||
use PhpOffice\PhpSpreadsheet\Worksheet\Worksheet;
|
||||
use Carbon\Carbon;
|
||||
use App\Models\UserLogs;
|
||||
use Illuminate\Support\Facades\Crypt;
|
||||
use Illuminate\Contracts\Encryption\DecryptException;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class ReportsController extends Controller
|
||||
{
|
||||
@ -368,4 +364,116 @@ class ReportsController extends Controller
|
||||
}
|
||||
}
|
||||
|
||||
public function api_view_trip_detail(Request $req, $token)
|
||||
{
|
||||
// token = base64_encode(tgl0 + '|' + tgl1 + '|' + nopol1 + '|' + now_unix())
|
||||
// $token = "1759686805|1759693045|B.10-517|1765845676";
|
||||
$token = base64_decode($token);
|
||||
$token = explode('|', $token);
|
||||
$tgl0 = (int) $token[0] ?? null;
|
||||
$tgl1 = (int) $token[1] ?? null;
|
||||
$nopol1 = $token[2] ?? null;
|
||||
$now = (int) $token[3] ?? null;
|
||||
$isMoreThanOneHour = time() - $now > 60 * 60;
|
||||
|
||||
if ($tgl0 == null || $tgl1 == null || $nopol1 == null || $now == null || $isMoreThanOneHour) {
|
||||
$apiResp = Responses::bad_request("Invalid token");
|
||||
return new Response($apiResp, $apiResp["meta"]["code"]);
|
||||
}
|
||||
|
||||
// get vid by nopol1
|
||||
$vid = DB::select("SELECT id FROM t_vehicles WHERE nopol1 = ?", [$nopol1]);
|
||||
if (count($vid) == 0) {
|
||||
$apiResp = Responses::bad_request("Vehicle not found");
|
||||
return new Response($apiResp, $apiResp["meta"]["code"]);
|
||||
}
|
||||
$vid = $vid[0]->id;
|
||||
|
||||
$d = [$vid, $tgl0, $tgl1];
|
||||
$list = DB::select("SELECT
|
||||
t.crt_d, t.latitude, t.longitude, t.speed,
|
||||
tgta.fulladdress,
|
||||
t.pre_milleage, t.vhc_milleage, fuel_count
|
||||
FROM
|
||||
t_gps_tracks t
|
||||
left join t_gps_tracks_address tgta on tgta.master_id = t.id
|
||||
WHERE
|
||||
t.vhc_id = ?
|
||||
and t.latitude IS NOT NULL
|
||||
AND t.longitude IS NOT NULL
|
||||
AND t.action = 'location'
|
||||
AND t.crt_d BETWEEN ? AND ?
|
||||
ORDER BY t.crt_d asc
|
||||
", $d);
|
||||
if (count($list) == 0) {
|
||||
$apiResp = Responses::not_found("Track not found");
|
||||
return new Response($apiResp, $apiResp["meta"]["code"]);
|
||||
}
|
||||
|
||||
$start = [
|
||||
'time' => $list[0]->crt_d,
|
||||
'fulladdress' => urldecode($list[0]->fulladdress),
|
||||
'mileage' => $list[0]->vhc_milleage,
|
||||
];
|
||||
$finish = [
|
||||
'time' => $list[count($list) - 1]->crt_d,
|
||||
'fulladdress' => urldecode($list[count($list) - 1]->fulladdress),
|
||||
'mileage' => $list[count($list) - 1]->vhc_milleage,
|
||||
];
|
||||
|
||||
$t0 = Carbon::createFromTimestamp($list[0]->crt_d);
|
||||
$t1 = Carbon::createFromTimestamp($list[count($list) - 1]->crt_d);
|
||||
$diff = $t1->diff($t0);
|
||||
$hours = $diff->h + ($diff->days * 24); // include days converted to hours
|
||||
$minutes = $diff->i;
|
||||
$duration = "{$hours} hour" . ($hours > 1 ? 's' : '') . " {$minutes} minute" . ($minutes > 1 ? 's' : '');
|
||||
|
||||
$distance = $list[count($list) - 1]->vhc_milleage - $list[0]->vhc_milleage;
|
||||
$fuel_consumed = $list[count($list) - 1]->fuel_count - $list[0]->fuel_count;
|
||||
|
||||
$data = [
|
||||
'nopol1' => $nopol1,
|
||||
'vid' => $vid,
|
||||
'tgl0' => $tgl0,
|
||||
'tgl1' => $tgl1,
|
||||
'list' => $list,
|
||||
'start' => $start,
|
||||
'finish' => $finish,
|
||||
'duration' => $duration,
|
||||
'distance' => $distance,
|
||||
'fuel_consumed' => $fuel_consumed,
|
||||
];
|
||||
// dd($list);
|
||||
return view('menu_v1.reports.view_trip_detail', $data);
|
||||
}
|
||||
|
||||
public function decryptText(string $ciphertext = "FqujI/06YPCpCSP0Xlt6bA==")
|
||||
{
|
||||
$secret = "Mg3Xt1169cRNJWX6HG12DVkgmAXINVuq";
|
||||
$iv_str = "Mg3Xt1169cRNJWX6";
|
||||
|
||||
// Derive key and IV exactly as the web tool does
|
||||
$md5 = md5($secret, true); // 16-byte raw binary MD5
|
||||
$key = $md5 . $md5; // 32 bytes → AES-256 key
|
||||
$iv = substr($md5, 0, 16); // IV = first 16 bytes of MD5
|
||||
|
||||
$data = base64_decode($ciphertext, true);
|
||||
if ($data === false) {
|
||||
return 'Invalid Base64';
|
||||
}
|
||||
|
||||
$plaintext = openssl_decrypt(
|
||||
$data,
|
||||
'aes-256-cbc',
|
||||
$key,
|
||||
0,
|
||||
$iv
|
||||
);
|
||||
|
||||
if ($plaintext === false) {
|
||||
return 'Decryption failed: ' . openssl_error_string();
|
||||
}
|
||||
|
||||
return $plaintext; // → "1764833739"
|
||||
}
|
||||
}
|
||||
|
||||
374
resources/views/menu_v1/reports/view_trip_detail.blade.php
Normal file
374
resources/views/menu_v1/reports/view_trip_detail.blade.php
Normal file
@ -0,0 +1,374 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="\en">
|
||||
|
||||
<head>
|
||||
<title>Movana Fleet Management | @yield('title', 'App')</title>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<meta name="csrf-token" content="{{ csrf_token() }}">
|
||||
<link rel="icon" type="image/x-icon" href="{{ asset('images/favicon.png') }}">
|
||||
<link href="{{ asset('fonts/stylesheet.css') }}" rel="stylesheet">
|
||||
<link href="{{ asset('assets/vendor/bootstrap-5.0.2-dist/css/bootstrap.css') }}" rel="stylesheet">
|
||||
<link href="{{ asset('assets/vendor/leaflet-1.7.1/leaflet.css') }}" rel="stylesheet">
|
||||
<link href="{{ asset('assets/vendor/ionicons-v2.0-1/css/ionicons.css') }}" rel="stylesheet">
|
||||
<link href="{{ asset('assets/vendor/select2-4.1.0-rc.0/dist/css/select2.css') }}" rel="stylesheet">
|
||||
<link href="{{ asset('assets/vendor/DataTables/datatables.css') }}" rel="stylesheet">
|
||||
<link href="{{ asset('assets/css/bootstrap-datepicker.min.css') }}" rel="stylesheet">
|
||||
<link href="{{ asset('assets/css/meus.css') }}" rel="stylesheet">
|
||||
<link rel="stylesheet"
|
||||
href="https://cdnjs.cloudflare.com/ajax/libs/jquery-datetimepicker/2.5.20/jquery.datetimepicker.min.css"
|
||||
integrity="sha512-f0tzWhCwVFS3WeYaofoLWkTP62ObhewQ1EZn65oSYDZUg1+CyywGKkWzm8BxaJj5HGKI72PnMH9jYyIFz+GH7g=="
|
||||
crossorigin="anonymous" referrerpolicy="no-referrer" />
|
||||
<style>
|
||||
.landscape-photo {
|
||||
max-height: max(21vh, 210px);
|
||||
}
|
||||
|
||||
.thumb-img-table {
|
||||
width: max(4vw, 75px);
|
||||
height: max(4vh, 55px);
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.thumb-img-small {
|
||||
width: max(2vw, 45px);
|
||||
height: max(2vh, 35px);
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.thumb-img-landscape {
|
||||
width: max(6vw, 275px);
|
||||
height: max(5vh, 125px);
|
||||
object-fit: cover;
|
||||
}
|
||||
|
||||
.thumb-img-landscape-med {
|
||||
width: max(4vw, 125px);
|
||||
height: max(2vh, 65px);
|
||||
object-fit: contain;
|
||||
}
|
||||
</style>
|
||||
@yield('customcss')
|
||||
</head>
|
||||
|
||||
{{--
|
||||
|
||||
<body onload="startTime()"> --}}
|
||||
|
||||
<body>
|
||||
<div class="container-fluid">
|
||||
<div class="content">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h4>{{$nopol1}}</h4>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row head-text" id="viewPdf">
|
||||
<div class="col-6">
|
||||
<p class="text-bold mb-0">Start</p>
|
||||
<p class="mb-0 time">{{ $start['time'] }}</p>
|
||||
<p class="mb-0">Vehicle Mileage: {{number_format($start['mileage'], 2)}} km</p>
|
||||
<p>{{$start['fulladdress']}}</p>
|
||||
</div>
|
||||
<div class="col-6">
|
||||
<p class="text-bold mb-0">Finish</p>
|
||||
<p class="mb-0 time">{{ $finish['time'] }}</p>
|
||||
<p class="mb-0">Vehicle Mileage: {{number_format($finish['mileage'], 2)}} km</p>
|
||||
<p>{{$finish['fulladdress']}}</p>
|
||||
</div>
|
||||
<div class="col-4">
|
||||
<p class="text-bold mb-0">Distance</p>
|
||||
<p class="mb-0">{{number_format($distance, 2)}} km</p>
|
||||
</div>
|
||||
<div class="col-4">
|
||||
<p class="text-bold mb-0">Duration</p>
|
||||
<p class="mb-0">{{$duration}}</p>
|
||||
</div>
|
||||
<div class="col-4">
|
||||
<p class="text-bold mb-0">Fuel consumption</p>
|
||||
<p class="mb-0">{{$fuel_consumed / 10}} L</p>
|
||||
</div>
|
||||
<div class="col-12 mt-2">
|
||||
<div id="leafMap" style="height: 400px;"></div>
|
||||
</div>
|
||||
<div class="col-12">
|
||||
<!-- <li class="list-group-item p-1 px-2">
|
||||
<p class="text-bold mb-0">Time: 25 Aug 2025 07:31:08</p>
|
||||
<p class="text-muted mb-0 dtl-text">-8.55387 - 125.542409</p>
|
||||
<p class="text-muted mb-0 dtl-text">Avenida Luro Mata, Praia dos Coqueiros, Bebunuk, Dom Aleixo, Dili, Timor-Leste;2066973</p>
|
||||
<p class="mb-0 dtl-text">Current speed: 7km/h</p>
|
||||
</li> -->
|
||||
@foreach ($list as $item)
|
||||
<!-- <li class="list-group-item p-1 px-2">
|
||||
<p class="text-bold mb-0">Time: {{date('d-m-Y H:i:s', $item->crt_d)}}</p>
|
||||
<p class="text-muted mb-0 dtl-text">Vehicle Mileage: {{number_format($item->vhc_milleage, 2)}} km</p>
|
||||
<p class="text-muted mb-0 dtl-text">{{number_format($item->latitude, 6)}} - {{number_format($item->longitude, 6)}}</p>
|
||||
<p class="text-muted mb-0 dtl-text">{{urldecode($item->fulladdress)}}</p>
|
||||
<p class="text-muted mb-0 dtl-text">Current speed: {{number_format($item->speed, 2)}} km/h</p>
|
||||
</li> -->
|
||||
<li class="list-group-item p-1 px-2">
|
||||
<div class="row">
|
||||
<div class="col-4">
|
||||
<p class="text-bold mb-0 dtl-text">Time: <span
|
||||
class="time">{{ $item->crt_d }}</span>
|
||||
</p>
|
||||
<p class="text-muted mb-0 dtl-text">Vehicle Mileage:
|
||||
{{number_format($item->vhc_milleage, 2)}} km
|
||||
</p>
|
||||
<p class="text-muted mb-0 dtl-text">Current speed: {{$item->speed}} km/h</p>
|
||||
</div>
|
||||
<div class="col-8">
|
||||
<p class="text-muted mb-0 dtl-text">{{number_format($item->latitude, 6)}},
|
||||
{{number_format($item->longitude, 6)}}
|
||||
</p>
|
||||
<p class="text-muted mb-0 dtl-text">{{urldecode($item->fulladdress)}}</p>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
@endforeach
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="{{ asset('assets/vendor/jquery-3.6.0/jquery-3.6.0.js') }}"></script>
|
||||
<script src="{{ asset('assets/vendor/bootstrap-5.0.2-dist/js/bootstrap.bundle.min.js') }}"></script>
|
||||
<script src="{{ asset('assets/js/moment.min.js') }}"></script>
|
||||
<script src="{{ asset('assets/js/helper.js') }}"></script>
|
||||
|
||||
<script src="{{ asset('assets/js/load-image.all.min.js') }}"></script>
|
||||
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"
|
||||
integrity="sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo=" crossorigin=""></script>
|
||||
<script src="https://unpkg.com/leaflet-routing-machine@3.2.12/dist/leaflet-routing-machine.js"></script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/html2canvas/1.4.1/html2canvas.min.js"></script>
|
||||
<script src="https://cdnjs.cloudflare.com/ajax/libs/jspdf/2.5.1/jspdf.umd.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/axios/dist/axios.min.js"></script>
|
||||
<script src="{{ asset('assets/vendor/printThis.js') }}"></script>
|
||||
|
||||
<script>
|
||||
$(document).ready(function () {
|
||||
$('.time').each(function () {
|
||||
const unix = parseInt($(this).text().trim()) + 25200;
|
||||
$(this).text(moment.unix(unix).format('DD MMM YYYY HH:mm:ss'));
|
||||
});
|
||||
|
||||
let coords
|
||||
setTimeout(async () => {
|
||||
map.invalidateSize(); // force Leaflet to recalc
|
||||
map.fitBounds(polyline.getBounds());
|
||||
// map.fitBounds(await coords.map(c => L.latLng(c[0], c[1])));
|
||||
}, 200);
|
||||
|
||||
const linesData = (@json($list));
|
||||
|
||||
// 1) Initialize map
|
||||
const map = L.map("leafMap").setView([-8.90507, 125.9945732], 10)
|
||||
|
||||
// 2) Add tile layer
|
||||
L.tileLayer("https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png", {
|
||||
maxZoom: 20,
|
||||
crossOrigin: true
|
||||
}).addTo(map);
|
||||
|
||||
// // // 3) Coordinates (Lat, Lng) for polyline
|
||||
const points = linesData
|
||||
.filter(p => p.latitude && p.longitude)
|
||||
.map((point) => [point.latitude, point.longitude])
|
||||
|
||||
// 4) Add polyline
|
||||
const polyline = L.polyline(points, {
|
||||
color: 'red',
|
||||
weight: 3,
|
||||
opacity: 0.7,
|
||||
smoothFactor: 1
|
||||
})
|
||||
// .addTo(map);
|
||||
|
||||
function chunkArray(arr, size) {
|
||||
const result = [];
|
||||
for (let i = 0; i < arr.length; i += size) {
|
||||
result.push(arr.slice(i, i + size));
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function fetchOsrm(points) {
|
||||
const coords = points;
|
||||
const hints = ";".repeat(points.length - 1);
|
||||
|
||||
const body = {
|
||||
coordinates: coords,
|
||||
overview: "false",
|
||||
alternatives: "false",
|
||||
steps: "true",
|
||||
hints: hints
|
||||
};
|
||||
|
||||
let config = {
|
||||
method: 'post',
|
||||
maxBodyLength: Infinity,
|
||||
url: 'https://brilianapps.britimorleste.tl/osrm-backend/post-route/v1/driving/',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
data: body
|
||||
};
|
||||
|
||||
return axios.request(config)
|
||||
.then((response) => {
|
||||
return response.data;
|
||||
})
|
||||
.catch((error) => {
|
||||
console.error("Error:", error.message);
|
||||
return null;
|
||||
});
|
||||
}
|
||||
|
||||
function decodeOSRMGeometry(encoded) {
|
||||
const coordinates = [];
|
||||
let index = 0,
|
||||
lat = 0,
|
||||
lng = 0;
|
||||
|
||||
while (index < encoded.length) {
|
||||
let result = 1,
|
||||
shift = 0,
|
||||
b;
|
||||
do {
|
||||
b = encoded.charCodeAt(index++) - 63 - 1;
|
||||
result += b << shift;
|
||||
shift += 5;
|
||||
} while (b >= 0x1f);
|
||||
lat += (result & 1 ? ~(result >> 1) : result >> 1);
|
||||
|
||||
result = 1;
|
||||
shift = 0;
|
||||
do {
|
||||
b = encoded.charCodeAt(index++) - 63 - 1;
|
||||
result += b << shift;
|
||||
shift += 5;
|
||||
} while (b >= 0x1f);
|
||||
lng += (result & 1 ? ~(result >> 1) : result >> 1);
|
||||
|
||||
coordinates.push([lat / 1e5, lng / 1e5]);
|
||||
}
|
||||
|
||||
return coordinates;
|
||||
}
|
||||
|
||||
async function getCoordinates(points) {
|
||||
const chunkSize = 500;
|
||||
const chunks = chunkArray(points, chunkSize); // Split the points array into chunks of 500
|
||||
let allCoords = [];
|
||||
|
||||
for (const chunk of chunks) {
|
||||
const osrm = await fetchOsrm(chunk); // Fetch OSRM data for each chunk
|
||||
|
||||
if (!osrm) {
|
||||
console.log("OSRM failed for chunk");
|
||||
return;
|
||||
}
|
||||
|
||||
const coords = osrm.routes[0].legs.flatMap(leg =>
|
||||
leg.steps.flatMap(step =>
|
||||
decodeOSRMGeometry(step.geometry)
|
||||
)
|
||||
);
|
||||
|
||||
allCoords = allCoords.concat(coords); // Combine the result
|
||||
}
|
||||
|
||||
// Now add the polyline to the map
|
||||
L.polyline(allCoords, {
|
||||
color: "#2980B9",
|
||||
weight: 3,
|
||||
opacity: 0.8
|
||||
}).addTo(map);
|
||||
// map.fitBounds(allCoords.map(c => L.latLng(c[0], c[1])));
|
||||
}
|
||||
|
||||
// Usage: Pass the array of coordinates to the function
|
||||
getCoordinates(points);
|
||||
|
||||
|
||||
// start and finish point
|
||||
const startIcon = L.icon({
|
||||
iconUrl: "{{ asset('images/start.png') }}",
|
||||
iconSize: [30, 30],
|
||||
iconAnchor: [15, 28], // lb, rt, bottom, rb. Positive
|
||||
})
|
||||
L.marker(points[0], { icon: startIcon }).addTo(map)
|
||||
|
||||
const finishIcon = L.icon({
|
||||
iconUrl: "{{ asset('images/finish.png') }}",
|
||||
iconSize: [30, 30],
|
||||
iconAnchor: [15, 28], // lb, rt, bottom, rb. Positive
|
||||
})
|
||||
L.marker(points[points.length - 1], { icon: finishIcon }).addTo(map)
|
||||
|
||||
// // 5) Auto-fit map to polyline bounds
|
||||
// map.fitBounds(polyline.getBounds())
|
||||
|
||||
// download pdf
|
||||
window._downloadReportBound ||= (
|
||||
$(document).on('click', '#btnDownloadReport', function () {
|
||||
$('#viewPdf').printThis({
|
||||
debug: false, // show the iframe for debugging
|
||||
importCSS: true, // copy linked styles
|
||||
importStyle: true, // copy inline styles
|
||||
});
|
||||
|
||||
// const viewPdf = document.getElementById("viewPdf");
|
||||
|
||||
// // find overlay svg (the one holding polylines)
|
||||
// const overlaySvg = document.querySelector('.leaflet-overlay-pane svg');
|
||||
// let originalTransform = '';
|
||||
// if (overlaySvg) {
|
||||
// originalTransform = overlaySvg.style.transform;
|
||||
// overlaySvg.style.transform = 'none';
|
||||
// }
|
||||
|
||||
// html2canvas(viewPdf, {
|
||||
// scale: 2,
|
||||
// useCORS: true,
|
||||
// logging: true
|
||||
// }).then(canvas => {
|
||||
// const imgData = canvas.toDataURL('image/png');
|
||||
// const { jsPDF } = window.jspdf;
|
||||
|
||||
// const pdf = new jsPDF('p', 'mm', 'a4');
|
||||
// const pageWidth = pdf.internal.pageSize.getWidth();
|
||||
// const pageHeight = pdf.internal.pageSize.getHeight();
|
||||
|
||||
// const imgWidth = pageWidth - 20; // margin
|
||||
// const imgHeight = canvas.height * imgWidth / canvas.width;
|
||||
|
||||
// let position = 10;
|
||||
|
||||
// // 👉 Handle multipage content
|
||||
// let heightLeft = imgHeight;
|
||||
// while (heightLeft > 0) {
|
||||
// pdf.addImage(imgData, 'PNG', 10, position, imgWidth, imgHeight);
|
||||
// heightLeft -= pageHeight;
|
||||
// if (heightLeft > 0) {
|
||||
// pdf.addPage();
|
||||
// position = 0;
|
||||
// }
|
||||
// }
|
||||
|
||||
// pdf.save(`{{$nopol1}} Trip Report {{$start['time']}}.pdf`);
|
||||
|
||||
// });
|
||||
}),
|
||||
true
|
||||
);
|
||||
|
||||
});
|
||||
</script>
|
||||
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@ -36,4 +36,7 @@ Route::post("/v1/inject/add_conf_rate_v1", "InjectController@add_conf_rate_v1");
|
||||
Route::post("/v1/storage/save_photos", "StorageController@save_photos")->name("api_storage_save_photos");
|
||||
|
||||
Route::post("auth/login", [AuthController::class, "login"]);
|
||||
|
||||
Route::get("/view/vehicle-trip-detail/{token}", "ReportsController@api_view_trip_detail")->name("api_view_trip_detail");
|
||||
|
||||
// });
|
||||
|
||||
Reference in New Issue
Block a user