448 lines
19 KiB
TypeScript
448 lines
19 KiB
TypeScript
'use client';
|
|
|
|
import { useEffect, useMemo, useState } from 'react';
|
|
import { AlertTriangle, CheckCircle2, CreditCard, Eye, Receipt, RefreshCw, Search } from 'lucide-react';
|
|
import { format } from 'date-fns';
|
|
import { toast } from 'sonner';
|
|
import { adminService } from '@/services/api/adminService';
|
|
import { formatCurrency } from '@/lib/utils';
|
|
import { SlideOver } from '@/components/ui/SlideOver';
|
|
import { StatsSparklineCard, datesToSparkline, weekOverWeekChange } from '@/components/ui/StatsSparklineCard';
|
|
import { TablePagination, useClientPagination } from '@/components/ui/TablePagination';
|
|
|
|
type InvoiceTab = 'ALL' | 'FINAL_PAYMENT_PENDING' | 'READY_FOR_DELIVERY' | 'CLOSED';
|
|
|
|
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 getPayments(detail: any): any[] {
|
|
if (!detail) return [];
|
|
if (Array.isArray(detail.payments)) return detail.payments;
|
|
if (Array.isArray(detail.payment_records)) return detail.payment_records;
|
|
return [];
|
|
}
|
|
|
|
function paymentTypeLabel(type: string) {
|
|
switch (type) {
|
|
case 'DEPOSIT':
|
|
return 'Booking deposit';
|
|
case 'FINAL':
|
|
return 'Final settlement';
|
|
case 'PARTIAL':
|
|
return 'Partial payment';
|
|
default:
|
|
return type || 'Payment';
|
|
}
|
|
}
|
|
|
|
function jobStatusBadgeClass(status?: string) {
|
|
const s = (status || '').toUpperCase();
|
|
if (s === 'CLOSED' || s === 'REPAIR_COMPLETED') return 'bg-success text-white';
|
|
if (s === 'CANCELLED' || s === 'BOOKING_PENDING') return 'bg-destructive text-white';
|
|
if (s === 'FINAL_PAYMENT_PENDING' || s === 'QUOTE_SENT' || s === 'REPAIR_IN_PROGRESS') return 'bg-warning text-white';
|
|
if (s === 'READY_FOR_DELIVERY' || s === 'DEVICE_INTAKE' || s === 'QUOTE_ACCEPTED') return 'bg-primary text-white';
|
|
return 'bg-muted text-muted-foreground';
|
|
}
|
|
|
|
function paymentBadgeClass(status?: string) {
|
|
const s = (status || '').toUpperCase();
|
|
if (s === 'PAID') return 'bg-success text-white';
|
|
if (s === 'FAILED' || s === 'REFUNDED') return 'bg-destructive text-white';
|
|
if (s === 'PENDING') 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 ServiceInvoicesPage() {
|
|
const [jobs, setJobs] = useState<any[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [searchQuery, setSearchQuery] = useState('');
|
|
const [filterStatus, setFilterStatus] = useState<InvoiceTab>('ALL');
|
|
|
|
const [selectedJobId, setSelectedJobId] = useState<string | null>(null);
|
|
const [detail, setDetail] = useState<any>(null);
|
|
const [loadingDetail, setLoadingDetail] = useState(false);
|
|
|
|
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 jobs.');
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
useEffect(() => {
|
|
loadJobs();
|
|
}, []);
|
|
|
|
const openInvoice = 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 invoice detail.');
|
|
setSelectedJobId(null);
|
|
} finally {
|
|
setLoadingDetail(false);
|
|
}
|
|
};
|
|
|
|
const closeInvoice = () => {
|
|
setSelectedJobId(null);
|
|
setDetail(null);
|
|
};
|
|
|
|
const pendingCount = jobs.filter((j) => j.status === 'FINAL_PAYMENT_PENDING').length;
|
|
const deliveryCount = jobs.filter((j) => j.status === 'READY_FOR_DELIVERY').length;
|
|
const closedCount = jobs.filter((j) => j.status === 'CLOSED').length;
|
|
|
|
const allJobDates = useMemo(() => jobs.map((j) => j.created_at), [jobs]);
|
|
const pendingJobDates = useMemo(
|
|
() => jobs.filter((j) => j.status === 'FINAL_PAYMENT_PENDING').map((j) => j.created_at),
|
|
[jobs]
|
|
);
|
|
const closedJobDates = useMemo(
|
|
() => jobs.filter((j) => j.status === 'CLOSED').map((j) => j.created_at),
|
|
[jobs]
|
|
);
|
|
|
|
const filteredJobs = useMemo(() => {
|
|
const q = searchQuery.trim().toLowerCase();
|
|
return jobs.filter((job) => {
|
|
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 statusMatch && (!q || haystack.includes(q));
|
|
});
|
|
}, [jobs, searchQuery, filterStatus]);
|
|
|
|
const pager = useClientPagination(filteredJobs);
|
|
|
|
const quote = getQuote(detail);
|
|
const payments = getPayments(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 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 renderPaymentBadge = (status?: string) => (
|
|
<span className={`inline-flex items-center px-2.5 py-1 crm-radius-badge text-[11px] font-semibold ${paymentBadgeClass(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 Invoices</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={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 shrink-0"
|
|
title="Reload"
|
|
>
|
|
<RefreshCw className="w-3.5 h-3.5" />
|
|
</button>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
|
|
<StatsSparklineCard
|
|
title="Total jobs"
|
|
value={jobs.length}
|
|
icon={Receipt}
|
|
tone="green"
|
|
series={datesToSparkline(allJobDates)}
|
|
deltaPercent={weekOverWeekChange(allJobDates)}
|
|
active={filterStatus === 'ALL'}
|
|
onClick={() => setFilterStatus('ALL')}
|
|
/>
|
|
<StatsSparklineCard
|
|
title="Payment pending"
|
|
value={pendingCount}
|
|
icon={AlertTriangle}
|
|
tone="orange"
|
|
series={datesToSparkline(pendingJobDates)}
|
|
deltaPercent={weekOverWeekChange(pendingJobDates)}
|
|
active={filterStatus === 'FINAL_PAYMENT_PENDING'}
|
|
onClick={() => setFilterStatus('FINAL_PAYMENT_PENDING')}
|
|
/>
|
|
<StatsSparklineCard
|
|
title="Closed / settled"
|
|
value={closedCount}
|
|
icon={CheckCircle2}
|
|
tone="blue"
|
|
series={datesToSparkline(closedJobDates)}
|
|
deltaPercent={weekOverWeekChange(closedJobDates)}
|
|
active={filterStatus === 'CLOSED'}
|
|
onClick={() => setFilterStatus('CLOSED')}
|
|
/>
|
|
</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: 'FINAL_PAYMENT_PENDING', label: `Payment pending (${pendingCount})` },
|
|
{ id: 'READY_FOR_DELIVERY', label: `Ready for delivery (${deliveryCount})` },
|
|
{ id: 'CLOSED', label: `Closed (${closedCount})` },
|
|
] as { id: InvoiceTab; label: string }[]
|
|
).map((tab) => (
|
|
<button
|
|
key={tab.id}
|
|
type="button"
|
|
onClick={() => setFilterStatus(tab.id)}
|
|
className={`h-8 px-3 crm-radius-toggle text-[13px] font-medium cursor-pointer transition-colors whitespace-nowrap ${
|
|
filterStatus === 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 invoices...</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 || filterStatus !== 'ALL' ? 'No jobs match your search.' : 'No service invoices 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={() => openInvoice(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();
|
|
openInvoice(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 invoice"
|
|
>
|
|
<Eye className="w-3.5 h-3.5" />
|
|
</button>
|
|
</div>
|
|
</td>
|
|
</tr>
|
|
))
|
|
)}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
)}
|
|
<TablePagination {...pager} />
|
|
</div>
|
|
|
|
<SlideOver
|
|
open={!!selectedJobId}
|
|
onClose={closeInvoice}
|
|
title={detail ? `Invoice ${detail.job_no || ''}` : 'Invoice details'}
|
|
icon={<Receipt 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 invoice...</span>
|
|
</div>
|
|
) : !detail ? (
|
|
<div className="px-5 py-12 text-center text-[13px] text-muted-foreground">Failed to load invoice data.</div>
|
|
) : (
|
|
<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">{customerName}</p>
|
|
<p className="text-[12px] text-muted-foreground mt-0.5">
|
|
{formatDeviceName(detail.device_brand, detail.device_model)}
|
|
{detail.service_name ? ` · ${detail.service_name}` : ''}
|
|
</p>
|
|
</div>
|
|
{renderJobBadge(detail.status)}
|
|
</div>
|
|
|
|
{quote ? (
|
|
<div className="p-3 border border-border crm-radius-card bg-muted/10 space-y-1.5 text-[13px]">
|
|
<p className="text-[11px] font-semibold text-muted-foreground uppercase">Cost estimate</p>
|
|
<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>
|
|
) : (
|
|
<p className="text-[13px] text-muted-foreground">No quote generated yet.</p>
|
|
)}
|
|
|
|
<div className="space-y-2">
|
|
<p className="text-[11px] font-semibold text-muted-foreground uppercase">Payments</p>
|
|
{payments.length > 0 ? (
|
|
payments.map((p: any, idx: number) => (
|
|
<div
|
|
key={p.payment_id || p.id || idx}
|
|
className="flex items-start justify-between gap-3 p-3 border border-border crm-radius-card"
|
|
>
|
|
<div className="flex items-start gap-2 min-w-0">
|
|
<CreditCard className="w-4 h-4 text-primary mt-0.5 shrink-0" />
|
|
<div className="min-w-0">
|
|
<p className="text-[13px] font-medium text-foreground">{paymentTypeLabel(p.payment_type)}</p>
|
|
{p.paid_at && (
|
|
<p className="text-[12px] text-muted-foreground mt-0.5">
|
|
{format(new Date(p.paid_at), 'dd MMM yyyy')}
|
|
</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
<div className="text-right shrink-0 space-y-1">
|
|
<p className="text-[13px] font-medium text-foreground">{money(p.amount)}</p>
|
|
{renderPaymentBadge(p.status)}
|
|
</div>
|
|
</div>
|
|
))
|
|
) : (
|
|
<p className="text-[13px] text-muted-foreground">No payments recorded for this job yet.</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</SlideOver>
|
|
</div>
|
|
);
|
|
}
|