ifixkart-admin/app/(admin)/technician/page.tsx

1079 lines
51 KiB
TypeScript

'use client';
import { useSearchParams } from 'next/navigation';
import { useEffect, useMemo, useState } from 'react';
import { Calendar, Clock, CreditCard, Eye, ListTodo, Package, RefreshCw, Search, Truck, User, Wrench } from 'lucide-react';
import { toast } from 'sonner';
import { adminService } from '@/services/api/adminService';
import { formatCurrency } from '@/lib/utils';
import { SlideOver } from '@/components/ui/SlideOver';
import { CustomSelect } from '@/components/ui/CustomSelect';
import { TablePagination, useClientPagination } from '@/components/ui/TablePagination';
import { MediaProofModal } from '@/components/ui/MediaProofModal';
const STATUS_OPTIONS = [
{ value: 'BOOKING_PENDING', label: 'Pending deposit' },
{ value: 'BOOKED', label: 'Booked' },
{ value: 'BOOKED_PICKUP', label: 'Booked pickup' },
{ value: 'PICKUP_SCHEDULED', label: 'Pickup scheduled' },
{ value: 'OUT_FOR_PICKUP', label: 'Out for pickup' },
{ value: 'DEVICE_PICKED_UP', label: 'Device picked up' },
{ value: 'IN_TRANSIT', label: 'In transit to store' },
{ value: 'DELIVERED_TO_STORE', label: 'Delivered to store' },
{ value: 'DEVICE_RECEIVED', label: 'Device received' },
{ value: 'DEVICE_INTAKE', label: 'Intake complete' },
{ value: 'INSPECTION_PENDING', label: 'Awaiting inspection' },
{ value: 'INSPECTION_COMPLETED', label: 'Inspection done' },
{ value: 'QUOTE_SENT', label: 'Quote sent' },
{ value: 'QUOTE_ACCEPTED', label: 'Quote approved' },
{ value: 'REPAIR_IN_PROGRESS', label: 'In repair' },
{ value: 'REPAIR_COMPLETED', label: 'Repair complete' },
{ value: 'FINAL_PAYMENT_PENDING', label: 'Payment required' },
{ value: 'READY_FOR_DELIVERY', label: 'Ready for delivery' },
{ value: 'CLOSED', label: 'Closed' },
] as const;
const BOOKED_PICKUP_STATUS_OPTIONS = [
{ value: 'BOOKED_PICKUP', label: 'Booked Pickup' },
{ value: 'PICKUP_SCHEDULED', label: 'Pickup Scheduled' },
{ value: 'OUT_FOR_PICKUP', label: 'Out for Pickup' },
{ value: 'DEVICE_PICKED_UP', label: 'Device Picked Up' },
{ value: 'IN_TRANSIT', label: 'In Transit to Store' },
{ value: 'DELIVERED_TO_STORE', label: 'Delivered to Store' },
{ value: 'READY_FOR_RETURN', label: 'Ready for Return' },
{ value: 'RETURN_IN_TRANSIT', label: 'Out for Return Delivery' },
{ value: 'DELIVERED_TO_CUSTOMER', label: 'Delivered to Customer' },
];
type QueueTab = 'all' | 'inspect' | 'repair' | 'closed';
function isPickupFulfillment(type?: string) {
const t = (type || '').toUpperCase();
return t === 'DOORSTEP_PICKUP' || t === 'PICKUP';
}
function renderFulfillmentBadge(type?: string) {
const t = (type || '').toUpperCase();
if (t === 'DOORSTEP_PICKUP' || t === 'PICKUP') {
return (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-[11px] font-bold uppercase tracking-wider bg-blue-100 text-blue-700 dark:bg-blue-900/50 dark:text-blue-300 border border-blue-200/80 dark:border-blue-800">
<Truck className="w-3 h-3 shrink-0" /> Doorstep Pickup
</span>
);
}
if (t === 'APPOINTMENT_WALK_IN' || t === 'APPOINTMENT') {
return (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-[11px] font-bold uppercase tracking-wider bg-emerald-100 text-emerald-700 dark:bg-emerald-900/50 dark:text-emerald-300 border border-emerald-200/80 dark:border-emerald-800">
<Calendar className="w-3 h-3 shrink-0" /> Appointment Walk-In
</span>
);
}
if (t === 'DELIVERY') {
return (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-[11px] font-bold uppercase tracking-wider bg-purple-100 text-purple-700 dark:bg-purple-900/50 dark:text-purple-300 border border-purple-200/80 dark:border-purple-800">
<Package className="w-3 h-3 shrink-0" /> Delivery
</span>
);
}
return (
<span className="inline-flex items-center gap-1 px-2 py-0.5 rounded text-[11px] font-bold uppercase tracking-wider bg-amber-100 text-amber-700 dark:bg-amber-900/50 dark:text-amber-300 border border-amber-200/80 dark:border-amber-800">
<User className="w-3 h-3 shrink-0" /> Store Walk-In
</span>
);
}
function isUnpaidOnlineDraft(job: any): boolean {
if (!job) return false;
const status = (job.status || '').toUpperCase();
if (status !== 'BOOKING_PENDING') return false;
const source = (job.source || '').toUpperCase();
if (source === 'WALK_IN' || source === 'STORE' || job.intake || job.device_intake) return false;
const pmntStatus = String(
job.payment_status || job.payment?.status || job.payments?.[0]?.status || ''
).toUpperCase();
return !['CAPTURED', 'PAID', 'COMPLETED', 'SUCCESS'].includes(pmntStatus);
}
function isClosedStatus(status?: string) {
const s = (status || '').toUpperCase();
return s === 'CLOSED' || s === 'CANCELLED' || s === 'DELIVERED';
}
function isInspectStatus(status?: string, job?: any) {
if (job && isUnpaidOnlineDraft(job)) return false;
const s = (status || '').toUpperCase();
return (
s === 'BOOKING_PENDING' ||
s === 'BOOKED' ||
s === 'BOOKED_PICKUP' ||
s === 'PICKUP_SCHEDULED' ||
s === 'OUT_FOR_PICKUP' ||
s === 'DEVICE_PICKED_UP' ||
s === 'IN_TRANSIT' ||
s === 'DELIVERED_TO_STORE' ||
s === 'DEVICE_RECEIVED' ||
s === 'DEVICE_INTAKE' ||
s === 'INSPECTION_PENDING' ||
s === 'INSPECTION_COMPLETED' ||
s === 'QUOTE_SENT' ||
s === 'QUOTE_ACCEPTED'
);
}
function isRepairStatus(status?: string) {
const s = (status || '').toUpperCase();
return !isClosedStatus(s) && !isInspectStatus(s);
}
function statusBadgeClass(status?: string) {
const s = (status || '').toUpperCase();
if (s === 'CLOSED' || s === 'REPAIR_COMPLETED' || s === 'READY_FOR_DELIVERY') return 'bg-success text-white';
if (s === 'CANCELLED' || s === 'BOOKING_PENDING') return 'bg-destructive text-white';
if (s === 'REPAIR_IN_PROGRESS' || s === 'INSPECTION_PENDING' || s === 'QUOTE_SENT' || s === 'FINAL_PAYMENT_PENDING') {
return 'bg-warning text-white';
}
if (s === 'DEVICE_INTAKE' || s === 'DEVICE_RECEIVED' || s === 'QUOTE_ACCEPTED' || s.includes('PICKUP') || s.includes('TRANSIT')) return 'bg-primary text-white';
return 'bg-muted text-muted-foreground';
}
function formatStatus(status?: string) {
if (!status) return 'Pending';
const s = status.toUpperCase();
if (s === 'BOOKED_PICKUP') return 'Booked pickup';
if (s === 'PICKUP_SCHEDULED') return 'Pickup scheduled';
if (s === 'OUT_FOR_PICKUP') return 'Out for pickup';
if (s === 'DEVICE_PICKED_UP') return 'Device picked up';
if (s === 'IN_TRANSIT') return 'In transit to store';
if (s === 'DELIVERED_TO_STORE') return 'Delivered to store';
return status.replace(/_/g, ' ');
}
function getQuote(job: any) {
if (!job) return null;
if (job.quote) return job.quote;
if (job.active_quote) return job.active_quote;
if (Array.isArray(job.quotes) && job.quotes.length > 0) return job.quotes[0];
return null;
}
function getIntake(job: any) {
if (!job) return null;
return job.intake || job.device_intake || job.intake_details || null;
}
function getInspection(job: any) {
if (!job) return null;
if (job.inspection) return job.inspection;
if (Array.isArray(job.inspections) && job.inspections.length > 0) {
return job.inspections[job.inspections.length - 1];
}
return null;
}
function getCustomerName(job: any) {
return job?.customer_name || job?.customer?.name || 'Guest customer';
}
function getCustomerContact(job: any) {
const phone = job?.customer_phone || job?.customer?.phone;
const email = job?.customer_email || job?.customer?.email;
if (phone && phone !== 'N/A') return phone;
if (email && email !== 'N/A') return email;
return '—';
}
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 TechnicianPortalPage() {
const searchParams = useSearchParams();
const targetJobId = searchParams.get('job_id') || searchParams.get('job');
const [jobs, setJobs] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [searchQuery, setSearchQuery] = useState('');
const [queueTab, setQueueTab] = useState<QueueTab>('all');
const [activeJobId, setActiveJobId] = useState<string | null>(null);
const [activeJob, setActiveJob] = useState<any>(null);
const [loadingDetail, setLoadingDetail] = useState(false);
const [updatingStatus, setUpdatingStatus] = useState(false);
const [submittingWorkspace, setSubmittingWorkspace] = useState(false);
const [courierName, setCourierName] = useState('');
const [awbNumber, setAwbNumber] = useState('');
const [pickupStatus, setPickupStatus] = useState('BOOKED_PICKUP');
const [savingLogistics, setSavingLogistics] = useState(false);
const [inspectionData, setInspectionData] = useState({
result: 'CUSTOMER_REPORT_CONFIRMED',
customer_report: '',
confirmed_damage: '',
additional_damage: '',
notes: '',
});
const [additionalCost, setAdditionalCost] = useState('0');
const loadAssignedJobs = 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) {
console.warn('Technician queue load:', err.message);
} finally {
setLoading(false);
}
};
useEffect(() => {
loadAssignedJobs();
if (targetJobId) {
handleSelectJob(targetJobId);
}
}, [targetJobId]);
const handleSelectJob = async (jobId: string) => {
setActiveJobId(jobId);
setActiveJob(null);
setLoadingDetail(true);
try {
const data = await adminService.getServiceJobDetailsAdmin(jobId);
setActiveJob(data);
setAdditionalCost('0');
setCourierName(data.courier_name || '');
setAwbNumber(data.awb_number || '');
setPickupStatus(data.pickup_status || 'BOOKED_PICKUP');
setInspectionData({
result: 'CUSTOMER_REPORT_CONFIRMED',
customer_report: `Customer reported: ${data.service_name || 'repair'}`,
confirmed_damage: '',
additional_damage: '',
notes: '',
});
} catch (err: any) {
toast.error(err.message || 'Failed to retrieve job details.');
setActiveJobId(null);
} finally {
setLoadingDetail(false);
}
};
const handleSaveLogistics = async () => {
if (!activeJobId) return;
setSavingLogistics(true);
try {
await adminService.updateServiceJobLogistics(activeJobId, {
courier_name: courierName,
awb_number: awbNumber,
pickup_status: pickupStatus,
});
toast.success('Booked pickup & logistics details saved successfully!');
handleSelectJob(activeJobId);
loadAssignedJobs();
} catch (err: any) {
toast.error(err.message || 'Failed to save logistics details.');
} finally {
setSavingLogistics(false);
}
};
const closeWorkspace = () => {
if (updatingStatus || submittingWorkspace) return;
setActiveJobId(null);
setActiveJob(null);
};
const [proofModalOpen, setProofModalOpen] = useState(false);
const [proofTargetStatus, setProofTargetStatus] = useState<'INSPECTION_COMPLETED' | 'READY_FOR_DELIVERY'>('INSPECTION_COMPLETED');
const handleStatusChange = async (newStatus: string) => {
if (!activeJobId || !activeJob) return;
if (newStatus === 'INSPECTION_COMPLETED' || newStatus === 'READY_FOR_DELIVERY') {
setProofTargetStatus(newStatus);
setProofModalOpen(true);
return;
}
const previous = activeJob.status;
setUpdatingStatus(true);
setActiveJob({ ...activeJob, status: newStatus });
setJobs((prev) => prev.map((j) => (j.job_id === activeJobId ? { ...j, status: newStatus } : j)));
try {
await adminService.updateServiceJobStatus(activeJobId, newStatus);
toast.success('Technician job status saved');
} catch (err: any) {
setActiveJob({ ...activeJob, status: previous });
setJobs((prev) => prev.map((j) => (j.job_id === activeJobId ? { ...j, status: previous } : j)));
toast.error(err.message || 'Failed to update status.');
} finally {
setUpdatingStatus(false);
}
};
const handleInspectionSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!activeJobId || !activeJob) return;
setSubmittingWorkspace(true);
try {
await adminService.submitDiagnosticInspection(activeJobId, {
result: inspectionData.result,
customer_report: inspectionData.customer_report || null,
confirmed_damage: inspectionData.confirmed_damage || null,
additional_damage: inspectionData.additional_damage || null,
notes: inspectionData.notes || null,
});
const baseCost = parseFloat(String(activeJob.base_price)) || 0;
const extraCost =
inspectionData.result === 'ADDITIONAL_DAMAGE_FOUND' ? parseFloat(additionalCost) || 0 : 0;
const subtotal = baseCost;
const additional_damage_amount = extraCost;
const tax = (subtotal + additional_damage_amount) * 0.18;
const total = subtotal + additional_damage_amount + tax;
await adminService.createOrReviseQuote(activeJobId, {
subtotal,
tax: parseFloat(tax.toFixed(2)),
additional_damage_amount,
total: parseFloat(total.toFixed(2)),
reason:
inspectionData.result === 'ADDITIONAL_DAMAGE_FOUND'
? `Revised estimate: additional damage found — ${inspectionData.additional_damage}`
: `Standard repair quote for ${activeJob.service_name}`,
});
const updatedDetail = await adminService.getServiceJobDetailsAdmin(activeJobId);
setActiveJob(updatedDetail);
toast.success('Inspection saved and quote generated');
loadAssignedJobs();
} catch (err: any) {
toast.error(err.message || 'Failed to submit inspection details.');
} finally {
setSubmittingWorkspace(false);
}
};
const validJobs = useMemo(() => jobs.filter((j) => !isUnpaidOnlineDraft(j)), [jobs]);
const inspectCount = validJobs.filter((j) => isInspectStatus(j.status, j)).length;
const repairCount = validJobs.filter((j) => isRepairStatus(j.status)).length;
const closedCount = validJobs.filter((j) => isClosedStatus(j.status)).length;
const filteredJobs = useMemo(() => {
const q = searchQuery.trim().toLowerCase();
return jobs.filter((job) => {
// Unpaid online drafts are hidden from active queues unless searching explicitly by query
if (isUnpaidOnlineDraft(job) && !q) return false;
const tabMatch =
queueTab === 'all' ||
(queueTab === 'inspect' && isInspectStatus(job.status, job)) ||
(queueTab === 'repair' && isRepairStatus(job.status)) ||
(queueTab === 'closed' && isClosedStatus(job.status));
const haystack = [
job.job_no,
getCustomerName(job),
getCustomerContact(job),
job.device_brand,
job.device_model,
job.service_name,
job.status,
]
.filter(Boolean)
.join(' ')
.toLowerCase();
return tabMatch && (!q || haystack.includes(q));
});
}, [jobs, searchQuery, queueTab]);
const pager = useClientPagination(filteredJobs);
const quote = getQuote(activeJob);
const intake = getIntake(activeJob);
const lastInspection = getInspection(activeJob);
const showInspectionForm = activeJob && isInspectStatus(activeJob.status, activeJob) && !isUnpaidOnlineDraft(activeJob);
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 inputClass =
'w-full h-9 px-3 bg-card border border-border crm-radius-control text-[13px] text-foreground focus:outline-none focus:border-primary disabled:opacity-50';
const labelClass = 'block text-[11px] font-semibold text-muted-foreground uppercase mb-1.5';
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 renderStatusBadge = (status?: string) => (
<span className={`inline-flex items-center px-2.5 py-1 crm-radius-badge text-[11px] font-semibold ${statusBadgeClass(status)}`}>
{formatStatus(status)}
</span>
);
const quoteCard = quote ? (
<div className="p-3 border border-border crm-radius-card bg-muted/10 space-y-1.5 text-[13px]">
<div className="flex items-center justify-between gap-2">
<p className="text-[11px] font-semibold text-muted-foreground uppercase">
Quote{quote.version != null ? ` v${quote.version}` : ''}
</p>
{quote.status && renderStatusBadge(quote.status)}
</div>
<div className="flex justify-between text-muted-foreground">
<span>{activeJob?.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</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>
{quote.reason && <p className="text-[12px] text-muted-foreground pt-1">{quote.reason}</p>}
</div>
) : null;
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">Technician Workspace</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={loadAssignedJobs}
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: 'inspect', label: `Inspect (${inspectCount})` },
{ id: 'repair', label: `Repair (${repairCount})` },
{ id: 'closed', label: `Closed (${closedCount})` },
] as { id: QueueTab; label: string }[]
).map((tab) => (
<button
key={tab.id}
type="button"
onClick={() => setQueueTab(tab.id)}
className={`h-8 px-3 crm-radius-toggle text-[13px] font-medium cursor-pointer transition-colors whitespace-nowrap ${
queueTab === 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 jobs...</span>
</div>
) : (
<div className="overflow-x-auto">
<table className="crm-data-table min-w-[860px]">
<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 / service</th>
<th className="px-5 py-3 text-left text-[13px] font-semibold text-gray-700">Price</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-center text-[13px] font-semibold text-gray-700">Action</th>
</tr>
</thead>
<tbody className="bg-card">
{filteredJobs.length === 0 ? (
<tr>
<td colSpan={6} className="text-center py-12 text-[13px] text-muted-foreground">
{searchQuery || queueTab !== 'all' ? 'No jobs match your search.' : 'No jobs currently assigned.'}
</td>
</tr>
) : (
pager.items.map((job) => (
<tr
key={job.job_id}
className={`border-t border-border hover:bg-muted/10 cursor-pointer ${
activeJobId === job.job_id ? 'bg-muted/20' : ''
}`}
onClick={() => handleSelectJob(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">{getCustomerName(job)}</p>
<p className="text-[12px] text-muted-foreground mt-0.5">{getCustomerContact(job)}</p>
</td>
<td className="px-5 py-3.5">
<p className="text-[13px] font-medium text-foreground">
{formatDeviceName(job.device_brand, job.device_model)}
</p>
<p className="text-[12px] text-muted-foreground mt-0.5">{job.service_name || '—'}</p>
</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">{renderStatusBadge(job.status)}</td>
<td className="px-5 py-3.5">
<div className="flex items-center justify-center">
<button
type="button"
onClick={(e) => {
e.stopPropagation();
handleSelectJob(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="Open workspace"
>
<Eye className="w-3.5 h-3.5" />
</button>
</div>
</td>
</tr>
))
)}
</tbody>
</table>
</div>
)}
<TablePagination {...pager} />
</div>
<SlideOver
open={!!activeJobId}
onClose={closeWorkspace}
title={activeJob ? `Job ${activeJob.job_no || activeJobId}` : 'Job workspace'}
icon={<Wrench 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 job...</span>
</div>
) : !activeJob ? (
<div className="px-5 py-12 text-center text-[13px] text-muted-foreground">Failed to load job details.</div>
) : (
<div className="flex min-h-0 flex-1 flex-col">
{showInspectionForm ? (
<form onSubmit={handleInspectionSubmit} 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">
{formatDeviceName(activeJob.device_brand, activeJob.device_model, 'Device')}
</p>
<p className="text-[12px] text-muted-foreground mt-0.5">
{activeJob.service_name || '—'} · {getCustomerName(activeJob)}
</p>
</div>
<div className="flex items-center gap-1.5 shrink-0 flex-wrap justify-end">
{renderFulfillmentBadge(activeJob.fulfillment_type)}
{renderStatusBadge(activeJob.status)}
</div>
</div>
{isUnpaidOnlineDraft(activeJob) && (
<div className="p-3 bg-amber-500/10 border border-amber-500/30 text-amber-700 dark:text-amber-300 crm-radius-card space-y-1">
<div className="flex items-center gap-1.5 font-bold text-[12px] uppercase tracking-wider">
<Clock className="w-3.5 h-3.5 text-amber-500 shrink-0" />
Advance Deposit Pending (20%)
</div>
<p className="text-[12px] text-muted-foreground leading-relaxed">
Customer has initiated this booking but advance deposit payment is still pending. Status controls, pickup logistics, and diagnostic inspection are locked until advance payment is verified.
</p>
</div>
)}
{activeJob.status === 'FINAL_PAYMENT_PENDING' && (
<div className="p-3 bg-blue-500/10 border border-blue-500/30 text-blue-700 dark:text-blue-300 crm-radius-card space-y-1">
<div className="flex items-center gap-1.5 font-bold text-[12px] uppercase tracking-wider">
<CreditCard className="w-3.5 h-3.5 text-primary shrink-0" />
Final Payment Collection Required
</div>
<p className="text-[12px] text-muted-foreground leading-relaxed">
Repair is completed. Waiting for final payment collection at device delivery or customer pickup.
</p>
</div>
)}
{!isUnpaidOnlineDraft(activeJob) && activeJob.status !== 'FINAL_PAYMENT_PENDING' && (
<div>
<label className={labelClass}>Status</label>
<CustomSelect
value={activeJob.status}
onChange={handleStatusChange}
disabled={updatingStatus}
options={[
...STATUS_OPTIONS.map((s) => ({ value: s.value, label: s.label })),
...(!STATUS_OPTIONS.some((s) => s.value === activeJob.status) && activeJob.status
? [{ value: activeJob.status, label: formatStatus(activeJob.status) }]
: []),
]}
/>
</div>
)}
{/* 2nd Dropdown: Booked Pickup Status - ONLY VISIBLE IF PICKUP & DEPOSIT PAID & NOT FINAL_PAYMENT_PENDING */}
{isPickupFulfillment(activeJob.fulfillment_type) && !isUnpaidOnlineDraft(activeJob) && activeJob.status !== 'FINAL_PAYMENT_PENDING' && (
<div className="space-y-3 p-3 border border-blue-200/80 bg-blue-50/40 dark:bg-blue-950/20 crm-radius-card">
<div>
<label className={labelClass}>Booked Pickup Status</label>
<CustomSelect
value={pickupStatus || 'BOOKED_PICKUP'}
onChange={async (val) => {
setPickupStatus(val);
if (!activeJobId) return;
setSavingLogistics(true);
try {
await adminService.updateServiceJobLogistics(activeJobId, {
courier_name: courierName,
awb_number: awbNumber,
pickup_status: val,
});
toast.success(`Booked pickup status updated to ${formatStatus(val)}!`);
loadAssignedJobs();
} catch (err: any) {
toast.error(err.message || 'Failed to update pickup status.');
} finally {
setSavingLogistics(false);
}
}}
options={BOOKED_PICKUP_STATUS_OPTIONS}
/>
</div>
<div className="space-y-2 text-[13px]">
<div className="flex items-center justify-between">
<span className="text-[11px] font-semibold text-primary uppercase tracking-wider flex items-center gap-1.5">
<Truck className="w-3.5 h-3.5" /> Courier & Tracking
</span>
<span className="text-[10px] font-bold px-2 py-0.5 bg-blue-100 text-blue-700 dark:bg-blue-900/50 dark:text-blue-300 rounded uppercase">
{activeJob?.fulfillment_type || 'DOORSTEP_PICKUP'}
</span>
</div>
<div className="grid grid-cols-2 gap-2">
<div>
<label className="text-[11px] text-muted-foreground font-medium mb-1 block">Courier Partner</label>
<input
type="text"
placeholder="e.g. Porter, Dunzo"
value={courierName}
onChange={(e) => setCourierName(e.target.value)}
onBlur={handleSaveLogistics}
className={inputClass}
/>
</div>
<div>
<label className="text-[11px] text-muted-foreground font-medium mb-1 block">AWB / Tracking No.</label>
<input
type="text"
placeholder="e.g. AWB-987654321"
value={awbNumber}
onChange={(e) => setAwbNumber(e.target.value)}
onBlur={handleSaveLogistics}
className={inputClass}
/>
</div>
</div>
</div>
</div>
)}
{intake && (
<div className="p-3 border border-border crm-radius-card space-y-1 text-[13px]">
<p className="text-[11px] font-semibold text-muted-foreground uppercase">Intake</p>
{intake.power_status && (
<p className="text-muted-foreground">Power: <span className="text-foreground">{formatStatus(intake.power_status)}</span></p>
)}
{intake.screen_condition && (
<p className="text-muted-foreground">Screen: <span className="text-foreground">{formatStatus(intake.screen_condition)}</span></p>
)}
{intake.body_condition && (
<p className="text-muted-foreground">Body: <span className="text-foreground">{formatStatus(intake.body_condition)}</span></p>
)}
{intake.accessories && (
<p className="text-muted-foreground">Accessories: <span className="text-foreground">{intake.accessories}</span></p>
)}
</div>
)}
{quoteCard}
<div className="space-y-3">
<div className="flex items-center gap-2">
<ListTodo className="w-4 h-4 text-primary" />
<p className="text-[11px] font-semibold text-muted-foreground uppercase">Diagnostic inspection</p>
</div>
<div>
<label className={labelClass}>
Result <span className="text-primary">*</span>
</label>
<CustomSelect
value={inspectionData.result}
onChange={(value) => setInspectionData({ ...inspectionData, result: value })}
options={[
{ value: 'CUSTOMER_REPORT_CONFIRMED', label: 'Customer report confirmed' },
{ value: 'ADDITIONAL_DAMAGE_FOUND', label: 'Additional damage found' },
{ value: 'CUSTOMER_REPORT_NOT_CONFIRMED', label: 'Reported issue not confirmed' },
]}
/>
</div>
<div>
<label className={labelClass}>Reported symptoms</label>
<textarea
rows={2}
value={inspectionData.customer_report}
onChange={(e) => setInspectionData({ ...inspectionData, customer_report: e.target.value })}
className={`${inputClass} h-20 py-2 resize-none`}
/>
</div>
<div>
<label className={labelClass}>Confirmed damage</label>
<textarea
rows={2}
placeholder="Verified findings"
value={inspectionData.confirmed_damage}
onChange={(e) => setInspectionData({ ...inspectionData, confirmed_damage: e.target.value })}
className={`${inputClass} h-20 py-2 resize-none`}
/>
</div>
{inspectionData.result === 'ADDITIONAL_DAMAGE_FOUND' && (
<div className="p-3 border border-border crm-radius-card bg-muted/10 space-y-3">
<div>
<label className={labelClass}>
Additional damage <span className="text-primary">*</span>
</label>
<textarea
required
rows={2}
placeholder="e.g. Back glass shattered"
value={inspectionData.additional_damage}
onChange={(e) => setInspectionData({ ...inspectionData, additional_damage: e.target.value })}
className={`${inputClass} h-20 py-2 resize-none`}
/>
</div>
<div>
<label className={labelClass}>
Additional cost () <span className="text-primary">*</span>
</label>
<input
type="number"
min="0"
step="0.01"
required
placeholder="2500"
value={additionalCost}
onChange={(e) => setAdditionalCost(e.target.value)}
className={inputClass}
/>
</div>
</div>
)}
<div>
<label className={labelClass}>Internal notes</label>
<textarea
rows={2}
placeholder="Diagnostic log"
value={inspectionData.notes}
onChange={(e) => setInspectionData({ ...inspectionData, notes: e.target.value })}
className={`${inputClass} h-20 py-2 resize-none`}
/>
</div>
{!quote && (() => {
const base = parseFloat(String(activeJob?.base_price)) || 0;
const doorstepFee = activeJob?.fulfillment_type === 'DOORSTEP_PICKUP' ? 250 : 0;
const extra =
inspectionData.result === 'ADDITIONAL_DAMAGE_FOUND' ? parseFloat(additionalCost) || 0 : 0;
const subtotal = base + doorstepFee + extra;
const tax = subtotal * 0.18;
const total = subtotal + tax;
return (
<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">Quote preview</p>
<div className="flex justify-between text-muted-foreground">
<span>Base service</span>
<span>{base.toFixed(2)}</span>
</div>
{doorstepFee > 0 && (
<div className="flex justify-between text-muted-foreground">
<span>Doorstep pickup fee</span>
<span>+ {doorstepFee.toFixed(2)}</span>
</div>
)}
{extra > 0 && (
<div className="flex justify-between text-muted-foreground">
<span>Additional damage</span>
<span>+ {extra.toFixed(2)}</span>
</div>
)}
<div className="flex justify-between text-muted-foreground">
<span>GST (18%)</span>
<span>{tax.toFixed(2)}</span>
</div>
<div className="flex justify-between font-semibold text-foreground border-t border-border pt-1.5">
<span>Total</span>
<span>{total.toFixed(2)}</span>
</div>
</div>
);
})()}
</div>
</div>
<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={closeWorkspace} disabled={submittingWorkspace} className={secondaryButton}>
Cancel
</button>
<button type="submit" disabled={submittingWorkspace} className={primaryButton}>
{submittingWorkspace ? (
<>
<RefreshCw className="w-3.5 h-3.5 animate-spin" />
Submitting...
</>
) : (
'Submit inspection'
)}
</button>
</div>
</form>
) : (
<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">
{formatDeviceName(activeJob.device_brand, activeJob.device_model, 'Device')}
</p>
<p className="text-[12px] text-muted-foreground mt-0.5">
{activeJob.service_name || '—'} · {getCustomerName(activeJob)}
</p>
</div>
<div className="flex items-center gap-1.5 shrink-0 flex-wrap justify-end">
{renderFulfillmentBadge(activeJob.fulfillment_type)}
{renderStatusBadge(activeJob.status)}
</div>
</div>
{isUnpaidOnlineDraft(activeJob) && (
<div className="p-3 bg-amber-500/10 border border-amber-500/30 text-amber-700 dark:text-amber-300 crm-radius-card space-y-1">
<div className="flex items-center gap-1.5 font-bold text-[12px] uppercase tracking-wider">
<Clock className="w-3.5 h-3.5 text-amber-500 shrink-0" />
Advance Deposit Pending (20%)
</div>
<p className="text-[12px] text-muted-foreground leading-relaxed">
Customer has initiated this booking but advance deposit payment is still pending. Status controls, pickup logistics, and diagnostic inspection are locked until advance payment is verified.
</p>
</div>
)}
{activeJob.status === 'FINAL_PAYMENT_PENDING' && (
<div className="p-3 bg-blue-500/10 border border-blue-500/30 text-blue-700 dark:text-blue-300 crm-radius-card space-y-1">
<div className="flex items-center gap-1.5 font-bold text-[12px] uppercase tracking-wider">
<CreditCard className="w-3.5 h-3.5 text-primary shrink-0" />
Final Payment Collection Required
</div>
<p className="text-[12px] text-muted-foreground leading-relaxed">
Repair is completed. Waiting for final payment collection at device delivery or customer pickup.
</p>
</div>
)}
{!isUnpaidOnlineDraft(activeJob) && activeJob.status !== 'FINAL_PAYMENT_PENDING' && (
<div>
<label className={labelClass}>Status</label>
<CustomSelect
value={activeJob.status}
onChange={handleStatusChange}
disabled={updatingStatus}
options={[
...STATUS_OPTIONS.map((s) => ({ value: s.value, label: s.label })),
...(!STATUS_OPTIONS.some((s) => s.value === activeJob.status) && activeJob.status
? [{ value: activeJob.status, label: formatStatus(activeJob.status) }]
: []),
]}
/>
</div>
)}
{/* 2nd Dropdown: Booked Pickup Status - ONLY VISIBLE IF PICKUP & DEPOSIT PAID */}
{isPickupFulfillment(activeJob.fulfillment_type) && !isUnpaidOnlineDraft(activeJob) && (
<div className="space-y-3 p-3 border border-blue-200/80 bg-blue-50/40 dark:bg-blue-950/20 crm-radius-card">
<div>
<label className={labelClass}>Booked Pickup Status</label>
<CustomSelect
value={pickupStatus || 'BOOKED_PICKUP'}
onChange={async (val) => {
setPickupStatus(val);
if (!activeJobId) return;
setSavingLogistics(true);
try {
await adminService.updateServiceJobLogistics(activeJobId, {
courier_name: courierName,
awb_number: awbNumber,
pickup_status: val,
});
toast.success(`Booked pickup status updated to ${formatStatus(val)}!`);
loadAssignedJobs();
} catch (err: any) {
toast.error(err.message || 'Failed to update pickup status.');
} finally {
setSavingLogistics(false);
}
}}
options={BOOKED_PICKUP_STATUS_OPTIONS}
/>
</div>
<div className="space-y-2 text-[13px]">
<div className="flex items-center justify-between">
<span className="text-[11px] font-semibold text-primary uppercase tracking-wider flex items-center gap-1.5">
<Truck className="w-3.5 h-3.5" /> Courier & Tracking
</span>
<span className="text-[10px] font-bold px-2 py-0.5 bg-blue-100 text-blue-700 dark:bg-blue-900/50 dark:text-blue-300 rounded uppercase">
{activeJob?.fulfillment_type || 'DOORSTEP_PICKUP'}
</span>
</div>
<div className="grid grid-cols-2 gap-2">
<div>
<label className="text-[11px] text-muted-foreground font-medium mb-1 block">Courier Partner</label>
<input
type="text"
placeholder="e.g. Porter, Dunzo"
value={courierName}
onChange={(e) => setCourierName(e.target.value)}
onBlur={handleSaveLogistics}
className={inputClass}
/>
</div>
<div>
<label className="text-[11px] text-muted-foreground font-medium mb-1 block">AWB / Tracking No.</label>
<input
type="text"
placeholder="e.g. AWB-987654321"
value={awbNumber}
onChange={(e) => setAwbNumber(e.target.value)}
onBlur={handleSaveLogistics}
className={inputClass}
/>
</div>
</div>
</div>
</div>
)}
<div className="grid grid-cols-2 gap-3 text-[13px]">
<div>
<p className="text-[11px] font-semibold text-muted-foreground uppercase mb-1">Service</p>
<p className="text-foreground">{activeJob.service_name || '—'}</p>
</div>
<div>
<p className="text-[11px] font-semibold text-muted-foreground uppercase mb-1">Base price</p>
<p className="text-foreground">{money(activeJob.base_price)}</p>
</div>
</div>
{intake && (
<div className="p-3 border border-border crm-radius-card space-y-1 text-[13px]">
<p className="text-[11px] font-semibold text-muted-foreground uppercase">Intake</p>
{intake.power_status && <p className="text-muted-foreground">Power: <span className="text-foreground">{formatStatus(intake.power_status)}</span></p>}
{intake.screen_condition && <p className="text-muted-foreground">Screen: <span className="text-foreground">{formatStatus(intake.screen_condition)}</span></p>}
{intake.body_condition && <p className="text-muted-foreground">Body: <span className="text-foreground">{formatStatus(intake.body_condition)}</span></p>}
{intake.accessories && <p className="text-muted-foreground">Accessories: <span className="text-foreground">{intake.accessories}</span></p>}
</div>
)}
{lastInspection && (
<div className="p-3 border border-border crm-radius-card space-y-1 text-[13px]">
<p className="text-[11px] font-semibold text-muted-foreground uppercase">Last inspection</p>
<p className="text-foreground">{formatStatus(lastInspection.result)}</p>
{lastInspection.confirmed_damage && (
<p className="text-muted-foreground">{lastInspection.confirmed_damage}</p>
)}
{lastInspection.additional_damage && (
<p className="text-muted-foreground">{lastInspection.additional_damage}</p>
)}
{lastInspection.notes && <p className="text-[12px] text-muted-foreground">{lastInspection.notes}</p>}
</div>
)}
{quoteCard || (
<p className="text-[13px] text-muted-foreground">No quote yet. Submit an inspection from the Inspect queue.</p>
)}
</div>
)}
</div>
)}
</SlideOver>
{activeJobId && (
<MediaProofModal
open={proofModalOpen}
onClose={() => setProofModalOpen(false)}
jobId={activeJobId}
targetStatus={proofTargetStatus}
onSuccess={() => {
handleSelectJob(activeJobId);
loadAssignedJobs();
}}
/>
)}
</div>
);
}