feat: implement comprehensive admin CRUD interfaces for swap stations, users, merchants, and roles with sidebar navigation updates
This commit is contained in:
@@ -140,6 +140,13 @@ const statusColors: Record<string, string> = {
|
|||||||
blocked: 'bg-red-100 text-red-700',
|
blocked: 'bg-red-100 text-red-700',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const statusLabels: Record<string, string> = {
|
||||||
|
active: 'Active',
|
||||||
|
pending: 'Pending',
|
||||||
|
inactive: 'Inactive',
|
||||||
|
blocked: 'Blocked',
|
||||||
|
};
|
||||||
|
|
||||||
const kycColors: Record<string, string> = {
|
const kycColors: Record<string, string> = {
|
||||||
verified: 'bg-green-100 text-green-700',
|
verified: 'bg-green-100 text-green-700',
|
||||||
pending: 'bg-amber-100 text-amber-700',
|
pending: 'bg-amber-100 text-amber-700',
|
||||||
@@ -228,7 +235,7 @@ export default function BikerDetailPage({ params }: PageProps) {
|
|||||||
{biker.status === 'active' && <Activity className="w-3 h-3" />}
|
{biker.status === 'active' && <Activity className="w-3 h-3" />}
|
||||||
{biker.status === 'pending' && <Clock className="w-3 h-3" />}
|
{biker.status === 'pending' && <Clock className="w-3 h-3" />}
|
||||||
{biker.status === 'blocked' && <Ban className="w-3 h-3" />}
|
{biker.status === 'blocked' && <Ban className="w-3 h-3" />}
|
||||||
{biker.status}
|
{statusLabels[biker.status] || biker.status}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-sm text-slate-500">ID: {biker.id} • {biker.location}</p>
|
<p className="text-sm text-slate-500">ID: {biker.id} • {biker.location}</p>
|
||||||
|
|||||||
@@ -385,6 +385,13 @@ const statusColors: Record<string, string> = {
|
|||||||
blocked: 'bg-red-100 text-red-700',
|
blocked: 'bg-red-100 text-red-700',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const statusLabels: Record<string, string> = {
|
||||||
|
active: 'Active',
|
||||||
|
pending: 'Pending',
|
||||||
|
inactive: 'Inactive',
|
||||||
|
blocked: 'Blocked',
|
||||||
|
};
|
||||||
|
|
||||||
const kycColors: Record<string, string> = {
|
const kycColors: Record<string, string> = {
|
||||||
verified: 'bg-green-100 text-green-700',
|
verified: 'bg-green-100 text-green-700',
|
||||||
pending: 'bg-amber-100 text-amber-700',
|
pending: 'bg-amber-100 text-amber-700',
|
||||||
@@ -749,7 +756,7 @@ export default function BikersPage() {
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex flex-wrap gap-2 mb-3">
|
<div className="flex flex-wrap gap-2 mb-3">
|
||||||
<span className={`inline-flex items-center gap-1 text-xs font-medium px-2.5 py-1 rounded-full ${statusColors[biker.status]}`}>
|
<span className={`inline-flex items-center gap-1 text-xs font-medium px-2.5 py-1 rounded-full ${statusColors[biker.status]}`}>
|
||||||
{biker.status}
|
{statusLabels[biker.status] || biker.status}
|
||||||
</span>
|
</span>
|
||||||
<span className={`inline-flex items-center gap-1 text-xs font-medium px-2.5 py-1 rounded-full ${kycColors[biker.kycStatus]}`}>
|
<span className={`inline-flex items-center gap-1 text-xs font-medium px-2.5 py-1 rounded-full ${kycColors[biker.kycStatus]}`}>
|
||||||
{biker.kycStatus}
|
{biker.kycStatus}
|
||||||
@@ -1160,7 +1167,7 @@ export default function BikersPage() {
|
|||||||
<div>
|
<div>
|
||||||
<h3 className="text-xl font-bold text-slate-800">{selectedBiker.name}</h3>
|
<h3 className="text-xl font-bold text-slate-800">{selectedBiker.name}</h3>
|
||||||
<p className="text-sm text-slate-500">ID: {selectedBiker.id}</p>
|
<p className="text-sm text-slate-500">ID: {selectedBiker.id}</p>
|
||||||
<span className={`inline-flex mt-1 text-xs font-medium px-2.5 py-1 rounded-full ${statusColors[selectedBiker.status]}`}>{selectedBiker.status}</span>
|
<span className={`inline-flex mt-1 text-xs font-medium px-2.5 py-1 rounded-full ${statusColors[selectedBiker.status]}`}>{statusLabels[selectedBiker.status] || selectedBiker.status}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
|
|||||||
315
src/app/admin/merchants/[id]/page.tsx
Normal file
315
src/app/admin/merchants/[id]/page.tsx
Normal file
@@ -0,0 +1,315 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, use } from 'react';
|
||||||
|
import { Store, ArrowLeft, Phone, Mail, MapPin, User, Bike, DollarSign, Clock, FileText, CheckCircle, XCircle, AlertCircle, Edit, Trash2, Plus, X, Upload } from 'lucide-react';
|
||||||
|
import Link from 'next/link';
|
||||||
|
|
||||||
|
type MerchantStatus = 'pending' | 'verified' | 'suspended';
|
||||||
|
type MerchantTier = 'basic' | 'silver' | 'gold' | 'platinum';
|
||||||
|
|
||||||
|
interface Merchant {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
shopName: string;
|
||||||
|
email: string;
|
||||||
|
phone: string;
|
||||||
|
address: string;
|
||||||
|
city: string;
|
||||||
|
status: MerchantStatus;
|
||||||
|
tier: MerchantTier;
|
||||||
|
kycStatus: 'pending' | 'approved' | 'rejected';
|
||||||
|
bikersAssigned: number;
|
||||||
|
bikesRequested: number;
|
||||||
|
totalEarnings: number;
|
||||||
|
pendingPayment: number;
|
||||||
|
joinedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Rental {
|
||||||
|
id: string;
|
||||||
|
bikeName: string;
|
||||||
|
biker: string;
|
||||||
|
startDate: string;
|
||||||
|
endDate: string;
|
||||||
|
amount: number;
|
||||||
|
status: 'active' | 'pending' | 'completed' | 'cancelled';
|
||||||
|
type: 'daily' | 'monthly';
|
||||||
|
}
|
||||||
|
|
||||||
|
const mockMerchant: Merchant = {
|
||||||
|
id: 'MCH-001',
|
||||||
|
name: 'Rahim Ahmed',
|
||||||
|
shopName: 'Rahim Delivery Service',
|
||||||
|
email: 'rahim@delivery.com',
|
||||||
|
phone: '+8801712345678',
|
||||||
|
address: 'House 12, Road 5, Gulshan 1',
|
||||||
|
city: 'Dhaka',
|
||||||
|
status: 'verified',
|
||||||
|
tier: 'gold',
|
||||||
|
kycStatus: 'approved',
|
||||||
|
bikersAssigned: 5,
|
||||||
|
bikesRequested: 5,
|
||||||
|
totalEarnings: 250000,
|
||||||
|
pendingPayment: 15000,
|
||||||
|
joinedAt: '2024-01-15'
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockRentals: Rental[] = [
|
||||||
|
{ id: 'RNT-001', bikeName: 'Yamaha NMAX', biker: 'Karim Khan', startDate: '2024-01-20', endDate: '2024-02-20', amount: 5000, status: 'completed', type: 'monthly' },
|
||||||
|
{ id: 'RNT-002', bikeName: 'Honda PCX', biker: 'Jamal Mia', startDate: '2024-02-01', endDate: '2024-03-01', amount: 5500, status: 'active', type: 'monthly' },
|
||||||
|
{ id: 'RNT-003', bikeName: 'TVS Ntorq', biker: 'Rashid Ali', startDate: '2024-02-15', endDate: '2024-03-15', amount: 4500, status: 'active', type: 'monthly' },
|
||||||
|
{ id: 'RNT-004', bikeName: 'Suzuki Burgman', biker: 'New Request', startDate: '-', endDate: '-', amount: 5000, status: 'pending', type: 'monthly' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const tierColors: Record<MerchantTier, string> = {
|
||||||
|
basic: 'bg-slate-100 text-slate-700 border-slate-300',
|
||||||
|
silver: 'bg-gray-200 text-gray-700 border-gray-400',
|
||||||
|
gold: 'bg-yellow-100 text-yellow-700 border-yellow-400',
|
||||||
|
platinum: 'bg-purple-100 text-purple-700 border-purple-400'
|
||||||
|
};
|
||||||
|
|
||||||
|
const statusColors: Record<MerchantStatus, string> = {
|
||||||
|
pending: 'bg-amber-100 text-amber-700 border-amber-300',
|
||||||
|
verified: 'bg-green-100 text-green-700 border-green-300',
|
||||||
|
suspended: 'bg-red-100 text-red-700 border-red-300'
|
||||||
|
};
|
||||||
|
|
||||||
|
const kycColors: Record<string, string> = {
|
||||||
|
pending: 'bg-amber-100 text-amber-700',
|
||||||
|
approved: 'bg-green-100 text-green-700',
|
||||||
|
rejected: 'bg-red-100 text-red-700'
|
||||||
|
};
|
||||||
|
|
||||||
|
const rentalStatusColors: Record<string, string> = {
|
||||||
|
active: 'bg-green-100 text-green-700',
|
||||||
|
pending: 'bg-amber-100 text-amber-700',
|
||||||
|
completed: 'bg-blue-100 text-blue-700',
|
||||||
|
cancelled: 'bg-red-100 text-red-700'
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function MerchantDetailPage({ params }: { params: Promise<{ id: string }> }) {
|
||||||
|
const { id } = use(params);
|
||||||
|
const [merchant] = useState<Merchant>(mockMerchant);
|
||||||
|
const [activeTab, setActiveTab] = useState<'overview' | 'rentals' | 'payments'>('overview');
|
||||||
|
|
||||||
|
const stats = [
|
||||||
|
{ label: 'Bikers Assigned', value: merchant.bikersAssigned, icon: Bike, color: 'text-blue-600' },
|
||||||
|
{ label: 'Bikes Requested', value: merchant.bikesRequested, icon: Plus, color: 'text-purple-600' },
|
||||||
|
{ label: 'Total Earnings', value: `৳${merchant.totalEarnings.toLocaleString()}`, icon: DollarSign, color: 'text-green-600' },
|
||||||
|
{ label: 'Pending Payment', value: `৳${merchant.pendingPayment.toLocaleString()}`, icon: Clock, color: 'text-amber-600' },
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-4 lg:p-6">
|
||||||
|
<div className="flex items-center gap-4 mb-6">
|
||||||
|
<Link href="/admin/merchants" 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 lg:text-2xl font-extrabold text-slate-800">{merchant.shopName}</h1>
|
||||||
|
<p className="text-sm text-slate-500">{merchant.id} • Joined {merchant.joinedAt}</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button className="py-2 px-4 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>
|
||||||
|
<button className="py-2 px-4 bg-red-500 text-white rounded-lg text-sm hover:bg-red-600 flex items-center gap-2">
|
||||||
|
<Trash2 className="w-4 h-4" /> Delete
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
|
||||||
|
{stats.map((stat, i) => (
|
||||||
|
<div key={i} className="bg-white rounded-xl p-4 shadow-sm border border-slate-100">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className={`p-2 rounded-lg bg-slate-50 ${stat.color}`}>
|
||||||
|
<stat.icon className="w-5 h-5" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-lg font-bold text-slate-800">{stat.value}</p>
|
||||||
|
<p className="text-xs text-slate-500">{stat.label}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-white rounded-xl shadow-sm border border-slate-100 mb-6">
|
||||||
|
<div className="flex gap-6 p-4 border-b border-slate-100">
|
||||||
|
{(['overview', 'rentals', 'payments'] as const).map(tab => (
|
||||||
|
<button
|
||||||
|
key={tab}
|
||||||
|
onClick={() => setActiveTab(tab)}
|
||||||
|
className={`text-sm font-medium pb-2 border-b-2 transition-colors ${
|
||||||
|
activeTab === tab
|
||||||
|
? 'text-accent border-accent'
|
||||||
|
: 'text-slate-500 border-transparent hover:text-slate-700'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{tab.charAt(0).toUpperCase() + tab.slice(1)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{activeTab === 'overview' && (
|
||||||
|
<div className="p-4 space-y-6">
|
||||||
|
<div className="grid lg:grid-cols-2 gap-6">
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h3 className="font-semibold text-slate-800">Business Information</h3>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Store className="w-4 h-4 text-slate-400" />
|
||||||
|
<span className="text-sm text-slate-600">{merchant.shopName}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<User className="w-4 h-4 text-slate-400" />
|
||||||
|
<span className="text-sm text-slate-600">{merchant.name}</span>
|
||||||
|
<span className={`text-xs px-2 py-0.5 rounded-full border ${tierColors[merchant.tier]}`}>
|
||||||
|
{merchant.tier.toUpperCase()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span className="text-xs px-2 py-1 rounded-full border bg-slate-50 text-slate-600">Status</span>
|
||||||
|
<span className={`text-xs px-2 py-1 rounded-full border ${statusColors[merchant.status]}`}>
|
||||||
|
{merchant.status.toUpperCase()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h3 className="font-semibold text-slate-800">Contact Information</h3>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Phone className="w-4 h-4 text-slate-400" />
|
||||||
|
<span className="text-sm text-slate-600">{merchant.phone}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Mail className="w-4 h-4 text-slate-400" />
|
||||||
|
<span className="text-sm text-slate-600">{merchant.email}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<MapPin className="w-4 h-4 text-slate-400" />
|
||||||
|
<span className="text-sm text-slate-600">{merchant.address}, {merchant.city}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid lg:grid-cols-2 gap-6 pt-4 border-t border-slate-100">
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h3 className="font-semibold text-slate-800">KYC Status</h3>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{merchant.kycStatus === 'approved' ? (
|
||||||
|
<CheckCircle className="w-5 h-5 text-green-500" />
|
||||||
|
) : merchant.kycStatus === 'rejected' ? (
|
||||||
|
<XCircle className="w-5 h-5 text-red-500" />
|
||||||
|
) : (
|
||||||
|
<AlertCircle className="w-5 h-5 text-amber-500" />
|
||||||
|
)}
|
||||||
|
<span className={`text-sm px-3 py-1 rounded-full ${kycColors[merchant.kycStatus]}`}>
|
||||||
|
{merchant.kycStatus.toUpperCase()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h3 className="font-semibold text-slate-800">Documents</h3>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<button className="w-full flex items-center justify-between p-3 border border-slate-200 rounded-lg hover:bg-slate-50">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<FileText className="w-4 h-4 text-slate-400" />
|
||||||
|
<span className="text-sm text-slate-600">Trade License</span>
|
||||||
|
</div>
|
||||||
|
<span className="text-xs text-green-600">Uploaded</span>
|
||||||
|
</button>
|
||||||
|
<button className="w-full flex items-center justify-between p-3 border border-slate-200 rounded-lg hover:bg-slate-50">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<FileText className="w-4 h-4 text-slate-400" />
|
||||||
|
<span className="text-sm text-slate-600">NID Copy</span>
|
||||||
|
</div>
|
||||||
|
<span className="text-xs text-green-600">Uploaded</span>
|
||||||
|
</button>
|
||||||
|
<button className="w-full flex items-center justify-between p-3 border border-slate-200 rounded-lg hover:bg-slate-50">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<FileText className="w-4 h-4 text-slate-400" />
|
||||||
|
<span className="text-sm text-slate-600">TIN Certificate</span>
|
||||||
|
</div>
|
||||||
|
<span className="text-xs text-amber-600">Pending</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeTab === 'rentals' && (
|
||||||
|
<div className="p-4">
|
||||||
|
<div className="flex justify-between items-center mb-4">
|
||||||
|
<h3 className="font-semibold text-slate-800">Active Rentals</h3>
|
||||||
|
<button className="py-2 px-4 bg-accent text-white rounded-lg text-sm flex items-center gap-2">
|
||||||
|
<Plus className="w-4 h-4" /> Request Bike
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full">
|
||||||
|
<thead className="bg-slate-50">
|
||||||
|
<tr>
|
||||||
|
<th className="px-4 py-3 text-left text-xs font-semibold text-slate-500 uppercase">Rental ID</th>
|
||||||
|
<th className="px-4 py-3 text-left text-xs font-semibold text-slate-500 uppercase">Bike</th>
|
||||||
|
<th className="px-4 py-3 text-left text-xs font-semibold text-slate-500 uppercase">Biker</th>
|
||||||
|
<th className="px-4 py-3 text-left text-xs font-semibold text-slate-500 uppercase">Period</th>
|
||||||
|
<th className="px-4 py-3 text-left text-xs font-semibold text-slate-500 uppercase">Amount</th>
|
||||||
|
<th className="px-4 py-3 text-left text-xs font-semibold text-slate-500 uppercase">Status</th>
|
||||||
|
<th className="px-4 py-3 text-left text-xs font-semibold text-slate-500 uppercase">Action</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-slate-50">
|
||||||
|
{mockRentals.map(rental => (
|
||||||
|
<tr key={rental.id} className="hover:bg-slate-50">
|
||||||
|
<td className="px-4 py-3 text-sm font-medium text-slate-700">{rental.id}</td>
|
||||||
|
<td className="px-4 py-3 text-sm text-slate-600">{rental.bikeName}</td>
|
||||||
|
<td className="px-4 py-3 text-sm text-slate-600">{rental.biker}</td>
|
||||||
|
<td className="px-4 py-3 text-sm text-slate-600">
|
||||||
|
{rental.startDate === '-' ? '-' : `${rental.startDate} to ${rental.endDate}`}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-sm font-medium text-green-600">৳{rental.amount.toLocaleString()}</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<span className={`text-xs px-2 py-1 rounded-full ${rentalStatusColors[rental.status]}`}>
|
||||||
|
{rental.status.charAt(0).toUpperCase() + rental.status.slice(1)}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
{rental.status === 'pending' ? (
|
||||||
|
<button className="text-xs text-accent hover:underline">Approve</button>
|
||||||
|
) : (
|
||||||
|
<Link href={`/admin/rentals/${rental.id}`} className="text-xs text-accent hover:underline">View</Link>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeTab === 'payments' && (
|
||||||
|
<div className="p-4">
|
||||||
|
<div className="flex justify-between items-center mb-4">
|
||||||
|
<h3 className="font-semibold text-slate-800">Payment History</h3>
|
||||||
|
<button className="py-2 px-4 border border-accent text-accent rounded-lg text-sm flex items-center gap-2">
|
||||||
|
<DollarSign className="w-4 h-4" /> Make Payment
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="text-center py-12 text-slate-500">
|
||||||
|
<DollarSign className="w-12 h-12 mx-auto mb-4 text-slate-300" />
|
||||||
|
<p className="text-sm">No payment records found</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
470
src/app/admin/merchants/page.tsx
Normal file
470
src/app/admin/merchants/page.tsx
Normal file
@@ -0,0 +1,470 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { Store, Plus, Search, Eye, Edit, Trash2, X, MapPin, Phone, Mail, User, DollarSign, Bike, FileText, Clock, CheckCircle, XCircle } from 'lucide-react';
|
||||||
|
import Link from 'next/link';
|
||||||
|
|
||||||
|
type MerchantStatus = 'pending' | 'verified' | 'suspended';
|
||||||
|
type MerchantTier = 'basic' | 'silver' | 'gold' | 'platinum';
|
||||||
|
|
||||||
|
interface Merchant {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
shopName: string;
|
||||||
|
email: string;
|
||||||
|
phone: string;
|
||||||
|
address: string;
|
||||||
|
city: string;
|
||||||
|
status: MerchantStatus;
|
||||||
|
tier: MerchantTier;
|
||||||
|
kycStatus: 'pending' | 'approved' | 'rejected';
|
||||||
|
bikersAssigned: number;
|
||||||
|
bikesRequested: number;
|
||||||
|
totalEarnings: number;
|
||||||
|
pendingPayment: number;
|
||||||
|
joinedAt: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const mockMerchants: Merchant[] = [
|
||||||
|
{
|
||||||
|
id: 'MCH-001',
|
||||||
|
name: 'Rahim Ahmed',
|
||||||
|
shopName: 'Rahim Delivery Service',
|
||||||
|
email: 'rahim@delivery.com',
|
||||||
|
phone: '+8801712345678',
|
||||||
|
address: 'House 12, Road 5, Gulshan 1',
|
||||||
|
city: 'Dhaka',
|
||||||
|
status: 'verified',
|
||||||
|
tier: 'gold',
|
||||||
|
kycStatus: 'approved',
|
||||||
|
bikersAssigned: 5,
|
||||||
|
bikesRequested: 5,
|
||||||
|
totalEarnings: 250000,
|
||||||
|
pendingPayment: 15000,
|
||||||
|
joinedAt: '2024-01-15'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'MCH-002',
|
||||||
|
name: 'Karim Hasan',
|
||||||
|
shopName: 'Karim Express',
|
||||||
|
email: 'karim@express.com',
|
||||||
|
phone: '+8801812345678',
|
||||||
|
address: 'House 8, Road 11, Banani',
|
||||||
|
city: 'Dhaka',
|
||||||
|
status: 'pending',
|
||||||
|
tier: 'basic',
|
||||||
|
kycStatus: 'pending',
|
||||||
|
bikersAssigned: 0,
|
||||||
|
bikesRequested: 3,
|
||||||
|
totalEarnings: 0,
|
||||||
|
pendingPayment: 0,
|
||||||
|
joinedAt: '2024-02-01'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'MCH-003',
|
||||||
|
name: 'Jamal Uddin',
|
||||||
|
shopName: 'Jamal Logistics',
|
||||||
|
email: 'jamal@logistics.com',
|
||||||
|
phone: '+8801912345678',
|
||||||
|
address: 'Sector 10, Uttara',
|
||||||
|
city: 'Dhaka',
|
||||||
|
status: 'verified',
|
||||||
|
tier: 'platinum',
|
||||||
|
kycStatus: 'approved',
|
||||||
|
bikersAssigned: 12,
|
||||||
|
bikesRequested: 10,
|
||||||
|
totalEarnings: 580000,
|
||||||
|
pendingPayment: 45000,
|
||||||
|
joinedAt: '2023-11-20'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'MCH-004',
|
||||||
|
name: 'Rashid Mia',
|
||||||
|
shopName: 'Rashid Delivery',
|
||||||
|
email: 'rashid@delivery.com',
|
||||||
|
phone: '+8801512345678',
|
||||||
|
address: 'Section 10, Mirpur',
|
||||||
|
city: 'Dhaka',
|
||||||
|
status: 'suspended',
|
||||||
|
tier: 'silver',
|
||||||
|
kycStatus: 'rejected',
|
||||||
|
bikersAssigned: 2,
|
||||||
|
bikesRequested: 2,
|
||||||
|
totalEarnings: 45000,
|
||||||
|
pendingPayment: 0,
|
||||||
|
joinedAt: '2024-01-05'
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
const tierColors: Record<MerchantTier, string> = {
|
||||||
|
basic: 'bg-slate-100 text-slate-700',
|
||||||
|
silver: 'bg-gray-200 text-gray-700',
|
||||||
|
gold: 'bg-yellow-100 text-yellow-700',
|
||||||
|
platinum: 'bg-purple-100 text-purple-700'
|
||||||
|
};
|
||||||
|
|
||||||
|
const statusColors: Record<MerchantStatus, string> = {
|
||||||
|
pending: 'bg-amber-100 text-amber-700',
|
||||||
|
verified: 'bg-green-100 text-green-700',
|
||||||
|
suspended: 'bg-red-100 text-red-700'
|
||||||
|
};
|
||||||
|
|
||||||
|
const kycColors: Record<string, string> = {
|
||||||
|
pending: 'bg-amber-100 text-amber-700',
|
||||||
|
approved: 'bg-green-100 text-green-700',
|
||||||
|
rejected: 'bg-red-100 text-red-700'
|
||||||
|
};
|
||||||
|
|
||||||
|
const statusLabels: Record<MerchantStatus, string> = {
|
||||||
|
pending: 'Pending',
|
||||||
|
verified: 'Verified',
|
||||||
|
suspended: 'Suspended'
|
||||||
|
};
|
||||||
|
|
||||||
|
const tierLabels: Record<MerchantTier, string> = {
|
||||||
|
basic: 'Basic',
|
||||||
|
silver: 'Silver',
|
||||||
|
gold: 'Gold',
|
||||||
|
platinum: 'Platinum'
|
||||||
|
};
|
||||||
|
|
||||||
|
const kycLabels: Record<string, string> = {
|
||||||
|
pending: 'Pending',
|
||||||
|
approved: 'Approved',
|
||||||
|
rejected: 'Rejected'
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function MerchantsPage() {
|
||||||
|
const [merchants, setMerchants] = useState<Merchant[]>(mockMerchants);
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
|
const [statusFilter, setStatusFilter] = useState('all');
|
||||||
|
const [showCreateModal, setShowCreateModal] = useState(false);
|
||||||
|
const [editingMerchant, setEditingMerchant] = useState<Merchant | null>(null);
|
||||||
|
|
||||||
|
const [formData, setFormData] = useState({
|
||||||
|
name: '',
|
||||||
|
shopName: '',
|
||||||
|
email: '',
|
||||||
|
phone: '',
|
||||||
|
address: '',
|
||||||
|
city: 'Dhaka',
|
||||||
|
tier: 'basic' as MerchantTier,
|
||||||
|
});
|
||||||
|
|
||||||
|
const filteredMerchants = merchants.filter(m => {
|
||||||
|
const matchesSearch = m.name.toLowerCase().includes(search.toLowerCase()) ||
|
||||||
|
m.shopName.toLowerCase().includes(search.toLowerCase()) ||
|
||||||
|
m.phone.includes(search) ||
|
||||||
|
m.id.toLowerCase().includes(search.toLowerCase());
|
||||||
|
const matchesStatus = statusFilter === 'all' || m.status === statusFilter;
|
||||||
|
return matchesSearch && matchesStatus;
|
||||||
|
});
|
||||||
|
|
||||||
|
const stats = {
|
||||||
|
total: merchants.length,
|
||||||
|
verified: merchants.filter(m => m.status === 'verified').length,
|
||||||
|
pending: merchants.filter(m => m.status === 'pending').length,
|
||||||
|
totalBikers: merchants.reduce((a, m) => a + m.bikersAssigned, 0),
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSave = () => {
|
||||||
|
if (!formData.name || !formData.shopName || !formData.phone) return;
|
||||||
|
|
||||||
|
if (editingMerchant) {
|
||||||
|
setMerchants(merchants.map(m => m.id === editingMerchant.id ? { ...m, ...formData } : m));
|
||||||
|
} else {
|
||||||
|
const newMerchant: Merchant = {
|
||||||
|
id: `MCH-${String(merchants.length + 1).padStart(3, '0')}`,
|
||||||
|
...formData,
|
||||||
|
status: 'pending',
|
||||||
|
kycStatus: 'pending',
|
||||||
|
bikersAssigned: 0,
|
||||||
|
bikesRequested: 0,
|
||||||
|
totalEarnings: 0,
|
||||||
|
pendingPayment: 0,
|
||||||
|
joinedAt: new Date().toISOString().split('T')[0],
|
||||||
|
};
|
||||||
|
setMerchants([...merchants, newMerchant]);
|
||||||
|
}
|
||||||
|
|
||||||
|
setShowCreateModal(false);
|
||||||
|
setEditingMerchant(null);
|
||||||
|
setFormData({ name: '', shopName: '', email: '', phone: '', address: '', city: 'Dhaka', tier: 'basic' });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = (id: string) => {
|
||||||
|
if (confirm('Are you sure you want to delete this merchant?')) {
|
||||||
|
setMerchants(merchants.filter(m => m.id !== id));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const openEdit = (merchant: Merchant) => {
|
||||||
|
setEditingMerchant(merchant);
|
||||||
|
setFormData({
|
||||||
|
name: merchant.name,
|
||||||
|
shopName: merchant.shopName,
|
||||||
|
email: merchant.email,
|
||||||
|
phone: merchant.phone,
|
||||||
|
address: merchant.address,
|
||||||
|
city: merchant.city,
|
||||||
|
tier: merchant.tier,
|
||||||
|
});
|
||||||
|
setShowCreateModal(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
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">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl lg:text-3xl font-extrabold text-slate-800">Merchants (P2)</h1>
|
||||||
|
<p className="text-sm text-slate-500 mt-1">Manage merchant partners and fleet requests</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setEditingMerchant(null);
|
||||||
|
setFormData({ name: '', shopName: '', email: '', phone: '', address: '', city: 'Dhaka', tier: 'basic' });
|
||||||
|
setShowCreateModal(true);
|
||||||
|
}}
|
||||||
|
className="py-2.5 px-4 bg-accent text-white rounded-lg font-semibold text-sm hover:bg-accent-dark transition-colors flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<Plus className="w-4 h-4" /> Add Merchant
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
|
||||||
|
<div className="bg-white rounded-xl p-5 shadow-sm border border-slate-100">
|
||||||
|
<p className="text-2xl font-extrabold text-slate-800">{stats.total}</p>
|
||||||
|
<p className="text-sm text-slate-500">Total Merchants</p>
|
||||||
|
</div>
|
||||||
|
<div className="bg-white rounded-xl p-5 shadow-sm border border-slate-100">
|
||||||
|
<p className="text-2xl font-extrabold text-green-600">{stats.verified}</p>
|
||||||
|
<p className="text-sm text-slate-500">Verified</p>
|
||||||
|
</div>
|
||||||
|
<div className="bg-white rounded-xl p-5 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-5 shadow-sm border border-slate-100">
|
||||||
|
<p className="text-2xl font-extrabold text-blue-600">{stats.totalBikers}</p>
|
||||||
|
<p className="text-sm text-slate-500">Bikers Assigned</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-white rounded-xl shadow-sm border border-slate-100">
|
||||||
|
<div className="p-4 border-b border-slate-100">
|
||||||
|
<div className="flex flex-col lg:flex-row gap-4">
|
||||||
|
<div className="relative flex-1 max-w-md">
|
||||||
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Search by name, shop, phone, ID..."
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
className="w-full pl-10 pr-4 py-2 border border-slate-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-accent focus:border-transparent"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<select
|
||||||
|
value={statusFilter}
|
||||||
|
onChange={(e) => setStatusFilter(e.target.value)}
|
||||||
|
className="py-2 px-4 border border-slate-200 rounded-lg text-sm"
|
||||||
|
>
|
||||||
|
<option value="all">All Status</option>
|
||||||
|
<option value="pending">Pending</option>
|
||||||
|
<option value="verified">Verified</option>
|
||||||
|
<option value="suspended">Suspended</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full">
|
||||||
|
<thead className="bg-slate-50">
|
||||||
|
<tr>
|
||||||
|
<th className="px-4 py-3 text-left text-xs font-semibold text-slate-500 uppercase">Merchant</th>
|
||||||
|
<th className="px-4 py-3 text-left text-xs font-semibold text-slate-500 uppercase">Shop</th>
|
||||||
|
<th className="px-4 py-3 text-left text-xs font-semibold text-slate-500 uppercase">Contact</th>
|
||||||
|
<th className="px-4 py-3 text-left text-xs font-semibold text-slate-500 uppercase">Bikers</th>
|
||||||
|
<th className="px-4 py-3 text-left text-xs font-semibold text-slate-500 uppercase">Earnings</th>
|
||||||
|
<th className="px-4 py-3 text-left text-xs font-semibold text-slate-500 uppercase">KYC</th>
|
||||||
|
<th className="px-4 py-3 text-left text-xs font-semibold text-slate-500 uppercase">Status</th>
|
||||||
|
<th className="px-4 py-3 text-left text-xs font-semibold text-slate-500 uppercase">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-slate-50">
|
||||||
|
{filteredMerchants.map(merchant => (
|
||||||
|
<tr key={merchant.id} className="hover:bg-slate-50 transition-colors">
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<Link href={`/admin/merchants/${merchant.id}`} className="hover:text-accent">
|
||||||
|
<div>
|
||||||
|
<span className="text-sm font-medium text-slate-700">{merchant.name}</span>
|
||||||
|
<p className="text-xs text-slate-400">{merchant.id}</p>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Store className="w-4 h-4 text-slate-400" />
|
||||||
|
<span className="text-sm text-slate-600">{merchant.shopName}</span>
|
||||||
|
</div>
|
||||||
|
<span className={`text-xs px-2 py-0.5 rounded-full ${tierColors[merchant.tier]}`}>
|
||||||
|
{tierLabels[merchant.tier]}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<p className="text-sm text-slate-600">{merchant.phone}</p>
|
||||||
|
<p className="text-xs text-slate-400">{merchant.city}</p>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Bike className="w-4 h-4 text-blue-400" />
|
||||||
|
<span className="text-sm font-medium text-slate-700">{merchant.bikersAssigned}/{merchant.bikesRequested}</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<p className="text-sm font-medium text-green-600">৳{merchant.totalEarnings.toLocaleString()}</p>
|
||||||
|
{merchant.pendingPayment > 0 && (
|
||||||
|
<p className="text-xs text-amber-600">Pending: ৳{merchant.pendingPayment.toLocaleString()}</p>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<span className={`text-xs font-medium px-2 py-1 rounded-full ${kycColors[merchant.kycStatus]}`}>
|
||||||
|
{kycLabels[merchant.kycStatus]}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<span className={`text-xs font-medium px-2 py-1 rounded-full ${statusColors[merchant.status]}`}>
|
||||||
|
{statusLabels[merchant.status]}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Link href={`/admin/merchants/${merchant.id}`} className="p-2 hover:bg-slate-100 rounded-lg">
|
||||||
|
<Eye className="w-4 h-4 text-slate-400" />
|
||||||
|
</Link>
|
||||||
|
<button onClick={() => openEdit(merchant)} className="p-2 hover:bg-slate-100 rounded-lg">
|
||||||
|
<Edit className="w-4 h-4 text-slate-400" />
|
||||||
|
</button>
|
||||||
|
<button onClick={() => handleDelete(merchant.id)} className="p-2 hover:bg-red-50 rounded-lg">
|
||||||
|
<Trash2 className="w-4 h-4 text-red-400" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</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-lg max-h-[90vh] overflow-y-auto">
|
||||||
|
<div className="p-4 border-b border-slate-100 flex justify-between items-center sticky top-0 bg-white">
|
||||||
|
<h3 className="font-semibold text-slate-800">{editingMerchant ? 'Edit Merchant' : 'Add New Merchant'}</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">Owner Name *</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={formData.name}
|
||||||
|
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||||
|
className="w-full px-3 py-2 border border-slate-200 rounded-lg text-sm mt-1"
|
||||||
|
placeholder="Full name"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-sm text-slate-600">Shop Name *</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={formData.shopName}
|
||||||
|
onChange={(e) => setFormData({ ...formData, shopName: e.target.value })}
|
||||||
|
className="w-full px-3 py-2 border border-slate-200 rounded-lg text-sm mt-1"
|
||||||
|
placeholder="Business name"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="text-sm text-slate-600">Phone *</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={formData.phone}
|
||||||
|
onChange={(e) => setFormData({ ...formData, phone: e.target.value })}
|
||||||
|
className="w-full px-3 py-2 border border-slate-200 rounded-lg text-sm mt-1"
|
||||||
|
placeholder="+8801xxxxxxxxx"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-sm text-slate-600">Email</label>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
value={formData.email}
|
||||||
|
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
|
||||||
|
className="w-full px-3 py-2 border border-slate-200 rounded-lg text-sm mt-1"
|
||||||
|
placeholder="email@example.com"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-sm text-slate-600">Address</label>
|
||||||
|
<textarea
|
||||||
|
value={formData.address}
|
||||||
|
onChange={(e) => setFormData({ ...formData, address: e.target.value })}
|
||||||
|
className="w-full px-3 py-2 border border-slate-200 rounded-lg text-sm mt-1"
|
||||||
|
rows={2}
|
||||||
|
placeholder="Full address"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="text-sm text-slate-600">City</label>
|
||||||
|
<select
|
||||||
|
value={formData.city}
|
||||||
|
onChange={(e) => setFormData({ ...formData, city: e.target.value })}
|
||||||
|
className="w-full px-3 py-2 border border-slate-200 rounded-lg text-sm mt-1"
|
||||||
|
>
|
||||||
|
<option value="Dhaka">Dhaka</option>
|
||||||
|
<option value="Chittagong">Chittagong</option>
|
||||||
|
<option value="Sylhet">Sylhet</option>
|
||||||
|
<option value="Khulna">Khulna</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-sm text-slate-600">Tier</label>
|
||||||
|
<select
|
||||||
|
value={formData.tier}
|
||||||
|
onChange={(e) => setFormData({ ...formData, tier: e.target.value as MerchantTier })}
|
||||||
|
className="w-full px-3 py-2 border border-slate-200 rounded-lg text-sm mt-1"
|
||||||
|
>
|
||||||
|
<option value="basic">Basic</option>
|
||||||
|
<option value="silver">Silver</option>
|
||||||
|
<option value="gold">Gold</option>
|
||||||
|
<option value="platinum">Platinum</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="p-4 border-t border-slate-100 flex justify-end gap-2 sticky bottom-0 bg-white">
|
||||||
|
<button
|
||||||
|
onClick={() => setShowCreateModal(false)}
|
||||||
|
className="px-4 py-2 border border-slate-200 text-slate-600 rounded-lg text-sm"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={!formData.name || !formData.shopName || !formData.phone}
|
||||||
|
className="px-4 py-2 bg-accent text-white rounded-lg text-sm disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{editingMerchant ? 'Update' : 'Create'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -273,6 +273,14 @@ export default function RentalDetailPage() {
|
|||||||
cancelled: 'bg-slate-100 text-slate-700',
|
cancelled: 'bg-slate-100 text-slate-700',
|
||||||
locked: 'bg-red-100 text-red-700'
|
locked: 'bg-red-100 text-red-700'
|
||||||
};
|
};
|
||||||
|
const statusLabels: Record<string, string> = {
|
||||||
|
active: 'Active',
|
||||||
|
pending: 'Pending',
|
||||||
|
completed: 'Completed',
|
||||||
|
disputed: 'Disputed',
|
||||||
|
cancelled: 'Cancelled',
|
||||||
|
locked: 'Locked'
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-4 lg:p-6 max-w-8xl mx-auto">
|
<div className="p-4 lg:p-6 max-w-8xl mx-auto">
|
||||||
@@ -290,7 +298,7 @@ export default function RentalDetailPage() {
|
|||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<h1 className="text-2xl font-extrabold text-slate-800">{rental.id}</h1>
|
<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]}`}>
|
<span className={`inline-flex items-center gap-1 text-xs font-medium px-2.5 py-1 rounded-full ${statusColors[rental.status]}`}>
|
||||||
{rental.status}
|
{statusLabels[rental.status] || rental.status}
|
||||||
</span>
|
</span>
|
||||||
{rental.status === 'locked' && (
|
{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">
|
<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">
|
||||||
|
|||||||
@@ -262,7 +262,15 @@ const generateInvoice = () => {
|
|||||||
cancelled: 'bg-slate-100 text-slate-700',
|
cancelled: 'bg-slate-100 text-slate-700',
|
||||||
locked: 'bg-red-100 text-red-700',
|
locked: 'bg-red-100 text-red-700',
|
||||||
};
|
};
|
||||||
return styles[status];
|
const labels: Record<string, string> = {
|
||||||
|
active: 'Active',
|
||||||
|
pending: 'Pending',
|
||||||
|
completed: 'Completed',
|
||||||
|
disputed: 'Disputed',
|
||||||
|
cancelled: 'Cancelled',
|
||||||
|
locked: 'Locked',
|
||||||
|
};
|
||||||
|
return { style: styles[status], label: labels[status] || status };
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -383,9 +391,14 @@ const generateInvoice = () => {
|
|||||||
<span className="text-sm font-semibold text-green-600">৳{rental.totalPaid.toLocaleString()}</span>
|
<span className="text-sm font-semibold text-green-600">৳{rental.totalPaid.toLocaleString()}</span>
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3">
|
<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}
|
const { style, label } = getStatusBadge(rental.status);
|
||||||
</span>
|
return (
|
||||||
|
<span className={`inline-flex items-center gap-1 text-xs font-medium px-2.5 py-1 rounded-full ${style}`}>
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-4 py-3">
|
<td className="px-4 py-3">
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
|
|||||||
431
src/app/admin/roles/page.tsx
Normal file
431
src/app/admin/roles/page.tsx
Normal file
@@ -0,0 +1,431 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { Shield, Plus, Search, X, Edit, Trash2, Copy, CheckCircle, XCircle, Eye } from 'lucide-react';
|
||||||
|
|
||||||
|
interface Permission {
|
||||||
|
module: string;
|
||||||
|
view: boolean;
|
||||||
|
create: boolean;
|
||||||
|
edit: boolean;
|
||||||
|
delete: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface Role {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
isDefault: boolean;
|
||||||
|
permissions: Permission[];
|
||||||
|
}
|
||||||
|
|
||||||
|
const defaultPermissions: Permission[] = [
|
||||||
|
{ module: 'Dashboard', view: true, create: false, edit: false, delete: false },
|
||||||
|
{ module: 'KYC', view: false, create: false, edit: false, delete: false },
|
||||||
|
{ module: 'Rentals', view: false, create: false, edit: false, delete: false },
|
||||||
|
{ module: 'Bikers', view: false, create: false, edit: false, delete: false },
|
||||||
|
{ module: 'Investors', view: false, create: false, edit: false, delete: false },
|
||||||
|
{ module: 'Fleet', view: false, create: false, edit: false, delete: false },
|
||||||
|
{ module: 'Merchants', view: false, create: false, edit: false, delete: false },
|
||||||
|
{ module: 'Swap Stations', view: false, create: false, edit: false, delete: false },
|
||||||
|
{ module: 'Hubs', view: false, create: false, edit: false, delete: false },
|
||||||
|
{ module: 'Maintenance', view: false, create: false, edit: false, delete: false },
|
||||||
|
{ module: 'Accounting', view: false, create: false, edit: false, delete: false },
|
||||||
|
{ module: 'Reports', view: false, create: false, edit: false, delete: false },
|
||||||
|
{ module: 'Users', view: false, create: false, edit: false, delete: false },
|
||||||
|
];
|
||||||
|
|
||||||
|
const mockRoles: Role[] = [
|
||||||
|
{
|
||||||
|
id: 'ROLE-001',
|
||||||
|
name: 'Admin',
|
||||||
|
description: 'Full system access with all permissions',
|
||||||
|
isDefault: false,
|
||||||
|
permissions: defaultPermissions.map(p => ({ ...p, view: true, create: true, edit: true, delete: true }))
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'ROLE-002',
|
||||||
|
name: 'Manager',
|
||||||
|
description: 'Management access with limited delete permissions',
|
||||||
|
isDefault: false,
|
||||||
|
permissions: defaultPermissions.map(p => ({
|
||||||
|
module: p.module,
|
||||||
|
view: true,
|
||||||
|
create: ['Dashboard', 'Reports'].includes(p.module) ? false : true,
|
||||||
|
edit: ['Dashboard', 'Users'].includes(p.module) ? false : true,
|
||||||
|
delete: false
|
||||||
|
}))
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'ROLE-003',
|
||||||
|
name: 'Biker',
|
||||||
|
description: 'Limited access for bike riders',
|
||||||
|
isDefault: false,
|
||||||
|
permissions: defaultPermissions.map(p => ({
|
||||||
|
module: p.module,
|
||||||
|
view: ['Dashboard', 'Fleet', 'Rentals'].includes(p.module),
|
||||||
|
create: p.module === 'Rentals',
|
||||||
|
edit: false,
|
||||||
|
delete: false
|
||||||
|
}))
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'ROLE-004',
|
||||||
|
name: 'Investor',
|
||||||
|
description: 'Access for investors to view portfolio',
|
||||||
|
isDefault: false,
|
||||||
|
permissions: defaultPermissions.map(p => ({
|
||||||
|
module: p.module,
|
||||||
|
view: ['Dashboard', 'Portfolio', 'Withdraw'].includes(p.module),
|
||||||
|
create: p.module === 'Withdraw',
|
||||||
|
edit: false,
|
||||||
|
delete: false
|
||||||
|
}))
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'ROLE-005',
|
||||||
|
name: 'Shop',
|
||||||
|
description: 'Access for shop owners',
|
||||||
|
isDefault: false,
|
||||||
|
permissions: defaultPermissions.map(p => ({
|
||||||
|
module: p.module,
|
||||||
|
view: ['Dashboard', 'Fleet', 'Deliveries'].includes(p.module),
|
||||||
|
create: p.module === 'Deliveries',
|
||||||
|
edit: p.module === 'Deliveries',
|
||||||
|
delete: false
|
||||||
|
}))
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'ROLE-006',
|
||||||
|
name: 'Merchant',
|
||||||
|
description: 'Access for merchants',
|
||||||
|
isDefault: true,
|
||||||
|
permissions: defaultPermissions.map(p => ({
|
||||||
|
module: p.module,
|
||||||
|
view: ['Dashboard', 'Deliveries'].includes(p.module),
|
||||||
|
create: false,
|
||||||
|
edit: ['Deliveries'].includes(p.module),
|
||||||
|
delete: false
|
||||||
|
}))
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const allModules = ['Dashboard', 'KYC', 'Rentals', 'Bikers', 'Investors', 'Fleet', 'Merchants', 'Swap Stations', 'Hubs', 'Maintenance', 'Accounting', 'Reports', 'Users', 'Portfolio', 'Withdraw', 'Deliveries'];
|
||||||
|
|
||||||
|
export default function RolesPage() {
|
||||||
|
const [roles, setRoles] = useState<Role[]>(mockRoles);
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
|
const [showCreateModal, setShowCreateModal] = useState(false);
|
||||||
|
const [editingRole, setEditingRole] = useState<Role | null>(null);
|
||||||
|
const [selectedRole, setSelectedRole] = useState<Role | null>(null);
|
||||||
|
|
||||||
|
const [formData, setFormData] = useState({
|
||||||
|
name: '',
|
||||||
|
description: '',
|
||||||
|
});
|
||||||
|
|
||||||
|
const filteredRoles = roles.filter(r =>
|
||||||
|
r.name.toLowerCase().includes(search.toLowerCase()) ||
|
||||||
|
r.description.toLowerCase().includes(search.toLowerCase())
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleSave = () => {
|
||||||
|
if (!formData.name) return;
|
||||||
|
|
||||||
|
if (editingRole) {
|
||||||
|
setRoles(roles.map(r => r.id === editingRole.id ? { ...r, ...formData, isDefault: false } : r));
|
||||||
|
} else {
|
||||||
|
const newRole: Role = {
|
||||||
|
id: `ROLE-${String(roles.length + 1).padStart(3, '0')}`,
|
||||||
|
...formData,
|
||||||
|
isDefault: false,
|
||||||
|
permissions: defaultPermissions.map(p => ({ ...p, view: false, create: false, edit: false, delete: false }))
|
||||||
|
};
|
||||||
|
setRoles([...roles, newRole]);
|
||||||
|
}
|
||||||
|
|
||||||
|
setShowCreateModal(false);
|
||||||
|
setEditingRole(null);
|
||||||
|
setFormData({ name: '', description: '' });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = (id: string) => {
|
||||||
|
if (confirm('Are you sure you want to delete this role?')) {
|
||||||
|
setRoles(roles.filter(r => r.id !== id));
|
||||||
|
setSelectedRole(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDuplicate = (role: Role) => {
|
||||||
|
const newRole: Role = {
|
||||||
|
id: `ROLE-${String(roles.length + 1).padStart(3, '0')}`,
|
||||||
|
name: `${role.name} (Copy)`,
|
||||||
|
description: role.description,
|
||||||
|
isDefault: false,
|
||||||
|
permissions: role.permissions.map(p => ({ ...p }))
|
||||||
|
};
|
||||||
|
setRoles([...roles, newRole]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const openEdit = (role: Role) => {
|
||||||
|
setEditingRole(role);
|
||||||
|
setFormData({
|
||||||
|
name: role.name,
|
||||||
|
description: role.description
|
||||||
|
});
|
||||||
|
setShowCreateModal(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
const togglePermission = (moduleIndex: number, action: 'view' | 'create' | 'edit' | 'delete') => {
|
||||||
|
if (!selectedRole) return;
|
||||||
|
const updated = [...selectedRole.permissions];
|
||||||
|
updated[moduleIndex][action] = !updated[moduleIndex][action];
|
||||||
|
setSelectedRole({ ...selectedRole, permissions: updated });
|
||||||
|
setRoles(roles.map(r => r.id === selectedRole.id ? { ...selectedRole, permissions: updated } : r));
|
||||||
|
};
|
||||||
|
|
||||||
|
const getPermissionCount = (role: Role, action: keyof Permission) => {
|
||||||
|
return role.permissions.filter(p => p[action] === true).length;
|
||||||
|
};
|
||||||
|
|
||||||
|
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">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl lg:text-3xl font-extrabold text-slate-800">Roles & Permissions</h1>
|
||||||
|
<p className="text-sm text-slate-500 mt-1">Manage roles and access permissions</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setEditingRole(null);
|
||||||
|
setFormData({ name: '', description: '' });
|
||||||
|
setShowCreateModal(true);
|
||||||
|
}}
|
||||||
|
className="py-2.5 px-4 bg-accent text-white rounded-lg font-semibold text-sm hover:bg-accent-dark transition-colors flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<Plus className="w-4 h-4" /> Create Role
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
|
||||||
|
<div className="bg-white rounded-xl p-5 shadow-sm border border-slate-100">
|
||||||
|
<p className="text-2xl font-extrabold text-slate-800">{roles.length}</p>
|
||||||
|
<p className="text-sm text-slate-500">Total Roles</p>
|
||||||
|
</div>
|
||||||
|
<div className="bg-white rounded-xl p-5 shadow-sm border border-slate-100">
|
||||||
|
<p className="text-2xl font-extrabold text-green-600">{roles.filter(r => r.isDefault).length}</p>
|
||||||
|
<p className="text-sm text-slate-500">Default Roles</p>
|
||||||
|
</div>
|
||||||
|
<div className="bg-white rounded-xl p-5 shadow-sm border border-slate-100">
|
||||||
|
<p className="text-2xl font-extrabold text-blue-600">{roles.reduce((a, r) => a + getPermissionCount(r, 'create'), 0)}</p>
|
||||||
|
<p className="text-sm text-slate-500">Total Create Perms</p>
|
||||||
|
</div>
|
||||||
|
<div className="bg-white rounded-xl p-5 shadow-sm border border-slate-100">
|
||||||
|
<p className="text-2xl font-extrabold text-purple-600">{roles.reduce((a, r) => a + getPermissionCount(r, 'view'), 0)}</p>
|
||||||
|
<p className="text-sm text-slate-500">Total View Perms</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid lg:grid-cols-3 gap-6">
|
||||||
|
<div className="lg:col-span-1 bg-white rounded-xl shadow-sm border border-slate-100">
|
||||||
|
<div className="p-4 border-b border-slate-100">
|
||||||
|
<div className="relative">
|
||||||
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Search roles..."
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
className="w-full pl-10 pr-4 py-2 border border-slate-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-accent focus:border-transparent"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="divide-y divide-slate-50 max-h-[600px] overflow-y-auto">
|
||||||
|
{filteredRoles.map(role => (
|
||||||
|
<div
|
||||||
|
key={role.id}
|
||||||
|
className={`p-4 hover:bg-slate-50 cursor-pointer transition-colors ${selectedRole?.id === role.id ? 'bg-accent-light' : ''}`}
|
||||||
|
onClick={() => setSelectedRole(role)}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-slate-700">{role.name}</p>
|
||||||
|
<p className="text-xs text-slate-400 truncate">{role.description}</p>
|
||||||
|
</div>
|
||||||
|
{role.isDefault && (
|
||||||
|
<span className="text-xs px-2 py-0.5 bg-accent-light text-accent rounded-full">Default</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 mt-2 text-xs text-slate-400">
|
||||||
|
<span>{getPermissionCount(role, 'view')} view</span>
|
||||||
|
<span>•</span>
|
||||||
|
<span>{getPermissionCount(role, 'create')} create</span>
|
||||||
|
<span>•</span>
|
||||||
|
<span>{getPermissionCount(role, 'edit')} edit</span>
|
||||||
|
<span>•</span>
|
||||||
|
<span>{getPermissionCount(role, 'delete')} delete</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="lg:col-span-2 bg-white rounded-xl shadow-sm border border-slate-100">
|
||||||
|
{selectedRole ? (
|
||||||
|
<>
|
||||||
|
<div className="p-4 border-b border-slate-100 flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h3 className="text-lg font-semibold text-slate-800">{selectedRole.name}</h3>
|
||||||
|
<p className="text-sm text-slate-500">{selectedRole.description}</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button onClick={() => handleDuplicate(selectedRole)} className="p-2 hover:bg-slate-100 rounded-lg text-slate-600">
|
||||||
|
<Copy className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
<button onClick={() => openEdit(selectedRole)} className="p-2 hover:bg-slate-100 rounded-lg text-slate-600">
|
||||||
|
<Edit className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
{!selectedRole.isDefault && (
|
||||||
|
<button onClick={() => handleDelete(selectedRole.id)} className="p-2 hover:bg-red-50 rounded-lg text-red-500">
|
||||||
|
<Trash2 className="w-4 h-4" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="p-4">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<h4 className="font-medium text-slate-800">Permissions</h4>
|
||||||
|
<div className="flex gap-4 text-xs">
|
||||||
|
<span className="text-slate-500">Check all <span className="font-medium text-green-600">view</span></span>
|
||||||
|
<span className="text-slate-500">Check all <span className="font-medium text-blue-600">create</span></span>
|
||||||
|
<span className="text-slate-500">Check all <span className="font-medium text-amber-600">edit</span></span>
|
||||||
|
<span className="text-slate-500">Check all <span className="font-medium text-red-600">delete</span></span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full">
|
||||||
|
<thead className="bg-slate-50">
|
||||||
|
<tr>
|
||||||
|
<th className="px-4 py-3 text-left text-xs font-semibold text-slate-500 uppercase">Module</th>
|
||||||
|
<th className="px-4 py-3 text-center text-xs font-semibold text-slate-500 uppercase">View</th>
|
||||||
|
<th className="px-4 py-3 text-center text-xs font-semibold text-slate-500 uppercase">Create</th>
|
||||||
|
<th className="px-4 py-3 text-center text-xs font-semibold text-slate-500 uppercase">Edit</th>
|
||||||
|
<th className="px-4 py-3 text-center text-xs font-semibold text-slate-500 uppercase">Delete</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-slate-50">
|
||||||
|
{selectedRole.permissions.map((perm, i) => (
|
||||||
|
<tr key={i} className="hover:bg-slate-50">
|
||||||
|
<td className="px-4 py-3 text-sm font-medium text-slate-700">{perm.module}</td>
|
||||||
|
<td className="px-4 py-3 text-center">
|
||||||
|
<button
|
||||||
|
onClick={() => togglePermission(i, 'view')}
|
||||||
|
className={`w-5 h-5 rounded flex items-center justify-center ${
|
||||||
|
perm.view ? 'bg-green-100 text-green-600' : 'bg-slate-100 text-slate-300'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{perm.view && <CheckCircle className="w-4 h-4" />}
|
||||||
|
{!perm.view && <XCircle className="w-4 h-4" />}
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-center">
|
||||||
|
<button
|
||||||
|
onClick={() => togglePermission(i, 'create')}
|
||||||
|
className={`w-5 h-5 rounded flex items-center justify-center ${
|
||||||
|
perm.create ? 'bg-blue-100 text-blue-600' : 'bg-slate-100 text-slate-300'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{perm.create && <CheckCircle className="w-4 h-4" />}
|
||||||
|
{!perm.create && <XCircle className="w-4 h-4" />}
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-center">
|
||||||
|
<button
|
||||||
|
onClick={() => togglePermission(i, 'edit')}
|
||||||
|
className={`w-5 h-5 rounded flex items-center justify-center ${
|
||||||
|
perm.edit ? 'bg-amber-100 text-amber-600' : 'bg-slate-100 text-slate-300'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{perm.edit && <CheckCircle className="w-4 h-4" />}
|
||||||
|
{!perm.edit && <XCircle className="w-4 h-4" />}
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-center">
|
||||||
|
<button
|
||||||
|
onClick={() => togglePermission(i, 'delete')}
|
||||||
|
className={`w-5 h-5 rounded flex items-center justify-center ${
|
||||||
|
perm.delete ? 'bg-red-100 text-red-600' : 'bg-slate-100 text-slate-300'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{perm.delete && <CheckCircle className="w-4 h-4" />}
|
||||||
|
{!perm.delete && <XCircle className="w-4 h-4" />}
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="p-12 text-center text-slate-500">
|
||||||
|
<Shield className="w-12 h-12 mx-auto mb-4 text-slate-300" />
|
||||||
|
<p className="text-sm">Select a role to view and edit permissions</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</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">{editingRole ? 'Edit Role' : 'Create New Role'}</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">Role Name *</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={formData.name}
|
||||||
|
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||||
|
className="w-full px-3 py-2 border border-slate-200 rounded-lg text-sm mt-1"
|
||||||
|
placeholder="e.g., Super Manager"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-sm text-slate-600">Description</label>
|
||||||
|
<textarea
|
||||||
|
value={formData.description}
|
||||||
|
onChange={(e) => setFormData({ ...formData, description: e.target.value })}
|
||||||
|
className="w-full px-3 py-2 border border-slate-200 rounded-lg text-sm mt-1"
|
||||||
|
rows={2}
|
||||||
|
placeholder="Describe what this role can do"
|
||||||
|
/>
|
||||||
|
</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={handleSave}
|
||||||
|
disabled={!formData.name}
|
||||||
|
className="px-4 py-2 bg-accent text-white rounded-lg text-sm disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{editingRole ? 'Update' : 'Create'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
1141
src/app/admin/settings/page.tsx
Normal file
1141
src/app/admin/settings/page.tsx
Normal file
File diff suppressed because it is too large
Load Diff
373
src/app/admin/swap-stations/[id]/page.tsx
Normal file
373
src/app/admin/swap-stations/[id]/page.tsx
Normal file
@@ -0,0 +1,373 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, use } from 'react';
|
||||||
|
import { Zap, ArrowLeft, Phone, Mail, MapPin, Battery, Clock, CheckCircle, XCircle, Edit, Trash2, RefreshCw, ZapOff } from 'lucide-react';
|
||||||
|
|
||||||
|
interface BatterySlot {
|
||||||
|
slotId: string;
|
||||||
|
batteryId: string | null;
|
||||||
|
charge: number;
|
||||||
|
status: 'empty' | 'charging' | 'ready' | 'in-use';
|
||||||
|
lastUpdate: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SwapStation {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
address: string;
|
||||||
|
city: string;
|
||||||
|
phone: string;
|
||||||
|
status: 'active' | 'inactive' | 'maintenance';
|
||||||
|
lastRestock: string;
|
||||||
|
hubId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SwapRequest {
|
||||||
|
id: string;
|
||||||
|
stationId: string;
|
||||||
|
bikerName: string;
|
||||||
|
bikerPhone: string;
|
||||||
|
bikeModel: string;
|
||||||
|
currentBattery: number;
|
||||||
|
requestedBattery: number;
|
||||||
|
status: 'pending' | 'approved' | 'rejected' | 'completed';
|
||||||
|
requestedAt: string;
|
||||||
|
processedAt?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const generateSlots = (total: number) => {
|
||||||
|
const slots: BatterySlot[] = [];
|
||||||
|
const statuses: BatterySlot['status'][] = ['ready', 'ready', 'ready', 'charging', 'charging', 'in-use'];
|
||||||
|
|
||||||
|
for (let i = 1; i <= total; i++) {
|
||||||
|
const status = i <= 12 ? 'ready' : i <= 17 ? 'charging' : 'in-use';
|
||||||
|
slots.push({
|
||||||
|
slotId: `SL-${String(i).padStart(2, '0')}`,
|
||||||
|
batteryId: `BAT-${String(1000 + i).padStart(4, '0')}`,
|
||||||
|
charge: status === 'ready' ? 85 + Math.floor(Math.random() * 15) : status === 'charging' ? 45 + Math.floor(Math.random() * 35) : 30 + Math.floor(Math.random() * 30),
|
||||||
|
status,
|
||||||
|
lastUpdate: '2024-02-16 10:00'
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return slots;
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockStation: SwapStation = {
|
||||||
|
id: 'SS-001',
|
||||||
|
name: 'JAIBEN Swap Hub - Gulshan',
|
||||||
|
address: 'House 45, Road 13, Gulshan 1',
|
||||||
|
city: 'Dhaka',
|
||||||
|
phone: '+8801712345670',
|
||||||
|
status: 'active',
|
||||||
|
lastRestock: '2024-02-15',
|
||||||
|
hubId: 'HUB-001'
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockRequests: SwapRequest[] = [
|
||||||
|
{ id: 'SWR-001', stationId: 'SS-001', bikerName: 'Karim Khan', bikerPhone: '+8801712345678', bikeModel: 'Yamaha NMAX', currentBattery: 15, requestedBattery: 100, status: 'pending', requestedAt: '2024-02-16 10:30:00' },
|
||||||
|
{ id: 'SWR-002', stationId: 'SS-001', bikerName: 'Jamal Mia', bikerPhone: '+8801812345678', bikeModel: 'Honda PCX', currentBattery: 20, requestedBattery: 100, status: 'approved', requestedAt: '2024-02-16 09:15:00' },
|
||||||
|
{ id: 'SWR-003', stationId: 'SS-001', bikerName: 'Rahim Ali', bikerPhone: '+8801912345678', bikeModel: 'TVS Ntorq', currentBattery: 10, requestedBattery: 100, status: 'pending', requestedAt: '2024-02-16 08:45:00' },
|
||||||
|
{ id: 'SWR-004', stationId: 'SS-001', bikerName: 'Rashid Khan', bikerPhone: '+8801512345678', bikeModel: 'Suzuki Burgman', currentBattery: 25, requestedBattery: 100, status: 'rejected', requestedAt: '2024-02-15 18:30:00' },
|
||||||
|
{ id: 'SWR-005', stationId: 'SS-001', bikerName: 'Saif Ahmed', bikerPhone: '+8801612345678', bikeModel: 'Yamaha NMAX', currentBattery: 18, requestedBattery: 100, status: 'completed', requestedAt: '2024-02-15 14:20:00', processedAt: '2024-02-15 14:25:00' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const statusColors: Record<string, string> = {
|
||||||
|
active: 'bg-green-100 text-green-700',
|
||||||
|
inactive: 'bg-slate-100 text-slate-700',
|
||||||
|
maintenance: 'bg-amber-100 text-amber-700'
|
||||||
|
};
|
||||||
|
|
||||||
|
const slotStatusColors: Record<string, string> = {
|
||||||
|
ready: 'bg-green-100 border-green-300',
|
||||||
|
charging: 'bg-amber-100 border-amber-300',
|
||||||
|
'in-use': 'bg-blue-100 border-blue-300',
|
||||||
|
empty: 'bg-slate-100 border-slate-300'
|
||||||
|
};
|
||||||
|
|
||||||
|
const requestStatusColors: Record<string, string> = {
|
||||||
|
pending: 'bg-amber-100 text-amber-700',
|
||||||
|
approved: 'bg-blue-100 text-blue-700',
|
||||||
|
rejected: 'bg-red-100 text-red-700',
|
||||||
|
completed: 'bg-green-100 text-green-700'
|
||||||
|
};
|
||||||
|
|
||||||
|
const requestStatusLabels: Record<string, string> = {
|
||||||
|
pending: 'Pending',
|
||||||
|
approved: 'Approved',
|
||||||
|
rejected: 'Rejected',
|
||||||
|
completed: 'Completed'
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function SwapStationDetailPage({ params }: { params: Promise<{ id: string }> }) {
|
||||||
|
const { id } = use(params);
|
||||||
|
const [station] = useState<SwapStation>(mockStation);
|
||||||
|
const [slots, setSlots] = useState<BatterySlot[]>(generateSlots(20));
|
||||||
|
const [requests, setRequests] = useState<SwapRequest[]>(mockRequests);
|
||||||
|
const [activeTab, setActiveTab] = useState<'slots' | 'requests' | 'transactions'>('slots');
|
||||||
|
|
||||||
|
const readyCount = slots.filter(s => s.status === 'ready').length;
|
||||||
|
const chargingCount = slots.filter(s => s.status === 'charging').length;
|
||||||
|
const inUseCount = slots.filter(s => s.status === 'in-use').length;
|
||||||
|
const emptyCount = slots.filter(s => s.status === 'empty').length;
|
||||||
|
|
||||||
|
const handleApprove = (requestId: string) => {
|
||||||
|
setRequests(requests.map(r => r.id === requestId ? { ...r, status: 'approved' as const, processedAt: new Date().toISOString().replace('T', ' ').split('.')[0] } : r));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleReject = (requestId: string) => {
|
||||||
|
setRequests(requests.map(r => r.id === requestId ? { ...r, status: 'rejected' as const, processedAt: new Date().toISOString().replace('T', ' ').split('.')[0] } : r));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleComplete = (requestId: string) => {
|
||||||
|
setRequests(requests.map(r => r.id === requestId ? { ...r, status: 'completed' as const, processedAt: new Date().toISOString().replace('T', ' ').split('.')[0] } : r));
|
||||||
|
};
|
||||||
|
|
||||||
|
const pendingRequests = requests.filter(r => r.status === 'pending');
|
||||||
|
const completedTransactions = requests.filter(r => r.status === 'completed');
|
||||||
|
|
||||||
|
const getChargeColor = (charge: number) => {
|
||||||
|
if (charge >= 80) return 'text-green-600';
|
||||||
|
if (charge >= 50) return 'text-amber-600';
|
||||||
|
return 'text-red-600';
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-4 lg:p-6">
|
||||||
|
<div className="flex items-center gap-4 mb-6">
|
||||||
|
<a href="/admin/swap-stations" className="p-2 hover:bg-slate-100 rounded-lg">
|
||||||
|
<ArrowLeft className="w-5 h-5 text-slate-600" />
|
||||||
|
</a>
|
||||||
|
<div className="flex-1">
|
||||||
|
<h1 className="text-xl lg:text-2xl font-extrabold text-slate-800">{station.name}</h1>
|
||||||
|
<p className="text-sm text-slate-500">{station.id} • Last restock: {station.lastRestock}</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button className="py-2 px-4 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>
|
||||||
|
<button className="py-2 px-4 bg-red-500 text-white rounded-lg text-sm hover:bg-red-600 flex items-center gap-2">
|
||||||
|
<Trash2 className="w-4 h-4" /> Delete
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 lg:grid-cols-5 gap-4 mb-6">
|
||||||
|
<div className="bg-white rounded-xl p-4 shadow-sm border border-slate-100">
|
||||||
|
<p className="text-xl lg:text-2xl font-bold text-green-600">{readyCount}</p>
|
||||||
|
<p className="text-sm text-slate-500">Ready to Swap</p>
|
||||||
|
</div>
|
||||||
|
<div className="bg-white rounded-xl p-4 shadow-sm border border-slate-100">
|
||||||
|
<p className="text-xl lg:text-2xl font-bold text-amber-600">{chargingCount}</p>
|
||||||
|
<p className="text-sm text-slate-500">Charging</p>
|
||||||
|
</div>
|
||||||
|
<div className="bg-white rounded-xl p-4 shadow-sm border border-slate-100">
|
||||||
|
<p className="text-xl lg:text-2xl font-bold text-blue-600">{inUseCount}</p>
|
||||||
|
<p className="text-sm text-slate-500">In Use</p>
|
||||||
|
</div>
|
||||||
|
<div className="bg-white rounded-xl p-4 shadow-sm border border-slate-100">
|
||||||
|
<p className="text-xl lg:text-2xl font-bold text-amber-600">{pendingRequests.length}</p>
|
||||||
|
<p className="text-sm text-slate-500">Pending Requests</p>
|
||||||
|
</div>
|
||||||
|
<div className="bg-white rounded-xl p-4 shadow-sm border border-slate-100">
|
||||||
|
<p className="text-xl lg:text-2xl font-bold text-blue-600">{completedTransactions.length}</p>
|
||||||
|
<p className="text-sm text-slate-500">Swaps Today</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid lg:grid-cols-2 gap-6 mb-6">
|
||||||
|
<div className="bg-white rounded-xl p-5 shadow-sm border border-slate-100">
|
||||||
|
<h3 className="font-semibold text-slate-800 mb-4">Station Information</h3>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Zap className="w-4 h-4 text-amber-500" />
|
||||||
|
<span className="text-sm text-slate-600">{station.name}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span className="text-xs px-2 py-1 rounded-full bg-slate-50 text-slate-600">Status</span>
|
||||||
|
<span className={`text-xs px-2 py-1 rounded-full ${statusColors[station.status]}`}>
|
||||||
|
{station.status.toUpperCase()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Phone className="w-4 h-4 text-slate-400" />
|
||||||
|
<span className="text-sm text-slate-600">{station.phone}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<MapPin className="w-4 h-4 text-slate-400" />
|
||||||
|
<span className="text-sm text-slate-600">{station.address}, {station.city}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-white rounded-xl p-5 shadow-sm border border-slate-100">
|
||||||
|
<h3 className="font-semibold text-slate-800 mb-4">Quick Actions</h3>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex justify-between text-sm">
|
||||||
|
<span className="text-slate-600">Total Slots</span>
|
||||||
|
<span className="font-medium text-slate-800">20</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between text-sm">
|
||||||
|
<span className="text-slate-600">Ready</span>
|
||||||
|
<span className="font-medium text-green-600">{readyCount}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between text-sm">
|
||||||
|
<span className="text-slate-600">Charging</span>
|
||||||
|
<span className="font-medium text-amber-600">{chargingCount}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between text-sm">
|
||||||
|
<span className="text-slate-600">In Use</span>
|
||||||
|
<span className="font-medium text-blue-600">{inUseCount}</span>
|
||||||
|
</div>
|
||||||
|
<button className="w-full mt-2 py-2 border border-slate-200 text-slate-600 rounded-lg text-sm hover:bg-slate-50 flex items-center justify-center gap-2">
|
||||||
|
<RefreshCw className="w-4 h-4" /> Restock Batteries
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-white rounded-xl shadow-sm border border-slate-100 mb-6">
|
||||||
|
<div className="flex gap-6 p-4 border-b border-slate-100 overflow-x-auto">
|
||||||
|
{(['slots', 'requests', 'transactions'] as const).map(tab => (
|
||||||
|
<button
|
||||||
|
key={tab}
|
||||||
|
onClick={() => setActiveTab(tab)}
|
||||||
|
className={`text-sm font-medium pb-2 border-b-2 whitespace-nowrap transition-colors ${
|
||||||
|
activeTab === tab
|
||||||
|
? 'text-accent border-accent'
|
||||||
|
: 'text-slate-500 border-transparent hover:text-slate-700'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{tab === 'slots' ? 'Battery Slots' : tab === 'requests' ? 'Swap Requests' : 'Transactions'}
|
||||||
|
{tab === 'requests' && pendingRequests.length > 0 && (
|
||||||
|
<span className="ml-2 px-1.5 py-0.5 bg-amber-500 text-white text-xs rounded-full">{pendingRequests.length}</span>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{activeTab === 'slots' && (
|
||||||
|
<div className="p-4">
|
||||||
|
<div className="flex items-center gap-4 mb-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="w-3 h-3 rounded-full bg-green-500" />
|
||||||
|
<span className="text-xs text-slate-600">Ready ({readyCount})</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="w-3 h-3 rounded-full bg-amber-500" />
|
||||||
|
<span className="text-xs text-slate-600">Charging ({chargingCount})</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="w-3 h-3 rounded-full bg-blue-500" />
|
||||||
|
<span className="text-xs text-slate-600">In Use ({inUseCount})</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="w-3 h-3 rounded-full bg-slate-300" />
|
||||||
|
<span className="text-xs text-slate-600">Empty ({emptyCount})</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-4 sm:grid-cols-5 md:grid-cols-6 lg:grid-cols-10 gap-3">
|
||||||
|
{slots.map(slot => (
|
||||||
|
<div
|
||||||
|
key={slot.slotId}
|
||||||
|
className={`
|
||||||
|
aspect-square rounded-lg border-2 p-2 flex flex-col justify-between
|
||||||
|
${slotStatusColors[slot.status]}
|
||||||
|
${slot.status === 'empty' ? 'opacity-50' : ''}
|
||||||
|
`}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-xs font-medium text-slate-600">{slot.slotId}</span>
|
||||||
|
{slot.status === 'charging' && (
|
||||||
|
<div className="w-2 h-2 rounded-full bg-amber-500 animate-pulse" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
{slot.batteryId ? (
|
||||||
|
<>
|
||||||
|
<p className="text-[10px] text-slate-500 truncate">{slot.batteryId}</p>
|
||||||
|
<p className={`text-lg font-bold ${getChargeColor(slot.charge)}`}>{slot.charge}%</p>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-center justify-center h-full">
|
||||||
|
<ZapOff className="w-5 h-5 text-slate-400" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeTab === 'requests' && (
|
||||||
|
<div className="p-4">
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full">
|
||||||
|
<thead className="bg-slate-50">
|
||||||
|
<tr>
|
||||||
|
<th className="px-4 py-3 text-left text-xs font-semibold text-slate-500 uppercase">Request</th>
|
||||||
|
<th className="px-4 py-3 text-left text-xs font-semibold text-slate-500 uppercase">Biker</th>
|
||||||
|
<th className="px-4 py-3 text-left text-xs font-semibold text-slate-500 uppercase">Bike</th>
|
||||||
|
<th className="px-4 py-3 text-left text-xs font-semibold text-slate-500 uppercase">Current %</th>
|
||||||
|
<th className="px-4 py-3 text-left text-xs font-semibold text-slate-500 uppercase">Time</th>
|
||||||
|
<th className="px-4 py-3 text-left text-xs font-semibold text-slate-500 uppercase">Status</th>
|
||||||
|
<th className="px-4 py-3 text-left text-xs font-semibold text-slate-500 uppercase">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-slate-50">
|
||||||
|
{requests.map(request => (
|
||||||
|
<tr key={request.id} className="hover:bg-slate-50">
|
||||||
|
<td className="px-4 py-3 text-sm font-medium text-slate-700">{request.id}</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<p className="text-sm text-slate-600">{request.bikerName}</p>
|
||||||
|
<p className="text-xs text-slate-400">{request.bikerPhone}</p>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-sm text-slate-600">{request.bikeModel}</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Battery className="w-4 h-4 text-green-500" />
|
||||||
|
<span className={`text-sm font-medium ${request.currentBattery <= 20 ? 'text-red-600' : 'text-green-600'}`}>
|
||||||
|
{request.currentBattery}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-sm text-slate-500">{request.requestedAt}</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<span className={`text-xs px-2 py-1 rounded-full ${requestStatusColors[request.status]}`}>
|
||||||
|
{requestStatusLabels[request.status]}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
{request.status === 'pending' && (
|
||||||
|
<div className="flex gap-1">
|
||||||
|
<button onClick={() => handleApprove(request.id)} className="px-2 py-1 text-xs bg-blue-500 text-white rounded hover:bg-blue-600">Approve</button>
|
||||||
|
<button onClick={() => handleReject(request.id)} className="px-2 py-1 text-xs bg-red-500 text-white rounded hover:bg-red-600">Reject</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{request.status === 'approved' && (
|
||||||
|
<button onClick={() => handleComplete(request.id)} className="px-2 py-1 text-xs bg-green-500 text-white rounded hover:bg-green-600">Complete</button>
|
||||||
|
)}
|
||||||
|
{request.status === 'completed' && <span className="text-xs text-slate-400">Done</span>}
|
||||||
|
{request.status === 'rejected' && <span className="text-xs text-red-500">Rejected</span>}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeTab === 'transactions' && (
|
||||||
|
<div className="p-4">
|
||||||
|
<div className="text-center py-12 text-slate-500">
|
||||||
|
<Zap className="w-12 h-12 mx-auto mb-4 text-amber-300" />
|
||||||
|
<p className="text-sm">Select a request tab to view completed transactions</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
409
src/app/admin/swap-stations/page.tsx
Normal file
409
src/app/admin/swap-stations/page.tsx
Normal file
@@ -0,0 +1,409 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { Zap, Search, Plus, X, MapPin, Phone, Battery, Clock, CheckCircle, XCircle, AlertCircle, Eye, Edit, Trash2, Filter } from 'lucide-react';
|
||||||
|
import Link from 'next/link';
|
||||||
|
|
||||||
|
interface SwapStation {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
address: string;
|
||||||
|
city: string;
|
||||||
|
phone: string;
|
||||||
|
status: 'active' | 'inactive' | 'maintenance';
|
||||||
|
totalSlots: number;
|
||||||
|
charging: number;
|
||||||
|
readyToSwap: number;
|
||||||
|
inUse: number;
|
||||||
|
lastRestock: string;
|
||||||
|
hubId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const mockStations: SwapStation[] = [
|
||||||
|
{
|
||||||
|
id: 'SS-001',
|
||||||
|
name: 'JAIBEN Swap Hub - Gulshan',
|
||||||
|
address: 'House 45, Road 13, Gulshan 1',
|
||||||
|
city: 'Dhaka',
|
||||||
|
phone: '+8801712345670',
|
||||||
|
status: 'active',
|
||||||
|
totalSlots: 20,
|
||||||
|
charging: 3,
|
||||||
|
readyToSwap: 12,
|
||||||
|
inUse: 5,
|
||||||
|
lastRestock: '2024-02-15',
|
||||||
|
hubId: 'HUB-001'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'SS-002',
|
||||||
|
name: 'JAIBEN Swap Hub - Banani',
|
||||||
|
address: 'House 8, Road 11, Banani',
|
||||||
|
city: 'Dhaka',
|
||||||
|
phone: '+8801712345671',
|
||||||
|
status: 'active',
|
||||||
|
totalSlots: 15,
|
||||||
|
charging: 2,
|
||||||
|
readyToSwap: 8,
|
||||||
|
inUse: 5,
|
||||||
|
lastRestock: '2024-02-14',
|
||||||
|
hubId: 'HUB-001'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'SS-003',
|
||||||
|
name: 'JAIBEN Swap Hub - Uttara',
|
||||||
|
address: 'Sector 10, Road 2, Uttara',
|
||||||
|
city: 'Dhaka',
|
||||||
|
phone: '+8801712345672',
|
||||||
|
status: 'maintenance',
|
||||||
|
totalSlots: 10,
|
||||||
|
charging: 0,
|
||||||
|
readyToSwap: 0,
|
||||||
|
inUse: 0,
|
||||||
|
lastRestock: '2024-02-10'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'SS-004',
|
||||||
|
name: 'JAIBEN Swap Hub - Mirpur',
|
||||||
|
address: 'Section 10, Mirpur',
|
||||||
|
city: 'Dhaka',
|
||||||
|
phone: '+8801712345673',
|
||||||
|
status: 'active',
|
||||||
|
totalSlots: 25,
|
||||||
|
charging: 5,
|
||||||
|
readyToSwap: 15,
|
||||||
|
inUse: 5,
|
||||||
|
lastRestock: '2024-02-16'
|
||||||
|
}
|
||||||
|
];
|
||||||
|
|
||||||
|
const statusColors: Record<string, string> = {
|
||||||
|
active: 'bg-green-100 text-green-700',
|
||||||
|
inactive: 'bg-slate-100 text-slate-700',
|
||||||
|
maintenance: 'bg-amber-100 text-amber-700'
|
||||||
|
};
|
||||||
|
|
||||||
|
const statusLabels: Record<string, string> = {
|
||||||
|
active: 'Active',
|
||||||
|
inactive: 'Inactive',
|
||||||
|
maintenance: 'Maintenance'
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function SwapStationsPage() {
|
||||||
|
const [stations, setStations] = useState<SwapStation[]>(mockStations);
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
|
const [statusFilter, setStatusFilter] = useState('all');
|
||||||
|
const [showCreateModal, setShowCreateModal] = useState(false);
|
||||||
|
const [editingStation, setEditingStation] = useState<SwapStation | null>(null);
|
||||||
|
const [selectedStation, setSelectedStation] = useState<SwapStation | null>(null);
|
||||||
|
|
||||||
|
const [formData, setFormData] = useState({
|
||||||
|
name: '',
|
||||||
|
address: '',
|
||||||
|
city: 'Dhaka',
|
||||||
|
phone: '',
|
||||||
|
status: 'active' as SwapStation['status'],
|
||||||
|
totalSlots: 10,
|
||||||
|
hubId: 'HUB-001'
|
||||||
|
});
|
||||||
|
|
||||||
|
const filteredStations = stations.filter(s => {
|
||||||
|
const matchesSearch = s.name.toLowerCase().includes(search.toLowerCase()) ||
|
||||||
|
s.address.toLowerCase().includes(search.toLowerCase()) ||
|
||||||
|
s.id.toLowerCase().includes(search.toLowerCase());
|
||||||
|
const matchesStatus = statusFilter === 'all' || s.status === statusFilter;
|
||||||
|
return matchesSearch && matchesStatus;
|
||||||
|
});
|
||||||
|
|
||||||
|
const stats = {
|
||||||
|
total: stations.length,
|
||||||
|
active: stations.filter(s => s.status === 'active').length,
|
||||||
|
totalSlots: stations.reduce((a, s) => a + s.totalSlots, 0),
|
||||||
|
readyToSwap: stations.reduce((a, s) => a + s.readyToSwap, 0),
|
||||||
|
charging: stations.reduce((a, s) => a + s.charging, 0)
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSave = () => {
|
||||||
|
if (!formData.name || !formData.address || !formData.phone) return;
|
||||||
|
|
||||||
|
if (editingStation) {
|
||||||
|
setStations(stations.map(s => s.id === editingStation.id ? { ...s, ...formData } : s));
|
||||||
|
} else {
|
||||||
|
const newStation: SwapStation = {
|
||||||
|
id: `SS-${String(stations.length + 1).padStart(3, '0')}`,
|
||||||
|
...formData,
|
||||||
|
charging: 2,
|
||||||
|
readyToSwap: Math.floor(formData.totalSlots * 0.6),
|
||||||
|
inUse: formData.totalSlots - Math.floor(formData.totalSlots * 0.6) - 2,
|
||||||
|
lastRestock: new Date().toISOString().split('T')[0]
|
||||||
|
};
|
||||||
|
setStations([...stations, newStation]);
|
||||||
|
}
|
||||||
|
|
||||||
|
setShowCreateModal(false);
|
||||||
|
setEditingStation(null);
|
||||||
|
setFormData({ name: '', address: '', city: 'Dhaka', phone: '', status: 'active', totalSlots: 10, hubId: 'HUB-001' });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = (id: string) => {
|
||||||
|
if (confirm('Are you sure you want to delete this station?')) {
|
||||||
|
setStations(stations.filter(s => s.id !== id));
|
||||||
|
}
|
||||||
|
setSelectedStation(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
const openEdit = (station: SwapStation) => {
|
||||||
|
setEditingStation(station);
|
||||||
|
setFormData({
|
||||||
|
name: station.name,
|
||||||
|
address: station.address,
|
||||||
|
city: station.city,
|
||||||
|
phone: station.phone,
|
||||||
|
status: station.status,
|
||||||
|
totalSlots: station.totalSlots,
|
||||||
|
hubId: station.hubId || 'HUB-001'
|
||||||
|
});
|
||||||
|
setShowCreateModal(true);
|
||||||
|
setSelectedStation(null);
|
||||||
|
};
|
||||||
|
|
||||||
|
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">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl lg:text-3xl font-extrabold text-slate-800">Swap Stations (P3)</h1>
|
||||||
|
<p className="text-sm text-slate-500 mt-1">Manage battery swap stations across Dhaka</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setEditingStation(null);
|
||||||
|
setFormData({ name: '', address: '', city: 'Dhaka', phone: '', status: 'active', totalSlots: 10, hubId: 'HUB-001' });
|
||||||
|
setShowCreateModal(true);
|
||||||
|
}}
|
||||||
|
className="py-2.5 px-4 bg-accent text-white rounded-lg font-semibold text-sm hover:bg-accent-dark transition-colors flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<Plus className="w-4 h-4" /> Add Station
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 lg:grid-cols-5 gap-4 mb-6">
|
||||||
|
<div className="bg-white rounded-xl p-5 shadow-sm border border-slate-100">
|
||||||
|
<p className="text-2xl font-extrabold text-slate-800">{stats.total}</p>
|
||||||
|
<p className="text-sm text-slate-500">Total Stations</p>
|
||||||
|
</div>
|
||||||
|
<div className="bg-white rounded-xl p-5 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</p>
|
||||||
|
</div>
|
||||||
|
<div className="bg-white rounded-xl p-5 shadow-sm border border-slate-100">
|
||||||
|
<p className="text-2xl font-extrabold text-blue-600">{stats.totalSlots}</p>
|
||||||
|
<p className="text-sm text-slate-500">Total Slots</p>
|
||||||
|
</div>
|
||||||
|
<div className="bg-white rounded-xl p-5 shadow-sm border border-slate-100">
|
||||||
|
<p className="text-2xl font-extrabold text-purple-600">{stats.readyToSwap}</p>
|
||||||
|
<p className="text-sm text-slate-500">Ready to Swap</p>
|
||||||
|
</div>
|
||||||
|
<div className="bg-white rounded-xl p-5 shadow-sm border border-slate-100">
|
||||||
|
<p className="text-2xl font-extrabold text-amber-600">{stats.charging}</p>
|
||||||
|
<p className="text-sm text-slate-500">Charging</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-white rounded-xl shadow-sm border border-slate-100">
|
||||||
|
<div className="p-4 border-b border-slate-100">
|
||||||
|
<div className="flex flex-col lg:flex-row gap-4">
|
||||||
|
<div className="relative flex-1 max-w-md">
|
||||||
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Search stations..."
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
className="w-full pl-10 pr-4 py-2 border border-slate-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-accent focus:border-transparent"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<select
|
||||||
|
value={statusFilter}
|
||||||
|
onChange={(e) => setStatusFilter(e.target.value)}
|
||||||
|
className="py-2 px-4 border border-slate-200 rounded-lg text-sm"
|
||||||
|
>
|
||||||
|
<option value="all">All Status</option>
|
||||||
|
<option value="active">Active</option>
|
||||||
|
<option value="inactive">Inactive</option>
|
||||||
|
<option value="maintenance">Maintenance</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full">
|
||||||
|
<thead className="bg-slate-50">
|
||||||
|
<tr>
|
||||||
|
<th className="px-4 py-3 text-left text-xs font-semibold text-slate-500 uppercase">Station</th>
|
||||||
|
<th className="px-4 py-3 text-left text-xs font-semibold text-slate-500 uppercase">Location</th>
|
||||||
|
<th className="px-4 py-3 text-left text-xs font-semibold text-slate-500 uppercase">Slots</th>
|
||||||
|
<th className="px-4 py-3 text-left text-xs font-semibold text-slate-500 uppercase">Batteries</th>
|
||||||
|
<th className="px-4 py-3 text-left text-xs font-semibold text-slate-500 uppercase">Status</th>
|
||||||
|
<th className="px-4 py-3 text-left text-xs font-semibold text-slate-500 uppercase">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-slate-50">
|
||||||
|
{filteredStations.map(station => (
|
||||||
|
<tr key={station.id} className="hover:bg-slate-50 transition-colors">
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<Link
|
||||||
|
href={`/admin/swap-stations/${station.id.toLowerCase()}`}
|
||||||
|
className="text-left hover:text-accent"
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Zap className="w-4 h-4 text-amber-500" />
|
||||||
|
<span className="text-sm font-medium text-slate-700">{station.name}</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-slate-400 pl-6">{station.id}</p>
|
||||||
|
</Link>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<p className="text-sm text-slate-600">{station.address}</p>
|
||||||
|
<p className="text-xs text-slate-400">{station.city}</p>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Battery className="w-4 h-4 text-blue-400" />
|
||||||
|
<span className="text-sm font-medium text-slate-700">
|
||||||
|
{station.readyToSwap}/{station.totalSlots}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-slate-400">{station.charging} charging</p>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<p className="text-sm font-medium text-green-600">{station.inUse}</p>
|
||||||
|
<p className="text-xs text-slate-400">in use</p>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<span className={`text-xs font-medium px-2 py-1 rounded-full ${statusColors[station.status]}`}>
|
||||||
|
{statusLabels[station.status]}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Link
|
||||||
|
href={`/admin/swap-stations/${station.id.toLowerCase()}`}
|
||||||
|
className="p-2 hover:bg-blue-100 rounded-lg"
|
||||||
|
>
|
||||||
|
<Eye className="w-4 h-4 text-blue-400" />
|
||||||
|
</Link>
|
||||||
|
<button onClick={() => openEdit(station)} className="p-2 hover:bg-slate-100 rounded-lg">
|
||||||
|
<Edit className="w-4 h-4 text-slate-400" />
|
||||||
|
</button>
|
||||||
|
<button onClick={() => handleDelete(station.id)} className="p-2 hover:bg-red-50 rounded-lg">
|
||||||
|
<Trash2 className="w-4 h-4 text-red-400" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</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-lg max-h-[90vh] overflow-y-auto">
|
||||||
|
<div className="p-4 border-b border-slate-100 flex justify-between items-center sticky top-0 bg-white">
|
||||||
|
<h3 className="font-semibold text-slate-800">{editingStation ? 'Edit Station' : 'Add New Station'}</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">Station Name *</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={formData.name}
|
||||||
|
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||||
|
className="w-full px-3 py-2 border border-slate-200 rounded-lg text-sm mt-1"
|
||||||
|
placeholder="e.g., JAIBEN Swap Hub - Gulshan"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-sm text-slate-600">Address *</label>
|
||||||
|
<textarea
|
||||||
|
value={formData.address}
|
||||||
|
onChange={(e) => setFormData({ ...formData, address: e.target.value })}
|
||||||
|
className="w-full px-3 py-2 border border-slate-200 rounded-lg text-sm mt-1"
|
||||||
|
rows={2}
|
||||||
|
placeholder="Full address"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="text-sm text-slate-600">City</label>
|
||||||
|
<select
|
||||||
|
value={formData.city}
|
||||||
|
onChange={(e) => setFormData({ ...formData, city: e.target.value })}
|
||||||
|
className="w-full px-3 py-2 border border-slate-200 rounded-lg text-sm mt-1"
|
||||||
|
>
|
||||||
|
<option value="Dhaka">Dhaka</option>
|
||||||
|
<option value="Chittagong">Chittagong</option>
|
||||||
|
<option value="Sylhet">Sylhet</option>
|
||||||
|
<option value="Khulna">Khulna</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-sm text-slate-600">Phone *</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={formData.phone}
|
||||||
|
onChange={(e) => setFormData({ ...formData, phone: e.target.value })}
|
||||||
|
className="w-full px-3 py-2 border border-slate-200 rounded-lg text-sm mt-1"
|
||||||
|
placeholder="+8801xxxxxxxxx"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="text-sm text-slate-600">Total Slots</label>
|
||||||
|
<input
|
||||||
|
type="number"
|
||||||
|
value={formData.totalSlots}
|
||||||
|
onChange={(e) => setFormData({ ...formData, totalSlots: parseInt(e.target.value) || 10 })}
|
||||||
|
className="w-full px-3 py-2 border border-slate-200 rounded-lg text-sm mt-1"
|
||||||
|
min={1}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-sm text-slate-600">Status</label>
|
||||||
|
<select
|
||||||
|
value={formData.status}
|
||||||
|
onChange={(e) => setFormData({ ...formData, status: e.target.value as SwapStation['status'] })}
|
||||||
|
className="w-full px-3 py-2 border border-slate-200 rounded-lg text-sm mt-1"
|
||||||
|
>
|
||||||
|
<option value="active">Active</option>
|
||||||
|
<option value="inactive">Inactive</option>
|
||||||
|
<option value="maintenance">Maintenance</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="p-4 border-t border-slate-100 flex justify-end gap-2 sticky bottom-0 bg-white">
|
||||||
|
<button
|
||||||
|
onClick={() => setShowCreateModal(false)}
|
||||||
|
className="px-4 py-2 border border-slate-200 text-slate-600 rounded-lg text-sm"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={!formData.name || !formData.address || !formData.phone}
|
||||||
|
className="px-4 py-2 bg-accent text-white rounded-lg text-sm disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{editingStation ? 'Update' : 'Create'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
332
src/app/admin/users/[id]/page.tsx
Normal file
332
src/app/admin/users/[id]/page.tsx
Normal file
@@ -0,0 +1,332 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState, use } from 'react';
|
||||||
|
import { Users, ArrowLeft, Mail, Phone, MapPin, Shield, Edit, Trash2, KeyRound, Eye, EyeOff, Wallet, Bike, Zap, Store, CheckCircle, XCircle, Clock, Link2 } from 'lucide-react';
|
||||||
|
import Link from 'next/link';
|
||||||
|
|
||||||
|
type UserRole = 'admin' | 'manager' | 'biker' | 'investor' | 'shop' | 'merchant';
|
||||||
|
type UserStatus = 'active' | 'inactive' | 'suspended';
|
||||||
|
|
||||||
|
interface User {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
email: string;
|
||||||
|
phone: string;
|
||||||
|
role: UserRole;
|
||||||
|
status: UserStatus;
|
||||||
|
password: string;
|
||||||
|
lastLogin: string;
|
||||||
|
createdAt: string;
|
||||||
|
linkedProfile?: {
|
||||||
|
type: 'biker' | 'investor' | 'shop' | 'merchant' | 'swap-station';
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const mockUser: User = {
|
||||||
|
id: 'USR-001',
|
||||||
|
name: 'Admin User',
|
||||||
|
email: 'admin@jaiben.com',
|
||||||
|
phone: '+8801712345678',
|
||||||
|
role: 'admin',
|
||||||
|
status: 'active',
|
||||||
|
password: '********',
|
||||||
|
lastLogin: '2024-02-16 10:30',
|
||||||
|
createdAt: '2023-01-01',
|
||||||
|
linkedProfile: undefined
|
||||||
|
};
|
||||||
|
|
||||||
|
const mockUsers: User[] = [
|
||||||
|
{ id: 'USR-003', name: 'Jamal Biker', email: 'jamal@biker.com', phone: '+8801912345678', role: 'biker', status: 'active', password: '********', lastLogin: '2024-02-15 18:00', createdAt: '2023-09-20', linkedProfile: { type: 'biker', id: 'BIK-001', name: 'Jamal Delivery' } },
|
||||||
|
{ id: 'USR-004', name: 'Rahim Investor', email: 'rahim@investor.com', phone: '+8801512345678', role: 'investor', status: 'active', password: '********', lastLogin: '2024-02-14 12:00', createdAt: '2023-03-10', linkedProfile: { type: 'investor', id: 'INV-001', name: 'Rahim Investments' } },
|
||||||
|
{ id: 'USR-005', name: 'Shop Owner', email: 'shop@owner.com', phone: '+8801612345678', role: 'shop', status: 'active', password: '********', lastLogin: '2024-02-15 14:30', createdAt: '2023-11-05', linkedProfile: { type: 'shop', id: 'SHP-001', name: 'Jaiben Shop' } },
|
||||||
|
{ id: 'USR-006', name: 'Merchant User', email: 'merchant@jaiben.com', phone: '+8801412345678', role: 'merchant', status: 'active', password: '********', lastLogin: '2024-02-15 16:00', createdAt: '2023-08-15', linkedProfile: { type: 'merchant', id: 'MCH-001', name: 'Rahim Delivery Service' } },
|
||||||
|
];
|
||||||
|
|
||||||
|
const roleColors: Record<UserRole, string> = {
|
||||||
|
admin: 'bg-red-100 text-red-700',
|
||||||
|
manager: 'bg-purple-100 text-purple-700',
|
||||||
|
biker: 'bg-blue-100 text-blue-700',
|
||||||
|
investor: 'bg-green-100 text-green-700',
|
||||||
|
shop: 'bg-amber-100 text-amber-700',
|
||||||
|
merchant: 'bg-cyan-100 text-cyan-700'
|
||||||
|
};
|
||||||
|
|
||||||
|
const statusColors: Record<UserStatus, string> = {
|
||||||
|
active: 'bg-green-100 text-green-700',
|
||||||
|
inactive: 'bg-slate-100 text-slate-700',
|
||||||
|
suspended: 'bg-red-100 text-red-700'
|
||||||
|
};
|
||||||
|
|
||||||
|
const roleLabels: Record<UserRole, string> = {
|
||||||
|
admin: 'Admin',
|
||||||
|
manager: 'Manager',
|
||||||
|
biker: 'Biker',
|
||||||
|
investor: 'Investor',
|
||||||
|
shop: 'Shop',
|
||||||
|
merchant: 'Merchant'
|
||||||
|
};
|
||||||
|
|
||||||
|
const statusLabels: Record<UserStatus, string> = {
|
||||||
|
active: 'Active',
|
||||||
|
inactive: 'Inactive',
|
||||||
|
suspended: 'Suspended'
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function UserDetailPage({ params }: { params: Promise<{ id: string }> }) {
|
||||||
|
const { id } = use(params);
|
||||||
|
const [user, setUser] = useState<User>(mockUser);
|
||||||
|
const [showPassword, setShowPassword] = useState(false);
|
||||||
|
const [passwordForm, setPasswordForm] = useState({ current: '', new: '', confirm: '' });
|
||||||
|
const [activeTab, setActiveTab] = useState<'profile' | 'security' | 'linked'>('profile');
|
||||||
|
|
||||||
|
const linkedProfiles = mockUsers.filter(u => u.linkedProfile);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-4 lg:p-6">
|
||||||
|
<div className="flex items-center gap-4 mb-6">
|
||||||
|
<Link href="/admin/users" 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 lg:text-2xl font-extrabold text-slate-800">Account Settings</h1>
|
||||||
|
<p className="text-sm text-slate-500">{user.id} • Joined {user.createdAt}</p>
|
||||||
|
</div>
|
||||||
|
<button className="py-2 px-4 bg-red-500 text-white rounded-lg text-sm hover:bg-red-600 flex items-center gap-2">
|
||||||
|
<Trash2 className="w-4 h-4" /> Delete Account
|
||||||
|
</button>
|
||||||
|
</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">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-12 h-12 rounded-full bg-accent-light flex items-center justify-center">
|
||||||
|
<span className="text-xl font-bold text-accent">{user.name.charAt(0).toUpperCase()}</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-lg font-bold text-slate-800">{user.name}</p>
|
||||||
|
<p className="text-xs text-slate-400">{roleLabels[user.role]}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="bg-white rounded-xl p-4 shadow-sm border border-slate-100 flex items-center justify-center">
|
||||||
|
<span className={`text-sm px-3 py-1 rounded-full ${statusColors[user.status]}`}>
|
||||||
|
{statusLabels[user.status]}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="bg-white rounded-xl p-4 shadow-sm border border-slate-100">
|
||||||
|
<p className="text-sm text-slate-500">Last Login</p>
|
||||||
|
<p className="text-lg font-bold text-slate-800">{user.lastLogin}</p>
|
||||||
|
</div>
|
||||||
|
<div className="bg-white rounded-xl p-4 shadow-sm border border-slate-100">
|
||||||
|
<p className="text-sm text-slate-500">Linked Profile</p>
|
||||||
|
<p className="text-lg font-bold text-slate-800">{user.linkedProfile ? user.linkedProfile.type.charAt(0).toUpperCase() + user.linkedProfile.type.slice(1).replace('-', ' ') : 'None'}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-white rounded-xl shadow-sm border border-slate-100 mb-6">
|
||||||
|
<div className="flex gap-6 p-4 border-b border-slate-100">
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveTab('profile')}
|
||||||
|
className={`text-sm font-medium pb-2 border-b-2 transition-colors ${
|
||||||
|
activeTab === 'profile'
|
||||||
|
? 'text-accent border-accent'
|
||||||
|
: 'text-slate-500 border-transparent hover:text-slate-700'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Profile
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveTab('security')}
|
||||||
|
className={`text-sm font-medium pb-2 border-b-2 transition-colors ${
|
||||||
|
activeTab === 'security'
|
||||||
|
? 'text-accent border-accent'
|
||||||
|
: 'text-slate-500 border-transparent hover:text-slate-700'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Security
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveTab('linked')}
|
||||||
|
className={`text-sm font-medium pb-2 border-b-2 transition-colors ${
|
||||||
|
activeTab === 'linked'
|
||||||
|
? 'text-accent border-accent'
|
||||||
|
: 'text-slate-500 border-transparent hover:text-slate-700'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
Linked Profiles
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{activeTab === 'profile' && (
|
||||||
|
<div className="p-4 space-y-6">
|
||||||
|
<div className="grid lg:grid-cols-2 gap-6">
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h3 className="font-semibold text-slate-800">Personal Information</h3>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Users className="w-4 h-4 text-slate-400" />
|
||||||
|
<span className="text-sm text-slate-600">{user.name}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span className="text-xs px-2 py-1 rounded-full bg-slate-50 text-slate-600">Role</span>
|
||||||
|
<span className={`text-xs px-2 py-1 rounded-full ${roleColors[user.role]}`}>
|
||||||
|
{roleLabels[user.role]}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<span className="text-xs px-2 py-1 rounded-full bg-slate-50 text-slate-600">Status</span>
|
||||||
|
<span className={`text-xs px-2 py-1 rounded-full ${statusColors[user.status]}`}>
|
||||||
|
{statusLabels[user.status]}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h3 className="font-semibold text-slate-800">Contact Information</h3>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Mail className="w-4 h-4 text-slate-400" />
|
||||||
|
<span className="text-sm text-slate-600">{user.email}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Phone className="w-4 h-4 text-slate-400" />
|
||||||
|
<span className="text-sm text-slate-600">{user.phone}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="pt-4 border-t border-slate-100">
|
||||||
|
<button className="py-2 px-4 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 Profile
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeTab === 'security' && (
|
||||||
|
<div className="p-4 space-y-6">
|
||||||
|
<div className="grid lg:grid-cols-2 gap-6">
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h3 className="font-semibold text-slate-800">Password</h3>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center justify-between p-3 border border-slate-200 rounded-lg">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<KeyRound className="w-4 h-4 text-slate-400" />
|
||||||
|
<span className="text-sm text-slate-600">Password</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-sm text-slate-500">{showPassword ? '123456' : '••••••••'}</span>
|
||||||
|
<button onClick={() => setShowPassword(!showPassword)} className="p-1">
|
||||||
|
{showPassword ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h3 className="font-semibold text-slate-800">Change Password</h3>
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div>
|
||||||
|
<label className="text-xs text-slate-500">Current Password</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={passwordForm.current}
|
||||||
|
onChange={(e) => setPasswordForm({ ...passwordForm, current: e.target.value })}
|
||||||
|
className="w-full px-3 py-2 border border-slate-200 rounded-lg text-sm mt-1"
|
||||||
|
placeholder="••••••••"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-xs text-slate-500">New Password</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={passwordForm.new}
|
||||||
|
onChange={(e) => setPasswordForm({ ...passwordForm, new: e.target.value })}
|
||||||
|
className="w-full px-3 py-2 border border-slate-200 rounded-lg text-sm mt-1"
|
||||||
|
placeholder="••••••••"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-xs text-slate-500">Confirm New Password</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={passwordForm.confirm}
|
||||||
|
onChange={(e) => setPasswordForm({ ...passwordForm, confirm: e.target.value })}
|
||||||
|
className="w-full px-3 py-2 border border-slate-200 rounded-lg text-sm mt-1"
|
||||||
|
placeholder="••••••••"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="pt-4 border-t border-slate-100 flex gap-2">
|
||||||
|
<button className="py-2 px-4 bg-accent text-white rounded-lg text-sm hover:bg-accent-dark">
|
||||||
|
Update Password
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{activeTab === 'linked' && (
|
||||||
|
<div className="p-4">
|
||||||
|
<div className="mb-4">
|
||||||
|
<p className="text-sm text-slate-500">This user is linked to the following profiles. Click to navigate.</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{user.linkedProfile ? (
|
||||||
|
<div className="p-4 border border-accent rounded-lg bg-accent-light">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{user.linkedProfile.type === 'biker' && <Bike className="w-5 h-5 text-blue-500" />}
|
||||||
|
{user.linkedProfile.type === 'investor' && <Wallet className="w-5 h-5 text-green-500" />}
|
||||||
|
{user.linkedProfile.type === 'shop' && <Store className="w-5 h-5 text-amber-500" />}
|
||||||
|
{user.linkedProfile.type === 'merchant' && <Zap className="w-5 h-5 text-cyan-500" />}
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-slate-700">{user.linkedProfile.name}</p>
|
||||||
|
<p className="text-xs text-slate-400">{user.linkedProfile.id}</p>
|
||||||
|
</div>
|
||||||
|
<Link
|
||||||
|
href={`/admin/${user.linkedProfile.type === 'biker' ? 'bikers' : user.linkedProfile.type === 'investor' ? 'investors' : user.linkedProfile.type === 'merchant' ? 'merchants' : 'fleet'}/${user.linkedProfile.id.toLowerCase()}`}
|
||||||
|
className="ml-auto py-1 px-3 bg-accent text-white rounded text-xs"
|
||||||
|
>
|
||||||
|
View
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="text-center py-8 text-slate-500">
|
||||||
|
<Link2 className="w-8 h-8 mx-auto mb-2 text-slate-300" />
|
||||||
|
<p className="text-sm">No linked profile</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="mt-6">
|
||||||
|
<h4 className="font-medium text-slate-800 mb-3">Link New Profile</h4>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<Link href="/admin/bikers" className="p-3 border border-slate-200 rounded-lg hover:bg-slate-50 flex items-center gap-2">
|
||||||
|
<Bike className="w-4 h-4 text-blue-500" />
|
||||||
|
<span className="text-sm text-slate-600">Biker</span>
|
||||||
|
</Link>
|
||||||
|
<Link href="/admin/investors" className="p-3 border border-slate-200 rounded-lg hover:bg-slate-50 flex items-center gap-2">
|
||||||
|
<Wallet className="w-4 h-4 text-green-500" />
|
||||||
|
<span className="text-sm text-slate-600">Investor</span>
|
||||||
|
</Link>
|
||||||
|
<Link href="/admin/merchants" className="p-3 border border-slate-200 rounded-lg hover:bg-slate-50 flex items-center gap-2">
|
||||||
|
<Zap className="w-4 h-4 text-cyan-500" />
|
||||||
|
<span className="text-sm text-slate-600">Merchant</span>
|
||||||
|
</Link>
|
||||||
|
<Link href="/admin/swap-stations" className="p-3 border border-slate-200 rounded-lg hover:bg-slate-50 flex items-center gap-2">
|
||||||
|
<Store className="w-4 h-4 text-amber-500" />
|
||||||
|
<span className="text-sm text-slate-600">Swap Station</span>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
372
src/app/admin/users/page.tsx
Normal file
372
src/app/admin/users/page.tsx
Normal file
@@ -0,0 +1,372 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { Users, Plus, Search, X, Eye, Edit, Trash2, Shield, Mail, Phone, MapPin, CheckCircle, XCircle } from 'lucide-react';
|
||||||
|
import Link from 'next/link';
|
||||||
|
|
||||||
|
type UserRole = 'admin' | 'manager' | 'biker' | 'investor' | 'shop' | 'merchant';
|
||||||
|
type UserStatus = 'active' | 'inactive' | 'suspended';
|
||||||
|
|
||||||
|
interface User {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
email: string;
|
||||||
|
phone: string;
|
||||||
|
role: UserRole;
|
||||||
|
status: UserStatus;
|
||||||
|
avatar?: string;
|
||||||
|
lastLogin: string;
|
||||||
|
createdAt: string;
|
||||||
|
linkedProfile?: {
|
||||||
|
type: 'biker' | 'investor' | 'shop' | 'merchant' | 'swap-station';
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const mockUsers: User[] = [
|
||||||
|
{ id: 'USR-001', name: 'Admin User', email: 'admin@jaiben.com', phone: '+8801712345678', role: 'admin', status: 'active', lastLogin: '2024-02-16 10:30', createdAt: '2023-01-01' },
|
||||||
|
{ id: 'USR-002', name: 'Karim Manager', email: 'karim@jaiben.com', phone: '+8801812345678', role: 'manager', status: 'active', lastLogin: '2024-02-16 09:15', createdAt: '2023-06-15' },
|
||||||
|
{ id: 'USR-003', name: 'Jamal Biker', email: 'jamal@biker.com', phone: '+8801912345678', role: 'biker', status: 'active', lastLogin: '2024-02-15 18:00', createdAt: '2023-09-20', linkedProfile: { type: 'biker', id: 'BIK-001', name: 'Jamal Delivery' } },
|
||||||
|
{ id: 'USR-004', name: 'Rahim Investor', email: 'rahim@investor.com', phone: '+8801512345678', role: 'investor', status: 'active', lastLogin: '2024-02-14 12:00', createdAt: '2023-03-10', linkedProfile: { type: 'investor', id: 'INV-001', name: 'Rahim Investments' } },
|
||||||
|
{ id: 'USR-005', name: 'Shop Owner', email: 'shop@owner.com', phone: '+8801612345678', role: 'shop', status: 'active', lastLogin: '2024-02-15 14:30', createdAt: '2023-11-05', linkedProfile: { type: 'shop', id: 'SHP-001', name: 'Jaiben Shop' } },
|
||||||
|
{ id: 'USR-006', name: 'Merchant User', email: 'merchant@jaiben.com', phone: '+8801412345678', role: 'merchant', status: 'active', lastLogin: '2024-02-15 16:00', createdAt: '2023-08-15', linkedProfile: { type: 'merchant', id: 'MCH-001', name: 'Rahim Delivery Service' } },
|
||||||
|
];
|
||||||
|
|
||||||
|
const roleColors: Record<UserRole, string> = {
|
||||||
|
admin: 'bg-red-100 text-red-700',
|
||||||
|
manager: 'bg-purple-100 text-purple-700',
|
||||||
|
biker: 'bg-blue-100 text-blue-700',
|
||||||
|
investor: 'bg-green-100 text-green-700',
|
||||||
|
shop: 'bg-amber-100 text-amber-700',
|
||||||
|
merchant: 'bg-cyan-100 text-cyan-700'
|
||||||
|
};
|
||||||
|
|
||||||
|
const statusColors: Record<UserStatus, string> = {
|
||||||
|
active: 'bg-green-100 text-green-700',
|
||||||
|
inactive: 'bg-slate-100 text-slate-700',
|
||||||
|
suspended: 'bg-red-100 text-red-700'
|
||||||
|
};
|
||||||
|
|
||||||
|
const roleLabels: Record<UserRole, string> = {
|
||||||
|
admin: 'Admin',
|
||||||
|
manager: 'Manager',
|
||||||
|
biker: 'Biker',
|
||||||
|
investor: 'Investor',
|
||||||
|
shop: 'Shop',
|
||||||
|
merchant: 'Merchant'
|
||||||
|
};
|
||||||
|
|
||||||
|
const statusLabels: Record<UserStatus, string> = {
|
||||||
|
active: 'Active',
|
||||||
|
inactive: 'Inactive',
|
||||||
|
suspended: 'Suspended'
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function UsersPage() {
|
||||||
|
const [users, setUsers] = useState<User[]>(mockUsers);
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
|
const [roleFilter, setRoleFilter] = useState('all');
|
||||||
|
const [statusFilter, setStatusFilter] = useState('all');
|
||||||
|
const [showCreateModal, setShowCreateModal] = useState(false);
|
||||||
|
const [editingUser, setEditingUser] = useState<User | null>(null);
|
||||||
|
|
||||||
|
const [formData, setFormData] = useState({
|
||||||
|
name: '',
|
||||||
|
email: '',
|
||||||
|
phone: '',
|
||||||
|
role: 'biker' as UserRole,
|
||||||
|
status: 'active' as UserStatus
|
||||||
|
});
|
||||||
|
|
||||||
|
const filteredUsers = users.filter(u => {
|
||||||
|
const matchesSearch = u.name.toLowerCase().includes(search.toLowerCase()) ||
|
||||||
|
u.email.toLowerCase().includes(search.toLowerCase()) ||
|
||||||
|
u.phone.includes(search) ||
|
||||||
|
u.id.toLowerCase().includes(search.toLowerCase());
|
||||||
|
const matchesRole = roleFilter === 'all' || u.role === roleFilter;
|
||||||
|
const matchesStatus = statusFilter === 'all' || u.status === statusFilter;
|
||||||
|
return matchesSearch && matchesRole && matchesStatus;
|
||||||
|
});
|
||||||
|
|
||||||
|
const stats = {
|
||||||
|
total: users.length,
|
||||||
|
active: users.filter(u => u.status === 'active').length,
|
||||||
|
admins: users.filter(u => u.role === 'admin').length,
|
||||||
|
managers: users.filter(u => u.role === 'manager').length,
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSave = () => {
|
||||||
|
if (!formData.name || !formData.email || !formData.phone) return;
|
||||||
|
|
||||||
|
if (editingUser) {
|
||||||
|
setUsers(users.map(u => u.id === editingUser.id ? { ...u, ...formData } : u));
|
||||||
|
} else {
|
||||||
|
const newUser: User = {
|
||||||
|
id: `USR-${String(users.length + 1).padStart(3, '0')}`,
|
||||||
|
...formData,
|
||||||
|
lastLogin: '-',
|
||||||
|
createdAt: new Date().toISOString().split('T')[0]
|
||||||
|
};
|
||||||
|
setUsers([...users, newUser]);
|
||||||
|
}
|
||||||
|
|
||||||
|
setShowCreateModal(false);
|
||||||
|
setEditingUser(null);
|
||||||
|
setFormData({ name: '', email: '', phone: '', role: 'biker', status: 'active' });
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = (id: string) => {
|
||||||
|
if (confirm('Are you sure you want to delete this user?')) {
|
||||||
|
setUsers(users.filter(u => u.id !== id));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const openEdit = (user: User) => {
|
||||||
|
setEditingUser(user);
|
||||||
|
setFormData({
|
||||||
|
name: user.name,
|
||||||
|
email: user.email,
|
||||||
|
phone: user.phone,
|
||||||
|
role: user.role,
|
||||||
|
status: user.status
|
||||||
|
});
|
||||||
|
setShowCreateModal(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
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">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl lg:text-3xl font-extrabold text-slate-800">Users</h1>
|
||||||
|
<p className="text-sm text-slate-500 mt-1">Manage system users and permissions</p>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setEditingUser(null);
|
||||||
|
setFormData({ name: '', email: '', phone: '', role: 'biker', status: 'active' });
|
||||||
|
setShowCreateModal(true);
|
||||||
|
}}
|
||||||
|
className="py-2.5 px-4 bg-accent text-white rounded-lg font-semibold text-sm hover:bg-accent-dark transition-colors flex items-center gap-2"
|
||||||
|
>
|
||||||
|
<Plus className="w-4 h-4" /> Add User
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
|
||||||
|
<div className="bg-white rounded-xl p-5 shadow-sm border border-slate-100">
|
||||||
|
<p className="text-2xl font-extrabold text-slate-800">{stats.total}</p>
|
||||||
|
<p className="text-sm text-slate-500">Total Users</p>
|
||||||
|
</div>
|
||||||
|
<div className="bg-white rounded-xl p-5 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</p>
|
||||||
|
</div>
|
||||||
|
<div className="bg-white rounded-xl p-5 shadow-sm border border-slate-100">
|
||||||
|
<p className="text-2xl font-extrabold text-red-600">{stats.admins}</p>
|
||||||
|
<p className="text-sm text-slate-500">Admins</p>
|
||||||
|
</div>
|
||||||
|
<div className="bg-white rounded-xl p-5 shadow-sm border border-slate-100">
|
||||||
|
<p className="text-2xl font-extrabold text-purple-600">{stats.managers}</p>
|
||||||
|
<p className="text-sm text-slate-500">Managers</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="bg-white rounded-xl shadow-sm border border-slate-100">
|
||||||
|
<div className="p-4 border-b border-slate-100">
|
||||||
|
<div className="flex flex-col lg:flex-row gap-4">
|
||||||
|
<div className="relative flex-1 max-w-md">
|
||||||
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-slate-400" />
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="Search users..."
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
className="w-full pl-10 pr-4 py-2 border border-slate-200 rounded-lg text-sm focus:outline-none focus:ring-2 focus:ring-accent focus:border-transparent"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<select
|
||||||
|
value={roleFilter}
|
||||||
|
onChange={(e) => setRoleFilter(e.target.value)}
|
||||||
|
className="py-2 px-4 border border-slate-200 rounded-lg text-sm"
|
||||||
|
>
|
||||||
|
<option value="all">All Roles</option>
|
||||||
|
<option value="admin">Admin</option>
|
||||||
|
<option value="manager">Manager</option>
|
||||||
|
<option value="biker">Biker</option>
|
||||||
|
<option value="investor">Investor</option>
|
||||||
|
<option value="shop">Shop</option>
|
||||||
|
<option value="merchant">Merchant</option>
|
||||||
|
</select>
|
||||||
|
<select
|
||||||
|
value={statusFilter}
|
||||||
|
onChange={(e) => setStatusFilter(e.target.value)}
|
||||||
|
className="py-2 px-4 border border-slate-200 rounded-lg text-sm"
|
||||||
|
>
|
||||||
|
<option value="all">All Status</option>
|
||||||
|
<option value="active">Active</option>
|
||||||
|
<option value="inactive">Inactive</option>
|
||||||
|
<option value="suspended">Suspended</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full">
|
||||||
|
<thead className="bg-slate-50">
|
||||||
|
<tr>
|
||||||
|
<th className="px-4 py-3 text-left text-xs font-semibold text-slate-500 uppercase">User</th>
|
||||||
|
<th className="px-4 py-3 text-left text-xs font-semibold text-slate-500 uppercase">Contact</th>
|
||||||
|
<th className="px-4 py-3 text-left text-xs font-semibold text-slate-500 uppercase">Role</th>
|
||||||
|
<th className="px-4 py-3 text-left text-xs font-semibold text-slate-500 uppercase">Linked</th>
|
||||||
|
<th className="px-4 py-3 text-left text-xs font-semibold text-slate-500 uppercase">Status</th>
|
||||||
|
<th className="px-4 py-3 text-left text-xs font-semibold text-slate-500 uppercase">Last Login</th>
|
||||||
|
<th className="px-4 py-3 text-left text-xs font-semibold text-slate-500 uppercase">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-slate-50">
|
||||||
|
{filteredUsers.map(user => (
|
||||||
|
<tr key={user.id} className="hover:bg-slate-50 transition-colors">
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<Link href={`/admin/users/${user.id.toLowerCase()}`} className="flex items-center gap-3 hover:text-accent">
|
||||||
|
<div className="w-10 h-10 rounded-full bg-accent-light flex items-center justify-center">
|
||||||
|
<span className="text-sm font-bold text-accent">{user.name.charAt(0).toUpperCase()}</span>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-slate-700">{user.name}</p>
|
||||||
|
<p className="text-xs text-slate-400">{user.id}</p>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<p className="text-sm text-slate-600">{user.email}</p>
|
||||||
|
<p className="text-xs text-slate-400">{user.phone}</p>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<span className={`text-xs px-2 py-1 rounded-full ${roleColors[user.role]}`}>
|
||||||
|
{roleLabels[user.role]}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
{user.linkedProfile ? (
|
||||||
|
<span className="text-xs px-2 py-1 rounded-full bg-accent-light text-accent">
|
||||||
|
{user.linkedProfile.type}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-xs text-slate-400">-</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<span className={`text-xs px-2 py-1 rounded-full ${statusColors[user.status]}`}>
|
||||||
|
{statusLabels[user.status]}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-sm text-slate-500">{user.lastLogin}</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<button onClick={() => openEdit(user)} className="p-2 hover:bg-slate-100 rounded-lg">
|
||||||
|
<Edit className="w-4 h-4 text-slate-400" />
|
||||||
|
</button>
|
||||||
|
<button onClick={() => handleDelete(user.id)} className="p-2 hover:bg-red-50 rounded-lg">
|
||||||
|
<Trash2 className="w-4 h-4 text-red-400" />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</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-lg max-h-[90vh] overflow-y-auto">
|
||||||
|
<div className="p-4 border-b border-slate-100 flex justify-between items-center sticky top-0 bg-white">
|
||||||
|
<h3 className="font-semibold text-slate-800">{editingUser ? 'Edit User' : 'Add New User'}</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">Full Name *</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={formData.name}
|
||||||
|
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
||||||
|
className="w-full px-3 py-2 border border-slate-200 rounded-lg text-sm mt-1"
|
||||||
|
placeholder="Enter full name"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-sm text-slate-600">Email *</label>
|
||||||
|
<input
|
||||||
|
type="email"
|
||||||
|
value={formData.email}
|
||||||
|
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
|
||||||
|
className="w-full px-3 py-2 border border-slate-200 rounded-lg text-sm mt-1"
|
||||||
|
placeholder="email@example.com"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-sm text-slate-600">Phone *</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={formData.phone}
|
||||||
|
onChange={(e) => setFormData({ ...formData, phone: e.target.value })}
|
||||||
|
className="w-full px-3 py-2 border border-slate-200 rounded-lg text-sm mt-1"
|
||||||
|
placeholder="+8801xxxxxxxxx"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="text-sm text-slate-600">Role</label>
|
||||||
|
<select
|
||||||
|
value={formData.role}
|
||||||
|
onChange={(e) => setFormData({ ...formData, role: e.target.value as UserRole })}
|
||||||
|
className="w-full px-3 py-2 border border-slate-200 rounded-lg text-sm mt-1"
|
||||||
|
>
|
||||||
|
<option value="admin">Admin</option>
|
||||||
|
<option value="manager">Manager</option>
|
||||||
|
<option value="biker">Biker</option>
|
||||||
|
<option value="investor">Investor</option>
|
||||||
|
<option value="shop">Shop</option>
|
||||||
|
<option value="merchant">Merchant</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="text-sm text-slate-600">Status</label>
|
||||||
|
<select
|
||||||
|
value={formData.status}
|
||||||
|
onChange={(e) => setFormData({ ...formData, status: e.target.value as UserStatus })}
|
||||||
|
className="w-full px-3 py-2 border border-slate-200 rounded-lg text-sm mt-1"
|
||||||
|
>
|
||||||
|
<option value="active">Active</option>
|
||||||
|
<option value="inactive">Inactive</option>
|
||||||
|
<option value="suspended">Suspended</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="p-4 border-t border-slate-100 flex justify-end gap-2 sticky bottom-0 bg-white">
|
||||||
|
<button
|
||||||
|
onClick={() => setShowCreateModal(false)}
|
||||||
|
className="px-4 py-2 border border-slate-200 text-slate-600 rounded-lg text-sm"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={handleSave}
|
||||||
|
disabled={!formData.name || !formData.email || !formData.phone}
|
||||||
|
className="px-4 py-2 bg-accent text-white rounded-lg text-sm disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{editingUser ? 'Update' : 'Create'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -32,10 +32,17 @@ const adminNavItems = [
|
|||||||
{ label: 'Bikers', href: '/admin/bikers', icon: Users },
|
{ label: 'Bikers', href: '/admin/bikers', icon: Users },
|
||||||
{ label: 'Investors', href: '/admin/investors', icon: Wallet },
|
{ label: 'Investors', href: '/admin/investors', icon: Wallet },
|
||||||
{ label: 'Fleet Management', href: '/admin/fleet', icon: Bike },
|
{ label: 'Fleet Management', href: '/admin/fleet', icon: Bike },
|
||||||
|
{ label: 'Merchants (P2)', href: '/admin/merchants', icon: Store },
|
||||||
|
{ label: 'Swap Stations (P3)', href: '/admin/swap-stations', icon: Zap },
|
||||||
|
|
||||||
{ label: 'Damage & Maintenance', href: '/admin/maintenance', icon: Wrench },
|
{ label: 'Damage & Maintenance', href: '/admin/maintenance', icon: Wrench },
|
||||||
{ label: 'Accounting', href: '/admin/accounting', icon: Calculator },
|
{ label: 'Accounting', href: '/admin/accounting', icon: Calculator },
|
||||||
{ label: 'Hubs', href: '/admin/hub', icon: MapPin },
|
{ label: 'Hubs', href: '/admin/hub', icon: MapPin },
|
||||||
{ label: 'Reports', href: '/admin/reports', icon: BarChart3 },
|
{ label: 'Reports', href: '/admin/reports', icon: BarChart3 },
|
||||||
|
{ label: 'Users Management', href: '/admin/users', icon: Users },
|
||||||
|
{ label: 'Roles & Permissions', href: '/admin/roles', icon: Shield },
|
||||||
|
{ label: 'Settings', href: '/admin/settings', icon: Settings },
|
||||||
|
|
||||||
];
|
];
|
||||||
|
|
||||||
const bikerNavItems = [
|
const bikerNavItems = [
|
||||||
@@ -124,7 +131,7 @@ export default function Sidebar() {
|
|||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
<div className="absolute bottom-0 left-0 right-0 p-3 border-t border-slate-100 bg-white">
|
<div className="absolute bottom-0 left-0 right-0 p-3 border-t border-slate-100 bg-white">
|
||||||
<div className="flex items-center gap-3 px-3 py-2">
|
<Link href="/admin/users/USR-001" className="flex items-center gap-3 px-3 py-2 hover:bg-slate-50 rounded-lg -mx-1">
|
||||||
<div className="w-8 h-8 rounded-full bg-accent-light flex items-center justify-center">
|
<div className="w-8 h-8 rounded-full bg-accent-light flex items-center justify-center">
|
||||||
<span className="text-sm font-bold text-accent">A</span>
|
<span className="text-sm font-bold text-accent">A</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -135,7 +142,7 @@ export default function Sidebar() {
|
|||||||
<button className="p-1.5 hover:bg-slate-100 rounded-lg">
|
<button className="p-1.5 hover:bg-slate-100 rounded-lg">
|
||||||
<LogOut className="w-4 h-4 text-slate-400" />
|
<LogOut className="w-4 h-4 text-slate-400" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</Link>
|
||||||
<div className="mt-2 text-xs text-slate-400 text-center">
|
<div className="mt-2 text-xs text-slate-400 text-center">
|
||||||
<p>Phase 1 - Core EV Rental</p>
|
<p>Phase 1 - Core EV Rental</p>
|
||||||
<p className="mt-1">v1.0.0</p>
|
<p className="mt-1">v1.0.0</p>
|
||||||
|
|||||||
Reference in New Issue
Block a user