feat: implement real-time rental map dashboard and integrate location tracking updates into admin modules

This commit is contained in:
sazzadulalambd
2026-05-16 22:34:44 +06:00
parent 1feab1fa23
commit 48fd93fea8
3 changed files with 387 additions and 1 deletions

View File

@@ -52,6 +52,8 @@ interface DamageRecord {
estimatedCost: number;
actualCost?: number;
status: 'reported' | 'in-progress' | 'resolved';
hubId?: string;
hubName?: string;
}
interface MaintenanceRecord {
@@ -63,6 +65,8 @@ interface MaintenanceRecord {
performedBy: string;
nextDueDate?: string;
status: 'completed' | 'pending' | 'overdue';
hubId?: string;
hubName?: string;
}
interface Battery {

View File

@@ -163,7 +163,7 @@ interface Bike {
const mockBikes: Bike[] = [
{
id: 'EV001', model: 'Etron ET50', brand: 'Etron', image: '', plateNumber: 'Dhaka Metro Cha-A-1234', status: 'rented', batteryLevel: 78, currentBatteryId: 'BAT-001', currentBatteryBrand: 'EVE Energy', currentBatteryModel: 'Li-Ion 60V50Ah', location: 'Gulshan 1', assignedTo: 'Rahim Ahmed', renterPhone: '01712345678', renterNid: '1234567890', rentalStartDate: '2024-03-01', subscriptionType: 'weekly', weeklyRent: 2400, location: 'Gulshan 1', assignedTo: 'Rahim Ahmed', hubId: 'HUB-001', hubName: 'JAIBEN Head Office', investorId: 'inv1', investorName: 'Mr. Hasan (Investor)', purchaseDate: '2024-01-15', purchasePrice: 125000, currentRent: 350, totalRides: 156, totalDistance: 2340, totalEarnings: 54600, lastService: '2024-03-01', nextService: '2024-04-01', insuranceExpiry: '2025-01-15', registrationExpiry: '2026-01-15',
id: 'EV001', model: 'Etron ET50', brand: 'Etron', image: '', plateNumber: 'Dhaka Metro Cha-A-1234', status: 'rented', batteryLevel: 78, currentBatteryId: 'BAT-001', currentBatteryBrand: 'EVE Energy', currentBatteryModel: 'Li-Ion 60V50Ah', location: 'Gulshan 1', assignedTo: 'Rahim Ahmed', renterPhone: '01712345678', renterNid: '1234567890', rentalStartDate: '2024-03-01', subscriptionType: 'weekly', weeklyRent: 2400, hubId: 'HUB-001', hubName: 'JAIBEN Head Office', investorId: 'inv1', investorName: 'Mr. Hasan (Investor)', purchaseDate: '2024-01-15', purchasePrice: 125000, currentRent: 350, totalRides: 156, totalDistance: 2340, totalEarnings: 54600, lastService: '2024-03-01', nextService: '2024-04-01', insuranceExpiry: '2025-01-15', registrationExpiry: '2026-01-15',
gpsDevice: { id: 'GPS001', phone: '01712345601', imei: '861234567890123', lastActive: '2024-03-21 14:30', signal: 85, battery: 72 },
documents: [
{ type: 'registration', number: 'REG-EV001-2024', issueDate: '2024-01-15', expiryDate: '2026-01-15', verified: true },

View File

@@ -0,0 +1,382 @@
'use client';
import { useState, useEffect } from 'react';
import Link from 'next/link';
import {
ArrowLeft, Search, Bike, User, MapPin, Battery,
Phone, MessageCircle, X, Navigation, Clock, RefreshCw
} from 'lucide-react';
interface Rental {
id: string;
bikeId: string;
userId: string;
userName: string;
userPhone: string;
bikeModel: string;
bikePlate: string;
bikeBattery: number;
status: 'pending' | 'accepted' | 'active' | 'completed' | 'cancelled' | 'locked';
type: 'single' | 'shared' | 'rent-to-own';
hubId: string;
hubName: string;
location?: {
lat: number;
lng: number;
address?: string;
lastUpdate?: string;
speed?: number;
heading?: number;
};
}
const mockRentals: Rental[] = [
{
id: 'RNT-001',
bikeId: 'BIKE-001',
userId: 'USR-003',
userName: 'Jamal Uddin',
userPhone: '+8801912345678',
bikeModel: 'AIMA Lightning',
bikePlate: 'Dhaka Metro Cha-9012',
bikeBattery: 87,
status: 'active',
type: 'single',
hubId: 'HUB-001',
hubName: 'Gulshan Hub',
location: { lat: 23.7925, lng: 90.4074, address: 'Gulshan 1, Dhaka', lastUpdate: '2024-03-28 14:30:00', speed: 0, heading: 180 },
},
{
id: 'RNT-002',
bikeId: 'BIKE-002',
userId: 'USR-004',
userName: 'Rafiq Islam',
userPhone: '+8801512345678',
bikeModel: 'Yadea DT3',
bikePlate: 'Dhaka Metro Ba-5521',
bikeBattery: 65,
status: 'active',
type: 'shared',
hubId: 'HUB-002',
hubName: 'Banani Hub',
location: { lat: 23.8041, lng: 90.4152, address: 'Banani, Dhaka', lastUpdate: '2024-03-28 14:28:00', speed: 15, heading: 90 },
},
{
id: 'RNT-003',
bikeId: 'BIKE-003',
userId: 'USR-001',
userName: 'Rahim Ahmed',
userPhone: '+8801712345678',
bikeModel: 'AIMA EM5',
bikePlate: 'Dhaka Metro Ko-1234',
bikeBattery: 92,
status: 'active',
type: 'rent-to-own',
hubId: 'HUB-003',
hubName: 'Uttara Hub',
location: { lat: 23.8776, lng: 90.4014, address: 'Uttara Sector 11, Dhaka', lastUpdate: '2024-03-28 14:25:00', speed: 25, heading: 270 },
},
{
id: 'RNT-004',
bikeId: 'BIKE-005',
userId: 'USR-005',
userName: 'Farid Ahmed',
userPhone: '+8801612345678',
bikeModel: 'Yadea G5',
bikePlate: 'Dhaka Metro Ha-5678',
bikeBattery: 45,
status: 'active',
type: 'single',
hubId: 'HUB-004',
hubName: 'Mirpur Hub',
location: { lat: 23.8222, lng: 90.3639, address: 'Mirpur 10, Dhaka', lastUpdate: '2024-03-28 14:20:00', speed: 0, heading: 0 },
},
{
id: 'RNT-005',
bikeId: 'BIKE-001',
userId: 'USR-002',
userName: 'Karim Hasan',
userPhone: '+8801812345678',
bikeModel: 'AIMA Lightning',
bikePlate: 'Dhaka Metro Cha-9012',
bikeBattery: 78,
status: 'pending',
type: 'single',
hubId: 'HUB-001',
hubName: 'Gulshan Hub',
location: { lat: 23.7889, lng: 90.4025, address: 'Dhanmondi 27, Dhaka', lastUpdate: '2024-03-28 14:15:00', speed: 8, heading: 45 },
},
];
const statusColors: Record<string, { bg: string; text: string }> = {
active: { bg: 'bg-green-100', text: 'text-green-700' },
pending: { bg: 'bg-amber-100', text: 'text-amber-700' },
accepted: { bg: 'bg-blue-100', text: 'text-blue-700' },
completed: { bg: 'bg-indigo-100', text: 'text-indigo-700' },
cancelled: { bg: 'bg-slate-100', text: 'text-slate-600' },
locked: { bg: 'bg-red-100', text: 'text-red-700' },
};
export default function RentalMapPage() {
const [rentals, setRentals] = useState<Rental[]>(mockRentals);
const [searchQuery, setSearchQuery] = useState('');
const [statusFilter, setStatusFilter] = useState<string>('all');
const [selectedRental, setSelectedRental] = useState<Rental | null>(null);
const [lastRefresh, setLastRefresh] = useState(new Date());
const [liveLocations, setLiveLocations] = useState<Record<string, { lat: number; lng: number; speed: number }>>({});
const filteredRentals = rentals.filter(r => {
const matchesSearch = r.id.toLowerCase().includes(searchQuery.toLowerCase()) ||
r.userName.toLowerCase().includes(searchQuery.toLowerCase()) ||
r.bikeModel.toLowerCase().includes(searchQuery.toLowerCase());
const matchesStatus = statusFilter === 'all' || r.status === statusFilter;
return matchesSearch && matchesStatus && r.location;
});
const simulateLiveUpdate = () => {
const newLocations: Record<string, { lat: number; lng: number; speed: number }> = {};
rentals.forEach(rental => {
if (rental.location && rental.status === 'active') {
const movement = Math.random() * 0.002 - 0.001;
newLocations[rental.id] = {
lat: rental.location.lat + movement,
lng: rental.location.lng + movement,
speed: Math.floor(Math.random() * 30),
};
}
});
setLiveLocations(newLocations);
setLastRefresh(new Date());
};
useEffect(() => {
const interval = setInterval(simulateLiveUpdate, 5000);
return () => clearInterval(interval);
}, [rentals]);
const getMarkerPosition = (rental: Rental) => {
if (liveLocations[rental.id]) {
return { lat: liveLocations[rental.id].lat, lng: liveLocations[rental.id].lng };
}
return { lat: rental.location?.lat || 0, lng: rental.location?.lng || 0 };
};
return (
<div className="min-h-screen bg-slate-100">
<div className="p-4 bg-white shadow-sm border-b border-slate-200">
<div className="flex items-center gap-3 mb-3">
<Link href="/admin/rentals" className="p-2 hover:bg-slate-100 rounded-lg">
<ArrowLeft className="w-5 h-5 text-slate-600" />
</Link>
<div className="flex-1">
<h1 className="text-xl font-extrabold text-slate-800">Live Rental Map</h1>
</div>
<button
onClick={simulateLiveUpdate}
className="p-2 bg-emerald-600 text-white rounded-lg hover:bg-emerald-700"
>
<RefreshCw className="w-5 h-5" />
</button>
</div>
<div className="flex flex-col sm:flex-row gap-2">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" />
<input
type="text"
placeholder="Search..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="w-full pl-10 pr-4 py-2 border border-slate-200 rounded-lg text-sm text-slate-600"
/>
</div>
<select
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value)}
className="py-2 px-4 border border-slate-200 rounded-lg text-sm font-medium text-slate-600"
>
<option value="all">All Status</option>
<option value="active">Active</option>
<option value="pending">Pending</option>
<option value="completed">Completed</option>
</select>
</div>
</div>
<div className="p-4 space-y-4">
<div className="bg-white rounded-xl shadow-sm border border-slate-100 relative overflow-hidden h-64 sm:h-80 lg:h-96">
<div className="absolute inset-0 bg-slate-100">
<div className="relative w-full h-full">
<div className="absolute inset-0 flex items-center justify-center">
<div className="text-center">
<Navigation className="w-16 h-16 text-emerald-400 mx-auto mb-4" />
<p className="text-lg font-semibold text-slate-600">Live Map View</p>
<p className="text-sm text-slate-500 mt-1">
Showing {filteredRentals.length} rentals on map
</p>
<p className="text-xs text-slate-400 mt-2">
Last updated: {lastRefresh.toLocaleTimeString()}
</p>
</div>
</div>
{filteredRentals.map((rental, index) => {
const pos = getMarkerPosition(rental);
const x = ((pos.lng - 90.35) / 0.15) * 100;
const y = ((23.95 - pos.lat) / 0.2) * 100;
return (
<div
key={rental.id}
className={`absolute transform -translate-x-1/2 -translate-y-1/2 cursor-pointer ${selectedRental?.id === rental.id ? 'z-20' : 'z-10'}`}
style={{ left: `${Math.min(95, Math.max(5, x))}%`, top: `${Math.min(90, Math.max(10, y))}%` }}
onClick={() => setSelectedRental(rental)}
>
<div className={`relative w-10 h-10 rounded-full flex items-center justify-center shadow-lg ${rental.status === 'active' ? 'bg-green-500' : rental.status === 'pending' ? 'bg-amber-500' : 'bg-slate-400'}`}>
<Bike className="w-5 h-5 text-white" />
<span className="absolute -top-1 -right-1 w-4 h-4 bg-white rounded-full flex items-center justify-center text-[10px] font-bold text-slate-700">
{index + 1}
</span>
</div>
<div className="absolute top-12 left-1/2 -translate-x-1/2 bg-white px-2 py-1 rounded-lg shadow text-xs font-medium text-slate-700 whitespace-nowrap">
{rental.id}
</div>
</div>
);
})}
</div>
</div>
</div>
<div className="bg-white rounded-xl shadow-sm border border-slate-100 overflow-hidden flex flex-col max-h-96">
<div className="p-3 border-b border-slate-100 flex justify-between items-center">
<h3 className="font-semibold text-slate-700 text-sm">
Rental List ({filteredRentals.length})
</h3>
<div className="flex items-center gap-1 text-xs text-slate-500">
<div className="w-2 h-2 rounded-full bg-green-500 animate-pulse"></div>
Live
</div>
</div>
<div className="flex-1 overflow-y-auto p-2 space-y-2">
{filteredRentals.map((rental, index) => (
<div
key={rental.id}
onClick={() => setSelectedRental(rental)}
className={`p-3 rounded-lg border cursor-pointer transition-all ${selectedRental?.id === rental.id ? 'border-emerald-500 bg-emerald-50' : 'border-slate-200 hover:border-emerald-300'}`}
>
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<span className="w-6 h-6 rounded-full bg-emerald-100 flex items-center justify-center text-xs font-bold text-emerald-700">
{index + 1}
</span>
<Link href={`/admin/rentals/${rental.id}`} className="text-sm font-medium text-emerald-600 hover:underline">
{rental.id}
</Link>
</div>
<span className={`text-xs font-medium px-2 py-0.5 rounded-full ${statusColors[rental.status].bg} ${statusColors[rental.status].text}`}>
{rental.status}
</span>
</div>
<div className="flex items-center gap-2 mb-2">
<Bike className="w-4 h-4 text-slate-400" />
<span className="text-sm text-slate-600">{rental.bikeModel}</span>
</div>
<div className="flex items-center gap-2 mb-2">
<User className="w-4 h-4 text-slate-400" />
<span className="text-sm text-slate-600">{rental.userName}</span>
</div>
<div className="flex items-center justify-between text-xs text-slate-500">
<div className="flex items-center gap-1">
<MapPin className="w-3 h-3" />
{rental.location?.address || rental.hubName}
</div>
<div className="flex items-center gap-1">
<Battery className="w-3 h-3" />
{rental.bikeBattery}%
</div>
</div>
{liveLocations[rental.id] && (
<div className="mt-2 pt-2 border-t border-slate-100 flex items-center justify-between text-xs">
<span className="text-slate-400">
Speed: {liveLocations[rental.id].speed} km/h
</span>
<span className="text-slate-400 flex items-center gap-1">
<Clock className="w-3 h-3" /> {rental.location?.lastUpdate}
</span>
</div>
)}
</div>
))}
</div>
</div>
</div>
{selectedRental && (
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4" onClick={() => setSelectedRental(null)}>
<div className="bg-white rounded-xl shadow-xl w-full max-w-md" onClick={e => e.stopPropagation()}>
<div className="p-4 border-b border-slate-100 flex justify-between items-center">
<h3 className="font-semibold text-slate-800">Rental Details</h3>
<button onClick={() => setSelectedRental(null)} className="text-slate-400 hover:text-slate-600">
<X className="w-5 h-5" />
</button>
</div>
<div className="p-4 space-y-4">
<div className="flex items-center justify-between">
<span className="text-lg font-bold text-emerald-600">{selectedRental.id}</span>
<span className={`text-xs font-medium px-2 py-1 rounded-full ${statusColors[selectedRental.status].bg} ${statusColors[selectedRental.status].text}`}>
{selectedRental.status}
</span>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="bg-slate-50 rounded-lg p-3">
<p className="text-xs text-slate-500">Bike</p>
<p className="font-medium text-slate-700">{selectedRental.bikeModel}</p>
<p className="text-xs text-slate-400">{selectedRental.bikePlate}</p>
</div>
<div className="bg-slate-50 rounded-lg p-3">
<p className="text-xs text-slate-500">Battery</p>
<p className={`font-medium ${selectedRental.bikeBattery > 50 ? 'text-green-600' : selectedRental.bikeBattery > 20 ? 'text-amber-600' : 'text-red-600'}`}>
{selectedRental.bikeBattery}%
</p>
</div>
</div>
<div className="bg-slate-50 rounded-lg p-3">
<p className="text-xs text-slate-500 mb-1">Renter</p>
<p className="font-medium text-slate-700">{selectedRental.userName}</p>
<p className="text-sm text-slate-500">{selectedRental.userPhone}</p>
</div>
<div className="bg-slate-50 rounded-lg p-3">
<p className="text-xs text-slate-500 mb-1">Location</p>
<p className="font-medium text-slate-700">{selectedRental.location?.address}</p>
<p className="text-xs text-slate-400">Lat: {selectedRental.location?.lat.toFixed(4)}, Lng: {selectedRental.location?.lng.toFixed(4)}</p>
{selectedRental.location?.lastUpdate && (
<p className="text-xs text-slate-400 mt-1">Last Update: {selectedRental.location.lastUpdate}</p>
)}
</div>
<div className="flex gap-2">
<a href={`tel:${selectedRental.userPhone}`} className="flex-1 py-2 px-4 bg-emerald-100 text-emerald-700 rounded-lg text-sm font-medium text-center flex items-center justify-center gap-2 hover:bg-emerald-200">
<Phone className="w-4 h-4" /> Call
</a>
<a href={`sms:${selectedRental.userPhone}`} className="flex-1 py-2 px-4 bg-blue-100 text-blue-700 rounded-lg text-sm font-medium text-center flex items-center justify-center gap-2 hover:bg-blue-200">
<MessageCircle className="w-4 h-4" /> SMS
</a>
</div>
<Link href={`/admin/rentals/${selectedRental.id}`} className="block w-full py-2 px-4 bg-slate-100 text-slate-700 rounded-lg text-sm font-medium text-center hover:bg-slate-200">
View Full Details
</Link>
</div>
</div>
</div>
)}
</div>
);
}