698 lines
29 KiB
TypeScript
698 lines
29 KiB
TypeScript
/**
|
|
* @page Service Job Real-Time Tracking & Payments (`app/services/tracking/[job_id]/page.tsx`)
|
|
* @purpose Progressive timelines, quote approvals, and milestone payments — storefront chrome.
|
|
*/
|
|
'use client';
|
|
|
|
import { useEffect, useState, use } from 'react';
|
|
import Link from 'next/link';
|
|
import { useAuthStore } from '@/store/authStore';
|
|
import { formatCurrency } from '@/lib/utils';
|
|
import { serviceBookingService } from '@/services/api/serviceBooking';
|
|
import { setAccessToken } from '@/services/api/client';
|
|
import { toast } from 'sonner';
|
|
import GoogleAuthModal from '@/components/auth/GoogleAuthModal';
|
|
import {
|
|
Wrench,
|
|
AlertTriangle,
|
|
Calendar,
|
|
CreditCard,
|
|
Loader2,
|
|
FileText,
|
|
ArrowLeft,
|
|
Video,
|
|
CheckCircle2,
|
|
Image as ImageIcon,
|
|
Maximize2,
|
|
KeyRound,
|
|
} from 'lucide-react';
|
|
import { BlurHashImage } from '@/components/ui/BlurHashImage';
|
|
import { AnnouncementBar } from '@/layout/AnnouncementBar';
|
|
import { Header } from '@/layout/Header';
|
|
import { Navbar } from '@/layout/Navbar';
|
|
import { Footer } from '@/layout/Footer';
|
|
import { StickyHeaderSpacer } from '@/components/StickyHeaderSpacer';
|
|
import { FloatingButtons } from '@/components/FloatingButtons';
|
|
|
|
interface Params {
|
|
job_id: string;
|
|
}
|
|
|
|
function PageShell({ children }: { children: React.ReactNode }) {
|
|
return (
|
|
<div className="min-h-screen bg-[#F8F9FB] text-[#1E293B] flex flex-col font-sans">
|
|
<AnnouncementBar />
|
|
<Header />
|
|
<StickyHeaderSpacer />
|
|
<Navbar />
|
|
<main className="flex-grow pb-16">{children}</main>
|
|
<Footer />
|
|
<FloatingButtons />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export default function ServiceJobTrackingPage({
|
|
params,
|
|
}: {
|
|
params: Promise<Params>;
|
|
}) {
|
|
const { job_id } = use(params);
|
|
const { isAuthenticated, accessToken, authReady, bootstrapAuth } = useAuthStore();
|
|
|
|
const [job, setJob] = useState<any>(null);
|
|
const [loading, setLoading] = useState(true);
|
|
const [errorMsg, setErrorMsg] = useState<string | null>(null);
|
|
const [actionLoading, setActionLoading] = useState(false);
|
|
const [previewImage, setPreviewImage] = useState<string | null>(null);
|
|
const [authModalOpen, setAuthModalOpen] = useState(false);
|
|
|
|
useEffect(() => {
|
|
void bootstrapAuth();
|
|
}, [bootstrapAuth]);
|
|
|
|
useEffect(() => {
|
|
if (accessToken) setAccessToken(accessToken);
|
|
}, [accessToken]);
|
|
|
|
useEffect(() => {
|
|
if (!authReady) return;
|
|
if (!isAuthenticated) {
|
|
setLoading(false);
|
|
setJob(null);
|
|
return;
|
|
}
|
|
void loadJobDetails();
|
|
}, [authReady, isAuthenticated, accessToken, job_id]);
|
|
|
|
const loadJobDetails = async () => {
|
|
const token = useAuthStore.getState().accessToken;
|
|
if (token) setAccessToken(token);
|
|
setLoading(true);
|
|
setErrorMsg(null);
|
|
try {
|
|
const data = await serviceBookingService.fetchServiceJob(job_id);
|
|
const item =
|
|
data?.job && typeof data.job === 'object' ? { ...data, ...data.job } : data;
|
|
setJob(item);
|
|
serviceBookingService.rememberLocalJob({
|
|
job_id: item?.job_id || job_id,
|
|
job_no: item?.job_no,
|
|
});
|
|
} catch (err: any) {
|
|
setErrorMsg(err.message || 'Failed to retrieve tracking details.');
|
|
} finally {
|
|
setLoading(false);
|
|
}
|
|
};
|
|
|
|
const handleRespondToQuote = async (action: 'ACCEPT' | 'REJECT') => {
|
|
if (!job?.quote?.quote_id) return;
|
|
setActionLoading(true);
|
|
setErrorMsg(null);
|
|
try {
|
|
await serviceBookingService.respondToQuote(
|
|
job_id,
|
|
job.quote.quote_id,
|
|
action
|
|
);
|
|
toast.success(
|
|
`Quote ${action === 'ACCEPT' ? 'Accepted' : 'Rejected'} successfully.`
|
|
);
|
|
await loadJobDetails();
|
|
} catch (err: any) {
|
|
toast.error(err.message || 'Failed to respond to estimate quote.');
|
|
} finally {
|
|
setActionLoading(false);
|
|
}
|
|
};
|
|
|
|
const loadRazorpayScript = (): Promise<boolean> => {
|
|
return new Promise((resolve) => {
|
|
if ((window as any).Razorpay) {
|
|
resolve(true);
|
|
return;
|
|
}
|
|
const script = document.createElement('script');
|
|
script.src = 'https://checkout.razorpay.com/v1/checkout.js';
|
|
script.onload = () => resolve(true);
|
|
script.onerror = () => resolve(false);
|
|
document.body.appendChild(script);
|
|
});
|
|
};
|
|
|
|
const handleMilestonePayment = async (type: 'ADDITIONAL' | 'FINAL') => {
|
|
setActionLoading(true);
|
|
setErrorMsg(null);
|
|
try {
|
|
const scriptLoaded = await loadRazorpayScript();
|
|
if (!scriptLoaded) {
|
|
throw new Error(
|
|
'Razorpay SDK failed to load. Please verify your internet connection.'
|
|
);
|
|
}
|
|
|
|
const initPayment = await serviceBookingService.initiateServicePayment(
|
|
job_id,
|
|
{
|
|
payment_type: type,
|
|
amount: 0,
|
|
quote_id: job?.quote?.quote_id || undefined,
|
|
}
|
|
);
|
|
|
|
const rzpKey =
|
|
initPayment.rzp_key_id ||
|
|
process.env.NEXT_PUBLIC_RAZORPAY_KEY_ID ||
|
|
'rzp_test_TTUzPFYF0hRV89';
|
|
|
|
const options = {
|
|
key: rzpKey,
|
|
amount: initPayment.amount,
|
|
currency: initPayment.currency || 'INR',
|
|
name: 'iFixKart Repair Payments',
|
|
description: `${type} milestone payment for repair #${job.job_no}`,
|
|
order_id: initPayment.rzp_order_id,
|
|
redirect: false,
|
|
theme: { color: '#1976F3' },
|
|
handler: async function (response: any) {
|
|
try {
|
|
toast.loading('Verifying milestone payment...');
|
|
await serviceBookingService.verifyServicePayment({
|
|
payment_id: initPayment.payment_id,
|
|
razorpay_order_id: response.razorpay_order_id,
|
|
razorpay_payment_id: response.razorpay_payment_id,
|
|
razorpay_signature: response.razorpay_signature,
|
|
});
|
|
toast.success('Payment verified successfully!');
|
|
await loadJobDetails();
|
|
} catch (vErr: any) {
|
|
toast.error(vErr.message || 'Milestone verification failed.');
|
|
} finally {
|
|
setActionLoading(false);
|
|
}
|
|
},
|
|
modal: {
|
|
escape: true,
|
|
handleback: true,
|
|
confirm_close: true,
|
|
ondismiss: function () {
|
|
setActionLoading(false);
|
|
},
|
|
},
|
|
};
|
|
|
|
const rzp = new (window as any).Razorpay(options);
|
|
rzp.open();
|
|
} catch (err: any) {
|
|
toast.error(err.message || 'Failed to initialize payment gateway.');
|
|
setActionLoading(false);
|
|
}
|
|
};
|
|
|
|
if (!authReady || (isAuthenticated && loading)) {
|
|
return (
|
|
<PageShell>
|
|
<div className="py-20 flex flex-col items-center justify-center text-gray-400 space-y-3">
|
|
<Loader2 className="w-8 h-8 text-primary animate-spin" />
|
|
<span className="text-[12px] font-medium">Loading repair details...</span>
|
|
</div>
|
|
</PageShell>
|
|
);
|
|
}
|
|
|
|
if (!isAuthenticated) {
|
|
return (
|
|
<PageShell>
|
|
<div className="container mx-auto px-4 sm:px-6 lg:px-8 max-w-[1400px] py-10 flex justify-center">
|
|
<div className="max-w-[440px] w-full bg-white border border-gray-200 rounded-xl shadow-sm overflow-hidden">
|
|
<div className="px-5 md:px-6 py-4 border-b border-gray-100 flex items-center gap-2.5">
|
|
<Wrench className="w-5 h-5 text-primary shrink-0" />
|
|
<div>
|
|
<h1 className="text-[18px] font-bold text-[#1a1a1a] tracking-tight">
|
|
Track Your Device Repair
|
|
</h1>
|
|
<p className="text-[12px] text-gray-500 mt-0.5">
|
|
Sign in to view this repair job.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
<div className="p-5 md:p-6">
|
|
<button
|
|
type="button"
|
|
onClick={() => setAuthModalOpen(true)}
|
|
className="w-full bg-primary hover:brightness-95 text-white font-semibold py-3.5 rounded-lg text-[14px] transition cursor-pointer flex items-center justify-center gap-2"
|
|
>
|
|
<KeyRound className="w-4 h-4" />
|
|
Continue with Google
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
<GoogleAuthModal
|
|
isOpen={authModalOpen}
|
|
onClose={() => setAuthModalOpen(false)}
|
|
/>
|
|
</PageShell>
|
|
);
|
|
}
|
|
|
|
if (!job) {
|
|
return (
|
|
<PageShell>
|
|
<div className="container mx-auto px-4 sm:px-6 lg:px-8 max-w-[1400px] py-16 flex flex-col items-center justify-center gap-4 text-center">
|
|
<AlertTriangle className="w-12 h-12 text-amber-500" />
|
|
<h2 className="text-[18px] font-bold text-[#222]">
|
|
Service Job Not Found
|
|
</h2>
|
|
<p className="text-[13px] text-gray-500 max-w-sm">
|
|
We could not find job details matching ID{' '}
|
|
<span className="font-mono font-bold text-[#222]">{job_id}</span>.
|
|
{errorMsg ? ` ${errorMsg}` : ''}
|
|
</p>
|
|
<Link
|
|
href="/services/tracking"
|
|
className="mt-2 px-5 py-2.5 bg-primary hover:bg-primary-hover text-white text-[13px] font-semibold transition"
|
|
>
|
|
Back to Track Repair
|
|
</Link>
|
|
</div>
|
|
</PageShell>
|
|
);
|
|
}
|
|
|
|
const getStatusBadge = (status: string) => {
|
|
switch (status) {
|
|
case 'CLOSED':
|
|
case 'DELIVERED':
|
|
return 'border-emerald-200 text-emerald-700 bg-emerald-50';
|
|
case 'CANCELLED':
|
|
return 'border-red-200 text-red-700 bg-red-50';
|
|
case 'BOOKED':
|
|
case 'REPAIR_IN_PROGRESS':
|
|
case 'INSPECTION_PENDING':
|
|
return 'border-primary/20 text-primary bg-primary/5';
|
|
default:
|
|
return 'border-[#e5e5e5] text-[#555] bg-[#f7f9fc]';
|
|
}
|
|
};
|
|
|
|
return (
|
|
<PageShell>
|
|
<div className="container mx-auto px-4 sm:px-6 lg:px-8 max-w-[1400px] py-8">
|
|
<Link
|
|
href="/services/tracking"
|
|
className="inline-flex items-center gap-1.5 text-[13px] font-semibold text-gray-500 hover:text-primary mb-4"
|
|
>
|
|
<ArrowLeft className="w-4 h-4" />
|
|
Back to your bookings
|
|
</Link>
|
|
<div className="grid grid-cols-1 lg:grid-cols-3 gap-5">
|
|
<div className="lg:col-span-2 space-y-5">
|
|
<div className="bg-white border border-[#e5e5e5]">
|
|
<div className="px-5 md:px-6 py-4 border-b border-[#e5e5e5] flex flex-col sm:flex-row sm:items-center justify-between gap-3">
|
|
<div>
|
|
<div className="inline-flex items-center gap-2 text-[11px] font-semibold text-primary mb-1">
|
|
<Wrench className="w-3.5 h-3.5" />
|
|
<span>Real-Time Device Tracking</span>
|
|
</div>
|
|
<h1 className="text-[18px] md:text-[20px] font-bold text-[#222] tracking-tight">
|
|
Repair Job #{job.job_no}
|
|
</h1>
|
|
<p className="text-[12px] text-gray-500 mt-1">
|
|
Service: {job.service_name} • Device: {job.device_brand}{' '}
|
|
{job.device_model}
|
|
</p>
|
|
</div>
|
|
|
|
<span
|
|
className={`px-2.5 py-1 text-[10px] font-bold border self-start uppercase tracking-wide ${getStatusBadge(job.status)}`}
|
|
>
|
|
{job.status}
|
|
</span>
|
|
</div>
|
|
|
|
<div className="p-5 md:p-6 space-y-4">
|
|
<h3 className="font-semibold text-[#222] text-[11px] uppercase tracking-wider">
|
|
Service Timeline
|
|
</h3>
|
|
|
|
{job.events?.length ? (
|
|
<div className="relative pl-6 border-l-2 border-[#e5e5e5] ml-2 space-y-5">
|
|
{job.events.map((event: any, index: number) => (
|
|
<div key={index} className="relative">
|
|
<span className="absolute -left-[31px] top-1 w-3.5 h-3.5 rounded-full bg-primary border-2 border-white" />
|
|
<div className="space-y-0.5">
|
|
<span className="font-bold text-[#222] text-[12px] uppercase tracking-wider block">
|
|
{event.event_type}
|
|
</span>
|
|
<span className="text-[13px] text-[#555] block leading-relaxed">
|
|
{event.notes}
|
|
</span>
|
|
<span className="text-[11px] text-gray-400 font-mono block">
|
|
{new Date(event.timestamp).toLocaleString()}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
) : (
|
|
<p className="text-[13px] text-gray-500">
|
|
No timeline events yet.
|
|
</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* Inspection Done & Ready for Delivery Proof Media Sections (Image 2 Location) */}
|
|
{(() => {
|
|
const mediaList: any[] = Array.isArray(job.media) ? job.media : [];
|
|
const inspectionMedia = mediaList.filter((m) => m.category === 'INSPECTION_DONE');
|
|
const deliveryMedia = mediaList.filter((m) => m.category === 'READY_FOR_DELIVERY');
|
|
|
|
if (inspectionMedia.length === 0 && deliveryMedia.length === 0) return null;
|
|
|
|
return (
|
|
<div className="space-y-5">
|
|
{/* Inspection Done Card */}
|
|
{inspectionMedia.length > 0 && (
|
|
<div className="bg-white border border-[#e5e5e5]">
|
|
<div className="px-5 md:px-6 py-4 border-b border-[#e5e5e5] flex items-center justify-between">
|
|
<div className="flex items-center gap-2">
|
|
<span className="px-2.5 py-1 text-[11px] font-bold bg-blue-50 text-primary border border-blue-200 rounded uppercase tracking-wide flex items-center gap-1.5">
|
|
<Video className="w-3.5 h-3.5" />
|
|
Inspection Done
|
|
</span>
|
|
<span className="text-[12px] text-gray-500 font-medium">Technician Media Proof</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="p-5 md:p-6 space-y-4">
|
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
|
{inspectionMedia.map((item: any, idx: number) => {
|
|
const isVideo = item.mime_type?.startsWith('video/') || item.url?.endsWith('.mp4') || item.url?.endsWith('.webm');
|
|
return (
|
|
<div key={idx} className="relative rounded-lg overflow-hidden border border-gray-200 bg-gray-900 group">
|
|
{isVideo ? (
|
|
<video controls className="w-full h-48 object-cover">
|
|
<source src={item.url} type={item.mime_type || 'video/mp4'} />
|
|
Your browser does not support HTML5 video.
|
|
</video>
|
|
) : (
|
|
<div
|
|
className="relative w-full h-48 cursor-pointer overflow-hidden"
|
|
onClick={() => setPreviewImage(item.url)}
|
|
>
|
|
<BlurHashImage
|
|
src={item.url}
|
|
alt={`Inspection proof photo ${idx + 1}`}
|
|
blurHash={item.blur_hash}
|
|
fill
|
|
className="object-cover group-hover:scale-105 transition-transform duration-300"
|
|
/>
|
|
<div className="absolute inset-0 bg-black/30 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center text-white">
|
|
<Maximize2 className="w-6 h-6" />
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Ready for Delivery Card */}
|
|
{deliveryMedia.length > 0 && (
|
|
<div className="bg-white border border-[#e5e5e5]">
|
|
<div className="px-5 md:px-6 py-4 border-b border-[#e5e5e5] flex items-center justify-between">
|
|
<div className="flex items-center gap-2">
|
|
<span className="px-2.5 py-1 text-[11px] font-bold bg-emerald-50 text-emerald-600 border border-emerald-200 rounded uppercase tracking-wide flex items-center gap-1.5">
|
|
<CheckCircle2 className="w-3.5 h-3.5" />
|
|
Ready for Delivery
|
|
</span>
|
|
<span className="text-[12px] text-gray-500 font-medium">Repaired Product Proof</span>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="p-5 md:p-6 space-y-4">
|
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
|
|
{deliveryMedia.map((item: any, idx: number) => {
|
|
const isVideo = item.mime_type?.startsWith('video/') || item.url?.endsWith('.mp4') || item.url?.endsWith('.webm');
|
|
return (
|
|
<div key={idx} className="relative rounded-lg overflow-hidden border border-gray-200 bg-gray-900 group">
|
|
{isVideo ? (
|
|
<video controls className="w-full h-48 object-cover">
|
|
<source src={item.url} type={item.mime_type || 'video/mp4'} />
|
|
Your browser does not support HTML5 video.
|
|
</video>
|
|
) : (
|
|
<div
|
|
className="relative w-full h-48 cursor-pointer overflow-hidden"
|
|
onClick={() => setPreviewImage(item.url)}
|
|
>
|
|
<BlurHashImage
|
|
src={item.url}
|
|
alt={`Ready for delivery proof photo ${idx + 1}`}
|
|
blurHash={item.blur_hash}
|
|
fill
|
|
className="object-cover group-hover:scale-105 transition-transform duration-300"
|
|
/>
|
|
<div className="absolute inset-0 bg-black/30 opacity-0 group-hover:opacity-100 transition-opacity flex items-center justify-center text-white">
|
|
<Maximize2 className="w-6 h-6" />
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
})()}
|
|
</div>
|
|
|
|
<div className="lg:col-span-1 space-y-5">
|
|
{(() => {
|
|
const payments = Array.isArray(job.payments)
|
|
? job.payments
|
|
: job.payment
|
|
? [job.payment]
|
|
: [];
|
|
const payment = payments[0] || job.payment;
|
|
const basePrice = Number(job.base_price || 0);
|
|
const doorstepFee = job.fulfillment_type === 'DOORSTEP_PICKUP' ? 250 : 0;
|
|
const totalQuote = basePrice + doorstepFee;
|
|
const paid = Number(payment?.amount || (totalQuote ? totalQuote * 0.2 : 0));
|
|
const remaining = Math.max(totalQuote - paid, 0);
|
|
const payStatusRaw =
|
|
payment?.status ||
|
|
payment?.payment_status ||
|
|
(job.status === 'BOOKING_PENDING' ? 'PENDING' : 'PAID');
|
|
const payStatusUpper = String(payStatusRaw).toUpperCase();
|
|
const isPaidStatus = ['CAPTURED', 'PAID', 'SUCCESS', 'COMPLETED'].includes(payStatusUpper);
|
|
const displayPayStatus = isPaidStatus ? 'PAID' : payStatusUpper.replace(/_/g, ' ');
|
|
|
|
return (
|
|
<div className="bg-white border border-[#e5e5e5] p-5 space-y-3 text-[13px]">
|
|
<h4 className="font-semibold text-[#222] uppercase tracking-wider text-[11px] border-b border-[#e5e5e5] pb-2 flex items-center gap-1.5">
|
|
<CreditCard className="w-4 h-4 text-primary" /> Payment
|
|
</h4>
|
|
<div className="flex justify-between text-gray-500">
|
|
<span>Status</span>
|
|
<span
|
|
className={`font-bold ${
|
|
isPaidStatus ? 'text-emerald-600' : 'text-amber-600'
|
|
}`}
|
|
>
|
|
{displayPayStatus}
|
|
</span>
|
|
</div>
|
|
{paid > 0 && (
|
|
<div className="flex justify-between text-gray-500">
|
|
<span>Advance paid</span>
|
|
<span className="font-bold text-primary">
|
|
{formatCurrency(paid)}
|
|
</span>
|
|
</div>
|
|
)}
|
|
{totalQuote > 0 && (
|
|
<div className="flex justify-between text-gray-500">
|
|
<span>Balance due</span>
|
|
<span className="font-semibold text-[#222]">
|
|
{formatCurrency(remaining)}
|
|
</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
})()}
|
|
|
|
{job.appointment && (
|
|
<div className="bg-white border border-[#e5e5e5] p-5 space-y-3 text-[13px]">
|
|
<h4 className="font-semibold text-[#222] uppercase tracking-wider text-[11px] border-b border-[#e5e5e5] pb-2">
|
|
Booking Schedule
|
|
</h4>
|
|
<div className="flex items-start gap-3">
|
|
<Calendar className="w-5 h-5 text-primary shrink-0 mt-0.5" />
|
|
<div>
|
|
<div className="font-bold text-[#222]">
|
|
{new Date(
|
|
job.appointment.scheduled_start
|
|
).toLocaleDateString('en-US', {
|
|
weekday: 'long',
|
|
month: 'short',
|
|
day: 'numeric',
|
|
})}
|
|
</div>
|
|
<div className="text-gray-500 font-mono text-[12px] mt-0.5">
|
|
Start:{' '}
|
|
{new Date(
|
|
job.appointment.scheduled_start
|
|
).toLocaleTimeString('en-US', {
|
|
hour: '2-digit',
|
|
minute: '2-digit',
|
|
})}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{job.quote && (
|
|
<div className="bg-white border border-[#e5e5e5] p-5 space-y-4 text-[13px]">
|
|
<h4 className="font-semibold text-[#222] uppercase tracking-wider text-[11px] border-b border-[#e5e5e5] pb-2 flex items-center gap-1.5">
|
|
<FileText className="w-4 h-4 text-primary" /> Estimate Quote V
|
|
{job.quote.version}
|
|
</h4>
|
|
|
|
<div className="space-y-2 text-[#555]">
|
|
<div className="flex justify-between">
|
|
<span>Subtotal</span>
|
|
<span className="font-semibold text-[#222]">
|
|
{formatCurrency(job.quote.subtotal)}
|
|
</span>
|
|
</div>
|
|
{job.quote.additional_damage_amount > 0 && (
|
|
<div className="flex justify-between text-amber-600 font-semibold">
|
|
<span>Additional Damage</span>
|
|
<span>
|
|
+{formatCurrency(job.quote.additional_damage_amount)}
|
|
</span>
|
|
</div>
|
|
)}
|
|
<div className="flex justify-between">
|
|
<span>GST Tax (18%)</span>
|
|
<span className="font-semibold text-[#222]">
|
|
{formatCurrency(job.quote.tax)}
|
|
</span>
|
|
</div>
|
|
<div className="flex justify-between border-t border-[#e5e5e5] pt-2 font-bold text-[14px] text-[#222]">
|
|
<span>Revised Total</span>
|
|
<span className="text-primary">
|
|
{formatCurrency(job.quote.total)}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
{job.quote.status === 'PENDING_CUSTOMER' && (
|
|
<div className="space-y-2 pt-1">
|
|
<div className="flex gap-2">
|
|
<button
|
|
type="button"
|
|
disabled={actionLoading}
|
|
onClick={() => handleRespondToQuote('ACCEPT')}
|
|
className="flex-1 bg-primary hover:bg-primary-hover text-white font-semibold py-2.5 text-[12px] transition cursor-pointer disabled:opacity-60"
|
|
>
|
|
{actionLoading ? 'Approving...' : 'Accept Quote'}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
disabled={actionLoading}
|
|
onClick={() => handleRespondToQuote('REJECT')}
|
|
className="flex-1 border border-[#e5e5e5] hover:bg-[#f7f9fc] text-[#333] font-semibold py-2.5 text-[12px] transition cursor-pointer disabled:opacity-60"
|
|
>
|
|
Reject
|
|
</button>
|
|
</div>
|
|
{job.quote.expires_at && (
|
|
<span className="text-[11px] text-gray-400 block text-center font-mono pt-1">
|
|
Expires on:{' '}
|
|
{new Date(job.quote.expires_at).toLocaleDateString()}
|
|
</span>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{job.status === 'QUOTE_ACCEPTED' &&
|
|
job.quote.additional_damage_amount > 0 && (
|
|
<button
|
|
type="button"
|
|
disabled={actionLoading}
|
|
onClick={() => handleMilestonePayment('ADDITIONAL')}
|
|
className="w-full bg-primary hover:bg-primary-hover text-white font-semibold py-3 text-[12px] transition flex items-center justify-center gap-2 cursor-pointer disabled:opacity-60"
|
|
>
|
|
<CreditCard className="w-4 h-4" />
|
|
<span>
|
|
Pay Additional Charge (
|
|
{formatCurrency(job.quote.additional_damage_amount)})
|
|
</span>
|
|
</button>
|
|
)}
|
|
</div>
|
|
)}
|
|
|
|
{job.status === 'FINAL_PAYMENT_PENDING' && (
|
|
<div className="bg-white border border-emerald-200 p-5 space-y-3 text-[13px]">
|
|
<h4 className="font-semibold text-emerald-700 uppercase tracking-wider text-[11px]">
|
|
Pay Outstanding Balance
|
|
</h4>
|
|
<p className="text-gray-500 leading-relaxed">
|
|
Repair complete! Please pay the remaining balance to download
|
|
the tax invoice and schedule pickup.
|
|
</p>
|
|
<button
|
|
type="button"
|
|
disabled={actionLoading}
|
|
onClick={() => handleMilestonePayment('FINAL')}
|
|
className="w-full bg-emerald-600 hover:bg-emerald-700 text-white font-semibold py-3 text-[13px] transition cursor-pointer disabled:opacity-60"
|
|
>
|
|
Complete Final Payment
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
<div className="bg-white border border-[#e5e5e5] p-5 text-[13px]">
|
|
<Link
|
|
href="/services/tracking"
|
|
className="text-primary font-semibold hover:underline"
|
|
>
|
|
Track another job
|
|
</Link>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{previewImage && (
|
|
<div
|
|
className="fixed inset-0 z-50 bg-black/80 flex items-center justify-center p-4"
|
|
onClick={() => setPreviewImage(null)}
|
|
>
|
|
<div className="relative max-w-4xl max-h-[90vh] w-full h-full flex items-center justify-center">
|
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
|
<img
|
|
src={previewImage}
|
|
alt="Proof media full view"
|
|
className="max-w-full max-h-full object-contain rounded-lg"
|
|
/>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</PageShell>
|
|
);
|
|
}
|