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

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