feat: implement real-time rental map dashboard and integrate location tracking updates into admin modules
This commit is contained in:
@@ -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 {
|
||||
|
||||
@@ -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 },
|
||||
|
||||
382
src/app/admin/rentals/map/page.tsx
Normal file
382
src/app/admin/rentals/map/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user