332 lines
14 KiB
TypeScript
332 lines
14 KiB
TypeScript
'use client';
|
|
|
|
import { useEffect, useMemo, useState } from 'react';
|
|
import Link from 'next/link';
|
|
import { Plus, RefreshCw, Search } from 'lucide-react';
|
|
import { toast } from 'sonner';
|
|
import { adminService } from '@/services/api/adminService';
|
|
import { formatCurrency } from '@/lib/utils';
|
|
import { CustomSelect } from '@/components/ui/CustomSelect';
|
|
import { TablePagination, useClientPagination } from '@/components/ui/TablePagination';
|
|
import { MediaProofModal } from '@/components/ui/MediaProofModal';
|
|
|
|
const STATUS_OPTIONS = [
|
|
{ value: 'BOOKING_PENDING', label: 'Pending deposit' },
|
|
{ value: 'BOOKED', label: 'Booked' },
|
|
{ value: 'BOOKED_PICKUP', label: 'Booked pickup' },
|
|
{ value: 'PICKUP_SCHEDULED', label: 'Pickup scheduled' },
|
|
{ value: 'OUT_FOR_PICKUP', label: 'Out for pickup' },
|
|
{ value: 'DEVICE_PICKED_UP', label: 'Device picked up' },
|
|
{ value: 'IN_TRANSIT', label: 'In transit to store' },
|
|
{ value: 'DELIVERED_TO_STORE', label: 'Delivered to store' },
|
|
{ value: 'DEVICE_RECEIVED', label: 'Device received' },
|
|
{ value: 'DEVICE_INTAKE', label: 'Intake complete' },
|
|
{ value: 'INSPECTION_PENDING', label: 'Awaiting inspection' },
|
|
{ value: 'INSPECTION_COMPLETED', label: 'Inspection done' },
|
|
{ value: 'QUOTE_SENT', label: 'Quote sent' },
|
|
{ value: 'QUOTE_ACCEPTED', label: 'Quote approved' },
|
|
{ value: 'REPAIR_IN_PROGRESS', label: 'In repair' },
|
|
{ value: 'REPAIR_COMPLETED', label: 'Repair complete' },
|
|
{ value: 'FINAL_PAYMENT_PENDING', label: 'Payment required' },
|
|
{ value: 'CLOSED', label: 'Closed' },
|
|
] as const;
|
|
|
|
type QueueTab = 'all' | 'open' | 'closed';
|
|
|
|
function isClosedStatus(status?: string) {
|
|
const s = (status || '').toUpperCase();
|
|
return s === 'CLOSED' || s === 'CANCELLED';
|
|
}
|
|
|
|
function getStatusBadge(status?: string) {
|
|
const s = (status || '').toUpperCase();
|
|
let label = s.replace(/_/g, ' ');
|
|
let color = 'bg-gray-100 text-gray-700 border-gray-200';
|
|
|
|
const match = STATUS_OPTIONS.find((opt) => opt.value === s);
|
|
if (match) label = match.label;
|
|
|
|
if (s.includes('PENDING') || s.includes('BOOKING')) {
|
|
color = 'bg-rose-50 text-rose-600 border-rose-200';
|
|
} else if (s.includes('QUOTE') || s.includes('INSPECTION')) {
|
|
color = 'bg-purple-50 text-purple-600 border-purple-200';
|
|
} else if (s.includes('PAYMENT') || s.includes('FINAL')) {
|
|
color = 'bg-amber-50 text-amber-700 border-amber-200';
|
|
} else if (s.includes('BOOKED') || s.includes('PICKUP') || s.includes('TRANSIT')) {
|
|
color = 'bg-sky-50 text-sky-600 border-sky-200';
|
|
} else if (s.includes('REPAIR') || s.includes('RECEIVED') || s.includes('INTAKE')) {
|
|
color = 'bg-blue-50 text-blue-600 border-blue-200';
|
|
} else if (s.includes('COMPLETED') || s.includes('CLOSED')) {
|
|
color = 'bg-emerald-50 text-emerald-600 border-emerald-200';
|
|
}
|
|
|
|
return (
|
|
<span className={`inline-flex items-center px-2.5 py-1 rounded-full text-[11px] font-semibold border uppercase tracking-wider ${color}`}>
|
|
{label}
|
|
</span>
|
|
);
|
|
}
|
|
|
|
export default function AdminServicesDashboard() {
|
|
const [jobs, setJobs] = useState<any[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [searchQuery, setSearchQuery] = useState('');
|
|
const [queueTab, setQueueTab] = useState<QueueTab>('all');
|
|
const [filterStatus, setFilterStatus] = useState<string>('ALL');
|
|
const [updatingJobId, setUpdatingJobId] = useState<string | null>(null);
|
|
|
|
const loadJobs = async () => {
|
|
setLoading(true);
|
|
try {
|
|
const data = await adminService.listServiceJobs();
|
|
const seen = new Set<string>();
|
|
const unique = (data as any[]).filter((j) => {
|
|
if (!j?.job_id || seen.has(j.job_id)) return false;
|
|
seen.add(j.job_id);
|
|
return true;
|
|
});
|
|
setJobs(unique);
|
|
} catch (err: any) {
|
|
toast.error(err.message || 'Failed to load service jobs');
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
function formatDeviceName(brand?: string, model?: string, fallback = '—') {
|
|
const b = (brand || '').trim();
|
|
const m = (model || '').trim();
|
|
if (!b) return m || fallback;
|
|
if (!m) return b || fallback;
|
|
if (m.toLowerCase().startsWith(b.toLowerCase())) return m;
|
|
return `${b} ${m}`;
|
|
}
|
|
|
|
useEffect(() => {
|
|
loadJobs();
|
|
}, []);
|
|
|
|
const [proofModalOpen, setProofModalOpen] = useState(false);
|
|
const [proofJobId, setProofJobId] = useState<string | null>(null);
|
|
const [proofTargetStatus, setProofTargetStatus] = useState<'INSPECTION_COMPLETED' | 'READY_FOR_DELIVERY'>('INSPECTION_COMPLETED');
|
|
|
|
const handleStatusChange = async (jobId: string, newStatus: string) => {
|
|
if (newStatus === 'INSPECTION_COMPLETED' || newStatus === 'READY_FOR_DELIVERY') {
|
|
setProofJobId(jobId);
|
|
setProofTargetStatus(newStatus);
|
|
setProofModalOpen(true);
|
|
return;
|
|
}
|
|
|
|
const previous = jobs.find((j) => j.job_id === jobId)?.status;
|
|
setJobs((prev) => prev.map((j) => (j.job_id === jobId ? { ...j, status: newStatus } : j)));
|
|
setUpdatingJobId(jobId);
|
|
try {
|
|
await adminService.updateServiceJobStatus(jobId, newStatus);
|
|
toast.success('Service job status saved');
|
|
} catch (err: any) {
|
|
setJobs((prev) => prev.map((j) => (j.job_id === jobId ? { ...j, status: previous } : j)));
|
|
toast.error(err.message || 'Could not update the job status');
|
|
} finally {
|
|
setUpdatingJobId(null);
|
|
}
|
|
};
|
|
|
|
const openCount = jobs.filter((j) => !isClosedStatus(j.status)).length;
|
|
const closedCount = jobs.filter((j) => isClosedStatus(j.status)).length;
|
|
|
|
const filteredJobs = useMemo(() => {
|
|
const q = searchQuery.trim().toLowerCase();
|
|
return jobs.filter((job) => {
|
|
const tabMatch =
|
|
queueTab === 'all' ||
|
|
(queueTab === 'open' && !isClosedStatus(job.status)) ||
|
|
(queueTab === 'closed' && isClosedStatus(job.status));
|
|
const statusMatch = filterStatus === 'ALL' || job.status === filterStatus;
|
|
const haystack = [
|
|
job.job_no,
|
|
job.customer_name,
|
|
job.customer_phone,
|
|
job.customer_email,
|
|
job.device_brand,
|
|
job.device_model,
|
|
job.service_name,
|
|
job.status,
|
|
]
|
|
.filter(Boolean)
|
|
.join(' ')
|
|
.toLowerCase();
|
|
return tabMatch && statusMatch && (!q || haystack.includes(q));
|
|
});
|
|
}, [jobs, searchQuery, queueTab, filterStatus]);
|
|
|
|
const pager = useClientPagination(filteredJobs);
|
|
|
|
const dataCardShell =
|
|
'crm-radius-section border border-border bg-card shadow-[0_1px_3px_rgba(16,24,40,0.06)] overflow-hidden min-w-0';
|
|
const primaryButton =
|
|
'inline-flex items-center justify-center gap-2 h-9 px-4 crm-radius-control bg-primary hover:bg-primary/90 text-white text-[13px] font-semibold cursor-pointer disabled:opacity-50';
|
|
const inputClass =
|
|
'h-9 px-3 bg-card border border-border crm-radius-control text-[13px] text-foreground focus:outline-none focus:border-primary';
|
|
|
|
return (
|
|
<div className="flex flex-col gap-5 min-w-0">
|
|
<div className="flex flex-wrap items-start justify-between gap-3 min-w-0">
|
|
<div className="min-w-0">
|
|
<div className="flex items-center gap-2">
|
|
<h1 className="text-lg font-semibold text-foreground">Service Jobs Queue</h1>
|
|
<span className="inline-flex items-center justify-center min-w-5 h-5 px-1.5 crm-radius-toggle bg-primary/10 text-primary text-[11px] font-semibold leading-none">
|
|
{jobs.length}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="flex items-center gap-2 shrink-0">
|
|
<button
|
|
type="button"
|
|
onClick={loadJobs}
|
|
className="w-8 h-8 crm-radius-control border border-border bg-card text-muted-foreground hover:text-foreground hover:bg-muted cursor-pointer transition-colors flex items-center justify-center"
|
|
title="Reload"
|
|
>
|
|
<RefreshCw className="w-3.5 h-3.5" />
|
|
</button>
|
|
<Link href="/services/intake" className={primaryButton}>
|
|
<span className="w-[18px] h-[18px] rounded-full bg-white text-primary flex items-center justify-center">
|
|
<Plus className="w-3 h-3" strokeWidth={3} />
|
|
</span>
|
|
New walk-in
|
|
</Link>
|
|
</div>
|
|
</div>
|
|
|
|
<div className={dataCardShell}>
|
|
<div className="px-5 pt-5">
|
|
<div className="-mx-5 px-5 flex flex-wrap items-center justify-between gap-3 min-w-0 pb-4 border-b border-border">
|
|
<div className="relative flex-1 min-w-[180px] max-w-[480px]">
|
|
<Search className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-muted-foreground pointer-events-none" />
|
|
<input
|
|
type="text"
|
|
placeholder="Search"
|
|
value={searchQuery}
|
|
onChange={(e) => setSearchQuery(e.target.value)}
|
|
className="w-full h-9 pl-9 pr-3 crm-radius-control border border-border bg-card text-[13px] text-foreground placeholder:text-muted-foreground focus:outline-none focus:border-primary transition-colors"
|
|
/>
|
|
</div>
|
|
<div className="flex flex-wrap items-center gap-2 shrink-0">
|
|
<div className="crm-radius-toggle inline-flex items-center gap-1 border border-border bg-card p-1">
|
|
{(
|
|
[
|
|
{ id: 'all', label: `All (${jobs.length})` },
|
|
{ id: 'open', label: `Open (${openCount})` },
|
|
{ id: 'closed', label: `Closed (${closedCount})` },
|
|
] as { id: QueueTab; label: string }[]
|
|
).map((tab) => (
|
|
<button
|
|
key={tab.id}
|
|
type="button"
|
|
onClick={() => setQueueTab(tab.id)}
|
|
className={`h-8 px-3 crm-radius-toggle text-[13px] font-medium cursor-pointer transition-colors ${
|
|
queueTab === tab.id ? 'bg-primary text-white' : 'text-foreground hover:bg-muted'
|
|
}`}
|
|
>
|
|
{tab.label}
|
|
</button>
|
|
))}
|
|
</div>
|
|
<CustomSelect
|
|
value={filterStatus}
|
|
onChange={setFilterStatus}
|
|
options={[
|
|
{ value: 'ALL', label: 'All statuses' },
|
|
...STATUS_OPTIONS.map((opt) => ({ value: opt.value, label: opt.label })),
|
|
]}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{loading ? (
|
|
<div className="flex flex-col items-center justify-center py-16 gap-2.5">
|
|
<RefreshCw className="w-6 h-6 text-primary animate-spin" />
|
|
<span className="text-[13px] text-muted-foreground">Loading jobs...</span>
|
|
</div>
|
|
) : (
|
|
<div className="overflow-x-auto">
|
|
<table className="crm-data-table min-w-[900px]">
|
|
<thead>
|
|
<tr className="bg-gray-50 border-y border-gray-200">
|
|
<th className="px-5 py-3 text-left text-[13px] font-semibold text-gray-700">Job no.</th>
|
|
<th className="px-5 py-3 text-left text-[13px] font-semibold text-gray-700">Customer</th>
|
|
<th className="px-5 py-3 text-left text-[13px] font-semibold text-gray-700">Device / service</th>
|
|
<th className="px-5 py-3 text-left text-[13px] font-semibold text-gray-700">Price</th>
|
|
<th className="px-5 py-3 text-left text-[13px] font-semibold text-gray-700">Status</th>
|
|
<th className="px-5 py-3 text-left text-[13px] font-semibold text-gray-700">Date</th>
|
|
</tr>
|
|
</thead>
|
|
<tbody className="bg-card">
|
|
{filteredJobs.length === 0 ? (
|
|
<tr>
|
|
<td colSpan={6} className="text-center py-12 text-[13px] text-muted-foreground">
|
|
{searchQuery || queueTab !== 'all' || filterStatus !== 'ALL'
|
|
? 'No jobs match your search.'
|
|
: 'No service jobs found.'}
|
|
</td>
|
|
</tr>
|
|
) : (
|
|
pager.items.map((job) => (
|
|
<tr key={job.job_id} className="border-t border-border hover:bg-muted/10">
|
|
<td className="px-5 py-3.5 text-[13px] font-semibold text-foreground font-mono">
|
|
{job.job_no || job.job_id}
|
|
</td>
|
|
<td className="px-5 py-3.5">
|
|
<p className="text-[13px] font-medium text-foreground">{job.customer_name || 'Guest customer'}</p>
|
|
<p className="text-[12px] text-muted-foreground 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
|
|
: '—'}
|
|
</p>
|
|
</td>
|
|
<td className="px-5 py-3.5">
|
|
<p className="text-[13px] font-medium text-foreground">
|
|
{formatDeviceName(job.device_brand, job.device_model)}
|
|
</p>
|
|
<p className="text-[12px] text-muted-foreground mt-0.5">{job.service_name || '—'}</p>
|
|
</td>
|
|
<td className="px-5 py-3.5 text-[13px] text-muted-foreground">
|
|
{typeof job.base_price === 'number' ? formatCurrency(job.base_price) : '—'}
|
|
</td>
|
|
<td className="px-5 py-3.5">
|
|
{getStatusBadge(job.status)}
|
|
</td>
|
|
<td className="px-5 py-3.5 text-[13px] text-muted-foreground">
|
|
{job.created_at ? new Date(job.created_at).toLocaleDateString() : '—'}
|
|
</td>
|
|
</tr>
|
|
))
|
|
)}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
)}
|
|
<TablePagination {...pager} />
|
|
</div>
|
|
|
|
{proofJobId && (
|
|
<MediaProofModal
|
|
open={proofModalOpen}
|
|
onClose={() => {
|
|
setProofModalOpen(false);
|
|
setProofJobId(null);
|
|
}}
|
|
jobId={proofJobId}
|
|
targetStatus={proofTargetStatus}
|
|
onSuccess={() => {
|
|
loadJobs();
|
|
}}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|