ifixkart-admin/app/(admin)/service-quotes/page.tsx

437 lines
19 KiB
TypeScript

'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<any[]>([]);
const [loading, setLoading] = useState(true);
const [searchQuery, setSearchQuery] = useState('');
const [quoteTab, setQuoteTab] = useState<QuoteTab>('all');
const [selectedJobId, setSelectedJobId] = useState<string | null>(null);
const [detail, setDetail] = useState<any>(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<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);
}
};
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) => (
<span className={`inline-flex items-center px-2.5 py-1 crm-radius-badge text-[11px] font-semibold ${jobStatusBadgeClass(status)}`}>
{formatStatus(status)}
</span>
);
const renderQuoteBadge = (status?: string) => (
<span className={`inline-flex items-center px-2.5 py-1 crm-radius-badge text-[11px] font-semibold ${quoteStatusBadgeClass(status)}`}>
{formatStatus(status)}
</span>
);
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 Quotations</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>
<button
type="button"
onClick={loadQuotedJobs}
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 shrink-0"
title="Reload"
>
<RefreshCw className="w-3.5 h-3.5" />
</button>
</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="crm-radius-toggle inline-flex items-center gap-1 border border-border bg-card p-1 shrink-0 overflow-x-auto max-w-full">
{(
[
{ 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) => (
<button
key={tab.id}
type="button"
onClick={() => setQuoteTab(tab.id)}
className={`h-8 px-3 crm-radius-toggle text-[13px] font-medium cursor-pointer transition-colors whitespace-nowrap ${
quoteTab === tab.id ? 'bg-primary text-white' : 'text-foreground hover:bg-muted'
}`}
>
{tab.label}
</button>
))}
</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 quotations...</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</th>
<th className="px-5 py-3 text-left text-[13px] font-semibold text-gray-700">Service</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">Base price</th>
<th className="px-5 py-3 text-center text-[13px] font-semibold text-gray-700">Action</th>
</tr>
</thead>
<tbody className="bg-card">
{filteredJobs.length === 0 ? (
<tr>
<td colSpan={7} className="text-center py-12 text-[13px] text-muted-foreground">
{searchQuery || quoteTab !== 'all' ? 'No quotations 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 cursor-pointer ${
selectedJobId === job.job_id ? 'bg-muted/20' : ''
}`}
onClick={() => openQuote(job.job_id)}
>
<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 text-[13px] text-foreground">
{formatDeviceName(job.device_brand, job.device_model)}
</td>
<td className="px-5 py-3.5 text-[13px] text-muted-foreground max-w-[200px] truncate">
{job.service_name || '—'}
</td>
<td className="px-5 py-3.5">{renderJobBadge(job.status)}</td>
<td className="px-5 py-3.5 text-[13px] text-muted-foreground">{money(job.base_price)}</td>
<td className="px-5 py-3.5">
<div className="flex items-center justify-center">
<button
type="button"
onClick={(e) => {
e.stopPropagation();
openQuote(job.job_id);
}}
className="w-8 h-8 crm-radius-control border border-border text-muted-foreground hover:text-foreground hover:bg-muted cursor-pointer flex items-center justify-center"
aria-label="View quote"
>
<Eye className="w-3.5 h-3.5" />
</button>
</div>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
)}
<TablePagination {...pager} />
</div>
<SlideOver
open={!!selectedJobId}
onClose={closeQuote}
title={detail ? `Quote ${detail.job_no || ''}` : 'Quote details'}
icon={<BadgeDollarSign className="w-4 h-4 text-primary shrink-0" />}
>
{loadingDetail ? (
<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 quote...</span>
</div>
) : !detail ? (
<div className="px-5 py-12 text-center text-[13px] text-muted-foreground">Failed to load quote details.</div>
) : !quote ? (
<div className="px-5 py-12 text-center text-[13px] text-muted-foreground">
No active quote found. Submit a diagnostic inspection first.
</div>
) : (
<div className="flex min-h-0 flex-1 flex-col">
<div className="flex-1 min-h-0 overflow-y-auto px-5 py-4 space-y-5">
<div className="flex items-start justify-between gap-3">
<div>
<p className="text-[13px] font-semibold text-foreground">
Quote{quote.version != null ? ` v${quote.version}` : ''}
</p>
<p className="text-[12px] text-muted-foreground mt-0.5">{customerName}</p>
</div>
{renderQuoteBadge(quote.status)}
</div>
<div className="p-3 border border-border crm-radius-card bg-muted/10 space-y-1.5 text-[13px]">
<div className="flex justify-between text-muted-foreground">
<span>{detail.service_name || 'Service'}</span>
<span>{money(quote.subtotal)}</span>
</div>
{Number(quote.additional_damage_amount) > 0 && (
<div className="flex justify-between text-muted-foreground">
<span>
{quote.additional_damage_description
? `Additional: ${quote.additional_damage_description}`
: 'Additional damage'}
</span>
<span>+ {money(quote.additional_damage_amount)}</span>
</div>
)}
<div className="flex justify-between text-muted-foreground">
<span>GST (18%)</span>
<span>{money(quote.tax)}</span>
</div>
<div className="flex justify-between font-semibold text-foreground border-t border-border pt-1.5">
<span>Total</span>
<span>{money(quote.total)}</span>
</div>
</div>
{quote.reason && (
<div>
<p className="text-[11px] font-semibold text-muted-foreground uppercase mb-1">Note</p>
<p className="text-[13px] text-foreground">{quote.reason}</p>
</div>
)}
{quote.expires_at && (
<p className="flex items-center gap-1.5 text-[12px] text-muted-foreground">
<Clock className="w-3.5 h-3.5" />
Expires {format(new Date(quote.expires_at), 'dd MMM yyyy, hh:mm a')}
</p>
)}
{quote.status === 'ACCEPTED' && (
<p className="flex items-center gap-1.5 text-[13px] text-success font-medium">
<CheckCircle2 className="w-4 h-4" />
Quote accepted repair authorised.
</p>
)}
</div>
{quote.status === 'PENDING_CUSTOMER' && (
<div className="shrink-0 border-t border-border px-5 py-3 flex items-center justify-end gap-2 bg-card">
<button
type="button"
onClick={() => handleRespondQuote(detail.job_id || selectedJobId!, quote.quote_id, 'REJECT')}
disabled={updatingQuote}
className={secondaryButton}
>
<XCircle className="w-3.5 h-3.5" />
Reject
</button>
<button
type="button"
onClick={() => handleRespondQuote(detail.job_id || selectedJobId!, quote.quote_id, 'ACCEPT')}
disabled={updatingQuote}
className={primaryButton}
>
{updatingQuote ? (
<RefreshCw className="w-3.5 h-3.5 animate-spin" />
) : (
<CheckCircle2 className="w-3.5 h-3.5" />
)}
Accept
</button>
</div>
)}
</div>
)}
</SlideOver>
</div>
);
}