Compare commits
20 Commits
5e59909e8e
...
fb1eff4931
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fb1eff4931 | ||
|
|
0274e9a90b | ||
|
|
48fd93fea8 | ||
|
|
1feab1fa23 | ||
|
|
4c25990e70 | ||
|
|
de9499b567 | ||
|
|
ec487f6d27 | ||
|
|
36b12772b7 | ||
|
|
4b1ff96db2 | ||
|
|
bd18c265ca | ||
|
|
ce4bbfaf81 | ||
|
|
adbcded611 | ||
|
|
21c408f828 | ||
|
|
1882cfbb91 | ||
|
|
41530a4691 | ||
|
|
e932e6b817 | ||
|
|
b39f3981fc | ||
|
|
f5cd411a05 | ||
|
|
62b8d567bd | ||
|
|
d8e82cef19 |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -50,3 +50,6 @@ next-env.d.ts
|
||||
|
||||
**/docs
|
||||
**/.docs
|
||||
|
||||
**/deploy.zip
|
||||
**/deploy
|
||||
20
deploy.sh
Executable file
20
deploy.sh
Executable file
@@ -0,0 +1,20 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Build the project
|
||||
echo "Building project..."
|
||||
npm run build
|
||||
|
||||
# Create a deployment folder
|
||||
echo "Preparing deployment files..."
|
||||
mkdir -p deploy
|
||||
cp -r .next deploy/
|
||||
cp -r public deploy/
|
||||
cp server.js deploy/
|
||||
cp package.json deploy/
|
||||
cp next.config.ts deploy/
|
||||
|
||||
# Optional: Zip the files
|
||||
echo "Zipping deployment files..."
|
||||
cd deploy && zip -r ../deploy.zip . && cd ..
|
||||
|
||||
echo "Done! Upload 'deploy.zip' to your cPanel directory and follow the guide."
|
||||
@@ -8,6 +8,7 @@ const withPWA = withPWAInit({
|
||||
} as any);
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
output: 'standalone',
|
||||
images: {
|
||||
remotePatterns: [
|
||||
{
|
||||
|
||||
19
server.js
Normal file
19
server.js
Normal file
@@ -0,0 +1,19 @@
|
||||
const { createServer } = require('http')
|
||||
const { parse } = require('url')
|
||||
const next = require('next')
|
||||
|
||||
const dev = process.env.NODE_ENV !== 'production'
|
||||
const app = next({ dev })
|
||||
const handle = app.getRequestHandler()
|
||||
|
||||
const port = process.env.PORT || 3000
|
||||
|
||||
app.prepare().then(() => {
|
||||
createServer((req, res) => {
|
||||
const parsedUrl = parse(req.url, true)
|
||||
handle(req, res, parsedUrl)
|
||||
}).listen(port, (err) => {
|
||||
if (err) throw err
|
||||
console.log(`> Ready on http://localhost:${port}`)
|
||||
})
|
||||
})
|
||||
1149
src/app/admin/batteries/[id]/page.tsx
Normal file
1149
src/app/admin/batteries/[id]/page.tsx
Normal file
File diff suppressed because it is too large
Load Diff
1357
src/app/admin/batteries/page.tsx
Normal file
1357
src/app/admin/batteries/page.tsx
Normal file
File diff suppressed because it is too large
Load Diff
@@ -752,7 +752,7 @@ function SectionCard({ title, icon: Icon, children, headerBg = 'bg-slate-50', ed
|
||||
{editKey && setEditingSection ? (
|
||||
editingSection !== editKey ? (
|
||||
<button onClick={() => { setEditingSection(editKey); onEdit?.(); }} className="p-1.5 hover:bg-white rounded-lg transition-colors">
|
||||
<Edit className="w-4 h-4 text-slate-500" />
|
||||
{/* <Edit className="w-4 h-4 text-slate-500" /> */}
|
||||
</button>
|
||||
) : (
|
||||
<div className="flex gap-1">
|
||||
@@ -2040,7 +2040,6 @@ export default function BikerDetailPage() {
|
||||
<>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<p className="text-xs font-semibold text-slate-500">Editing: {biker.bikes.batteries[editForm.editingIndex]?.name}</p>
|
||||
<button onClick={() => setEditForm({ ...editForm, editingIndex: undefined })} className="text-xs text-blue-600 hover:underline">+ Add New</button>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
@@ -2083,60 +2082,14 @@ export default function BikerDetailPage() {
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<p className="text-xs font-semibold text-slate-500">Add New Battery</p>
|
||||
{biker.bikes.batteries.length > 0 && (
|
||||
<button onClick={() => { const bat = biker.bikes.batteries[0]; setEditForm({ batId: bat.id, batName: bat.name, batPercent: bat.percent, batStatus: bat.status, batLocation: bat.location, batSwappedAt: bat.swappedAt, batOdometer: bat.odometer, editingIndex: 0 }); }} className="text-xs text-blue-600 hover:underline">Edit existing</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="text-xs font-medium text-slate-600 mb-1 block">Battery ID <span className="text-red-500">*</span></label>
|
||||
<input type="text" value={editForm.batId || ''} onChange={(e) => setEditForm({ ...editForm, batId: e.target.value })} className="w-full px-3 py-2 border border-slate-200 rounded-lg text-xs" placeholder="BAT-DH-004" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs font-medium text-slate-600 mb-1 block">Battery Name</label>
|
||||
<input type="text" value={editForm.batName || ''} onChange={(e) => setEditForm({ ...editForm, batName: e.target.value })} className="w-full px-3 py-2 border border-slate-200 rounded-lg text-xs" placeholder="Battery D" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="text-xs font-medium text-slate-600 mb-1 block">Charge %</label>
|
||||
<input type="number" value={editForm.batPercent || ''} onChange={(e) => setEditForm({ ...editForm, batPercent: e.target.value })} className="w-full px-3 py-2 border border-slate-200 rounded-lg text-xs" placeholder="0-100" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs font-medium text-slate-600 mb-1 block">Status</label>
|
||||
<select value={editForm.batStatus || 'available'} onChange={(e) => setEditForm({ ...editForm, batStatus: e.target.value })} className="w-full px-3 py-2 border border-slate-200 rounded-lg text-xs bg-white">
|
||||
<option value="active">Active</option>
|
||||
<option value="available">Available</option>
|
||||
<option value="charging">Charging</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs font-medium text-slate-600 mb-1 block">Location</label>
|
||||
<input type="text" value={editForm.batLocation || ''} onChange={(e) => setEditForm({ ...editForm, batLocation: e.target.value })} className="w-full px-3 py-2 border border-slate-200 rounded-lg text-xs" placeholder="Swap Station" />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="text-xs font-medium text-slate-600 mb-1 block">Swapped At</label>
|
||||
<input type="text" value={editForm.batSwappedAt || ''} onChange={(e) => setEditForm({ ...editForm, batSwappedAt: e.target.value })} className="w-full px-3 py-2 border border-slate-200 rounded-lg text-xs" placeholder="YYYY-MM-DD HH:MM" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-xs font-medium text-slate-600 mb-1 block">Odometer (KM)</label>
|
||||
<input type="number" value={editForm.batOdometer || ''} onChange={(e) => setEditForm({ ...editForm, batOdometer: e.target.value })} className="w-full px-3 py-2 border border-slate-200 rounded-lg text-xs" />
|
||||
</div>
|
||||
<div className="p-3 bg-slate-50 rounded-lg border border-slate-200">
|
||||
<p className="text-sm text-slate-600">Click edit button on existing batteries to update them.</p>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex justify-end mb-2">
|
||||
<button onClick={() => { setEditingSection('batteryHistory'); setEditForm({ editingIndex: undefined }); }} className="px-3 py-1.5 bg-blue-600 text-white rounded-lg text-xs font-medium hover:bg-blue-700 flex items-center gap-1">
|
||||
<Plus className="w-3 h-3" /> Add Battery
|
||||
</button>
|
||||
</div>
|
||||
{biker.bikes.batteries.map((bat, idx) => (
|
||||
<div key={bat.id} className={`p-4 rounded-lg border ${bat.status === 'active' ? 'bg-green-50 border-green-200' : bat.status === 'charging' ? 'bg-amber-50 border-amber-200' : 'bg-slate-50 border-slate-200'}`}>
|
||||
<div className="flex items-center justify-between">
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
GaugeCircle, CheckCircle, AlertTriangle, Activity, Award, TrendingUp, Wallet,
|
||||
MoreHorizontal, Map, Navigation2, Satellite, FileCheck, FileX, Clock3,
|
||||
History, CreditCard, User2, Phone, Mail, MapPinned, ExternalLink, Plus,
|
||||
AlertCircle, Image as ImageIcon
|
||||
AlertCircle, Image as ImageIcon, Camera
|
||||
} from 'lucide-react';
|
||||
|
||||
interface GPSDevice {
|
||||
@@ -74,6 +74,8 @@ interface DamageRecord {
|
||||
estimatedCost?: number;
|
||||
actualCost?: number;
|
||||
status: 'reported' | 'under_repair' | 'repaired' | 'claim_rejected';
|
||||
hubId?: string;
|
||||
hubName?: string;
|
||||
images?: string[];
|
||||
billImage?: string;
|
||||
resolvedAt?: string;
|
||||
@@ -89,9 +91,26 @@ interface MaintenanceRecord {
|
||||
cost: number;
|
||||
nextDueDate?: string;
|
||||
status: 'scheduled' | 'in_progress' | 'completed';
|
||||
hubId?: string;
|
||||
hubName?: string;
|
||||
notes?: string;
|
||||
}
|
||||
|
||||
interface BatteryHistory {
|
||||
id: string;
|
||||
batteryId: string;
|
||||
brand: string;
|
||||
model: string;
|
||||
serialNumber: string;
|
||||
assignedDate: string;
|
||||
returnedDate?: string;
|
||||
swappedToBatteryId?: string;
|
||||
status: 'active' | 'returned' | 'swapped';
|
||||
socStart: number;
|
||||
socEnd?: number;
|
||||
monthlyRent?: number;
|
||||
}
|
||||
|
||||
interface Bike {
|
||||
id: string;
|
||||
model: string;
|
||||
@@ -100,10 +119,19 @@ interface Bike {
|
||||
plateNumber: string;
|
||||
status: 'available' | 'rented' | 'maintenance' | 'retired';
|
||||
batteryLevel: number;
|
||||
location?: string; // deprecated - use hubId/hubName
|
||||
currentBatteryId?: string;
|
||||
currentBatteryBrand?: string;
|
||||
currentBatteryModel?: string;
|
||||
location?: string;
|
||||
hubId?: string;
|
||||
hubName?: string;
|
||||
assignedTo?: string;
|
||||
renterPhone?: string;
|
||||
renterNid?: string;
|
||||
rentalStartDate?: string;
|
||||
subscriptionType?: 'daily' | 'weekly' | 'monthly';
|
||||
weeklyRent?: number;
|
||||
monthlyRent?: number;
|
||||
investorId?: string;
|
||||
investorName?: string;
|
||||
purchaseDate?: string;
|
||||
@@ -124,11 +152,18 @@ interface Bike {
|
||||
assignmentHistory?: BikeAssignment[];
|
||||
damageHistory?: DamageRecord[];
|
||||
maintenanceHistory?: MaintenanceRecord[];
|
||||
batteryHistory?: BatteryHistory[];
|
||||
bikeImages?: {
|
||||
front?: string;
|
||||
back?: string;
|
||||
left?: string;
|
||||
right?: string;
|
||||
};
|
||||
}
|
||||
|
||||
const mockBikes: Bike[] = [
|
||||
{
|
||||
id: 'EV001', model: 'Etron ET50', brand: 'Etron', image: '', plateNumber: 'Dhaka Metro Cha-A-1234', status: 'rented', batteryLevel: 78, 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 },
|
||||
@@ -149,14 +184,36 @@ const mockBikes: Bike[] = [
|
||||
{ id: 'A005', action: 'Insurance Renewed', details: 'Insurance renewed for 1 year', date: '2024-01-15', by: 'Admin' },
|
||||
],
|
||||
damageHistory: [
|
||||
{ id: 'DMG001', date: '2024-02-10', type: 'accident', description: 'Minor collision at Mirpur intersection', reportedBy: 'Jamal Khan', reportedAt: '2024-02-10 14:30', estimatedCost: 5000, actualCost: 4500, status: 'repaired', resolvedAt: '2024-02-15' },
|
||||
{ id: 'DMG002', date: '2024-03-15', type: 'wear_tear', description: 'Front tire wear - replaced', reportedBy: 'Rahim Ahmed', reportedAt: '2024-03-15 09:00', estimatedCost: 2500, actualCost: 2200, status: 'repaired', resolvedAt: '2024-03-16' },
|
||||
{ id: 'DMG001', date: '2024-02-10', type: 'accident', description: 'Minor collision at Mirpur intersection', reportedBy: 'Jamal Khan', reportedAt: '2024-02-10 14:30', estimatedCost: 5000, actualCost: 4500, status: 'repaired', resolvedAt: '2024-02-15', hubId: 'HUB-001', hubName: 'Gulshan Hub' },
|
||||
{ id: 'DMG002', date: '2024-03-15', type: 'wear_tear', description: 'Front tire wear - replaced', reportedBy: 'Rahim Ahmed', reportedAt: '2024-03-15 09:00', estimatedCost: 2500, actualCost: 2200, status: 'repaired', resolvedAt: '2024-03-16', hubId: 'HUB-002', hubName: 'Banani Hub' },
|
||||
],
|
||||
maintenanceHistory: [
|
||||
{ id: 'MNT001', date: '2024-03-01', type: 'routine', description: 'Full service - oil change, brake check, tire rotation', performedBy: 'Service Center', cost: 1500, nextDueDate: '2024-04-01', status: 'completed' },
|
||||
{ id: 'MNT002', date: '2024-02-15', type: 'battery', description: 'Battery health check and terminal cleaning', performedBy: 'Service Center', cost: 500, nextDueDate: '2024-05-15', status: 'completed' },
|
||||
{ id: 'MNT003', date: '2024-01-20', type: 'tire', description: 'Tire pressure check and inflation', performedBy: 'Service Center', cost: 300, nextDueDate: '2024-04-20', status: 'completed' },
|
||||
]
|
||||
{ id: 'MNT001', date: '2024-03-01', type: 'routine', description: 'Full service - oil change, brake check, tire rotation', performedBy: 'Service Center', cost: 1500, nextDueDate: '2024-04-01', status: 'completed', hubId: 'HUB-001', hubName: 'Gulshan Hub' },
|
||||
{ id: 'MNT002', date: '2024-02-15', type: 'battery', description: 'Battery health check and terminal cleaning', performedBy: 'Service Center', cost: 500, nextDueDate: '2024-05-15', status: 'completed', hubId: 'HUB-003', hubName: 'Uttara Hub' },
|
||||
{ id: 'MNT003', date: '2024-01-20', type: 'tire', description: 'Tire pressure check and inflation', performedBy: 'Service Center', cost: 300, nextDueDate: '2024-04-20', status: 'completed', hubId: 'HUB-004', hubName: 'Mirpur Hub' },
|
||||
],
|
||||
batteryHistory: [
|
||||
{ id: 'BH001', batteryId: 'BAT-001', brand: 'EVE Energy', model: 'Li-Ion 60V50Ah', serialNumber: 'SN-2024-00001', assignedDate: '2024-03-15', status: 'active', socStart: 85, monthlyRent: 1500 },
|
||||
{ id: 'BH002', batteryId: 'BAT-005', brand: 'Samsung SDI', model: 'Li-Ion 60V45Ah', serialNumber: 'SN-2023-00045', assignedDate: '2024-01-10', returnedDate: '2024-03-14', status: 'returned', socStart: 80, socEnd: 45, monthlyRent: 1200 },
|
||||
{ id: 'BH003', batteryId: 'BAT-008', brand: 'LG Chem', model: 'Li-Ion 48V40Ah', serialNumber: 'SN-2023-00012', assignedDate: '2023-11-05', returnedDate: '2024-01-09', status: 'swapped', socStart: 90, socEnd: 60, monthlyRent: 1000 },
|
||||
{ id: 'BH004', batteryId: 'BAT-003', brand: 'Panasonic', model: 'Li-Ion 60V50Ah', serialNumber: 'SN-2023-00078', assignedDate: '2023-08-20', returnedDate: '2023-11-04', status: 'swapped', socStart: 88, socEnd: 55, monthlyRent: 1500 },
|
||||
{ id: 'BH005', batteryId: 'BAT-002', brand: 'Sony', model: 'Li-Ion 48V35Ah', serialNumber: 'SN-2023-00034', assignedDate: '2023-05-15', returnedDate: '2023-08-19', status: 'returned', socStart: 75, socEnd: 40, monthlyRent: 900 },
|
||||
{ id: 'BH006', batteryId: 'BAT-012', brand: 'BYD', model: 'LiFePO4 60V40Ah', serialNumber: 'SN-2023-00056', assignedDate: '2024-04-20', returnedDate: '2024-06-15', status: 'returned', socStart: 92, socEnd: 35, monthlyRent: 1300 },
|
||||
{ id: 'BH007', batteryId: 'BAT-015', brand: 'CATL', model: 'Li-Ion 72V50Ah', serialNumber: 'SN-2024-00089', assignedDate: '2024-06-20', status: 'active', socStart: 88, monthlyRent: 1800 },
|
||||
{ id: 'BH008', batteryId: 'BAT-009', brand: 'Tongsheng', model: 'Li-Ion 48V45Ah', serialNumber: 'SN-2023-00023', assignedDate: '2023-02-10', returnedDate: '2023-05-14', status: 'returned', socStart: 82, socEnd: 50, monthlyRent: 1100 },
|
||||
{ id: 'BH009', batteryId: 'BAT-011', brand: 'Binek', model: 'Li-Ion 60V48Ah', serialNumber: 'SN-2023-00067', assignedDate: '2024-02-01', returnedDate: '2024-04-18', status: 'returned', socStart: 78, socEnd: 42, monthlyRent: 1400 },
|
||||
{ id: 'BH010', batteryId: 'BAT-007', brand: 'Kexin', model: 'Li-Ion 48V36Ah', serialNumber: 'SN-2022-00045', assignedDate: '2022-12-05', returnedDate: '2023-02-08', status: 'returned', socStart: 85, socEnd: 55, monthlyRent: 850 },
|
||||
{ id: 'BH011', batteryId: 'BAT-004', brand: 'Faraday', model: 'LiFePO4 48V42Ah', serialNumber: 'SN-2022-00089', assignedDate: '2022-09-15', returnedDate: '2022-12-04', status: 'returned', socStart: 90, socEnd: 48, monthlyRent: 1200 },
|
||||
{ id: 'BH012', batteryId: 'BAT-006', brand: 'Reliance', model: 'Lead Acid 48V32Ah', serialNumber: 'SN-2022-00034', assignedDate: '2022-06-20', returnedDate: '2022-09-14', status: 'returned', socStart: 95, socEnd: 30, monthlyRent: 600 },
|
||||
{ id: 'BH013', batteryId: 'BAT-020', brand: 'Maxell', model: 'Li-Ion 60V45Ah', serialNumber: 'SN-2024-00123', assignedDate: '2024-07-10', swappedToBatteryId: 'BAT-025', status: 'swapped', socStart: 75, socEnd: 65, monthlyRent: 1400 },
|
||||
{ id: 'BH014', batteryId: 'BAT-018', brand: 'Nikola', model: 'LiFePO4 48V40Ah', serialNumber: 'SN-2023-00078', assignedDate: '2024-05-05', swappedToBatteryId: 'BAT-022', status: 'swapped', socStart: 82, socEnd: 55, monthlyRent: 1150 },
|
||||
],
|
||||
bikeImages: {
|
||||
front: 'https://images.unsplash.com/photo-1558618666-fcd25c85cd64?w=400&h=300&fit=crop',
|
||||
back: 'https://images.unsplash.com/photo-1591637333184-19aa84de3fbd?w=400&h=300&fit=crop',
|
||||
left: 'https://images.unsplash.com/photo-1622185135505-2d795043906a?w=400&h=300&fit=crop',
|
||||
right: 'https://images.unsplash.com/photo-1609630875171-b1321377ee53?w=400&h=300&fit=crop',
|
||||
}
|
||||
},
|
||||
{
|
||||
id: 'EV002', model: 'Yadea DT3', brand: 'Yadea', image: '', plateNumber: 'Dhaka Metro Cha-A-5678', status: 'available', batteryLevel: 95, location: 'Banani', hubId: 'HUB-002', hubName: 'Banani Hub', investorId: 'inv1', investorName: 'Mr. Hasan (Investor)', purchaseDate: '2024-02-01', purchasePrice: 118000, totalRides: 89, totalDistance: 1567, totalEarnings: 31150, lastService: '2024-03-15', nextService: '2024-04-15', insuranceExpiry: '2025-02-01', registrationExpiry: '2026-02-01',
|
||||
@@ -258,6 +315,13 @@ export default function FleetDetailPage({ params }: { params: Promise<{ id: stri
|
||||
{ value: 'other', label: 'Other' },
|
||||
];
|
||||
|
||||
const mockHubs = [
|
||||
{ id: 'HUB-001', name: 'Gulshan Hub' },
|
||||
{ id: 'HUB-002', name: 'Banani Hub' },
|
||||
{ id: 'HUB-003', name: 'Uttara Hub' },
|
||||
{ id: 'HUB-004', name: 'Mirpur Hub' },
|
||||
];
|
||||
|
||||
const handleAddDamage = (damage: DamageRecord) => {
|
||||
setBikes(bikes.map(b => {
|
||||
if (b.id === bike.id) {
|
||||
@@ -415,6 +479,7 @@ export default function FleetDetailPage({ params }: { params: Promise<{ id: stri
|
||||
<th className="px-4 py-3 text-left text-xs font-semibold text-slate-500 uppercase">Date</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-semibold text-slate-500 uppercase">Type</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-semibold text-slate-500 uppercase">Description</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-semibold text-slate-500 uppercase">Hub</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-semibold text-slate-500 uppercase">Reported By</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-semibold text-slate-500 uppercase">Est. Cost</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-semibold text-slate-500 uppercase">Actual Cost</th>
|
||||
@@ -430,6 +495,7 @@ export default function FleetDetailPage({ params }: { params: Promise<{ id: stri
|
||||
<span className="text-sm text-slate-700 capitalize">{damage.type.replace('_', ' ')}</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-slate-600 max-w-xs truncate">{damage.description}</td>
|
||||
<td className="px-4 py-3 text-sm text-slate-600">{damage.hubName || '-'}</td>
|
||||
<td className="px-4 py-3 text-sm text-slate-600">{damage.reportedBy}</td>
|
||||
<td className="px-4 py-3 text-sm text-slate-600">৳{damage.estimatedCost || 0}</td>
|
||||
<td className="px-4 py-3 text-sm font-medium text-slate-700">৳{damage.actualCost || '-'}</td>
|
||||
@@ -498,6 +564,7 @@ export default function FleetDetailPage({ params }: { params: Promise<{ id: stri
|
||||
<th className="px-4 py-3 text-left text-xs font-semibold text-slate-500 uppercase">Date</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-semibold text-slate-500 uppercase">Type</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-semibold text-slate-500 uppercase">Description</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-semibold text-slate-500 uppercase">Hub</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-semibold text-slate-500 uppercase">Performed By</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-semibold text-slate-500 uppercase">Cost</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-semibold text-slate-500 uppercase">Next Due</th>
|
||||
@@ -513,6 +580,7 @@ export default function FleetDetailPage({ params }: { params: Promise<{ id: stri
|
||||
<span className="text-sm text-slate-700 capitalize">{maintenance.type}</span>
|
||||
</td>
|
||||
<td className="px-4 py-3 text-sm text-slate-600 max-w-xs truncate">{maintenance.description}</td>
|
||||
<td className="px-4 py-3 text-sm text-slate-600">{maintenance.hubName || '-'}</td>
|
||||
<td className="px-4 py-3 text-sm text-slate-600">{maintenance.performedBy}</td>
|
||||
<td className="px-4 py-3 text-sm font-medium text-slate-700">৳{maintenance.cost}</td>
|
||||
<td className="px-4 py-3 text-sm text-slate-600">{maintenance.nextDueDate || '-'}</td>
|
||||
@@ -591,6 +659,15 @@ export default function FleetDetailPage({ params }: { params: Promise<{ id: stri
|
||||
}
|
||||
|
||||
function OverviewTab({ bike }: { bike: Bike }) {
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const itemsPerPage = 5;
|
||||
const batteryHistory = bike.batteryHistory || [];
|
||||
const totalPages = Math.ceil(batteryHistory.length / itemsPerPage);
|
||||
const paginatedHistory = batteryHistory.slice(
|
||||
(currentPage - 1) * itemsPerPage,
|
||||
currentPage * itemsPerPage
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="bg-white rounded-xl p-4 lg:p-6 shadow-sm border border-slate-100">
|
||||
@@ -614,20 +691,38 @@ function OverviewTab({ bike }: { bike: Bike }) {
|
||||
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-3">
|
||||
<div className="bg-slate-50 rounded-lg p-3">
|
||||
<p className="text-xs text-slate-500">Plate Number</p>
|
||||
<p className="font-semibold text-slate-700">{bike.plateNumber}</p>
|
||||
<p className="text-xs text-slate-500">Bike</p>
|
||||
<p className="font-semibold text-slate-700">{bike.brand} {bike.model}</p>
|
||||
<p className="text-xs text-slate-500 mt-1">{bike.plateNumber}</p>
|
||||
</div>
|
||||
<div className="bg-slate-50 rounded-lg p-3">
|
||||
<p className="text-xs text-slate-500">Battery</p>
|
||||
<p className={`font-semibold ${bike.batteryLevel > 50 ? 'text-green-600' : bike.batteryLevel > 20 ? 'text-amber-600' : 'text-red-600'}`}>{bike.batteryLevel}%</p>
|
||||
<p className={`font-semibold text-lg ${bike.batteryLevel > 50 ? 'text-green-600' : bike.batteryLevel > 20 ? 'text-amber-600' : 'text-red-600'}`}>{bike.batteryLevel}%</p>
|
||||
{bike.currentBatteryId && (
|
||||
<p className="text-xs font-medium text-slate-700 mt-1">{bike.currentBatteryId}</p>
|
||||
)}
|
||||
{bike.currentBatteryBrand && (
|
||||
<p className="text-xs text-slate-500">{bike.currentBatteryBrand} {bike.currentBatteryModel}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="bg-slate-50 rounded-lg p-3">
|
||||
<p className="text-xs text-slate-500">Current Renter</p>
|
||||
<p className="font-semibold text-slate-700">{bike.assignedTo || 'Available'}</p>
|
||||
{bike.renterPhone && <p className="text-xs text-slate-500 mt-1">{bike.renterPhone}</p>}
|
||||
{bike.rentalStartDate && <p className="text-xs text-slate-400 mt-1">Since: {bike.rentalStartDate}</p>}
|
||||
</div>
|
||||
<div className="bg-slate-50 rounded-lg p-3">
|
||||
<p className="text-xs text-slate-500">Daily Rate</p>
|
||||
<p className="font-semibold text-green-600">৳{bike.currentRent || 0}</p>
|
||||
<p className="text-xs text-slate-500">Subscription</p>
|
||||
<p className="font-semibold text-slate-700 capitalize">{bike.subscriptionType || 'Daily'}</p>
|
||||
{bike.subscriptionType === 'weekly' && bike.weeklyRent && (
|
||||
<p className="text-xs text-green-600 mt-1">৳{bike.weeklyRent}/week</p>
|
||||
)}
|
||||
{bike.subscriptionType === 'monthly' && bike.monthlyRent && (
|
||||
<p className="text-xs text-green-600 mt-1">৳{bike.monthlyRent}/month</p>
|
||||
)}
|
||||
{bike.subscriptionType === 'daily' && (
|
||||
<p className="text-xs text-green-600 mt-1">৳{bike.currentRent || 0}/day</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -681,6 +776,91 @@ function OverviewTab({ bike }: { bike: Bike }) {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-xl p-4 lg:p-6 shadow-sm border border-slate-100">
|
||||
<h3 className="font-semibold text-slate-700 mb-4">Battery History</h3>
|
||||
{batteryHistory.length > 0 ? (
|
||||
<>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead className="bg-slate-50">
|
||||
<tr>
|
||||
<th className="px-3 py-2 text-left text-xs font-semibold text-slate-500">Battery ID</th>
|
||||
<th className="px-3 py-2 text-left text-xs font-semibold text-slate-500">Brand/Model</th>
|
||||
<th className="px-3 py-2 text-left text-xs font-semibold text-slate-500">Assigned Date</th>
|
||||
<th className="px-3 py-2 text-left text-xs font-semibold text-slate-500">Returned/Swapped</th>
|
||||
<th className="px-3 py-2 text-left text-xs font-semibold text-slate-500">Start SOC</th>
|
||||
<th className="px-3 py-2 text-left text-xs font-semibold text-slate-500">End SOC</th>
|
||||
<th className="px-3 py-2 text-left text-xs font-semibold text-slate-500">Monthly Rent</th>
|
||||
<th className="px-3 py-2 text-left text-xs font-semibold text-slate-500">Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-100">
|
||||
{paginatedHistory.map(bh => (
|
||||
<tr key={bh.id} className="hover:bg-slate-50">
|
||||
<td className="px-3 py-2 text-sm text-slate-700 font-medium">{bh.batteryId}</td>
|
||||
<td className="px-3 py-2 text-sm text-slate-600">{bh.brand} {bh.model}</td>
|
||||
<td className="px-3 py-2 text-sm text-slate-600">{bh.assignedDate}</td>
|
||||
<td className="px-3 py-2 text-sm text-slate-600">
|
||||
{bh.status === 'swapped' ? (
|
||||
<>
|
||||
<span className="text-blue-600 block">Swapped to {bh.swappedToBatteryId}</span>
|
||||
<span className="text-blue-600 text-[10px] block opacity-75">{bh.returnedDate}</span>
|
||||
</>
|
||||
) : bh.returnedDate || '-'}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-sm text-slate-600">{bh.socStart}%</td>
|
||||
<td className="px-3 py-2 text-sm text-slate-600">{bh.socEnd ? `${bh.socEnd}%` : '-'}</td>
|
||||
<td className="px-3 py-2 text-sm text-green-600">৳{bh.monthlyRent}</td>
|
||||
<td className="px-3 py-2">
|
||||
<span className={`text-xs font-medium px-2 py-1 rounded-full ${bh.status === 'active' ? 'bg-green-100 text-green-700' : bh.status === 'swapped' ? 'bg-blue-100 text-blue-700' : 'bg-slate-100 text-slate-500'}`}>
|
||||
{bh.status === 'active' ? 'Active' : bh.status === 'swapped' ? 'Swapped' : 'Returned'}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{totalPages > 1 && (
|
||||
<div className="flex items-center justify-between mt-4 pt-4 border-t border-slate-100">
|
||||
<p className="text-sm text-slate-500">
|
||||
Showing {(currentPage - 1) * itemsPerPage + 1} to {Math.min(currentPage * itemsPerPage, batteryHistory.length)} of {batteryHistory.length} batteries
|
||||
</p>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => setCurrentPage(p => Math.max(1, p - 1))}
|
||||
disabled={currentPage === 1}
|
||||
className="px-3 py-1 text-sm border border-slate-200 rounded-lg hover:bg-slate-50 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
Previous
|
||||
</button>
|
||||
{Array.from({ length: totalPages }, (_, i) => i + 1).map(page => (
|
||||
<button
|
||||
key={page}
|
||||
onClick={() => setCurrentPage(page)}
|
||||
className={`px-3 py-1 text-sm rounded-lg ${currentPage === page ? 'bg-accent text-white' : 'border border-slate-200 hover:bg-slate-50'}`}
|
||||
>
|
||||
{page}
|
||||
</button>
|
||||
))}
|
||||
<button
|
||||
onClick={() => setCurrentPage(p => Math.min(totalPages, p + 1))}
|
||||
disabled={currentPage === totalPages}
|
||||
className="px-3 py-1 text-sm border border-slate-200 rounded-lg hover:bg-slate-50 disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<div className="text-center py-8 text-slate-500">
|
||||
No battery history found
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -768,9 +948,50 @@ function GPSTab({ bike }: { bike: Bike }) {
|
||||
|
||||
function DocumentsTab({ bike }: { bike: Bike }) {
|
||||
const docs = bike.documents || [];
|
||||
const [images, setImages] = useState(bike.bikeImages || { front: '', back: '', left: '', right: '' });
|
||||
const [uploading, setUploading] = useState<string | null>(null);
|
||||
|
||||
const handleImageUpload = (view: 'front' | 'back' | 'left' | 'right', e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) {
|
||||
setUploading(view);
|
||||
const reader = new FileReader();
|
||||
reader.onloadend = () => {
|
||||
setImages(prev => ({ ...prev, [view]: reader.result as string }));
|
||||
setUploading(null);
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="bg-white rounded-xl p-4 lg:p-6 shadow-sm border border-slate-100">
|
||||
<h3 className="font-semibold text-slate-700 mb-4">Bike Images</h3>
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
|
||||
{(['front', 'back', 'left', 'right'] as const).map(view => (
|
||||
<div key={view} className="space-y-2">
|
||||
<p className="text-xs font-medium text-slate-500 capitalize">{view} View</p>
|
||||
<div className="relative aspect-video bg-slate-100 rounded-lg overflow-hidden border border-slate-200">
|
||||
{images[view] ? (
|
||||
<img src={images[view]} alt={`${view} view`} className="w-full h-full object-cover" />
|
||||
) : (
|
||||
<div className="w-full h-full flex items-center justify-center">
|
||||
<Camera className="w-8 h-8 text-slate-300" />
|
||||
</div>
|
||||
)}
|
||||
<label className="absolute inset-0 cursor-pointer hover:bg-black/20 flex items-center justify-center opacity-0 hover:opacity-100 transition-opacity">
|
||||
<span className="bg-white px-3 py-1 rounded-full text-xs font-medium text-slate-700 shadow">
|
||||
{uploading === view ? 'Uploading...' : 'Upload'}
|
||||
</span>
|
||||
<input type="file" accept="image/*" className="hidden" onChange={(e) => handleImageUpload(view, e)} />
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-xl p-4 lg:p-6 shadow-sm border border-slate-100">
|
||||
<h3 className="font-semibold text-slate-700 mb-4">Bike Documents</h3>
|
||||
{docs.length === 0 ? (
|
||||
@@ -1305,8 +1526,17 @@ function DamageModal({ bike, damage, onClose, onSave }: { bike: Bike; damage: Da
|
||||
estimatedCost: damage?.estimatedCost || 0,
|
||||
actualCost: damage?.actualCost || 0,
|
||||
status: damage?.status || 'reported',
|
||||
hubId: damage?.hubId || '',
|
||||
hubName: damage?.hubName || '',
|
||||
});
|
||||
|
||||
const mockHubs = [
|
||||
{ id: 'HUB-001', name: 'Gulshan Hub' },
|
||||
{ id: 'HUB-002', name: 'Banani Hub' },
|
||||
{ id: 'HUB-003', name: 'Uttara Hub' },
|
||||
{ id: 'HUB-004', name: 'Mirpur Hub' },
|
||||
];
|
||||
|
||||
const damageTypes = [
|
||||
{ value: 'accident', label: 'Accident' },
|
||||
{ value: 'theft', label: 'Theft' },
|
||||
@@ -1379,6 +1609,19 @@ function DamageModal({ bike, damage, onClose, onSave }: { bike: Bike; damage: Da
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium text-slate-600 mb-1 block">Hub</label>
|
||||
<select
|
||||
value={formData.hubId}
|
||||
onChange={(e) => setFormData({ ...formData, hubId: e.target.value, hubName: mockHubs.find(h => h.id === e.target.value)?.name || '' })}
|
||||
className="w-full px-3 py-2 border border-slate-200 rounded-lg text-sm"
|
||||
>
|
||||
<option value="">Select Hub</option>
|
||||
{mockHubs.map(hub => (
|
||||
<option key={hub.id} value={hub.id}>{hub.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="text-sm font-medium text-slate-600 mb-1 block">Estimated Cost (৳)</label>
|
||||
@@ -1435,8 +1678,17 @@ function MaintenanceModal({ bike, maintenance, onClose, onSave }: { bike: Bike;
|
||||
cost: maintenance?.cost || 0,
|
||||
nextDueDate: maintenance?.nextDueDate || '',
|
||||
status: maintenance?.status || 'completed',
|
||||
hubId: maintenance?.hubId || '',
|
||||
hubName: maintenance?.hubName || '',
|
||||
});
|
||||
|
||||
const mockHubs = [
|
||||
{ id: 'HUB-001', name: 'Gulshan Hub' },
|
||||
{ id: 'HUB-002', name: 'Banani Hub' },
|
||||
{ id: 'HUB-003', name: 'Uttara Hub' },
|
||||
{ id: 'HUB-004', name: 'Mirpur Hub' },
|
||||
];
|
||||
|
||||
const maintenanceTypes = [
|
||||
{ value: 'routine', label: 'Routine Service' },
|
||||
{ value: 'battery', label: 'Battery' },
|
||||
@@ -1510,6 +1762,19 @@ function MaintenanceModal({ bike, maintenance, onClose, onSave }: { bike: Bike;
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm font-medium text-slate-600 mb-1 block">Hub</label>
|
||||
<select
|
||||
value={formData.hubId}
|
||||
onChange={(e) => setFormData({ ...formData, hubId: e.target.value, hubName: mockHubs.find(h => h.id === e.target.value)?.name || '' })}
|
||||
className="w-full px-3 py-2 border border-slate-200 rounded-lg text-sm"
|
||||
>
|
||||
<option value="">Select Hub</option>
|
||||
{mockHubs.map(hub => (
|
||||
<option key={hub.id} value={hub.id}>{hub.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="text-sm font-medium text-slate-600 mb-1 block">Cost (৳)</label>
|
||||
|
||||
@@ -6,8 +6,10 @@ import {
|
||||
AlertTriangle, Search, Plus, X, Check, Clock, Bike, User, Phone,
|
||||
MapPin, FileText, Image, DollarSign, Wrench, Battery, Key,
|
||||
CheckCircle, XCircle, ChevronLeft, Save, Printer, Send, QrCode,
|
||||
Wallet, Building, Edit, MessageSquare, Calendar, ArrowLeft
|
||||
Wallet, Building, Edit, MessageSquare, Calendar, ArrowLeft, Trash2,
|
||||
Package, Settings
|
||||
} from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
|
||||
type TransactionType = 'deposit' | 'rent_income' | 'investor_funding' | 'investor_withdraw' | 'salary' | 'rent_expense' | 'utility' | 'maintenance' | 'bike_purchase' | 'bike_sale' | 'other_income' | 'other_expense';
|
||||
|
||||
@@ -16,6 +18,16 @@ type DamageSeverity = 'critical' | 'major' | 'minor' | 'cosmetic';
|
||||
type MaintenanceStatus = 'reported' | 'in_progress' | 'parts_ordered' | 'completed' | 'cancelled';
|
||||
type PaymentStatus = 'pending' | 'approved' | 'paid' | 'rejected';
|
||||
|
||||
interface PartUsed {
|
||||
id: string;
|
||||
partId: string;
|
||||
partName: string;
|
||||
quantity: number;
|
||||
unitPrice: number;
|
||||
totalPrice: number;
|
||||
addedAt: string;
|
||||
}
|
||||
|
||||
interface MaintenanceRecord {
|
||||
id: string;
|
||||
date: string;
|
||||
@@ -35,7 +47,8 @@ interface MaintenanceRecord {
|
||||
location: string;
|
||||
estimatedCost: number;
|
||||
actualCost?: number;
|
||||
partsUsed?: string[];
|
||||
serviceCost?: number;
|
||||
partsUsed?: PartUsed[];
|
||||
images: { id: string; name: string; url: string; uploadedAt: string }[];
|
||||
assignedTo?: string;
|
||||
notes: string[];
|
||||
@@ -44,6 +57,30 @@ interface MaintenanceRecord {
|
||||
createdBy: string;
|
||||
}
|
||||
|
||||
interface EVPart {
|
||||
id: string;
|
||||
name: string;
|
||||
buyingPrice: number;
|
||||
sellingPrice: number;
|
||||
category: string;
|
||||
inStock: boolean;
|
||||
}
|
||||
|
||||
const mockParts: EVPart[] = [
|
||||
{ id: 'PRT-001', name: 'Front fender', buyingPrice: 800, sellingPrice: 1500, category: 'Body Parts', inStock: true },
|
||||
{ id: 'PRT-002', name: 'Rear fender', buyingPrice: 900, sellingPrice: 1600, category: 'Body Parts', inStock: true },
|
||||
{ id: 'PRT-003', name: 'Mounting brackets', buyingPrice: 400, sellingPrice: 800, category: 'Hardware', inStock: true },
|
||||
{ id: 'PRT-004', name: 'Brake pads', buyingPrice: 350, sellingPrice: 600, category: 'Brakes', inStock: true },
|
||||
{ id: 'PRT-005', name: 'Front wheel', buyingPrice: 2500, sellingPrice: 4000, category: 'Wheels', inStock: true },
|
||||
{ id: 'PRT-006', name: 'Rear wheel', buyingPrice: 2200, sellingPrice: 3500, category: 'Wheels', inStock: true },
|
||||
{ id: 'PRT-007', name: 'Motor controller', buyingPrice: 3500, sellingPrice: 5500, category: 'Electrical', inStock: true },
|
||||
{ id: 'PRT-008', name: 'Display unit', buyingPrice: 1200, sellingPrice: 2000, category: 'Electrical', inStock: true },
|
||||
{ id: 'PRT-009', name: 'Throttle', buyingPrice: 450, sellingPrice: 750, category: 'Controls', inStock: true },
|
||||
{ id: 'PRT-010', name: 'Handlebar', buyingPrice: 800, sellingPrice: 1400, category: 'Controls', inStock: true },
|
||||
{ id: 'PRT-011', name: 'Seat', buyingPrice: 600, sellingPrice: 1000, category: 'Comfort', inStock: true },
|
||||
{ id: 'PRT-012', name: 'Side stand', buyingPrice: 250, sellingPrice: 450, category: 'Hardware', inStock: true },
|
||||
];
|
||||
|
||||
const mockMaintenance: MaintenanceRecord[] = [
|
||||
{
|
||||
id: 'MNT-001',
|
||||
@@ -64,7 +101,16 @@ const mockMaintenance: MaintenanceRecord[] = [
|
||||
location: 'Gulshan, Dhaka',
|
||||
estimatedCost: 3500,
|
||||
actualCost: 3200,
|
||||
partsUsed: ['Front fender', 'Mounting brackets'],
|
||||
partsUsed: [
|
||||
{ id: 'PU-001', partId: 'PRT-001', partName: 'Front fender', quantity: 1, unitPrice: 1500, totalPrice: 1500, addedAt: '2024-03-21' },
|
||||
{ id: 'PU-002', partId: 'PRT-003', partName: 'Mounting brackets', quantity: 2, unitPrice: 800, totalPrice: 1600, addedAt: '2024-03-21' },
|
||||
{ id: 'PU-003', partId: 'PRT-004', partName: 'Brake pads', quantity: 2, unitPrice: 600, totalPrice: 1200, addedAt: '2024-03-21' },
|
||||
{ id: 'PU-004', partId: 'PRT-005', partName: 'Front wheel', quantity: 1, unitPrice: 4000, totalPrice: 4000, addedAt: '2024-03-21' },
|
||||
{ id: 'PU-005', partId: 'PRT-009', partName: 'Throttle', quantity: 1, unitPrice: 750, totalPrice: 750, addedAt: '2024-03-21' },
|
||||
{ id: 'PU-006', partId: 'PRT-011', partName: 'Seat', quantity: 1, unitPrice: 1000, totalPrice: 1000, addedAt: '2024-03-21' },
|
||||
{ id: 'PU-007', partId: 'PRT-010', partName: 'Handlebar', quantity: 1, unitPrice: 1250, totalPrice: 1250, addedAt: '2024-03-21' },
|
||||
],
|
||||
serviceCost: 3200,
|
||||
images: [
|
||||
{ id: 'img1', name: 'Damage Front', url: '', uploadedAt: '2024-03-21' },
|
||||
{ id: 'img2', name: 'Damage Side', url: '', uploadedAt: '2024-03-21' },
|
||||
@@ -237,9 +283,19 @@ export default function MaintenanceDetailPage() {
|
||||
const [showPaymentModal, setShowPaymentModal] = useState(false);
|
||||
const [showInvoiceModal, setShowInvoiceModal] = useState(false);
|
||||
const [showAddNoteModal, setShowAddNoteModal] = useState(false);
|
||||
const [showAddPartModal, setShowAddPartModal] = useState(false);
|
||||
const [showAddServiceCostModal, setShowAddServiceCostModal] = useState(false);
|
||||
const [partSearch, setPartSearch] = useState('');
|
||||
const [invoiceData, setInvoiceData] = useState({ tips: 0, discount: 0 });
|
||||
const [invoiceCreated, setInvoiceCreated] = useState(false);
|
||||
const [selectedPaymentMethod, setSelectedPaymentMethod] = useState<'online' | 'offline' | 'later' | null>(null);
|
||||
const [showPaymentSuccess, setShowPaymentSuccess] = useState(false);
|
||||
const [editForm, setEditForm] = useState<Partial<MaintenanceRecord>>({});
|
||||
const [newNoteText, setNewNoteText] = useState('');
|
||||
const [actualCost, setActualCost] = useState('');
|
||||
const [selectedPart, setSelectedPart] = useState<EVPart | null>(null);
|
||||
const [partQuantity, setPartQuantity] = useState(1);
|
||||
const [serviceCostInput, setServiceCostInput] = useState('');
|
||||
|
||||
useEffect(() => {
|
||||
const found = mockMaintenance.find(r => r.id === id);
|
||||
@@ -286,18 +342,22 @@ export default function MaintenanceDetailPage() {
|
||||
|
||||
const handlePayment = (source: 'bank' | 'cash' | 'biker') => {
|
||||
if (!record) return;
|
||||
const cost = record.actualCost || record.estimatedCost;
|
||||
const cost = ((record.partsUsed?.reduce((s, p) => s + p.totalPrice, 0) || 0) + (record.serviceCost || 0)) + invoiceData.tips - invoiceData.discount;
|
||||
|
||||
setRecord(prev => prev ? { ...prev, paymentStatus: 'paid' } : null);
|
||||
setRecord(prev => prev ? { ...prev, paymentStatus: 'paid', actualCost: cost, status: 'completed', resolvedAt: new Date().toISOString().split('T')[0] } : null);
|
||||
setShowPaymentModal(false);
|
||||
setShowInvoiceModal(true);
|
||||
setShowPaymentSuccess(true);
|
||||
};
|
||||
|
||||
const handleGenerateInvoice = () => {
|
||||
if (!record) return;
|
||||
import('jspdf').then(jsPDF => {
|
||||
const doc = new jsPDF.default();
|
||||
const cost = record.actualCost || record.estimatedCost;
|
||||
const partsTotal = record.partsUsed?.reduce((s, p) => s + p.totalPrice, 0) || 0;
|
||||
const serviceCost = record.serviceCost || 0;
|
||||
const subtotal = partsTotal + serviceCost;
|
||||
const total = subtotal + invoiceData.tips - invoiceData.discount;
|
||||
const cost = total || record.estimatedCost;
|
||||
const qrData = `INV-${record.id}|${record.bikePlate}|${record.type}|${cost}|${new Date().toISOString().split('T')[0]}`;
|
||||
|
||||
doc.setFontSize(20);
|
||||
@@ -348,11 +408,22 @@ export default function MaintenanceDetailPage() {
|
||||
doc.setFontSize(11);
|
||||
doc.text('Cost Breakdown', 20, 175);
|
||||
doc.setFontSize(10);
|
||||
doc.text(`Estimated Cost: ৳${record.estimatedCost}`, 20, 181);
|
||||
if (record.actualCost) doc.text(`Actual Cost: ৳${record.actualCost}`, 20, 187);
|
||||
if (record.partsUsed && record.partsUsed.length > 0) {
|
||||
doc.text(`Parts: ${record.partsUsed.join(', ')}`, 20, 193);
|
||||
doc.text(`Parts Total: ৳${partsTotal.toLocaleString()}`, 20, 181);
|
||||
doc.text(`Service Cost (Labor): ৳${serviceCost.toLocaleString()}`, 20, 187);
|
||||
doc.text(`Subtotal: ৳${subtotal.toLocaleString()}`, 20, 193);
|
||||
if (invoiceData.tips > 0) {
|
||||
doc.setTextColor(22, 163, 74);
|
||||
doc.text(`Tips: +৳${invoiceData.tips.toLocaleString()}`, 20, 199);
|
||||
doc.setTextColor(100);
|
||||
}
|
||||
if (invoiceData.discount > 0) {
|
||||
doc.setTextColor(220, 38, 38);
|
||||
doc.text(`Discount: -৳${invoiceData.discount.toLocaleString()}`, 20, 205);
|
||||
doc.setTextColor(100);
|
||||
}
|
||||
doc.setFontSize(12);
|
||||
doc.setTextColor(0);
|
||||
doc.text(`Total: ৳${total.toLocaleString()}`, 20, 215);
|
||||
|
||||
doc.setFontSize(14);
|
||||
doc.setTextColor(6, 95, 70);
|
||||
@@ -375,6 +446,8 @@ export default function MaintenanceDetailPage() {
|
||||
setShowAddNoteModal(false);
|
||||
};
|
||||
|
||||
const invoiceTotal = record ? ((record.partsUsed?.reduce((s, p) => s + p.totalPrice, 0) || 0) + (record.serviceCost || 0)) + invoiceData.tips - invoiceData.discount : 0;
|
||||
|
||||
return (
|
||||
<div className="p-4 lg:p-6 max-w-8xl mx-auto">
|
||||
<button
|
||||
@@ -414,20 +487,47 @@ export default function MaintenanceDetailPage() {
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{!invoiceCreated && (
|
||||
<button onClick={() => setEditMode(true)} className="px-4 py-2 border border-slate-200 text-slate-600 rounded-lg text-sm hover:bg-slate-50 flex items-center gap-2">
|
||||
<Edit className="w-4 h-4" /> Edit
|
||||
</button>
|
||||
)}
|
||||
{!invoiceCreated && (
|
||||
<button onClick={() => setShowAddNoteModal(true)} className="px-4 py-2 border border-slate-200 text-slate-600 rounded-lg text-sm hover:bg-slate-50 flex items-center gap-2">
|
||||
<MessageSquare className="w-4 h-4" /> Note
|
||||
</button>
|
||||
{record.status !== 'completed' && (
|
||||
)}
|
||||
{record.status !== 'completed' && !invoiceCreated && (
|
||||
<button
|
||||
onClick={() => setShowCompleteModal(true)}
|
||||
className="px-4 py-2 bg-green-600 text-white rounded-lg text-sm hover:bg-green-700 flex items-center gap-2"
|
||||
>
|
||||
<Check className="w-4 h-4" /> Complete
|
||||
<FileText className="w-4 h-4" /> Create Invoice
|
||||
</button>
|
||||
)}
|
||||
{record.status !== 'completed' && invoiceCreated && (
|
||||
<>
|
||||
<button
|
||||
onClick={handleGenerateInvoice}
|
||||
className="px-4 py-2 bg-purple-600 text-white rounded-lg text-sm hover:bg-purple-700 flex items-center gap-2"
|
||||
>
|
||||
<Printer className="w-4 h-4" /> Print Invoice
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowPaymentModal(true)}
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded-lg text-sm hover:bg-blue-700 flex items-center gap-2"
|
||||
>
|
||||
<DollarSign className="w-4 h-4" /> Proceed to Payment
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowCompleteModal(true)}
|
||||
className="px-3 py-2 border border-slate-200 text-slate-600 rounded-lg text-sm hover:bg-slate-50"
|
||||
title="Edit Invoice"
|
||||
>
|
||||
<Edit className="w-4 h-4" />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
{record.status === 'completed' && record.paymentStatus !== 'paid' && (
|
||||
<button
|
||||
onClick={() => setShowPaymentModal(true)}
|
||||
@@ -438,10 +538,10 @@ export default function MaintenanceDetailPage() {
|
||||
)}
|
||||
{record.paymentStatus === 'paid' && (
|
||||
<button
|
||||
onClick={() => setShowInvoiceModal(true)}
|
||||
onClick={handleGenerateInvoice}
|
||||
className="px-4 py-2 bg-purple-600 text-white rounded-lg text-sm hover:bg-purple-700 flex items-center gap-2"
|
||||
>
|
||||
<Printer className="w-4 h-4" /> Invoice
|
||||
<Printer className="w-4 h-4" /> Print Invoice
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
@@ -538,142 +638,6 @@ export default function MaintenanceDetailPage() {
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="bg-amber-50 p-4 rounded-xl border border-amber-100">
|
||||
<h3 className="font-semibold text-amber-800 mb-3 flex items-center gap-2">
|
||||
<FileText className="w-5 h-5" /> Description
|
||||
</h3>
|
||||
{editMode ? (
|
||||
<textarea
|
||||
value={editForm.description || ''}
|
||||
onChange={(e) => setEditForm({ ...editForm, description: e.target.value })}
|
||||
className="w-full px-3 py-2 border border-amber-200 rounded-lg text-sm"
|
||||
rows={3}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<p className="text-sm text-amber-700 mb-3">{record.description}</p>
|
||||
<div className="flex items-center gap-2 text-xs text-amber-600">
|
||||
<MapPin className="w-3 h-3" /> {record.location}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="bg-purple-50 p-4 rounded-xl border border-purple-100">
|
||||
<h3 className="font-semibold text-purple-800 mb-3 flex items-center gap-2">
|
||||
<DollarSign className="w-5 h-5" /> Cost Details
|
||||
</h3>
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div className="bg-white p-3 rounded-lg">
|
||||
<p className="text-xs text-purple-600">Estimated</p>
|
||||
{editMode ? (
|
||||
<input
|
||||
type="number"
|
||||
value={editForm.estimatedCost || 0}
|
||||
onChange={(e) => setEditForm({ ...editForm, estimatedCost: parseInt(e.target.value) })}
|
||||
className="w-full px-2 py-1 border border-purple-200 rounded text-sm"
|
||||
/>
|
||||
) : (
|
||||
<p className="text-lg font-bold text-purple-800">৳{record.estimatedCost}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="bg-white p-3 rounded-lg">
|
||||
<p className="text-xs text-purple-600">Actual</p>
|
||||
<p className="text-lg font-bold text-purple-800">৳{record.actualCost || record.estimatedCost}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-cyan-50 p-4 rounded-xl border border-cyan-100">
|
||||
<h3 className="font-semibold text-cyan-800 mb-3 flex items-center gap-2">
|
||||
<User className="w-5 h-5" /> Assigned To
|
||||
</h3>
|
||||
{editMode ? (
|
||||
<select
|
||||
value={editForm.assignedTo || ''}
|
||||
onChange={(e) => setEditForm({ ...editForm, assignedTo: e.target.value })}
|
||||
className="w-full px-3 py-2 border border-cyan-200 rounded-lg text-sm"
|
||||
>
|
||||
<option value="">Select Service Center</option>
|
||||
<option value="Service Center A">Service Center A</option>
|
||||
<option value="Service Center B">Service Center B</option>
|
||||
<option value="Authorized Service Center">Authorized Service Center</option>
|
||||
<option value="Gulshan Hub">Gulshan Hub</option>
|
||||
<option value="Banani Hub">Banani Hub</option>
|
||||
<option value="Dhanmondi Hub">Dhanmondi Hub</option>
|
||||
</select>
|
||||
) : (
|
||||
<p className="text-sm text-cyan-700">{record.assignedTo || 'Not assigned'}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="bg-orange-50 p-4 rounded-xl border border-orange-100">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h3 className="font-semibold text-orange-800 flex items-center gap-2">
|
||||
<Wrench className="w-5 h-5" /> Parts Used
|
||||
</h3>
|
||||
{editMode && (
|
||||
<select
|
||||
onChange={(e) => {
|
||||
if (e.target.value) {
|
||||
const currentParts = editForm.partsUsed || [];
|
||||
if (!currentParts.includes(e.target.value)) {
|
||||
setEditForm({ ...editForm, partsUsed: [...currentParts, e.target.value] });
|
||||
}
|
||||
e.target.value = '';
|
||||
}
|
||||
}}
|
||||
className="px-2 py-1 text-xs border border-orange-200 rounded"
|
||||
>
|
||||
<option value="">+ Add Part</option>
|
||||
<option value="Front fender">Front fender</option>
|
||||
<option value="Rear fender">Rear fender</option>
|
||||
<option value="Mirror">Mirror</option>
|
||||
<option value="Headlight">Headlight</option>
|
||||
<option value="Tail light">Tail light</option>
|
||||
<option value="Brake pad">Brake pad</option>
|
||||
<option value="Brake shoe">Brake shoe</option>
|
||||
<option value="Chain">Chain</option>
|
||||
<option value="Battery">Battery</option>
|
||||
<option value="Motor">Motor</option>
|
||||
<option value="Controller">Controller</option>
|
||||
<option value="Throttle">Throttle</option>
|
||||
<option value="Lever">Lever</option>
|
||||
<option value="Stand">Stand</option>
|
||||
<option value="Seat">Seat</option>
|
||||
<option value="Tyre">Tyre</option>
|
||||
<option value="Tube">Tube</option>
|
||||
<option value="Mounting brackets">Mounting brackets</option>
|
||||
<option value="Bolt set">Bolt set</option>
|
||||
</select>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{(editMode ? editForm.partsUsed : record.partsUsed)?.map((part, idx) => (
|
||||
<span key={idx} className="px-3 py-1 bg-white rounded-full text-sm text-orange-700 border border-orange-200 flex items-center gap-1">
|
||||
{part}
|
||||
{editMode && (
|
||||
<button
|
||||
onClick={() => {
|
||||
const updated = [...(editForm.partsUsed || [])];
|
||||
updated.splice(idx, 1);
|
||||
setEditForm({ ...editForm, partsUsed: updated });
|
||||
}}
|
||||
className="ml-1 text-orange-400 hover:text-red-500"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
)}
|
||||
</span>
|
||||
))}
|
||||
{(editMode ? editForm.partsUsed : record.partsUsed)?.length === 0 && (
|
||||
<p className="text-sm text-orange-400">No parts added</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-indigo-50 p-4 rounded-xl border border-indigo-100">
|
||||
<h3 className="font-semibold text-indigo-800 mb-3 flex items-center gap-2">
|
||||
@@ -709,6 +673,193 @@ export default function MaintenanceDetailPage() {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-amber-50 p-4 rounded-xl border border-amber-100">
|
||||
<h3 className="font-semibold text-amber-800 mb-3 flex items-center gap-2">
|
||||
<FileText className="w-5 h-5" /> Description
|
||||
</h3>
|
||||
{editMode ? (
|
||||
<textarea
|
||||
value={editForm.description || ''}
|
||||
onChange={(e) => setEditForm({ ...editForm, description: e.target.value })}
|
||||
className="w-full px-3 py-2 border border-amber-200 rounded-lg text-sm"
|
||||
rows={3}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<p className="text-sm text-amber-700 mb-3">{record.description}</p>
|
||||
<div className="flex items-center gap-2 text-xs text-amber-600">
|
||||
<MapPin className="w-3 h-3" /> {record.location}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
|
||||
|
||||
<div className="bg-purple-50 p-4 rounded-xl border border-purple-100">
|
||||
<h3 className="font-semibold text-purple-800 mb-3 flex items-center gap-2">
|
||||
<DollarSign className="w-5 h-5" /> Cost Details
|
||||
</h3>
|
||||
<div className="bg-gradient-to-r from-purple-50 to-indigo-50 p-4 rounded-xl border border-purple-100">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h3 className="font-semibold text-purple-800 flex items-center gap-2">
|
||||
<DollarSign className="w-5 h-5" /> Cost Breakdown
|
||||
</h3>
|
||||
{editMode && (
|
||||
<input
|
||||
type="number"
|
||||
value={editForm.estimatedCost || 0}
|
||||
onChange={(e) => setEditForm({ ...editForm, estimatedCost: parseInt(e.target.value) })}
|
||||
className="px-2 py-1 border border-purple-200 rounded text-sm w-24"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between items-center text-sm">
|
||||
<span className="text-slate-600">Estimated Cost:</span>
|
||||
<span className="font-medium text-purple-700">৳{record.estimatedCost.toLocaleString()}</span>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-purple-100 pt-2">
|
||||
<div className="flex justify-between items-center text-sm">
|
||||
<span className="text-slate-600 flex items-center gap-1">
|
||||
<Package className="w-4 h-4" /> Parts Total:
|
||||
</span>
|
||||
<span className="font-medium text-orange-600">৳{record.partsUsed?.reduce((sum, p) => sum + p.totalPrice, 0).toLocaleString() || 0}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-between items-center text-sm mt-1">
|
||||
<span className="text-slate-600 flex items-center gap-1">
|
||||
<Wrench className="w-4 h-4" /> Service Cost (Labor):
|
||||
</span>
|
||||
<span className="font-medium text-blue-600">৳{(record.serviceCost || 0).toLocaleString()}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t-2 border-purple-200 pt-2 mt-2">
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="font-semibold text-purple-800">Actual Total Cost:</span>
|
||||
<span className="text-xl font-bold text-purple-800">
|
||||
৳{((record.partsUsed?.reduce((sum, p) => sum + p.totalPrice, 0) || 0) + (record.serviceCost || 0)).toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-cyan-50 p-4 rounded-xl border border-cyan-100">
|
||||
<h3 className="font-semibold text-cyan-800 mb-3 flex items-center gap-2">
|
||||
<User className="w-5 h-5" /> Assigned To
|
||||
</h3>
|
||||
{editMode ? (
|
||||
<select
|
||||
value={editForm.assignedTo || ''}
|
||||
onChange={(e) => setEditForm({ ...editForm, assignedTo: e.target.value })}
|
||||
className="w-full px-3 py-2 border border-cyan-200 rounded-lg text-sm"
|
||||
>
|
||||
<option value="">Select Service Center</option>
|
||||
<option value="Service Center A">Service Center A</option>
|
||||
<option value="Service Center B">Service Center B</option>
|
||||
<option value="Authorized Service Center">Authorized Service Center</option>
|
||||
<option value="Gulshan Hub">Gulshan Hub</option>
|
||||
<option value="Banani Hub">Banani Hub</option>
|
||||
<option value="Dhanmondi Hub">Dhanmondi Hub</option>
|
||||
</select>
|
||||
) : (
|
||||
<p className="text-sm text-cyan-700">{record.assignedTo || 'Not assigned'}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="bg-orange-50 p-4 rounded-xl border border-orange-100">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h3 className="font-semibold text-orange-800 flex items-center gap-2">
|
||||
<Wrench className="w-5 h-5" /> Parts Used
|
||||
</h3>
|
||||
{!editMode && (
|
||||
<button
|
||||
onClick={() => setShowAddPartModal(true)}
|
||||
className="px-3 py-1 bg-orange-600 text-white text-xs rounded-lg hover:bg-orange-700 flex items-center gap-1"
|
||||
>
|
||||
<Plus className="w-3 h-3" /> Add Part
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{(record.partsUsed || []).length > 0 ? (
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-orange-100">
|
||||
<tr>
|
||||
<th className="px-3 py-2 text-left text-orange-800 font-medium">Part</th>
|
||||
<th className="px-3 py-2 text-center text-orange-800 font-medium">Qty</th>
|
||||
<th className="px-3 py-2 text-right text-orange-800 font-medium">Unit Price</th>
|
||||
<th className="px-3 py-2 text-right text-orange-800 font-medium">Total</th>
|
||||
<th className="px-3 py-2 text-center text-orange-800 font-medium">Action</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{record.partsUsed?.map((part) => (
|
||||
<tr key={part.id} className="bg-white border-b border-orange-100">
|
||||
<td className="px-3 py-2 text-slate-700">{part.partName}</td>
|
||||
<td className="px-3 py-2 text-center">
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
value={part.quantity}
|
||||
onChange={(e) => {
|
||||
const newQty = Math.max(1, parseInt(e.target.value) || 1);
|
||||
setRecord(prev => prev ? {
|
||||
...prev,
|
||||
partsUsed: prev.partsUsed?.map(p =>
|
||||
p.id === part.id
|
||||
? { ...p, quantity: newQty, totalPrice: newQty * p.unitPrice }
|
||||
: p
|
||||
)
|
||||
} : null);
|
||||
}}
|
||||
className="w-16 px-2 py-1 border border-orange-200 rounded text-center text-sm"
|
||||
/>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right text-slate-600">৳{part.unitPrice.toLocaleString()}</td>
|
||||
<td className="px-3 py-2 text-right font-medium text-orange-700">৳{part.totalPrice.toLocaleString()}</td>
|
||||
<td className="px-3 py-2 text-center">
|
||||
<button
|
||||
onClick={() => {
|
||||
setRecord(prev => prev ? {
|
||||
...prev,
|
||||
partsUsed: prev.partsUsed?.filter(p => p.id !== part.id)
|
||||
} : null);
|
||||
}}
|
||||
className="text-red-400 hover:text-red-600 p-1"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
<tfoot className="bg-orange-50">
|
||||
<tr>
|
||||
<td colSpan={3} className="px-3 py-2 text-right font-semibold text-orange-800">Parts Total:</td>
|
||||
<td className="px-3 py-2 text-right font-bold text-orange-700">
|
||||
৳{record.partsUsed?.reduce((sum, p) => sum + p.totalPrice, 0).toLocaleString()}
|
||||
</td>
|
||||
<td></td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
) : (
|
||||
<p className="text-sm text-orange-400">No parts added</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
|
||||
</div>
|
||||
|
||||
<div className="bg-slate-50 p-4 rounded-xl border border-slate-100">
|
||||
<h3 className="font-semibold text-slate-800 mb-3 flex items-center gap-2">
|
||||
@@ -762,34 +913,90 @@ export default function MaintenanceDetailPage() {
|
||||
|
||||
{showCompleteModal && (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-white rounded-xl shadow-xl w-full max-w-md">
|
||||
<div className="p-4 border-b border-slate-100">
|
||||
<h3 className="font-semibold text-slate-800">Complete Maintenance</h3>
|
||||
<div className="bg-white rounded-xl shadow-xl w-full max-w-lg">
|
||||
<div className="p-4 border-b border-slate-100 bg-gradient-to-r from-green-50 to-emerald-50">
|
||||
<h3 className="font-semibold text-slate-800 flex items-center gap-2">
|
||||
<FileText className="w-5 h-5 text-green-600" /> {invoiceCreated ? 'Edit Invoice' : 'Create Invoice'}
|
||||
</h3>
|
||||
<p className="text-xs text-slate-500">{record.id} • {record.bikeModel}</p>
|
||||
</div>
|
||||
<div className="p-4 space-y-4">
|
||||
<div className="bg-green-50 p-4 rounded-lg">
|
||||
<p className="text-sm text-green-700">Enter actual cost to complete this record</p>
|
||||
<div className="bg-slate-50 p-3 rounded-lg">
|
||||
<div className="flex justify-between text-sm mb-2">
|
||||
<span className="text-slate-500">Estimated Cost:</span>
|
||||
<span className="font-medium text-slate-600">৳{record.estimatedCost.toLocaleString()}</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm mb-2">
|
||||
<span className="text-slate-500">Parts Total:</span>
|
||||
<span className="font-medium text-orange-600">৳{record.partsUsed?.reduce((s, p) => s + p.totalPrice, 0).toLocaleString()}</span>
|
||||
</div>
|
||||
<div className="flex justify-between text-sm mb-2">
|
||||
<span className="text-slate-500">Service Cost (Labor):</span>
|
||||
<span className="font-medium text-blue-600">৳{(record.serviceCost || 0).toLocaleString()}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-3">
|
||||
<div>
|
||||
<label className="text-sm font-medium text-slate-600 mb-2 block">Actual Cost (৳)</label>
|
||||
<label className="text-xs font-medium text-slate-500 mb-1 block">Add Tips (৳)</label>
|
||||
<input
|
||||
type="number"
|
||||
value={actualCost}
|
||||
onChange={(e) => setActualCost(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-slate-200 rounded-lg text-lg font-bold"
|
||||
min="0"
|
||||
value={invoiceData.tips}
|
||||
onChange={(e) => setInvoiceData({ ...invoiceData, tips: parseInt(e.target.value) || 0 })}
|
||||
className="w-full px-3 py-2 border border-slate-200 rounded-lg text-sm"
|
||||
placeholder="0"
|
||||
/>
|
||||
</div>
|
||||
<div className="text-sm text-slate-500">
|
||||
Estimated: ৳{record.estimatedCost}
|
||||
<div>
|
||||
<label className="text-xs font-medium text-slate-500 mb-1 block">Discount (৳)</label>
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
value={invoiceData.discount}
|
||||
onChange={(e) => setInvoiceData({ ...invoiceData, discount: parseInt(e.target.value) || 0 })}
|
||||
className="w-full px-3 py-2 border border-slate-200 rounded-lg text-sm"
|
||||
placeholder="0"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-green-50 p-4 rounded-lg border border-green-100">
|
||||
<div className="flex justify-between items-center mb-2">
|
||||
<span className="text-slate-600">Subtotal:</span>
|
||||
<span className="font-medium text-slate-700">৳{((record.partsUsed?.reduce((s, p) => s + p.totalPrice, 0) || 0) + (record.serviceCost || 0)).toLocaleString()}</span>
|
||||
</div>
|
||||
{invoiceData.tips > 0 && (
|
||||
<div className="flex justify-between items-center mb-2">
|
||||
<span className="text-slate-500">Tips:</span>
|
||||
<span className="text-green-600">+৳{invoiceData.tips.toLocaleString()}</span>
|
||||
</div>
|
||||
)}
|
||||
{invoiceData.discount > 0 && (
|
||||
<div className="flex justify-between items-center mb-2">
|
||||
<span className="text-slate-500">Discount:</span>
|
||||
<span className="text-red-500">-৳{invoiceData.discount.toLocaleString()}</span>
|
||||
</div>
|
||||
)}
|
||||
<div className="border-t border-green-200 pt-2 mt-2">
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="font-bold text-green-800">Total Amount:</span>
|
||||
<span className="text-2xl font-bold text-green-700">৳{(((record.partsUsed?.reduce((s, p) => s + p.totalPrice, 0) || 0) + (record.serviceCost || 0)) + invoiceData.tips - invoiceData.discount).toLocaleString()}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p className="text-xs text-slate-400 flex items-center gap-1">
|
||||
<AlertTriangle className="w-3 h-3" /> After payment, this maintenance will be marked as completed
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-4 border-t border-slate-100 flex justify-end gap-2">
|
||||
<button onClick={() => setShowCompleteModal(false)} className="px-4 py-2 border border-slate-200 text-slate-600 rounded-lg">Cancel</button>
|
||||
<button onClick={() => { setShowCompleteModal(false); setInvoiceData({ tips: 0, discount: 0 }); }} className="px-4 py-2 border border-slate-200 text-slate-600 rounded-lg">Cancel</button>
|
||||
<button
|
||||
onClick={handleComplete}
|
||||
onClick={() => { setInvoiceCreated(true); setShowCompleteModal(false); }}
|
||||
className="px-4 py-2 bg-green-600 text-white rounded-lg flex items-center gap-2"
|
||||
>
|
||||
<Check className="w-4 h-4" /> Mark Complete
|
||||
<FileText className="w-4 h-4" /> {invoiceCreated ? 'Update Invoice' : 'Create Invoice'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -799,9 +1006,14 @@ export default function MaintenanceDetailPage() {
|
||||
{showPaymentModal && record && (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-white rounded-xl shadow-xl w-full max-w-lg">
|
||||
<div className="p-4 border-b border-slate-100">
|
||||
<h3 className="font-semibold text-slate-800">Process Payment - {record.id}</h3>
|
||||
<p className="text-sm text-slate-500">Amount: ৳{record.actualCost || record.estimatedCost}</p>
|
||||
<div className="p-4 border-b border-slate-100 bg-blue-50">
|
||||
<h3 className="font-semibold text-slate-800 flex items-center gap-2">
|
||||
<Wallet className="w-5 h-5 text-blue-600" /> Process Payment
|
||||
</h3>
|
||||
<div className="mt-2 bg-white p-3 rounded-lg border border-blue-100">
|
||||
<p className="text-xs text-slate-500">Invoice Total</p>
|
||||
<p className="text-2xl font-bold text-blue-700">৳{invoiceTotal.toLocaleString()}</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-4 space-y-4">
|
||||
<p className="text-sm text-slate-600 mb-2">Select payment method:</p>
|
||||
@@ -828,22 +1040,26 @@ export default function MaintenanceDetailPage() {
|
||||
<Wallet className="w-5 h-5 text-green-600" />
|
||||
</div>
|
||||
<div className="text-left">
|
||||
<p className="font-medium text-slate-800">Cash</p>
|
||||
<p className="text-xs text-slate-500">Debit Cash (1100) → Credit Maintenance (5400)</p>
|
||||
<p className="font-medium text-slate-800">Offline (Cash)</p>
|
||||
<p className="text-xs text-slate-500">Pay with cash</p>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handlePayment('biker')}
|
||||
className="w-full p-4 border-2 border-slate-200 rounded-lg hover:border-purple-500 hover:bg-purple-50 transition-colors"
|
||||
onClick={() => {
|
||||
if (!record) return;
|
||||
setRecord(prev => prev ? { ...prev, paymentStatus: 'pending' } : null);
|
||||
setShowPaymentModal(false);
|
||||
}}
|
||||
className="w-full p-4 border-2 border-slate-200 rounded-lg hover:border-orange-500 hover:bg-orange-50 transition-colors"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="w-10 h-10 rounded-lg bg-purple-100 flex items-center justify-center">
|
||||
<User className="w-5 h-5 text-purple-600" />
|
||||
<div className="w-10 h-10 rounded-lg bg-orange-100 flex items-center justify-center">
|
||||
<Clock className="w-5 h-5 text-orange-600" />
|
||||
</div>
|
||||
<div className="text-left">
|
||||
<p className="font-medium text-slate-800">Biker Wallet</p>
|
||||
<p className="text-xs text-slate-500">Deduct from rider wallet</p>
|
||||
<p className="font-medium text-slate-800">Pay Later</p>
|
||||
<p className="text-xs text-slate-500">Mark as pending payment</p>
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
@@ -936,6 +1152,202 @@ export default function MaintenanceDetailPage() {
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showAddPartModal && (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-white rounded-xl shadow-xl w-full max-w-lg">
|
||||
<div className="p-4 border-b border-slate-100 flex justify-between items-center">
|
||||
<h3 className="font-semibold text-slate-800 flex items-center gap-2">
|
||||
<Package className="w-5 h-5 text-orange-600" /> Add Part
|
||||
</h3>
|
||||
<button onClick={() => setShowAddPartModal(false)} className="text-slate-400 hover:text-slate-600">
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="p-4 space-y-4">
|
||||
<div>
|
||||
<label className="text-sm font-medium text-slate-600 mb-1 block">Select Part from EV Parts</label>
|
||||
<p className="text-xs text-slate-400 mb-2">Prices are based on selling price from Settings</p>
|
||||
<div className="relative mb-3">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search parts..."
|
||||
value={partSearch}
|
||||
onChange={(e) => setPartSearch(e.target.value)}
|
||||
className="w-full pl-10 pr-4 py-2 border border-slate-200 rounded-lg text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div className="max-h-48 overflow-y-auto border border-slate-200 rounded-lg">
|
||||
{mockParts.filter(p => p.name.toLowerCase().includes(partSearch.toLowerCase())).map(part => (
|
||||
<div
|
||||
key={part.id}
|
||||
onClick={() => { setSelectedPart(part); setPartQuantity(1); }}
|
||||
className={`p-3 border-b border-slate-100 cursor-pointer hover:bg-orange-50 ${selectedPart?.id === part.id ? 'bg-orange-50 border-orange-300' : ''}`}
|
||||
>
|
||||
<div className="flex justify-between items-center">
|
||||
<div>
|
||||
<p className="font-medium text-slate-700">{part.name}</p>
|
||||
<p className="text-xs text-slate-500">{part.category}</p>
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="font-bold text-orange-600">৳{part.sellingPrice.toLocaleString()}</p>
|
||||
<p className="text-xs text-slate-400">per unit</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{selectedPart && (
|
||||
<div className="bg-orange-50 p-4 rounded-lg border border-orange-200">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="flex-1">
|
||||
<label className="text-xs font-medium text-slate-500 mb-1 block">Quantity</label>
|
||||
<input
|
||||
type="number"
|
||||
min="1"
|
||||
value={partQuantity}
|
||||
onChange={(e) => setPartQuantity(Math.max(1, parseInt(e.target.value) || 1))}
|
||||
className="w-full px-3 py-2 border border-orange-200 rounded-lg text-sm"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<label className="text-xs font-medium text-slate-500 mb-1 block">Unit Price</label>
|
||||
<p className="text-lg font-bold text-orange-700">৳{selectedPart.sellingPrice}</p>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<label className="text-xs font-medium text-slate-500 mb-1 block">Total</label>
|
||||
<p className="text-lg font-bold text-orange-700">৳{(selectedPart.sellingPrice * partQuantity).toLocaleString()}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
</div>
|
||||
<div className="p-4 border-t border-slate-100 flex justify-end gap-2">
|
||||
<button onClick={() => setShowAddPartModal(false)} className="px-4 py-2 border border-slate-200 text-slate-600 rounded-lg">Cancel</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
if (selectedPart) {
|
||||
const newPart: PartUsed = {
|
||||
id: 'PU-' + Date.now(),
|
||||
partId: selectedPart.id,
|
||||
partName: selectedPart.name,
|
||||
quantity: partQuantity,
|
||||
unitPrice: selectedPart.sellingPrice,
|
||||
totalPrice: selectedPart.sellingPrice * partQuantity,
|
||||
addedAt: new Date().toISOString().split('T')[0]
|
||||
};
|
||||
setRecord(prev => prev ? { ...prev, partsUsed: [...(prev.partsUsed || []), newPart] } : null);
|
||||
setShowAddPartModal(false);
|
||||
setSelectedPart(null);
|
||||
}
|
||||
}}
|
||||
disabled={!selectedPart}
|
||||
className="px-4 py-2 bg-orange-600 text-white rounded-lg disabled:opacity-50 flex items-center gap-2"
|
||||
>
|
||||
<Plus className="w-4 h-4" /> Add Part
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="fixed bottom-4 right-4 z-40">
|
||||
<button
|
||||
onClick={() => setShowAddServiceCostModal(true)}
|
||||
className="px-4 py-3 bg-blue-600 text-white rounded-full shadow-lg hover:bg-blue-700 flex items-center gap-2"
|
||||
>
|
||||
<DollarSign className="w-5 h-5" /> Add Service Cost
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{showAddServiceCostModal && (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-white rounded-xl shadow-xl w-full max-w-sm">
|
||||
<div className="p-4 border-b border-slate-100 flex justify-between items-center">
|
||||
<h3 className="font-semibold text-slate-800 flex items-center gap-2">
|
||||
<Wrench className="w-5 h-5 text-blue-600" /> Add Service Cost
|
||||
</h3>
|
||||
<button onClick={() => setShowAddServiceCostModal(false)} className="text-slate-400 hover:text-slate-600">
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="p-4 space-y-4">
|
||||
<div>
|
||||
<label className="text-sm font-medium text-slate-600 mb-1 block">Service Cost (Labor charge)</label>
|
||||
<input
|
||||
type="number"
|
||||
value={serviceCostInput}
|
||||
onChange={(e) => setServiceCostInput(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-slate-200 rounded-lg text-lg"
|
||||
placeholder="Enter service cost"
|
||||
/>
|
||||
</div>
|
||||
<div className="bg-slate-50 p-3 rounded-lg">
|
||||
<p className="text-sm text-slate-600">Current Service Cost: <span className="font-bold text-blue-600">৳{record?.serviceCost || 0}</span></p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-4 border-t border-slate-100 flex justify-end gap-2">
|
||||
<button onClick={() => setShowAddServiceCostModal(false)} className="px-4 py-2 border border-slate-200 text-slate-600 rounded-lg">Cancel</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
const cost = parseFloat(serviceCostInput) || 0;
|
||||
setRecord(prev => prev ? { ...prev, serviceCost: cost } : null);
|
||||
setShowAddServiceCostModal(false);
|
||||
setServiceCostInput('');
|
||||
}}
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded-lg flex items-center gap-2"
|
||||
>
|
||||
<Plus className="w-4 h-4" /> Add Cost
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showPaymentSuccess && (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-white rounded-xl shadow-xl w-full max-w-md">
|
||||
<div className="p-8 text-center">
|
||||
<div className="w-20 h-20 bg-green-100 rounded-full flex items-center justify-center mx-auto mb-4">
|
||||
<CheckCircle className="w-12 h-12 text-green-600" />
|
||||
</div>
|
||||
<h3 className="text-2xl font-bold text-green-600 mb-2">Payment Successful!</h3>
|
||||
<p className="text-slate-600 mb-4">Maintenance has been completed</p>
|
||||
|
||||
<div className="bg-slate-50 p-4 rounded-lg text-left mb-6">
|
||||
<div className="flex justify-between mb-2">
|
||||
<span className="text-sm text-slate-500">Maintenance ID</span>
|
||||
<span className="text-sm font-bold text-slate-700">{record?.id}</span>
|
||||
</div>
|
||||
<div className="flex justify-between mb-2">
|
||||
<span className="text-sm text-slate-500">Bike</span>
|
||||
<span className="text-sm font-medium text-slate-700">{record?.bikeModel}</span>
|
||||
</div>
|
||||
<div className="flex justify-between mb-2">
|
||||
<span className="text-sm text-slate-500">Status</span>
|
||||
<span className="text-sm font-bold text-green-600">Completed</span>
|
||||
</div>
|
||||
<div className="flex justify-between pt-2 border-t border-slate-200">
|
||||
<span className="text-sm font-semibold text-slate-700">Total Paid</span>
|
||||
<span className="text-lg font-bold text-green-600">৳{((record?.partsUsed?.reduce((s, p) => s + p.totalPrice, 0) || 0) + (record?.serviceCost || 0) + invoiceData.tips - invoiceData.discount).toLocaleString()}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={() => { setShowPaymentSuccess(false); setInvoiceData({ tips: 0, discount: 0 }); }}
|
||||
className="px-6 py-3 bg-green-600 text-white rounded-lg font-semibold hover:bg-green-700"
|
||||
>
|
||||
Done
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -6,7 +6,7 @@ import Link from 'next/link';
|
||||
import {
|
||||
ArrowLeft, Bike, User, Calendar, DollarSign, Wallet, Shield, CheckCircle, XCircle,
|
||||
Clock, Edit, Save, Plus, Trash2, Image, Upload, Lock, Unlock, AlertTriangle, MessageSquare, MapPin,
|
||||
Phone, MessageCircle, Play, Check, X, FileText, Download
|
||||
Phone, MessageCircle, Play, Check, X, FileText, Download, Battery
|
||||
} from 'lucide-react';
|
||||
import {
|
||||
canRentalAccept, canRentalReject, canRentalCancel, canRentalEdit,
|
||||
@@ -41,6 +41,9 @@ interface Rental {
|
||||
bikeModel: string;
|
||||
bikePlate: string;
|
||||
bikeBattery: number;
|
||||
batteryId?: string;
|
||||
batteryName?: string;
|
||||
batteryRent?: number;
|
||||
type: RentalType;
|
||||
status: RentalStatus;
|
||||
startDate: string;
|
||||
@@ -73,6 +76,17 @@ interface Rental {
|
||||
activatedAt?: string;
|
||||
lockHistory?: LockEvent[];
|
||||
paymentHistory?: PaymentHistory[];
|
||||
batteryHistory?: BatteryRentalHistory[];
|
||||
}
|
||||
|
||||
interface BatteryRentalHistory {
|
||||
id: string;
|
||||
batteryId: string;
|
||||
batteryName: string;
|
||||
assignedAt: string;
|
||||
returnedAt?: string;
|
||||
monthlyRent: number;
|
||||
status: 'active' | 'returned';
|
||||
}
|
||||
|
||||
interface LockEvent {
|
||||
@@ -114,6 +128,7 @@ const mockRentals: Rental[] = [
|
||||
dailyRate: 150,
|
||||
weeklyRate: 900,
|
||||
monthlyRate: 3500,
|
||||
batteryRent: 1500,
|
||||
totalPaid: 38500,
|
||||
dueRental: 0,
|
||||
pendingRent: 0,
|
||||
@@ -146,6 +161,10 @@ const mockRentals: Rental[] = [
|
||||
{ id: 'lh3', action: 'locked', reason: 'Second payment overdue - Day 2 penalty', performedBy: 'System', performedAt: '2024-03-01' },
|
||||
{ id: 'lh4', action: 'unlocked', reason: 'Payment cleared', performedBy: 'Admin Manager', performedAt: '2024-03-02' },
|
||||
],
|
||||
batteryHistory: [
|
||||
{ id: 'BAT-RENT-001', batteryId: 'BAT-DH-001', batteryName: 'Galaxy 72V 45Ah', assignedAt: '2024-01-16', monthlyRent: 1500, status: 'active' },
|
||||
{ id: 'BAT-RENT-002', batteryId: 'BAT-DH-002', batteryName: 'Titan 72V 50Ah', assignedAt: '2024-02-20', returnedAt: '2024-03-15', monthlyRent: 1800, status: 'returned' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'RNT-002',
|
||||
@@ -269,6 +288,23 @@ const mockHubs = [
|
||||
{ id: 'HUB-004', name: 'Mirpur Hub' },
|
||||
];
|
||||
|
||||
interface BatteryOption {
|
||||
id: string;
|
||||
brand: string;
|
||||
model: string;
|
||||
soc: number;
|
||||
monthlyRent: number;
|
||||
status: 'available' | 'in-use';
|
||||
}
|
||||
|
||||
const mockBatteries: BatteryOption[] = [
|
||||
{ id: 'BAT-DH-001', brand: 'Galaxy', model: '72V 45Ah', soc: 85, monthlyRent: 1500, status: 'available' },
|
||||
{ id: 'BAT-DH-002', brand: 'Titan', model: '72V 50Ah', soc: 92, monthlyRent: 1800, status: 'available' },
|
||||
{ id: 'BAT-DH-003', brand: 'PowerMax', model: '60V 40Ah', soc: 78, monthlyRent: 1200, status: 'available' },
|
||||
{ id: 'BAT-DH-004', brand: 'UltraCell', model: '72V 55Ah', soc: 88, monthlyRent: 2000, status: 'available' },
|
||||
{ id: 'BAT-DH-005', brand: 'EcoVolt', model: '48V 30Ah', soc: 65, monthlyRent: 800, status: 'available' },
|
||||
];
|
||||
|
||||
const mockDamageHistory = [
|
||||
{ id: 'DMG-001', date: '2024-02-10', description: 'Minor scratch on left mirror', severity: 'minor', status: 'resolved' },
|
||||
{ id: 'DMG-002', date: '2024-03-05', description: 'Front fender dented', severity: 'moderate', status: 'reported' },
|
||||
@@ -316,6 +352,8 @@ export default function RentalDetailPage() {
|
||||
const [documents, setDocuments] = useState(mockDocuments);
|
||||
const [showUploadModal, setShowUploadModal] = useState(false);
|
||||
const [uploadDocName, setUploadDocName] = useState('');
|
||||
const [showAddBatteryModal, setShowAddBatteryModal] = useState(false);
|
||||
const [selectedBatteryId, setSelectedBatteryId] = useState('');
|
||||
|
||||
const [acceptPermission, setAcceptPermission] = useState(false);
|
||||
const [rejectPermission, setRejectPermission] = useState(false);
|
||||
@@ -468,6 +506,31 @@ export default function RentalDetailPage() {
|
||||
setRental(prev => prev ? { ...prev, status: 'active', activatedAt: new Date().toISOString().split('T')[0] } : null);
|
||||
};
|
||||
|
||||
const handleAddBattery = () => {
|
||||
if (!selectedBatteryId || !rental) return;
|
||||
const battery = mockBatteries.find(b => b.id === selectedBatteryId);
|
||||
if (!battery) return;
|
||||
|
||||
const newBatteryHistory: BatteryRentalHistory = {
|
||||
id: `BAT-RENT-${Date.now()}`,
|
||||
batteryId: battery.id,
|
||||
batteryName: `${battery.brand} ${battery.model}`,
|
||||
assignedAt: new Date().toISOString().split('T')[0],
|
||||
monthlyRent: battery.monthlyRent,
|
||||
status: 'active',
|
||||
};
|
||||
|
||||
setRental(prev => prev ? {
|
||||
...prev,
|
||||
batteryId: battery.id,
|
||||
batteryName: `${battery.brand} ${battery.model}`,
|
||||
batteryRent: (prev.batteryRent || 0) + battery.monthlyRent,
|
||||
batteryHistory: [...(prev.batteryHistory || []), newBatteryHistory],
|
||||
} : null);
|
||||
setShowAddBatteryModal(false);
|
||||
setSelectedBatteryId('');
|
||||
};
|
||||
|
||||
const handleAddNote = () => {
|
||||
if (!newNote.trim()) return;
|
||||
setNotes(prev => [...prev, { id: `n${Date.now()}`, text: newNote, createdAt: new Date().toISOString().split('T')[0] }]);
|
||||
@@ -684,12 +747,36 @@ export default function RentalDetailPage() {
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between"><span className="text-sm text-slate-600">Type</span><span className="text-sm font-medium text-slate-800 capitalize">{rental.subscriptionType}</span></div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-sm text-slate-600">Rate</span>
|
||||
<span className="text-sm text-slate-600">Bike Rate</span>
|
||||
<span className="text-sm font-medium text-slate-800">
|
||||
৳{rental.subscriptionType === 'daily' ? rental.dailyRate : rental.subscriptionType === 'weekly' ? rental.weeklyRate : rental.monthlyRate}/
|
||||
{rental.subscriptionType === 'daily' ? 'day' : rental.subscriptionType === 'weekly' ? 'week' : 'month'}
|
||||
</span>
|
||||
</div>
|
||||
{rental.batteryRent && rental.batteryRent > 0 && (
|
||||
<>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-sm text-amber-600">Battery Rate</span>
|
||||
<span className="text-sm font-medium text-amber-700">
|
||||
৳{Math.round(rental.batteryRent / (rental.subscriptionType === 'daily' ? 30 : rental.subscriptionType === 'weekly' ? 4 : 1))}/
|
||||
{rental.subscriptionType === 'daily' ? 'day' : rental.subscriptionType === 'weekly' ? 'week' : 'month'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="pt-2 mt-2 border-t border-slate-100">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-sm font-medium text-emerald-700">Total (Bike + Battery)</span>
|
||||
<span className="text-sm font-bold text-emerald-700">
|
||||
৳{rental.subscriptionType === 'daily'
|
||||
? rental.dailyRate + Math.round(rental.batteryRent / 30)
|
||||
: rental.subscriptionType === 'weekly'
|
||||
? rental.weeklyRate + Math.round(rental.batteryRent / 4)
|
||||
: rental.monthlyRate + rental.batteryRent}/
|
||||
{rental.subscriptionType === 'daily' ? 'day' : rental.subscriptionType === 'weekly' ? 'week' : 'month'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -710,6 +797,124 @@ export default function RentalDetailPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Battery Rental History */}
|
||||
<div className="bg-white p-4 rounded-xl border border-slate-200">
|
||||
<div className="flex items-center justify-between mb-3">
|
||||
<h3 className="font-semibold text-slate-700 flex items-center gap-2">
|
||||
<Battery className="w-5 h-5 text-amber-500" /> Battery Rental History
|
||||
</h3>
|
||||
<button onClick={() => setShowAddBatteryModal(true)} className="px-3 py-1.5 bg-amber-600 text-white rounded-lg text-xs font-medium hover:bg-amber-700 flex items-center gap-1">
|
||||
<Plus className="w-3 h-3" /> Add Battery
|
||||
</button>
|
||||
</div>
|
||||
{rental.batteryHistory && rental.batteryHistory.length > 0 ? (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full">
|
||||
<thead className="bg-slate-50">
|
||||
<tr>
|
||||
<th className="px-3 py-2 text-left text-xs font-semibold text-slate-500">Battery ID</th>
|
||||
<th className="px-3 py-2 text-left text-xs font-semibold text-slate-500">Battery Name</th>
|
||||
<th className="px-3 py-2 text-left text-xs font-semibold text-slate-500">Assigned</th>
|
||||
<th className="px-3 py-2 text-left text-xs font-semibold text-slate-500">Returned</th>
|
||||
<th className="px-3 py-2 text-left text-xs font-semibold text-slate-500">Monthly Rent</th>
|
||||
<th className="px-3 py-2 text-left text-xs font-semibold text-slate-500">Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-100">
|
||||
{rental.batteryHistory.map(bat => (
|
||||
<tr key={bat.id} className="hover:bg-slate-50">
|
||||
<td className="px-3 py-2 text-sm text-slate-700 font-mono">{bat.batteryId}</td>
|
||||
<td className="px-3 py-2 text-sm text-slate-700">{bat.batteryName}</td>
|
||||
<td className="px-3 py-2 text-sm text-slate-600">{bat.assignedAt}</td>
|
||||
<td className="px-3 py-2 text-sm text-slate-600">{bat.returnedAt || '-'}</td>
|
||||
<td className="px-3 py-2 text-sm font-medium text-green-600">৳{bat.monthlyRent}</td>
|
||||
<td className="px-3 py-2">
|
||||
<span className={`text-xs font-medium px-2 py-0.5 rounded-full ${bat.status === 'active' ? 'bg-green-100 text-green-700' : 'bg-slate-100 text-slate-600'}`}>
|
||||
{bat.status === 'active' ? 'Active' : 'Returned'}
|
||||
</span>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-slate-500">No battery assigned yet.</p>
|
||||
)}
|
||||
{rental.batteryHistory?.some(b => b.status === 'active') && (
|
||||
<div className="mt-3 p-3 bg-amber-50 border border-amber-200 rounded-lg">
|
||||
<p className="text-sm text-amber-700">
|
||||
<span className="font-medium">Active Battery Rent: </span>
|
||||
৳{rental.batteryHistory.filter(b => b.status === 'active').reduce((sum, b) => sum + b.monthlyRent, 0)}/month
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Add Battery Modal */}
|
||||
{showAddBatteryModal && (
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-white rounded-xl shadow-xl w-full max-w-md">
|
||||
<div className="p-4 border-b border-slate-100 flex justify-between items-center">
|
||||
<h3 className="font-semibold text-slate-800">Add Battery to Rental</h3>
|
||||
<button onClick={() => setShowAddBatteryModal(false)} className="text-slate-400 hover:text-slate-600">
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="p-4 space-y-4">
|
||||
<div>
|
||||
<label className="text-sm text-slate-600 mb-1 block">Select Battery</label>
|
||||
<select
|
||||
value={selectedBatteryId}
|
||||
onChange={(e) => setSelectedBatteryId(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-slate-200 rounded-lg text-sm"
|
||||
>
|
||||
<option value="">Select Battery...</option>
|
||||
{mockBatteries.map(bat => (
|
||||
<option key={bat.id} value={bat.id}>{bat.brand} {bat.model} - SOC: {bat.soc}% - Rent: ৳{bat.monthlyRent}/month</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
{selectedBatteryId && (() => {
|
||||
const batteryMonthlyRent = mockBatteries.find(b => b.id === selectedBatteryId)?.monthlyRent || 0;
|
||||
return (
|
||||
<div className="p-3 bg-amber-50 border border-amber-200 rounded-lg space-y-2">
|
||||
<p className="text-sm text-amber-700">
|
||||
Battery Monthly Rent: <span className="font-bold">৳{batteryMonthlyRent}/month</span>
|
||||
</p>
|
||||
<div className="text-xs text-amber-600 pt-2 border-t border-amber-100">
|
||||
<p className="font-medium mb-1">Calculated Rate by Subscription:</p>
|
||||
<p>• Daily: ৳{Math.round(batteryMonthlyRent / 30)}/day (৳{batteryMonthlyRent}/30)</p>
|
||||
<p>• Weekly: ৳{Math.round(batteryMonthlyRent / 4)}/week (৳{batteryMonthlyRent}/4)</p>
|
||||
<p>• Monthly (30 days): ৳{batteryMonthlyRent}/month</p>
|
||||
<p>• Monthly (31 days): ৳{batteryMonthlyRent}/month</p>
|
||||
</div>
|
||||
<div className="pt-2 mt-2 border-t border-amber-100">
|
||||
<p className="text-xs font-medium text-amber-700">Your Current Subscription: {rental.subscriptionType}</p>
|
||||
<p className="text-sm font-bold text-amber-800">
|
||||
You will pay: ৳{rental.subscriptionType === 'daily'
|
||||
? Math.round(batteryMonthlyRent / 30)
|
||||
: rental.subscriptionType === 'weekly'
|
||||
? Math.round(batteryMonthlyRent / 4)
|
||||
: batteryMonthlyRent}/{rental.subscriptionType}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
<div className="flex gap-2 pt-2">
|
||||
<button onClick={() => setShowAddBatteryModal(false)} className="flex-1 py-2 px-4 border border-slate-200 text-slate-600 rounded-lg text-sm hover:bg-slate-50">
|
||||
Cancel
|
||||
</button>
|
||||
<button onClick={handleAddBattery} disabled={!selectedBatteryId} className="flex-1 py-2 px-4 bg-amber-600 text-white rounded-lg text-sm hover:bg-amber-700 disabled:opacity-50">
|
||||
Add Battery
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Initial Condition Images */}
|
||||
{/* {(rental.status === 'pending' || rental.status === 'accepted') && rental.initialImages && ( */}
|
||||
{rental.initialImages && (
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import { useState, useEffect } from 'react';
|
||||
import {
|
||||
FileText, Search, Filter, Bike, User, Calendar, DollarSign, Clock, MoreVertical,
|
||||
Eye, Plus, Phone, MessageCircle, X, CreditCard, Wallet, Building, Download,
|
||||
Printer, ChevronLeft, ChevronRight, CheckCircle, AlertTriangle
|
||||
Printer, ChevronLeft, ChevronRight, CheckCircle, AlertTriangle, MapPin
|
||||
} from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
import {
|
||||
@@ -33,6 +33,10 @@ interface Rental {
|
||||
bikeModel: string;
|
||||
bikePlate: string;
|
||||
bikeBattery: number;
|
||||
batteryId?: string;
|
||||
batteryName?: string;
|
||||
batteryRent?: number;
|
||||
batteryRentPending?: number;
|
||||
type: RentalType;
|
||||
status: RentalStatus;
|
||||
startDate: string;
|
||||
@@ -56,6 +60,11 @@ interface Rental {
|
||||
lockedReason?: string;
|
||||
hubId: string;
|
||||
hubName: string;
|
||||
location?: {
|
||||
lat: number;
|
||||
lng: number;
|
||||
address?: string;
|
||||
};
|
||||
initialImages?: BikeImage[];
|
||||
imagesApproved: boolean;
|
||||
bikerNote?: string;
|
||||
@@ -63,6 +72,15 @@ interface Rental {
|
||||
createdAt: string;
|
||||
acceptedAt?: string;
|
||||
activatedAt?: string;
|
||||
batteryHistory?: {
|
||||
id: string;
|
||||
batteryId: string;
|
||||
batteryName: string;
|
||||
assignedAt: string;
|
||||
returnedAt?: string;
|
||||
monthlyRent: number;
|
||||
status: 'active' | 'returned';
|
||||
}[];
|
||||
}
|
||||
|
||||
interface Bike {
|
||||
@@ -103,6 +121,23 @@ const mockHubs = [
|
||||
{ id: 'HUB-004', name: 'Mirpur Hub' },
|
||||
];
|
||||
|
||||
interface Battery {
|
||||
id: string;
|
||||
brand: string;
|
||||
model: string;
|
||||
soc: number;
|
||||
monthlyRent: number;
|
||||
status: 'available' | 'in-use' | 'maintenance';
|
||||
}
|
||||
|
||||
const mockBatteries: Battery[] = [
|
||||
{ id: 'BAT-DH-001', brand: 'Galaxy', model: '72V 45Ah', soc: 85, monthlyRent: 1500, status: 'available' },
|
||||
{ id: 'BAT-DH-002', brand: 'Titan', model: '72V 50Ah', soc: 92, monthlyRent: 1800, status: 'available' },
|
||||
{ id: 'BAT-DH-003', brand: 'PowerMax', model: '60V 40Ah', soc: 78, monthlyRent: 1200, status: 'available' },
|
||||
{ id: 'BAT-DH-004', brand: 'UltraCell', model: '72V 55Ah', soc: 88, monthlyRent: 2000, status: 'available' },
|
||||
{ id: 'BAT-DH-005', brand: 'EcoVolt', model: '48V 30Ah', soc: 65, monthlyRent: 800, status: 'available' },
|
||||
];
|
||||
|
||||
const rentalSettings = {
|
||||
single: {
|
||||
Premium: { deposit: 5000, contractMonths: [1, 3, 6, 12], dailyRate: 200, weeklyRate: 1200, monthlyRate: 5000 },
|
||||
@@ -142,6 +177,7 @@ const mockRentals: Rental[] = [
|
||||
dailyRate: 150,
|
||||
weeklyRate: 900,
|
||||
monthlyRate: 3500,
|
||||
batteryRent: 1500,
|
||||
totalPaid: 38500,
|
||||
dueRental: 0,
|
||||
pendingRent: 0,
|
||||
@@ -151,6 +187,7 @@ const mockRentals: Rental[] = [
|
||||
penaltyAmount: 0,
|
||||
hubId: 'HUB-001',
|
||||
hubName: 'Gulshan Hub',
|
||||
location: { lat: 23.7925, lng: 90.4074, address: 'Gulshan 1, Dhaka' },
|
||||
imagesApproved: true,
|
||||
createdAt: '2024-01-15',
|
||||
acceptedAt: '2024-01-15',
|
||||
@@ -185,6 +222,7 @@ const mockRentals: Rental[] = [
|
||||
penaltyAmount: 0,
|
||||
hubId: 'HUB-002',
|
||||
hubName: 'Banani Hub',
|
||||
location: { lat: 23.8041, lng: 90.4152, address: 'Banani, Dhaka' },
|
||||
imagesApproved: false,
|
||||
createdAt: '2024-02-10',
|
||||
},
|
||||
@@ -243,6 +281,7 @@ const mockRentals: Rental[] = [
|
||||
dailyRate: 100,
|
||||
weeklyRate: 600,
|
||||
monthlyRate: 2200,
|
||||
batteryRent: 1500,
|
||||
totalPaid: 2600,
|
||||
dueRental: 600,
|
||||
pendingRent: 600,
|
||||
@@ -304,6 +343,8 @@ export default function RentalsPage() {
|
||||
subscriptionType: 'daily' | 'weekly' | 'monthly';
|
||||
contractMonths: number;
|
||||
bikeId: string;
|
||||
batteryId: string;
|
||||
batteryRent: number;
|
||||
startDate: string;
|
||||
hubId: string;
|
||||
depositAmount: number;
|
||||
@@ -316,12 +357,16 @@ export default function RentalsPage() {
|
||||
subscriptionType: 'daily',
|
||||
contractMonths: 0,
|
||||
bikeId: '',
|
||||
batteryId: '',
|
||||
batteryRent: 0,
|
||||
startDate: new Date().toISOString().split('T')[0],
|
||||
hubId: '',
|
||||
depositAmount: 0,
|
||||
depositPaymentMethod: 'cash',
|
||||
});
|
||||
|
||||
const availableBatteries = mockBatteries.filter(b => b.status === 'available');
|
||||
|
||||
const [showJournalPreview, setShowJournalPreview] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -439,6 +484,7 @@ export default function RentalsPage() {
|
||||
const bike = mockBikes.find(b => b.id === newRental.bikeId);
|
||||
const user = mockUsers.find(u => u.id === newRental.userId);
|
||||
const hub = mockHubs.find(h => h.id === newRental.hubId);
|
||||
const battery = mockBatteries.find(b => b.id === newRental.batteryId);
|
||||
const settings = planConditions[newRental.type]?.find(p => p.name === newRental.planConditionName) || planConditions[newRental.type]?.[0];
|
||||
|
||||
const rental: Rental = {
|
||||
@@ -450,6 +496,9 @@ export default function RentalsPage() {
|
||||
bikeModel: bike?.model || '',
|
||||
bikePlate: bike?.plate || '',
|
||||
bikeBattery: bike?.battery || 0,
|
||||
batteryId: newRental.batteryId || undefined,
|
||||
batteryName: battery ? `${battery.brand} ${battery.model}` : undefined,
|
||||
batteryRent: newRental.batteryRent || undefined,
|
||||
type: newRental.type,
|
||||
status: 'pending',
|
||||
startDate: newRental.startDate,
|
||||
@@ -473,6 +522,14 @@ export default function RentalsPage() {
|
||||
hubName: hub?.name || '',
|
||||
imagesApproved: false,
|
||||
createdAt: new Date().toISOString().split('T')[0],
|
||||
batteryHistory: newRental.batteryId ? [{
|
||||
id: `BAT-RENT-${Date.now()}`,
|
||||
batteryId: newRental.batteryId,
|
||||
batteryName: battery ? `${battery.brand} ${battery.model}` : '',
|
||||
assignedAt: new Date().toISOString().split('T')[0],
|
||||
monthlyRent: newRental.batteryRent,
|
||||
status: 'active' as const,
|
||||
}] : undefined,
|
||||
};
|
||||
|
||||
setRentals([...rentals, rental]);
|
||||
@@ -486,6 +543,8 @@ export default function RentalsPage() {
|
||||
subscriptionType: 'daily',
|
||||
contractMonths: 0,
|
||||
bikeId: '',
|
||||
batteryId: '',
|
||||
batteryRent: 0,
|
||||
evModel: '',
|
||||
startDate: new Date().toISOString().split('T')[0],
|
||||
hubId: '',
|
||||
@@ -516,6 +575,12 @@ export default function RentalsPage() {
|
||||
<button className="py-2 px-4 border border-slate-200 rounded-lg text-sm font-medium text-slate-600 hover:bg-slate-50 flex items-center justify-center gap-2">
|
||||
<Download className="w-4 h-4" /> Export
|
||||
</button>
|
||||
<Link
|
||||
href="/admin/rentals/map"
|
||||
className="py-2 px-4 border border-slate-200 rounded-lg text-sm font-medium text-slate-600 hover:bg-slate-50 flex items-center justify-center gap-2"
|
||||
>
|
||||
<MapPin className="w-4 h-4" /> Map View
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -643,23 +708,42 @@ export default function RentalsPage() {
|
||||
</td> */}
|
||||
<td className="px-4 py-3">
|
||||
{(() => {
|
||||
const rent = rental[rental.subscriptionType === 'daily' ? 'dailyRate' : rental.subscriptionType === 'weekly' ? 'weeklyRate' : 'monthlyRate'];
|
||||
const due = rental.pendingRent || 0;
|
||||
const bikeRent = rental[rental.subscriptionType === 'daily' ? 'dailyRate' : rental.subscriptionType === 'weekly' ? 'weeklyRate' : 'monthlyRate'];
|
||||
const batteryRent = rental.batteryRent || 0;
|
||||
const batteryRentAdjusted = rental.subscriptionType === 'daily'
|
||||
? Math.round(batteryRent / 30)
|
||||
: rental.subscriptionType === 'weekly'
|
||||
? Math.round(batteryRent / 4)
|
||||
: batteryRent;
|
||||
const totalRent = bikeRent + batteryRentAdjusted;
|
||||
const due = (rental.pendingRent || 0) + (rental.batteryRentPending || 0);
|
||||
const penalty = rental.penaltyAmount || 0;
|
||||
const totalDue = due + penalty;
|
||||
const periodLabel = rental.subscriptionType === 'daily' ? 'day' : rental.subscriptionType === 'weekly' ? 'wk' : 'mo';
|
||||
if (totalDue > 0) {
|
||||
return (
|
||||
<div>
|
||||
<div className="space-y-1">
|
||||
<span className="text-sm font-medium text-amber-600">৳{totalDue.toLocaleString()}</span>
|
||||
<p className="text-xs text-slate-400">{rental.subscriptionType} ৳{rent}/{(rental.subscriptionType === 'daily' ? 'day' : rental.subscriptionType === 'weekly' ? 'wk' : 'mo')}</p>
|
||||
{penalty > 0 && <span className="text-xs text-red-500">+৳{penalty} penalty</span>}
|
||||
{batteryRent > 0 ? (
|
||||
<p className="text-xs text-slate-500">
|
||||
{rental.subscriptionType} ৳{bikeRent}{periodLabel} +Battery: ৳{batteryRentAdjusted}{periodLabel}
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-xs text-red-400">{rental.subscriptionType} ৳{totalRent}{periodLabel} +৳{penalty} penalty</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div>
|
||||
<span className="text-sm font-medium text-slate-700">৳{rent.toLocaleString()}</span>
|
||||
<div className="space-y-1">
|
||||
<span className="text-sm font-medium text-slate-700">৳{totalRent.toLocaleString()}</span>
|
||||
{batteryRent > 0 ? (
|
||||
<p className="text-xs text-slate-500">
|
||||
{rental.subscriptionType} ৳{bikeRent}{periodLabel} +Battery: ৳{batteryRentAdjusted}{periodLabel}
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-xs text-slate-400 capitalize">{rental.subscriptionType}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
@@ -751,19 +835,40 @@ export default function RentalsPage() {
|
||||
{rental.depositPaid ? 'Paid' : 'Unpaid'}
|
||||
</span>
|
||||
</div>
|
||||
{totalDue > 0 ? (
|
||||
{(() => {
|
||||
const batteryRent = rental.batteryRent || 0;
|
||||
const batteryRentAdjusted = rental.subscriptionType === 'daily'
|
||||
? Math.round(batteryRent / 30)
|
||||
: rental.subscriptionType === 'weekly'
|
||||
? Math.round(batteryRent / 4)
|
||||
: batteryRent;
|
||||
const totalRent = rent + batteryRentAdjusted;
|
||||
const periodLabel = rental.subscriptionType === 'daily' ? 'day' : rental.subscriptionType === 'weekly' ? 'wk' : 'mo';
|
||||
return totalDue > 0 ? (
|
||||
<div className="text-right">
|
||||
<span className="text-sm font-medium text-amber-600">৳{totalDue.toLocaleString()}</span>
|
||||
<p className="text-xs text-slate-400">{rental.subscriptionType} ৳{rent}/{(rental.subscriptionType === 'daily' ? 'day' : rental.subscriptionType === 'weekly' ? 'wk' : 'mo')}</p>
|
||||
{penalty > 0 && <span className="text-xs text-red-500">+৳{penalty}</span>}
|
||||
{batteryRent > 0 ? (
|
||||
<p className="text-xs text-slate-500">
|
||||
{rental.subscriptionType} ৳{rent}{periodLabel} +Battery: ৳{batteryRentAdjusted}{periodLabel} +৳{penalty} penalty
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-xs text-slate-400">{rental.subscriptionType} ৳{totalRent}{periodLabel} +৳{penalty} penalty</p>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-right">
|
||||
<span className="text-sm font-medium text-slate-700">৳{rent.toLocaleString()}</span>
|
||||
<span className="text-sm font-medium text-slate-700">৳{totalRent.toLocaleString()}</span>
|
||||
{batteryRent > 0 ? (
|
||||
<p className="text-xs text-slate-500">
|
||||
{rental.subscriptionType} ৳{rent}{periodLabel} +Battery: ৳{batteryRentAdjusted}{periodLabel}
|
||||
</p>
|
||||
) : (
|
||||
<p className="text-xs text-slate-400 capitalize">{rental.subscriptionType}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1.5 pt-1">
|
||||
<Link href={`/admin/rentals/${rental.id}`} className="flex-1 flex items-center justify-center gap-1 p-2 bg-white hover:bg-slate-100 rounded-lg text-xs font-medium text-slate-600">
|
||||
@@ -918,17 +1023,31 @@ export default function RentalsPage() {
|
||||
<span className="text-xs text-emerald-600 font-medium uppercase tracking-wide">{newRental.planConditionName}</span>
|
||||
</div>
|
||||
<p className="text-sm text-slate-600">Deposit: <span className="font-semibold text-slate-800">৳{selectedPlan?.deposit.toLocaleString()}</span></p>
|
||||
<p className="text-sm text-slate-600">
|
||||
Rate: <span className="font-semibold text-slate-800">৳{newRental.subscriptionType === 'daily' ? selectedPlan?.dailyRate : newRental.subscriptionType === 'weekly' ? selectedPlan?.weeklyRate : selectedPlan?.monthlyRate}</span>
|
||||
<span className="text-slate-500">/{newRental.subscriptionType === 'daily' ? 'day' : newRental.subscriptionType === 'weekly' ? 'week' : 'month'}</span>
|
||||
</p>
|
||||
<div className="text-sm text-slate-600">
|
||||
<span>Rate: </span>
|
||||
{(() => {
|
||||
const bikeRate = newRental.subscriptionType === 'daily' ? selectedPlan?.dailyRate : newRental.subscriptionType === 'weekly' ? selectedPlan?.weeklyRate : selectedPlan?.monthlyRate;
|
||||
const batteryRent = newRental.batteryRent || 0;
|
||||
const batteryRate = batteryRent > 0 ? (newRental.subscriptionType === 'daily' ? Math.round(batteryRent / 30) : newRental.subscriptionType === 'weekly' ? Math.round(batteryRent / 4) : batteryRent) : 0;
|
||||
const totalRate = bikeRate + batteryRate;
|
||||
return (
|
||||
<span className="font-semibold text-slate-800">
|
||||
৳{totalRate.toLocaleString()}/{newRental.subscriptionType === 'daily' ? 'day' : newRental.subscriptionType === 'weekly' ? 'week' : 'month'}
|
||||
{batteryRent > 0 && <span className="text-xs text-amber-600 ml-1">(Bike: ৳{bikeRate} + Battery: ৳{batteryRate})</span>}
|
||||
</span>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{createStep === 3 && (
|
||||
<div className="space-y-4">
|
||||
<h4 className="font-medium text-slate-700 mb-3">Step 3: Select Bike & Battery</h4>
|
||||
|
||||
<div>
|
||||
<h4 className="font-medium text-slate-700 mb-3">Step 3: Select Bike</h4>
|
||||
<label className="text-sm text-slate-600 mb-1 block">Select Bike</label>
|
||||
<select
|
||||
value={newRental.bikeId}
|
||||
onChange={(e) => setNewRental({ ...newRental, bikeId: e.target.value })}
|
||||
@@ -940,7 +1059,7 @@ export default function RentalsPage() {
|
||||
))}
|
||||
</select>
|
||||
{selectedBike && (
|
||||
<div className="mt-3 flex items-center gap-2">
|
||||
<div className="mt-2 flex items-center gap-2">
|
||||
<span className="text-sm text-slate-600">Battery:</span>
|
||||
<span className={`text-sm px-2 py-1 rounded-full ${selectedBike.battery > 70 ? 'bg-green-100 text-green-700' : selectedBike.battery > 40 ? 'bg-amber-100 text-amber-700' : 'bg-red-100 text-red-700'}`}>
|
||||
{selectedBike.battery}%
|
||||
@@ -948,6 +1067,37 @@ export default function RentalsPage() {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-sm text-slate-600 mb-1 block">Request Battery (Optional)</label>
|
||||
<p className="text-xs text-slate-500 mb-2">Biker can request for battery. It will be added to their rental.</p>
|
||||
<select
|
||||
value={newRental.batteryId}
|
||||
onChange={(e) => {
|
||||
const bat = availableBatteries.find(b => b.id === e.target.value);
|
||||
setNewRental({ ...newRental, batteryId: e.target.value, batteryRent: bat?.monthlyRent || 0 });
|
||||
}}
|
||||
className="w-full px-3 py-2 border border-slate-200 rounded-lg text-sm"
|
||||
>
|
||||
<option value="">No Battery - Use Bike's Battery</option>
|
||||
{availableBatteries.map(bat => (
|
||||
<option key={bat.id} value={bat.id}>{bat.brand} {bat.model} - SOC: {bat.soc}% - Rent: ৳{bat.monthlyRent}/month</option>
|
||||
))}
|
||||
</select>
|
||||
{newRental.batteryId && (
|
||||
<div className="mt-2 p-3 bg-amber-50 border border-amber-200 rounded-lg space-y-1">
|
||||
<p className="text-sm text-amber-700">Battery Monthly Rent: <span className="font-bold">৳{newRental.batteryRent}/month</span></p>
|
||||
<div className="text-xs text-amber-600 pt-1 border-t border-amber-100">
|
||||
<p>Calculated Rate by Subscription:</p>
|
||||
<p>• Daily: ৳{Math.round(newRental.batteryRent / 30)}/day (৳{newRental.batteryRent}/30)</p>
|
||||
<p>• Weekly: ৳{Math.round(newRental.batteryRent / 4)}/week (৳{newRental.batteryRent}/4)</p>
|
||||
<p>• Monthly (30 days): ৳{newRental.batteryRent}/month</p>
|
||||
<p>• Monthly (31 days): ৳{newRental.batteryRent}/month</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -958,7 +1108,43 @@ export default function RentalsPage() {
|
||||
<p className="text-sm text-slate-600">Deposit Amount: ৳{selectedPlan?.deposit.toLocaleString()}</p>
|
||||
<p className="text-sm text-slate-600">User: {selectedUser?.name}</p>
|
||||
<p className="text-sm text-slate-600">Bike: {selectedBike?.model} ({selectedBike?.plate})</p>
|
||||
{newRental.batteryId && (
|
||||
<p className="text-sm text-slate-600">Battery: {availableBatteries.find(b => b.id === newRental.batteryId)?.brand} {availableBatteries.find(b => b.id === newRental.batteryId)?.model} (Monthly Rent: ৳{newRental.batteryRent})</p>
|
||||
)}
|
||||
<p className="text-sm text-slate-600">Hub: {mockHubs.find(h => h.id === newRental.hubId)?.name}</p>
|
||||
{newRental.batteryId && (
|
||||
<div className="mt-3 pt-3 border-t border-slate-200">
|
||||
<p className="text-sm font-medium text-emerald-700 mb-2">Total Rental Payment (Bike + Battery):</p>
|
||||
{(() => {
|
||||
const bikeRate = newRental.subscriptionType === 'daily' ? selectedPlan?.dailyRate : newRental.subscriptionType === 'weekly' ? selectedPlan?.weeklyRate : selectedPlan?.monthlyRate;
|
||||
const batteryMonthlyRent = newRental.batteryRent || 0;
|
||||
const dailyRate = bikeRate + Math.round(batteryMonthlyRent / 30);
|
||||
const weeklyRate = bikeRate + Math.round(batteryMonthlyRent / 4);
|
||||
const monthly30Rate = bikeRate + batteryMonthlyRent;
|
||||
const monthly31Rate = bikeRate + batteryMonthlyRent;
|
||||
return (
|
||||
<div className="space-y-1 text-xs">
|
||||
<p className="text-slate-600">
|
||||
<span className="font-medium">Daily:</span> ৳{dailyRate}/day
|
||||
<span className="text-amber-600"> (Bike: ৳{bikeRate} + Battery: ৳{Math.round(batteryMonthlyRent / 30)})</span>
|
||||
</p>
|
||||
<p className="text-slate-600">
|
||||
<span className="font-medium">Weekly:</span> ৳{weeklyRate}/week
|
||||
<span className="text-amber-600"> (Bike: ৳{bikeRate} + Battery: ৳{Math.round(batteryMonthlyRent / 4)})</span>
|
||||
</p>
|
||||
<p className="text-slate-600">
|
||||
<span className="font-medium">Monthly (30 days):</span> ৳{monthly30Rate}/month
|
||||
<span className="text-amber-600"> (Bike: ৳{bikeRate} + Battery: ৳{batteryMonthlyRent})</span>
|
||||
</p>
|
||||
<p className="text-slate-600">
|
||||
<span className="font-medium">Monthly (31 days):</span> ৳{monthly31Rate}/month
|
||||
<span className="text-amber-600"> (Bike: ৳{bikeRate} + Battery: ৳{batteryMonthlyRent})</span>
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { investors, bikes, transactions } from '@/data/mockData';
|
||||
import { Wallet, TrendingUp, Bike, Target, DollarSign, FileText, Phone, BarChart3, Clock, ArrowRight, ShieldCheck, Zap, AlertCircle } from 'lucide-react';
|
||||
import { Wallet, TrendingUp, Bike, Target, DollarSign, FileText, Phone, BarChart3, Clock, ArrowRight, ShieldCheck, Zap, AlertCircle, Download, X, ExternalLink } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
import TransactionList from '@/components/TransactionList';
|
||||
import InvestorNotification from '@/components/InvestorNotification';
|
||||
@@ -11,6 +14,40 @@ export default function InvestorDashboardPage() {
|
||||
|
||||
const availableBalance = investor.totalEarnings - investor.totalWithdrawn - investor.withdrawalPending;
|
||||
|
||||
const [deferredPrompt, setDeferredPrompt] = useState<any>(null);
|
||||
const [showInstallBanner, setShowInstallBanner] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const handler = (e: Event) => {
|
||||
e.preventDefault();
|
||||
setDeferredPrompt(e);
|
||||
setShowInstallBanner(true);
|
||||
};
|
||||
window.addEventListener('beforeinstallprompt', handler);
|
||||
return () => window.removeEventListener('beforeinstallprompt', handler);
|
||||
}, []);
|
||||
|
||||
const handleInstall = async () => {
|
||||
if (!deferredPrompt) return;
|
||||
deferredPrompt.prompt();
|
||||
const { outcome } = await deferredPrompt.userChoice;
|
||||
if (outcome === 'accepted') {
|
||||
setDeferredPrompt(null);
|
||||
}
|
||||
setShowInstallBanner(false);
|
||||
};
|
||||
|
||||
const dismissBanner = () => {
|
||||
setShowInstallBanner(false);
|
||||
localStorage.setItem('pwa_install_dismissed', 'true');
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
if (localStorage.getItem('pwa_install_dismissed') === 'true') {
|
||||
setShowInstallBanner(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="min-h-screen lg:pt-6 pt-0">
|
||||
<InvestorNotification isMobile />
|
||||
@@ -82,6 +119,29 @@ export default function InvestorDashboardPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* PWA Install Banner */}
|
||||
{showInstallBanner && (
|
||||
<div className="bg-gradient-to-r from-investor/10 to-purple-50 border border-investor/20 rounded-2xl p-4 mb-6 animate-in fade-in slide-in-from-bottom-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<div className="w-12 h-12 bg-gradient-to-br from-investor to-investor-light rounded-xl flex items-center justify-center shrink-0 shadow-lg">
|
||||
<Download className="w-6 h-6 text-white" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="font-bold text-slate-800">Install JML Investor App</p>
|
||||
<p className="text-xs text-slate-500 mt-0.5">Add to home screen for quick access & offline support</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 shrink-0">
|
||||
<button onClick={dismissBanner} className="p-2 hover:bg-slate-100 rounded-lg text-slate-400 transition-colors">
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
<button onClick={handleInstall} className="px-4 py-2 bg-investor text-white rounded-lg font-bold text-sm hover:bg-investor-dark transition-colors flex items-center gap-2 shadow-sm">
|
||||
<ExternalLink className="w-4 h-4" /> Install
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid lg:grid-cols-3 gap-6 mb-6">
|
||||
<div className="lg:col-span-2 bg-white rounded-2xl border border-slate-200 shadow-sm flex flex-col">
|
||||
<div className="p-5 border-b border-slate-100 flex items-center justify-between">
|
||||
|
||||
@@ -13,6 +13,16 @@ import { investors } from '@/data/mockData';
|
||||
import toast from 'react-hot-toast';
|
||||
import InvestorNotification from '@/components/InvestorNotification';
|
||||
|
||||
interface PaymentRecord {
|
||||
id: string;
|
||||
date: string;
|
||||
amount: number;
|
||||
installmentNo: number | null;
|
||||
type: 'full' | 'partial' | 'installment';
|
||||
method: string;
|
||||
status: 'completed' | 'pending';
|
||||
}
|
||||
|
||||
export default function InvestorInvestmentDetailPage({ params }: { params: Promise<{ id: string }> }) {
|
||||
const resolvedParams = use(params);
|
||||
const { id: investmentId } = resolvedParams;
|
||||
@@ -23,7 +33,12 @@ export default function InvestorInvestmentDetailPage({ params }: { params: Promi
|
||||
|
||||
const [activeTab, setActiveTab] = useState('overview');
|
||||
const [showPaymentModal, setShowPaymentModal] = useState(false);
|
||||
const [selectedInstallment, setSelectedInstallment] = useState<'full' | '2' | '3'>('3');
|
||||
const [paymentAmount, setPaymentAmount] = useState('');
|
||||
|
||||
const paymentHistory: PaymentRecord[] = [
|
||||
{ id: 'pay1', date: '2024-01-15', amount: 400000, installmentNo: 1, type: 'installment', method: 'Bank Transfer', status: 'completed' },
|
||||
{ id: 'pay2', date: '2024-02-15', amount: 150000, installmentNo: null, type: 'partial', method: 'bKash', status: 'completed' },
|
||||
];
|
||||
|
||||
if (!investment) {
|
||||
return (
|
||||
@@ -42,6 +57,9 @@ export default function InvestorInvestmentDetailPage({ params }: { params: Promi
|
||||
);
|
||||
}
|
||||
|
||||
const totalPaid = paymentHistory.reduce((sum, p) => p.status === 'completed' ? sum + p.amount : sum, 0);
|
||||
const dueAmount = investment.totalInvestment - totalPaid;
|
||||
|
||||
const planConfig: Record<string, { badge: string }> = {
|
||||
silver: { badge: 'bg-slate-200 text-slate-700' },
|
||||
gold: { badge: 'bg-amber-100 text-amber-700' },
|
||||
@@ -70,18 +88,25 @@ export default function InvestorInvestmentDetailPage({ params }: { params: Promi
|
||||
{ id: 'tx8', date: '2024-05-08', description: 'Rental Income - Bike CD-5678', amount: 500, status: 'completed' },
|
||||
];
|
||||
|
||||
const dueAmount = investment.totalInvestment * 0.33;
|
||||
const paidAmount = investment.totalInvestment * 0.67;
|
||||
|
||||
const handlePaymentSubmit = () => {
|
||||
toast.success(`Payment of ৳${(dueAmount / (selectedInstallment === '2' ? 2 : 3)).toLocaleString()} initiated successfully!`);
|
||||
const amount = parseFloat(paymentAmount);
|
||||
if (!amount || amount <= 0) {
|
||||
toast.error('Please enter a valid amount');
|
||||
return;
|
||||
}
|
||||
if (amount > dueAmount) {
|
||||
toast.error('Amount exceeds due amount');
|
||||
return;
|
||||
}
|
||||
toast.success(`Payment of ৳${amount.toLocaleString()} submitted successfully!`);
|
||||
setShowPaymentModal(false);
|
||||
setPaymentAmount('');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen lg:pt-6 pt-0 ">
|
||||
<div className="min-h-screen lg:pt-6 pt-0">
|
||||
<InvestorNotification isMobile />
|
||||
<div className="pt-18 lg:pt-0 p-4 lg:p-6 max-w-8xl mx-auto mb-20 lg:mb-0">
|
||||
<div className="pt-18 lg:pt-0 p-4 lg:p-6 max-w-8xl mx-auto mb-12 lg:mb-0">
|
||||
<div className="mb-6">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
|
||||
<div className="flex items-center gap-3">
|
||||
@@ -89,17 +114,33 @@ export default function InvestorInvestmentDetailPage({ params }: { params: Promi
|
||||
<ArrowLeft className="w-5 h-5 text-slate-600" />
|
||||
</button>
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex flex-col gap-2 lg:flex-row lg:items-center lg:justify-between">
|
||||
<h1 className="text-xl lg:text-2xl font-bold text-slate-800 flex items-center gap-2">
|
||||
<Target className="w-5 h-5 lg:w-6 lg:h-6 text-investor" />{investment.planName}
|
||||
<Target className="w-5 h-5 lg:w-6 lg:h-6 text-investor" />
|
||||
{investment.planName}
|
||||
</h1>
|
||||
<span className={`px-2.5 py-1 rounded-full text-xs font-bold ${style.badge} capitalize`}>{investment.planType}</span>
|
||||
<span className={`px-2.5 py-1 rounded-full text-xs font-bold ${investment.status === 'active' ? 'bg-green-100 text-green-700' : 'bg-slate-100 text-slate-600'} capitalize`}>{investment.status}</span>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span
|
||||
className={`px-2.5 py-1 rounded-full text-xs font-bold ${style.badge} capitalize`}
|
||||
>
|
||||
{investment.planType}
|
||||
</span>
|
||||
|
||||
<span
|
||||
className={`px-2.5 py-1 rounded-full text-xs font-bold ${investment.status === 'active'
|
||||
? 'bg-green-100 text-green-700'
|
||||
: 'bg-slate-100 text-slate-600'
|
||||
} capitalize`}
|
||||
>
|
||||
{investment.status}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-sm text-slate-500 mt-1">ID: #{investment.id?.toUpperCase()} • Started: {investment.startDate}</p>
|
||||
</div>
|
||||
</div>
|
||||
<button onClick={() => toast.success('Download started')} className="px-4 py-2.5 bg-white border border-slate-200 text-slate-700 rounded-lg text-sm font-medium hover:bg-slate-50 flex items-center gap-2 shadow-sm">
|
||||
<button onClick={() => toast.success('Download started')} className="px-4 py-2.5 bg-white border border-slate-200 text-slate-700 rounded-lg text-sm font-medium hover:bg-slate-50 flex justify-center lg:justify-start items-center gap-2 shadow-sm">
|
||||
<Download className="w-4 h-4" /> Download
|
||||
</button>
|
||||
</div>
|
||||
@@ -122,7 +163,7 @@ export default function InvestorInvestmentDetailPage({ params }: { params: Promi
|
||||
</div>
|
||||
<p className="text-xs text-slate-500 font-medium">Total Return</p>
|
||||
</div>
|
||||
<p className="text-xl lg:text-2xl font-bold text-green-600">৳{(investment.actualEarnings / 1000).toFixed(0)}k</p>
|
||||
<p className="text-xl lg:text-2xl font-bold text-green-600">৳{investment.actualEarnings.toLocaleString()}</p>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl p-4 lg:p-5 border border-amber-200 shadow-sm bg-amber-50/50">
|
||||
<div className="flex items-center gap-2 mb-2">
|
||||
@@ -148,19 +189,18 @@ export default function InvestorInvestmentDetailPage({ params }: { params: Promi
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-xl border border-slate-200 shadow-sm overflow-hidden">
|
||||
<div className="flex overflow-x-auto border-b border-slate-100">
|
||||
<div className="flex overflow-x-auto border-b border-slate-100 justify-between sm:justify-start px-4 lg:px-0">
|
||||
{[
|
||||
{ key: 'overview', label: 'Overview', icon: FileText, count: null },
|
||||
{ key: 'bikes', label: 'Bikes', icon: Bike, count: demoBikes.length },
|
||||
{ key: 'transactions', label: 'Transactions', icon: CreditCard, count: demoTransactions.length },
|
||||
{ key: 'statement', label: 'Statement', icon: Receipt, count: null },
|
||||
|
||||
].map((tab) => {
|
||||
const Icon = tab.icon;
|
||||
return (
|
||||
<button key={tab.key} onClick={() => setActiveTab(tab.key)}
|
||||
className={`px-3 lg:px-6 py-3 text-sm font-semibold capitalize whitespace-nowrap transition-colors flex items-center gap-2 ${activeTab === tab.key ? 'text-investor bg-investor/5 border-b-2 border-investor' : 'text-slate-500 hover:text-slate-700 hover:bg-slate-50'}`}>
|
||||
<Icon className="w-4 h-4" />
|
||||
className={`px-4 lg:px-6 py-3 w-full lg:w-auto text-sm font-semibold capitalize whitespace-nowrap transition-colors flex justify-center items-center gap-2 ${activeTab === tab.key ? 'text-investor bg-investor/5 border-b-2 border-investor' : 'text-slate-500 hover:text-slate-700 hover:bg-slate-50'}`}>
|
||||
<Icon className="w-4 h-4 " />
|
||||
<span className="hidden lg:inline">{tab.label}</span>
|
||||
{tab.count !== null && <span className={`px-1.5 py-0.5 rounded text-[10px] font-bold ${activeTab === tab.key ? 'bg-investor text-white' : 'bg-slate-200 text-slate-600'}`}>{tab.count}</span>}
|
||||
</button>
|
||||
@@ -241,6 +281,41 @@ export default function InvestorInvestmentDetailPage({ params }: { params: Promi
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-green-50 border border-green-200 rounded-xl p-5">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h4 className="font-semibold text-green-800 flex items-center gap-2">
|
||||
<DollarSign className="w-4 h-4 text-green-600" /> Payment History
|
||||
</h4>
|
||||
<button onClick={() => setShowPaymentModal(true)} className="px-3 py-1.5 bg-green-600 text-white text-xs font-bold rounded-lg hover:bg-green-700 transition-colors">
|
||||
Make Payment
|
||||
</button>
|
||||
</div>
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-white/50">
|
||||
<tr>
|
||||
<th className="px-3 py-2 text-left text-xs font-semibold text-slate-500">Date</th>
|
||||
<th className="px-3 py-2 text-left text-xs font-semibold text-slate-500">Type</th>
|
||||
<th className="px-3 py-2 text-left text-xs font-semibold text-slate-500">Method</th>
|
||||
<th className="px-3 py-2 text-right text-xs font-semibold text-slate-500">Amount</th>
|
||||
<th className="px-3 py-2 text-center text-xs font-semibold text-slate-500">Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-green-100">
|
||||
{paymentHistory.map((payment) => (
|
||||
<tr key={payment.id} className="bg-white/50">
|
||||
<td className="px-3 py-2 text-slate-600">{payment.date}</td>
|
||||
<td className="px-3 py-2"><span className="px-2 py-0.5 rounded text-[10px] font-bold uppercase bg-slate-100 text-slate-600">{payment.type}</span></td>
|
||||
<td className="px-3 py-2 text-slate-600">{payment.method}</td>
|
||||
<td className="px-3 py-2 text-right font-bold text-green-700">৳{payment.amount.toLocaleString()}</td>
|
||||
<td className="px-3 py-2 text-center"><span className="px-2 py-0.5 rounded text-[10px] font-bold uppercase bg-green-100 text-green-700">{payment.status}</span></td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -346,6 +421,62 @@ export default function InvestorInvestmentDetailPage({ params }: { params: Promi
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{activeTab === 'payments' && (
|
||||
<div className="space-y-4">
|
||||
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-3">
|
||||
<p className="text-sm text-slate-500">{paymentHistory.length} payments made</p>
|
||||
<button onClick={() => setShowPaymentModal(true)} className="px-4 py-2 bg-investor text-white rounded-lg text-sm font-medium hover:bg-investor-dark flex items-center gap-2">
|
||||
<DollarSign className="w-4 h-4" /> Make Payment
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="overflow-x-auto">
|
||||
<table className="hidden lg:table w-full text-left">
|
||||
<thead className="bg-slate-50">
|
||||
<tr>
|
||||
<th className="px-4 py-3 text-xs font-semibold text-slate-500 uppercase">Date</th>
|
||||
<th className="px-4 py-3 text-xs font-semibold text-slate-500 uppercase">Type</th>
|
||||
<th className="px-4 py-3 text-xs font-semibold text-slate-500 uppercase">Installment</th>
|
||||
<th className="px-4 py-3 text-xs font-semibold text-slate-500 uppercase">Method</th>
|
||||
<th className="px-4 py-3 text-xs font-semibold text-slate-500 uppercase text-right">Amount</th>
|
||||
<th className="px-4 py-3 text-xs font-semibold text-slate-500 uppercase">Status</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-100">
|
||||
{paymentHistory.map((payment) => (
|
||||
<tr key={payment.id} className="hover:bg-slate-50 transition-colors">
|
||||
<td className="px-4 py-3 text-sm text-slate-600 whitespace-nowrap">{payment.date}</td>
|
||||
<td className="px-4 py-3"><span className="px-2 py-0.5 rounded text-[10px] font-bold uppercase bg-slate-100 text-slate-600">{payment.type}</span></td>
|
||||
<td className="px-4 py-3 text-sm text-slate-600">{payment.installmentNo ? `#${payment.installmentNo}` : '-'}</td>
|
||||
<td className="px-4 py-3 text-sm text-slate-600">{payment.method}</td>
|
||||
<td className="px-4 py-3 text-sm font-bold text-green-600 text-right">৳{payment.amount.toLocaleString()}</td>
|
||||
<td className="px-4 py-3"><span className="inline-flex px-2 py-0.5 rounded text-[10px] font-bold uppercase bg-green-100 text-green-700">{payment.status}</span></td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div className="lg:hidden space-y-3">
|
||||
{paymentHistory.map((payment) => (
|
||||
<div key={payment.id} className="p-4 bg-white rounded-xl border border-slate-200">
|
||||
<div className="flex items-start justify-between mb-2">
|
||||
<div>
|
||||
<p className="text-sm font-medium text-slate-800">৳{payment.amount.toLocaleString()}</p>
|
||||
<p className="text-xs text-slate-400">{payment.date} • {payment.method}</p>
|
||||
</div>
|
||||
<span className="px-2 py-0.5 rounded text-[10px] font-bold uppercase bg-green-100 text-green-700">{payment.status}</span>
|
||||
</div>
|
||||
<div className="flex gap-2 mt-2">
|
||||
<span className="px-2 py-0.5 rounded text-[10px] font-bold uppercase bg-slate-100 text-slate-600">{payment.type}</span>
|
||||
{payment.installmentNo && <span className="px-2 py-0.5 rounded text-[10px] font-bold uppercase bg-blue-100 text-blue-600">Inst #{payment.installmentNo}</span>}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -354,55 +485,44 @@ export default function InvestorInvestmentDetailPage({ params }: { params: Promi
|
||||
<div className="fixed inset-0 bg-black/50 flex items-center justify-center z-50 p-4">
|
||||
<div className="bg-white rounded-2xl w-full max-w-md shadow-2xl">
|
||||
<div className="flex items-center justify-between p-5 border-b border-slate-100">
|
||||
<h3 className="text-lg font-bold text-slate-800">Pay Due Amount</h3>
|
||||
<h3 className="text-lg font-bold text-slate-800">Make Payment</h3>
|
||||
<button onClick={() => setShowPaymentModal(false)} className="p-1 hover:bg-slate-100 rounded-lg">
|
||||
<X className="w-5 h-5 text-slate-400" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="p-5 space-y-4">
|
||||
<div className="bg-amber-50 border border-amber-200 rounded-xl p-4 text-center">
|
||||
<p className="text-sm text-amber-700 mb-1">Total Due</p>
|
||||
<p className="text-3xl font-bold text-amber-600">৳{dueAmount.toLocaleString()}</p>
|
||||
<div className="bg-slate-50 border border-slate-200 rounded-xl p-4">
|
||||
<div className="flex justify-between mb-2">
|
||||
<span className="text-sm text-slate-500">Total Investment</span>
|
||||
<span className="text-sm font-semibold text-slate-800">৳{investment.totalInvestment.toLocaleString()}</span>
|
||||
</div>
|
||||
<div className="flex justify-between mb-2">
|
||||
<span className="text-sm text-slate-500">Already Paid</span>
|
||||
<span className="text-sm font-semibold text-green-600">৳{totalPaid.toLocaleString()}</span>
|
||||
</div>
|
||||
<div className="flex justify-between pt-2 border-t border-slate-200">
|
||||
<span className="text-sm font-semibold text-slate-800">Due Amount</span>
|
||||
<span className="text-sm font-bold text-amber-600">৳{dueAmount.toLocaleString()}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-slate-700 mb-3">Installment List</p>
|
||||
<div className="space-y-2">
|
||||
<div className="p-3 bg-green-50 border border-green-200 rounded-lg flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Check className="w-4 h-4 text-green-600" />
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-slate-800">Installment 1</p>
|
||||
<p className="text-xs text-slate-500">Paid on Jan 15, 2024</p>
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-sm font-bold text-green-600">৳{(investment.totalInvestment * 0.4).toLocaleString()}</span>
|
||||
</div>
|
||||
<div className="p-3 bg-amber-50 border border-amber-300 rounded-lg flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Clock className="w-4 h-4 text-amber-600" />
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-slate-800">Installment 2</p>
|
||||
<p className="text-xs text-amber-600">Due: Jun 15, 2024</p>
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-sm font-bold text-amber-600">৳{(investment.totalInvestment * 0.3).toLocaleString()}</span>
|
||||
</div>
|
||||
<div className="p-3 bg-amber-50 border border-amber-300 rounded-lg flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<Clock className="w-4 h-4 text-amber-600" />
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-slate-800">Installment 3</p>
|
||||
<p className="text-xs text-amber-600">Due: Jul 15, 2024</p>
|
||||
</div>
|
||||
</div>
|
||||
<span className="text-sm font-bold text-amber-600">৳{(investment.totalInvestment * 0.3).toLocaleString()}</span>
|
||||
<label className="text-sm font-semibold text-slate-700 mb-2 block">Enter Amount</label>
|
||||
<div className="relative">
|
||||
<span className="absolute left-4 top-1/2 -translate-y-1/2 text-slate-400">৳</span>
|
||||
<input type="number" value={paymentAmount} onChange={(e) => setPaymentAmount(e.target.value)}
|
||||
placeholder="Enter amount"
|
||||
className="w-full pl-8 pr-4 py-3 border border-slate-200 rounded-xl text-lg font-semibold focus:outline-none focus:border-investor" />
|
||||
</div>
|
||||
<div className="flex gap-2 mt-2">
|
||||
<button onClick={() => setPaymentAmount(dueAmount.toString())} className="px-3 py-1 text-xs bg-slate-100 text-slate-600 rounded-lg hover:bg-slate-200">Full Due</button>
|
||||
<button onClick={() => setPaymentAmount((dueAmount / 2).toString())} className="px-3 py-1 text-xs bg-slate-100 text-slate-600 rounded-lg hover:bg-slate-200">Half</button>
|
||||
<button onClick={() => setPaymentAmount((dueAmount / 4).toString())} className="px-3 py-1 text-xs bg-slate-100 text-slate-600 rounded-lg hover:bg-slate-200">25%</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="pt-2">
|
||||
<p className="text-sm font-semibold text-slate-700 mb-2">Select Payment</p>
|
||||
<label className="text-sm font-semibold text-slate-700 mb-2 block">Payment Method</label>
|
||||
<div className="flex gap-2">
|
||||
<button className="flex-1 p-3 border border-slate-200 rounded-lg text-center hover:border-investor hover:bg-investor/5 transition-colors">
|
||||
<Building2 className="w-5 h-5 mx-auto text-slate-600 mb-1" />
|
||||
@@ -416,7 +536,7 @@ export default function InvestorInvestmentDetailPage({ params }: { params: Promi
|
||||
</div>
|
||||
|
||||
<button onClick={handlePaymentSubmit} className="w-full py-3 bg-investor text-white rounded-xl font-bold hover:bg-investor-dark transition-colors">
|
||||
Pay Now ৳{(investment.totalInvestment * 0.3).toLocaleString()}
|
||||
Pay ৳{paymentAmount ? parseFloat(paymentAmount).toLocaleString() : '0'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -47,6 +47,7 @@ const adminNavItems: NavItem[] = [
|
||||
{ label: 'Bikers', href: '/admin/bikers', icon: Users },
|
||||
{ label: 'Investors', href: '/admin/investors', icon: Wallet },
|
||||
{ label: 'Fleet Management', href: '/admin/fleet', icon: Bike },
|
||||
{ label: 'Battery Management', href: '/admin/batteries', icon: Battery },
|
||||
{ label: 'Merchants (P2)', href: '/admin/merchants', icon: Store },
|
||||
{ label: 'Swap Stations (P3)', href: '/admin/swap-stations', icon: Zap },
|
||||
|
||||
|
||||
Reference in New Issue
Block a user