Files
spq-v2/src/pages/technician/TechnicianJobs.tsx
T
2026-07-12 10:01:39 -04:00

324 lines
14 KiB
TypeScript

import { useState, useMemo } from 'react';
import { pb } from '../../lib/pocketbase';
import { Search, X, ChevronRight, AlertTriangle } from 'lucide-react';
import type { TechnicianAssignment, AvailableRepairOrder } from '../../lib/technicianData';
import { fetchTechnicianAssignments, fetchAvailableRepairOrders, createTechnicianAssignment, clockElapsedMinutes, parseRepairOrderServices } from '../../lib/technicianData';
import { useTechnicianContext, useTechnicianNav } from '../../components/technician/TechnicianContext';
import { useToast } from '../../components/ui/Toast';
import { ListSkeleton } from '../../components/ui/ListSkeleton';
import { ListError } from '../../components/ui/ListError';
import { formatElapsed } from '../../lib/format';
import { usePagedList } from '../../hooks/usePagedList';
type StatusTab = 'all' | 'active' | 'completed' | 'available';
export default function TechnicianJobs() {
const { navigateTech } = useTechnicianNav();
const { showToast } = useToast();
const { effectiveUserId, isImpersonating } = useTechnicianContext();
const authUserId = pb.authStore.model?.id as string | undefined;
const shopUserId = isImpersonating ? authUserId : (pb.authStore.model as Record<string, unknown>)?.shopUserId as string | undefined;
const {
items: assignments,
loading,
error: assignmentsError,
} = usePagedList<TechnicianAssignment>(
(page, perPage) => {
if (!effectiveUserId) return Promise.resolve({ items: [], page: 1, perPage, totalItems: 0, totalPages: 1 });
return fetchTechnicianAssignments(effectiveUserId, page, perPage);
},
50,
);
const {
items: availableRDs,
loading: availableLoading,
error: availableError,
} = usePagedList<AvailableRepairOrder>(
(page, perPage) => {
if (!shopUserId) return Promise.resolve({ items: [], page: 1, perPage, totalItems: 0, totalPages: 1 });
return fetchAvailableRepairOrders(shopUserId, page, perPage);
},
50,
);
const [search, setSearch] = useState('');
const [statusTab, setStatusTab] = useState<StatusTab>('active');
const [claimingId, setClaimingId] = useState<string | null>(null);
const [claimError, setClaimError] = useState<string | null>(null);
const filtered = useMemo(() => {
let list = assignments;
if (statusTab === 'active') {
list = list.filter(
(a) =>
a.status === 'pending' ||
a.status === 'in_progress' ||
a.status === 'waiting_parts',
);
} else if (statusTab === 'completed') {
list = list.filter((a) => a.status === 'completed');
}
if (search.trim()) {
const q = search.toLowerCase();
list = list.filter(
(a) =>
a.roNumber?.toLowerCase().includes(q) ||
a.vehicleInfo?.toLowerCase().includes(q) ||
a.vin?.toLowerCase().includes(q),
);
}
return list;
}, [assignments, statusTab, search]);
const assignedROIds = useMemo(() => {
return new Set(assignments.map((a) => a.repairOrderId));
}, [assignments]);
const unclaimedRDs = useMemo(() => {
return availableRDs.filter((ro) => !assignedROIds.has(ro.id));
}, [availableRDs, assignedROIds]);
const handleClaim = async (ro: AvailableRepairOrder) => {
setClaimError(null);
if (!effectiveUserId) {
showToast('You must be signed in to claim a job.', 'error');
return;
}
const shopUserId = isImpersonating ? authUserId : (pb.authStore.model as Record<string, unknown>)?.shopUserId as string | undefined;
if (!shopUserId) {
showToast('Your account is not linked to a shop. Contact your shop owner.', 'error');
return;
}
setClaimingId(ro.id);
try {
const full = await pb.collection('repairOrders').getOne(ro.id) as Record<string, unknown>;
const serviceList = parseRepairOrderServices(full.services).map((s) => ({
id: s.id,
name: s.name,
description: s.description || '',
status: s.status || 'pending',
}));
if (serviceList.length === 0) {
showToast('This repair order has no services to claim.', 'error');
setClaimingId(null);
return;
}
const allIds = serviceList.map((s: { id: string }) => s.id);
const assignment = await createTechnicianAssignment(
{
id: ro.id,
roNumber: (full.roNumber as string) || '',
vehicleInfo: (full.vehicleInfo as string) || '',
vin: (full.vin as string) || '',
mileage: (full.mileage as string) || '',
serviceLocation: (full.serviceLocation as string) || '',
services: serviceList,
},
effectiveUserId,
allIds,
shopUserId,
);
showToast('Job claimed successfully.', 'success');
navigateTech(`/technician/jobs/${assignment.id}`);
} catch (err) {
console.error('Failed to claim job', err);
showToast('Failed to claim job. Please try again.', 'error');
setClaimingId(null);
}
};
const tabs: { id: StatusTab; label: string }[] = [
{ id: 'active', label: 'Active' },
{ id: 'available', label: 'Available' },
{ id: 'all', label: 'All' },
{ id: 'completed', label: 'Completed' },
];
return (
<div className="mx-auto max-w-4xl space-y-4">
{/* Search */}
<div className="relative">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-gray-400" />
<input
type="text"
placeholder="Search by RO#, vehicle, or VIN…"
value={search}
onChange={(e) => setSearch(e.target.value)}
className="w-full rounded-xl border border-gray-200 bg-white py-3 pl-10 pr-10 text-sm text-gray-900 placeholder-gray-400 focus:border-green-400 focus:outline-none focus:ring-2 focus:ring-green-100 dark:border-gray-800 dark:bg-gray-900 dark:text-white dark:placeholder-gray-500 dark:focus:border-green-600 dark:focus:ring-green-900/30"
/>
{search && (
<button
onClick={() => setSearch('')}
className="absolute right-3 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
>
<X className="h-4 w-4" />
</button>
)}
</div>
{/* Tabs */}
<div className="flex gap-1 rounded-xl bg-gray-100 p-1 dark:bg-gray-800">
{tabs.map((tab) => (
<button
key={tab.id}
onClick={() => setStatusTab(tab.id)}
className={`flex-1 rounded-lg px-4 py-2 text-sm font-medium transition-colors ${
statusTab === tab.id
? 'bg-white text-gray-900 shadow-sm dark:bg-gray-700 dark:text-white'
: 'text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200'
}`}
>
{tab.label}
</button>
))}
</div>
{/* Error banner */}
{claimError && (
<div className="flex items-center gap-2 rounded-xl border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700 dark:border-red-800/50 dark:bg-red-950/50 dark:text-red-400">
<AlertTriangle className="h-4 w-4 shrink-0" />
<span>{claimError}</span>
<button onClick={() => setClaimError(null)} className="ml-auto shrink-0 text-red-400 hover:text-red-600 dark:hover:text-red-300">
<X className="h-4 w-4" />
</button>
</div>
)}
{/* List */}
{statusTab === 'available' ? (
availableLoading ? (
<ListSkeleton rows={4} />
) : availableError ? (
<ListError title="Unable to load available jobs" message="Failed to load available repair orders." onRetry={() => window.location.reload()} />
) : unclaimedRDs.length === 0 ? (
<div className="rounded-2xl border border-gray-200 bg-white p-12 text-center shadow-sm dark:border-gray-800 dark:bg-gray-900">
<div className="mx-auto flex h-12 w-12 items-center justify-center rounded-full bg-gray-100 dark:bg-gray-800">
<Search className="h-6 w-6 text-gray-400 dark:text-gray-500" />
</div>
<p className="mt-4 text-sm text-gray-500 dark:text-gray-400">
No active repair orders available to claim.
</p>
</div>
) : (
<div className="space-y-3">
{unclaimedRDs.map((ro) => {
const statusBadge: Record<string, string> = {
active: 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400',
in_progress: 'bg-yellow-100 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400',
waiting_parts: 'bg-orange-100 text-orange-700 dark:bg-orange-900/30 dark:text-orange-400',
waiting_pickup: 'bg-teal-100 text-teal-700 dark:bg-teal-900/30 dark:text-teal-400',
};
const badge = statusBadge[ro.status] ?? 'bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-400';
return (
<div
key={ro.id}
className="rounded-2xl border border-gray-200 bg-white p-4 shadow-sm dark:border-gray-800 dark:bg-gray-900"
>
<div className="flex items-center justify-between gap-3">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="text-base font-bold text-gray-900 dark:text-white">
{ro.roNumber || '—'}
</span>
<span className={`rounded-full px-2 py-0.5 text-xs font-medium ${badge}`}>
{ro.status.replace(/_/g, ' ')}
</span>
</div>
<p className="mt-0.5 text-sm text-gray-600 dark:text-gray-400">
{ro.vehicleInfo || 'No vehicle info'}
</p>
<div className="mt-1 flex items-center gap-3 text-xs text-gray-400 dark:text-gray-500">
{ro.vin && <span>VIN {ro.vin}</span>}
{ro.mileage && <span>{ro.mileage} mi</span>}
<span>{ro.serviceCount} service{ro.serviceCount !== 1 ? 's' : ''}</span>
</div>
</div>
<button
onClick={() => handleClaim(ro)}
disabled={claimingId === ro.id}
className="shrink-0 rounded-xl bg-green-600 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-green-700 disabled:cursor-not-allowed disabled:opacity-60"
>
{claimingId === ro.id ? 'Claiming…' : 'Claim'}
</button>
</div>
</div>
);
})}
</div>
)
) : loading ? (
<ListSkeleton rows={4} />
) : assignmentsError ? (
<ListError title="Unable to load assigned jobs" message="Failed to load your assigned jobs." onRetry={() => window.location.reload()} />
) : filtered.length === 0 ? (
<div className="rounded-2xl border border-gray-200 bg-white p-12 text-center shadow-sm dark:border-gray-800 dark:bg-gray-900">
<div className="mx-auto flex h-12 w-12 items-center justify-center rounded-full bg-gray-100 dark:bg-gray-800">
<Search className="h-6 w-6 text-gray-400 dark:text-gray-500" />
</div>
<p className="mt-4 text-sm text-gray-500 dark:text-gray-400">
{search
? 'No results match your search.'
: 'No assigned jobs yet.'}
</p>
</div>
) : (
<div className="space-y-3">
{filtered.map((job) => {
const running = job.clockEntries.some((e) => !e.end);
const statusColors: Record<string, string> = {
pending: 'border-l-gray-300 dark:border-l-gray-700',
in_progress: 'border-l-yellow-400 dark:border-l-yellow-600',
waiting_parts: 'border-l-orange-400 dark:border-l-orange-600',
completed: 'border-l-green-400 dark:border-l-green-600',
};
return (
<button
key={job.id}
onClick={() => navigateTech(`/technician/jobs/${job.id}`)}
className={`w-full rounded-2xl border border-gray-200 bg-white p-4 text-left shadow-sm transition-colors hover:border-green-300 dark:border-gray-800 dark:bg-gray-900 dark:hover:border-green-700 ${
statusColors[job.status] ?? 'border-l-gray-300'
} border-l-4`}
>
<div className="flex items-center justify-between gap-3">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="text-base font-bold text-gray-900 dark:text-white">
{job.roNumber || '—'}
</span>
{job.status === 'waiting_parts' && (
<AlertTriangle className="h-4 w-4 text-orange-500" />
)}
</div>
<p className="mt-0.5 text-sm text-gray-600 dark:text-gray-400">
{job.vehicleInfo || 'No vehicle info'}
</p>
<div className="mt-1 flex items-center gap-3 text-xs text-gray-400 dark:text-gray-500">
{job.vin && <span>VIN {job.vin}</span>}
{job.mileage && <span>{job.mileage} mi</span>}
</div>
</div>
<div className="flex flex-col items-end gap-1.5">
{running && (
<span className="inline-flex items-center gap-1 rounded-full bg-green-100 px-2 py-0.5 text-xs font-medium text-green-700 dark:bg-green-900/30 dark:text-green-400">
<span className="h-1.5 w-1.5 animate-pulse rounded-full bg-green-500" />
Clocked
</span>
)}
<span className="text-xs text-gray-400 dark:text-gray-500">
{formatElapsed(clockElapsedMinutes(job.clockEntries))}
</span>
<ChevronRight className="h-4 w-4 text-gray-300 dark:text-gray-600" />
</div>
</div>
</button>
);
})}
</div>
)}
</div>
);
}