405 lines
14 KiB
TypeScript
405 lines
14 KiB
TypeScript
/**
|
|
* @page Service bookings list (`app/services/tracking/page.tsx`)
|
|
* @purpose Look up a job number and list the customer's bookings. Details open on a separate page.
|
|
*/
|
|
'use client';
|
|
|
|
import { Suspense, useEffect, useMemo, useState } from 'react';
|
|
import { useRouter, useSearchParams } from 'next/navigation';
|
|
import Link from 'next/link';
|
|
import { formatCurrency } from '@/lib/utils';
|
|
import { serviceBookingService } from '@/services/api/serviceBooking';
|
|
import { setAccessToken } from '@/services/api/client';
|
|
import { useAuthStore } from '@/store/authStore';
|
|
import { Clock, Loader2, Search, ChevronRight, Smartphone, KeyRound } from 'lucide-react';
|
|
import { toast } from 'sonner';
|
|
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';
|
|
import GoogleAuthModal from '@/components/auth/GoogleAuthModal';
|
|
|
|
function unwrapJob(raw: any): any {
|
|
if (!raw) return null;
|
|
if (raw.job && typeof raw.job === 'object') return { ...raw, ...raw.job };
|
|
return raw;
|
|
}
|
|
|
|
function statusBadgeClass(status?: string) {
|
|
const s = String(status || '').toUpperCase();
|
|
if (s === 'CLOSED' || s === 'DELIVERED' || s === 'BOOKED') {
|
|
return 'border-emerald-200 text-emerald-700 bg-emerald-50';
|
|
}
|
|
if (s === 'CANCELLED' || s === 'BOOKING_PENDING' || s === 'PENDING') {
|
|
return 'border-amber-200 text-amber-700 bg-amber-50';
|
|
}
|
|
if (s === 'FINAL_PAYMENT_PENDING') {
|
|
return 'border-red-200 text-red-700 bg-red-50';
|
|
}
|
|
return 'border-primary/20 text-primary bg-primary/5';
|
|
}
|
|
|
|
function formatStatus(status?: string) {
|
|
return String(status || 'UNKNOWN').replace(/_/g, ' ');
|
|
}
|
|
|
|
function firstPayment(job: any): any | null {
|
|
if (!job) return null;
|
|
const list = Array.isArray(job.payments)
|
|
? job.payments
|
|
: Array.isArray(job.payment_records)
|
|
? job.payment_records
|
|
: job.payment
|
|
? [job.payment]
|
|
: [];
|
|
return list[0] || null;
|
|
}
|
|
|
|
function resolvePayment(job: any) {
|
|
const payment = firstPayment(job);
|
|
const basePrice = Number(job?.base_price ?? job?.quoted_price ?? 0);
|
|
const paidFromPayment = Number(payment?.amount ?? payment?.paid_amount ?? 0);
|
|
const paidAmount =
|
|
paidFromPayment > 0 ? paidFromPayment : basePrice > 0 ? basePrice * 0.2 : 0;
|
|
const status =
|
|
payment?.status ||
|
|
payment?.payment_status ||
|
|
(String(job?.status || '').toUpperCase() === 'BOOKING_PENDING'
|
|
? 'PENDING'
|
|
: paidAmount > 0
|
|
? 'CAPTURED'
|
|
: '—');
|
|
return { paidAmount, status };
|
|
}
|
|
|
|
function jobDetailPath(item: { job_id?: string; job_no?: string }, fallback?: string) {
|
|
const id = item.job_id || item.job_no || fallback || '';
|
|
return id ? `/services/tracking/${encodeURIComponent(id)}` : '';
|
|
}
|
|
|
|
function TrackingListContent() {
|
|
const router = useRouter();
|
|
const searchParams = useSearchParams();
|
|
const { isAuthenticated, accessToken, authReady, bootstrapAuth } =
|
|
useAuthStore();
|
|
|
|
const [inputJobNo, setInputJobNo] = useState('');
|
|
const [debouncedJobNo, setDebouncedJobNo] = useState('');
|
|
const [myJobs, setMyJobs] = useState<any[]>([]);
|
|
const [loadingMyJobs, setLoadingMyJobs] = useState(false);
|
|
const [authModalOpen, setAuthModalOpen] = useState(false);
|
|
|
|
useEffect(() => {
|
|
const timer = window.setTimeout(() => {
|
|
setDebouncedJobNo(inputJobNo.trim());
|
|
}, 350);
|
|
return () => window.clearTimeout(timer);
|
|
}, [inputJobNo]);
|
|
|
|
const filteredJobs = useMemo(() => {
|
|
const q = debouncedJobNo.toLowerCase();
|
|
if (!q) return myJobs;
|
|
return myJobs.filter((job) => {
|
|
const haystack = [
|
|
job.job_no,
|
|
job.job_id,
|
|
job.service_name,
|
|
job.device_brand,
|
|
job.device_model,
|
|
job.status,
|
|
]
|
|
.filter(Boolean)
|
|
.join(' ')
|
|
.toLowerCase();
|
|
return haystack.includes(q);
|
|
});
|
|
}, [myJobs, debouncedJobNo]);
|
|
|
|
useEffect(() => {
|
|
const fromQuery = searchParams.get('job_id') || searchParams.get('job_no');
|
|
if (fromQuery) {
|
|
router.replace(`/services/tracking/${encodeURIComponent(fromQuery)}`);
|
|
}
|
|
}, [searchParams, router]);
|
|
|
|
useEffect(() => {
|
|
void bootstrapAuth();
|
|
}, [bootstrapAuth]);
|
|
|
|
useEffect(() => {
|
|
if (accessToken) setAccessToken(accessToken);
|
|
}, [accessToken]);
|
|
|
|
useEffect(() => {
|
|
if (!authReady || !isAuthenticated) {
|
|
setMyJobs([]);
|
|
setLoadingMyJobs(false);
|
|
return;
|
|
}
|
|
void loadMyBookings();
|
|
}, [authReady, isAuthenticated, accessToken]);
|
|
|
|
const loadMyBookings = async () => {
|
|
setLoadingMyJobs(true);
|
|
const byKey = new Map<string, any>();
|
|
|
|
const addJob = (raw: any) => {
|
|
const item = unwrapJob(raw);
|
|
if (!item) return;
|
|
const key = String(item.job_id || item.job_no || '');
|
|
if (!key) return;
|
|
byKey.set(key, item);
|
|
};
|
|
|
|
try {
|
|
const token = useAuthStore.getState().accessToken;
|
|
if (token) setAccessToken(token);
|
|
|
|
const local = serviceBookingService.readLocalJobs();
|
|
local.forEach((ref) => {
|
|
if (ref.job_id || ref.job_no) {
|
|
addJob({
|
|
job_id: ref.job_id,
|
|
job_no: ref.job_no,
|
|
status: 'BOOKED',
|
|
service_name: 'Device repair',
|
|
});
|
|
}
|
|
});
|
|
|
|
await Promise.all(
|
|
local.map(async (ref) => {
|
|
const id = ref.job_id || ref.job_no;
|
|
if (!id) return;
|
|
try {
|
|
addJob(await serviceBookingService.fetchServiceJob(id));
|
|
} catch {
|
|
/* keep stub */
|
|
}
|
|
})
|
|
);
|
|
|
|
const sorted = Array.from(byKey.values()).sort((a, b) => {
|
|
const ta = new Date(a.created_at || a.updated_at || 0).getTime();
|
|
const tb = new Date(b.created_at || b.updated_at || 0).getTime();
|
|
return tb - ta;
|
|
});
|
|
setMyJobs(sorted);
|
|
} finally {
|
|
setLoadingMyJobs(false);
|
|
}
|
|
};
|
|
|
|
const openJobPage = (item?: { job_id?: string; job_no?: string }, fallback?: string) => {
|
|
const path = jobDetailPath(item || {}, fallback);
|
|
if (!path) return;
|
|
router.push(path);
|
|
};
|
|
|
|
const handleSearchSubmit = (e: React.FormEvent) => {
|
|
e.preventDefault();
|
|
const q = inputJobNo.trim();
|
|
if (!q) {
|
|
toast.warning(
|
|
'Please enter a valid Service Job Number (e.g. SRV-1787644172).'
|
|
);
|
|
return;
|
|
}
|
|
const match = myJobs.find(
|
|
(j) => j.job_no === q || j.job_id === q
|
|
);
|
|
openJobPage(match, q);
|
|
};
|
|
|
|
if (!authReady) {
|
|
return (
|
|
<div className="py-20 flex flex-col items-center justify-center text-gray-400 space-y-3">
|
|
<Loader2 className="w-8 h-8 animate-spin text-primary" />
|
|
<span className="text-[12px] font-medium">Loading...</span>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
if (!isAuthenticated) {
|
|
return (
|
|
<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">
|
|
<Clock 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 see your repair bookings and track job status.
|
|
</p>
|
|
</div>
|
|
</div>
|
|
<div className="p-5 md:p-6 space-y-4">
|
|
<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>
|
|
<GoogleAuthModal
|
|
isOpen={authModalOpen}
|
|
onClose={() => setAuthModalOpen(false)}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<div className="container mx-auto px-4 sm:px-6 lg:px-8 max-w-[1400px] py-8 space-y-5">
|
|
<div className="bg-white border border-gray-200 rounded-xl shadow-sm">
|
|
<div className="px-5 md:px-6 py-4 border-b border-gray-100 flex items-center gap-2.5">
|
|
<Clock className="w-5 h-5 text-primary shrink-0" />
|
|
<div>
|
|
<h1 className="text-[18px] md:text-[20px] font-bold text-[#1a1a1a] tracking-tight">
|
|
Track Your Device Repair
|
|
</h1>
|
|
<p className="text-[12px] text-gray-500 mt-0.5">
|
|
Enter your Service Booking Reference ID (e.g., SRV-1787644172)
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
<form
|
|
onSubmit={handleSearchSubmit}
|
|
className="p-5 md:p-6 flex flex-col sm:flex-row gap-3"
|
|
>
|
|
<div className="relative flex-1">
|
|
<Search className="w-4 h-4 text-gray-400 absolute left-3.5 top-1/2 -translate-y-1/2" />
|
|
<input
|
|
type="text"
|
|
placeholder="e.g. SRV-1787644172"
|
|
value={inputJobNo}
|
|
onChange={(e) => setInputJobNo(e.target.value)}
|
|
className="w-full pl-10 pr-4 py-2.5 bg-white border border-gray-200 rounded-lg text-[13px] font-mono text-[#1a1a1a] outline-none focus:border-primary focus:ring-2 focus:ring-primary/15"
|
|
/>
|
|
</div>
|
|
<button
|
|
type="submit"
|
|
className="px-6 py-2.5 bg-primary hover:brightness-95 text-white font-semibold text-[13px] rounded-lg transition cursor-pointer shrink-0"
|
|
>
|
|
Track Status
|
|
</button>
|
|
</form>
|
|
</div>
|
|
|
|
<div className="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">
|
|
<h2 className="text-[16px] font-bold text-[#1a1a1a]">Your bookings</h2>
|
|
</div>
|
|
|
|
{loadingMyJobs ? (
|
|
<div className="py-10 flex items-center justify-center text-gray-400 gap-2">
|
|
<Loader2 className="w-5 h-5 animate-spin text-primary" />
|
|
<span className="text-[13px]">Loading your bookings...</span>
|
|
</div>
|
|
) : myJobs.length === 0 ? (
|
|
<div className="p-6 text-[13px] text-gray-500 text-center">
|
|
No service bookings found yet.
|
|
<div className="mt-3">
|
|
<Link href="/services" className="text-primary font-semibold hover:underline">
|
|
Book a repair
|
|
</Link>
|
|
</div>
|
|
</div>
|
|
) : filteredJobs.length === 0 ? (
|
|
<div className="p-6 text-[13px] text-gray-500 text-center">
|
|
No bookings match “{debouncedJobNo}”.
|
|
</div>
|
|
) : (
|
|
<div className="divide-y divide-gray-100">
|
|
{filteredJobs.map((item) => {
|
|
const id = item.job_id || item.job_no;
|
|
const device = [item.device_brand, item.device_model]
|
|
.filter(Boolean)
|
|
.join(' ');
|
|
const pay = resolvePayment(item);
|
|
return (
|
|
<button
|
|
key={id}
|
|
type="button"
|
|
onClick={() => openJobPage(item)}
|
|
className="w-full text-left px-5 md:px-6 py-4 flex items-center gap-3 hover:bg-gray-50 transition cursor-pointer"
|
|
>
|
|
<div className="w-10 h-10 rounded-lg bg-primary-light text-primary flex items-center justify-center shrink-0">
|
|
<Smartphone className="w-5 h-5" />
|
|
</div>
|
|
<div className="min-w-0 flex-1">
|
|
<div className="flex flex-wrap items-center gap-2">
|
|
<span className="font-mono font-bold text-[13px] text-[#1a1a1a]">
|
|
{item.job_no || item.job_id}
|
|
</span>
|
|
<span
|
|
className={`text-[10px] font-bold px-2 py-0.5 uppercase tracking-wide border rounded-md ${statusBadgeClass(item.status)}`}
|
|
>
|
|
{formatStatus(item.status)}
|
|
</span>
|
|
</div>
|
|
<p className="text-[12px] text-gray-500 mt-0.5 truncate">
|
|
{[item.service_name, device].filter(Boolean).join(' • ') ||
|
|
'Device repair'}
|
|
</p>
|
|
<p className="text-[11px] mt-0.5 font-semibold text-emerald-600">
|
|
{String(pay.status).toUpperCase() === 'CAPTURED' ||
|
|
String(item.status || '').toUpperCase() === 'BOOKED'
|
|
? `Advance paid${pay.paidAmount ? ` • ${formatCurrency(pay.paidAmount)}` : ''}`
|
|
: `Payment ${formatStatus(pay.status)}`}
|
|
</p>
|
|
</div>
|
|
<ChevronRight className="w-4 h-4 text-gray-400 shrink-0" />
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<GoogleAuthModal
|
|
isOpen={authModalOpen}
|
|
onClose={() => setAuthModalOpen(false)}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
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 ServiceJobTrackingQueryPage() {
|
|
return (
|
|
<PageShell>
|
|
<Suspense
|
|
fallback={
|
|
<div className="py-20 flex flex-col items-center justify-center text-gray-400 space-y-3">
|
|
<Loader2 className="w-8 h-8 animate-spin text-primary" />
|
|
<span className="text-[12px] font-medium">Loading tracking...</span>
|
|
</div>
|
|
}
|
|
>
|
|
<TrackingListContent />
|
|
</Suspense>
|
|
</PageShell>
|
|
);
|
|
}
|