1079 lines
51 KiB
TypeScript
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>
|
|
);
|
|
}
|