211 lines
10 KiB
TypeScript
211 lines
10 KiB
TypeScript
/**
|
|
* @page Service Jobs Dashboard ERP/CRM (`app/(admin)/services/page.tsx`)
|
|
* @purpose General dashboard queue for active scheduling service jobs, calendars, and technician tracking.
|
|
*/
|
|
'use client';
|
|
|
|
import { useEffect, useState } from 'react';
|
|
import Link from 'next/link';
|
|
import { adminService } from '@/services/api/adminService';
|
|
import {
|
|
Wrench,
|
|
Clock,
|
|
Calendar,
|
|
CheckCircle,
|
|
AlertCircle,
|
|
Loader2,
|
|
Plus,
|
|
Filter,
|
|
FileText,
|
|
UserCheck
|
|
} from 'lucide-react';
|
|
import { formatCurrency } from '@/lib/utils';
|
|
|
|
export default function AdminServicesDashboard() {
|
|
const [jobs, setJobs] = useState<any[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [errorMsg, setErrorMsg] = useState<string | null>(null);
|
|
const [filterStatus, setFilterStatus] = useState<string>('ALL');
|
|
|
|
useEffect(() => {
|
|
loadJobs();
|
|
}, []);
|
|
|
|
const loadJobs = async () => {
|
|
setLoading(true);
|
|
setErrorMsg(null);
|
|
try {
|
|
const data = await adminService.listServiceJobs();
|
|
// Deduplicate by job_id to prevent React key conflicts
|
|
const seen = new Set<string>();
|
|
const unique = (data as any[]).filter(j => {
|
|
if (seen.has(j.job_id)) return false;
|
|
seen.add(j.job_id);
|
|
return true;
|
|
});
|
|
setJobs(unique);
|
|
} catch (err: any) {
|
|
setErrorMsg(err.message || 'Failed to load service job queues.');
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
const filteredJobs = filterStatus === 'ALL'
|
|
? jobs
|
|
: jobs.filter(j => j.status === filterStatus);
|
|
|
|
if (loading) {
|
|
return (
|
|
<div className="w-full min-h-[400px] flex items-center justify-center">
|
|
<Loader2 className="w-8 h-8 text-[#e4382f] animate-spin" />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="space-y-6 font-sans text-xs">
|
|
|
|
{/* Header section */}
|
|
<div className="flex flex-col sm:flex-row justify-between items-start sm:items-center gap-4">
|
|
<div>
|
|
<h1 className="text-lg font-black text-[#1b2559] uppercase tracking-wider flex items-center gap-2">
|
|
<Wrench className="w-5 h-5 text-[#e4382f]" /> Service Jobs & Repairs
|
|
</h1>
|
|
<p className="text-[10px] text-gray-400 font-bold uppercase mt-1">
|
|
Manage online slot appointments and walk-in counter diagnostics
|
|
</p>
|
|
</div>
|
|
|
|
<div className="flex gap-2">
|
|
<Link
|
|
href="/services/intake"
|
|
className="bg-[#1b2559] hover:bg-[#1b2559]/90 text-white font-extrabold uppercase py-2.5 px-4 rounded-xl flex items-center gap-1.5 transition"
|
|
>
|
|
<Plus className="w-4 h-4" /> New Walk-In Intake
|
|
</Link>
|
|
</div>
|
|
</div>
|
|
|
|
{errorMsg && (
|
|
<div className="bg-red-50 border border-red-200 text-red-700 text-xs font-semibold px-4 py-3 rounded-2xl flex items-center gap-1.5">
|
|
<AlertCircle className="w-4 h-4 text-red-650" /> {errorMsg}
|
|
</div>
|
|
)}
|
|
|
|
{/* Filter and stats cards */}
|
|
<div className="bg-white border border-gray-200 rounded-3xl p-5 shadow-sm space-y-4">
|
|
<div className="flex justify-between items-center border-b border-gray-100 pb-3">
|
|
<div className="flex items-center gap-2">
|
|
<Filter className="w-4 h-4 text-gray-450" />
|
|
<span className="font-extrabold text-[#1b2559] uppercase text-[10px]">Filter Status Queue</span>
|
|
</div>
|
|
<select
|
|
value={filterStatus}
|
|
onChange={(e) => setFilterStatus(e.target.value)}
|
|
className="bg-gray-50 border border-gray-200 rounded-lg p-1.5 outline-none font-bold text-[10px]"
|
|
>
|
|
<option value="ALL">ALL ACTIVE QUEUES</option>
|
|
<option value="BOOKING_PENDING">PENDING DEPOSIT</option>
|
|
<option value="BOOKED">BOOKED (AWAITING DEVICE)</option>
|
|
<option value="DEVICE_RECEIVED">DEVICE RECEIVED</option>
|
|
<option value="DEVICE_INTAKE">INTAKE COMPLETE</option>
|
|
<option value="INSPECTION_PENDING">AWAITING INSPECTION</option>
|
|
<option value="INSPECTION_COMPLETED">INSPECTION DONE</option>
|
|
<option value="QUOTE_SENT">QUOTE SENT</option>
|
|
<option value="QUOTE_ACCEPTED">QUOTE APPROVED</option>
|
|
<option value="REPAIR_IN_PROGRESS">IN REPAIR</option>
|
|
<option value="REPAIR_COMPLETED">REPAIR COMPLETE</option>
|
|
<option value="FINAL_PAYMENT_PENDING">PAYMENT REQUIRED</option>
|
|
<option value="CLOSED">CLOSED</option>
|
|
</select>
|
|
</div>
|
|
|
|
{/* Table list */}
|
|
<div className="overflow-x-auto">
|
|
<table className="w-full text-left border-collapse">
|
|
<thead>
|
|
<tr className="border-b border-gray-200 text-[10px] text-gray-450 uppercase tracking-wider font-extrabold">
|
|
<th className="pb-3">Job Reference</th>
|
|
<th className="pb-3">Customer</th>
|
|
<th className="pb-3">Device / Service</th>
|
|
<th className="pb-3">Price</th>
|
|
<th className="pb-3">Current Step</th>
|
|
<th className="pb-3">Intake Date</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="divide-y divide-gray-100 font-bold text-[#1b2559]">
|
|
{filteredJobs.length > 0 ? (
|
|
filteredJobs.map((job) => (
|
|
<tr key={job.job_id} className="hover:bg-gray-50/50 transition">
|
|
<td className="py-3.5 font-mono text-[10.5px]">
|
|
{job.job_no}
|
|
</td>
|
|
<td className="py-3.5">
|
|
<span className="block font-extrabold text-slate-100">{job.customer_name || 'Guest Customer'}</span>
|
|
<span className="text-[10px] text-slate-400 font-medium block mt-0.5">{job.customer_phone && job.customer_phone !== 'N/A' ? job.customer_phone : (job.customer_email && job.customer_email !== 'N/A' ? job.customer_email : '—')}</span>
|
|
</td>
|
|
<td className="py-3.5">
|
|
<span className="block font-extrabold">{job.device_brand} {job.device_model}</span>
|
|
<span className="text-[10px] text-gray-400 font-medium block mt-0.5">{job.service_name}</span>
|
|
</td>
|
|
<td className="py-3.5 text-gray-700">
|
|
{formatCurrency(job.base_price)}
|
|
</td>
|
|
<td className="py-3.5">
|
|
<select
|
|
value={job.status}
|
|
onChange={async (e) => {
|
|
const newStatus = e.target.value;
|
|
try {
|
|
await adminService.updateServiceJobStatus(job.job_id, newStatus);
|
|
setJobs(prevJobs =>
|
|
prevJobs.map(j =>
|
|
j.job_id === job.job_id ? { ...j, status: newStatus } : j
|
|
)
|
|
);
|
|
} catch (err: any) {
|
|
setErrorMsg(err.message || 'Failed to update status.');
|
|
}
|
|
}}
|
|
className={`px-2.5 py-1 text-[9px] font-black tracking-wider uppercase rounded-lg cursor-pointer outline-none transition ${
|
|
job.status === 'CLOSED' || job.status === 'REPAIR_COMPLETED' ? 'bg-emerald-500/20 text-emerald-300 border border-emerald-500/40' :
|
|
job.status === 'BOOKING_PENDING' ? 'bg-rose-500/20 text-rose-300 border border-rose-500/40' :
|
|
job.status === 'REPAIR_IN_PROGRESS' || job.status === 'INSPECTION_PENDING' || job.status === 'INSPECTION_COMPLETED' ? 'bg-amber-500/20 text-amber-300 border border-amber-500/40' :
|
|
job.status === 'QUOTE_SENT' || job.status === 'QUOTE_ACCEPTED' ? 'bg-purple-500/20 text-purple-300 border border-purple-500/40' :
|
|
'bg-sky-500/20 text-sky-300 border border-sky-500/40'
|
|
}`}
|
|
>
|
|
<option className="bg-[#131d38] text-slate-100 font-bold" value="BOOKING_PENDING">PENDING DEPOSIT</option>
|
|
<option className="bg-[#131d38] text-slate-100 font-bold" value="BOOKED">BOOKED (AWAITING DEVICE)</option>
|
|
<option className="bg-[#131d38] text-slate-100 font-bold" value="DEVICE_RECEIVED">DEVICE RECEIVED</option>
|
|
<option className="bg-[#131d38] text-slate-100 font-bold" value="DEVICE_INTAKE">INTAKE COMPLETE</option>
|
|
<option className="bg-[#131d38] text-slate-100 font-bold" value="INSPECTION_PENDING">AWAITING INSPECTION</option>
|
|
<option className="bg-[#131d38] text-slate-100 font-bold" value="INSPECTION_COMPLETED">INSPECTION DONE</option>
|
|
<option className="bg-[#131d38] text-slate-100 font-bold" value="QUOTE_SENT">QUOTE SENT</option>
|
|
<option className="bg-[#131d38] text-slate-100 font-bold" value="QUOTE_ACCEPTED">QUOTE APPROVED</option>
|
|
<option className="bg-[#131d38] text-slate-100 font-bold" value="REPAIR_IN_PROGRESS">IN REPAIR</option>
|
|
<option className="bg-[#131d38] text-slate-100 font-bold" value="REPAIR_COMPLETED">REPAIR COMPLETE</option>
|
|
<option className="bg-[#131d38] text-slate-100 font-bold" value="FINAL_PAYMENT_PENDING">PAYMENT REQUIRED</option>
|
|
<option className="bg-[#131d38] text-slate-100 font-bold" value="CLOSED">CLOSED</option>
|
|
</select>
|
|
</td>
|
|
<td className="py-3.5 font-mono text-[10px] text-gray-400">
|
|
{new Date(job.created_at).toLocaleDateString()}
|
|
</td>
|
|
</tr>
|
|
))
|
|
) : (
|
|
<tr>
|
|
<td colSpan={6} className="py-8 text-center text-gray-450">
|
|
No active service jobs match this queue filter.
|
|
</td>
|
|
</tr>
|
|
)}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|