ifixkart-admin/app/(admin)/services/(list)/page.tsx

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>
);
}