'use client'; import { useEffect, useMemo, useState } from 'react'; import { BadgeDollarSign, CheckCircle2, Clock, Eye, RefreshCw, Search, XCircle } from 'lucide-react'; import { format } from 'date-fns'; import { toast } from 'sonner'; import { adminService } from '@/services/api/adminService'; import { apiFetch } from '@/services/api/client'; import { formatCurrency } from '@/lib/utils'; import { SlideOver } from '@/components/ui/SlideOver'; import { TablePagination, useClientPagination } from '@/components/ui/TablePagination'; type QuoteTab = 'all' | 'pending' | 'accepted' | 'other'; function getQuote(detail: any) { if (!detail) return null; if (detail.quote) return detail.quote; if (detail.active_quote) return detail.active_quote; if (Array.isArray(detail.quotes) && detail.quotes.length > 0) return detail.quotes[0]; return null; } function isPendingJob(status?: string) { const s = (status || '').toUpperCase(); return s === 'QUOTE_SENT' || s === 'INSPECTION_COMPLETED'; } function isAcceptedJob(status?: string) { const s = (status || '').toUpperCase(); return s === 'QUOTE_ACCEPTED'; } function jobStatusBadgeClass(status?: string) { const s = (status || '').toUpperCase(); if (s === 'QUOTE_ACCEPTED' || s === 'CLOSED' || s === 'REPAIR_COMPLETED') return 'bg-success text-white'; if (s === 'REJECTED' || s === 'CANCELLED' || s === 'BOOKING_PENDING') return 'bg-destructive text-white'; if (s === 'QUOTE_SENT' || s === 'REPAIR_IN_PROGRESS' || s === 'FINAL_PAYMENT_PENDING') return 'bg-warning text-white'; if (s === 'DEVICE_INTAKE' || s === 'DEVICE_RECEIVED') return 'bg-primary text-white'; return 'bg-muted text-muted-foreground'; } function quoteStatusBadgeClass(status?: string) { const s = (status || '').toUpperCase(); if (s === 'ACCEPTED') return 'bg-success text-white'; if (s === 'REJECTED') return 'bg-destructive text-white'; if (s === 'PENDING_CUSTOMER') return 'bg-warning text-white'; return 'bg-muted text-muted-foreground'; } function formatStatus(status?: string) { return (status || 'Pending').replace(/_/g, ' '); } function money(value: unknown) { const n = typeof value === 'number' ? value : parseFloat(String(value ?? '')); return Number.isFinite(n) ? formatCurrency(n) : '—'; } 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}`; } export default function ServiceQuotationsPage() { const [jobs, setJobs] = useState([]); const [loading, setLoading] = useState(true); const [searchQuery, setSearchQuery] = useState(''); const [quoteTab, setQuoteTab] = useState('all'); const [selectedJobId, setSelectedJobId] = useState(null); const [detail, setDetail] = useState(null); const [loadingDetail, setLoadingDetail] = useState(false); const [updatingQuote, setUpdatingQuote] = useState(false); const loadQuotedJobs = async () => { setLoading(true); try { const data = await adminService.listServiceJobs(); const seen = new Set(); 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); } }; useEffect(() => { loadQuotedJobs(); }, []); const openQuote = async (jobId: string) => { setSelectedJobId(jobId); setDetail(null); setLoadingDetail(true); try { const next = await adminService.getServiceJobDetailsAdmin(jobId); setDetail(next); } catch (err: any) { toast.error(err.message || 'Failed to load quote detail.'); setSelectedJobId(null); } finally { setLoadingDetail(false); } }; const closeQuote = () => { if (updatingQuote) return; setSelectedJobId(null); setDetail(null); }; const handleRespondQuote = async (jobId: string, quoteId: string, action: 'ACCEPT' | 'REJECT') => { setUpdatingQuote(true); try { await apiFetch(`/api/v1/service/jobs/${jobId}/quotes/${quoteId}/respond?action=${action}`, { method: 'POST' }); const next = await adminService.getServiceJobDetailsAdmin(jobId); setDetail(next); toast.success(action === 'ACCEPT' ? 'Customer quote accepted' : 'Customer quote rejected'); await loadQuotedJobs(); } catch (err: any) { toast.error(err.message || `Failed to ${action.toLowerCase()} quote.`); } finally { setUpdatingQuote(false); } }; const pendingCount = jobs.filter((j) => isPendingJob(j.status)).length; const acceptedCount = jobs.filter((j) => isAcceptedJob(j.status)).length; const otherCount = jobs.filter((j) => !isPendingJob(j.status) && !isAcceptedJob(j.status)).length; const filteredJobs = useMemo(() => { const q = searchQuery.trim().toLowerCase(); return jobs.filter((job) => { const tabMatch = quoteTab === 'all' || (quoteTab === 'pending' && isPendingJob(job.status)) || (quoteTab === 'accepted' && isAcceptedJob(job.status)) || (quoteTab === 'other' && !isPendingJob(job.status) && !isAcceptedJob(job.status)); 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 && (!q || haystack.includes(q)); }); }, [jobs, searchQuery, quoteTab]); const pager = useClientPagination(filteredJobs); const quote = getQuote(detail); const customerName = detail?.customer?.name || detail?.customer_name || 'Guest customer'; 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 secondaryButton = 'inline-flex items-center justify-center gap-2 h-9 px-4 crm-radius-control border border-border bg-card text-foreground text-[13px] font-medium hover:bg-muted cursor-pointer disabled:opacity-50'; const renderJobBadge = (status?: string) => ( {formatStatus(status)} ); const renderQuoteBadge = (status?: string) => ( {formatStatus(status)} ); return (

Service Quotations

{jobs.length}
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" />
{( [ { id: 'all', label: `All (${jobs.length})` }, { id: 'pending', label: `Pending (${pendingCount})` }, { id: 'accepted', label: `Accepted (${acceptedCount})` }, { id: 'other', label: `Other (${otherCount})` }, ] as { id: QuoteTab; label: string }[] ).map((tab) => ( ))}
{loading ? (
Loading quotations...
) : (
{filteredJobs.length === 0 ? ( ) : ( pager.items.map((job) => ( openQuote(job.job_id)} > )) )}
Job no. Customer Device Service Status Base price Action
{searchQuery || quoteTab !== 'all' ? 'No quotations match your search.' : 'No service jobs found.'}
{job.job_no || job.job_id}

{job.customer_name || 'Guest customer'}

{job.customer_phone && job.customer_phone !== 'N/A' ? job.customer_phone : job.customer_email && job.customer_email !== 'N/A' ? job.customer_email : '—'}

{formatDeviceName(job.device_brand, job.device_model)} {job.service_name || '—'} {renderJobBadge(job.status)} {money(job.base_price)}
)}
} > {loadingDetail ? (
Loading quote...
) : !detail ? (
Failed to load quote details.
) : !quote ? (
No active quote found. Submit a diagnostic inspection first.
) : (

Quote{quote.version != null ? ` v${quote.version}` : ''}

{customerName}

{renderQuoteBadge(quote.status)}
{detail.service_name || 'Service'} {money(quote.subtotal)}
{Number(quote.additional_damage_amount) > 0 && (
{quote.additional_damage_description ? `Additional: ${quote.additional_damage_description}` : 'Additional damage'} + {money(quote.additional_damage_amount)}
)}
GST (18%) {money(quote.tax)}
Total {money(quote.total)}
{quote.reason && (

Note

{quote.reason}

)} {quote.expires_at && (

Expires {format(new Date(quote.expires_at), 'dd MMM yyyy, hh:mm a')}

)} {quote.status === 'ACCEPTED' && (

Quote accepted — repair authorised.

)}
{quote.status === 'PENDING_CUSTOMER' && (
)}
)}
); }