feat: add service center management module with CRUD functionality and status filtering
This commit is contained in:
801
src/app/admin/service-centers/[id]/page.tsx
Normal file
801
src/app/admin/service-centers/[id]/page.tsx
Normal file
@@ -0,0 +1,801 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useParams, useRouter } from 'next/navigation';
|
||||
import {
|
||||
ArrowLeft, Building2, Star, Phone, Mail, MapPin, Activity, Wrench,
|
||||
Battery, Bike, DollarSign, CheckCircle2, Clock, AlertTriangle, Search,
|
||||
SlidersHorizontal, ArrowUpDown, User, Calendar, Shield, Tag, Plus, Eye,
|
||||
BarChart3, Percent, ChevronRight, ExternalLink
|
||||
} from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
import { ServiceCenter } from '../page';
|
||||
|
||||
// Interface for Maintenance History Record
|
||||
interface HistoryRecord {
|
||||
id: string;
|
||||
date: string;
|
||||
assetId: string;
|
||||
assetType: 'EV Bike' | 'Battery';
|
||||
serviceType: 'Damage' | 'Repair' | 'Service' | 'Battery Swap' | 'Inspection';
|
||||
description: string;
|
||||
severity: 'critical' | 'major' | 'minor' | 'cosmetic';
|
||||
status: 'completed' | 'in_progress' | 'parts_ordered';
|
||||
estimatedCost: number;
|
||||
actualCost: number;
|
||||
partsUsed: { name: string; qty: number; price: number }[];
|
||||
laborCost: number;
|
||||
technician: string;
|
||||
}
|
||||
|
||||
// Generate realistic mock history data based on Center ID/Name
|
||||
const getMockHistoryData = (centerName: string): HistoryRecord[] => {
|
||||
const baseHistory: HistoryRecord[] = [
|
||||
{
|
||||
id: 'MNT-101',
|
||||
date: '2024-03-21',
|
||||
assetId: 'EV-004',
|
||||
assetType: 'EV Bike',
|
||||
serviceType: 'Damage',
|
||||
description: 'Front fender shattered in traffic collision. Replaced brackets and front wheel.',
|
||||
severity: 'major',
|
||||
status: 'in_progress',
|
||||
estimatedCost: 3500,
|
||||
actualCost: 3200,
|
||||
partsUsed: [
|
||||
{ name: 'Front fender', qty: 1, price: 1500 },
|
||||
{ name: 'Mounting brackets', qty: 2, price: 800 },
|
||||
{ name: 'Brake pads', qty: 1, price: 600 }
|
||||
],
|
||||
laborCost: 1200,
|
||||
technician: 'Sabbir Ahmed'
|
||||
},
|
||||
{
|
||||
id: 'MNT-102',
|
||||
date: '2024-03-18',
|
||||
assetId: 'BAT-044',
|
||||
assetType: 'Battery',
|
||||
serviceType: 'Battery Swap',
|
||||
description: 'Internal diagnostic showing rapid voltage degradation. Replaced cells and recalibrated BMS.',
|
||||
severity: 'critical',
|
||||
status: 'completed',
|
||||
estimatedCost: 12000,
|
||||
actualCost: 11500,
|
||||
partsUsed: [
|
||||
{ name: 'Battery 60V cell pack', qty: 1, price: 9500 },
|
||||
{ name: 'BMS Controller Board', qty: 1, price: 2000 }
|
||||
],
|
||||
laborCost: 2500,
|
||||
technician: 'Kamrul Hasan'
|
||||
},
|
||||
{
|
||||
id: 'MNT-103',
|
||||
date: '2024-03-15',
|
||||
assetId: 'EV-012',
|
||||
assetType: 'EV Bike',
|
||||
serviceType: 'Service',
|
||||
description: 'Routine 5,000km periodic maintenance. Calibrated drum brakes and greased chassis bearings.',
|
||||
severity: 'minor',
|
||||
status: 'completed',
|
||||
estimatedCost: 1500,
|
||||
actualCost: 1450,
|
||||
partsUsed: [
|
||||
{ name: 'Brake Cable', qty: 1, price: 250 },
|
||||
{ name: 'Sprocket kit', qty: 1, price: 450 }
|
||||
],
|
||||
laborCost: 750,
|
||||
technician: 'Sabbir Ahmed'
|
||||
},
|
||||
{
|
||||
id: 'MNT-104',
|
||||
date: '2024-03-10',
|
||||
assetId: 'BAT-021',
|
||||
assetType: 'Battery',
|
||||
serviceType: 'Inspection',
|
||||
description: 'Thermal warning flag during hyper-charging cycle. Terminals cleaned and thermal gel reapplied.',
|
||||
severity: 'cosmetic',
|
||||
status: 'completed',
|
||||
estimatedCost: 500,
|
||||
actualCost: 400,
|
||||
partsUsed: [
|
||||
{ name: 'Thermal paste', qty: 1, price: 150 }
|
||||
],
|
||||
laborCost: 250,
|
||||
technician: 'Kamrul Hasan'
|
||||
},
|
||||
{
|
||||
id: 'MNT-105',
|
||||
date: '2024-03-05',
|
||||
assetId: 'EV-009',
|
||||
assetType: 'EV Bike',
|
||||
serviceType: 'Repair',
|
||||
description: 'Throttle failure reported by delivery driver. Replaced magnetic sensor assembly.',
|
||||
severity: 'major',
|
||||
status: 'completed',
|
||||
estimatedCost: 1800,
|
||||
actualCost: 2100,
|
||||
partsUsed: [
|
||||
{ name: 'Throttle control assembly', qty: 1, price: 800 },
|
||||
{ name: 'Wiring loom adapter', qty: 1, price: 450 }
|
||||
],
|
||||
laborCost: 850,
|
||||
technician: 'Rafiqul Islam'
|
||||
},
|
||||
{
|
||||
id: 'MNT-106',
|
||||
date: '2024-02-28',
|
||||
assetId: 'EV-017',
|
||||
assetType: 'EV Bike',
|
||||
serviceType: 'Damage',
|
||||
description: 'Rear tire blowout due to road debris. Replacement and alignment completed.',
|
||||
severity: 'minor',
|
||||
status: 'completed',
|
||||
estimatedCost: 2800,
|
||||
actualCost: 2750,
|
||||
partsUsed: [
|
||||
{ name: 'Rear Tire tubeless', qty: 1, price: 2200 },
|
||||
{ name: 'Chain replacement', qty: 1, price: 400 }
|
||||
],
|
||||
laborCost: 500,
|
||||
technician: 'Sabbir Ahmed'
|
||||
},
|
||||
{
|
||||
id: 'MNT-107',
|
||||
date: '2024-02-20',
|
||||
assetId: 'BAT-089',
|
||||
assetType: 'Battery',
|
||||
serviceType: 'Battery Swap',
|
||||
description: 'Dead module replacement under premium warranty. Replaced sub-assemblies.',
|
||||
severity: 'critical',
|
||||
status: 'parts_ordered',
|
||||
estimatedCost: 15000,
|
||||
actualCost: 0,
|
||||
partsUsed: [
|
||||
{ name: 'Battery 48V cell pack', qty: 1, price: 8000 }
|
||||
],
|
||||
laborCost: 1500,
|
||||
technician: 'Kamrul Hasan'
|
||||
}
|
||||
];
|
||||
|
||||
// Variations in records based on Center's specialty & size to make data dynamic
|
||||
if (centerName.includes('Gulshan') || centerName.includes('Center A')) {
|
||||
return baseHistory;
|
||||
} else if (centerName.includes('Banani') || centerName.includes('Center B')) {
|
||||
return baseHistory.filter(h => h.serviceType === 'Battery Swap' || h.serviceType === 'Service' || h.serviceType === 'Inspection').map(h => ({
|
||||
...h,
|
||||
id: h.id.replace('10', '20'),
|
||||
technician: 'Tanvir Rahman'
|
||||
}));
|
||||
} else {
|
||||
// Uttara / Authorized
|
||||
return baseHistory.filter(h => h.serviceType === 'Inspection' || h.serviceType === 'Repair').map(h => ({
|
||||
...h,
|
||||
id: h.id.replace('10', '30'),
|
||||
technician: 'Arif Chowdhury'
|
||||
}));
|
||||
}
|
||||
};
|
||||
|
||||
export default function ServiceCenterDetailsPage() {
|
||||
const params = useParams();
|
||||
const router = useRouter();
|
||||
const id = params.id as string;
|
||||
|
||||
const [isMounted, setIsMounted] = useState(false);
|
||||
const [center, setCenter] = useState<ServiceCenter | null>(null);
|
||||
const [history, setHistory] = useState<HistoryRecord[]>([]);
|
||||
|
||||
// Filtering / Sorting / Search states for history
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [assetFilter, setAssetFilter] = useState('all');
|
||||
const [typeFilter, setTypeFilter] = useState('all');
|
||||
const [severityFilter, setSeverityFilter] = useState('all');
|
||||
const [statusFilter, setStatusFilter] = useState('all');
|
||||
|
||||
const [sortBy, setSortBy] = useState<'date-desc' | 'date-asc' | 'cost-desc' | 'cost-asc' | 'urgency-desc' | 'urgency-asc'>('date-desc');
|
||||
|
||||
// Map Interactive detail popup state
|
||||
const [mapPopup, setMapPopup] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
setIsMounted(true);
|
||||
|
||||
// Load Service Centers from localStorage
|
||||
const stored = localStorage.getItem('jaiben_service_centers');
|
||||
let foundCenter: ServiceCenter | null = null;
|
||||
|
||||
if (stored) {
|
||||
try {
|
||||
const centers: ServiceCenter[] = JSON.parse(stored);
|
||||
foundCenter = centers.find(c => c.id === id) || null;
|
||||
} catch (e) {}
|
||||
}
|
||||
|
||||
if (foundCenter) {
|
||||
setCenter(foundCenter);
|
||||
setHistory(getMockHistoryData(foundCenter.name));
|
||||
} else {
|
||||
router.push('/admin/service-centers');
|
||||
}
|
||||
}, [id, router]);
|
||||
|
||||
if (!isMounted || !center) return null;
|
||||
|
||||
// Filter History records
|
||||
const filteredHistory = history.filter(h => {
|
||||
const matchesSearch = h.id.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
h.assetId.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
h.description.toLowerCase().includes(searchQuery.toLowerCase()) ||
|
||||
h.technician.toLowerCase().includes(searchQuery.toLowerCase());
|
||||
|
||||
const matchesAsset = assetFilter === 'all' || h.assetType === assetFilter;
|
||||
const matchesType = typeFilter === 'all' || h.serviceType === typeFilter;
|
||||
const matchesSeverity = severityFilter === 'all' || h.severity === severityFilter;
|
||||
const matchesStatus = statusFilter === 'all' || h.status === statusFilter;
|
||||
|
||||
return matchesSearch && matchesAsset && matchesType && matchesSeverity && matchesStatus;
|
||||
});
|
||||
|
||||
// Sort History records
|
||||
const severityWeights = { cosmetic: 1, minor: 2, major: 3, critical: 4 };
|
||||
const sortedHistory = [...filteredHistory].sort((a, b) => {
|
||||
switch (sortBy) {
|
||||
case 'date-desc':
|
||||
return new Date(b.date).getTime() - new Date(a.date).getTime();
|
||||
case 'date-asc':
|
||||
return new Date(a.date).getTime() - new Date(b.date).getTime();
|
||||
case 'cost-desc':
|
||||
return (b.actualCost || b.estimatedCost) - (a.actualCost || a.estimatedCost);
|
||||
case 'cost-asc':
|
||||
return (a.actualCost || a.estimatedCost) - (b.actualCost || b.estimatedCost);
|
||||
case 'urgency-desc':
|
||||
return severityWeights[b.severity] - severityWeights[a.severity];
|
||||
case 'urgency-asc':
|
||||
return severityWeights[a.severity] - severityWeights[b.severity];
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
});
|
||||
|
||||
// Financial & Aggregate calculations
|
||||
const totalRepairs = history.length;
|
||||
const completedRepairs = history.filter(h => h.status === 'completed');
|
||||
|
||||
const totalEstimatedCost = completedRepairs.reduce((sum, h) => sum + h.estimatedCost, 0);
|
||||
const totalActualCost = completedRepairs.reduce((sum, h) => sum + h.actualCost, 0);
|
||||
const costVariance = totalActualCost - totalEstimatedCost;
|
||||
|
||||
const totalPartsCost = completedRepairs.reduce((sum, h) => sum + h.partsUsed.reduce((s, p) => s + (p.price * p.qty), 0), 0);
|
||||
const totalLaborCost = completedRepairs.reduce((sum, h) => sum + h.laborCost, 0);
|
||||
const totalSpend = totalPartsCost + totalLaborCost;
|
||||
|
||||
// Aggregate Parts Utilized Log
|
||||
const partsAggregated: { name: string; totalQty: number; totalCost: number }[] = [];
|
||||
history.forEach(h => {
|
||||
h.partsUsed.forEach(part => {
|
||||
const existing = partsAggregated.find(p => p.name === part.name);
|
||||
if (existing) {
|
||||
existing.totalQty += part.qty;
|
||||
existing.totalCost += part.price * part.qty;
|
||||
} else {
|
||||
partsAggregated.push({
|
||||
name: part.name,
|
||||
totalQty: part.qty,
|
||||
totalCost: part.price * part.qty
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
const topParts = partsAggregated.sort((a, b) => b.totalQty - a.totalQty).slice(0, 5);
|
||||
|
||||
const statusColors = {
|
||||
active: 'bg-emerald-100 text-emerald-700',
|
||||
busy: 'bg-amber-100 text-amber-700',
|
||||
inactive: 'bg-slate-100 text-slate-700'
|
||||
};
|
||||
|
||||
const severityColors = {
|
||||
critical: 'bg-red-100 text-red-700',
|
||||
major: 'bg-orange-100 text-orange-700',
|
||||
minor: 'bg-amber-100 text-amber-700',
|
||||
cosmetic: 'bg-slate-100 text-slate-700'
|
||||
};
|
||||
|
||||
const statusHistoryColors = {
|
||||
completed: 'bg-emerald-100 text-emerald-700',
|
||||
in_progress: 'bg-blue-100 text-blue-700',
|
||||
parts_ordered: 'bg-purple-100 text-purple-700'
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-4 lg:p-6 mb-6 lg:mb-0 space-y-6 max-w-8xl mx-auto">
|
||||
|
||||
{/* Navigation Top - standard layout of other detail profiles */}
|
||||
<div className="flex items-center justify-between border-b border-slate-100 pb-4 mb-4">
|
||||
<button
|
||||
onClick={() => router.push('/admin/service-centers')}
|
||||
className="flex items-center gap-2 text-slate-600 hover:text-slate-900 text-sm font-semibold transition-colors duration-200"
|
||||
>
|
||||
<ArrowLeft className="w-4 h-4 text-slate-400" /> Back to Service Centers
|
||||
</button>
|
||||
|
||||
<span className="text-xs font-bold text-slate-400">
|
||||
Node Registry: {center.id}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Main Profile Info Header - rounded-xl alignment matching maintenance page */}
|
||||
<div className="bg-white rounded-xl p-6 border border-slate-100 shadow-sm space-y-6 relative overflow-hidden">
|
||||
|
||||
{/* Glow effect decorative */}
|
||||
<div className="absolute top-0 right-0 w-64 h-64 bg-accent/5 rounded-full blur-3xl -mr-16 -mt-16 pointer-events-none" />
|
||||
|
||||
<div className="flex flex-col lg:flex-row lg:items-start justify-between gap-6 relative z-10">
|
||||
|
||||
<div className="space-y-4 flex-1">
|
||||
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<div className="w-14 h-14 rounded-xl bg-slate-50 border border-slate-100 flex items-center justify-center flex-shrink-0">
|
||||
<Building2 className="w-7 h-7 text-slate-600" />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-2xl lg:text-3xl font-extrabold text-slate-800 leading-tight">{center.name}</h1>
|
||||
<div className="flex flex-wrap items-center gap-2 mt-1">
|
||||
<span className={`px-2.5 py-0.5 text-xs font-semibold rounded-full border border-transparent ${statusColors[center.status]}`}>
|
||||
{center.status}
|
||||
</span>
|
||||
<div className="flex items-center gap-1 text-yellow-500 font-bold text-sm bg-yellow-50 px-2 py-0.5 rounded border border-yellow-100">
|
||||
<Star className="w-3.5 h-3.5 fill-yellow-500" />
|
||||
<span>{center.rating.toFixed(1)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Profile items - clean, consistent spacing */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-y-3 gap-x-6 text-sm text-slate-600 pt-2">
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<MapPin className="w-4 h-4 text-slate-400" />
|
||||
<div className="flex flex-wrap items-center gap-x-2">
|
||||
<span>{center.address}</span>
|
||||
{center.googleMapLink && (
|
||||
<a
|
||||
href={center.googleMapLink}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-accent hover:underline flex items-center gap-0.5 font-bold"
|
||||
>
|
||||
Map Link <ExternalLink className="w-3 h-3" />
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Phone className="w-4 h-4 text-slate-400" />
|
||||
<span>{center.phone}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Mail className="w-4 h-4 text-slate-400" />
|
||||
<span>{center.email}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Shield className="w-4 h-4 text-slate-400" />
|
||||
<span>Staff: <strong className="font-semibold text-slate-800">{center.staffCount} technicians</strong></span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Wrench className="w-4 h-4 text-slate-400" />
|
||||
<span>Capacity: <strong className="font-semibold text-slate-800">{center.capacity}</strong> total slots</span>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
{/* Specialization List Header */}
|
||||
<div className="space-y-1.5 pt-2">
|
||||
<span className="text-[10px] font-extrabold uppercase text-slate-400 tracking-wider">Node Specializations</span>
|
||||
<div className="flex flex-wrap gap-1.5">
|
||||
{center.specialization.map(spec => (
|
||||
<span key={spec} className="px-2.5 py-1 bg-slate-50 border border-slate-100 rounded-lg text-xs font-bold text-slate-600">
|
||||
{spec}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
{/* Quick Stats Header Summary - simplified matching maintenance specs, occupancy not needed */}
|
||||
<div className="grid grid-cols-2 gap-4 bg-slate-50 p-5 rounded-xl border border-slate-100 w-full lg:max-w-xs flex-shrink-0">
|
||||
|
||||
<div className="space-y-1">
|
||||
<p className="text-xs font-bold text-slate-400 uppercase tracking-wide">Repairs Logs</p>
|
||||
<p className="text-2xl font-extrabold text-slate-800">{totalRepairs}</p>
|
||||
<p className="text-[10px] text-slate-500">{completedRepairs.length} completed</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1">
|
||||
<p className="text-xs font-bold text-slate-400 uppercase tracking-wide">Capacity</p>
|
||||
<p className="text-2xl font-extrabold text-slate-800">{center.capacity}</p>
|
||||
<p className="text-[10px] text-slate-500">Service slots registered</p>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
{/* Analytics: Map Mockup & Cost Breakdown Grid */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
|
||||
{/* INTERACTIVE STYLIZED MAP CONTAINER - aligned clean white rounded-xl styles */}
|
||||
<div className="bg-white rounded-xl p-6 border border-slate-100 shadow-sm space-y-4 flex flex-col justify-between lg:col-span-1 min-h-[380px]">
|
||||
|
||||
<div className="space-y-1">
|
||||
<h3 className="font-extrabold text-slate-800 text-lg">Node Location Map</h3>
|
||||
<p className="text-xs text-slate-400">Dhaka city arterial coverage grid mockup</p>
|
||||
</div>
|
||||
|
||||
{/* Map canvas container */}
|
||||
<div className="flex-1 bg-slate-900 rounded-xl relative overflow-hidden border border-slate-850 shadow-inner flex items-center justify-center min-h-[220px]">
|
||||
|
||||
{/* Pulsating target coordinate representing the Center */}
|
||||
<div
|
||||
className="absolute w-8 h-8 flex items-center justify-center cursor-pointer group z-20"
|
||||
style={{ top: '45%', left: '50%', transform: 'translate(-50%, -50%)' }}
|
||||
onClick={() => setMapPopup(center.name)}
|
||||
>
|
||||
<span className="absolute inline-flex h-full w-full rounded-full bg-accent opacity-75 animate-ping" />
|
||||
<div className="relative w-4 h-4 bg-accent border-2 border-white rounded-full flex items-center justify-center shadow-lg group-hover:scale-125 transition-transform">
|
||||
<div className="w-1.5 h-1.5 bg-white rounded-full" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Stylized Dhaka grids & landmarks using SVGs */}
|
||||
<svg className="w-full h-full absolute inset-0 opacity-40 select-none pointer-events-none" viewBox="0 0 100 100" preserveAspectRatio="none">
|
||||
{/* Arterial Highways */}
|
||||
<line x1="20" y1="0" x2="20" y2="100" stroke="#475569" strokeWidth="0.75" strokeDasharray="2" />
|
||||
<line x1="50" y1="0" x2="50" y2="100" stroke="#475569" strokeWidth="1.5" />
|
||||
<line x1="80" y1="0" x2="80" y2="100" stroke="#475569" strokeWidth="0.75" />
|
||||
|
||||
<line x1="0" y1="30" x2="100" y2="30" stroke="#475569" strokeWidth="0.75" />
|
||||
<line x1="0" y1="50" x2="100" y2="50" stroke="#475569" strokeWidth="1.5" />
|
||||
<line x1="0" y1="80" x2="100" y2="80" stroke="#475569" strokeWidth="0.75" />
|
||||
|
||||
{/* Waterway (Gulshan Lake) */}
|
||||
<path d="M 50,0 Q 52,25 48,50 T 54,100" fill="none" stroke="#1e3a8a" strokeWidth="3" opacity="0.3" />
|
||||
<path d="M 47,40 Q 70,45 80,42" fill="none" stroke="#1e3a8a" strokeWidth="2.5" opacity="0.3" />
|
||||
|
||||
{/* Other hubs mockup dots */}
|
||||
<circle cx="20" cy="30" r="1.5" fill="#4f46e5" />
|
||||
<circle cx="80" cy="30" r="1.5" fill="#4f46e5" />
|
||||
<circle cx="20" cy="80" r="1.5" fill="#4f46e5" />
|
||||
<circle cx="80" cy="80" r="1.5" fill="#4f46e5" />
|
||||
</svg>
|
||||
|
||||
{/* Scale watermark */}
|
||||
<div className="absolute bottom-2 left-2 text-[9px] text-slate-500 font-bold bg-slate-950/75 px-1.5 py-0.5 rounded border border-slate-800">
|
||||
GPS: {center.latitude.toFixed(4)}°N, {center.longitude.toFixed(4)}°E
|
||||
</div>
|
||||
|
||||
{/* Stylized popup when clicked */}
|
||||
{mapPopup && (
|
||||
<div className="absolute top-2 right-2 left-2 bg-slate-950/90 border border-slate-800 rounded-lg p-2.5 text-xs text-white z-30 animate-fadeIn space-y-1">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="font-extrabold text-accent">{center.name}</span>
|
||||
<button onClick={() => setMapPopup(null)} className="text-slate-400 hover:text-white font-bold">×</button>
|
||||
</div>
|
||||
<p className="text-[10px] text-slate-400">{center.address}</p>
|
||||
<div className="flex justify-between pt-1 border-t border-slate-800 text-[9px] text-slate-400 font-bold">
|
||||
<span>Capacity: {center.capacity} slots</span>
|
||||
<span>Rating: {center.rating.toFixed(1)}</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Custom street labels */}
|
||||
<div className="absolute top-4 left-[53%] text-[8px] font-bold text-slate-600 tracking-widest uppercase origin-center rotate-90 select-none">
|
||||
Gulshan Lake Road
|
||||
</div>
|
||||
<div className="absolute top-[52%] left-4 text-[8px] font-bold text-slate-600 tracking-widest uppercase select-none">
|
||||
Tejgaon-Gulshan Link Road
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
<div className="text-center text-[10px] font-bold text-slate-400 uppercase tracking-widest pt-1">
|
||||
📍 Click GPS coordinate node for telemetry details
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
{/* FINANCIAL PERFORMANCE & EXPENSE TRACKING */}
|
||||
<div className="bg-white rounded-xl p-6 border border-slate-100 shadow-sm space-y-6 lg:col-span-2">
|
||||
|
||||
<div className="space-y-1">
|
||||
<h3 className="font-extrabold text-slate-800 text-lg">Financial Performance & Cost Margins</h3>
|
||||
<p className="text-xs text-slate-400">Aggregated historical metrics from completed maintenance invoices</p>
|
||||
</div>
|
||||
|
||||
{/* Financial details panel */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
|
||||
<div className="bg-slate-50 border border-slate-100 p-4 rounded-xl flex flex-col justify-between">
|
||||
<span className="text-xs font-bold text-slate-400 uppercase tracking-wider block">Estimated Spend</span>
|
||||
<div className="mt-2">
|
||||
<p className="text-2xl font-extrabold text-slate-800">৳{totalEstimatedCost.toLocaleString()}</p>
|
||||
<p className="text-[10px] text-slate-500 mt-0.5">Budgeted repairs cost</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-indigo-50/50 border border-indigo-100/50 p-4 rounded-xl flex flex-col justify-between">
|
||||
<span className="text-xs font-bold text-slate-400 uppercase tracking-wider block">Actual Invoice Spend</span>
|
||||
<div className="mt-2">
|
||||
<p className="text-2xl font-extrabold text-indigo-700">৳{totalActualCost.toLocaleString()}</p>
|
||||
<p className="text-[10px] text-indigo-500 mt-0.5">Billed repair totals</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={`p-4 rounded-xl border flex flex-col justify-between ${costVariance > 0 ? 'bg-rose-50 border-rose-100' : 'bg-emerald-50 border-emerald-100'}`}>
|
||||
<span className="text-xs font-bold text-slate-400 uppercase tracking-wider block">Cost Variance</span>
|
||||
<div className="mt-2">
|
||||
<p className={`text-2xl font-extrabold ${costVariance > 0 ? 'text-rose-700' : 'text-emerald-700'}`}>
|
||||
{costVariance > 0 ? `+৳${costVariance.toLocaleString()}` : `-৳${Math.abs(costVariance).toLocaleString()}`}
|
||||
</p>
|
||||
<p className="text-[10px] text-slate-500 mt-0.5">
|
||||
{costVariance > 0 ? 'Over budget invoices' : 'Under budget savings!'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
{/* Parts Used Aggregates & Labor Breakdown */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6 pt-2">
|
||||
|
||||
{/* Margins */}
|
||||
<div className="space-y-4">
|
||||
<h4 className="text-xs font-extrabold uppercase text-slate-400 tracking-wider">Expense Margin Distribution</h4>
|
||||
|
||||
<div className="space-y-3">
|
||||
|
||||
{/* Parts Spend Bar */}
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center justify-between text-xs font-bold text-slate-600">
|
||||
<span className="flex items-center gap-1"><Battery className="w-3.5 h-3.5 text-indigo-500" /> Spare Parts Cost</span>
|
||||
<span>৳{totalPartsCost.toLocaleString()} ({totalSpend > 0 ? Math.round((totalPartsCost/totalSpend)*100) : 0}%)</span>
|
||||
</div>
|
||||
<div className="w-full bg-slate-100 rounded-full h-2">
|
||||
<div
|
||||
className="bg-indigo-650 h-full rounded-full"
|
||||
style={{ width: `${totalSpend > 0 ? (totalPartsCost/totalSpend)*100 : 0}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Labor Spend Bar */}
|
||||
<div className="space-y-1">
|
||||
<div className="flex items-center justify-between text-xs font-bold text-slate-600">
|
||||
<span className="flex items-center gap-1"><Wrench className="w-3.5 h-3.5 text-emerald-500" /> Labor Costs</span>
|
||||
<span>৳{totalLaborCost.toLocaleString()} ({totalSpend > 0 ? Math.round((totalLaborCost/totalSpend)*100) : 0}%)</span>
|
||||
</div>
|
||||
<div className="w-full bg-slate-100 rounded-full h-2">
|
||||
<div
|
||||
className="bg-emerald-550 h-full rounded-full"
|
||||
style={{ width: `${totalSpend > 0 ? (totalLaborCost/totalSpend)*100 : 0}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
{/* General Health Tip */}
|
||||
<div className="p-3 bg-slate-50 border border-slate-100 rounded-lg text-xs text-slate-500 flex items-start gap-2">
|
||||
<AlertTriangle className="w-4 h-4 text-amber-500 flex-shrink-0 mt-0.5" />
|
||||
<span>
|
||||
<strong>Cost Ratio Notice:</strong> This node maintains a healthy parts-to-labor ratio of {totalSpend > 0 ? Math.round((totalPartsCost/totalSpend)*100) : 0}:{totalSpend > 0 ? Math.round((totalLaborCost/totalSpend)*100) : 0}. Lower labor ratios reflect technician efficiency.
|
||||
</span>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
{/* Parts utilized list */}
|
||||
<div className="space-y-3">
|
||||
<h4 className="text-xs font-extrabold uppercase text-slate-400 tracking-wider">Top Spare Parts Log</h4>
|
||||
|
||||
<div className="divide-y divide-slate-100 border border-slate-100 rounded-xl overflow-hidden bg-slate-50/50">
|
||||
{topParts.length === 0 ? (
|
||||
<p className="text-xs text-slate-400 p-4 text-center">No spare parts recorded yet</p>
|
||||
) : topParts.map(part => (
|
||||
<div key={part.name} className="p-2.5 flex items-center justify-between text-xs text-slate-600">
|
||||
<span className="font-semibold text-slate-700">{part.name}</span>
|
||||
<div className="flex items-center gap-4 text-right">
|
||||
<span className="font-bold text-slate-500">Qty: {part.totalQty}</span>
|
||||
<span className="font-extrabold text-slate-800">৳{part.totalCost.toLocaleString()}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
|
||||
{/* Interactive History Log - aligned standard filters and table headers */}
|
||||
<div className="bg-white rounded-xl p-6 border border-slate-100 shadow-sm space-y-6">
|
||||
|
||||
{/* Section title */}
|
||||
<div className="flex flex-col lg:flex-row lg:items-center justify-between gap-4 border-b border-slate-100 pb-4">
|
||||
<div className="space-y-1">
|
||||
<h3 className="font-extrabold text-slate-800 text-lg flex items-center gap-2">
|
||||
<Activity className="w-5 h-5 text-indigo-500" />
|
||||
<span>Serviced Fleet History Log</span>
|
||||
</h3>
|
||||
<p className="text-xs text-slate-400">Integrated audit list for EV bikes and Battery Swap maintenance nodes</p>
|
||||
</div>
|
||||
|
||||
{/* Quick Counter */}
|
||||
<span className="px-3 py-1 bg-indigo-50 border border-indigo-100/50 rounded-lg text-xs font-extrabold text-indigo-700 self-start lg:self-auto">
|
||||
{sortedHistory.length} Matches Found
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Filter Controls Panel */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-5 gap-3 bg-slate-50 p-4 rounded-lg border border-slate-100">
|
||||
|
||||
{/* Search bar */}
|
||||
<div className="relative col-span-1 lg:col-span-2">
|
||||
<Search className="w-4 h-4 absolute left-3 top-1/2 -translate-y-1/2 text-slate-400" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search by ID, tech, description..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="w-full pl-9 pr-3 py-2 border border-slate-200 rounded-lg text-xs focus:outline-none focus:ring-2 focus:ring-accent focus:border-transparent transition-all bg-white"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Filter 1: Asset Type */}
|
||||
<select
|
||||
value={assetFilter}
|
||||
onChange={(e) => setAssetFilter(e.target.value)}
|
||||
className="py-2 px-3 border border-slate-200 rounded-lg text-xs font-semibold text-slate-600 bg-white focus:outline-none focus:ring-2 focus:ring-accent focus:border-transparent transition-all"
|
||||
>
|
||||
<option value="all">All Asset Types</option>
|
||||
<option value="EV Bike">EV Bike</option>
|
||||
<option value="Battery">Battery</option>
|
||||
</select>
|
||||
|
||||
{/* Filter 2: Service Type */}
|
||||
<select
|
||||
value={typeFilter}
|
||||
onChange={(e) => setTypeFilter(e.target.value)}
|
||||
className="py-2 px-3 border border-slate-200 rounded-lg text-xs font-semibold text-slate-600 bg-white focus:outline-none focus:ring-2 focus:ring-accent focus:border-transparent transition-all"
|
||||
>
|
||||
<option value="all">All Service Types</option>
|
||||
<option value="Damage">Damage</option>
|
||||
<option value="Repair">Repair</option>
|
||||
<option value="Service">Service</option>
|
||||
<option value="Battery Swap">Battery Swap</option>
|
||||
<option value="Inspection">Inspection</option>
|
||||
</select>
|
||||
|
||||
{/* Sorting Dropdown */}
|
||||
<select
|
||||
value={sortBy}
|
||||
onChange={(e) => setSortBy(e.target.value as any)}
|
||||
className="py-2 px-3 border border-slate-200 rounded-lg text-xs font-bold text-indigo-600 bg-white focus:outline-none focus:ring-2 focus:ring-accent focus:border-transparent transition-all"
|
||||
>
|
||||
<option value="date-desc">📆 Date: Newest First</option>
|
||||
<option value="date-asc">📆 Date: Oldest First</option>
|
||||
<option value="cost-desc">৳ Cost: Highest First</option>
|
||||
<option value="cost-asc">৳ Cost: Lowest First</option>
|
||||
<option value="urgency-desc">⚠️ Severity: Critical First</option>
|
||||
<option value="urgency-asc">⚠️ Severity: Cosmetic First</option>
|
||||
</select>
|
||||
|
||||
</div>
|
||||
|
||||
{/* Table / List representation */}
|
||||
{sortedHistory.length === 0 ? (
|
||||
<div className="text-center py-12 bg-slate-50 rounded-lg border border-dashed border-slate-200">
|
||||
<Activity className="w-12 h-12 text-slate-300 mx-auto mb-3" />
|
||||
<p className="text-sm font-semibold text-slate-500">No matching service records</p>
|
||||
<p className="text-xs text-slate-400 mt-0.5">Try clearing filters or search variables</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto border border-slate-100 rounded-lg">
|
||||
<table className="w-full text-left border-collapse">
|
||||
<thead>
|
||||
<tr className="bg-slate-50 border-b border-slate-100 text-xs font-semibold uppercase tracking-wider text-slate-500">
|
||||
<th className="py-3 px-4">Record ID</th>
|
||||
<th className="py-3 px-4">Asset Code</th>
|
||||
<th className="py-3 px-4">Service Type</th>
|
||||
<th className="py-3 px-4">Description</th>
|
||||
<th className="py-3 px-4 text-center">Severity</th>
|
||||
<th className="py-3 px-4 text-center">Status</th>
|
||||
<th className="py-3 px-4 text-right">Invoice cost</th>
|
||||
<th className="py-3 px-4 text-right">Details</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-slate-100 text-sm text-slate-700">
|
||||
{sortedHistory.map(record => (
|
||||
<tr key={record.id} className="hover:bg-slate-50/50 transition-colors">
|
||||
<td className="py-4 px-4 font-bold text-slate-400">{record.id}</td>
|
||||
|
||||
<td className="py-4 px-4">
|
||||
<div className="flex items-center gap-1.5">
|
||||
{record.assetType === 'EV Bike' ? (
|
||||
<Bike className="w-4 h-4 text-purple-600 flex-shrink-0" />
|
||||
) : (
|
||||
<Battery className="w-4 h-4 text-green-600 flex-shrink-0" />
|
||||
)}
|
||||
<div className="space-y-0.5">
|
||||
<span className="font-extrabold text-slate-800">{record.assetId}</span>
|
||||
<span className="text-[10px] text-slate-400 uppercase font-semibold block">{record.assetType}</span>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
<td className="py-4 px-4 font-semibold text-slate-700">{record.serviceType}</td>
|
||||
|
||||
<td className="py-4 px-4 max-w-sm">
|
||||
<div className="space-y-1">
|
||||
<p className="text-xs text-slate-600 line-clamp-2 leading-relaxed">{record.description}</p>
|
||||
<div className="flex items-center gap-3 text-[10px] text-slate-400 font-semibold">
|
||||
<span className="flex items-center gap-0.5"><User className="w-3 h-3" /> Tech: {record.technician}</span>
|
||||
<span className="flex items-center gap-0.5"><Calendar className="w-3 h-3" /> {record.date}</span>
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
<td className="py-4 px-4 text-center">
|
||||
<span className={`px-2 py-0.5 text-xs font-semibold rounded-full border inline-block ${severityColors[record.severity]}`}>
|
||||
{record.severity}
|
||||
</span>
|
||||
</td>
|
||||
|
||||
<td className="py-4 px-4 text-center">
|
||||
<span className={`px-2.5 py-0.5 text-xs font-semibold rounded-full border inline-block ${statusHistoryColors[record.status]}`}>
|
||||
{record.status.replace('_', ' ')}
|
||||
</span>
|
||||
</td>
|
||||
|
||||
<td className="py-4 px-4 text-right font-extrabold text-slate-800">
|
||||
{record.actualCost > 0 ? (
|
||||
<span>৳{record.actualCost.toLocaleString()}</span>
|
||||
) : (
|
||||
<span className="text-slate-400 font-normal italic text-xs">Pending invoice</span>
|
||||
)}
|
||||
</td>
|
||||
|
||||
<td className="py-4 px-4 text-right">
|
||||
{/* Deep link details */}
|
||||
<Link
|
||||
href={`/admin/maintenance/${record.id}`}
|
||||
className="p-1.5 hover:bg-slate-100 rounded-lg text-slate-400 hover:text-slate-700 transition-colors inline-block"
|
||||
title="Open full maintenance record"
|
||||
>
|
||||
<Eye className="w-4 h-4" />
|
||||
</Link>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
);
|
||||
}
|
||||
1106
src/app/admin/service-centers/page.tsx
Normal file
1106
src/app/admin/service-centers/page.tsx
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user