feat: implement automated double-entry accounting for investments and add transaction details modal
This commit is contained in:
686
src/app/admin/rentals/[id]/page.tsx
Normal file
686
src/app/admin/rentals/[id]/page.tsx
Normal file
@@ -0,0 +1,686 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useParams, useRouter } from 'next/navigation';
|
||||
import {
|
||||
ArrowLeft, Bike, User, Calendar, DollarSign, Wallet, Shield, CheckCircle, XCircle,
|
||||
Clock, Edit, Save, Plus, Trash2, Image, Upload, Lock, Unlock, AlertTriangle, MessageSquare, MapPin,
|
||||
Phone, MessageCircle
|
||||
} from 'lucide-react';
|
||||
|
||||
type RentalStatus = 'active' | 'pending' | 'completed' | 'disputed' | 'cancelled' | 'locked';
|
||||
type RentalType = 'single' | 'shared' | 'rent-to-own';
|
||||
|
||||
interface BikeImage {
|
||||
id: string;
|
||||
type: 'front' | 'back' | 'left' | 'right';
|
||||
url?: string;
|
||||
}
|
||||
|
||||
interface Note {
|
||||
id: string;
|
||||
text: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
interface Rental {
|
||||
id: string;
|
||||
bikeId: string;
|
||||
userId: string;
|
||||
type: RentalType;
|
||||
status: RentalStatus;
|
||||
startDate: string;
|
||||
endDate?: string;
|
||||
deposit: number;
|
||||
dailyRate: number;
|
||||
totalPaid: number;
|
||||
dueRental?: number;
|
||||
lockedAt?: string;
|
||||
lockedReason?: string;
|
||||
hubId?: string;
|
||||
hubName?: string;
|
||||
}
|
||||
|
||||
const mockRentals: Rental[] = [
|
||||
{
|
||||
id: 'RNT-001',
|
||||
bikeId: 'BIKE-001',
|
||||
userId: 'USR-001',
|
||||
type: 'single',
|
||||
status: 'active',
|
||||
startDate: '2024-01-15',
|
||||
deposit: 5000,
|
||||
dailyRate: 300,
|
||||
totalPaid: 81900,
|
||||
dueRental: 0,
|
||||
hubId: 'HUB-001',
|
||||
hubName: 'Gulshan Hub'
|
||||
},
|
||||
{
|
||||
id: 'RNT-002',
|
||||
bikeId: 'BIKE-002',
|
||||
userId: 'USR-002',
|
||||
type: 'shared',
|
||||
status: 'pending',
|
||||
startDate: '2024-02-01',
|
||||
deposit: 3000,
|
||||
dailyRate: 200,
|
||||
totalPaid: 2000,
|
||||
dueRental: 0,
|
||||
hubId: 'HUB-002',
|
||||
hubName: 'Banani Hub'
|
||||
},
|
||||
{
|
||||
id: 'RNT-003',
|
||||
bikeId: 'BIKE-003',
|
||||
userId: 'USR-003',
|
||||
type: 'rent-to-own',
|
||||
status: 'completed',
|
||||
startDate: '2023-06-01',
|
||||
endDate: '2023-12-01',
|
||||
deposit: 10000,
|
||||
dailyRate: 500,
|
||||
totalPaid: 150000,
|
||||
hubId: 'HUB-001',
|
||||
hubName: 'Gulshan Hub'
|
||||
}
|
||||
];
|
||||
|
||||
const mockBikes: Record<string, {
|
||||
id: string;
|
||||
model: string;
|
||||
plate: string;
|
||||
status: string;
|
||||
odometer: number;
|
||||
batteryHealth: number;
|
||||
images: BikeImage[];
|
||||
}> = {
|
||||
'BIKE-001': {
|
||||
id: 'BIKE-001',
|
||||
model: 'AIMA Lightning',
|
||||
plate: 'Dhaka Metro Cha-9012',
|
||||
status: 'active',
|
||||
odometer: 3510,
|
||||
batteryHealth: 85,
|
||||
images: [
|
||||
{ id: 'img1', type: 'front', url: '' },
|
||||
{ id: 'img2', type: 'back', url: '' },
|
||||
{ id: 'img3', type: 'left', url: '' },
|
||||
{ id: 'img4', type: 'right', url: '' }
|
||||
]
|
||||
},
|
||||
'BIKE-002': {
|
||||
id: 'BIKE-002',
|
||||
model: 'Yadea DT3',
|
||||
plate: 'Dhaka Metro Ba-5521',
|
||||
status: 'active',
|
||||
odometer: 2100,
|
||||
batteryHealth: 92,
|
||||
images: []
|
||||
}
|
||||
};
|
||||
|
||||
const mockUsers: Record<string, {
|
||||
id: string;
|
||||
name: string;
|
||||
phone: string;
|
||||
email: string;
|
||||
walletBalance: number;
|
||||
membership: string;
|
||||
joinedFrom: string;
|
||||
kycStatus: 'verified' | 'pending' | 'rejected';
|
||||
insurance: 'active' | 'expired' | 'none';
|
||||
insuranceExpiry?: string;
|
||||
}> = {
|
||||
'USR-001': {
|
||||
id: 'USR-001',
|
||||
name: 'Rahim Ahmed',
|
||||
phone: '+8801712345678',
|
||||
email: 'rahim@example.com',
|
||||
walletBalance: 2100,
|
||||
membership: 'vip',
|
||||
joinedFrom: 'Facebook',
|
||||
kycStatus: 'verified',
|
||||
insurance: 'active',
|
||||
insuranceExpiry: '2024-12-01'
|
||||
},
|
||||
'USR-002': {
|
||||
id: 'USR-002',
|
||||
name: 'Karim Hasan',
|
||||
phone: '+8801812345678',
|
||||
email: 'karim@example.com',
|
||||
walletBalance: 500,
|
||||
membership: 'standard',
|
||||
joinedFrom: 'Referral',
|
||||
kycStatus: 'pending',
|
||||
insurance: 'none'
|
||||
}
|
||||
};
|
||||
|
||||
const mockHubs = [
|
||||
{ id: 'HUB-001', name: 'Gulshan Hub', address: 'Gulshan 1, Dhaka' },
|
||||
{ id: 'HUB-002', name: 'Banani Hub', address: 'Banani, Dhaka' },
|
||||
{ id: 'HUB-003', name: 'Uttara Hub', address: 'Uttara, Dhaka' }
|
||||
];
|
||||
|
||||
export default function RentalDetailPage() {
|
||||
const params = useParams();
|
||||
const router = useRouter();
|
||||
const id = params.id as string;
|
||||
|
||||
const [rental, setRental] = useState<Rental | null>(null);
|
||||
const [user, setUser] = useState<typeof mockUsers['USR-001'] | null>(null);
|
||||
const [bike, setBike] = useState<typeof mockBikes['BIKE-001'] | null>(null);
|
||||
const [editMode, setEditMode] = useState(false);
|
||||
const [notes, setNotes] = useState<Note[]>([
|
||||
{ id: 'n1', text: 'Initial rental started. Bike in good condition.', createdAt: '2024-01-15' },
|
||||
{ id: 'n2', text: 'Battery replaced on 2024-01-20.', createdAt: '2024-01-20' }
|
||||
]);
|
||||
const [newNote, setNewNote] = useState('');
|
||||
const [editForm, setEditForm] = useState<Partial<Rental>>({});
|
||||
const [showLockModal, setShowLockModal] = useState(false);
|
||||
const [lockReason, setLockReason] = useState('');
|
||||
const [dueAmount, setDueAmount] = useState(0);
|
||||
const [showDueModal, setShowDueModal] = useState(false);
|
||||
const [showImageModal, setShowImageModal] = useState(false);
|
||||
const [uploadImageType, setUploadImageType] = useState<string>('');
|
||||
|
||||
useEffect(() => {
|
||||
const found = mockRentals.find(r => r.id === id);
|
||||
if (found) {
|
||||
setRental(found);
|
||||
setEditForm(found);
|
||||
setUser(mockUsers[found.userId as keyof typeof mockUsers] || null);
|
||||
setBike(mockBikes[found.bikeId as keyof typeof mockBikes] || mockBikes['BIKE-001']);
|
||||
}
|
||||
}, [id]);
|
||||
|
||||
if (!rental) {
|
||||
return (
|
||||
<div className="p-6 flex items-center justify-center min-h-[50vh]">
|
||||
<div className="text-center">
|
||||
<Bike className="w-16 h-16 text-slate-300 mx-auto mb-4" />
|
||||
<p className="text-slate-500">Rental not found</p>
|
||||
<button
|
||||
onClick={() => router.push('/admin/rentals')}
|
||||
className="mt-4 px-4 py-2 bg-accent text-white rounded-lg text-sm"
|
||||
>
|
||||
Back to Rentals
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const handleSaveEdit = () => {
|
||||
setRental(prev => prev ? { ...prev, ...editForm } : null);
|
||||
setEditMode(false);
|
||||
};
|
||||
|
||||
const handleLockRental = () => {
|
||||
if (!lockReason.trim()) return;
|
||||
setRental(prev => prev ? { ...prev, status: 'locked', lockedAt: new Date().toISOString(), lockedReason: lockReason } : null);
|
||||
setShowLockModal(false);
|
||||
setLockReason('');
|
||||
};
|
||||
|
||||
const handleUnlockRental = () => {
|
||||
setRental(prev => prev ? { ...prev, status: 'active', lockedAt: undefined, lockedReason: undefined } : null);
|
||||
};
|
||||
|
||||
const handleCancelRental = () => {
|
||||
if (confirm('Are you sure you want to cancel this rental? This action cannot be undone.')) {
|
||||
setRental(prev => prev ? { ...prev, status: 'cancelled', endDate: new Date().toISOString() } : null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddDue = () => {
|
||||
setRental(prev => prev ? { ...prev, dueRental: (prev.dueRental || 0) + dueAmount } : null);
|
||||
setShowDueModal(false);
|
||||
setDueAmount(0);
|
||||
};
|
||||
|
||||
const handleAddNote = () => {
|
||||
if (!newNote.trim()) return;
|
||||
setNotes(prev => [...prev, { id: `n${Date.now()}`, text: newNote, createdAt: new Date().toISOString().split('T')[0] }]);
|
||||
setNewNote('');
|
||||
};
|
||||
|
||||
const handleUpdateOdometer = (value: number) => {
|
||||
if (bike) setBike(prev => prev ? { ...prev, odometer: value } : null);
|
||||
};
|
||||
|
||||
const handleUpdateBattery = (value: number) => {
|
||||
if (bike) setBike(prev => prev ? { ...prev, batteryHealth: value } : null);
|
||||
};
|
||||
|
||||
const handleUploadImage = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file || !bike) return;
|
||||
const url = URL.createObjectURL(file);
|
||||
setBike(prev => prev ? {
|
||||
...prev,
|
||||
images: prev.images.map(img => img.type === uploadImageType ? { ...img, url } : img)
|
||||
} : null);
|
||||
setShowImageModal(false);
|
||||
};
|
||||
|
||||
const statusColors = {
|
||||
active: 'bg-green-100 text-green-700',
|
||||
pending: 'bg-amber-100 text-amber-700',
|
||||
completed: 'bg-blue-100 text-blue-700',
|
||||
disputed: 'bg-red-100 text-red-700',
|
||||
cancelled: 'bg-slate-100 text-slate-700',
|
||||
locked: 'bg-red-100 text-red-700'
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-4 lg:p-6 max-w-8xl mx-auto">
|
||||
<button
|
||||
onClick={() => router.push('/admin/rentals')}
|
||||
className="flex items-center gap-2 text-slate-600 hover:text-slate-800 mb-4"
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4" /> Back to Rentals
|
||||
</button>
|
||||
|
||||
<div className="bg-white rounded-xl shadow-sm border border-slate-100 overflow-hidden">
|
||||
<div className="p-6 border-b border-slate-100">
|
||||
<div className="flex flex-col lg:flex-row lg:items-start lg:justify-between gap-4">
|
||||
<div>
|
||||
<div className="flex items-center gap-3">
|
||||
<h1 className="text-2xl font-extrabold text-slate-800">{rental.id}</h1>
|
||||
<span className={`inline-flex items-center gap-1 text-xs font-medium px-2.5 py-1 rounded-full ${statusColors[rental.status]}`}>
|
||||
{rental.status}
|
||||
</span>
|
||||
{rental.status === 'locked' && (
|
||||
<span className="inline-flex items-center gap-1 text-xs font-medium px-2.5 py-1 rounded-full bg-red-100 text-red-700">
|
||||
<Lock className="w-3 h-3" /> Locked
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-slate-500 mt-1">Started {rental.startDate} • From {rental.hubName || 'N/A'}</p>
|
||||
</div>
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
{editMode ? (
|
||||
<>
|
||||
<button onClick={handleSaveEdit} className="px-4 py-2 bg-green-600 text-white rounded-lg text-sm hover:bg-green-700 flex items-center gap-2">
|
||||
<Save className="w-4 h-4" /> Save
|
||||
</button>
|
||||
<button onClick={() => { setEditForm(rental); setEditMode(false); }} className="px-4 py-2 border border-slate-200 text-slate-600 rounded-lg text-sm hover:bg-slate-50">
|
||||
Cancel
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<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>
|
||||
{rental.status === 'active' && (
|
||||
<>
|
||||
<button onClick={() => setShowDueModal(true)} className="px-4 py-2 bg-amber-600 text-white rounded-lg text-sm hover:bg-amber-700 flex items-center gap-2">
|
||||
<DollarSign className="w-4 h-4" /> Add Due
|
||||
</button>
|
||||
{rental.status === 'active' ? (
|
||||
<button onClick={() => setShowLockModal(true)} className="px-4 py-2 bg-red-600 text-white rounded-lg text-sm hover:bg-red-700 flex items-center gap-2">
|
||||
<Lock className="w-4 h-4" /> Lock Rental
|
||||
</button>
|
||||
) : rental.status === 'locked' ? (
|
||||
<button onClick={handleUnlockRental} className="px-4 py-2 bg-green-600 text-white rounded-lg text-sm hover:bg-green-700 flex items-center gap-2">
|
||||
<Unlock className="w-4 h-4" /> Unlock Rental
|
||||
</button>
|
||||
) : null}
|
||||
</>
|
||||
)}
|
||||
{rental.status !== 'cancelled' && rental.status !== 'completed' && (
|
||||
<button onClick={handleCancelRental} className="px-4 py-2 bg-slate-600 text-white rounded-lg text-sm hover:bg-slate-700 flex items-center gap-2">
|
||||
<XCircle className="w-4 h-4" /> Cancel Rental
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="p-6 grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
<div className="lg:col-span-2 space-y-6">
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<div className="bg-blue-50 p-4 rounded-xl border border-blue-100">
|
||||
<p className="text-sm text-blue-600">Total Spent</p>
|
||||
<p className="text-xl font-bold text-blue-800">৳{rental.totalPaid.toLocaleString()}</p>
|
||||
</div>
|
||||
<div className="bg-green-50 p-4 rounded-xl border border-green-100">
|
||||
<p className="text-sm text-green-600">Wallet Balance</p>
|
||||
<p className="text-xl font-bold text-green-800">৳{user?.walletBalance.toLocaleString() || 0}</p>
|
||||
</div>
|
||||
<div className="bg-purple-50 p-4 rounded-xl border border-purple-100">
|
||||
<p className="text-sm text-purple-600">Deposit Paid</p>
|
||||
<p className="text-xl font-bold text-purple-800">৳{rental.deposit.toLocaleString()}</p>
|
||||
</div>
|
||||
<div className="bg-amber-50 p-4 rounded-xl border border-amber-100">
|
||||
<p className="text-sm text-amber-600">Due Rental</p>
|
||||
<p className="text-xl font-bold text-amber-800">৳{(rental.dueRental || 0).toLocaleString()}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-blue-50 p-4 rounded-xl border border-blue-100">
|
||||
<h3 className="font-semibold text-blue-800 mb-3 flex items-center gap-2">
|
||||
<Bike className="w-5 h-5" /> Rented Bike Details
|
||||
</h3>
|
||||
{bike && (
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between"><span className="text-sm text-blue-600">Model</span><span className="text-sm font-medium text-blue-800">{bike.model}</span></div>
|
||||
<div className="flex justify-between"><span className="text-sm text-blue-600">Plate</span><span className="text-sm font-medium text-blue-800">{bike.plate}</span></div>
|
||||
<div className="flex justify-between"><span className="text-sm text-blue-600">Type</span><span className="text-sm font-medium text-blue-800 capitalize">{rental.type}</span></div>
|
||||
<div className="flex justify-between"><span className="text-sm text-blue-600">Daily Rate</span><span className="text-sm font-medium text-blue-800">৳{rental.dailyRate}/day</span></div>
|
||||
</div>
|
||||
</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">
|
||||
<Gauge className="w-5 h-5" /> Mileage Tracking
|
||||
</h3>
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="text-sm text-amber-600">Current Odometer (km)</label>
|
||||
<input
|
||||
type="number"
|
||||
value={bike?.odometer || 0}
|
||||
onChange={(e) => handleUpdateOdometer(Number(e.target.value))}
|
||||
className="w-full px-3 py-2 border border-amber-200 rounded-lg text-sm mt-1"
|
||||
disabled={rental.status !== 'active'}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm text-amber-600">Total Distance</label>
|
||||
<p className="text-lg font-semibold text-amber-800">{(bike?.odometer || 0).toLocaleString()} km</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-green-50 p-4 rounded-xl border border-green-100">
|
||||
<h3 className="font-semibold text-green-800 mb-3 flex items-center gap-2">
|
||||
<Battery className="w-5 h-5" /> Battery Health
|
||||
</h3>
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<label className="text-sm text-green-600">Battery Percentage (0-100%)</label>
|
||||
<input
|
||||
type="number"
|
||||
max={100}
|
||||
min={0}
|
||||
value={bike?.batteryHealth || 0}
|
||||
onChange={(e) => handleUpdateBattery(Number(e.target.value))}
|
||||
className="w-full px-3 py-2 border border-green-200 rounded-lg text-sm mt-1"
|
||||
disabled={rental.status !== 'active'}
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm text-green-600">Health Status</label>
|
||||
<p className={`text-lg font-semibold ${(bike?.batteryHealth || 0) > 70 ? 'text-green-700' : (bike?.batteryHealth || 0) > 40 ? 'text-amber-700' : 'text-red-700'}`}>
|
||||
{(bike?.batteryHealth || 0) > 70 ? 'Good' : (bike?.batteryHealth || 0) > 40 ? 'Fair' : 'Poor'}
|
||||
</p>
|
||||
<p className="text-xs text-green-600">Estimated Range: {Math.round((bike?.batteryHealth || 0) * 1)} km</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-purple-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">
|
||||
<Image className="w-5 h-5" /> Bike Images
|
||||
</h3>
|
||||
{rental.status === 'active' && (
|
||||
<button
|
||||
onClick={() => setShowImageModal(true)}
|
||||
className="text-xs px-2 py-1 bg-white rounded border border-purple-200 text-purple-700 hover:bg-purple-100 flex items-center gap-1"
|
||||
>
|
||||
<Upload className="w-3 h-3" /> Upload
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div className="grid grid-cols-4 gap-2">
|
||||
{['front', 'back', 'left', 'right'].map(type => {
|
||||
const img = bike?.images.find(i => i.type === type);
|
||||
return (
|
||||
<div key={type} className="aspect-video bg-white rounded-lg border border-purple-200 flex items-center justify-center overflow-hidden">
|
||||
{img?.url ? (
|
||||
<img src={img.url} alt={type} className="w-full h-full object-cover" />
|
||||
) : (
|
||||
<div className="text-center p-2">
|
||||
<Image className="w-8 h-8 text-purple-300 mx-auto" />
|
||||
<p className="text-xs text-purple-500 capitalize mt-1">{type}</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</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">
|
||||
<MessageSquare className="w-5 h-5" /> Notes ({notes.length})
|
||||
</h3>
|
||||
{notes.length > 0 ? (
|
||||
<div className="space-y-2 mb-3">
|
||||
{notes.map(note => (
|
||||
<div key={note.id} className="bg-white p-3 rounded-lg">
|
||||
<p className="text-sm text-slate-700">{note.text}</p>
|
||||
<p className="text-xs text-slate-400 mt-1">{note.createdAt}</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<p className="text-sm text-slate-500 mb-3">No notes yet.</p>
|
||||
)}
|
||||
{rental.status === 'active' && (
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={newNote}
|
||||
onChange={(e) => setNewNote(e.target.value)}
|
||||
placeholder="Add notes about the bike condition, issues, etc..."
|
||||
className="flex-1 px-3 py-2 border border-slate-200 rounded-lg text-sm"
|
||||
/>
|
||||
<button onClick={handleAddNote} disabled={!newNote.trim()} className="px-4 py-2 bg-accent text-white rounded-lg text-sm disabled:opacity-50">
|
||||
<Plus className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-4">
|
||||
<div className="bg-blue-50 p-4 rounded-xl border border-blue-100">
|
||||
<h3 className="font-semibold text-blue-800 mb-3 flex items-center gap-2">
|
||||
<User className="w-5 h-5" /> User Info
|
||||
</h3>
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between"><span className="text-sm text-blue-600">Name</span><span className="text-sm font-medium text-blue-800">{user?.name}</span></div>
|
||||
<div className="flex justify-between"><span className="text-sm text-blue-600">Phone</span><span className="text-sm font-medium text-blue-800">{user?.phone}</span></div>
|
||||
<div className="flex justify-between"><span className="text-sm text-blue-600">Email</span><span className="text-sm font-medium text-blue-800">{user?.email || '-'}</span></div>
|
||||
{user && (
|
||||
<div className="flex gap-2 mt-2 pt-2 border-t border-blue-100">
|
||||
<a href={`tel:${user.phone}`} className="flex-1 py-2 bg-green-500 text-white rounded-lg text-sm text-center hover:bg-green-600 flex items-center justify-center gap-2">
|
||||
<Phone className="w-4 h-4" /> Call
|
||||
</a>
|
||||
<a href={`sms:${user.phone}`} className="flex-1 py-2 bg-blue-500 text-white rounded-lg text-sm text-center hover:bg-blue-600 flex items-center justify-center gap-2">
|
||||
<MessageCircle className="w-4 h-4" /> Message
|
||||
</a>
|
||||
</div>
|
||||
)}
|
||||
</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">
|
||||
<Shield className="w-5 h-5" /> Membership & Insurance
|
||||
</h3>
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between"><span className="text-sm text-purple-600">Membership</span><span className="text-sm font-medium text-purple-800 uppercase">{user?.membership}</span></div>
|
||||
<div className="flex justify-between"><span className="text-sm text-purple-600">KYC Status</span><span className="text-sm font-medium text-purple-800 capitalize">{user?.kycStatus}</span></div>
|
||||
<div className="flex justify-between"><span className="text-sm text-purple-600">Insurance</span><span className="text-sm font-medium text-purple-800 capitalize">{user?.insurance}</span></div>
|
||||
{user?.insuranceExpiry && <div className="flex justify-between"><span className="text-sm text-purple-600">Insurance Expiry</span><span className="text-sm font-medium text-purple-800">{user.insuranceExpiry}</span></div>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-green-50 p-4 rounded-xl border border-green-100">
|
||||
<h3 className="font-semibold text-green-800 mb-3 flex items-center gap-2">
|
||||
<MapPin className="w-5 h-5" /> Hub Info
|
||||
</h3>
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between"><span className="text-sm text-green-600">Hub</span><span className="text-sm font-medium text-green-800">{rental.hubName || '-'}</span></div>
|
||||
{editMode ? (
|
||||
<select
|
||||
value={editForm.hubId || ''}
|
||||
onChange={(e) => {
|
||||
const hub = mockHubs.find(h => h.id === e.target.value);
|
||||
setEditForm({ ...editForm, hubId: e.target.value, hubName: hub?.name });
|
||||
}}
|
||||
className="w-full px-3 py-2 border border-green-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 className="flex justify-between"><span className="text-sm text-green-600">Joined From</span><span className="text-sm font-medium text-green-800">{user?.joinedFrom || '-'}</span></div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{rental.status === 'locked' && (
|
||||
<div className="bg-red-50 p-4 rounded-xl border border-red-100">
|
||||
<h3 className="font-semibold text-red-800 mb-3 flex items-center gap-2">
|
||||
<Lock className="w-5 h-5" /> Locked Info
|
||||
</h3>
|
||||
<div className="space-y-2">
|
||||
<div className="flex justify-between"><span className="text-sm text-red-600">Locked At</span><span className="text-sm font-medium text-red-800">{rental.lockedAt?.split('T')[0]}</span></div>
|
||||
<div className="flex justify-between"><span className="text-sm text-red-600">Reason</span><span className="text-sm font-medium text-red-800">{rental.lockedReason}</span></div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showLockModal && (
|
||||
<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 flex items-center gap-2">
|
||||
<Lock className="w-5 h-5" /> Lock Rental
|
||||
</h3>
|
||||
<button onClick={() => setShowLockModal(false)} className="text-slate-400 hover:text-slate-600">×</button>
|
||||
</div>
|
||||
<div className="p-4">
|
||||
<label className="text-sm text-slate-600">Reason for locking</label>
|
||||
<textarea
|
||||
value={lockReason}
|
||||
onChange={(e) => setLockReason(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-slate-200 rounded-lg text-sm mt-1"
|
||||
rows={3}
|
||||
placeholder="Enter reason..."
|
||||
/>
|
||||
</div>
|
||||
<div className="p-4 border-t border-slate-100 flex justify-end gap-2">
|
||||
<button onClick={() => setShowLockModal(false)} className="px-4 py-2 border border-slate-200 text-slate-600 rounded-lg">Cancel</button>
|
||||
<button onClick={handleLockRental} disabled={!lockReason.trim()} className="px-4 py-2 bg-red-600 text-white rounded-lg disabled:opacity-50">Lock Rental</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showDueModal && (
|
||||
<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 flex items-center gap-2">
|
||||
<DollarSign className="w-5 h-5" /> Add Due Rental
|
||||
</h3>
|
||||
<button onClick={() => setShowDueModal(false)} className="text-slate-400 hover:text-slate-600">×</button>
|
||||
</div>
|
||||
<div className="p-4">
|
||||
<label className="text-sm text-slate-600">Due Amount (৳)</label>
|
||||
<input
|
||||
type="number"
|
||||
value={dueAmount}
|
||||
onChange={(e) => setDueAmount(Number(e.target.value))}
|
||||
className="w-full px-3 py-2 border border-slate-200 rounded-lg text-sm mt-1"
|
||||
placeholder="Enter amount..."
|
||||
/>
|
||||
<p className="text-xs text-slate-500 mt-2">Current Due: ৳{rental.dueRental || 0}</p>
|
||||
</div>
|
||||
<div className="p-4 border-t border-slate-100 flex justify-end gap-2">
|
||||
<button onClick={() => setShowDueModal(false)} className="px-4 py-2 border border-slate-200 text-slate-600 rounded-lg">Cancel</button>
|
||||
<button onClick={handleAddDue} disabled={dueAmount <= 0} className="px-4 py-2 bg-amber-600 text-white rounded-lg disabled:opacity-50">Add Due</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showImageModal && (
|
||||
<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">Upload Bike Image</h3>
|
||||
<button onClick={() => setShowImageModal(false)} className="text-slate-400 hover:text-slate-600">×</button>
|
||||
</div>
|
||||
<div className="p-4">
|
||||
<label className="text-sm text-slate-600">Select Image Type</label>
|
||||
<select
|
||||
value={uploadImageType}
|
||||
onChange={(e) => setUploadImageType(e.target.value)}
|
||||
className="w-full px-3 py-2 border border-slate-200 rounded-lg text-sm mt-1"
|
||||
>
|
||||
<option value="">Select type...</option>
|
||||
<option value="front">Front</option>
|
||||
<option value="back">Back</option>
|
||||
<option value="left">Left Side</option>
|
||||
<option value="right">Right Side</option>
|
||||
</select>
|
||||
{uploadImageType && (
|
||||
<div className="mt-4">
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
onChange={handleUploadImage}
|
||||
className="w-full px-3 py-2 border border-slate-200 rounded-lg text-sm file:mr-4 file:px-4 file:py-2 file:bg-accent file:text-white file:rounded-lg file:border-0 cursor-pointer"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="p-4 border-t border-slate-100 flex justify-end gap-2">
|
||||
<button onClick={() => setShowImageModal(false)} className="px-4 py-2 border border-slate-200 text-slate-600 rounded-lg">Cancel</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Gauge({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<path d="M12 2v4M12 18v4M4.93 4.93l2.83 2.83M16.24 16.24l2.83 2.83M2 12h4M18 12h4M4.93 19.07l2.83-2.83M16.24 7.76l2.83-2.83" />
|
||||
<circle cx="12" cy="12" r="4" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function Battery({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<rect x="1" y="6" width="18" height="12" rx="2" />
|
||||
<line x1="23" y1="10" x2="23" y2="14" />
|
||||
<line x1="7" y1="10" x2="7" y2="14" />
|
||||
<line x1="11" y1="10" x2="11" y2="14" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,270 @@
|
||||
import { FileText, Search, Filter, Bike, User, Calendar, DollarSign, Clock, MoreVertical, Eye } from 'lucide-react';
|
||||
import { rentals, bikes, users } from '@/data/mockData';
|
||||
'use client';
|
||||
|
||||
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 } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
|
||||
type RentalStatus = 'active' | 'pending' | 'completed' | 'disputed' | 'cancelled' | 'locked';
|
||||
type RentalType = 'single' | 'shared' | 'rent-to-own';
|
||||
|
||||
interface Rental {
|
||||
id: string;
|
||||
bikeId: string;
|
||||
userId: string;
|
||||
type: RentalType;
|
||||
status: RentalStatus;
|
||||
startDate: string;
|
||||
endDate?: string;
|
||||
contractMonths?: number;
|
||||
deposit: number;
|
||||
dailyRate: number;
|
||||
totalPaid: number;
|
||||
dueRental?: number;
|
||||
lockedAt?: string;
|
||||
lockedReason?: string;
|
||||
hubId?: string;
|
||||
hubName?: string;
|
||||
}
|
||||
|
||||
const mockBikes = [
|
||||
{ id: 'BIKE-001', model: 'AIMA Lightning', plate: 'Dhaka Metro Cha-9012', status: 'available' },
|
||||
{ id: 'BIKE-002', model: 'Yadea DT3', plate: 'Dhaka Metro Ba-5521', status: 'available' },
|
||||
{ id: 'BIKE-003', model: 'AIMA EM5', plate: 'Dhaka Metro Ko-1234', status: 'available' },
|
||||
{ id: 'BIKE-004', model: 'AIMA Lightning', plate: 'Dhaka Metro Cha-9013', status: 'rented' },
|
||||
{ id: 'BIKE-005', model: 'Yadea G5', plate: 'Dhaka Metro Ha-5678', status: 'available' },
|
||||
];
|
||||
|
||||
const mockUsers = [
|
||||
{ id: 'USR-001', name: 'Rahim Ahmed', phone: '+8801712345678', membership: 'vip' },
|
||||
{ id: 'USR-002', name: 'Karim Hasan', phone: '+8801812345678', membership: 'standard' },
|
||||
{ id: 'USR-003', name: 'Jamal Uddin', phone: '+8801912345678', membership: 'premium' },
|
||||
{ id: 'USR-004', name: 'Rafiq Islam', phone: '+8801512345678', membership: 'standard' },
|
||||
];
|
||||
|
||||
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 mockRentals: Rental[] = [
|
||||
{
|
||||
id: 'RNT-001',
|
||||
bikeId: 'BIKE-001',
|
||||
userId: 'USR-001',
|
||||
type: 'single',
|
||||
status: 'active',
|
||||
startDate: '2024-01-15',
|
||||
deposit: 5000,
|
||||
dailyRate: 300,
|
||||
totalPaid: 81900,
|
||||
dueRental: 0,
|
||||
hubId: 'HUB-001',
|
||||
hubName: 'Gulshan Hub'
|
||||
},
|
||||
{
|
||||
id: 'RNT-002',
|
||||
bikeId: 'BIKE-002',
|
||||
userId: 'USR-002',
|
||||
type: 'single',
|
||||
status: 'pending',
|
||||
startDate: '2024-02-10',
|
||||
deposit: 0,
|
||||
dailyRate: 150,
|
||||
totalPaid: 150,
|
||||
dueRental: 0,
|
||||
hubId: 'HUB-002',
|
||||
hubName: 'Banani Hub'
|
||||
},
|
||||
{
|
||||
id: 'RNT-003',
|
||||
bikeId: 'BIKE-003',
|
||||
userId: 'USR-003',
|
||||
type: 'rent-to-own',
|
||||
status: 'completed',
|
||||
startDate: '2023-06-01',
|
||||
endDate: '2023-12-01',
|
||||
deposit: 10000,
|
||||
dailyRate: 500,
|
||||
totalPaid: 150000,
|
||||
hubId: 'HUB-001',
|
||||
hubName: 'Gulshan Hub'
|
||||
}
|
||||
];
|
||||
|
||||
export default function RentalsPage() {
|
||||
const [rentals, setRentals] = useState<Rental[]>(mockRentals);
|
||||
const [showCreateModal, setShowCreateModal] = useState(false);
|
||||
const [statusFilter, setStatusFilter] = useState<string>('all');
|
||||
|
||||
const [newRental, setNewRental] = useState({
|
||||
bikeId: '',
|
||||
userId: '',
|
||||
type: 'single' as RentalType,
|
||||
startDate: new Date().toISOString().split('T')[0],
|
||||
contractMonths: 0,
|
||||
deposit: 0,
|
||||
dailyRate: 150,
|
||||
hubId: '',
|
||||
paymentMethod: 'cash' as 'cash' | 'bank' | 'biker_wallet',
|
||||
});
|
||||
|
||||
const [showJournalPreview, setShowJournalPreview] = useState(false);
|
||||
|
||||
const filteredRentals = rentals.filter(r => {
|
||||
if (statusFilter !== 'all' && r.status !== statusFilter) return false;
|
||||
return true;
|
||||
});
|
||||
|
||||
const stats = {
|
||||
active: rentals.filter(r => r.status === 'active').length,
|
||||
pending: rentals.filter(r => r.status === 'pending').length,
|
||||
completed: rentals.filter(r => r.status === 'completed').length,
|
||||
disputed: rentals.filter(r => r.status === 'disputed').length,
|
||||
};
|
||||
|
||||
const handleCreateRental = () => {
|
||||
if (!newRental.bikeId || !newRental.userId || !newRental.hubId) return;
|
||||
setShowJournalPreview(true);
|
||||
};
|
||||
|
||||
const confirmCreateRental = () => {
|
||||
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 rental: Rental = {
|
||||
id: `RNT-${String(rentals.length + 1).padStart(3, '0')}`,
|
||||
bikeId: newRental.bikeId,
|
||||
userId: newRental.userId,
|
||||
type: newRental.type,
|
||||
status: 'pending',
|
||||
startDate: newRental.startDate,
|
||||
endDate: newRental.contractMonths > 0
|
||||
? new Date(new Date(newRental.startDate).setMonth(new Date(newRental.startDate).getMonth() + newRental.contractMonths)).toISOString().split('T')[0]
|
||||
: undefined,
|
||||
contractMonths: newRental.contractMonths || undefined,
|
||||
deposit: newRental.deposit,
|
||||
dailyRate: newRental.dailyRate,
|
||||
totalPaid: newRental.deposit,
|
||||
dueRental: 0,
|
||||
hubId: newRental.hubId,
|
||||
hubName: hub?.name,
|
||||
};
|
||||
|
||||
setRentals([...rentals, rental]);
|
||||
setShowJournalPreview(false);
|
||||
setShowCreateModal(false);
|
||||
setNewRental({
|
||||
bikeId: '',
|
||||
userId: '',
|
||||
type: 'single',
|
||||
startDate: new Date().toISOString().split('T')[0],
|
||||
contractMonths: 0,
|
||||
deposit: 0,
|
||||
dailyRate: 150,
|
||||
hubId: '',
|
||||
paymentMethod: 'cash',
|
||||
});
|
||||
};
|
||||
|
||||
const generateInvoice = () => {
|
||||
if (newRental.deposit <= 0) return;
|
||||
|
||||
const rentalId = `RNT-${String(rentals.length + 1).padStart(3, '0')}`;
|
||||
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 date = new Date().toISOString().split('T')[0];
|
||||
const time = new Date().toLocaleTimeString('en-US', { hour: '2-digit', minute: '2-digit' });
|
||||
const contractMonths = newRental.contractMonths;
|
||||
const endDate = contractMonths > 0
|
||||
? new Date(new Date(newRental.startDate).setMonth(new Date(newRental.startDate).getMonth() + contractMonths)).toISOString().split('T')[0]
|
||||
: '';
|
||||
|
||||
import('jspdf').then((jsPDF) => {
|
||||
const doc = new jsPDF.default();
|
||||
|
||||
doc.setFillColor(6, 95, 70);
|
||||
doc.rect(0, 0, 220, 40, 'F');
|
||||
doc.setTextColor(255, 255, 255);
|
||||
doc.setFontSize(24);
|
||||
doc.text('JAIBEN Mobility Ltd', 20, 20);
|
||||
doc.setFontSize(12);
|
||||
doc.text('Payment Receipt', 20, 30);
|
||||
|
||||
doc.setTextColor(6, 95, 70);
|
||||
doc.setFontSize(18);
|
||||
doc.text('OFFICIAL RECEIPT', 120, 25);
|
||||
|
||||
doc.setTextColor(0, 0, 0);
|
||||
doc.setFontSize(10);
|
||||
doc.text(`Receipt No: ${rentalId}-DEP`, 20, 55);
|
||||
doc.text(`Date: ${date} ${time}`, 20, 62);
|
||||
|
||||
doc.line(20, 70, 190, 70);
|
||||
|
||||
doc.setFontSize(11);
|
||||
doc.text('Payment Details', 20, 82);
|
||||
doc.setFontSize(10);
|
||||
doc.text(`Rental ID: ${rentalId}`, 20, 90);
|
||||
doc.text(`Bike: ${bike?.model} (${bike?.plate})`, 20, 97);
|
||||
doc.text(`Customer: ${user?.name}`, 20, 104);
|
||||
doc.text(`Phone: ${user?.phone}`, 20, 111);
|
||||
doc.text(`Hub: ${hub?.name}`, 20, 118);
|
||||
doc.text(`Rental Type: ${newRental.type}`, 20, 125);
|
||||
doc.text(`Start Date: ${newRental.startDate}`, 20, 132);
|
||||
if (contractMonths > 0) {
|
||||
doc.text(`Contract: ${contractMonths} Months`, 20, 139);
|
||||
doc.text(`End Date: ${endDate}`, 20, 146);
|
||||
doc.text(`Daily Rate: ৳${newRental.dailyRate}/day`, 20, 156);
|
||||
} else {
|
||||
doc.text(`Daily Rate: ৳${newRental.dailyRate}/day`, 20, 139);
|
||||
}
|
||||
|
||||
doc.line(20, 165, 190, 165);
|
||||
|
||||
doc.setFontSize(12);
|
||||
doc.text('Amount Details', 20, 175);
|
||||
doc.setFillColor(240, 240, 240);
|
||||
doc.rect(20, 180, 170, 20, 'F');
|
||||
doc.setFontSize(10);
|
||||
doc.text('Deposit', 30, 192);
|
||||
doc.setFontSize(14);
|
||||
doc.setTextColor(6, 95, 70);
|
||||
doc.text(`৳${newRental.deposit}`, 150, 192);
|
||||
|
||||
doc.setTextColor(0, 0, 0);
|
||||
doc.setFontSize(10);
|
||||
doc.text(`Payment: ${newRental.paymentMethod === 'cash' ? 'Cash' : newRental.paymentMethod === 'bank' ? 'Bank' : 'Wallet'}`, 20, 210);
|
||||
|
||||
const qrData = `JAIBEN|${rentalId}|${date}|${user?.name}|${bike?.model}|${newRental.deposit}|${newRental.startDate}|${contractMonths}`;
|
||||
const publicUrl = `https://jaiben.app/rental/${rentalId}`;
|
||||
|
||||
doc.setFontSize(8);
|
||||
doc.setTextColor(100);
|
||||
doc.text('Generated from JAIBEN Rental System', 20, 250);
|
||||
doc.text(`QR: ${qrData.substring(0, 35)}...`, 20, 258);
|
||||
doc.setTextColor(6, 95, 70);
|
||||
doc.text(`URL: ${publicUrl}`, 20, 266);
|
||||
|
||||
doc.save(`deposit-receipt-${rentalId}.pdf`);
|
||||
});
|
||||
};
|
||||
|
||||
const getStatusBadge = (status: RentalStatus) => {
|
||||
const styles = {
|
||||
active: 'bg-green-100 text-green-700',
|
||||
pending: 'bg-amber-100 text-amber-700',
|
||||
completed: 'bg-blue-100 text-blue-700',
|
||||
disputed: 'bg-red-100 text-red-700',
|
||||
cancelled: 'bg-slate-100 text-slate-700',
|
||||
locked: 'bg-red-100 text-red-700',
|
||||
};
|
||||
return styles[status];
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-4 lg:p-6">
|
||||
<div className="flex flex-col lg:flex-row lg:items-center lg:justify-between gap-4 mb-6">
|
||||
@@ -10,25 +273,48 @@ export default function RentalsPage() {
|
||||
<p className="text-sm text-slate-500 mt-1">View and manage all rental transactions</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<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 gap-2">
|
||||
<Filter className="w-4 h-4" /> Filter
|
||||
<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 hover:bg-slate-50"
|
||||
>
|
||||
<option value="all">All Status</option>
|
||||
<option value="active">Active</option>
|
||||
<option value="pending">Pending</option>
|
||||
<option value="completed">Completed</option>
|
||||
<option value="disputed">Disputed</option>
|
||||
<option value="locked">Locked</option>
|
||||
<option value="cancelled">Cancelled</option>
|
||||
</select>
|
||||
<button
|
||||
onClick={() => setShowCreateModal(true)}
|
||||
className="py-2 px-4 bg-accent text-white rounded-lg text-sm font-semibold hover:bg-accent-dark flex items-center gap-2"
|
||||
>
|
||||
<Plus className="w-4 h-4" /> New Rental
|
||||
</button>
|
||||
<button className="py-2 px-4 bg-accent text-white rounded-lg text-sm font-semibold hover:bg-accent-dark">
|
||||
<button className="py-2 px-4 border border-slate-200 rounded-lg text-sm font-medium text-slate-600 hover:bg-slate-50">
|
||||
Export
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-4 gap-4 mb-6">
|
||||
{['Active', 'Pending', 'Completed', 'Disputed'].map(status => {
|
||||
const count = rentals.filter(r => r.status === status.toLowerCase()).length;
|
||||
return (
|
||||
<div key={status} className="bg-white rounded-xl p-4 shadow-sm border border-slate-100">
|
||||
<p className="text-2xl font-extrabold text-slate-800">{count}</p>
|
||||
<p className="text-sm text-slate-500">{status} Rentals</p>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
|
||||
<div className="bg-white rounded-xl p-4 shadow-sm border border-slate-100">
|
||||
<p className="text-2xl font-extrabold text-green-600">{stats.active}</p>
|
||||
<p className="text-sm text-slate-500">Active Rentals</p>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl p-4 shadow-sm border border-slate-100">
|
||||
<p className="text-2xl font-extrabold text-amber-600">{stats.pending}</p>
|
||||
<p className="text-sm text-slate-500">Pending</p>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl p-4 shadow-sm border border-slate-100">
|
||||
<p className="text-2xl font-extrabold text-blue-600">{stats.completed}</p>
|
||||
<p className="text-sm text-slate-500">Completed</p>
|
||||
</div>
|
||||
<div className="bg-white rounded-xl p-4 shadow-sm border border-slate-100">
|
||||
<p className="text-2xl font-extrabold text-red-600">{stats.disputed}</p>
|
||||
<p className="text-sm text-slate-500">Disputed</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-xl shadow-sm border border-slate-100 overflow-hidden">
|
||||
@@ -41,15 +327,18 @@ export default function RentalsPage() {
|
||||
<th className="px-4 py-3 text-left text-xs font-semibold text-slate-500 uppercase tracking-wider">User</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-semibold text-slate-500 uppercase tracking-wider">Type</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-semibold text-slate-500 uppercase tracking-wider">Start Date</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-semibold text-slate-500 uppercase tracking-wider">Hub</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-semibold text-slate-500 uppercase tracking-wider">Deposit</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-semibold text-slate-500 uppercase tracking-wider">Daily</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-semibold text-slate-500 uppercase tracking-wider">Total Paid</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-semibold text-slate-500 uppercase tracking-wider">Status</th>
|
||||
<th className="px-4 py-3 text-left text-xs font-semibold text-slate-500 uppercase tracking-wider">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-50">
|
||||
{rentals.map(rental => {
|
||||
const bike = bikes.find(b => b.id === rental.bikeId);
|
||||
{filteredRentals.map(rental => {
|
||||
const bike = mockBikes.find(b => b.id === rental.bikeId);
|
||||
const user = mockUsers.find(u => u.id === rental.userId);
|
||||
return (
|
||||
<tr key={rental.id} className="hover:bg-slate-50 transition-colors">
|
||||
<td className="px-4 py-3">
|
||||
@@ -58,13 +347,19 @@ export default function RentalsPage() {
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<Bike className="w-4 h-4 text-slate-400" />
|
||||
<span className="text-sm text-slate-600">{bike?.model}</span>
|
||||
<div>
|
||||
<span className="text-sm text-slate-600">{bike?.model || rental.bikeId}</span>
|
||||
<p className="text-xs text-slate-400">{bike?.plate}</p>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<User className="w-4 h-4 text-slate-400" />
|
||||
<span className="text-sm text-slate-600">{rental.userId}</span>
|
||||
<div>
|
||||
<span className="text-sm text-slate-600">{user?.name || rental.userId}</span>
|
||||
<p className="text-xs text-slate-400">{user?.phone}</p>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
@@ -75,26 +370,39 @@ export default function RentalsPage() {
|
||||
<Calendar className="w-3 h-3" /> {rental.startDate}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className="text-sm text-slate-600">{rental.hubName || '-'}</span>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className="text-sm font-medium text-slate-700">৳{rental.deposit}</span>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className="text-sm font-semibold text-green-600">৳{rental.totalPaid}</span>
|
||||
<span className="text-sm text-slate-600">৳{rental.dailyRate}/d</span>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className={`inline-flex items-center gap-1 text-xs font-medium px-2.5 py-1 rounded-full ${
|
||||
rental.status === 'active' ? 'bg-green-100 text-green-700' :
|
||||
rental.status === 'pending' ? 'bg-amber-100 text-amber-700' :
|
||||
rental.status === 'completed' ? 'bg-blue-100 text-blue-700' :
|
||||
'bg-red-100 text-red-700'
|
||||
}`}>
|
||||
<span className="text-sm font-semibold text-green-600">৳{rental.totalPaid.toLocaleString()}</span>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<span className={`inline-flex items-center gap-1 text-xs font-medium px-2.5 py-1 rounded-full ${getStatusBadge(rental.status)}`}>
|
||||
{rental.status}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<button className="p-2 hover:bg-slate-100 rounded-lg">
|
||||
<Eye className="w-4 h-4 text-slate-400" />
|
||||
</button>
|
||||
<div className="flex items-center gap-1">
|
||||
<Link href={`/admin/rentals/${rental.id}`} className="p-1.5 hover:bg-slate-100 rounded-lg" title="View">
|
||||
<Eye className="w-4 h-4 text-slate-500" />
|
||||
</Link>
|
||||
{user && (
|
||||
<>
|
||||
<a href={`tel:${user.phone}`} className="p-1.5 hover:bg-green-100 rounded-lg" title="Call">
|
||||
<Phone className="w-4 h-4 text-green-500" />
|
||||
</a>
|
||||
<a href={`sms:${user.phone}`} className="p-1.5 hover:bg-blue-100 rounded-lg" title="Message">
|
||||
<MessageCircle className="w-4 h-4 text-blue-500" />
|
||||
</a>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
@@ -103,6 +411,281 @@ export default function RentalsPage() {
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showCreateModal && (
|
||||
<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">Create New Rental</h3>
|
||||
<button onClick={() => setShowCreateModal(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">Select User</label>
|
||||
<select
|
||||
value={newRental.userId}
|
||||
onChange={(e) => setNewRental({ ...newRental, userId: e.target.value })}
|
||||
className="w-full px-3 py-2 border border-slate-200 rounded-lg text-sm mt-1"
|
||||
>
|
||||
<option value="">Select User...</option>
|
||||
{mockUsers.map(user => (
|
||||
<option key={user.id} value={user.id}>{user.name} ({user.phone})</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm text-slate-600">Select Bike</label>
|
||||
<select
|
||||
value={newRental.bikeId}
|
||||
onChange={(e) => setNewRental({ ...newRental, bikeId: e.target.value })}
|
||||
className="w-full px-3 py-2 border border-slate-200 rounded-lg text-sm mt-1"
|
||||
>
|
||||
<option value="">Select Bike...</option>
|
||||
{mockBikes.filter(b => b.status === 'available').map(bike => (
|
||||
<option key={bike.id} value={bike.id}>{bike.model} - {bike.plate}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm text-slate-600">Rental Type</label>
|
||||
<select
|
||||
value={newRental.type}
|
||||
onChange={(e) => setNewRental({ ...newRental, type: e.target.value as RentalType })}
|
||||
className="w-full px-3 py-2 border border-slate-200 rounded-lg text-sm mt-1"
|
||||
>
|
||||
<option value="single">Single</option>
|
||||
<option value="shared">Shared</option>
|
||||
<option value="rent-to-own">Rent to Own</option>
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm text-slate-600">Start Date</label>
|
||||
<input
|
||||
type="date"
|
||||
value={newRental.startDate}
|
||||
onChange={(e) => setNewRental({ ...newRental, startDate: e.target.value })}
|
||||
className="w-full px-3 py-2 border border-slate-200 rounded-lg text-sm mt-1"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm text-slate-600">Contract Months</label>
|
||||
<select
|
||||
value={newRental.contractMonths}
|
||||
onChange={(e) => setNewRental({ ...newRental, contractMonths: Number(e.target.value) })}
|
||||
className="w-full px-3 py-2 border border-slate-200 rounded-lg text-sm mt-1"
|
||||
>
|
||||
<option value={0}>Daily Basis</option>
|
||||
<option value={3}>3 Months</option>
|
||||
<option value={6}>6 Months</option>
|
||||
<option value={12}>12 Months</option>
|
||||
<option value={18}>18 Months</option>
|
||||
<option value={24}>24 Months</option>
|
||||
</select>
|
||||
{newRental.contractMonths > 0 && (
|
||||
<p className="text-xs text-green-600 mt-1">
|
||||
End Date: {new Date(new Date(newRental.startDate).setMonth(new Date(newRental.startDate).getMonth() + newRental.contractMonths)).toISOString().split('T')[0]}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm text-slate-600">Daily Rate (৳)</label>
|
||||
<input
|
||||
type="number"
|
||||
value={newRental.dailyRate}
|
||||
onChange={(e) => setNewRental({ ...newRental, dailyRate: Number(e.target.value) })}
|
||||
className="w-full px-3 py-2 border border-slate-200 rounded-lg text-sm mt-1"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm text-slate-600">Deposit (৳)</label>
|
||||
<input
|
||||
type="number"
|
||||
value={newRental.deposit}
|
||||
onChange={(e) => setNewRental({ ...newRental, deposit: Number(e.target.value) })}
|
||||
className="w-full px-3 py-2 border border-slate-200 rounded-lg text-sm mt-1"
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<label className="text-sm text-slate-600">Hub</label>
|
||||
<select
|
||||
value={newRental.hubId}
|
||||
onChange={(e) => setNewRental({ ...newRental, hubId: e.target.value })}
|
||||
className="w-full px-3 py-2 border border-slate-200 rounded-lg text-sm mt-1"
|
||||
>
|
||||
<option value="">Select Hub...</option>
|
||||
{mockHubs.map(hub => (
|
||||
<option key={hub.id} value={hub.id}>{hub.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
{newRental.deposit > 0 && (
|
||||
<div>
|
||||
<label className="text-sm text-slate-600">Deposit Payment Method</label>
|
||||
<div className="flex gap-2 mt-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setNewRental({ ...newRental, paymentMethod: 'cash' })}
|
||||
className={`flex-1 py-2 px-3 rounded-lg text-sm flex items-center justify-center gap-2 border ${
|
||||
newRental.paymentMethod === 'cash'
|
||||
? 'bg-green-100 border-green-300 text-green-700'
|
||||
: 'border-slate-200 text-slate-600 hover:bg-slate-50'
|
||||
}`}
|
||||
>
|
||||
<Wallet className="w-4 h-4" /> Cash
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setNewRental({ ...newRental, paymentMethod: 'bank' })}
|
||||
className={`flex-1 py-2 px-3 rounded-lg text-sm flex items-center justify-center gap-2 border ${
|
||||
newRental.paymentMethod === 'bank'
|
||||
? 'bg-blue-100 border-blue-300 text-blue-700'
|
||||
: 'border-slate-200 text-slate-600 hover:bg-slate-50'
|
||||
}`}
|
||||
>
|
||||
<Building className="w-4 h-4" /> Bank
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setNewRental({ ...newRental, paymentMethod: 'biker_wallet' })}
|
||||
className={`flex-1 py-2 px-3 rounded-lg text-sm flex items-center justify-center gap-2 border ${
|
||||
newRental.paymentMethod === 'biker_wallet'
|
||||
? 'bg-purple-100 border-purple-300 text-purple-700'
|
||||
: 'border-slate-200 text-slate-600 hover:bg-slate-50'
|
||||
}`}
|
||||
>
|
||||
<CreditCard className="w-4 h-4" /> Wallet
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="p-4 border-t border-slate-100 flex justify-end gap-2">
|
||||
<button
|
||||
onClick={() => setShowCreateModal(false)}
|
||||
className="px-4 py-2 border border-slate-200 text-slate-600 rounded-lg text-sm"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleCreateRental}
|
||||
disabled={!newRental.bikeId || !newRental.userId || !newRental.hubId}
|
||||
className="px-4 py-2 bg-accent text-white rounded-lg text-sm disabled:opacity-50"
|
||||
>
|
||||
Review & Create
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showJournalPreview && (
|
||||
<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">Journal Entry Preview</h3>
|
||||
<p className="text-sm text-slate-500">Auto-generated journal for rental deposit</p>
|
||||
</div>
|
||||
<div className="p-4 space-y-4">
|
||||
<div className="bg-slate-50 p-3 rounded-lg">
|
||||
<p className="text-sm font-medium text-slate-700 mb-2">Rental: {newRental.type.toUpperCase()}</p>
|
||||
<p className="text-xs text-slate-500">Date: {new Date().toISOString().split('T')[0]}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="text-sm font-medium text-slate-700 mb-2">Journal Entries</p>
|
||||
<table className="w-full text-sm">
|
||||
<thead className="bg-slate-100">
|
||||
<tr>
|
||||
<th className="px-2 py-1 text-left">Account</th>
|
||||
<th className="px-2 py-1 text-right">Debit (৳)</th>
|
||||
<th className="px-2 py-1 text-right">Credit (৳)</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y">
|
||||
{newRental.deposit > 0 && (
|
||||
<>
|
||||
<tr>
|
||||
<td className="px-2 py-2">
|
||||
{newRental.paymentMethod === 'cash' && '1000 - Cash'}
|
||||
{newRental.paymentMethod === 'bank' && '1100 - Bank'}
|
||||
{newRental.paymentMethod === 'biker_wallet' && '1200 - Biker Wallet'}
|
||||
</td>
|
||||
<td className="px-2 py-2 text-right font-medium">{newRental.deposit}</td>
|
||||
<td className="px-2 py-2 text-right">-</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="px-2 py-2">2100 - Deposit Received</td>
|
||||
<td className="px-2 py-2 text-right">-</td>
|
||||
<td className="px-2 py-2 text-right font-medium">{newRental.deposit}</td>
|
||||
</tr>
|
||||
</>
|
||||
)}
|
||||
{newRental.deposit === 0 && (
|
||||
<tr>
|
||||
<td className="px-2 py-2 text-center text-slate-500" colSpan={3}>
|
||||
No deposit amount - Journal not required
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
{newRental.deposit > 0 && (
|
||||
<tfoot className="bg-slate-100">
|
||||
<tr>
|
||||
<td className="px-2 py-1 font-medium">Total</td>
|
||||
<td className="px-2 py-1 text-right font-medium">{newRental.deposit}</td>
|
||||
<td className="px-2 py-1 text-right font-medium">{newRental.deposit}</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
)}
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<div className="bg-blue-50 p-3 rounded-lg">
|
||||
<p className="text-sm text-blue-700">
|
||||
<strong>Bike:</strong> {mockBikes.find(b => b.id === newRental.bikeId)?.model} {mockBikes.find(b => b.id === newRental.bikeId)?.plate}
|
||||
</p>
|
||||
<p className="text-sm text-blue-700">
|
||||
<strong>User:</strong> {mockUsers.find(u => u.id === newRental.userId)?.name} ({mockUsers.find(u => u.id === newRental.userId)?.phone})
|
||||
</p>
|
||||
<p className="text-sm text-blue-700">
|
||||
<strong>Hub:</strong> {mockHubs.find(h => h.id === newRental.hubId)?.name}
|
||||
</p>
|
||||
<p className="text-sm text-blue-700">
|
||||
<strong>Type:</strong> {newRental.type} | <strong>Contract:</strong> {newRental.contractMonths ? `${newRental.contractMonths} Months` : 'Daily Basis'}
|
||||
</p>
|
||||
<p className="text-sm text-blue-700">
|
||||
<strong>Daily Rate:</strong> ৳{newRental.dailyRate}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-4 border-t border-slate-100 flex justify-between">
|
||||
<button
|
||||
onClick={generateInvoice}
|
||||
disabled={newRental.deposit <= 0}
|
||||
className="px-4 py-2 bg-blue-600 text-white rounded-lg text-sm hover:bg-blue-700 disabled:opacity-50 flex items-center gap-2"
|
||||
>
|
||||
<Printer className="w-4 h-4" /> Print Invoice
|
||||
</button>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={() => setShowJournalPreview(false)}
|
||||
className="px-4 py-2 border border-slate-200 text-slate-600 rounded-lg text-sm"
|
||||
>
|
||||
Back
|
||||
</button>
|
||||
<button
|
||||
onClick={confirmCreateRental}
|
||||
className="px-4 py-2 bg-green-600 text-white rounded-lg text-sm hover:bg-green-700"
|
||||
>
|
||||
Confirm & Create
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user