initial commit
This commit is contained in:
@@ -0,0 +1,323 @@
|
||||
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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user