437 lines
19 KiB
TypeScript
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>
|
|
);
|
|
}
|