initial commit
This commit is contained in:
@@ -0,0 +1,592 @@
|
||||
import { useEffect, useState, useCallback, useMemo } from 'react';
|
||||
import { pb } from '../lib/pocketbase';
|
||||
import { usePagedList } from '../hooks/usePagedList';
|
||||
import { ListError } from '../components/ui/ListError';
|
||||
import { Search, Plus, X, Sparkles } from 'lucide-react';
|
||||
import type { Appointment, CustomerType } from '../types';
|
||||
import { useToast } from '../components/ui/Toast';
|
||||
import { getSetupRequiredMessage, isMissingCollectionError, logError } from '../lib/userMessages';
|
||||
import type { CustomerRecord, CustomerWithVehicles } from './Customers';
|
||||
import {
|
||||
todayISO,
|
||||
tomorrowISO,
|
||||
weekEndISO,
|
||||
getDateString,
|
||||
normalizeAppointmentRecord,
|
||||
buildAppointmentPayload,
|
||||
} from '../lib/appointments';
|
||||
import {
|
||||
DateGroup,
|
||||
AppointmentModal,
|
||||
DeleteDialog,
|
||||
ScanScreenshotModal,
|
||||
CheckInModal,
|
||||
EmptyState,
|
||||
AppointmentsSkeleton,
|
||||
} from '../components/appointments';
|
||||
|
||||
type FilterPreset = 'today' | 'tomorrow' | 'this_week' | 'all';
|
||||
|
||||
interface AppointmentFormData {
|
||||
customerId: string;
|
||||
firstName: string;
|
||||
middleName: string;
|
||||
lastName: string;
|
||||
customerName: string;
|
||||
customerPhone: string;
|
||||
customerEmail: string;
|
||||
vehicleInfo: string;
|
||||
vin: string;
|
||||
date: string;
|
||||
time: string;
|
||||
arrivalWindowMinutes: number;
|
||||
tripMiles: number;
|
||||
durationMinutes: number;
|
||||
advisorName: string;
|
||||
serviceType: string;
|
||||
serviceLocation: string;
|
||||
notes: string;
|
||||
}
|
||||
|
||||
export default function AppointmentsPage() {
|
||||
const { showToast } = useToast();
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const [filterPreset, setFilterPreset] = useState<FilterPreset>('today');
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [editingAppointment, setEditingAppointment] = useState<Appointment | null>(null);
|
||||
|
||||
const [scanModalOpen, setScanModalOpen] = useState(false);
|
||||
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
const [deletingAppointment, setDeletingAppointment] = useState<Appointment | null>(null);
|
||||
|
||||
const [showCheckInModal, setShowCheckInModal] = useState(false);
|
||||
const [checkInAppointment, setCheckInAppointment] = useState<Appointment | null>(null);
|
||||
|
||||
const [customers, setCustomers] = useState<CustomerWithVehicles[]>([]);
|
||||
|
||||
const userId = pb.authStore.model?.id;
|
||||
|
||||
const fetchAppointmentsPage = useCallback(async (page: number, perPage: number) => {
|
||||
if (!userId) return { items: [], page: 1, perPage, totalItems: 0, totalPages: 1 };
|
||||
const records = await pb.collection('appointments').getList(page, perPage, {
|
||||
filter: `userId = '${userId}'`,
|
||||
sort: '-id',
|
||||
});
|
||||
return {
|
||||
items: records.items.map((record) => normalizeAppointmentRecord(record)),
|
||||
page: records.page,
|
||||
perPage: records.perPage,
|
||||
totalItems: records.totalItems,
|
||||
totalPages: records.totalPages,
|
||||
};
|
||||
}, [userId]);
|
||||
|
||||
const {
|
||||
items: appointments,
|
||||
setItems: setAppointments,
|
||||
loading,
|
||||
hasMore,
|
||||
loadMore,
|
||||
refresh: refreshAppointments,
|
||||
adjustTotal: adjustAppointmentTotal,
|
||||
replaceItem: replaceAppointmentItem,
|
||||
removeItem: removeAppointmentItem,
|
||||
} = usePagedList<Appointment>(fetchAppointmentsPage, 50);
|
||||
|
||||
const fetchCustomers = useCallback(async () => {
|
||||
if (!userId) return;
|
||||
try {
|
||||
const result = await pb.collection('customers').getList(1, 500, {
|
||||
filter: `userId = '${userId}'`,
|
||||
sort: 'name',
|
||||
fields: 'id,name,phone,email,address,notes,userId,created,updated',
|
||||
});
|
||||
const customerList = result.items as unknown as CustomerRecord[];
|
||||
const enriched: CustomerWithVehicles[] = customerList.map((c) => ({ ...c, vehicles: [], vehicleCount: 0 }));
|
||||
for (let i = 0; i < enriched.length; i++) {
|
||||
try {
|
||||
const vehicles = await pb.collection('vehicles').getFullList({
|
||||
filter: `customerId = '${enriched[i].id}'`,
|
||||
batch: 50,
|
||||
});
|
||||
enriched[i] = { ...enriched[i], vehicles: vehicles as any[], vehicleCount: vehicles.length };
|
||||
} catch {}
|
||||
}
|
||||
setCustomers(enriched);
|
||||
} catch { /* non-critical */ }
|
||||
}, [userId]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchCustomers();
|
||||
}, [fetchCustomers]);
|
||||
|
||||
useEffect(() => {
|
||||
let unsubA: (() => void) | null = null;
|
||||
let unsubRO: (() => void) | null = null;
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
try {
|
||||
unsubA = await pb.collection('appointments').subscribe('*', (data: any) => {
|
||||
if (cancelled) return;
|
||||
const action = data.action;
|
||||
const record = data.record;
|
||||
if (!record) return;
|
||||
if (record.userId && record.userId !== userId) return;
|
||||
if (action === 'delete') {
|
||||
removeAppointmentItem(record.id, (a) => a.id);
|
||||
adjustAppointmentTotal(-1);
|
||||
return;
|
||||
}
|
||||
const norm = normalizeAppointmentRecord(record);
|
||||
replaceAppointmentItem(record.id, norm, (a) => a.id);
|
||||
setAppointments((prev) => {
|
||||
if (prev.some((a) => a.id === record.id)) return prev;
|
||||
adjustAppointmentTotal(1);
|
||||
return [norm, ...prev];
|
||||
});
|
||||
});
|
||||
} catch (e) {
|
||||
console.warn('Appointments realtime subscribe failed', e);
|
||||
}
|
||||
})();
|
||||
(async () => {
|
||||
try {
|
||||
unsubRO = await pb.collection('repairOrders').subscribe('*', () => {
|
||||
if (!cancelled) refreshAppointments();
|
||||
});
|
||||
} catch {
|
||||
// non-critical
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
cancelled = true;
|
||||
if (unsubA) unsubA();
|
||||
if (unsubRO) unsubRO();
|
||||
};
|
||||
}, [userId, refreshAppointments]);
|
||||
|
||||
const filteredAppointments = useMemo(() => {
|
||||
let filtered = [...appointments];
|
||||
|
||||
if (filterPreset === 'today') {
|
||||
const today = todayISO();
|
||||
filtered = filtered.filter((a) => getDateString(a.date) === today);
|
||||
} else if (filterPreset === 'tomorrow') {
|
||||
const tomorrow = tomorrowISO();
|
||||
filtered = filtered.filter((a) => getDateString(a.date) === tomorrow);
|
||||
} else if (filterPreset === 'this_week') {
|
||||
const today = todayISO();
|
||||
const weekEnd = weekEndISO();
|
||||
filtered = filtered.filter((a) => {
|
||||
const d = getDateString(a.date);
|
||||
return d >= today && d <= weekEnd;
|
||||
});
|
||||
}
|
||||
|
||||
if (searchQuery.trim()) {
|
||||
const q = searchQuery.trim().toLowerCase();
|
||||
filtered = filtered.filter((a) =>
|
||||
a.customerName?.toLowerCase().includes(q) ||
|
||||
a.vehicleInfo?.toLowerCase().includes(q) ||
|
||||
a.serviceType?.toLowerCase().includes(q) ||
|
||||
a.serviceLocation?.toLowerCase().includes(q)
|
||||
);
|
||||
}
|
||||
|
||||
return filtered;
|
||||
}, [appointments, filterPreset, searchQuery]);
|
||||
|
||||
const groupedByDate = useMemo(() => {
|
||||
const map = new Map<string, Appointment[]>();
|
||||
|
||||
for (const appt of filteredAppointments) {
|
||||
const dateKey = getDateString(appt.date);
|
||||
if (!map.has(dateKey)) {
|
||||
map.set(dateKey, []);
|
||||
}
|
||||
map.get(dateKey)!.push(appt);
|
||||
}
|
||||
|
||||
const sorted = Array.from(map.entries()).sort(([a], [b]) => a.localeCompare(b));
|
||||
|
||||
for (const [, appts] of sorted) {
|
||||
appts.sort((a, b) => (a.time || '').localeCompare(b.time || ''));
|
||||
}
|
||||
|
||||
return sorted;
|
||||
}, [filteredAppointments]);
|
||||
|
||||
const todayStr = todayISO();
|
||||
const hasTodayGroup = groupedByDate.some(([date]) => date === todayStr);
|
||||
|
||||
const handleCreate = useCallback(async (data: AppointmentFormData) => {
|
||||
try {
|
||||
await pb.collection('appointments').create(buildAppointmentPayload(data as unknown as Record<string, unknown>, { userId, status: 'scheduled' }));
|
||||
await refreshAppointments();
|
||||
showToast('Appointment created successfully', 'success');
|
||||
} catch (err: unknown) {
|
||||
logError('Failed to create appointment', err);
|
||||
if (isMissingCollectionError(err)) {
|
||||
setError(getSetupRequiredMessage());
|
||||
} else {
|
||||
setError('Appointment could not be created. Please try again.');
|
||||
}
|
||||
}
|
||||
}, [userId, refreshAppointments, showToast]);
|
||||
|
||||
const handleUpdate = useCallback(async (data: AppointmentFormData) => {
|
||||
if (!editingAppointment) return;
|
||||
try {
|
||||
await pb.collection('appointments').update(
|
||||
editingAppointment.id,
|
||||
buildAppointmentPayload(data as unknown as Record<string, unknown>, { status: editingAppointment.status })
|
||||
);
|
||||
await refreshAppointments();
|
||||
showToast('Appointment updated successfully', 'success');
|
||||
} catch (err: unknown) {
|
||||
logError('Failed to update appointment', err);
|
||||
if (isMissingCollectionError(err)) {
|
||||
setError(getSetupRequiredMessage());
|
||||
} else {
|
||||
setError('Appointment could not be updated. Please try again.');
|
||||
}
|
||||
}
|
||||
}, [editingAppointment, refreshAppointments, showToast]);
|
||||
|
||||
const handleDelete = useCallback(async () => {
|
||||
if (!deletingAppointment) return;
|
||||
try {
|
||||
await pb.collection('appointments').delete(deletingAppointment.id);
|
||||
setDeletingAppointment(null);
|
||||
await refreshAppointments();
|
||||
} catch (err: unknown) {
|
||||
logError('Failed to delete appointment', err);
|
||||
if (isMissingCollectionError(err)) {
|
||||
setError(getSetupRequiredMessage());
|
||||
} else {
|
||||
setError('Appointment could not be deleted. Please try again.');
|
||||
}
|
||||
}
|
||||
}, [deletingAppointment, refreshAppointments]);
|
||||
|
||||
const handleStatusChange = useCallback(async (appt: Appointment, newStatus: Appointment['status']) => {
|
||||
try {
|
||||
await pb.collection('appointments').update(appt.id, { status: newStatus });
|
||||
await refreshAppointments();
|
||||
} catch (err: unknown) {
|
||||
logError('Failed to update appointment status', err);
|
||||
if (isMissingCollectionError(err)) {
|
||||
setError(getSetupRequiredMessage());
|
||||
} else {
|
||||
setError('Appointment status could not be updated. Please try again.');
|
||||
}
|
||||
}
|
||||
}, [refreshAppointments]);
|
||||
|
||||
const openEditModal = useCallback((appt: Appointment) => {
|
||||
setEditingAppointment(appt);
|
||||
setModalOpen(true);
|
||||
}, []);
|
||||
|
||||
const openDeleteDialog = useCallback((appt: Appointment) => {
|
||||
setDeletingAppointment(appt);
|
||||
setDeleteDialogOpen(true);
|
||||
}, []);
|
||||
|
||||
const handleCheckIn = useCallback((appt: Appointment) => {
|
||||
setCheckInAppointment(appt);
|
||||
setShowCheckInModal(true);
|
||||
}, []);
|
||||
|
||||
const handleCheckInSubmit = useCallback(async (data: {
|
||||
customerName: string;
|
||||
customerPhone: string;
|
||||
customerEmail: string;
|
||||
vehicleInfo: string;
|
||||
vin: string;
|
||||
mileage: string;
|
||||
advisorName: string;
|
||||
roNumber: string;
|
||||
notes: string;
|
||||
estimatedDuration: number;
|
||||
customerType: CustomerType;
|
||||
serviceLocation: string;
|
||||
travelFee: number;
|
||||
tripMiles: number;
|
||||
}) => {
|
||||
if (!checkInAppointment || !userId) return;
|
||||
|
||||
try {
|
||||
const now = new Date().toISOString();
|
||||
|
||||
const customerId = checkInAppointment.customerId || '';
|
||||
const createdRepairOrder = await pb.collection('repairOrders').create({
|
||||
customerName: data.customerName,
|
||||
customerPhone: data.customerPhone,
|
||||
customerEmail: data.customerEmail,
|
||||
vehicleInfo: data.vehicleInfo,
|
||||
vin: data.vin,
|
||||
mileage: data.mileage,
|
||||
advisorName: data.advisorName,
|
||||
roNumber: data.roNumber,
|
||||
notes: data.notes,
|
||||
status: 'active',
|
||||
customerId,
|
||||
services: '[]',
|
||||
total: 0,
|
||||
tax: 0,
|
||||
shopCharges: 0,
|
||||
warranty: false,
|
||||
writeupTime: now,
|
||||
estimatedTime: (data.estimatedDuration / 60).toFixed(1),
|
||||
serviceLocation: data.serviceLocation,
|
||||
travelFee: data.travelFee,
|
||||
tripMiles: data.tripMiles,
|
||||
financial: JSON.stringify({ customerType: data.customerType }),
|
||||
userId,
|
||||
});
|
||||
|
||||
if (customerId && data.vehicleInfo.trim()) {
|
||||
try {
|
||||
const parts = data.vehicleInfo.trim().match(/^(\d{4})\s+(.+)$/);
|
||||
let year = '', make = '', model = '';
|
||||
if (parts) {
|
||||
year = parts[1];
|
||||
const rest = parts[2].trim();
|
||||
const spaceIdx = rest.indexOf(' ');
|
||||
if (spaceIdx > 0) { make = rest.slice(0, spaceIdx); model = rest.slice(spaceIdx + 1); }
|
||||
else { make = rest; }
|
||||
} else {
|
||||
make = data.vehicleInfo || '';
|
||||
}
|
||||
await pb.collection('vehicles').create({
|
||||
userId,
|
||||
customerId,
|
||||
make, model, year,
|
||||
vin: data.vin || '',
|
||||
mileage: data.mileage || '',
|
||||
licensePlate: '', color: '', engine: '',
|
||||
});
|
||||
fetchCustomers();
|
||||
} catch {}
|
||||
}
|
||||
|
||||
await pb.collection('appointments').update(checkInAppointment.id, {
|
||||
status: 'checked_in',
|
||||
repairOrderId: createdRepairOrder.id,
|
||||
});
|
||||
|
||||
setShowCheckInModal(false);
|
||||
setCheckInAppointment(null);
|
||||
await refreshAppointments();
|
||||
} catch (err: unknown) {
|
||||
logError('Failed to check in appointment', err);
|
||||
if (isMissingCollectionError(err)) {
|
||||
setError(getSetupRequiredMessage());
|
||||
} else {
|
||||
setError('Could not create the repair order. Please try again.');
|
||||
}
|
||||
}
|
||||
}, [checkInAppointment, userId, refreshAppointments, fetchCustomers]);
|
||||
|
||||
const handleScanImport = useCallback(async (appts: any[]) => {
|
||||
for (const appt of appts) {
|
||||
await pb.collection('appointments').create(buildAppointmentPayload(appt, { userId, status: 'scheduled' }));
|
||||
}
|
||||
await refreshAppointments();
|
||||
}, [userId, refreshAppointments]);
|
||||
|
||||
const openCreateModal = useCallback(() => {
|
||||
setEditingAppointment(null);
|
||||
setModalOpen(true);
|
||||
}, []);
|
||||
|
||||
const openScanModal = useCallback(() => {
|
||||
setScanModalOpen(true);
|
||||
}, []);
|
||||
|
||||
const closeAppointmentModal = useCallback(() => {
|
||||
setModalOpen(false);
|
||||
setEditingAppointment(null);
|
||||
}, []);
|
||||
|
||||
const closeDeleteDialog = useCallback(() => {
|
||||
setDeleteDialogOpen(false);
|
||||
setDeletingAppointment(null);
|
||||
}, []);
|
||||
|
||||
const closeScanModal = useCallback(() => {
|
||||
setScanModalOpen(false);
|
||||
}, []);
|
||||
|
||||
const closeCheckInModal = useCallback(() => {
|
||||
setShowCheckInModal(false);
|
||||
setCheckInAppointment(null);
|
||||
}, []);
|
||||
|
||||
const filters: { label: string; value: FilterPreset }[] = [
|
||||
{ label: 'Today', value: 'today' },
|
||||
{ label: 'Tomorrow', value: 'tomorrow' },
|
||||
{ label: 'This Week', value: 'this_week' },
|
||||
{ label: 'All', value: 'all' },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-6xl px-4 py-8 sm:px-6 lg:px-8">
|
||||
<div className="mb-6 overflow-hidden rounded-3xl border border-gray-200 bg-white shadow-sm shadow-gray-200/60 dark:border-gray-700 dark:bg-gray-900 dark:shadow-black/10">
|
||||
<div className="flex flex-col gap-6 bg-gradient-to-r from-emerald-50 via-white to-purple-50 px-6 py-6 dark:from-emerald-950/20 dark:via-gray-900 dark:to-purple-950/20 lg:flex-row lg:items-end lg:justify-between">
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-emerald-700 dark:text-emerald-400">
|
||||
Shop Schedule
|
||||
</p>
|
||||
<h1 className="mt-2 text-3xl font-semibold tracking-tight text-gray-900 dark:text-white">
|
||||
Appointments
|
||||
</h1>
|
||||
<p className="mt-2 max-w-2xl text-sm text-gray-500 dark:text-gray-400">
|
||||
Keep the day moving with clearer service slots, quick check-ins, and screenshot imports that land directly in your calendar.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-center">
|
||||
<button
|
||||
onClick={openScanModal}
|
||||
className="inline-flex items-center justify-center gap-2 rounded-xl bg-gradient-to-r from-purple-600 to-purple-500 px-4 py-2.5 text-sm font-medium text-white shadow-sm transition-all hover:from-purple-700 hover:to-purple-600 focus:outline-none focus:ring-2 focus:ring-purple-500 focus:ring-offset-2 dark:focus:ring-offset-gray-900"
|
||||
>
|
||||
<Sparkles className="h-4 w-4" />
|
||||
Scan Screenshot
|
||||
</button>
|
||||
<button
|
||||
onClick={openCreateModal}
|
||||
className="inline-flex items-center justify-center gap-2 rounded-xl bg-gradient-to-r from-emerald-600 to-emerald-500 px-5 py-2.5 text-sm font-medium text-white shadow-sm transition-all hover:from-emerald-700 hover:to-emerald-600 focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:ring-offset-2 dark:focus:ring-offset-gray-900"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
New Appointment
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="border-t border-gray-200 px-6 py-4 dark:border-gray-700">
|
||||
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{filters.map((f) => (
|
||||
<button
|
||||
key={f.value}
|
||||
onClick={() => setFilterPreset(f.value)}
|
||||
className={`rounded-full px-4 py-2 text-sm font-medium transition-all ${
|
||||
filterPreset === f.value
|
||||
? 'bg-emerald-600 text-white shadow-sm shadow-emerald-600/20'
|
||||
: 'border border-gray-300 bg-white text-gray-700 hover:bg-gray-50 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700'
|
||||
}`}
|
||||
>
|
||||
{f.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
|
||||
{!loading && !error && (
|
||||
<p className="text-xs font-medium uppercase tracking-[0.14em] text-gray-400 dark:text-gray-500">
|
||||
{filteredAppointments.length} appointment{filteredAppointments.length !== 1 ? 's' : ''}
|
||||
{filterPreset !== 'all' && ` | ${filterPreset.replace('_', ' ')}`}
|
||||
{searchQuery.trim() && ` | ${searchQuery.trim()}`}
|
||||
</p>
|
||||
)}
|
||||
<div className="relative w-full sm:w-72">
|
||||
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-gray-400" />
|
||||
<input
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
placeholder="Search customer, vehicle, service, or location..."
|
||||
className="w-full rounded-xl border border-gray-300 bg-white py-2.5 pl-10 pr-3 text-sm text-gray-900 placeholder-gray-400 shadow-sm transition-colors focus:border-emerald-500 focus:outline-none focus:ring-2 focus:ring-emerald-500/20 dark:border-gray-600 dark:bg-gray-800 dark:text-white dark:placeholder-gray-500 dark:focus:border-emerald-400"
|
||||
/>
|
||||
{searchQuery && (
|
||||
<button
|
||||
onClick={() => setSearchQuery('')}
|
||||
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>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loading && <AppointmentsSkeleton />}
|
||||
|
||||
{!loading && error && <ListError title="Unable to load appointments" message={error} onRetry={refreshAppointments} />}
|
||||
|
||||
{!loading && !error && (
|
||||
<>
|
||||
{filteredAppointments.length === 0 ? (
|
||||
<EmptyState onCreate={openCreateModal} />
|
||||
) : (
|
||||
<div className="space-y-4">
|
||||
{groupedByDate.map(([date, appts]) => (
|
||||
<div key={date}>
|
||||
{appts.length > 0 ? (
|
||||
<DateGroup
|
||||
date={date}
|
||||
appointments={appts}
|
||||
onEdit={openEditModal}
|
||||
onDelete={openDeleteDialog}
|
||||
onStatusChange={handleStatusChange}
|
||||
onCheckIn={handleCheckIn}
|
||||
defaultExpanded={date === todayStr || !hasTodayGroup}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{hasMore && (
|
||||
<div className="mt-4 flex justify-center">
|
||||
<button
|
||||
onClick={loadMore}
|
||||
disabled={loading}
|
||||
className="rounded-lg border border-gray-300 bg-white px-6 py-2 text-sm font-medium text-gray-700 shadow-sm transition-colors hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-green-500 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-60 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700"
|
||||
>
|
||||
{loading ? 'Loading\u2026' : 'Load more'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
<AppointmentModal
|
||||
isOpen={modalOpen}
|
||||
onClose={closeAppointmentModal}
|
||||
onSave={editingAppointment ? handleUpdate : handleCreate}
|
||||
appointment={editingAppointment}
|
||||
customers={customers}
|
||||
/>
|
||||
|
||||
<DeleteDialog
|
||||
isOpen={deleteDialogOpen}
|
||||
onClose={closeDeleteDialog}
|
||||
onConfirm={handleDelete}
|
||||
appointment={deletingAppointment}
|
||||
/>
|
||||
|
||||
<ScanScreenshotModal
|
||||
isOpen={scanModalOpen}
|
||||
onClose={closeScanModal}
|
||||
onImport={handleScanImport}
|
||||
customers={customers}
|
||||
/>
|
||||
|
||||
<CheckInModal
|
||||
isOpen={showCheckInModal}
|
||||
onClose={closeCheckInModal}
|
||||
onSubmit={handleCheckInSubmit}
|
||||
appointment={checkInAppointment}
|
||||
customers={customers}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,694 @@
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { X, Plus, Search, UserPlus, Users, Car, PhoneOff, ListFilter } from 'lucide-react';
|
||||
import { pb } from '../lib/pocketbase';
|
||||
import { useToast } from '../components/ui/Toast';
|
||||
import { getSetupRequiredMessage, isMissingCollectionError, logError } from '../lib/userMessages';
|
||||
import { customerWriteSchema } from '../schemas/customer';
|
||||
import {
|
||||
formatVehicleLabel,
|
||||
vehicleKey,
|
||||
activeROFilterForCustomer,
|
||||
} from '../lib/customers';
|
||||
import {
|
||||
CustomersSkeleton,
|
||||
EmptyState,
|
||||
ErrorState,
|
||||
CustomerDirectoryTable,
|
||||
CustomerFormModal,
|
||||
VehicleFormModal,
|
||||
DeleteConfirmModal,
|
||||
CustomerDetailView,
|
||||
} from '../components/customers';
|
||||
|
||||
export type {
|
||||
CustomerRecord,
|
||||
CustomerWithVehicles,
|
||||
CustomerFormData,
|
||||
} from '../components/customers/types';
|
||||
|
||||
import type {
|
||||
CustomerWithVehicles,
|
||||
CustomerRecord,
|
||||
VehicleRecord,
|
||||
RepairOrderRecord,
|
||||
QuoteRecord,
|
||||
CustomerFormData,
|
||||
VehicleFormData,
|
||||
} from '../components/customers/types';
|
||||
|
||||
/* ── Main Customers Page ────────────────────────────────────────────────────── */
|
||||
|
||||
export default function Customers() {
|
||||
const { showToast } = useToast();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const initialSearch = (() => {
|
||||
const q = searchParams.get('search');
|
||||
return q && q.trim() ? q.trim() : '';
|
||||
})();
|
||||
|
||||
const [customers, setCustomers] = useState<CustomerWithVehicles[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const [searchQuery, setSearchQuery] = useState(initialSearch);
|
||||
const [debouncedSearch, setDebouncedSearch] = useState(initialSearch);
|
||||
|
||||
useEffect(() => {
|
||||
if (initialSearch) {
|
||||
const sp = new URLSearchParams(searchParams);
|
||||
sp.delete('search');
|
||||
setSearchParams(sp, { replace: true });
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const [selectedCustomer, setSelectedCustomer] = useState<CustomerWithVehicles | null>(null);
|
||||
const [selectedVehicles, setSelectedVehicles] = useState<VehicleRecord[]>([]);
|
||||
const [customerRepairOrders, setCustomerRepairOrders] = useState<RepairOrderRecord[]>([]);
|
||||
const [repairOrdersLoading, setRepairOrdersLoading] = useState(false);
|
||||
const [customerQuotes, setCustomerQuotes] = useState<QuoteRecord[]>([]);
|
||||
const [quotesLoading, setQuotesLoading] = useState(false);
|
||||
|
||||
const [showAddModal, setShowAddModal] = useState(false);
|
||||
const [editingCustomer, setEditingCustomer] = useState<CustomerWithVehicles | null>(null);
|
||||
|
||||
const [showVehicleModal, setShowVehicleModal] = useState(false);
|
||||
const [editingVehicle, setEditingVehicle] = useState<VehicleRecord | null>(null);
|
||||
|
||||
const [deletingCustomer, setDeletingCustomer] = useState<CustomerWithVehicles | null>(null);
|
||||
const [deletingVehicle, setDeletingVehicle] = useState<VehicleRecord | null>(null);
|
||||
|
||||
const userId = pb.authStore.model?.id;
|
||||
|
||||
/* ── Debounced search ───────────────────────────────────────────────────── */
|
||||
|
||||
const searchTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (searchTimerRef.current) clearTimeout(searchTimerRef.current);
|
||||
searchTimerRef.current = setTimeout(() => {
|
||||
setDebouncedSearch(searchQuery);
|
||||
}, 300);
|
||||
return () => {
|
||||
if (searchTimerRef.current) clearTimeout(searchTimerRef.current);
|
||||
};
|
||||
}, [searchQuery]);
|
||||
|
||||
/* ── Fetch customers ────────────────────────────────────────────────────── */
|
||||
|
||||
const fetchAll = useCallback(async () => {
|
||||
if (!userId) {
|
||||
setError('You must be logged in to view customers.');
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const customerResult = await pb.collection('customers').getList(1, 200, {
|
||||
filter: `userId = '${userId}'`,
|
||||
sort: 'name',
|
||||
fields: 'id,name,phone,email,address,notes,userId,created,updated',
|
||||
});
|
||||
|
||||
const customerList = customerResult.items as unknown as CustomerRecord[];
|
||||
const customerNames = customerList.map((c) => c.name).filter(Boolean);
|
||||
const persistedVehiclesByCustomer = new Map<string, VehicleRecord[]>();
|
||||
|
||||
try {
|
||||
const vehicleRecords = await pb.collection('vehicles').getFullList({
|
||||
filter: `userId = '${userId}'`,
|
||||
sort: 'year,make,model',
|
||||
fields: 'id,customerId,userId,make,model,year,vin,licensePlate,mileage,color,engine',
|
||||
batch: 500,
|
||||
});
|
||||
for (const vehicle of vehicleRecords as unknown as VehicleRecord[]) {
|
||||
const existing = persistedVehiclesByCustomer.get(vehicle.customerId) ?? [];
|
||||
existing.push(vehicle);
|
||||
persistedVehiclesByCustomer.set(vehicle.customerId, existing);
|
||||
}
|
||||
} catch {
|
||||
// Older installs may not have the vehicles collection until migrations run.
|
||||
}
|
||||
|
||||
// ── Fetch vehicles from repairOrders & quotes (legacy pattern) ──
|
||||
interface VehicleSource {
|
||||
vehicleInfo: string;
|
||||
vin: string;
|
||||
mileage: string;
|
||||
customerName: string;
|
||||
}
|
||||
|
||||
let vehicleSources: VehicleSource[] = [];
|
||||
if (customerNames.length > 0) {
|
||||
const nameFilter = customerNames.map((n) => `customerName ~ '${n.replace(/'/g, "\\'")}'`).join(' || ');
|
||||
|
||||
try {
|
||||
const roResult = await pb.collection('repairOrders').getList(1, 500, {
|
||||
filter: nameFilter,
|
||||
fields: 'vehicleInfo,vin,mileage,customerName',
|
||||
});
|
||||
vehicleSources = roResult.items as unknown as VehicleSource[];
|
||||
} catch { /* repairOrders collection may not exist */ }
|
||||
|
||||
try {
|
||||
const qResult = await pb.collection('quotes').getList(1, 500, {
|
||||
filter: nameFilter,
|
||||
fields: 'vehicleInfo,vin,mileage,customerName',
|
||||
});
|
||||
vehicleSources = [...vehicleSources, ...(qResult.items as unknown as VehicleSource[])];
|
||||
} catch { /* quotes collection may not exist */ }
|
||||
}
|
||||
|
||||
// Deduplicate by vehicleInfo per customer, preferring entries with a VIN
|
||||
function parseVehicleInfo(info: string): { year: string; make: string; model: string } {
|
||||
const match = info.trim().match(/^(\d{4})\s+(.+)$/);
|
||||
if (match) {
|
||||
const rest = match[2].trim();
|
||||
const spaceIdx = rest.indexOf(' ');
|
||||
if (spaceIdx > 0) {
|
||||
return { year: match[1], make: rest.slice(0, spaceIdx), model: rest.slice(spaceIdx + 1) };
|
||||
}
|
||||
return { year: match[1], make: rest, model: '' };
|
||||
}
|
||||
return { year: '', make: info, model: '' };
|
||||
}
|
||||
|
||||
const customerVehicleMap = new Map<string, Map<string, VehicleRecord>>();
|
||||
for (const vs of vehicleSources) {
|
||||
if (!vs.vehicleInfo || !vs.customerName) continue;
|
||||
if (!customerVehicleMap.has(vs.customerName)) {
|
||||
customerVehicleMap.set(vs.customerName, new Map());
|
||||
}
|
||||
const customerVehicles = customerVehicleMap.get(vs.customerName)!;
|
||||
const existing = customerVehicles.get(vs.vehicleInfo);
|
||||
if (!existing || (!existing.vin && vs.vin)) {
|
||||
const parsed = parseVehicleInfo(vs.vehicleInfo);
|
||||
customerVehicles.set(vs.vehicleInfo, {
|
||||
id: '',
|
||||
customerId: '',
|
||||
make: parsed.make,
|
||||
model: parsed.model,
|
||||
year: parsed.year,
|
||||
vin: vs.vin || '',
|
||||
licensePlate: '',
|
||||
mileage: vs.mileage || '',
|
||||
color: '',
|
||||
engine: '',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const enriched: CustomerWithVehicles[] = customerList.map((c) => {
|
||||
const persistedVehicles = persistedVehiclesByCustomer.get(c.id) ?? [];
|
||||
const legacyVehicles = customerVehicleMap.get(c.name) ? Array.from(customerVehicleMap.get(c.name)!.values()) : [];
|
||||
const mergedVehicles = new Map<string, VehicleRecord>();
|
||||
for (const vehicle of persistedVehicles) mergedVehicles.set(vehicleKey(vehicle) || vehicle.id, vehicle);
|
||||
for (const vehicle of legacyVehicles) {
|
||||
const key = vehicleKey(vehicle);
|
||||
if (key && !mergedVehicles.has(key)) mergedVehicles.set(key, vehicle);
|
||||
}
|
||||
const vehicles = Array.from(mergedVehicles.values());
|
||||
return { ...c, vehicles, vehicleCount: vehicles.length };
|
||||
});
|
||||
|
||||
setCustomers(enriched);
|
||||
} catch (err: unknown) {
|
||||
logError('Failed to load customers', err);
|
||||
setError(isMissingCollectionError(err) ? getSetupRequiredMessage() : 'Customers could not be loaded. Please refresh and try again.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [userId]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchAll();
|
||||
}, [fetchAll]);
|
||||
|
||||
/* ── Filter customers by search ─────────────────────────────────────────── */
|
||||
|
||||
const filteredCustomers = customers.filter((c) => {
|
||||
if (!debouncedSearch) return true;
|
||||
const q = debouncedSearch.toLowerCase();
|
||||
const nameMatch = c.name.toLowerCase().includes(q);
|
||||
const phoneClean = c.phone.replace(/\D/g, '');
|
||||
const searchClean = q.replace(/\D/g, '');
|
||||
const phoneMatch = searchClean.length > 0 && phoneClean.includes(searchClean);
|
||||
const emailMatch = c.email.toLowerCase().includes(q);
|
||||
const vehicleMatch = c.vehicles.some(
|
||||
(v) =>
|
||||
v.make.toLowerCase().includes(q) ||
|
||||
v.model.toLowerCase().includes(q) ||
|
||||
v.year.includes(q) ||
|
||||
v.vin.toLowerCase().includes(q) ||
|
||||
v.licensePlate.toLowerCase().includes(q)
|
||||
);
|
||||
return nameMatch || phoneMatch || emailMatch || vehicleMatch;
|
||||
});
|
||||
|
||||
const customersWithVehicles = customers.filter((c) => c.vehicleCount > 0).length;
|
||||
const customersMissingPhone = customers.filter((c) => !c.phone.trim()).length;
|
||||
|
||||
/* ── Load vehicles for a customer when viewing details ──────────────────── */
|
||||
|
||||
const handleViewCustomer = useCallback(
|
||||
async (customerId: string) => {
|
||||
const customer = customers.find((c) => c.id === customerId);
|
||||
if (!customer) return;
|
||||
|
||||
setSelectedCustomer(customer);
|
||||
setSelectedVehicles(customer.vehicles);
|
||||
setCustomerRepairOrders([]);
|
||||
setRepairOrdersLoading(true);
|
||||
setCustomerQuotes([]);
|
||||
setQuotesLoading(true);
|
||||
|
||||
const customerName = customer.name.replace(/'/g, "\\'");
|
||||
|
||||
try {
|
||||
const result = await pb.collection('repairOrders').getList(1, 100, {
|
||||
filter: `customerName = '${customerName}'`,
|
||||
sort: '-createdAt',
|
||||
fields: 'id,roNumber,customerId,customerName,vehicleInfo,vin,mileage,status,workStatus,createdAt,completedTime,writeupTime,technician,notes,services',
|
||||
});
|
||||
setCustomerRepairOrders(result.items as unknown as RepairOrderRecord[]);
|
||||
} catch (err) {
|
||||
logError('Failed to load customer repair orders', err);
|
||||
setCustomerRepairOrders([]);
|
||||
} finally {
|
||||
setRepairOrdersLoading(false);
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await pb.collection('quotes').getList(1, 100, {
|
||||
filter: `customerName = '${customerName}'`,
|
||||
sort: '-createdAt',
|
||||
fields: 'id,customerId,customerName,vehicleInfo,vin,mileage,status,createdAt,total,serviceAdvisor,services,notes',
|
||||
});
|
||||
setCustomerQuotes(result.items as unknown as QuoteRecord[]);
|
||||
} catch (err) {
|
||||
logError('Failed to load customer quotes', err);
|
||||
setCustomerQuotes([]);
|
||||
} finally {
|
||||
setQuotesLoading(false);
|
||||
}
|
||||
},
|
||||
[customers]
|
||||
);
|
||||
|
||||
/* ── CRUD: Customer ─────────────────────────────────────────────────────── */
|
||||
|
||||
const handleAddCustomer = useCallback(
|
||||
async (data: CustomerFormData) => {
|
||||
const parsed = customerWriteSchema.safeParse({ ...data, userId });
|
||||
if (!parsed.success) {
|
||||
showToast(parsed.error.issues[0].message, 'error');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await pb.collection('customers').create({
|
||||
...data,
|
||||
userId,
|
||||
});
|
||||
showToast('Customer added successfully', 'success');
|
||||
setShowAddModal(false);
|
||||
fetchAll();
|
||||
} catch (err) {
|
||||
logError('Failed to add customer', err);
|
||||
showToast(isMissingCollectionError(err) ? getSetupRequiredMessage() : 'Customer could not be saved. Please try again.', 'error');
|
||||
}
|
||||
},
|
||||
[userId, showToast, fetchAll]
|
||||
);
|
||||
|
||||
const handleEditCustomer = useCallback(
|
||||
async (data: CustomerFormData) => {
|
||||
if (!editingCustomer) return;
|
||||
const parsed = customerWriteSchema.safeParse(data);
|
||||
if (!parsed.success) {
|
||||
showToast(parsed.error.issues[0].message, 'error');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await pb.collection('customers').update(editingCustomer.id, data);
|
||||
if (userId) {
|
||||
const linkedActiveROs = await pb.collection('repairOrders').getFullList({
|
||||
filter: activeROFilterForCustomer(userId, editingCustomer.id, editingCustomer.name),
|
||||
fields: 'id',
|
||||
batch: 200,
|
||||
});
|
||||
await Promise.all(linkedActiveROs.map((ro) =>
|
||||
pb.collection('repairOrders').update(ro.id, {
|
||||
customerId: editingCustomer.id,
|
||||
customerName: data.name.trim(),
|
||||
customerPhone: data.phone.trim(),
|
||||
customerEmail: data.email.trim(),
|
||||
})
|
||||
));
|
||||
}
|
||||
showToast('Customer updated successfully', 'success');
|
||||
setEditingCustomer(null);
|
||||
fetchAll();
|
||||
if (selectedCustomer?.id === editingCustomer.id) {
|
||||
setSelectedCustomer((prev) =>
|
||||
prev ? { ...prev, ...data } : null
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
logError('Failed to update customer', err);
|
||||
showToast(isMissingCollectionError(err) ? getSetupRequiredMessage() : 'Customer could not be updated. Please try again.', 'error');
|
||||
}
|
||||
},
|
||||
[editingCustomer, userId, showToast, fetchAll, selectedCustomer]
|
||||
);
|
||||
|
||||
const handleDeleteCustomer = useCallback(async () => {
|
||||
if (!deletingCustomer) return;
|
||||
|
||||
const vehicleIds = deletingCustomer.vehicles.map((v) => v.id).filter(Boolean);
|
||||
for (const vid of vehicleIds) {
|
||||
try {
|
||||
await pb.collection('vehicles').delete(vid);
|
||||
} catch {
|
||||
// ignore individual vehicle deletion failures
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await pb.collection('customers').delete(deletingCustomer.id);
|
||||
} catch (err: unknown) {
|
||||
logError('Failed to delete customer', err);
|
||||
showToast(isMissingCollectionError(err) ? getSetupRequiredMessage() : 'Customer could not be deleted. Please try again.', 'error');
|
||||
return;
|
||||
}
|
||||
showToast('Customer deleted successfully', 'success');
|
||||
setDeletingCustomer(null);
|
||||
setSelectedCustomer(null);
|
||||
fetchAll();
|
||||
}, [deletingCustomer, showToast, fetchAll]);
|
||||
|
||||
/* ── CRUD: Vehicles ─────────────────────────────────────────────────────── */
|
||||
|
||||
const handleAddVehicle = useCallback(
|
||||
async (data: VehicleFormData) => {
|
||||
if (!selectedCustomer || !userId) return;
|
||||
try {
|
||||
await pb.collection('vehicles').create({
|
||||
...data,
|
||||
customerId: selectedCustomer.id,
|
||||
userId,
|
||||
});
|
||||
showToast('Vehicle added successfully', 'success');
|
||||
} catch (err: unknown) {
|
||||
logError('Failed to add vehicle', err);
|
||||
showToast(isMissingCollectionError(err) ? getSetupRequiredMessage() : 'Vehicle could not be added. Please try again.', 'error');
|
||||
return;
|
||||
}
|
||||
setShowVehicleModal(false);
|
||||
fetchAll();
|
||||
setSelectedCustomer((prev) => {
|
||||
if (prev) handleViewCustomer(prev.id);
|
||||
return prev;
|
||||
});
|
||||
},
|
||||
[selectedCustomer, userId, showToast, fetchAll, handleViewCustomer]
|
||||
);
|
||||
|
||||
const handleEditVehicle = useCallback(
|
||||
async (data: VehicleFormData) => {
|
||||
if (!editingVehicle || !selectedCustomer || !userId) return;
|
||||
try {
|
||||
if (editingVehicle.id) {
|
||||
await pb.collection('vehicles').update(editingVehicle.id, data);
|
||||
} else {
|
||||
await pb.collection('vehicles').create({
|
||||
...data,
|
||||
customerId: selectedCustomer.id,
|
||||
userId,
|
||||
});
|
||||
}
|
||||
showToast('Vehicle updated successfully', 'success');
|
||||
} catch (err: unknown) {
|
||||
logError('Failed to update vehicle', err);
|
||||
showToast(isMissingCollectionError(err) ? getSetupRequiredMessage() : 'Vehicle could not be updated. Please try again.', 'error');
|
||||
return;
|
||||
}
|
||||
setEditingVehicle(null);
|
||||
setShowVehicleModal(false);
|
||||
fetchAll();
|
||||
if (selectedCustomer) handleViewCustomer(selectedCustomer.id);
|
||||
},
|
||||
[editingVehicle, selectedCustomer, userId, showToast, fetchAll, handleViewCustomer]
|
||||
);
|
||||
|
||||
const handleDeleteVehicle = useCallback(async () => {
|
||||
if (!deletingVehicle) return;
|
||||
if (!deletingVehicle.id) {
|
||||
showToast('Historical vehicles from repair orders or quotes cannot be removed here.', 'error');
|
||||
setDeletingVehicle(null);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await pb.collection('vehicles').delete(deletingVehicle.id);
|
||||
showToast('Vehicle removed successfully', 'success');
|
||||
} catch (err: unknown) {
|
||||
logError('Failed to remove vehicle', err);
|
||||
showToast(isMissingCollectionError(err) ? getSetupRequiredMessage() : 'Vehicle could not be removed. Please try again.', 'error');
|
||||
return;
|
||||
}
|
||||
setDeletingVehicle(null);
|
||||
fetchAll();
|
||||
if (selectedCustomer) handleViewCustomer(selectedCustomer.id);
|
||||
}, [deletingVehicle, showToast, fetchAll, selectedCustomer, handleViewCustomer]);
|
||||
|
||||
/* ── Render: List View ──────────────────────────────────────────────────── */
|
||||
|
||||
const renderListView = () => (
|
||||
<>
|
||||
<div className="mb-6 grid grid-cols-2 gap-3 lg:grid-cols-4">
|
||||
<div className="rounded-xl bg-white p-4 shadow-sm ring-1 ring-gray-200 dark:bg-gray-800 dark:ring-gray-700">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="rounded-lg bg-green-100 p-2 text-green-700 dark:bg-green-900/30 dark:text-green-300">
|
||||
<Users className="h-4 w-4" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs font-medium uppercase tracking-wide text-gray-500 dark:text-gray-400">Customers</p>
|
||||
<p className="text-xl font-bold text-gray-900 dark:text-white">{customers.length}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-xl bg-white p-4 shadow-sm ring-1 ring-gray-200 dark:bg-gray-800 dark:ring-gray-700">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="rounded-lg bg-blue-100 p-2 text-blue-700 dark:bg-blue-900/30 dark:text-blue-300">
|
||||
<Car className="h-4 w-4" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs font-medium uppercase tracking-wide text-gray-500 dark:text-gray-400">With Vehicles</p>
|
||||
<p className="text-xl font-bold text-gray-900 dark:text-white">{customersWithVehicles}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-xl bg-white p-4 shadow-sm ring-1 ring-gray-200 dark:bg-gray-800 dark:ring-gray-700">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="rounded-lg bg-amber-100 p-2 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300">
|
||||
<PhoneOff className="h-4 w-4" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs font-medium uppercase tracking-wide text-gray-500 dark:text-gray-400">Missing Phone</p>
|
||||
<p className="text-xl font-bold text-gray-900 dark:text-white">{customersMissingPhone}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-xl bg-white p-4 shadow-sm ring-1 ring-gray-200 dark:bg-gray-800 dark:ring-gray-700">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="rounded-lg bg-purple-100 p-2 text-purple-700 dark:bg-purple-900/30 dark:text-purple-300">
|
||||
<ListFilter className="h-4 w-4" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs font-medium uppercase tracking-wide text-gray-500 dark:text-gray-400">Showing</p>
|
||||
<p className="text-xl font-bold text-gray-900 dark:text-white">{filteredCustomers.length}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Search bar */}
|
||||
<div className="mb-4 rounded-xl bg-white p-4 shadow-sm ring-1 ring-gray-200 dark:bg-gray-800 dark:ring-gray-700">
|
||||
<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"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
placeholder="Search by name, phone, email, vehicle, or VIN..."
|
||||
className="w-full rounded-lg border border-gray-300 bg-white py-2.5 pl-10 pr-10 text-sm text-gray-900 placeholder-gray-400 focus:border-green-500 focus:outline-none focus:ring-2 focus:ring-green-500/20 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100 dark:placeholder-gray-500"
|
||||
/>
|
||||
{searchQuery && (
|
||||
<button
|
||||
onClick={() => {
|
||||
setSearchQuery('');
|
||||
setDebouncedSearch('');
|
||||
}}
|
||||
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>
|
||||
<div className="mt-3 flex items-center justify-between">
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||
{debouncedSearch
|
||||
? `${filteredCustomers.length} of ${customers.length} customers match`
|
||||
: `${customers.length} total customers`}
|
||||
</p>
|
||||
<button
|
||||
onClick={() => setShowAddModal(true)}
|
||||
className="inline-flex items-center gap-1.5 rounded-lg bg-green-600 px-3 py-1.5 text-xs font-medium text-white transition-colors hover:bg-green-700"
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
Add Customer
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Customer directory */}
|
||||
{filteredCustomers.length === 0 ? (
|
||||
<EmptyState
|
||||
searchTerm={debouncedSearch}
|
||||
onCreate={() => setShowAddModal(true)}
|
||||
/>
|
||||
) : (
|
||||
<CustomerDirectoryTable
|
||||
customers={filteredCustomers}
|
||||
onView={handleViewCustomer}
|
||||
onEdit={(c) => setEditingCustomer(c)}
|
||||
onDelete={(c) => setDeletingCustomer(c)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
/* ── Render: Detail View ────────────────────────────────────────────────── */
|
||||
|
||||
const renderDetailView = () => {
|
||||
if (!selectedCustomer) return null;
|
||||
|
||||
return (
|
||||
<CustomerDetailView
|
||||
customer={selectedCustomer}
|
||||
vehicles={selectedVehicles}
|
||||
repairOrders={customerRepairOrders}
|
||||
repairOrdersLoading={repairOrdersLoading}
|
||||
quotes={customerQuotes}
|
||||
quotesLoading={quotesLoading}
|
||||
onBack={() => {
|
||||
setSelectedCustomer(null);
|
||||
setSelectedVehicles([]);
|
||||
setCustomerRepairOrders([]);
|
||||
setCustomerQuotes([]);
|
||||
}}
|
||||
onEdit={(c) => setEditingCustomer(c)}
|
||||
onAddVehicle={() => {
|
||||
setEditingVehicle(null);
|
||||
setShowVehicleModal(true);
|
||||
}}
|
||||
onEditVehicle={(v) => {
|
||||
setEditingVehicle(v);
|
||||
setShowVehicleModal(true);
|
||||
}}
|
||||
onDeleteVehicle={(v) => setDeletingVehicle(v)}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
/* ── Main Render ────────────────────────────────────────────────────────── */
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-7xl px-4 py-8 sm:px-6 lg:px-8">
|
||||
{/* Header row */}
|
||||
<div className="mb-8 flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">
|
||||
{selectedCustomer ? 'Customer Details' : 'Customers'}
|
||||
</h1>
|
||||
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||
{selectedCustomer
|
||||
? `Viewing details for ${selectedCustomer.name}`
|
||||
: 'Manage your customer database'}
|
||||
</p>
|
||||
</div>
|
||||
{!selectedCustomer && (
|
||||
<button
|
||||
onClick={() => setShowAddModal(true)}
|
||||
className="inline-flex items-center gap-2 rounded-lg bg-green-600 px-5 py-2.5 text-sm font-medium text-white shadow-sm transition-colors hover:bg-green-700 focus:outline-none focus:ring-2 focus:ring-green-500 focus:ring-offset-2 dark:focus:ring-offset-gray-900"
|
||||
>
|
||||
<UserPlus className="h-4 w-4" />
|
||||
Add Customer
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Loading state */}
|
||||
{loading && <CustomersSkeleton />}
|
||||
|
||||
{/* Error state */}
|
||||
{!loading && error && <ErrorState message={error} onRetry={fetchAll} />}
|
||||
|
||||
{/* Content */}
|
||||
{!loading && !error && (
|
||||
<>
|
||||
{selectedCustomer ? renderDetailView() : renderListView()}
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* ── Modals ──────────────────────────────────────────────────────────── */}
|
||||
|
||||
{/* Add Customer Modal */}
|
||||
<CustomerFormModal
|
||||
open={showAddModal}
|
||||
customer={null}
|
||||
onClose={() => setShowAddModal(false)}
|
||||
onSave={handleAddCustomer}
|
||||
/>
|
||||
|
||||
{/* Edit Customer Modal */}
|
||||
<CustomerFormModal
|
||||
open={editingCustomer !== null}
|
||||
customer={editingCustomer}
|
||||
onClose={() => setEditingCustomer(null)}
|
||||
onSave={handleEditCustomer}
|
||||
/>
|
||||
|
||||
{/* Delete Customer Confirmation */}
|
||||
<DeleteConfirmModal
|
||||
open={deletingCustomer !== null}
|
||||
customerName={deletingCustomer?.name || ''}
|
||||
onClose={() => setDeletingCustomer(null)}
|
||||
onConfirm={handleDeleteCustomer}
|
||||
/>
|
||||
|
||||
{/* Add / Edit Vehicle Modal */}
|
||||
<VehicleFormModal
|
||||
open={showVehicleModal}
|
||||
vehicle={editingVehicle}
|
||||
customerName={selectedCustomer?.name || ''}
|
||||
onClose={() => {
|
||||
setShowVehicleModal(false);
|
||||
setEditingVehicle(null);
|
||||
}}
|
||||
onSave={editingVehicle ? handleEditVehicle : handleAddVehicle}
|
||||
/>
|
||||
|
||||
{/* Delete Vehicle Confirmation */}
|
||||
<DeleteConfirmModal
|
||||
open={deletingVehicle !== null}
|
||||
customerName={`vehicle ${deletingVehicle ? formatVehicleLabel(deletingVehicle) : ''}`}
|
||||
onClose={() => setDeletingVehicle(null)}
|
||||
onConfirm={handleDeleteVehicle}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,332 @@
|
||||
import { useEffect, useState, useCallback } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { pb } from '../lib/pocketbase';
|
||||
import { useSettings } from '../store/settings';
|
||||
import {
|
||||
FileText,
|
||||
CheckCircle2,
|
||||
DollarSign,
|
||||
Clock,
|
||||
Plus,
|
||||
BellRing,
|
||||
} from 'lucide-react';
|
||||
import { logError } from '../lib/userMessages';
|
||||
import { CardSkeleton } from '../components/ui/ListSkeleton';
|
||||
import { ListError } from '../components/ui/ListError';
|
||||
import { ListEmpty } from '../components/ui/ListEmpty';
|
||||
|
||||
/* ── Types ─────────────────────────────────────────── */
|
||||
|
||||
interface QuoteRecord {
|
||||
id: string;
|
||||
customerName: string;
|
||||
vehicleInfo: string;
|
||||
roNumber: string;
|
||||
repairOrderNumber?: string;
|
||||
total: number;
|
||||
status: 'draft' | 'sent' | 'approved' | 'declined';
|
||||
createdAt: string; // text field (may be empty for older records)
|
||||
lastUpdated?: string; // date field
|
||||
}
|
||||
|
||||
interface Stats {
|
||||
totalThisMonth: number;
|
||||
approved: number;
|
||||
revenue: number;
|
||||
pendingReview: number;
|
||||
}
|
||||
|
||||
interface ReminderRecord {
|
||||
id: string;
|
||||
customerId: string;
|
||||
customerName: string;
|
||||
vehicleInfo: string;
|
||||
mileageAtService: number;
|
||||
intervalMileage: number;
|
||||
dueMileage: number;
|
||||
notes: string;
|
||||
active: boolean;
|
||||
}
|
||||
|
||||
/* ── Stat card ──────────────────────────────────────── */
|
||||
|
||||
function StatCard({
|
||||
icon: Icon,
|
||||
label,
|
||||
value,
|
||||
bg,
|
||||
}: {
|
||||
icon: React.ComponentType<{ className?: string }>;
|
||||
label: string;
|
||||
value: string | number;
|
||||
bg: string;
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center gap-4 rounded-xl bg-white p-5 shadow-sm ring-1 ring-gray-200 dark:bg-gray-800 dark:ring-gray-700">
|
||||
<div className={`flex h-12 w-12 items-center justify-center rounded-lg ${bg}`}>
|
||||
<Icon className="h-6 w-6 text-white" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-500 dark:text-gray-400">{label}</p>
|
||||
<p className="text-2xl font-bold text-gray-900 dark:text-white">{value}</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Main Dashboard component ─────────────────────────── */
|
||||
|
||||
export default function Dashboard() {
|
||||
const navigate = useNavigate();
|
||||
const businessType = useSettings().businessType;
|
||||
|
||||
const [quotes, setQuotes] = useState<QuoteRecord[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const [reminders, setReminders] = useState<ReminderRecord[]>([]);
|
||||
|
||||
const userId = pb.authStore.model?.id;
|
||||
|
||||
const fetchQuotes = useCallback(async () => {
|
||||
if (!userId) {
|
||||
setError('You must be logged in to view the dashboard.');
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
// NOTE: The 'quotes' collection does NOT have a 'created' system autodate
|
||||
// field (it was created without one). We sort by '-id' instead — PocketBase
|
||||
// IDs are time-sortable (newer records have lexicographically later IDs).
|
||||
// For display we use 'createdAt' (text field) even though existing records
|
||||
// may have empty values.
|
||||
const records = await pb.collection('quotes').getList(1, 50, {
|
||||
filter: `userId = '${userId}'`,
|
||||
sort: '-id',
|
||||
fields: 'id,customerName,vehicleInfo,repairOrderNumber,total,status,createdAt,lastUpdated',
|
||||
});
|
||||
setQuotes((records.items as any[]).map((record) => ({
|
||||
...record,
|
||||
roNumber: record.roNumber || record.repairOrderNumber || '',
|
||||
})) as QuoteRecord[]);
|
||||
} catch (err: unknown) {
|
||||
logError('Failed to load dashboard quotes', err);
|
||||
setError('Dashboard data could not be loaded. Please refresh and try again.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [userId]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchQuotes();
|
||||
}, [fetchQuotes]);
|
||||
|
||||
// Fetch maintenance reminders
|
||||
const fetchReminders = useCallback(async () => {
|
||||
if (!userId) return;
|
||||
try {
|
||||
const records = await pb.collection('reminders').getFullList({
|
||||
filter: `shopUserId = '${userId}' && active = true`,
|
||||
sort: '-created',
|
||||
});
|
||||
setReminders(records.map((r: Record<string, unknown>) => ({
|
||||
id: r.id as string,
|
||||
customerId: (r.customerId as string) || '',
|
||||
customerName: (r.customerName as string) || '',
|
||||
vehicleInfo: (r.vehicleInfo as string) || '',
|
||||
mileageAtService: Number(r.mileageAtService) || 0,
|
||||
intervalMileage: Number(r.intervalMileage) || 0,
|
||||
dueMileage: Number(r.dueMileage) || 0,
|
||||
notes: (r.notes as string) || '',
|
||||
active: r.active !== false,
|
||||
})));
|
||||
} catch (err) {
|
||||
logError('Failed to load reminders', err);
|
||||
}
|
||||
}, [userId]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchReminders();
|
||||
}, [fetchReminders]);
|
||||
|
||||
// Real-time subscription — delta application (no full refetch)
|
||||
useEffect(() => {
|
||||
let unsub: (() => void) | null = null;
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
try {
|
||||
unsub = await pb.collection('quotes').subscribe('*', (data: any) => {
|
||||
if (cancelled) return;
|
||||
const action = data.action;
|
||||
const record = data.record;
|
||||
if (!record) return;
|
||||
if (record.userId && record.userId !== userId) return;
|
||||
if (action === 'delete') {
|
||||
setQuotes((prev) => prev.filter((q) => q.id !== record.id));
|
||||
return;
|
||||
}
|
||||
const norm: QuoteRecord = {
|
||||
...record,
|
||||
roNumber: record.roNumber || record.repairOrderNumber || '',
|
||||
};
|
||||
setQuotes((prev) => {
|
||||
const idx = prev.findIndex((q) => q.id === record.id);
|
||||
if (idx === -1) return [norm, ...prev];
|
||||
const next = [...prev];
|
||||
next[idx] = norm;
|
||||
return next;
|
||||
});
|
||||
});
|
||||
} catch (e) {
|
||||
console.warn('Dashboard quotes realtime subscribe failed', e);
|
||||
}
|
||||
})();
|
||||
return () => { cancelled = true; if (unsub) unsub(); };
|
||||
}, [userId]);
|
||||
|
||||
/* ── Compute stats ──────────────────────────────── */
|
||||
|
||||
const now = new Date();
|
||||
const startOfMonth = new Date(now.getFullYear(), now.getMonth(), 1).toISOString();
|
||||
|
||||
// Get a comparable date string from a quote — createdAt (text) or lastUpdated (date)
|
||||
const quoteDate = (q: QuoteRecord): string => {
|
||||
return q.createdAt || q.lastUpdated || '';
|
||||
};
|
||||
|
||||
const stats: Stats = {
|
||||
totalThisMonth: quotes.filter((q) => {
|
||||
const d = quoteDate(q);
|
||||
return d ? d >= startOfMonth : true; // if no date, count it
|
||||
}).length,
|
||||
approved: quotes.filter((q) => q.status === 'approved').length,
|
||||
revenue: quotes
|
||||
.filter((q) => q.status === 'approved')
|
||||
.reduce((sum, q) => sum + (q.total || 0), 0),
|
||||
pendingReview: quotes.filter((q) => q.status === 'sent' || q.status === 'draft').length,
|
||||
};
|
||||
|
||||
const formatCurrency = (amount: number) =>
|
||||
new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(amount);
|
||||
|
||||
/* ── Render ─────────────────────────────────────── */
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-7xl px-4 py-8 sm:px-6 lg:px-8">
|
||||
{/* Header row */}
|
||||
<div className="mb-8 flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">Dashboard</h1>
|
||||
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||
{businessType === 'mobile' || businessType === 'home'
|
||||
? 'Overview of your quotes, scheduled work, and business performance.'
|
||||
: 'Overview of your quotes and business performance.'}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => navigate('/quote')}
|
||||
className="inline-flex items-center gap-2 rounded-lg bg-green-600 px-5 py-2.5 text-sm font-medium text-white shadow-sm transition-colors hover:bg-green-700 focus:outline-none focus:ring-2 focus:ring-green-500 focus:ring-offset-2 dark:focus:ring-offset-gray-900"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
New Quote
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Maintenance Reminders */}
|
||||
{reminders.length > 0 && (
|
||||
<div className="mb-8 rounded-xl bg-white p-5 shadow-sm ring-1 ring-gray-200 dark:bg-gray-800 dark:ring-gray-700">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
<BellRing className="h-5 w-5 text-blue-500" />
|
||||
<h2 className="text-base font-semibold text-gray-900 dark:text-white">Maintenance Reminders</h2>
|
||||
<span className="rounded-full bg-blue-100 px-2 py-0.5 text-xs font-medium text-blue-700 dark:bg-blue-900/30 dark:text-blue-300">
|
||||
{reminders.length} active
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{reminders.map((rem) => (
|
||||
<div
|
||||
key={rem.id}
|
||||
className="flex flex-wrap items-center justify-between gap-3 rounded-lg border border-gray-100 bg-gray-50 px-4 py-3 dark:border-gray-700 dark:bg-gray-900/50"
|
||||
>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="text-sm font-medium text-gray-900 dark:text-gray-100">
|
||||
{rem.customerName || 'Unknown Customer'}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||
{rem.vehicleInfo || 'No vehicle info'}
|
||||
</p>
|
||||
{rem.notes && (
|
||||
<p className="mt-0.5 text-xs italic text-gray-400 dark:text-gray-500">
|
||||
{rem.notes}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-right">
|
||||
<p className="text-sm font-semibold text-gray-900 dark:text-gray-100">
|
||||
{rem.dueMileage.toLocaleString()} mi
|
||||
</p>
|
||||
<p className="text-[11px] font-medium text-gray-500 dark:text-gray-400">
|
||||
due at mileage
|
||||
</p>
|
||||
<span className={`mt-1 inline-block rounded-full px-2 py-0.5 text-[10px] font-medium ${
|
||||
rem.dueMileage > 0
|
||||
? 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-400'
|
||||
: 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-300'
|
||||
}`}>
|
||||
{rem.dueMileage > 0 ? 'Active' : 'Paused'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Loading state */}
|
||||
{loading && <CardSkeleton count={4} />}
|
||||
|
||||
{/* Error state */}
|
||||
{!loading && error && <ListError title="Unable to load dashboard" message={error} onRetry={fetchQuotes} />}
|
||||
|
||||
{/* Stats cards */}
|
||||
{!loading && !error && (
|
||||
quotes.length === 0 ? (
|
||||
<ListEmpty icon={FileText} title="No quotes yet" message="Create your first quote to see dashboard stats." actionLabel="New Quote" onAction={() => navigate('/quote')} />
|
||||
) : (
|
||||
<div className="mb-8 grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<StatCard
|
||||
icon={FileText}
|
||||
label="Quotes This Month"
|
||||
value={stats.totalThisMonth}
|
||||
bg="bg-blue-500"
|
||||
/>
|
||||
<StatCard
|
||||
icon={CheckCircle2}
|
||||
label="Approved Quotes"
|
||||
value={stats.approved}
|
||||
bg="bg-green-500"
|
||||
/>
|
||||
<StatCard
|
||||
icon={DollarSign}
|
||||
label="Revenue"
|
||||
value={formatCurrency(stats.revenue)}
|
||||
bg="bg-purple-500"
|
||||
/>
|
||||
<StatCard
|
||||
icon={Clock}
|
||||
label="Pending Review"
|
||||
value={stats.pendingReview}
|
||||
bg="bg-amber-500"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,318 @@
|
||||
import { useState, useEffect, type FormEvent } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Wrench, Mail, Lock, Eye, EyeOff, User } from 'lucide-react';
|
||||
import { pb, APP_NAME } from '../lib/pocketbase';
|
||||
import { logError } from '../lib/userMessages';
|
||||
import { isSetupComplete, loadSettingsForUser } from '../lib/settings';
|
||||
import { getCurrentUserRole, isTechnicianRole } from '../lib/rbx';
|
||||
|
||||
type Mode = 'signin' | 'signup';
|
||||
|
||||
export default function Login() {
|
||||
const navigate = useNavigate();
|
||||
const [mode, setMode] = useState<Mode>('signin');
|
||||
|
||||
// Apply dark mode from stored preference
|
||||
useEffect(() => {
|
||||
const isDark = localStorage.getItem('spq-dark-mode') === 'true';
|
||||
document.documentElement.classList.toggle('dark', isDark);
|
||||
}, []);
|
||||
|
||||
// Redirect already-authenticated users to their role-appropriate home
|
||||
useEffect(() => {
|
||||
if (pb.authStore.isValid) {
|
||||
navigateAfterAuth();
|
||||
}
|
||||
}, []); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
// Shared state
|
||||
const [email, setEmail] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [success, setSuccess] = useState<string | null>(null);
|
||||
|
||||
// Sign-up only
|
||||
const [name, setName] = useState('');
|
||||
const [confirmPassword, setConfirmPassword] = useState('');
|
||||
|
||||
function resetForm() {
|
||||
setEmail('');
|
||||
setPassword('');
|
||||
setConfirmPassword('');
|
||||
setName('');
|
||||
setError(null);
|
||||
setSuccess(null);
|
||||
}
|
||||
|
||||
function switchMode(newMode: Mode) {
|
||||
setMode(newMode);
|
||||
resetForm();
|
||||
}
|
||||
|
||||
async function navigateAfterAuth() {
|
||||
const role = getCurrentUserRole();
|
||||
if (isTechnicianRole(role)) {
|
||||
navigate('/technician', { replace: true });
|
||||
return;
|
||||
}
|
||||
const settings = await loadSettingsForUser(pb.authStore.model?.id);
|
||||
navigate(isSetupComplete(settings) ? '/' : '/setup', { replace: true });
|
||||
}
|
||||
|
||||
async function handleSignIn(e: FormEvent) {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
setSuccess(null);
|
||||
|
||||
if (!email.trim()) { setError('Please enter your email address.'); return; }
|
||||
if (!password) { setError('Please enter your password.'); return; }
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
await pb.collection('users').authWithPassword(email, password);
|
||||
await navigateAfterAuth();
|
||||
} catch (err: unknown) {
|
||||
logError('Failed to sign in', err);
|
||||
setError('Sign in failed. Please check your email and password and try again.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSignUp(e: FormEvent) {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
setSuccess(null);
|
||||
|
||||
if (!email.trim()) { setError('Please enter your email address.'); return; }
|
||||
if (!password) { setError('Please enter a password.'); return; }
|
||||
if (password.length < 8) { setError('Password must be at least 8 characters.'); return; }
|
||||
if (password !== confirmPassword) { setError('Passwords do not match.'); return; }
|
||||
|
||||
setLoading(true);
|
||||
try {
|
||||
await pb.collection('users').create({
|
||||
email: email.trim(),
|
||||
password,
|
||||
passwordConfirm: confirmPassword,
|
||||
name: name.trim() || '',
|
||||
displayName: name.trim() || '',
|
||||
emailVisibility: true,
|
||||
// role and shopUserId are now server-set by the M20 PB hook.
|
||||
// Do NOT send them from the client — doing so would be overwritten.
|
||||
});
|
||||
|
||||
// Auto-sign-in so the user has an active session immediately.
|
||||
// PocketBase may refuse this if "only verified users can sign in"
|
||||
// is enabled; we fall back to the manual sign-in message.
|
||||
try {
|
||||
await pb.collection('users').authWithPassword(email, password);
|
||||
} catch { /* sign-in may be blocked for unverified users */ }
|
||||
|
||||
// Explicitly send the verification email regardless of PB auto-send setting.
|
||||
try {
|
||||
await pb.collection('users').requestVerification(email.trim());
|
||||
} catch (err) { logError('requestVerification failed (non-critical)', err); }
|
||||
|
||||
// If we're now signed in, proceed directly to dashboard/setup.
|
||||
if (pb.authStore.isValid) {
|
||||
await navigateAfterAuth();
|
||||
return;
|
||||
}
|
||||
|
||||
setSuccess(
|
||||
'Account created. Check your email and click the verification link, then sign in.'
|
||||
);
|
||||
setPassword('');
|
||||
setConfirmPassword('');
|
||||
setName('');
|
||||
} catch (err: unknown) {
|
||||
logError('Failed to create account', err);
|
||||
setError('Account could not be created. Please try again.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
const isSignIn = mode === 'signin';
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-gradient-to-br from-gray-50 to-gray-100 px-4 dark:from-gray-950 dark:to-gray-900">
|
||||
<div className="w-full max-w-md">
|
||||
{/* Logo */}
|
||||
<div className="mb-8 text-center">
|
||||
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-2xl bg-green-600 shadow-lg shadow-green-600/20 dark:bg-green-700">
|
||||
<Wrench className="h-8 w-8 text-white" />
|
||||
</div>
|
||||
<h1 className="text-3xl font-bold tracking-tight text-gray-900 dark:text-white">
|
||||
ShopPro<span className="text-green-600 dark:text-green-400">Quote</span>
|
||||
</h1>
|
||||
<p className="mt-1.5 text-sm text-gray-500 dark:text-gray-400">
|
||||
{isSignIn ? 'Sign in to manage your shop' : 'Create your account'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Card */}
|
||||
<div className="rounded-xl border border-gray-200 bg-white p-8 shadow-xl shadow-gray-200/50 transition-all duration-300 dark:border-gray-800 dark:bg-gray-900 dark:shadow-black/20">
|
||||
{/* Tabs */}
|
||||
<div className="mb-6 flex rounded-lg bg-gray-100 p-1 dark:bg-gray-800">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => switchMode('signin')}
|
||||
className={`flex-1 rounded-md py-2 text-sm font-medium transition-colors ${
|
||||
isSignIn
|
||||
? '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'
|
||||
}`}
|
||||
>
|
||||
Sign In
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => switchMode('signup')}
|
||||
className={`flex-1 rounded-md py-2 text-sm font-medium transition-colors ${
|
||||
!isSignIn
|
||||
? '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'
|
||||
}`}
|
||||
>
|
||||
Sign Up
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={isSignIn ? handleSignIn : handleSignUp} className="space-y-4" noValidate>
|
||||
{/* Success message */}
|
||||
{success && (
|
||||
<div className="flex items-center gap-2.5 rounded-lg border border-green-200 bg-green-50 px-4 py-3 text-sm text-green-700 dark:border-green-800/50 dark:bg-green-950/50 dark:text-green-400">
|
||||
<span className="inline-block h-1.5 w-1.5 rounded-full bg-green-500" />
|
||||
{success}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error message */}
|
||||
{error && (
|
||||
<div className="flex items-center gap-2.5 rounded-lg 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">
|
||||
<span className="inline-block h-1.5 w-1.5 rounded-full bg-red-500" />
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Name (sign-up only) */}
|
||||
{!isSignIn && (
|
||||
<div>
|
||||
<label htmlFor="name" className="mb-1.5 block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Name <span className="text-gray-400 font-normal">(optional)</span>
|
||||
</label>
|
||||
<div className="relative">
|
||||
<User className="pointer-events-none absolute left-3 top-1/2 h-5 w-5 -translate-y-1/2 text-gray-400 dark:text-gray-500" />
|
||||
<input
|
||||
id="name"
|
||||
type="text"
|
||||
autoComplete="name"
|
||||
placeholder="Your name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
disabled={loading}
|
||||
className="w-full rounded-lg border border-gray-300 bg-white py-2.5 pl-10 pr-3 text-sm text-gray-900 placeholder-gray-400 transition-colors focus:border-green-500 focus:ring-2 focus:ring-green-500/20 focus:outline-none disabled:cursor-not-allowed disabled:opacity-60 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-100 dark:placeholder-gray-500 dark:focus:border-green-400 dark:focus:ring-green-400/20"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Email */}
|
||||
<div>
|
||||
<label htmlFor="email" className="mb-1.5 block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Email
|
||||
</label>
|
||||
<div className="relative">
|
||||
<Mail className="pointer-events-none absolute left-3 top-1/2 h-5 w-5 -translate-y-1/2 text-gray-400 dark:text-gray-500" />
|
||||
<input
|
||||
id="email"
|
||||
type="email"
|
||||
autoComplete="email"
|
||||
placeholder="you@example.com"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
disabled={loading}
|
||||
className="w-full rounded-lg border border-gray-300 bg-white py-2.5 pl-10 pr-3 text-sm text-gray-900 placeholder-gray-400 transition-colors focus:border-green-500 focus:ring-2 focus:ring-green-500/20 focus:outline-none disabled:cursor-not-allowed disabled:opacity-60 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-100 dark:placeholder-gray-500 dark:focus:border-green-400 dark:focus:ring-green-400/20"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Password */}
|
||||
<div>
|
||||
<label htmlFor="password" className="mb-1.5 block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Password
|
||||
</label>
|
||||
<div className="relative">
|
||||
<Lock className="pointer-events-none absolute left-3 top-1/2 h-5 w-5 -translate-y-1/2 text-gray-400 dark:text-gray-500" />
|
||||
<input
|
||||
id="password"
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
autoComplete={isSignIn ? 'current-password' : 'new-password'}
|
||||
placeholder={isSignIn ? 'Enter your password' : 'At least 8 characters'}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
disabled={loading}
|
||||
className="w-full rounded-lg border border-gray-300 bg-white py-2.5 pl-10 pr-10 text-sm text-gray-900 placeholder-gray-400 transition-colors focus:border-green-500 focus:ring-2 focus:ring-green-500/20 focus:outline-none disabled:cursor-not-allowed disabled:opacity-60 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-100 dark:placeholder-gray-500 dark:focus:border-green-400 dark:focus:ring-green-400/20"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword((prev) => !prev)}
|
||||
className="absolute right-2.5 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600 dark:text-gray-500 dark:hover:text-gray-300"
|
||||
tabIndex={-1}
|
||||
aria-label={showPassword ? 'Hide password' : 'Show password'}
|
||||
>
|
||||
{showPassword ? <EyeOff className="h-5 w-5" /> : <Eye className="h-5 w-5" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Confirm Password (sign-up only) */}
|
||||
{!isSignIn && (
|
||||
<div>
|
||||
<label htmlFor="confirmPassword" className="mb-1.5 block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Confirm Password
|
||||
</label>
|
||||
<div className="relative">
|
||||
<Lock className="pointer-events-none absolute left-3 top-1/2 h-5 w-5 -translate-y-1/2 text-gray-400 dark:text-gray-500" />
|
||||
<input
|
||||
id="confirmPassword"
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
autoComplete="new-password"
|
||||
placeholder="Repeat your password"
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
disabled={loading}
|
||||
className="w-full rounded-lg border border-gray-300 bg-white py-2.5 pl-10 pr-3 text-sm text-gray-900 placeholder-gray-400 transition-colors focus:border-green-500 focus:ring-2 focus:ring-green-500/20 focus:outline-none disabled:cursor-not-allowed disabled:opacity-60 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-100 dark:placeholder-gray-500 dark:focus:border-green-400 dark:focus:ring-green-400/20"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Submit */}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading}
|
||||
className="flex w-full items-center justify-center gap-2 rounded-lg bg-green-600 px-4 py-2.5 text-sm font-semibold text-white shadow-sm transition-all duration-200 hover:bg-green-700 focus:ring-2 focus:ring-green-500/40 focus:outline-none active:scale-[0.98] disabled:cursor-not-allowed disabled:opacity-60 dark:bg-green-700 dark:hover:bg-green-600"
|
||||
>
|
||||
{loading && (
|
||||
<svg className="h-4 w-4 animate-spin" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
|
||||
</svg>
|
||||
)}
|
||||
{loading ? (isSignIn ? 'Signing in…' : 'Creating account…') : isSignIn ? 'Sign In' : 'Create Account'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<p className="mt-6 text-center text-xs text-gray-400 dark:text-gray-600">
|
||||
© {new Date().getFullYear()} {APP_NAME}. All rights reserved.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,238 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Download, Trash2, Shield, AlertTriangle } from 'lucide-react';
|
||||
import { pb } from '../lib/pocketbase';
|
||||
import { useToast } from '../components/ui/Toast';
|
||||
|
||||
export default function Privacy() {
|
||||
const { showToast } = useToast();
|
||||
const navigate = useNavigate();
|
||||
const userId = pb.authStore.model?.id as string;
|
||||
|
||||
const [exporting, setExporting] = useState(false);
|
||||
const [deleting, setDeleting] = useState(false);
|
||||
const [deleteConfirm, setDeleteConfirm] = useState('');
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
||||
|
||||
const handleExport = useCallback(async () => {
|
||||
if (!userId) return;
|
||||
setExporting(true);
|
||||
try {
|
||||
const [repairOrders, customers, appointments, invoices, settings] =
|
||||
await Promise.all([
|
||||
pb.collection('repairOrders').getFullList({
|
||||
filter: `userId = '${userId}'`,
|
||||
sort: '-id',
|
||||
batch: 200,
|
||||
}),
|
||||
pb.collection('customers').getFullList({
|
||||
filter: `userId = '${userId}'`,
|
||||
sort: 'name',
|
||||
batch: 200,
|
||||
}),
|
||||
pb.collection('appointments').getFullList({
|
||||
filter: `userId = '${userId}'`,
|
||||
sort: '-id',
|
||||
batch: 200,
|
||||
}),
|
||||
pb.collection('invoices').getFullList({
|
||||
filter: `userId = '${userId}'`,
|
||||
sort: '-id',
|
||||
batch: 200,
|
||||
}),
|
||||
pb
|
||||
.collection('settings')
|
||||
.getFullList({ filter: `userId = '${userId}'` }),
|
||||
]);
|
||||
|
||||
const exportData = {
|
||||
exportedAt: new Date().toISOString(),
|
||||
userEmail: pb.authStore.model?.email,
|
||||
repairOrders,
|
||||
customers,
|
||||
appointments,
|
||||
invoices,
|
||||
settings: settings[0] || null,
|
||||
};
|
||||
|
||||
const json = JSON.stringify(exportData, null, 2);
|
||||
const blob = new Blob([json], { type: 'application/json' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
const date = new Date().toISOString().slice(0, 10);
|
||||
a.download = `spq-data-export-${date}.json`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
|
||||
showToast('Data exported successfully', 'success');
|
||||
} catch {
|
||||
showToast('Failed to export data. Please try again.', 'error');
|
||||
} finally {
|
||||
setExporting(false);
|
||||
}
|
||||
}, [userId, showToast]);
|
||||
|
||||
const handleDelete = useCallback(async () => {
|
||||
if (!userId || deleteConfirm !== 'DELETE') return;
|
||||
setDeleting(true);
|
||||
try {
|
||||
// Delete all user records from each collection
|
||||
const collections = [
|
||||
'repairOrders',
|
||||
'customers',
|
||||
'appointments',
|
||||
'invoices',
|
||||
'settings',
|
||||
'service_advisors',
|
||||
'technicianAssignments',
|
||||
'technicianInvites',
|
||||
];
|
||||
|
||||
for (const col of collections) {
|
||||
try {
|
||||
const records = await pb.collection(col).getFullList({
|
||||
filter: `userId = '${userId}'`,
|
||||
batch: 200,
|
||||
});
|
||||
for (const record of records) {
|
||||
await pb.collection(col).delete(record.id);
|
||||
}
|
||||
} catch {
|
||||
// Some collections may not exist or be accessible — continue
|
||||
}
|
||||
}
|
||||
|
||||
// Delete the user account
|
||||
await pb.collection('users').delete(userId);
|
||||
pb.authStore.clear();
|
||||
showToast('Account deleted successfully', 'success');
|
||||
navigate('/login', { replace: true });
|
||||
} catch {
|
||||
showToast('Failed to delete account. Please try again.', 'error');
|
||||
} finally {
|
||||
setDeleting(false);
|
||||
}
|
||||
}, [userId, deleteConfirm, showToast, navigate]);
|
||||
|
||||
return (
|
||||
<div className="h-full overflow-y-auto bg-gray-50 dark:bg-gray-950">
|
||||
<div className="mx-auto max-w-2xl px-4 py-10 sm:px-6">
|
||||
<div className="mb-8">
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-gray-100">
|
||||
Privacy & Data
|
||||
</h1>
|
||||
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||
Manage your data and account. These actions cannot be undone.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
{/* Export Section */}
|
||||
<div className="rounded-2xl border border-gray-200/90 bg-white p-6 shadow-sm dark:border-gray-700 dark:bg-gray-900">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-green-100 dark:bg-green-900/30">
|
||||
<Download className="h-5 w-5 text-green-600 dark:text-green-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100">
|
||||
Export My Data
|
||||
</h2>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
Download all your data as a JSON file, including repair
|
||||
orders, customers, appointments, invoices, and settings.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleExport}
|
||||
disabled={exporting}
|
||||
className="mt-4 inline-flex items-center gap-2 rounded-lg bg-green-600 px-4 py-2.5 text-sm font-medium text-white shadow-sm transition-colors hover:bg-green-700 focus:outline-none focus:ring-2 focus:ring-green-500/40 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
<Download className="h-4 w-4" />
|
||||
{exporting ? 'Exporting...' : 'Export My Data'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Delete Section (Danger Zone) */}
|
||||
<div className="rounded-2xl border border-red-200 bg-white p-6 shadow-sm dark:border-red-900/50 dark:bg-gray-900">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-red-100 dark:bg-red-900/30">
|
||||
<Shield className="h-5 w-5 text-red-600 dark:text-red-400" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-red-700 dark:text-red-400">
|
||||
Danger Zone
|
||||
</h2>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
Permanently delete your account and all associated data. This
|
||||
action cannot be undone.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!showDeleteConfirm ? (
|
||||
<button
|
||||
onClick={() => setShowDeleteConfirm(true)}
|
||||
className="mt-4 inline-flex items-center gap-2 rounded-lg border border-red-300 bg-white px-4 py-2.5 text-sm font-medium text-red-600 transition-colors hover:bg-red-50 focus:outline-none focus:ring-2 focus:ring-red-500/40 dark:border-red-800 dark:bg-transparent dark:text-red-400 dark:hover:bg-red-950"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
Delete My Account
|
||||
</button>
|
||||
) : (
|
||||
<div className="mt-4 space-y-4 rounded-lg border border-red-200 bg-red-50 p-4 dark:border-red-900/50 dark:bg-red-950/30">
|
||||
<div className="flex items-start gap-3">
|
||||
<AlertTriangle className="mt-0.5 h-5 w-5 shrink-0 text-red-500" />
|
||||
<div>
|
||||
<p className="text-sm font-medium text-red-800 dark:text-red-300">
|
||||
Are you absolutely sure?
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-red-600 dark:text-red-400">
|
||||
This will permanently delete your account and remove all
|
||||
your data including repair orders, customers,
|
||||
appointments, invoices, and settings. This action is
|
||||
irreversible.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
Type <code className="rounded bg-red-100 px-1.5 py-0.5 text-xs font-bold text-red-700 dark:bg-red-900/50 dark:text-red-300">DELETE</code> to confirm:
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={deleteConfirm}
|
||||
onChange={(e) => setDeleteConfirm(e.target.value)}
|
||||
placeholder="DELETE"
|
||||
className="w-full rounded-lg border border-red-300 bg-white px-3 py-2 text-sm text-gray-900 placeholder-gray-400 focus:border-red-500 focus:ring-2 focus:ring-red-500/20 focus:outline-none dark:border-red-800 dark:bg-gray-800 dark:text-gray-100 dark:placeholder-gray-500"
|
||||
/>
|
||||
|
||||
<div className="flex gap-3">
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowDeleteConfirm(false);
|
||||
setDeleteConfirm('');
|
||||
}}
|
||||
className="inline-flex items-center gap-2 rounded-lg border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 transition-colors hover:bg-gray-50 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleDelete}
|
||||
disabled={deleteConfirm !== 'DELETE' || deleting}
|
||||
className="inline-flex items-center gap-2 rounded-lg bg-red-600 px-4 py-2.5 text-sm font-medium text-white shadow-sm transition-colors hover:bg-red-700 focus:outline-none focus:ring-2 focus:ring-red-500/40 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
{deleting ? 'Deleting...' : 'Delete My Account'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,684 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useParams, Link } from 'react-router-dom';
|
||||
import { CheckCircle2, XCircle, AlertTriangle, Loader2, Clock, ShieldCheck, ShieldX, Signature } from 'lucide-react';
|
||||
import { pb } from '../lib/pocketbase';
|
||||
import { useSettingsStore } from '../store/settings';
|
||||
import { logError } from '../lib/userMessages';
|
||||
import { computeQuoteTotals, computeSplitQuoteTotals } from '../lib/totals';
|
||||
import { syncApprovedQuoteServicesToRO } from '../lib/quoteRoSync';
|
||||
import type { QuoteService, ShopSettings } from '../types';
|
||||
|
||||
type PageStatus = 'loading' | 'invalid' | 'expired' | 'done' | 'error' | 'phone_verify';
|
||||
|
||||
const MAX_PHONE_ATTEMPTS = 3;
|
||||
|
||||
interface QuoteDisplayData {
|
||||
id: string;
|
||||
customerName: string;
|
||||
customerPhone: string;
|
||||
vehicleInfo: string;
|
||||
advisorName: string;
|
||||
services: QuoteService[];
|
||||
discountValue: number;
|
||||
discountType: 'dollar' | 'percent';
|
||||
subtotal: number;
|
||||
tax: number;
|
||||
total: number;
|
||||
status: string;
|
||||
createdAt: string;
|
||||
shareToken: string;
|
||||
viewedAt?: string;
|
||||
phoneVerifiedAt?: string;
|
||||
repairOrderId?: string;
|
||||
}
|
||||
|
||||
export default function QuoteApprove() {
|
||||
const { token = '' } = useParams<{ token: string }>();
|
||||
const [pageStatus, setPageStatus] = useState<PageStatus>('loading');
|
||||
const [quote, setQuote] = useState<QuoteDisplayData | null>(null);
|
||||
const [settings, setSettings] = useState<ShopSettings | null>(null);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState('');
|
||||
const [submitted, setSubmitted] = useState(false);
|
||||
const [signatureName, setSignatureName] = useState('');
|
||||
const [decisions, setDecisions] = useState<Record<string, 'approved' | 'declined'>>({});
|
||||
const [viewedStamped, setViewedStamped] = useState(false);
|
||||
const [phoneInput, setPhoneInput] = useState('');
|
||||
const [phoneAttempts, setPhoneAttempts] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (!token) {
|
||||
setPageStatus('invalid');
|
||||
return;
|
||||
}
|
||||
loadQuote(token);
|
||||
}, [token]);
|
||||
|
||||
useEffect(() => {
|
||||
const meta = document.createElement('meta');
|
||||
meta.name = 'referrer';
|
||||
meta.content = 'no-referrer';
|
||||
document.head.appendChild(meta);
|
||||
return () => { document.head.removeChild(meta); };
|
||||
}, []);
|
||||
|
||||
const stampViewed = useCallback(async (quoteId: string, shareTok: string, currentViewedAt?: string) => {
|
||||
if (viewedStamped || currentViewedAt) return;
|
||||
try {
|
||||
await pb.collection('quotes').update(quoteId, {
|
||||
viewedAt: new Date().toISOString(),
|
||||
lastUpdated: new Date().toISOString(),
|
||||
}, { query: { shareToken: shareTok } });
|
||||
setViewedStamped(true);
|
||||
} catch {
|
||||
// Non-critical — don't block the page
|
||||
}
|
||||
}, [viewedStamped]);
|
||||
|
||||
async function loadQuote(tok: string) {
|
||||
try {
|
||||
const record = await pb.collection('quotes').getFirstListItem(
|
||||
`shareToken = "${tok.replace(/"/g, '')}"`,
|
||||
{ query: { shareToken: tok } }
|
||||
);
|
||||
|
||||
if (!record) {
|
||||
setPageStatus('invalid');
|
||||
return;
|
||||
}
|
||||
|
||||
const rawServices = record.services;
|
||||
let servicesList: QuoteService[] = [];
|
||||
if (typeof rawServices === 'string') {
|
||||
try { servicesList = JSON.parse(rawServices); } catch (err) { logError('Failed to parse quote services JSON', err); servicesList = []; }
|
||||
} else if (Array.isArray(rawServices)) {
|
||||
servicesList = rawServices;
|
||||
}
|
||||
|
||||
const recordAny = record as any;
|
||||
const quoteId = record.id;
|
||||
const shareTok = tok;
|
||||
const currentViewedAt = recordAny.viewedAt as string | undefined;
|
||||
|
||||
// Set decisions from existing authorization
|
||||
const initialDecisions: Record<string, 'approved' | 'declined'> = {};
|
||||
for (const s of servicesList) {
|
||||
if (s.customerDecision === 'approved' || s.customerDecision === 'declined') {
|
||||
initialDecisions[s.id] = s.customerDecision;
|
||||
}
|
||||
}
|
||||
setDecisions(initialDecisions);
|
||||
|
||||
setQuote({
|
||||
id: quoteId,
|
||||
customerName: recordAny.customerName || '',
|
||||
customerPhone: recordAny.customerPhone || '',
|
||||
vehicleInfo: recordAny.vehicleInfo || '',
|
||||
advisorName: recordAny.serviceAdvisor || '',
|
||||
services: servicesList,
|
||||
discountValue: recordAny.discountValue ?? 0,
|
||||
discountType: recordAny.discountType ?? 'dollar',
|
||||
subtotal: recordAny.subtotal ?? 0,
|
||||
tax: recordAny.tax ?? 0,
|
||||
total: recordAny.total ?? 0,
|
||||
status: recordAny.status ?? 'draft',
|
||||
createdAt: recordAny.createdAt ?? '',
|
||||
shareToken: shareTok,
|
||||
viewedAt: currentViewedAt,
|
||||
repairOrderId: recordAny.repairOrderId || '',
|
||||
});
|
||||
|
||||
// If the quote has a phone number, gate behind phone verification
|
||||
const rawPhone = (recordAny.customerPhone as string) || '';
|
||||
const hasPhone = rawPhone.replace(/\D/g, '').length >= 4;
|
||||
if (hasPhone) {
|
||||
setPageStatus('phone_verify');
|
||||
} else {
|
||||
setPageStatus('done');
|
||||
}
|
||||
|
||||
// Fetch shop settings for branding
|
||||
try {
|
||||
const userId = recordAny.userId;
|
||||
if (userId) {
|
||||
const settingsRecords = await pb.collection('settings').getFullList({
|
||||
filter: `userId="${userId}"`,
|
||||
query: { shareToken: tok },
|
||||
});
|
||||
if (settingsRecords.length > 0) {
|
||||
const sData = (settingsRecords[0] as any).data;
|
||||
if (sData) {
|
||||
const merged = { ...useSettingsStore.getState().settings, ...sData };
|
||||
setSettings(merged);
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
setSettings(useSettingsStore.getState().settings);
|
||||
}
|
||||
|
||||
// Stamp viewedAt after everything else is set
|
||||
void stampViewed(quoteId, shareTok, currentViewedAt);
|
||||
} catch (err: any) {
|
||||
if (err?.status === 404 || err?.message?.includes('not found')) {
|
||||
setPageStatus('invalid');
|
||||
} else if (err?.status === 410 || err?.message?.includes('Quote expired')) {
|
||||
setPageStatus('expired');
|
||||
} else {
|
||||
setPageStatus('error');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function setDecision(serviceId: string, decision: 'approved' | 'declined') {
|
||||
setDecisions((prev) => ({ ...prev, [serviceId]: decision }));
|
||||
}
|
||||
|
||||
async function handleSubmitAll() {
|
||||
if (!quote || saving) return;
|
||||
|
||||
// Validate every visible service has a decision
|
||||
const visibleServices = quote.services.filter((s) => s.selected !== false);
|
||||
const allDecided = visibleServices.every((s) => decisions[s.id] !== undefined);
|
||||
if (!allDecided) {
|
||||
setError('Please approve or decline each service before submitting.');
|
||||
return;
|
||||
}
|
||||
|
||||
const name = signatureName.trim();
|
||||
if (!name) {
|
||||
setError('Please enter your full name to authorize your decisions.');
|
||||
return;
|
||||
}
|
||||
|
||||
setSaving(true);
|
||||
setError('');
|
||||
|
||||
try {
|
||||
const now = new Date().toISOString();
|
||||
const updatedServices = quote.services.map((s) => {
|
||||
const decision = decisions[s.id];
|
||||
if (!decision) return s;
|
||||
return {
|
||||
...s,
|
||||
customerDecision: decision,
|
||||
approved: decision === 'approved',
|
||||
authorizedBy: name,
|
||||
authorizedAt: now,
|
||||
authorizedMethod: 'signature' as const,
|
||||
};
|
||||
});
|
||||
|
||||
// Determine top-level status
|
||||
const decidedSvcs = visibleServices.filter((s) => decisions[s.id]);
|
||||
const allApproved = decidedSvcs.every((s) => decisions[s.id] === 'approved');
|
||||
const allDeclined = decidedSvcs.every((s) => decisions[s.id] === 'declined');
|
||||
let newStatus: string;
|
||||
if (allApproved) {
|
||||
newStatus = 'approved';
|
||||
} else if (allDeclined) {
|
||||
newStatus = 'declined';
|
||||
} else {
|
||||
newStatus = 'sent';
|
||||
}
|
||||
|
||||
await pb.collection('quotes').update(quote.id, {
|
||||
services: updatedServices,
|
||||
status: newStatus,
|
||||
lastUpdated: now,
|
||||
}, { query: { shareToken: quote.shareToken } });
|
||||
|
||||
if (quote.repairOrderId) {
|
||||
try {
|
||||
await syncApprovedQuoteServicesToRO({
|
||||
pb,
|
||||
repairOrderId: quote.repairOrderId,
|
||||
quoteServices: updatedServices,
|
||||
settings: settings || useSettingsStore.getState().settings,
|
||||
});
|
||||
} catch (err) {
|
||||
logError('Failed to sync customer-approved services to RO', err);
|
||||
}
|
||||
}
|
||||
|
||||
setSubmitted(true);
|
||||
} catch (err: any) {
|
||||
if (err?.status === 410 || err?.message?.includes('Quote expired')) {
|
||||
setPageStatus('expired');
|
||||
} else {
|
||||
setError('Unable to save your decisions. Please try again.');
|
||||
}
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
function handlePhoneVerify() {
|
||||
if (!quote) return;
|
||||
const digitsOnly = quote.customerPhone.replace(/\D/g, '');
|
||||
const last4 = digitsOnly.slice(-4);
|
||||
const inputDigits = phoneInput.replace(/\D/g, '');
|
||||
if (inputDigits === last4) {
|
||||
setError('');
|
||||
setPhoneAttempts(0);
|
||||
setPageStatus('done');
|
||||
} else {
|
||||
const newAttempts = phoneAttempts + 1;
|
||||
setPhoneAttempts(newAttempts);
|
||||
setPhoneInput('');
|
||||
if (newAttempts >= MAX_PHONE_ATTEMPTS) {
|
||||
setError('Too many incorrect attempts. Please contact the shop directly to access your quote.');
|
||||
} else {
|
||||
setError(`Incorrect. ${MAX_PHONE_ATTEMPTS - newAttempts} attempt(s) remaining.`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const accentColor = settings?.pdfAccentColor || '#16a34a';
|
||||
const visibleServices = quote?.services.filter((s) => s.selected !== false) || [];
|
||||
const quoteSettings = settings || useSettingsStore.getState().settings;
|
||||
const decisionServices: QuoteService[] = visibleServices.map((s) => {
|
||||
const decision: QuoteService['customerDecision'] = decisions[s.id] || s.customerDecision || 'pending';
|
||||
return { ...s, customerDecision: decision, approved: decision === 'approved' };
|
||||
});
|
||||
const decisionDiscount = quote ? { value: quote.discountValue || 0, type: quote.discountType || 'dollar' } : { value: 0, type: 'dollar' as const };
|
||||
const hasApprovedServices = decisionServices.some((s) => s.customerDecision === 'approved');
|
||||
const hasPendingServices = decisionServices.some((s) => s.customerDecision === 'pending');
|
||||
const decisionTotals = quote ? computeQuoteTotals({ services: decisionServices, discount: decisionDiscount, settings: quoteSettings }) : null;
|
||||
const decisionSplit = quote && hasApprovedServices && hasPendingServices
|
||||
? computeSplitQuoteTotals(decisionServices, decisionDiscount, quoteSettings)
|
||||
: null;
|
||||
const approvedCount = visibleServices.filter((s) => decisions[s.id] === 'approved').length;
|
||||
const declinedCount = visibleServices.filter((s) => decisions[s.id] === 'declined').length;
|
||||
const hasPending = visibleServices.some((s) => decisions[s.id] === undefined);
|
||||
|
||||
if (pageStatus === 'loading') {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-gray-50 px-4 dark:bg-gray-950">
|
||||
<div className="text-center">
|
||||
<Loader2 className="mx-auto h-10 w-10 animate-spin" style={{ color: accentColor }} />
|
||||
<p className="mt-4 text-sm text-gray-500 dark:text-gray-400">Loading your quote...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (pageStatus === 'invalid') {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-gray-50 px-4 dark:bg-gray-950">
|
||||
<div className="max-w-md text-center">
|
||||
<AlertTriangle className="mx-auto h-12 w-12 text-amber-500" />
|
||||
<h2 className="mt-4 text-lg font-semibold text-gray-900 dark:text-white">Quote Link Not Valid</h2>
|
||||
<p className="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||
This approval link is invalid. Please contact your service provider for an updated link.
|
||||
</p>
|
||||
<Link to="/" className="mt-6 inline-block text-sm font-medium" style={{ color: accentColor }}>
|
||||
Go Home
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (pageStatus === 'expired') {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-gray-50 px-4 dark:bg-gray-950">
|
||||
<div className="max-w-md text-center">
|
||||
<Clock className="mx-auto h-12 w-12 text-red-500" />
|
||||
<h2 className="mt-4 text-lg font-semibold text-gray-900 dark:text-white">Quote Has Expired</h2>
|
||||
<p className="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||
This quote is no longer valid for approval. Please contact your service provider for an updated quote.
|
||||
</p>
|
||||
<Link to="/" className="mt-6 inline-block text-sm font-medium" style={{ color: accentColor }}>
|
||||
Go Home
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (pageStatus === 'error') {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-gray-50 px-4 dark:bg-gray-950">
|
||||
<div className="max-w-md text-center">
|
||||
<AlertTriangle className="mx-auto h-12 w-12 text-red-500" />
|
||||
<h2 className="mt-4 text-lg font-semibold text-gray-900 dark:text-white">Unable to Load Quote</h2>
|
||||
<p className="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||
Something went wrong while loading your quote. Please try again or contact your service provider.
|
||||
</p>
|
||||
<button
|
||||
onClick={() => token && loadQuote(token)}
|
||||
className="mt-6 inline-block rounded-lg px-5 py-2.5 text-sm font-medium text-white"
|
||||
style={{ backgroundColor: accentColor }}
|
||||
>
|
||||
Try Again
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (pageStatus === 'phone_verify' && quote) {
|
||||
const lockedOut = phoneAttempts >= MAX_PHONE_ATTEMPTS;
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-gray-50 px-4 dark:bg-gray-950">
|
||||
<div className="w-full max-w-sm text-center">
|
||||
{settings?.logoUrl ? (
|
||||
<img src={settings.logoUrl} alt={settings.businessName || 'Shop'} className="mx-auto h-16 mb-4 object-contain" />
|
||||
) : (
|
||||
<div className="mx-auto mb-4 flex h-14 w-14 items-center justify-center rounded-xl text-white text-lg font-bold" style={{ backgroundColor: accentColor }}>
|
||||
{(settings?.businessName || 'S').charAt(0)}
|
||||
</div>
|
||||
)}
|
||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-white mb-1">
|
||||
Verify Your Identity
|
||||
</h2>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mb-1">
|
||||
This quote is for <span className="font-medium text-gray-700 dark:text-gray-300">{quote.customerName}</span>
|
||||
</p>
|
||||
<p className="text-xs text-gray-400 dark:text-gray-500 mb-6">
|
||||
Enter the last 4 digits of the phone number on file to continue.
|
||||
</p>
|
||||
|
||||
<div className="max-w-[200px] mx-auto">
|
||||
{lockedOut ? (
|
||||
<div className="rounded-xl border border-red-200 bg-red-50 p-5 dark:border-red-800 dark:bg-red-900/20">
|
||||
<ShieldX className="mx-auto h-8 w-8 text-red-500 mb-2" />
|
||||
<p className="text-sm text-red-700 dark:text-red-400 font-medium">Access Locked</p>
|
||||
<p className="mt-1 text-xs text-red-600 dark:text-red-400">
|
||||
Too many incorrect attempts. Please call the shop directly to verify your identity.
|
||||
</p>
|
||||
<p className="mt-3 text-xs text-gray-500 dark:text-gray-400">
|
||||
{settings?.businessPhone && <span>Call {settings.businessPhone}</span>}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<input
|
||||
type="text"
|
||||
value={phoneInput}
|
||||
onChange={(e) => setPhoneInput(e.target.value.replace(/\D/g, '').slice(0, 4))}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter') handlePhoneVerify(); }}
|
||||
placeholder="Last 4 digits"
|
||||
maxLength={4}
|
||||
autoFocus
|
||||
className="w-full text-center text-2xl tracking-[0.5em] rounded-xl border border-gray-300 bg-white px-4 py-4 text-gray-900 placeholder-gray-300 focus:outline-none focus:ring-2 focus:ring-green-500/20 focus:border-green-500 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100 dark:placeholder-gray-600"
|
||||
inputMode="numeric"
|
||||
autoComplete="off"
|
||||
/>
|
||||
{error && (
|
||||
<p className="mt-2 text-xs text-red-600 dark:text-red-400">{error}</p>
|
||||
)}
|
||||
<button
|
||||
onClick={handlePhoneVerify}
|
||||
disabled={phoneInput.replace(/\D/g, '').length < 4}
|
||||
className="mt-4 w-full flex items-center justify-center gap-2 rounded-lg px-4 py-3 text-sm font-semibold text-white transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
style={{ backgroundColor: accentColor }}
|
||||
>
|
||||
<ShieldCheck className="h-4 w-4" />
|
||||
Verify & View Quote
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p className="mt-6 text-xs text-gray-400 dark:text-gray-500">
|
||||
Having trouble? {settings?.businessName || 'Your service provider'} can help.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (submitted) {
|
||||
const name = signatureName.trim() || 'Customer';
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-gray-50 px-4 dark:bg-gray-950">
|
||||
<div className="max-w-md text-center">
|
||||
<CheckCircle2 className="mx-auto h-16 w-16" style={{ color: accentColor }} />
|
||||
<h2 className="mt-4 text-xl font-semibold text-gray-900 dark:text-white">Thank You, {name}</h2>
|
||||
<p className="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||
Your decisions have been recorded. {quote?.advisorName ? `${quote.advisorName} will review them shortly.` : 'Your service provider will review them shortly.'}
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-gray-400 dark:text-gray-500">
|
||||
Authorized by {name} on {new Date().toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric', hour: 'numeric', minute: '2-digit' })}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!quote) return null;
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-gray-50 dark:bg-gray-950">
|
||||
<div className="mx-auto max-w-2xl px-4 py-8 sm:px-6 lg:px-8">
|
||||
{/* Header */}
|
||||
<div className="mb-8 text-center">
|
||||
{settings?.logoUrl ? (
|
||||
<img src={settings.logoUrl} alt={settings.businessName || 'Shop'} className="mx-auto h-16 mb-3 object-contain" />
|
||||
) : (
|
||||
<div className="mx-auto mb-3 flex h-16 w-16 items-center justify-center rounded-xl text-white text-xl font-bold" style={{ backgroundColor: accentColor }}>
|
||||
{(settings?.businessName || 'S').charAt(0)}
|
||||
</div>
|
||||
)}
|
||||
<h1 className="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
{settings?.businessName || 'Your Service Provider'}
|
||||
</h1>
|
||||
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||
Service Estimate for {quote.customerName}
|
||||
</p>
|
||||
<p className="text-xs text-gray-400 dark:text-gray-500">
|
||||
{quote.vehicleInfo}
|
||||
{quote.createdAt ? ` • ${new Date(quote.createdAt).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })}` : ''}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Intro */}
|
||||
<div className="mb-6 rounded-xl border border-gray-200 bg-white p-5 dark:border-gray-800 dark:bg-gray-900">
|
||||
<p className="text-sm text-gray-600 dark:text-gray-300">
|
||||
Please review the services below. Select <span className="font-medium text-green-600">Approve</span> for
|
||||
each service you would like performed, or <span className="font-medium text-red-600">Decline</span> for
|
||||
services you do not want at this time.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Services List */}
|
||||
<div className="space-y-3 mb-6">
|
||||
{visibleServices.map((service) => {
|
||||
const decision = decisions[service.id];
|
||||
const isApproved = decision === 'approved';
|
||||
const isDeclined = decision === 'declined';
|
||||
const isPending = decision === undefined;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={service.id}
|
||||
className={`rounded-xl border bg-white p-4 dark:bg-gray-900 transition-colors ${
|
||||
isApproved
|
||||
? 'border-green-300 dark:border-green-800'
|
||||
: isDeclined
|
||||
? 'border-red-300 dark:border-red-800'
|
||||
: 'border-gray-200 dark:border-gray-800'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
{isApproved && <CheckCircle2 className="h-4 w-4 text-green-600 shrink-0" />}
|
||||
{isDeclined && <XCircle className="h-4 w-4 text-red-600 shrink-0" />}
|
||||
<h3 className="text-sm font-medium text-gray-900 dark:text-white">{service.name}</h3>
|
||||
</div>
|
||||
{service.explanation && (
|
||||
<p className="mt-1 text-xs text-gray-500 dark:text-gray-400 line-clamp-2">{service.explanation}</p>
|
||||
)}
|
||||
{service.priority && (
|
||||
<span className="inline-block mt-2 text-xs font-medium uppercase tracking-wide text-amber-600 dark:text-amber-400">
|
||||
{service.priority.replace(/_/g, ' ')}
|
||||
</span>
|
||||
)}
|
||||
{isPending && (
|
||||
<div className="mt-2 flex flex-wrap gap-2">
|
||||
<button
|
||||
onClick={() => setDecision(service.id, 'approved')}
|
||||
className="inline-flex items-center gap-1.5 rounded-lg bg-green-600 px-3 py-1.5 text-xs font-medium text-white hover:bg-green-700 transition-colors"
|
||||
>
|
||||
<CheckCircle2 className="h-3.5 w-3.5" /> Approve
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setDecision(service.id, 'declined')}
|
||||
className="inline-flex items-center gap-1.5 rounded-lg border border-red-300 bg-white px-3 py-1.5 text-xs font-medium text-red-600 hover:bg-red-50 transition-colors dark:border-red-700 dark:bg-gray-800 dark:text-red-400 dark:hover:bg-red-900/20"
|
||||
>
|
||||
<XCircle className="h-3.5 w-3.5" /> Decline
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-right shrink-0">
|
||||
<span className={`text-sm font-semibold ${
|
||||
isApproved ? 'text-green-600 dark:text-green-400' :
|
||||
isDeclined ? 'text-gray-400 dark:text-gray-500 line-through' :
|
||||
'text-gray-900 dark:text-gray-100'
|
||||
}`}>
|
||||
${(Number(service.price) || 0).toFixed(2)}
|
||||
</span>
|
||||
{isApproved && (
|
||||
<p className="mt-0.5 text-xs text-green-600 dark:text-green-400 font-medium">Approved</p>
|
||||
)}
|
||||
{isDeclined && (
|
||||
<p className="mt-0.5 text-xs text-red-600 dark:text-red-400 font-medium">Declined</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Summary */}
|
||||
<div className="rounded-xl border border-gray-200 bg-white p-5 dark:border-gray-800 dark:bg-gray-900 mb-6">
|
||||
<h3 className="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-3">Estimate Summary</h3>
|
||||
<div className="space-y-1.5 text-sm">
|
||||
{decisionSplit ? (
|
||||
<>
|
||||
<div className="rounded-lg border border-green-200 bg-green-50/60 px-3 py-2.5 dark:border-green-800 dark:bg-green-900/15">
|
||||
<div className="flex justify-between font-semibold text-green-700 dark:text-green-400">
|
||||
<span>Already approved work</span>
|
||||
<span>${decisionSplit.approved.total.toFixed(2)}</span>
|
||||
</div>
|
||||
<div className="mt-1 space-y-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
<div className="flex justify-between"><span>Services</span><span>${decisionSplit.approved.subtotal.toFixed(2)}</span></div>
|
||||
{decisionSplit.approved.shopCharge > 0 && <div className="flex justify-between"><span>Shop charge</span><span>${decisionSplit.approved.shopCharge.toFixed(2)}</span></div>}
|
||||
{decisionSplit.approved.tax > 0 && <div className="flex justify-between"><span>{quoteSettings.taxLabel || 'Tax'}</span><span>${decisionSplit.approved.tax.toFixed(2)}</span></div>}
|
||||
</div>
|
||||
</div>
|
||||
<div className="rounded-lg border border-amber-200 bg-amber-50/60 px-3 py-2.5 dark:border-amber-800 dark:bg-amber-900/15">
|
||||
<div className="flex justify-between font-semibold text-amber-700 dark:text-amber-400">
|
||||
<span>Additional recommendations</span>
|
||||
<span>${decisionSplit.pending.total.toFixed(2)}</span>
|
||||
</div>
|
||||
<div className="mt-1 space-y-1 text-xs text-gray-500 dark:text-gray-400">
|
||||
<div className="flex justify-between"><span>Services</span><span>${decisionSplit.pending.subtotal.toFixed(2)}</span></div>
|
||||
{decisionSplit.pending.shopCharge > 0 && <div className="flex justify-between"><span>Shop charge</span><span>${decisionSplit.pending.shopCharge.toFixed(2)}</span></div>}
|
||||
{decisionSplit.pending.tax > 0 && <div className="flex justify-between"><span>{quoteSettings.taxLabel || 'Tax'}</span><span>${decisionSplit.pending.tax.toFixed(2)}</span></div>}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-between rounded-lg bg-gray-100 px-3 py-2 dark:bg-gray-800">
|
||||
<span className="font-semibold text-gray-900 dark:text-white">Total if all work is approved</span>
|
||||
<span className="font-bold" style={{ color: accentColor }}>${decisionSplit.combined.total.toFixed(2)}</span>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-500 dark:text-gray-400">Services</span>
|
||||
<span className="text-gray-900 dark:text-gray-100">${(decisionTotals?.subtotal || 0).toFixed(2)}</span>
|
||||
</div>
|
||||
{(decisionTotals?.discountAmount || 0) > 0 && (
|
||||
<div className="flex justify-between text-green-600 dark:text-green-400">
|
||||
<span>Discount</span>
|
||||
<span>-${(decisionTotals?.discountAmount || 0).toFixed(2)}</span>
|
||||
</div>
|
||||
)}
|
||||
{(decisionTotals?.shopCharge || 0) > 0 && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-500 dark:text-gray-400">Shop charge</span>
|
||||
<span className="text-gray-900 dark:text-gray-100">${(decisionTotals?.shopCharge || 0).toFixed(2)}</span>
|
||||
</div>
|
||||
)}
|
||||
{(decisionTotals?.tax || 0) > 0 && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-gray-500 dark:text-gray-400">{quoteSettings.taxLabel || 'Tax'}</span>
|
||||
<span className="text-gray-900 dark:text-gray-100">${(decisionTotals?.tax || 0).toFixed(2)}</span>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
<div className="border-t border-gray-200 dark:border-gray-700 pt-2 mt-2 flex justify-between">
|
||||
<span className="font-semibold text-gray-900 dark:text-white">Estimated total based on choices</span>
|
||||
<span className="font-bold text-lg" style={{ color: accentColor }}>
|
||||
${(decisionTotals?.total || 0).toFixed(2)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
{visibleServices.length > 0 && (
|
||||
<div className="mt-3 flex gap-4 text-xs text-gray-500 dark:text-gray-400">
|
||||
<span className="flex items-center gap-1"><CheckCircle2 className="h-3 w-3 text-green-600" /> {approvedCount} approved</span>
|
||||
<span className="flex items-center gap-1"><XCircle className="h-3 w-3 text-red-600" /> {declinedCount} declined</span>
|
||||
<span className="flex items-center gap-1"><Clock className="h-3 w-3 text-amber-500" /> {visibleServices.length - approvedCount - declinedCount} pending</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Signature */}
|
||||
<div className="rounded-xl border border-gray-200 bg-white p-5 dark:border-gray-800 dark:bg-gray-900 mb-6">
|
||||
<h3 className="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-3 flex items-center gap-1.5">
|
||||
<Signature className="h-4 w-4" /> Authorization
|
||||
</h3>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mb-3">
|
||||
By entering your full name below, you authorize your service provider to perform the approved services and acknowledge the declined services.
|
||||
</p>
|
||||
<input
|
||||
type="text"
|
||||
value={signatureName}
|
||||
onChange={(e) => setSignatureName(e.target.value)}
|
||||
placeholder="Enter your full name"
|
||||
className="w-full rounded-lg border border-gray-300 bg-white px-4 py-3 text-sm text-gray-900 placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-green-500/20 focus:border-green-500 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100 dark:placeholder-gray-500"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Actions */}
|
||||
<div className="space-y-3">
|
||||
{error && (
|
||||
<div className="rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700 dark:border-red-800 dark:bg-red-900/20 dark:text-red-400">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
{!hasPending && (
|
||||
<button
|
||||
onClick={handleSubmitAll}
|
||||
disabled={saving || !signatureName.trim()}
|
||||
className="w-full flex items-center justify-center gap-2 rounded-lg px-4 py-3 text-sm font-semibold text-white transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
style={{ backgroundColor: accentColor }}
|
||||
>
|
||||
{saving ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<ShieldCheck className="h-4 w-4" />
|
||||
)}
|
||||
{saving ? 'Submitting...' : 'Submit Decisions'}
|
||||
</button>
|
||||
)}
|
||||
{hasPending && (
|
||||
<p className="text-center text-xs text-gray-400 dark:text-gray-500 flex items-center justify-center gap-1">
|
||||
<ShieldX className="h-3.5 w-3.5" /> Please approve or decline each service above
|
||||
</p>
|
||||
)}
|
||||
<p className="text-center text-xs text-gray-400 dark:text-gray-500">
|
||||
{settings?.businessName || 'Your provider'} will be notified of your decisions.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,913 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useSearchParams, useNavigate, useLocation } from 'react-router-dom';
|
||||
import {
|
||||
Search, Trash2, Plus, FileText, X,
|
||||
ListOrdered, Lightbulb, Loader2, Wrench, AlertTriangle,
|
||||
} from 'lucide-react';
|
||||
import { pb } from '../lib/pocketbase';
|
||||
import { useQuoteStore } from '../store/quote';
|
||||
import { useSettingsStore } from '../store/settings';
|
||||
import { getPriorityAnalysis, aiSuggestServices } from '../lib/ai';
|
||||
import { uniqueRONumber } from '../lib/roNumber';
|
||||
import { useToast } from '../components/ui/Toast';
|
||||
import type { QuoteService, CustomerInfo } from '../types';
|
||||
import { logError } from '../lib/userMessages';
|
||||
import { normalizeCustomerInfo, formatQuoteCurrency, formatQuoteDate } from '../lib/quoteHelpers';
|
||||
|
||||
/* ── Helper: convert legacy flat customer fields to CustomerInfo ── */
|
||||
function toCustomerInfo(opts: {
|
||||
name?: string; phone?: string; vehicleInfo?: string; vin?: string;
|
||||
mileage?: string; roNumber?: string; repairOrderId?: string; serviceAdvisor?: string;
|
||||
}): CustomerInfo {
|
||||
const n = opts.name || '';
|
||||
const parts = n.trim().split(/\s+/);
|
||||
return {
|
||||
firstName: parts[0] || '',
|
||||
middleName: '',
|
||||
lastName: parts.slice(1).join(' ') || '',
|
||||
name: n,
|
||||
phone: opts.phone || '',
|
||||
vehicleInfo: opts.vehicleInfo || '',
|
||||
vin: opts.vin || '',
|
||||
mileage: opts.mileage || '',
|
||||
roNumber: opts.roNumber || '',
|
||||
repairOrderId: opts.repairOrderId || '',
|
||||
serviceAdvisor: opts.serviceAdvisor || '',
|
||||
email: '',
|
||||
};
|
||||
}
|
||||
|
||||
// Extracted components
|
||||
import {
|
||||
type QuoteRecord,
|
||||
type RecentQuoteFilter,
|
||||
type RecentQuoteSort,
|
||||
RECENT_QUOTE_FILTERS,
|
||||
RecentStatusBadge,
|
||||
CustomerInfoPanel,
|
||||
ServiceSearch,
|
||||
ServicesTable,
|
||||
QuoteSummary,
|
||||
} from '../components/quoteGenerator';
|
||||
|
||||
/* ── Helpers kept local (recent-quotes panel only) ──── */
|
||||
|
||||
const recentQuoteDate = (quote: QuoteRecord) => quote.createdAt || quote.lastUpdated || '';
|
||||
const recentQuoteTimestamp = (quote: QuoteRecord) => {
|
||||
const value = recentQuoteDate(quote);
|
||||
const parsed = value ? new Date(value).getTime() : NaN;
|
||||
return Number.isNaN(parsed) ? 0 : parsed;
|
||||
};
|
||||
const matchesRecentStatusFilter = (quote: QuoteRecord, filter: RecentQuoteFilter) => {
|
||||
if (filter === 'all') return true;
|
||||
if (filter === 'pending') return quote.status === 'sent';
|
||||
return quote.status === filter;
|
||||
};
|
||||
|
||||
/* ── Main Page ──────────────────────────────────────── */
|
||||
|
||||
export default function QuoteGenerator() {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const [searchParams] = useSearchParams();
|
||||
const editId = searchParams.get('edit');
|
||||
const { reset, setCustomerInfo, addService } = useQuoteStore();
|
||||
const [repairOriginId, setRepairOriginId] = useState<string | null>(null);
|
||||
|
||||
// Pre-populate from RO data passed via URL params
|
||||
useEffect(() => {
|
||||
const fromRO = searchParams.get('fromRO');
|
||||
const name = searchParams.get('name');
|
||||
if (!fromRO && !name) return;
|
||||
|
||||
const applyCI = (ci: CustomerInfo) => {
|
||||
if (ci.name) setCustomerInfo(ci);
|
||||
};
|
||||
|
||||
if (!fromRO) {
|
||||
applyCI(toCustomerInfo({
|
||||
name: name || '',
|
||||
phone: searchParams.get('phone') || '',
|
||||
vehicleInfo: searchParams.get('vehicleInfo') || '',
|
||||
vin: searchParams.get('vin') || '',
|
||||
mileage: searchParams.get('mileage') || '',
|
||||
roNumber: searchParams.get('roNumber') || '',
|
||||
serviceAdvisor: searchParams.get('serviceAdvisor') || '',
|
||||
}));
|
||||
return;
|
||||
}
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const ro = (await pb.collection('repairOrders').getOne(fromRO)) as any;
|
||||
setRepairOriginId(ro.id);
|
||||
|
||||
let roServices: any[] = [];
|
||||
const raw = ro.services;
|
||||
if (typeof raw === 'string' && raw.trim()) {
|
||||
try { roServices = JSON.parse(raw); } catch (err) { logError('Failed to parse quote-to-RO services JSON', err); roServices = []; }
|
||||
} else if (Array.isArray(raw)) {
|
||||
roServices = raw;
|
||||
}
|
||||
|
||||
reset();
|
||||
applyCI(toCustomerInfo({
|
||||
name: ro.customerName || name || '',
|
||||
phone: ro.customerPhone || searchParams.get('phone') || '',
|
||||
vehicleInfo: ro.vehicleInfo || searchParams.get('vehicleInfo') || '',
|
||||
vin: ro.vin || searchParams.get('vin') || '',
|
||||
mileage: ro.mileage || searchParams.get('mileage') || '',
|
||||
roNumber: ro.roNumber || searchParams.get('roNumber') || '',
|
||||
repairOrderId: ro.id,
|
||||
serviceAdvisor: ro.advisorName || searchParams.get('serviceAdvisor') || '',
|
||||
}));
|
||||
roServices
|
||||
.filter((s: any) => s.name && s.name.trim())
|
||||
.forEach((s: any) => {
|
||||
const splitPrice =
|
||||
(Number(s.laborHours) || 0) * (Number(s.laborRate) || 0) +
|
||||
(Number(s.partsCost) || 0);
|
||||
const flatPrice = splitPrice || (Number(s.total) || 0);
|
||||
addService({
|
||||
id: `ro-${ro.id}-${s.id || Math.random().toString(36).slice(2, 8)}`,
|
||||
name: s.name,
|
||||
price: flatPrice,
|
||||
selected: true,
|
||||
approved: true,
|
||||
customerDecision: 'approved',
|
||||
applyShopCharge: s.applyShopCharge !== false,
|
||||
sourceROServiceId: s.id || undefined,
|
||||
pricingMode: s.pricingMode || (splitPrice ? 'split' : 'full'),
|
||||
laborHours: Number(s.laborHours) || undefined,
|
||||
laborRate: Number(s.laborRate) || undefined,
|
||||
partsCost: Number(s.partsCost) || undefined,
|
||||
authorizedBy: ro.advisorName || 'RO Pre-Approval',
|
||||
authorizedAt: ro.updated || new Date().toISOString(),
|
||||
authorizedMethod: 'in_person',
|
||||
technicianNotes: s.technician ? `Tech: ${s.technician}` : undefined,
|
||||
});
|
||||
});
|
||||
if (roServices.length > 0) {
|
||||
showToast(`Loaded ${roServices.length} service(s) from RO #${ro.roNumber || ro.id.slice(0, 6)}`, 'success');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to load RO for quote pre-fill:', err);
|
||||
applyCI(toCustomerInfo({
|
||||
name: name || '',
|
||||
phone: searchParams.get('phone') || '',
|
||||
vehicleInfo: searchParams.get('vehicleInfo') || '',
|
||||
vin: searchParams.get('vin') || '',
|
||||
mileage: searchParams.get('mileage') || '',
|
||||
roNumber: searchParams.get('roNumber') || '',
|
||||
serviceAdvisor: searchParams.get('serviceAdvisor') || '',
|
||||
}));
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
})();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
// ── AI: Generate Priorities ──
|
||||
const [prioritiesLoading, setPrioritiesLoading] = useState(false);
|
||||
const { showToast } = useToast();
|
||||
const { services } = useQuoteStore();
|
||||
const updateService = useQuoteStore((s) => s.updateService);
|
||||
|
||||
// ── AI: Suggest Services ──
|
||||
const [suggestLoading, setSuggestLoading] = useState(false);
|
||||
const [suggestions, setSuggestions] = useState<{ id: string; name: string; price: number; reason: string; explanation: string; recommendation: string }[]>([]);
|
||||
const [showSuggestions, setShowSuggestions] = useState(false);
|
||||
|
||||
// ── Recent Quotes ──
|
||||
const [recentQuotes, setRecentQuotes] = useState<QuoteRecord[]>([]);
|
||||
const [recentOpen, setRecentOpen] = useState(false);
|
||||
const [recentLoading, setRecentLoading] = useState(false);
|
||||
const [recentQuery, setRecentQuery] = useState('');
|
||||
const [recentStatusFilter, setRecentStatusFilter] = useState<RecentQuoteFilter>('all');
|
||||
const [recentSort, setRecentSort] = useState<RecentQuoteSort>('newest');
|
||||
const [deleteTarget, setDeleteTarget] = useState<QuoteRecord | null>(null);
|
||||
const [deletingQuote, setDeletingQuote] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!recentOpen) return;
|
||||
const handleKeyDown = (e: KeyboardEvent) => {
|
||||
if (e.key === 'Escape') setRecentOpen(false);
|
||||
};
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
return () => document.removeEventListener('keydown', handleKeyDown);
|
||||
}, [recentOpen]);
|
||||
|
||||
const loadRecentQuotes = async () => {
|
||||
setRecentLoading(true);
|
||||
try {
|
||||
const userId = pb.authStore.model?.id;
|
||||
if (!userId) return;
|
||||
const records = await pb.collection('quotes').getList(1, 50, {
|
||||
filter: `userId = '${userId}'`,
|
||||
sort: '-id',
|
||||
fields: 'id,customerName,vehicleInfo,repairOrderNumber,total,status,createdAt,lastUpdated,services',
|
||||
});
|
||||
setRecentQuotes((records.items as any[]).map((record) => {
|
||||
const rawServices = record.services;
|
||||
let servicesList: any[] = [];
|
||||
if (typeof rawServices === 'string') { try { servicesList = JSON.parse(rawServices); } catch (err) { logError('Failed to parse recent quote services JSON', err); servicesList = []; } }
|
||||
else if (Array.isArray(rawServices)) { servicesList = rawServices; }
|
||||
const approvedTotal = servicesList.reduce((sum: number, s: any) => s.customerDecision === 'approved' ? sum + (parseFloat(String(s.price)) || 0) : sum, 0);
|
||||
const declinedTotal = servicesList.reduce((sum: number, s: any) => s.customerDecision === 'declined' ? sum + (parseFloat(String(s.price)) || 0) : sum, 0);
|
||||
const hasPending = servicesList.some((s: any) => !s.customerDecision || s.customerDecision === 'pending');
|
||||
// Derive status from service decisions when ALL services are decided
|
||||
let derivedStatus: QuoteRecord['status'] = record.status;
|
||||
if (!hasPending && servicesList.length > 0) {
|
||||
const allApproved = servicesList.every((s: any) => s.customerDecision === 'approved');
|
||||
const allDeclined = servicesList.every((s: any) => s.customerDecision === 'declined');
|
||||
if (allApproved) derivedStatus = 'approved';
|
||||
else if (allDeclined) derivedStatus = 'declined';
|
||||
}
|
||||
return {
|
||||
...record,
|
||||
roNumber: record.roNumber || record.repairOrderNumber || '',
|
||||
approvedTotal,
|
||||
declinedTotal,
|
||||
hasPending,
|
||||
status: derivedStatus,
|
||||
};
|
||||
}) as QuoteRecord[]);
|
||||
} catch {
|
||||
setRecentQuotes([]);
|
||||
} finally {
|
||||
setRecentLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteQuote = async () => {
|
||||
if (!deleteTarget) return;
|
||||
setDeletingQuote(true);
|
||||
try {
|
||||
await pb.collection('quotes').delete(deleteTarget.id);
|
||||
showToast('Quote deleted', 'success');
|
||||
setDeleteTarget(null);
|
||||
loadRecentQuotes();
|
||||
} catch (err: unknown) {
|
||||
logError('Failed to delete quote', err);
|
||||
showToast('Quote could not be deleted. Please try again.', 'error');
|
||||
} finally {
|
||||
setDeletingQuote(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRecentToggle = async () => {
|
||||
if (recentOpen) {
|
||||
setRecentOpen(false);
|
||||
return;
|
||||
}
|
||||
setRecentOpen(true);
|
||||
if (recentQuotes.length === 0) {
|
||||
await loadRecentQuotes();
|
||||
}
|
||||
};
|
||||
|
||||
const filteredQuotes = recentQuotes
|
||||
.filter((q) => {
|
||||
if (!matchesRecentStatusFilter(q, recentStatusFilter)) return false;
|
||||
if (!recentQuery.trim()) return true;
|
||||
const ql = recentQuery.toLowerCase();
|
||||
return (q.customerName || '').toLowerCase().includes(ql) ||
|
||||
(q.roNumber || '').toLowerCase().includes(ql) ||
|
||||
(q.vehicleInfo || '').toLowerCase().includes(ql);
|
||||
})
|
||||
.sort((a, b) => {
|
||||
if (recentSort === 'oldest') return recentQuoteTimestamp(a) - recentQuoteTimestamp(b);
|
||||
if (recentSort === 'highest-total') return (b.total || 0) - (a.total || 0);
|
||||
if (recentSort === 'customer-asc') return (a.customerName || '').localeCompare(b.customerName || '');
|
||||
return recentQuoteTimestamp(b) - recentQuoteTimestamp(a);
|
||||
});
|
||||
|
||||
const recentCounts = recentQuotes.reduce<Record<RecentQuoteFilter, number>>((counts, quote) => {
|
||||
counts.all += 1;
|
||||
if (quote.status === 'sent') counts.pending += 1;
|
||||
else if (quote.status === 'approved') counts.approved += 1;
|
||||
else if (quote.status === 'declined') counts.declined += 1;
|
||||
else counts.draft += 1;
|
||||
return counts;
|
||||
}, { all: 0, draft: 0, pending: 0, approved: 0, declined: 0 });
|
||||
|
||||
const handleGeneratePriorities = async () => {
|
||||
const allServices = services;
|
||||
if (allServices.length < 1) {
|
||||
showToast('Add at least one service before generating priorities.', 'info');
|
||||
return;
|
||||
}
|
||||
setPrioritiesLoading(true);
|
||||
try {
|
||||
const results = await getPriorityAnalysis(allServices);
|
||||
if (results) {
|
||||
for (const r of results) {
|
||||
const svc = allServices.find((s: QuoteService) => s.name === r.name);
|
||||
if (svc) {
|
||||
updateService(svc.id, { priority: r.rank === 1 ? 'CRITICAL_SAFETY' : r.rank <= 2 ? 'SAFETY_CONCERN' : 'RECOMMENDED' as QuoteService['priority'], priorityReason: r.priority_reason });
|
||||
}
|
||||
}
|
||||
showToast(`Priorities generated for ${results.length} service(s).`, 'success');
|
||||
} else {
|
||||
showToast('Priority analysis failed', 'error');
|
||||
}
|
||||
} catch (err) {
|
||||
logError('Failed to generate priorities', err);
|
||||
showToast('Priority analysis could not be completed. Please try again.', 'error');
|
||||
} finally {
|
||||
setPrioritiesLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAiSuggest = async () => {
|
||||
const { customerInfo } = useQuoteStore.getState();
|
||||
const parts = (customerInfo.vehicleInfo || '').match(/^(\d{4})\s+(.+?)\s+(.+)$/);
|
||||
const vehicleInfo = parts
|
||||
? { year: parts[1], make: parts[2], model: parts[3] }
|
||||
: { year: '', make: customerInfo.vehicleInfo || '', model: '' };
|
||||
|
||||
if (!vehicleInfo.year && !vehicleInfo.make) {
|
||||
showToast('Please fill in the vehicle info (year, make, model) first.', 'info');
|
||||
return;
|
||||
}
|
||||
|
||||
setSuggestLoading(true);
|
||||
try {
|
||||
const catalog: { id: string; name: string; price: number }[] = [];
|
||||
try {
|
||||
const userId = pb.authStore.model?.id;
|
||||
if (userId) {
|
||||
const records = await pb.collection('services').getFullList({ filter: `userId="${userId}"` });
|
||||
catalog.push(...records.map((r: any) => ({ id: r.id, name: r.name, price: r.price || 0 })));
|
||||
}
|
||||
} catch { /* use empty catalog */ }
|
||||
|
||||
const results = await aiSuggestServices(
|
||||
{ vehicleInfo: customerInfo.vehicleInfo || `${vehicleInfo.year || ''} ${vehicleInfo.make || ''} ${vehicleInfo.model || ''}`.trim(), mileage: parseInt(customerInfo.mileage) || 0, vin: customerInfo.vin },
|
||||
services.map((s) => s.name),
|
||||
catalog.map((c) => ({ name: c.name, category: '', price: c.price })),
|
||||
);
|
||||
const suggestions = (results || []).map((r) => ({
|
||||
id: `sug-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`,
|
||||
name: r.name,
|
||||
reason: r.reason,
|
||||
price: catalog.find((c) => c.name === r.name)?.price || 0,
|
||||
explanation: '',
|
||||
recommendation: '',
|
||||
}));
|
||||
setSuggestions(suggestions);
|
||||
setShowSuggestions(suggestions.length > 0);
|
||||
if (suggestions.length === 0) {
|
||||
showToast('No additional service recommendations were found for this vehicle.', 'info');
|
||||
}
|
||||
} catch (err) {
|
||||
logError('Failed to generate AI suggestions', err);
|
||||
showToast('Service suggestions could not be generated. Please try again.', 'error');
|
||||
} finally {
|
||||
setSuggestLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddSuggestion = (sug: typeof suggestions[0]) => {
|
||||
const newSvc: QuoteService = {
|
||||
id: `sug-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`,
|
||||
name: sug.name,
|
||||
price: sug.price,
|
||||
explanation: sug.explanation,
|
||||
recommendation: sug.recommendation,
|
||||
selected: true,
|
||||
approved: false,
|
||||
customerDecision: 'pending',
|
||||
};
|
||||
addService(newSvc);
|
||||
setSuggestions((prev) => {
|
||||
const next = prev.filter((s) => s.id !== sug.id);
|
||||
if (next.length === 0) setShowSuggestions(false);
|
||||
return next;
|
||||
});
|
||||
showToast(`Added "${sug.name}" to the quote.`, 'success');
|
||||
};
|
||||
|
||||
// ── Convert a saved quote to a Repair Order ──
|
||||
const [convertingId, setConvertingId] = useState<string | null>(null);
|
||||
|
||||
const handleConvertQuoteToRO = async (quote: QuoteRecord) => {
|
||||
setConvertingId(quote.id);
|
||||
try {
|
||||
const q = (await pb.collection('quotes').getOne(quote.id)) as any;
|
||||
|
||||
let existing = new Set<string>();
|
||||
try {
|
||||
const userId = pb.authStore.model?.id;
|
||||
if (userId) {
|
||||
const ros = await pb.collection('repairOrders').getFullList({
|
||||
filter: `userId = "${userId}"`,
|
||||
fields: 'roNumber,repairOrderNumber',
|
||||
batch: 500,
|
||||
});
|
||||
existing = new Set(ros.map((r: any) => r.roNumber || r.repairOrderNumber).filter(Boolean));
|
||||
}
|
||||
} catch { /* ignore */ }
|
||||
|
||||
const ci = normalizeCustomerInfo({
|
||||
...q,
|
||||
...(typeof q.customerInfo === 'string' ? (() => { try { return JSON.parse(q.customerInfo); } catch (err) { logError('Failed to parse quote customerInfo JSON', err); return {}; } })() : (q.customerInfo || {})),
|
||||
}) || { name: quote.customerName, phone: '', vehicleInfo: quote.vehicleInfo, vin: '', mileage: '', roNumber: quote.roNumber || quote.repairOrderNumber || '', serviceAdvisor: '' };
|
||||
|
||||
const settings = useSettingsStore.getState().settings;
|
||||
const laborRate = settings.defaultLaborRate || 95;
|
||||
let svcs: any[] = [];
|
||||
const rawSvcs = q.services;
|
||||
if (typeof rawSvcs === 'string') { try { svcs = JSON.parse(rawSvcs); } catch (err) { logError('Failed to parse quote-to-RO svcs JSON', err); svcs = []; } }
|
||||
else if (Array.isArray(rawSvcs)) { svcs = rawSvcs; }
|
||||
|
||||
const roServices = svcs
|
||||
.filter((s: any) => s.approved)
|
||||
.map((s: any, i: number) => {
|
||||
const price = parseFloat(String(s.price ?? 0)) || 0;
|
||||
const laborHours = price / laborRate;
|
||||
return {
|
||||
id: s.id || `rosvc-${i}`,
|
||||
name: s.name || '',
|
||||
description: s.explanation || '',
|
||||
laborHours: Math.round(laborHours * 100) / 100,
|
||||
laborRate,
|
||||
partsCost: 0,
|
||||
total: price,
|
||||
status: 'pending' as const,
|
||||
technician: '',
|
||||
pricingMode: s.pricingMode || 'full',
|
||||
applyShopCharge: s.applyShopCharge !== false,
|
||||
technicianNotes: s.technicianNotes || '',
|
||||
};
|
||||
});
|
||||
|
||||
const roNumber = uniqueRONumber(existing);
|
||||
const userId = pb.authStore.model?.id;
|
||||
const payload: Record<string, any> = {
|
||||
userId,
|
||||
quoteId: q.id,
|
||||
customerId: q.customerId || '',
|
||||
customerName: ci.name,
|
||||
customerPhone: ci.phone,
|
||||
vehicleInfo: ci.vehicleInfo,
|
||||
vin: ci.vin,
|
||||
mileage: ci.mileage,
|
||||
advisorName: ci.serviceAdvisor,
|
||||
roNumber,
|
||||
notes: `Converted from quote #${q.id.slice(0, 8)}`,
|
||||
warranty: false,
|
||||
status: 'active',
|
||||
customerType: 'drop-off',
|
||||
estimatedTime: '1.0',
|
||||
services: JSON.stringify(roServices),
|
||||
writeupTime: new Date().toISOString(),
|
||||
};
|
||||
|
||||
const created = await pb.collection('repairOrders').create(payload);
|
||||
try {
|
||||
await pb.collection('quotes').update(q.id, { repairOrderId: created.id });
|
||||
} catch { /* best-effort */ }
|
||||
|
||||
showToast(`Created RO #${roNumber} from quote.`, 'success');
|
||||
setRecentOpen(false);
|
||||
setRecentQuery('');
|
||||
navigate('/repair-orders');
|
||||
} catch (err) {
|
||||
logError('Failed to convert quote to repair order', err);
|
||||
showToast('Quote could not be converted to a repair order. Please try again.', 'error');
|
||||
} finally {
|
||||
setConvertingId(null);
|
||||
}
|
||||
};
|
||||
|
||||
// ── Edit quote loading ──
|
||||
useEffect(() => {
|
||||
if (!editId) return;
|
||||
const load = async () => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const record = await pb.collection('quotes').getOne(editId);
|
||||
const data = record as any;
|
||||
const ci = normalizeCustomerInfo(data);
|
||||
const recentQuoteState = (location.state as { recentQuote?: QuoteRecord } | null)?.recentQuote;
|
||||
const nextCustomerInfo: CustomerInfo = (() => {
|
||||
if (ci) {
|
||||
const parts = (ci.name || '').trim().split(/\s+/);
|
||||
return { ...ci, firstName: parts[0] || '', middleName: '', lastName: parts.slice(1).join(' ') || '', email: '', customerId: data.customerId || undefined, repairOrderId: data.repairOrderId || ci.repairOrderId || '' };
|
||||
}
|
||||
return toCustomerInfo({
|
||||
name: data.customerName || '',
|
||||
phone: data.customerPhone || '',
|
||||
vehicleInfo: data.vehicleInfo || '',
|
||||
vin: data.vin || '',
|
||||
mileage: data.mileage || '',
|
||||
roNumber: data.roNumber || data.repairOrderNumber || '',
|
||||
repairOrderId: data.repairOrderId || '',
|
||||
serviceAdvisor: data.serviceAdvisor || '',
|
||||
});
|
||||
})();
|
||||
if (!nextCustomerInfo.roNumber && recentQuoteState?.id === editId) {
|
||||
nextCustomerInfo.roNumber = recentQuoteState.roNumber || '';
|
||||
}
|
||||
const nextServices = (() => {
|
||||
if (!data.services) return [] as QuoteService[];
|
||||
try {
|
||||
const parsed = typeof data.services === 'string'
|
||||
? JSON.parse(data.services)
|
||||
: data.services;
|
||||
return Array.isArray(parsed) ? parsed as QuoteService[] : [];
|
||||
} catch {
|
||||
return [] as QuoteService[];
|
||||
}
|
||||
})();
|
||||
const nextDiscount = (() => {
|
||||
if (!data.discount && data.discountValue == null) return { value: 0, type: 'dollar' as const };
|
||||
try {
|
||||
const parsed = data.discount != null
|
||||
? (typeof data.discount === 'string' ? JSON.parse(data.discount) : data.discount)
|
||||
: { value: data.discountValue, type: data.discountType };
|
||||
return {
|
||||
value: Number(parsed?.value ?? data.discountValue) || 0,
|
||||
type: (parsed?.type ?? data.discountType) === 'percent' ? 'percent' as const : 'dollar' as const,
|
||||
};
|
||||
} catch {
|
||||
return { value: 0, type: 'dollar' as const };
|
||||
}
|
||||
})();
|
||||
|
||||
// Preserve the originating RO link so approved services can sync back
|
||||
if (data.repairOrderId) {
|
||||
setRepairOriginId(data.repairOrderId);
|
||||
}
|
||||
|
||||
useQuoteStore.setState({
|
||||
customerInfo: nextCustomerInfo,
|
||||
services: nextServices,
|
||||
discount: nextDiscount,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('Failed to load quote:', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
load();
|
||||
}, [editId, location.state]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-20">
|
||||
<div className="h-8 w-8 animate-spin rounded-full border-2 border-green-600 border-t-transparent" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-6xl">
|
||||
<div className="flex items-center justify-between mb-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">Quote Generator</h1>
|
||||
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">{editId ? 'Editing quote' : 'Create a new service quote'}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
{/* Recent Quotes */}
|
||||
<button
|
||||
onClick={handleRecentToggle}
|
||||
disabled={recentLoading && !recentOpen}
|
||||
className="flex items-center gap-1.5 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 px-3 py-2 text-xs font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
{recentLoading && !recentOpen ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <FileText className="h-3.5 w-3.5" />}
|
||||
Recent Quotes
|
||||
</button>
|
||||
|
||||
{/* Generate Priorities */}
|
||||
<button
|
||||
onClick={handleGeneratePriorities}
|
||||
disabled={prioritiesLoading}
|
||||
className="flex items-center gap-1.5 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 px-3 py-2 text-xs font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
{prioritiesLoading ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <ListOrdered className="h-3.5 w-3.5" />}
|
||||
Generate Priorities
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { reset(); window.history.replaceState({}, '', '/quote'); }}
|
||||
className="flex items-center gap-1.5 rounded-lg border border-red-200 dark:border-red-800 bg-red-50 dark:bg-red-900/20 px-3 py-2 text-xs font-medium text-red-700 dark:text-red-400 hover:bg-red-100 dark:hover:bg-red-900/30 transition-colors"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" /> Clear Quote
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
<CustomerInfoPanel />
|
||||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
|
||||
<div className="lg:col-span-2 space-y-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex-1">
|
||||
<ServiceSearch />
|
||||
</div>
|
||||
{/* AI Suggest */}
|
||||
<button
|
||||
onClick={handleAiSuggest}
|
||||
disabled={suggestLoading}
|
||||
className="shrink-0 flex items-center gap-1.5 rounded-lg border border-purple-300 dark:border-purple-700 bg-purple-50 dark:bg-purple-900/20 px-3 py-2.5 text-xs font-medium text-purple-700 dark:text-purple-400 hover:bg-purple-100 dark:hover:bg-purple-900/30 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
||||
>
|
||||
{suggestLoading ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Lightbulb className="h-3.5 w-3.5" />}
|
||||
AI Suggest
|
||||
</button>
|
||||
</div>
|
||||
<ServicesTable />
|
||||
</div>
|
||||
<div>
|
||||
<QuoteSummary editId={editId} repairOriginId={repairOriginId} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{recentOpen && (
|
||||
<div className="fixed inset-0 z-50">
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Close recent quotes panel"
|
||||
className="absolute inset-0 bg-gray-950/40 backdrop-blur-[1px]"
|
||||
onClick={() => setRecentOpen(false)}
|
||||
/>
|
||||
<div className="absolute inset-y-0 right-0 flex w-full justify-end">
|
||||
<div className="flex h-full w-full max-w-3xl flex-col overflow-hidden border-l border-gray-200 bg-white shadow-2xl dark:border-gray-700 dark:bg-gray-900 sm:w-[44rem]">
|
||||
<div className="border-b border-gray-200 px-4 py-4 dark:border-gray-700 sm:px-6">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">Recent Quotes</h2>
|
||||
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||
{recentCounts.all} total quotes{recentStatusFilter !== 'all' ? ` · ${recentCounts[recentStatusFilter]} ${recentStatusFilter}` : ''}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setRecentOpen(false)}
|
||||
className="rounded-lg p-2 text-gray-400 transition-colors hover:bg-gray-100 hover:text-gray-600 dark:hover:bg-gray-800 dark:hover:text-gray-200"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 space-y-3">
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-[minmax(0,1fr)_11rem]">
|
||||
<div className="relative">
|
||||
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-gray-400" />
|
||||
<input
|
||||
value={recentQuery}
|
||||
onChange={(e) => setRecentQuery(e.target.value)}
|
||||
placeholder="Search by customer, RO, or vehicle"
|
||||
className="w-full rounded-xl border border-gray-300 bg-white py-2.5 pl-9 pr-3 text-sm text-gray-900 placeholder-gray-400 focus:border-green-500 focus:outline-none focus:ring-2 focus:ring-green-500/20 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
<select
|
||||
value={recentSort}
|
||||
onChange={(e) => setRecentSort(e.target.value as RecentQuoteSort)}
|
||||
className="rounded-xl border border-gray-300 bg-white px-3 py-2.5 text-sm text-gray-900 focus:border-green-500 focus:outline-none focus:ring-2 focus:ring-green-500/20 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100"
|
||||
>
|
||||
<option value="newest">Newest</option>
|
||||
<option value="oldest">Oldest</option>
|
||||
<option value="highest-total">Highest Total</option>
|
||||
<option value="customer-asc">Customer A-Z</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{RECENT_QUOTE_FILTERS.map((filter) => (
|
||||
<button
|
||||
key={filter.value}
|
||||
type="button"
|
||||
onClick={() => setRecentStatusFilter(filter.value)}
|
||||
className={`rounded-full border px-3 py-1.5 text-xs font-medium transition-colors ${recentStatusFilter === filter.value
|
||||
? 'border-green-600 bg-green-600 text-white'
|
||||
: 'border-gray-300 bg-white text-gray-700 hover:bg-gray-50 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700'
|
||||
}`}
|
||||
>
|
||||
{filter.label} ({recentCounts[filter.value]})
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto px-4 py-4 dark:bg-gray-900 sm:px-6">
|
||||
{recentLoading ? (
|
||||
<div className="space-y-3">
|
||||
{Array.from({ length: 6 }).map((_, index) => (
|
||||
<div key={index} className="animate-pulse rounded-2xl border border-gray-200 bg-white p-4 dark:border-gray-700 dark:bg-gray-800/60">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="min-w-0 flex-1 space-y-2">
|
||||
<div className="h-4 w-40 rounded bg-gray-200 dark:bg-gray-700" />
|
||||
<div className="h-3 w-56 rounded bg-gray-100 dark:bg-gray-800" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<div className="h-4 w-20 rounded bg-gray-200 dark:bg-gray-700" />
|
||||
<div className="h-6 w-24 rounded-full bg-gray-100 dark:bg-gray-800" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 h-9 w-32 rounded-xl bg-gray-100 dark:bg-gray-800" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : filteredQuotes.length === 0 ? (
|
||||
<div className="flex h-full min-h-[18rem] flex-col items-center justify-center rounded-2xl border border-dashed border-gray-300 px-6 text-center dark:border-gray-700">
|
||||
<FileText className="h-8 w-8 text-gray-300 dark:text-gray-600" />
|
||||
<h3 className="mt-3 text-sm font-semibold text-gray-900 dark:text-white">
|
||||
{recentQuotes.length === 0 ? 'No recent quotes on file' : 'No quotes match your filters'}
|
||||
</h3>
|
||||
<p className="mt-1 max-w-sm text-sm text-gray-500 dark:text-gray-400">
|
||||
{recentQuotes.length === 0
|
||||
? 'Saved quotes will appear here for quick reopening, review, and conversion into active work.'
|
||||
: 'Adjust the search term, status filter, or sort option to broaden the results.'}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{filteredQuotes.map((quote) => (
|
||||
<div
|
||||
key={quote.id}
|
||||
className="rounded-2xl border border-gray-200 bg-white p-4 transition-colors hover:border-green-300 hover:bg-green-50/30 dark:border-gray-700 dark:bg-gray-800/60 dark:hover:border-green-700 dark:hover:bg-gray-800"
|
||||
onClick={() => { setRecentOpen(false); setRecentQuery(''); navigate(`/quote?edit=${quote.id}`, { state: { recentQuote: quote } }); }}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
setRecentOpen(false);
|
||||
setRecentQuery('');
|
||||
navigate(`/quote?edit=${quote.id}`, { state: { recentQuote: quote } });
|
||||
}
|
||||
}}
|
||||
>
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex flex-wrap items-center gap-x-3 gap-y-1">
|
||||
<span className="truncate text-base font-semibold text-gray-900 dark:text-white">
|
||||
{quote.customerName || 'Unknown customer'}
|
||||
</span>
|
||||
<span className="text-xs font-medium uppercase tracking-wide text-gray-400">
|
||||
{quote.roNumber ? `RO #${quote.roNumber}` : `Quote #${quote.id.slice(0, 6)}`}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-2 flex flex-wrap items-center gap-x-3 gap-y-1 text-sm text-gray-500 dark:text-gray-400">
|
||||
<span className="min-w-0 truncate">{quote.vehicleInfo || 'Vehicle not added'}</span>
|
||||
<span className="hidden text-gray-300 dark:text-gray-600 sm:inline">•</span>
|
||||
<span>{formatQuoteDate(recentQuoteDate(quote))}</span>
|
||||
</div>
|
||||
<div className="mt-1.5 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs font-medium">
|
||||
{quote.approvedTotal > 0 && (
|
||||
<span className="text-green-600 dark:text-green-400">Approved: {formatQuoteCurrency(quote.approvedTotal)}</span>
|
||||
)}
|
||||
{quote.declinedTotal > 0 && (
|
||||
<span className="text-red-500 dark:text-red-400">Declined: {formatQuoteCurrency(quote.declinedTotal)}</span>
|
||||
)}
|
||||
{quote.hasPending && quote.status === 'sent' && (
|
||||
<span className="text-yellow-600 dark:text-yellow-400">Pending</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start justify-between gap-3 sm:block sm:text-right">
|
||||
<div>
|
||||
<div className="text-base font-semibold text-gray-900 dark:text-white">
|
||||
{quote.total != null ? formatQuoteCurrency(quote.total) : '—'}
|
||||
</div>
|
||||
<div className="mt-2">
|
||||
<RecentStatusBadge status={quote.status} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex flex-wrap items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setRecentOpen(false);
|
||||
setRecentQuery('');
|
||||
navigate(`/quote?edit=${quote.id}`, { state: { recentQuote: quote } });
|
||||
}}
|
||||
className="rounded-xl bg-green-600 px-3 py-2 text-sm font-medium text-white transition-colors hover:bg-green-700"
|
||||
>
|
||||
Open Quote
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => { e.stopPropagation(); handleConvertQuoteToRO(quote); }}
|
||||
disabled={convertingId === quote.id}
|
||||
className="inline-flex items-center gap-2 rounded-xl border border-gray-300 bg-white px-3 py-2 text-sm font-medium text-gray-700 transition-colors hover:bg-gray-50 disabled:cursor-not-allowed disabled:opacity-50 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700"
|
||||
>
|
||||
{convertingId === quote.id
|
||||
? <Loader2 className="h-4 w-4 animate-spin" />
|
||||
: <Wrench className="h-4 w-4" />}
|
||||
Convert to RO
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={(e) => { e.stopPropagation(); setDeleteTarget(quote); }}
|
||||
className="rounded-xl border border-red-200 bg-white p-2 text-red-400 transition-colors hover:bg-red-50 hover:text-red-600 dark:border-red-900/40 dark:bg-gray-800 dark:text-red-400 dark:hover:bg-red-900/20 dark:hover:text-red-300"
|
||||
title="Delete quote"
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* AI Suggest Modal */}
|
||||
{showSuggestions && suggestions.length > 0 && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40" onClick={() => setShowSuggestions(false)}>
|
||||
<div
|
||||
className="relative w-full max-w-lg mx-4 rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900 shadow-2xl"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div className="flex items-center justify-between px-5 py-4 border-b border-gray-200 dark:border-gray-700">
|
||||
<h3 className="text-sm font-semibold text-gray-900 dark:text-white">AI Service Suggestions</h3>
|
||||
<button onClick={() => setShowSuggestions(false)} className="p-1 rounded-lg text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800">
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
<div className="px-5 py-3 max-h-80 overflow-y-auto space-y-2">
|
||||
{suggestions.map((sug) => (
|
||||
<div key={sug.id} className="rounded-lg border border-gray-200 dark:border-gray-700 p-3 hover:bg-gray-50 dark:hover:bg-gray-800/50 transition-colors">
|
||||
<div className="flex items-center justify-between mb-1">
|
||||
<span className="text-sm font-medium text-gray-900 dark:text-gray-100">{sug.name}</span>
|
||||
<span className="text-xs font-semibold text-green-600 dark:text-green-400">${sug.price.toFixed(2)}</span>
|
||||
</div>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400 mb-2">{sug.reason}</p>
|
||||
<button
|
||||
onClick={() => handleAddSuggestion(sug)}
|
||||
className="flex items-center gap-1 text-xs font-medium text-purple-600 dark:text-purple-400 hover:text-purple-700 dark:hover:text-purple-300"
|
||||
>
|
||||
<Plus className="h-3 w-3" /> Add to quote
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="px-5 py-3 border-t border-gray-200 dark:border-gray-700 text-center">
|
||||
<button
|
||||
onClick={() => setShowSuggestions(false)}
|
||||
className="text-xs text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{deleteTarget && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
|
||||
<div className="w-full max-w-md rounded-xl bg-white p-6 shadow-2xl dark:bg-gray-800">
|
||||
<div className="mb-4 flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-red-50 dark:bg-red-900/20">
|
||||
<AlertTriangle className="h-5 w-5 text-red-500" />
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
|
||||
Delete Quote
|
||||
</h3>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
Are you sure you want to delete the quote for{' '}
|
||||
<strong>{deleteTarget.customerName || 'Unknown customer'}</strong>? This action cannot be undone.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex justify-end gap-3">
|
||||
<button
|
||||
onClick={() => setDeleteTarget(null)}
|
||||
className="rounded-lg border border-gray-300 px-4 py-2 text-sm font-medium text-gray-700 transition-colors hover:bg-gray-50 dark:border-gray-600 dark:text-gray-300 dark:hover:bg-gray-700"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleDeleteQuote}
|
||||
disabled={deletingQuote}
|
||||
className="inline-flex items-center gap-2 rounded-lg bg-red-600 px-4 py-2 text-sm font-medium text-white shadow-sm transition-colors hover:bg-red-700 disabled:opacity-60"
|
||||
>
|
||||
{deletingQuote && <Loader2 className="h-4 w-4 animate-spin" />}
|
||||
{deletingQuote ? 'Deleting...' : 'Delete'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,943 @@
|
||||
import { useEffect, useState, useCallback, useRef, useMemo } from 'react';
|
||||
import { useNavigate, useLocation } from 'react-router-dom';
|
||||
import { pb } from '../lib/pocketbase';
|
||||
import {
|
||||
Search,
|
||||
X,
|
||||
Plus,
|
||||
Ban,
|
||||
UserCheck,
|
||||
Filter,
|
||||
MapPin,
|
||||
} from 'lucide-react';
|
||||
import type { RepairOrder, ROService, CustomerType } from '../types';
|
||||
import { repairOrderWriteSchema } from '../schemas/repairOrder';
|
||||
import {
|
||||
getStatusConfig,
|
||||
statusLabelForBusiness,
|
||||
} from '../lib/status';
|
||||
import { formatCurrency, formatDateTime } from '../lib/format';
|
||||
import {
|
||||
appendROEvent,
|
||||
isVoided,
|
||||
setVoided,
|
||||
} from '../lib/roEvents';
|
||||
import { useToast } from '../components/ui/Toast';
|
||||
import { getSetupRequiredMessage, isMissingCollectionError, logError } from '../lib/userMessages';
|
||||
import { useSettings } from '../store/settings';
|
||||
import type { TechnicianAssignment, TechnicianUser } from '../lib/technicianData';
|
||||
import { fetchAllAssignmentsForShop, fetchTechnicianUsers, syncAssignmentsForROServices } from '../lib/technicianData';
|
||||
import { usePagedList } from '../hooks/usePagedList';
|
||||
|
||||
// Extracted components from the barrel
|
||||
import {
|
||||
type TabId,
|
||||
type CompletedRangeFilter,
|
||||
type CompletedStatusFilter,
|
||||
type CompletedRatingFilter,
|
||||
type SavedView,
|
||||
TABS,
|
||||
loadSavedViews,
|
||||
persistSavedViews,
|
||||
TabButton,
|
||||
LoadingSkeleton,
|
||||
EmptyState,
|
||||
ErrorState,
|
||||
ROModal,
|
||||
FragmentRow,
|
||||
CompletedRow,
|
||||
StatusBadge,
|
||||
CustomerTypeBadge,
|
||||
TimeRemainingCell,
|
||||
RowProgressBar,
|
||||
} from '../components/repairOrders';
|
||||
|
||||
// Pure helpers
|
||||
import {
|
||||
normalizeRO,
|
||||
sortOrders,
|
||||
sortCompletedOrders,
|
||||
getUrgencyClass,
|
||||
displayCustomerName,
|
||||
isWithinCompletedRange,
|
||||
formatDueDateTime,
|
||||
getCompletedMetricDate,
|
||||
getStatusChips,
|
||||
ratingLabel,
|
||||
} from '../lib/repairOrders';
|
||||
|
||||
/* ── Main Repair Orders Component ───────────────────── */
|
||||
|
||||
export default function RepairOrders() {
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const { showToast } = useToast();
|
||||
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [activeTab, setActiveTab] = useState<TabId>('active');
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const [editRO, setEditRO] = useState<RepairOrder | null>(null);
|
||||
const [now, setNow] = useState(Date.now());
|
||||
const tickRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||
const [techAssignments, setTechAssignments] = useState<TechnicianAssignment[]>([]);
|
||||
const [techUsers, setTechUsers] = useState<TechnicianUser[]>([]);
|
||||
const [customers, setCustomers] = useState<import('./Customers').CustomerWithVehicles[]>([]);
|
||||
|
||||
// ── List-page filter state ──
|
||||
const [statusChips, setStatusChips] = useState<string[]>([]);
|
||||
const [waiterOnly, setWaiterOnly] = useState(false);
|
||||
const [showVoided, setShowVoided] = useState(false);
|
||||
const [completedRangeFilter, setCompletedRangeFilter] = useState<CompletedRangeFilter>('all');
|
||||
const [completedStatusFilter, setCompletedStatusFilter] = useState<CompletedStatusFilter>('all');
|
||||
const [completedRatingFilter, setCompletedRatingFilter] = useState<CompletedRatingFilter>('all');
|
||||
|
||||
// Saved views loaded once on mount.
|
||||
const [savedViews, setSavedViews] = useState<SavedView[]>(() => loadSavedViews());
|
||||
const [savedViewName, setSavedViewName] = useState('');
|
||||
|
||||
// ── Bulk selection ──
|
||||
const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
|
||||
|
||||
const settings = useSettings();
|
||||
const statusConfig = useMemo(() => getStatusConfig(settings), [settings]);
|
||||
const statusChipOptions = useMemo(() => getStatusChips(settings), [settings]);
|
||||
|
||||
const userId = pb.authStore.model?.id;
|
||||
|
||||
const fetchOrdersPage = useCallback(async (_page: number, perPage: number) => {
|
||||
if (!userId) return { items: [], page: 1, perPage, totalItems: 0, totalPages: 1 };
|
||||
const records = await pb.collection('repairOrders').getFullList({
|
||||
filter: `userId = '${userId}'`,
|
||||
sort: '-id',
|
||||
});
|
||||
return {
|
||||
items: (records as any[]).map(normalizeRO),
|
||||
page: 1,
|
||||
perPage: 999999,
|
||||
totalItems: records.length,
|
||||
totalPages: 1,
|
||||
};
|
||||
}, [userId]);
|
||||
|
||||
const {
|
||||
items: orders,
|
||||
setItems: setOrders,
|
||||
loading,
|
||||
hasMore,
|
||||
loadMore,
|
||||
refresh: refreshOrders,
|
||||
adjustTotal: adjustOrderTotal,
|
||||
replaceItem: replaceOrderItem,
|
||||
removeItem: removeOrderItem,
|
||||
} = usePagedList<RepairOrder>(fetchOrdersPage, 50);
|
||||
|
||||
const existingRoNumbers = useMemo(
|
||||
() => new Set(orders.map((o) => o.roNumber).filter(Boolean) as string[]),
|
||||
[orders]
|
||||
);
|
||||
|
||||
const ordersRef = useRef<RepairOrder[]>([]);
|
||||
ordersRef.current = orders;
|
||||
|
||||
const fetchTechAssignments = useCallback(async () => {
|
||||
if (!userId) return;
|
||||
const [assignments, users] = await Promise.all([
|
||||
fetchAllAssignmentsForShop(userId),
|
||||
fetchTechnicianUsers(userId),
|
||||
]);
|
||||
setTechAssignments(assignments);
|
||||
setTechUsers(users);
|
||||
}, [userId]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchTechAssignments();
|
||||
}, [fetchTechAssignments]);
|
||||
|
||||
const fetchCustomers = useCallback(async () => {
|
||||
if (!userId) return;
|
||||
try {
|
||||
const result = await pb.collection('customers').getList(1, 500, {
|
||||
filter: `userId = '${userId}'`,
|
||||
sort: 'name',
|
||||
fields: 'id,name,phone,email,address,notes,userId,created,updated',
|
||||
});
|
||||
const list = result.items as unknown as import('./Customers').CustomerRecord[];
|
||||
const enriched: import('./Customers').CustomerWithVehicles[] = list.map((c) => ({ ...c, vehicles: [], vehicleCount: 0 }));
|
||||
setCustomers(enriched);
|
||||
} catch { /* non-critical */ }
|
||||
}, [userId]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchCustomers();
|
||||
}, [fetchCustomers]);
|
||||
|
||||
const techAssignmentMap = useMemo(() => {
|
||||
const map = new Map<string, TechnicianAssignment[]>();
|
||||
for (const a of techAssignments) {
|
||||
const existing = map.get(a.repairOrderId) ?? [];
|
||||
existing.push(a);
|
||||
map.set(a.repairOrderId, existing);
|
||||
}
|
||||
return map;
|
||||
}, [techAssignments]);
|
||||
|
||||
const techNameById = useMemo(() => {
|
||||
const map = new Map<string, string>();
|
||||
for (const tech of techUsers) {
|
||||
map.set(tech.id, tech.displayName || tech.email || 'Technician');
|
||||
}
|
||||
return map;
|
||||
}, [techUsers]);
|
||||
|
||||
const techNamesByRO = useMemo(() => {
|
||||
const map = new Map<string, string[]>();
|
||||
for (const [roId, assignments] of techAssignmentMap.entries()) {
|
||||
const names = assignments.map((a) => techNameById.get(a.technicianUserId) || 'Technician');
|
||||
map.set(roId, Array.from(new Set(names)));
|
||||
}
|
||||
return map;
|
||||
}, [techAssignmentMap, techNameById]);
|
||||
|
||||
// ── PocketBase realtime subscription ──────────────────────────────────────
|
||||
useEffect(() => {
|
||||
if (!userId) return;
|
||||
let unsubRO: (() => Promise<void>) | null = null;
|
||||
let unsubTA: (() => Promise<void>) | null = null;
|
||||
let cancelled = false;
|
||||
(async () => {
|
||||
try {
|
||||
unsubRO = await pb.collection('repairOrders').subscribe('*', (data: any) => {
|
||||
if (cancelled) return;
|
||||
const action = data.action;
|
||||
const record = data.record;
|
||||
if (!record) return;
|
||||
if (record.userId && record.userId !== userId) return;
|
||||
if (action === 'delete') {
|
||||
removeOrderItem(record.id, (o) => o.id);
|
||||
adjustOrderTotal(-1);
|
||||
return;
|
||||
}
|
||||
const norm = normalizeRO(record) as unknown as RepairOrder;
|
||||
replaceOrderItem(record.id, norm, (o) => o.id);
|
||||
// If it's a new insert (didn't exist in window), prepend
|
||||
setOrders((prev) => {
|
||||
if (prev.some((o) => o.id === record.id)) return prev;
|
||||
adjustOrderTotal(1);
|
||||
return [norm, ...prev];
|
||||
});
|
||||
});
|
||||
} catch (e) {
|
||||
console.warn('RO realtime subscribe failed, polling every 30s', e);
|
||||
}
|
||||
})();
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
unsubTA = await pb.collection('technicianAssignments').subscribe('*', () => {
|
||||
if (!cancelled) fetchTechAssignments();
|
||||
});
|
||||
} catch {
|
||||
// non-critical
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
if (unsubRO) unsubRO().catch(() => {});
|
||||
if (unsubTA) unsubTA().catch(() => {});
|
||||
};
|
||||
}, [userId, fetchTechAssignments, removeOrderItem, adjustOrderTotal, replaceOrderItem, setOrders]);
|
||||
|
||||
// Live countdown tick
|
||||
useEffect(() => {
|
||||
const tick = () => {
|
||||
const ts = Date.now();
|
||||
const minuteBucket = Math.floor(ts / 60000);
|
||||
setNow((prev) => (Math.floor(prev / 60000) === minuteBucket ? prev : ts));
|
||||
};
|
||||
tickRef.current = setInterval(tick, 30000);
|
||||
return () => { if (tickRef.current) clearInterval(tickRef.current); };
|
||||
}, []);
|
||||
|
||||
/* ── Customer Type Toggle (list row badge) ────────────────────────────── */
|
||||
|
||||
const handleToggleCustomerType = useCallback(
|
||||
(id: string, currentType?: CustomerType) => {
|
||||
const newType: CustomerType = currentType === 'waiter' ? 'drop-off' : 'waiter';
|
||||
const confirmed = window.confirm(
|
||||
`Are you sure you want to change this customer to "${newType === 'waiter' ? 'Waiter' : 'Drop-Off'}"?`
|
||||
);
|
||||
if (!confirmed) return;
|
||||
|
||||
setOrders((prev) =>
|
||||
prev.map((o) => (o.id === id ? { ...o, customerType: newType } : o))
|
||||
);
|
||||
|
||||
const ro = ordersRef.current.find((o) => o.id === id);
|
||||
let financial: Record<string, any> = {};
|
||||
if (ro?.financial) {
|
||||
try { financial = typeof ro.financial === 'string' ? JSON.parse(ro.financial) : ro.financial; } catch (err) { logError('Failed to parse RO financial blob', err); }
|
||||
}
|
||||
financial.customerType = newType;
|
||||
pb.collection('repairOrders')
|
||||
.update(id, { financial: JSON.stringify(financial), customerType: newType })
|
||||
.catch(() => {
|
||||
setOrders((prev) =>
|
||||
prev.map((o) => (o.id === id ? { ...o, customerType: currentType } : o))
|
||||
);
|
||||
});
|
||||
},
|
||||
[setOrders]
|
||||
);
|
||||
|
||||
/* ── Filter & sort ───────────────────────────────────────────────────── */
|
||||
|
||||
const filteredOrders = useMemo(() => {
|
||||
const filtered = orders.filter((ro) => {
|
||||
const voided = isVoided(ro);
|
||||
if (voided && !showVoided) return false;
|
||||
if (!voided && showVoided) return false;
|
||||
|
||||
if (statusChips.length > 0 && !statusChips.includes(ro.status || '')) return false;
|
||||
|
||||
if (waiterOnly && ro.customerType !== 'waiter') return false;
|
||||
|
||||
if (activeTab === 'active') {
|
||||
if (ro.status === 'completed' || ro.status === 'final_close' || ro.status === 'delivered') return false;
|
||||
} else if (activeTab === 'completed') {
|
||||
if (ro.status !== 'completed' && ro.status !== 'final_close' && ro.status !== 'delivered') return false;
|
||||
if (completedStatusFilter !== 'all' && ro.status !== completedStatusFilter) return false;
|
||||
if (!isWithinCompletedRange(ro, completedRangeFilter)) return false;
|
||||
if (completedRatingFilter === 'rated' && !ro.satisfaction) return false;
|
||||
if (completedRatingFilter === 'unrated' && ro.satisfaction) return false;
|
||||
}
|
||||
|
||||
if (searchQuery.trim()) {
|
||||
const q = searchQuery.toLowerCase();
|
||||
const name = (ro.customerName || '').toLowerCase();
|
||||
const roNum = (ro.roNumber || '').toLowerCase();
|
||||
const vehicle = (ro.vehicleInfo || '').toLowerCase();
|
||||
const vin = (ro.vin || '').toLowerCase();
|
||||
const advisor = (ro.advisorName || '').toLowerCase();
|
||||
const location = (ro.serviceLocation || '').toLowerCase();
|
||||
const tripMiles = ro.tripMiles ? String(ro.tripMiles) : '';
|
||||
if (
|
||||
!name.includes(q) && !roNum.includes(q) && !vehicle.includes(q) &&
|
||||
!vin.includes(q) && !advisor.includes(q) && !location.includes(q) &&
|
||||
!tripMiles.includes(q)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
});
|
||||
return activeTab === 'completed' ? sortCompletedOrders(filtered) : sortOrders(filtered);
|
||||
}, [orders, activeTab, searchQuery, statusChips, waiterOnly, showVoided, completedRangeFilter, completedStatusFilter, completedRatingFilter]);
|
||||
|
||||
const activeCount = orders.filter(
|
||||
(ro) => !isVoided(ro) && ro.status !== 'completed' && ro.status !== 'final_close' && ro.status !== 'delivered'
|
||||
).length;
|
||||
const completedCount = orders.filter(
|
||||
(ro) => !isVoided(ro) && (ro.status === 'completed' || ro.status === 'final_close' || ro.status === 'delivered')
|
||||
).length;
|
||||
|
||||
const completedSummary = useMemo(() => {
|
||||
const completedOrders = orders.filter(
|
||||
(ro) => !isVoided(ro) && (ro.status === 'completed' || ro.status === 'final_close' || ro.status === 'delivered')
|
||||
);
|
||||
const rated = completedOrders.filter((ro) => ro.satisfaction);
|
||||
return {
|
||||
total: completedOrders.length,
|
||||
today: completedOrders.filter((ro) => isWithinCompletedRange(ro, 'today')).length,
|
||||
finalClose: completedOrders.filter((ro) => ro.status === 'final_close' || ro.status === 'delivered').length,
|
||||
avgRating: rated.length > 0
|
||||
? rated.reduce((sum, ro) => sum + (ro.satisfaction || 0), 0) / rated.length
|
||||
: 0,
|
||||
};
|
||||
}, [orders]);
|
||||
|
||||
/* ── CRUD handlers ───────────────────────────────────────────────────── */
|
||||
|
||||
const handleSave = async (data: Partial<RepairOrder>) => {
|
||||
const parsed = repairOrderWriteSchema.safeParse(data);
|
||||
if (!parsed.success) {
|
||||
showToast(parsed.error.issues[0].message, 'error');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const { estimatedDuration, customerType, ...rest } = data;
|
||||
const payload: Record<string, any> = {
|
||||
...rest,
|
||||
userId,
|
||||
services: JSON.stringify(data.services || []),
|
||||
estimatedTime: estimatedDuration != null ? (estimatedDuration / 60).toFixed(1) : '',
|
||||
};
|
||||
if (customerType) {
|
||||
let existingBlob: Record<string, any> = {};
|
||||
if (editRO) {
|
||||
const existingFin = editRO.financial;
|
||||
if (typeof existingFin === 'string') {
|
||||
try { existingBlob = JSON.parse(existingFin); } catch (err) { logError('Failed to parse existing financial blob', err); }
|
||||
} else if (typeof existingFin === 'object' && existingFin) {
|
||||
existingBlob = existingFin as Record<string, any>;
|
||||
}
|
||||
}
|
||||
payload.financial = JSON.stringify({ ...existingBlob, customerType });
|
||||
}
|
||||
|
||||
if (editRO) {
|
||||
await pb.collection('repairOrders').update(editRO.id, payload);
|
||||
await syncAssignmentsForROServices(editRO.id, (data.services || []) as ROService[]);
|
||||
showToast('Repair order updated.', 'success');
|
||||
} else {
|
||||
const created = await pb.collection('repairOrders').create(payload);
|
||||
if (created?.id) {
|
||||
try { await appendROEvent(created.id, 'created', {}); } catch (err) { logError('Failed to append RO created event', err); showToast('Saved, but the timeline event could not be recorded.', 'error'); }
|
||||
}
|
||||
showToast('Repair order created.', 'success');
|
||||
}
|
||||
|
||||
refreshOrders();
|
||||
} catch (err) {
|
||||
logError('Failed to save repair order', err);
|
||||
if (isMissingCollectionError(err)) {
|
||||
throw new Error(getSetupRequiredMessage());
|
||||
}
|
||||
throw new Error('Repair order could not be saved. Please try again.');
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpenCreate = () => {
|
||||
setEditRO(null);
|
||||
setShowModal(true);
|
||||
};
|
||||
|
||||
const handleStatusChange = async (id: string, status: RepairOrder['status']) => {
|
||||
const prev = ordersRef.current.find((o) => o.id === id);
|
||||
if (!prev) return;
|
||||
|
||||
const prevStatus = prev.status;
|
||||
const prevServices = prev.services || [];
|
||||
const prevCompletedTime = prev.completedTime || prev.completedDate;
|
||||
|
||||
let updatedServices: ROService[] | undefined;
|
||||
let updatedCompletedTime: string | undefined;
|
||||
|
||||
if (status === 'completed') {
|
||||
updatedCompletedTime = new Date().toISOString();
|
||||
const allDone = prevServices.every((s: ROService) => s.status === 'completed');
|
||||
if (!allDone) {
|
||||
updatedServices = prevServices.map((s: ROService) => ({ ...s, status: 'completed' as const })) as ROService[];
|
||||
}
|
||||
} else if (status === 'waiting_pickup' || status === 'active' || status === 'in_progress' || status === 'waiting_parts') {
|
||||
updatedCompletedTime = '';
|
||||
}
|
||||
|
||||
// Optimistic local update
|
||||
setOrders((prevOrders) =>
|
||||
prevOrders.map((o) => {
|
||||
if (o.id !== id) return o;
|
||||
return { ...o, status, completedTime: updatedCompletedTime, completedDate: updatedCompletedTime, services: updatedServices ?? o.services };
|
||||
})
|
||||
);
|
||||
|
||||
const update: Partial<RepairOrder> = { status };
|
||||
if (updatedCompletedTime !== undefined) update.completedTime = updatedCompletedTime;
|
||||
if (updatedServices) update.services = updatedServices;
|
||||
|
||||
const linkedAppointmentStatus =
|
||||
status === 'completed' || status === 'final_close' || status === 'delivered'
|
||||
? 'completed'
|
||||
: 'checked_in';
|
||||
|
||||
try {
|
||||
await pb.collection('repairOrders').update(id, update);
|
||||
try {
|
||||
const linkedAppointment = await pb.collection('appointments').getFirstListItem(
|
||||
`repairOrderId = '${id}' && userId = '${userId}'`
|
||||
);
|
||||
if (linkedAppointment?.status !== linkedAppointmentStatus) {
|
||||
await pb.collection('appointments').update(linkedAppointment.id, { status: linkedAppointmentStatus });
|
||||
}
|
||||
} catch {
|
||||
// Not every RO originates from an appointment.
|
||||
}
|
||||
try { await appendROEvent(id, 'status', { from: prevStatus, to: status }); } catch (err) { logError('Failed to append RO status event', err); showToast('Saved, but the timeline event could not be recorded.', 'error'); }
|
||||
} catch (err) {
|
||||
// Rollback on failure
|
||||
setOrders((prevOrders) =>
|
||||
prevOrders.map((o) => {
|
||||
if (o.id !== id) return o;
|
||||
return { ...o, status: prevStatus, completedTime: prevCompletedTime, completedDate: prevCompletedTime, services: prevServices as ROService[] };
|
||||
})
|
||||
);
|
||||
logError('Failed to update repair order status', err);
|
||||
if (isMissingCollectionError(err)) {
|
||||
setError(getSetupRequiredMessage());
|
||||
} else {
|
||||
showToast('Repair order status could not be updated. Please try again.', 'error');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
/* ── Bulk selection helpers ──────────────────────────────────────────── */
|
||||
|
||||
const toggleSelect = (id: string) => {
|
||||
setSelectedIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
const selectAllVisible = () => setSelectedIds(new Set(filteredOrders.map((o) => o.id)));
|
||||
const clearSelection = () => setSelectedIds(new Set());
|
||||
|
||||
const bulkStatusChange = async (status: RepairOrder['status']) => {
|
||||
const ids = Array.from(selectedIds);
|
||||
showToast(`Updating ${ids.length} RO(s) to ${statusLabelForBusiness(status, settings)}…`, 'info');
|
||||
let failed = 0;
|
||||
for (const id of ids) {
|
||||
try {
|
||||
await handleStatusChange(id, status);
|
||||
} catch { failed++; }
|
||||
}
|
||||
if (failed === 0) showToast(`Marked ${ids.length} RO(s) as ${statusLabelForBusiness(status, settings)}.`, 'success');
|
||||
else showToast(`${ids.length - failed} updated, ${failed} failed.`, 'error');
|
||||
clearSelection();
|
||||
};
|
||||
|
||||
const bulkVoid = async () => {
|
||||
if (!window.confirm(`Void ${selectedIds.size} selected RO(s)? This is reversible from each RO's Details page.`)) return;
|
||||
const ids = Array.from(selectedIds);
|
||||
for (const id of ids) { try { await setVoided(id, true); } catch {} }
|
||||
refreshOrders();
|
||||
clearSelection();
|
||||
showToast(`Voided ${ids.length} RO(s).`, 'success');
|
||||
};
|
||||
|
||||
/* ── Saved-view helpers ──────────────────────────────────────────────── */
|
||||
|
||||
const saveCurrentView = () => {
|
||||
const name = savedViewName.trim();
|
||||
if (!name) { showToast('Enter a name for the saved view first.', 'error'); return; }
|
||||
const view: SavedView = {
|
||||
id: `${Date.now()}-${Math.random().toString(36).slice(2, 6)}`,
|
||||
label: name,
|
||||
statusChips: [...statusChips],
|
||||
waiterOnly,
|
||||
showVoided,
|
||||
search: searchQuery,
|
||||
};
|
||||
const next = [...savedViews.filter((v) => v.label !== name), view];
|
||||
setSavedViews(next);
|
||||
persistSavedViews(next);
|
||||
setSavedViewName('');
|
||||
showToast(`Saved view "${name}".`, 'success');
|
||||
};
|
||||
const applySavedView = (v: SavedView) => {
|
||||
setStatusChips(v.statusChips);
|
||||
setWaiterOnly(v.waiterOnly);
|
||||
setShowVoided(v.showVoided);
|
||||
setSearchQuery(v.search);
|
||||
showToast(`Loaded view "${v.label}".`, 'info');
|
||||
};
|
||||
const deleteSavedView = (id: string) => {
|
||||
const next = savedViews.filter((v) => v.id !== id);
|
||||
setSavedViews(next);
|
||||
persistSavedViews(next);
|
||||
};
|
||||
|
||||
const toggleStatusChip = (value: string) => {
|
||||
setStatusChips((prev) =>
|
||||
prev.includes(value) ? prev.filter((v) => v !== value) : [...prev, value]
|
||||
);
|
||||
};
|
||||
|
||||
/* ── Render: List ────────────────────────────────────────────────────── */
|
||||
|
||||
const mainContent = (
|
||||
<>
|
||||
{/* Tabs + Search + Create */}
|
||||
<div className="mb-4 flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="flex items-center gap-2">
|
||||
{TABS.map((tab) => (
|
||||
<TabButton
|
||||
key={tab.id}
|
||||
tab={tab.id}
|
||||
active={activeTab === tab.id}
|
||||
count={
|
||||
tab.id === 'active' ? activeCount
|
||||
: tab.id === 'completed' ? completedCount
|
||||
: undefined
|
||||
}
|
||||
onClick={() => { setActiveTab(tab.id); clearSelection(); }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="relative">
|
||||
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-gray-400" />
|
||||
<input
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
placeholder="Search name, RO#, vehicle, VIN, advisor, location..."
|
||||
className="w-64 rounded-lg border border-gray-300 bg-white py-2 pl-10 pr-4 text-sm dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100 dark:placeholder-gray-400"
|
||||
/>
|
||||
{searchQuery && (
|
||||
<button
|
||||
onClick={() => setSearchQuery('')}
|
||||
className="absolute right-2 top-1/2 -translate-y-1/2 rounded p-0.5 text-gray-400 hover:text-gray-600"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<button
|
||||
onClick={handleOpenCreate}
|
||||
className="inline-flex items-center gap-2 rounded-lg bg-blue-600 px-4 py-2 text-sm font-medium text-white shadow-sm transition-colors hover:bg-blue-700"
|
||||
>
|
||||
<Plus className="h-4 w-4" />
|
||||
New RO
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Filter bar */}
|
||||
<div className="mb-4 flex flex-wrap items-center gap-2 rounded-lg border border-gray-200 bg-white p-3 text-sm dark:border-gray-700 dark:bg-gray-800">
|
||||
<Filter className="h-4 w-4 text-gray-400" />
|
||||
{statusChipOptions.map((chip) => {
|
||||
const active = statusChips.includes(chip.value);
|
||||
return (
|
||||
<button
|
||||
key={chip.value}
|
||||
onClick={() => toggleStatusChip(chip.value)}
|
||||
className={`rounded-full px-2.5 py-1 text-xs font-medium transition ${
|
||||
active
|
||||
? 'ring-2 ' + chip.colors
|
||||
: 'bg-gray-100 text-gray-500 hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-400'
|
||||
}`}
|
||||
>
|
||||
{chip.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
<span className="mx-1 h-4 w-px bg-gray-200 dark:bg-gray-700" />
|
||||
<label className="inline-flex cursor-pointer items-center gap-1.5 text-xs text-gray-600 dark:text-gray-400">
|
||||
<input type="checkbox" checked={waiterOnly} onChange={(e) => setWaiterOnly(e.target.checked)} className="rounded border-gray-300 text-purple-600 focus:ring-purple-500" />
|
||||
<UserCheck className="h-3.5 w-3.5 text-purple-500" /> Waiters only
|
||||
</label>
|
||||
<label className="inline-flex cursor-pointer items-center gap-1.5 text-xs text-gray-600 dark:text-gray-400">
|
||||
<input type="checkbox" checked={showVoided} onChange={(e) => setShowVoided(e.target.checked)} className="rounded border-gray-300 text-red-600 focus:ring-red-500" />
|
||||
<Ban className="h-3.5 w-3.5 text-red-500" /> Show voided
|
||||
</label>
|
||||
<span className="mx-1 h-4 w-px bg-gray-200 dark:bg-gray-700" />
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400">Views:</span>
|
||||
{savedViews.map((v) => (
|
||||
<span key={v.id} className="inline-flex items-center gap-1 rounded-full bg-blue-50 px-2 py-0.5 text-xs text-blue-700 dark:bg-blue-900/30 dark:text-blue-300">
|
||||
<button onClick={() => applySavedView(v)} className="hover:underline">{v.label}</button>
|
||||
<button onClick={() => deleteSavedView(v.id)} className="text-blue-400 hover:text-red-500" title="Delete saved view">×</button>
|
||||
</span>
|
||||
))}
|
||||
<input
|
||||
value={savedViewName}
|
||||
onChange={(e) => setSavedViewName(e.target.value)}
|
||||
placeholder="Save as…"
|
||||
className="w-24 rounded border border-gray-300 bg-white px-2 py-1 text-xs dark:border-gray-600 dark:bg-gray-700 dark:text-gray-100"
|
||||
/>
|
||||
<button
|
||||
onClick={saveCurrentView}
|
||||
className="rounded border border-gray-300 bg-white px-2 py-1 text-xs text-gray-700 hover:bg-gray-50 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-300"
|
||||
>Save</button>
|
||||
</div>
|
||||
|
||||
{activeTab === 'completed' && (
|
||||
<div className="mb-4 space-y-4">
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-4">
|
||||
<div className="rounded-xl bg-white p-4 ring-1 ring-gray-200 dark:bg-gray-800 dark:ring-gray-700">
|
||||
<p className="text-xs font-medium text-gray-500 dark:text-gray-400">Completed/Final Close</p>
|
||||
<p className="mt-1 text-2xl font-bold text-gray-900 dark:text-white">{completedSummary.total}</p>
|
||||
</div>
|
||||
<div className="rounded-xl bg-white p-4 ring-1 ring-gray-200 dark:bg-gray-800 dark:ring-gray-700">
|
||||
<p className="text-xs font-medium text-gray-500 dark:text-gray-400">Completed Today</p>
|
||||
<p className="mt-1 text-2xl font-bold text-green-600 dark:text-green-400">{completedSummary.today}</p>
|
||||
</div>
|
||||
<div className="rounded-xl bg-white p-4 ring-1 ring-gray-200 dark:bg-gray-800 dark:ring-gray-700">
|
||||
<p className="text-xs font-medium text-gray-500 dark:text-gray-400">Final Close</p>
|
||||
<p className="mt-1 text-2xl font-bold text-gray-900 dark:text-white">{completedSummary.finalClose}</p>
|
||||
</div>
|
||||
<div className="rounded-xl bg-white p-4 ring-1 ring-gray-200 dark:bg-gray-800 dark:ring-gray-700">
|
||||
<p className="text-xs font-medium text-gray-500 dark:text-gray-400">Avg Rating</p>
|
||||
<p className="mt-1 text-2xl font-bold text-yellow-500">
|
||||
{completedSummary.avgRating > 0 ? completedSummary.avgRating.toFixed(1) : '—'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-2 rounded-lg border border-gray-200 bg-white p-3 text-sm dark:border-gray-700 dark:bg-gray-800">
|
||||
<span className="text-xs font-medium text-gray-500 dark:text-gray-400">Completed filters:</span>
|
||||
{([
|
||||
['all', 'All'],
|
||||
['today', 'Today'],
|
||||
['this_week', 'This Week'],
|
||||
['this_month', 'This Month'],
|
||||
] as [CompletedRangeFilter, string][]).map(([value, label]) => (
|
||||
<button
|
||||
key={value}
|
||||
onClick={() => setCompletedRangeFilter(value)}
|
||||
className={`rounded-full px-2.5 py-1 text-xs font-medium transition ${
|
||||
completedRangeFilter === value
|
||||
? 'bg-blue-100 text-blue-800 ring-2 ring-blue-300 dark:bg-blue-900/40 dark:text-blue-300 dark:ring-blue-700'
|
||||
: 'bg-gray-100 text-gray-500 hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-400'
|
||||
}`}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
<span className="mx-1 h-4 w-px bg-gray-200 dark:bg-gray-700" />
|
||||
{([
|
||||
['all', 'Any Status'],
|
||||
['completed', 'Completed'],
|
||||
['final_close', 'Final Close'],
|
||||
] as [CompletedStatusFilter, string][]).map(([value, label]) => (
|
||||
<button
|
||||
key={value}
|
||||
onClick={() => setCompletedStatusFilter(value)}
|
||||
className={`rounded-full px-2.5 py-1 text-xs font-medium transition ${
|
||||
completedStatusFilter === value
|
||||
? 'bg-green-100 text-green-800 ring-2 ring-green-300 dark:bg-green-900/40 dark:text-green-300 dark:ring-green-700'
|
||||
: 'bg-gray-100 text-gray-500 hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-400'
|
||||
}`}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
<span className="mx-1 h-4 w-px bg-gray-200 dark:bg-gray-700" />
|
||||
{([
|
||||
['all', 'Any Rating'],
|
||||
['rated', 'Rated'],
|
||||
['unrated', 'Unrated'],
|
||||
] as [CompletedRatingFilter, string][]).map(([value, label]) => (
|
||||
<button
|
||||
key={value}
|
||||
onClick={() => setCompletedRatingFilter(value)}
|
||||
className={`rounded-full px-2.5 py-1 text-xs font-medium transition ${
|
||||
completedRatingFilter === value
|
||||
? 'bg-yellow-100 text-yellow-800 ring-2 ring-yellow-300 dark:bg-yellow-900/40 dark:text-yellow-300 dark:ring-yellow-700'
|
||||
: 'bg-gray-100 text-gray-500 hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-400'
|
||||
}`}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Bulk action bar */}
|
||||
{selectedIds.size > 0 && (
|
||||
<div className="sticky top-0 z-20 mb-3 flex flex-wrap items-center gap-2 rounded-lg border border-blue-300 bg-blue-50 p-3 text-sm shadow-sm dark:border-blue-700 dark:bg-blue-900/30">
|
||||
<span className="font-medium text-blue-700 dark:text-blue-300">{selectedIds.size} selected</span>
|
||||
<button onClick={selectAllVisible} className="rounded border border-blue-300 bg-white px-2 py-1 text-xs text-blue-700 hover:bg-blue-100 dark:border-blue-700 dark:bg-blue-900/30 dark:text-blue-300">Select all visible</button>
|
||||
<button onClick={clearSelection} className="rounded border border-gray-300 bg-white px-2 py-1 text-xs text-gray-700 hover:bg-gray-50 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-300">Clear</button>
|
||||
<span className="mx-1 h-4 w-px bg-blue-200 dark:bg-blue-800" />
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400">Bulk:</span>
|
||||
{activeTab !== 'completed' && (
|
||||
<button onClick={() => bulkStatusChange('completed')} className="rounded border border-green-300 bg-white px-2 py-1 text-xs text-green-700 hover:bg-green-50 dark:border-green-700 dark:bg-green-900/20 dark:text-green-300">Mark Complete</button>
|
||||
)}
|
||||
<button onClick={() => bulkStatusChange('final_close')} className="rounded border border-gray-300 bg-white px-2 py-1 text-xs text-gray-700 hover:bg-gray-50 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-300">Final Close</button>
|
||||
<button onClick={() => bulkStatusChange('active')} className="rounded border border-blue-300 bg-white px-2 py-1 text-xs text-blue-700 hover:bg-blue-50 dark:border-blue-700 dark:bg-blue-900/20 dark:text-blue-300">Reopen</button>
|
||||
<button onClick={bulkVoid} className="inline-flex items-center gap-1 rounded border border-red-300 bg-white px-2 py-1 text-xs text-red-700 hover:bg-red-50 dark:border-red-700 dark:bg-red-900/20 dark:text-red-300">
|
||||
<Ban className="h-3 w-3" /> Void
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading && <LoadingSkeleton />}
|
||||
{!loading && error && <ErrorState message={error} onRetry={refreshOrders} />}
|
||||
|
||||
{!loading && !error && (
|
||||
<>
|
||||
{filteredOrders.length === 0 ? (
|
||||
<EmptyState tab={activeTab} onCreate={handleOpenCreate} />
|
||||
) : (
|
||||
<div className="rounded-xl bg-white ring-1 ring-gray-200 dark:bg-gray-800 dark:ring-gray-700">
|
||||
{/* Desktop table */}
|
||||
<div className="hidden overflow-x-auto xl:block">
|
||||
<table className="w-full text-left text-xs">
|
||||
<thead>
|
||||
{activeTab === 'completed' ? (
|
||||
<tr className="border-b border-gray-200 text-xs uppercase tracking-wide text-gray-500 dark:border-gray-700 dark:text-gray-400">
|
||||
<th className="px-2 py-2" />
|
||||
<th className="px-3 py-2 font-medium">RO #</th>
|
||||
<th className="px-3 py-2 font-medium">Customer</th>
|
||||
<th className="px-3 py-2 font-medium">Vehicle</th>
|
||||
<th className="px-3 py-2 font-medium">Completed</th>
|
||||
<th className="px-3 py-2 font-medium">Status</th>
|
||||
<th className="px-3 py-2 font-medium">Total</th>
|
||||
<th className="px-3 py-2 font-medium">Rating</th>
|
||||
<th className="px-3 py-2 font-medium">Tech</th>
|
||||
<th className="w-10 px-3 py-2" />
|
||||
</tr>
|
||||
) : (
|
||||
<tr className="border-b border-gray-200 text-xs uppercase tracking-wide text-gray-500 dark:border-gray-700 dark:text-gray-400">
|
||||
<th className="px-2 py-2" />
|
||||
<th className="px-3 py-2 font-medium">RO #</th>
|
||||
<th className="px-3 py-2 font-medium">Customer</th>
|
||||
<th className="px-3 py-2 font-medium">Vehicle</th>
|
||||
<th className="px-3 py-2 font-medium">Status</th>
|
||||
<th className="px-3 py-2 font-medium">Write-up</th>
|
||||
<th className="px-3 py-2 font-medium">Promised</th>
|
||||
<th className="px-3 py-2 font-medium">Remaining</th>
|
||||
<th className="px-3 py-2 font-medium">Progress</th>
|
||||
<th className="px-3 py-2 font-medium">Tech</th>
|
||||
<th className="w-10 px-3 py-2" />
|
||||
</tr>
|
||||
)}
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-200 dark:divide-gray-700">
|
||||
{filteredOrders.map((ro) => activeTab === 'completed' ? (
|
||||
<CompletedRow
|
||||
key={ro.id}
|
||||
ro={ro}
|
||||
selected={selectedIds.has(ro.id)}
|
||||
onToggleSelect={() => toggleSelect(ro.id)}
|
||||
onOpen={() => navigate(`/repair-orders/${ro.id}`, { state: { backgroundLocation: location } })}
|
||||
onToggleCustomerType={handleToggleCustomerType}
|
||||
techNames={techNamesByRO.get(ro.id) ?? []}
|
||||
/>
|
||||
) : (
|
||||
<FragmentRow
|
||||
key={ro.id}
|
||||
ro={ro}
|
||||
now={now}
|
||||
selected={selectedIds.has(ro.id)}
|
||||
onToggleSelect={() => toggleSelect(ro.id)}
|
||||
onOpen={() => navigate(`/repair-orders/${ro.id}`, { state: { backgroundLocation: location } })}
|
||||
onToggleCustomerType={handleToggleCustomerType}
|
||||
techNames={techNamesByRO.get(ro.id) ?? []}
|
||||
/>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* Cards (tablet / narrow) */}
|
||||
<div className="divide-y divide-gray-200 dark:divide-gray-700 xl:hidden">
|
||||
{filteredOrders.map((ro) => {
|
||||
const urgency = getUrgencyClass(ro, now);
|
||||
const voided = isVoided(ro);
|
||||
const cardClass = voided ? 'opacity-50' : urgency === 'urgent' ? 'bg-red-50 dark:bg-red-900/15' : '';
|
||||
const completedDate = getCompletedMetricDate(ro);
|
||||
const roTechNames = techNamesByRO.get(ro.id) ?? [];
|
||||
return (
|
||||
<div key={ro.id} className={`flex items-start gap-2 ${cardClass}`}>
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); toggleSelect(ro.id); }}
|
||||
className="mt-4 ml-3 text-gray-400 hover:text-blue-600 dark:hover:text-blue-400"
|
||||
>
|
||||
{selectedIds.has(ro.id)
|
||||
? <span className="h-4 w-4 rounded bg-blue-600" />
|
||||
: <span className="inline-block h-4 w-4 rounded border border-gray-400" />}
|
||||
</button>
|
||||
<div
|
||||
onClick={() => navigate(`/repair-orders/${ro.id}`, { state: { backgroundLocation: location } })}
|
||||
className="flex-1 cursor-pointer px-5 py-4 transition-colors hover:bg-gray-50 dark:hover:bg-gray-700/50"
|
||||
>
|
||||
<div className="mb-1 flex items-center justify-between">
|
||||
<span className="font-mono text-xs text-gray-400">{ro.roNumber || ro.id.slice(0, 8)}</span>
|
||||
<div className="flex items-center gap-2">
|
||||
{roTechNames.length > 0 && (
|
||||
<span className="max-w-[10rem] truncate 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" title={roTechNames.join(', ')}>
|
||||
{roTechNames.join(', ')}
|
||||
</span>
|
||||
)}
|
||||
<StatusBadge status={voided ? 'voided' : (ro.status || '')} config={statusConfig} />
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<p className="font-medium text-gray-900 dark:text-white">{displayCustomerName(ro.customerName)}</p>
|
||||
<CustomerTypeBadge customerType={ro.customerType} onClick={() => handleToggleCustomerType(ro.id, ro.customerType)} />
|
||||
</div>
|
||||
<p className="mt-0.5 text-sm text-gray-500 dark:text-gray-400">
|
||||
{ro.vehicleInfo || '—'} · {activeTab === 'completed'
|
||||
? `Completed: ${completedDate ? formatDateTime(completedDate) : '—'}`
|
||||
: formatDateTime(ro.writeupTime || ro.created)}
|
||||
</p>
|
||||
{ro.serviceLocation && (
|
||||
<p className="mt-1 inline-flex items-center gap-1 text-xs text-sky-600 dark:text-sky-400">
|
||||
<MapPin className="h-3.5 w-3.5" />
|
||||
<span className="truncate">{ro.serviceLocation}</span>
|
||||
</p>
|
||||
)}
|
||||
{typeof ro.tripMiles === 'number' && ro.tripMiles > 0 && (
|
||||
<p className="mt-1 text-xs text-purple-600 dark:text-purple-400">
|
||||
{ro.tripMiles} trip mi
|
||||
</p>
|
||||
)}
|
||||
{roTechNames.length > 0 && (
|
||||
<p className="mt-1 text-xs text-green-700 dark:text-green-400">
|
||||
Tech: {roTechNames.join(', ')}
|
||||
</p>
|
||||
)}
|
||||
{activeTab === 'completed' ? (
|
||||
<div className="mt-1 flex items-center justify-between text-xs text-gray-500 dark:text-gray-400">
|
||||
<span>{formatCurrency(ro.total || 0)}</span>
|
||||
<span>{ratingLabel(ro.satisfaction)}</span>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<p className="text-xs text-gray-400 dark:text-gray-500">Due: {formatDueDateTime(ro)}</p>
|
||||
<div className="mt-1 flex items-center justify-between">
|
||||
<TimeRemainingCell ro={ro} now={now} />
|
||||
<RowProgressBar ro={ro} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{hasMore && (
|
||||
<div className="mt-4 flex justify-center">
|
||||
<button
|
||||
onClick={loadMore}
|
||||
disabled={loading}
|
||||
className="rounded-lg border border-gray-300 bg-white px-6 py-2 text-sm font-medium text-gray-700 shadow-sm transition-colors hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-green-500 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-60 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700"
|
||||
>
|
||||
{loading ? 'Loading…' : 'Load more'}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="px-4 py-8 sm:px-6 lg:px-8">
|
||||
<div className="mb-8">
|
||||
<h1 className="text-xl font-bold text-gray-900 dark:text-white">Repair Orders</h1>
|
||||
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||
Manage active repair orders, track services, and update statuses.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{mainContent}
|
||||
|
||||
<ROModal
|
||||
isOpen={showModal}
|
||||
onClose={() => { setShowModal(false); setEditRO(null); }}
|
||||
onSave={handleSave}
|
||||
existingRoNumbers={existingRoNumbers}
|
||||
settings={settings}
|
||||
editRO={editRO}
|
||||
customers={customers}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,414 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { pb } from '../lib/pocketbase';
|
||||
import type { ServiceItem } from '../types';
|
||||
import { useToast } from '../components/ui/Toast';
|
||||
import { Search, Plus, Edit3, Trash2, ToggleLeft, ToggleRight, Wrench, DollarSign, Package, Tag, Check, X, Layers, Sparkles, Loader2 } from 'lucide-react';
|
||||
import { aiWriteCatalogDescription } from '../lib/ai';
|
||||
import { logError } from '../lib/userMessages';
|
||||
import { ListSkeleton } from '../components/ui/ListSkeleton';
|
||||
import { ListError } from '../components/ui/ListError';
|
||||
|
||||
type ServicePricingMode = 'full' | 'split';
|
||||
|
||||
type ServiceFormState = {
|
||||
name: string;
|
||||
category: string;
|
||||
description: string;
|
||||
pricingMode: ServicePricingMode;
|
||||
fullPrice: number;
|
||||
laborPrice: number;
|
||||
partsPrice: number;
|
||||
};
|
||||
|
||||
function getServiceLaborPrice(service: Pick<ServiceItem, 'laborHours' | 'laborRate'>): number {
|
||||
return (Number(service.laborHours) || 0) * (Number(service.laborRate) || 0);
|
||||
}
|
||||
|
||||
function getServicePricingMode(service: Pick<ServiceItem, 'laborHours' | 'laborRate' | 'partsCost'>): ServicePricingMode {
|
||||
return getServiceLaborPrice(service) > 0 || (Number(service.partsCost) || 0) > 0 ? 'split' : 'full';
|
||||
}
|
||||
|
||||
function createEmptyServiceForm(): ServiceFormState {
|
||||
return {
|
||||
name: '',
|
||||
category: '',
|
||||
description: '',
|
||||
pricingMode: 'full',
|
||||
fullPrice: 0,
|
||||
laborPrice: 0,
|
||||
partsPrice: 0,
|
||||
};
|
||||
}
|
||||
|
||||
function FormField({ label, children }: { label: string; children: React.ReactNode }) {
|
||||
return (
|
||||
<div className="space-y-1.5">
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">{label}</label>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Input({ value, onChange, placeholder, type = 'text', icon }: { value: string | number; onChange: (value: string) => void; placeholder?: string; type?: string; icon?: React.ReactNode }) {
|
||||
return (
|
||||
<div className="relative">
|
||||
{icon && <span className="pointer-events-none absolute inset-y-0 left-0 flex items-center pl-3 text-gray-400">{icon}</span>}
|
||||
<input
|
||||
type={type}
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder={placeholder}
|
||||
className={`w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm text-gray-900 placeholder-gray-400 focus:border-green-500 focus:outline-none focus:ring-2 focus:ring-green-500/20 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100 ${icon ? 'pl-9' : ''}`}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Textarea({ value, onChange, placeholder, rows = 3 }: { value: string; onChange: (value: string) => void; placeholder?: string; rows?: number }) {
|
||||
return (
|
||||
<textarea
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder={placeholder}
|
||||
rows={rows}
|
||||
className="w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm text-gray-900 placeholder-gray-400 focus:border-green-500 focus:outline-none focus:ring-2 focus:ring-green-500/20 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function ServiceRow({ service, onEdit, onDelete, onToggleActive }: { service: ServiceItem; onEdit: () => void; onDelete: () => void; onToggleActive: () => void }) {
|
||||
const laborPrice = getServiceLaborPrice(service);
|
||||
const pricingMode = getServicePricingMode(service);
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-gray-200 bg-white p-4 shadow-sm dark:border-gray-700 dark:bg-gray-900">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<h3 className="truncate text-sm font-semibold text-gray-900 dark:text-gray-100">{service.name}</h3>
|
||||
{service.category && <span className="rounded-full bg-blue-100 px-2 py-0.5 text-xs font-medium text-blue-700 dark:bg-blue-900/30 dark:text-blue-300">{service.category}</span>}
|
||||
<span className={`rounded-full px-2 py-0.5 text-xs font-medium ${service.active ? 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-300' : 'bg-gray-100 text-gray-500 dark:bg-gray-800 dark:text-gray-400'}`}>{service.active ? 'Active' : 'Inactive'}</span>
|
||||
</div>
|
||||
{service.description && <p className="mt-1 text-xs text-gray-500 dark:text-gray-400">{service.description}</p>}
|
||||
<div className="mt-2 flex flex-wrap gap-x-4 gap-y-1 text-xs text-gray-600 dark:text-gray-400">
|
||||
{pricingMode === 'split' && <span className="flex items-center gap-1"><DollarSign className="h-3 w-3" />Labor: ${laborPrice.toFixed(2)}</span>}
|
||||
{pricingMode === 'split' && <span className="flex items-center gap-1"><Package className="h-3 w-3" />Parts: ${(service.partsCost || 0).toFixed(2)}</span>}
|
||||
<span className="flex items-center gap-1 font-semibold text-green-600 dark:text-green-400"><Tag className="h-3 w-3" />${(service.price || 0).toFixed(2)}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex shrink-0 items-center gap-1">
|
||||
<button onClick={onToggleActive} className={`rounded p-1.5 transition-colors ${service.active ? 'text-green-500 hover:bg-green-100 dark:hover:bg-green-900/30' : 'text-gray-400 hover:bg-gray-100 dark:hover:bg-gray-800'}`} title={service.active ? 'Deactivate' : 'Activate'}>
|
||||
{service.active ? <ToggleRight className="h-4 w-4" /> : <ToggleLeft className="h-4 w-4" />}
|
||||
</button>
|
||||
<button onClick={onEdit} className="rounded p-1.5 text-gray-400 transition-colors hover:bg-gray-100 hover:text-blue-600 dark:hover:bg-gray-800 dark:hover:text-blue-400" title="Edit service">
|
||||
<Edit3 className="h-4 w-4" />
|
||||
</button>
|
||||
<button onClick={onDelete} className="rounded p-1.5 text-gray-400 transition-colors hover:bg-gray-100 hover:text-red-600 dark:hover:bg-gray-800 dark:hover:text-red-400" title="Delete service">
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default function ServiceCatalog() {
|
||||
const { showToast } = useToast();
|
||||
const userId = pb.authStore.model?.id;
|
||||
const [services, setServices] = useState<ServiceItem[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [aiWritingDescription, setAiWritingDescription] = useState(false);
|
||||
const [showServiceForm, setShowServiceForm] = useState(false);
|
||||
const [editingService, setEditingService] = useState<ServiceItem | null>(null);
|
||||
const [serviceSearch, setServiceSearch] = useState('');
|
||||
const [serviceForm, setServiceForm] = useState<ServiceFormState>(createEmptyServiceForm());
|
||||
|
||||
const loadServices = useCallback(async () => {
|
||||
if (!userId) {
|
||||
setServices([]);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
try {
|
||||
const records = await pb.collection('services').getFullList({ filter: `userId="${userId}"`, sort: 'name' });
|
||||
setServices(records.map((r: Record<string, unknown>) => ({
|
||||
id: r.id as string,
|
||||
name: (r.name as string) || '',
|
||||
category: (r.category as string) || '',
|
||||
description: (r.explanation as string) || (r.description as string) || '',
|
||||
laborHours: Number(r.laborHours) || 0,
|
||||
laborRate: Number(r.laborRate) || 0,
|
||||
partsCost: Number(r.partsCost) || 0,
|
||||
price: Number(r.price) || 0,
|
||||
active: r.active !== false,
|
||||
created: (r.created as string) || '',
|
||||
updated: (r.updated as string) || '',
|
||||
})));
|
||||
} catch (err) {
|
||||
logError('Failed to load services', err);
|
||||
setError('Failed to load services. Check your connection and try again.');
|
||||
setServices([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [userId]);
|
||||
|
||||
useEffect(() => {
|
||||
loadServices();
|
||||
}, [loadServices]);
|
||||
|
||||
const resetServiceForm = () => {
|
||||
setServiceForm(createEmptyServiceForm());
|
||||
setEditingService(null);
|
||||
setShowServiceForm(false);
|
||||
setAiWritingDescription(false);
|
||||
};
|
||||
|
||||
const handleAiWriteDescription = async () => {
|
||||
if (!serviceForm.name.trim()) {
|
||||
showToast('Enter a service name before using AI Write', 'error');
|
||||
return;
|
||||
}
|
||||
setAiWritingDescription(true);
|
||||
try {
|
||||
const description = await aiWriteCatalogDescription({
|
||||
serviceName: serviceForm.name,
|
||||
category: serviceForm.category,
|
||||
existingDescription: serviceForm.description,
|
||||
});
|
||||
if (!description) {
|
||||
showToast('AI Write returned no result', 'error');
|
||||
return;
|
||||
}
|
||||
setServiceForm((prev) => ({ ...prev, description }));
|
||||
showToast(`AI description generated for ${serviceForm.name}`, 'success');
|
||||
} catch (err) {
|
||||
logError('Failed to generate catalog description', err);
|
||||
showToast('Service description could not be generated. Please try again.', 'error');
|
||||
} finally {
|
||||
setAiWritingDescription(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleSaveService = async () => {
|
||||
if (!serviceForm.name.trim()) {
|
||||
showToast('Service name is required', 'error');
|
||||
return;
|
||||
}
|
||||
if (!userId) {
|
||||
showToast('Please log in to manage services', 'error');
|
||||
return;
|
||||
}
|
||||
const fullPrice = Number(serviceForm.fullPrice) || 0;
|
||||
const laborPrice = Number(serviceForm.laborPrice) || 0;
|
||||
const partsPrice = Number(serviceForm.partsPrice) || 0;
|
||||
const isSplitPricing = serviceForm.pricingMode === 'split';
|
||||
const description = serviceForm.description.trim();
|
||||
const data = {
|
||||
name: serviceForm.name.trim(),
|
||||
category: serviceForm.category.trim(),
|
||||
description,
|
||||
explanation: description,
|
||||
laborHours: isSplitPricing && laborPrice > 0 ? 1 : 0,
|
||||
laborRate: isSplitPricing ? laborPrice : 0,
|
||||
partsCost: isSplitPricing ? partsPrice : 0,
|
||||
price: isSplitPricing ? laborPrice + partsPrice : fullPrice,
|
||||
active: editingService ? editingService.active : true,
|
||||
userId,
|
||||
};
|
||||
try {
|
||||
if (editingService?.id) {
|
||||
await pb.collection('services').update(editingService.id, data);
|
||||
showToast('Service updated', 'success');
|
||||
} else {
|
||||
await pb.collection('services').create(data);
|
||||
showToast('Service added', 'success');
|
||||
}
|
||||
resetServiceForm();
|
||||
await loadServices();
|
||||
} catch (err) {
|
||||
logError('Failed to save service', err);
|
||||
showToast('Service could not be saved. Please try again.', 'error');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteService = async (service: ServiceItem) => {
|
||||
if (!userId || !service.id) return;
|
||||
try {
|
||||
await pb.collection('services').delete(service.id);
|
||||
showToast('Service deleted', 'success');
|
||||
await loadServices();
|
||||
} catch (err) {
|
||||
logError('Failed to delete service', err);
|
||||
showToast('Service could not be deleted. Please try again.', 'error');
|
||||
}
|
||||
};
|
||||
|
||||
const handleToggleServiceActive = async (service: ServiceItem) => {
|
||||
if (!userId || !service.id) return;
|
||||
try {
|
||||
await pb.collection('services').update(service.id, { active: !service.active });
|
||||
await loadServices();
|
||||
} catch (err) {
|
||||
console.error(err);
|
||||
showToast('Failed to toggle service', 'error');
|
||||
}
|
||||
};
|
||||
|
||||
const startEditService = (service: ServiceItem) => {
|
||||
setEditingService(service);
|
||||
const pricingMode = getServicePricingMode(service);
|
||||
setServiceForm({
|
||||
name: service.name,
|
||||
category: service.category,
|
||||
description: service.description,
|
||||
pricingMode,
|
||||
fullPrice: service.price,
|
||||
laborPrice: getServiceLaborPrice(service),
|
||||
partsPrice: service.partsCost,
|
||||
});
|
||||
setShowServiceForm(true);
|
||||
};
|
||||
|
||||
const filteredServices = services.filter((service) => {
|
||||
if (!serviceSearch.trim()) return true;
|
||||
const q = serviceSearch.toLowerCase();
|
||||
return service.name.toLowerCase().includes(q) || service.category.toLowerCase().includes(q) || service.description.toLowerCase().includes(q);
|
||||
});
|
||||
|
||||
if (loading) {
|
||||
return <div className="mx-auto max-w-6xl px-4 py-8 sm:px-6"><ListSkeleton rows={8} /></div>;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="mx-auto max-w-6xl px-4 py-8 sm:px-6">
|
||||
<ListError title="Unable to load services" message={error} onRetry={loadServices} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-6xl px-4 py-8 sm:px-6">
|
||||
<div className="mb-8 flex flex-col gap-4 sm:flex-row sm:items-end sm:justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">Service Catalog</h1>
|
||||
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">Manage reusable services, pricing templates, and catalog defaults outside of settings.</p>
|
||||
</div>
|
||||
{!showServiceForm && (
|
||||
<button onClick={() => setShowServiceForm(true)} className="inline-flex items-center gap-2 rounded-lg bg-green-600 px-4 py-2.5 text-sm font-medium text-white transition-colors hover:bg-green-700">
|
||||
<Plus className="h-4 w-4" /> Add Service
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mb-6 rounded-2xl border border-gray-200 bg-white p-4 shadow-sm dark:border-gray-700 dark:bg-gray-900">
|
||||
<div className="relative max-w-md">
|
||||
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-gray-400" />
|
||||
<input
|
||||
type="text"
|
||||
value={serviceSearch}
|
||||
onChange={(e) => setServiceSearch(e.target.value)}
|
||||
placeholder="Search services, categories, or descriptions"
|
||||
className="w-full rounded-lg border border-gray-300 bg-white py-2.5 pl-9 pr-3 text-sm text-gray-900 placeholder-gray-400 focus:border-green-500 focus:outline-none focus:ring-2 focus:ring-green-500/20 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{showServiceForm && (
|
||||
<div className="mb-6 rounded-2xl border border-green-200 bg-green-50 p-5 shadow-sm dark:border-green-700 dark:bg-green-900/10">
|
||||
<div className="mb-4 flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<h2 className="text-base font-semibold text-gray-900 dark:text-gray-100">{editingService ? 'Edit Service' : 'Add New Service'}</h2>
|
||||
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">Build a reusable service template for quotes and write-ups.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid gap-3 sm:grid-cols-2">
|
||||
<FormField label="Service Name"><Input value={serviceForm.name} onChange={(value) => setServiceForm((prev) => ({ ...prev, name: value }))} placeholder="e.g. Oil Change" icon={<Wrench className="h-4 w-4" />} /></FormField>
|
||||
<FormField label="Category"><Input value={serviceForm.category} onChange={(value) => setServiceForm((prev) => ({ ...prev, category: value }))} placeholder="e.g. Maintenance" icon={<Layers className="h-4 w-4" />} /></FormField>
|
||||
</div>
|
||||
<div className="mt-3">
|
||||
<FormField label="Description">
|
||||
<div className="space-y-2">
|
||||
<Textarea value={serviceForm.description} onChange={(value) => setServiceForm((prev) => ({ ...prev, description: value }))} placeholder="Brief description of the service..." rows={2} />
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleAiWriteDescription}
|
||||
disabled={aiWritingDescription}
|
||||
className="inline-flex items-center gap-1.5 rounded-lg border border-purple-300 bg-purple-50 px-3 py-2 text-xs font-medium text-purple-700 transition-colors hover:bg-purple-100 disabled:cursor-not-allowed disabled:opacity-50 dark:border-purple-700 dark:bg-purple-900/20 dark:text-purple-300 dark:hover:bg-purple-900/30"
|
||||
>
|
||||
{aiWritingDescription ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Sparkles className="h-3.5 w-3.5" />}
|
||||
AI Write
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</FormField>
|
||||
</div>
|
||||
<div className="mt-3 rounded-xl border border-gray-200 bg-white p-4 dark:border-gray-700 dark:bg-gray-950/40">
|
||||
<p className="text-sm font-medium text-gray-700 dark:text-gray-300">Pricing</p>
|
||||
<div className="mt-3 grid gap-2 sm:grid-cols-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setServiceForm((prev) => ({ ...prev, pricingMode: 'full' }))}
|
||||
className={`rounded-lg border px-4 py-3 text-left text-sm transition-colors ${serviceForm.pricingMode === 'full' ? 'border-green-500 bg-green-50 text-green-700 dark:border-green-500 dark:bg-green-900/20 dark:text-green-300' : 'border-gray-300 bg-white text-gray-700 hover:bg-gray-50 dark:border-gray-700 dark:bg-gray-900 dark:text-gray-300 dark:hover:bg-gray-800'}`}
|
||||
>
|
||||
<span className="block font-medium">Option 1</span>
|
||||
<span className="block text-xs text-gray-500 dark:text-gray-400">Enter one full price for the service.</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setServiceForm((prev) => ({ ...prev, pricingMode: 'split' }))}
|
||||
className={`rounded-lg border px-4 py-3 text-left text-sm transition-colors ${serviceForm.pricingMode === 'split' ? 'border-green-500 bg-green-50 text-green-700 dark:border-green-500 dark:bg-green-900/20 dark:text-green-300' : 'border-gray-300 bg-white text-gray-700 hover:bg-gray-50 dark:border-gray-700 dark:bg-gray-900 dark:text-gray-300 dark:hover:bg-gray-800'}`}
|
||||
>
|
||||
<span className="block font-medium">Option 2</span>
|
||||
<span className="block text-xs text-gray-500 dark:text-gray-400">Enter labor price and parts price separately.</span>
|
||||
</button>
|
||||
</div>
|
||||
{serviceForm.pricingMode === 'full' ? (
|
||||
<div className="mt-3 grid gap-3 sm:grid-cols-2">
|
||||
<FormField label="Full Price ($)"><Input type="number" value={serviceForm.fullPrice} onChange={(value) => setServiceForm((prev) => ({ ...prev, fullPrice: Number(value) || 0 }))} icon={<Tag className="h-4 w-4" />} /></FormField>
|
||||
</div>
|
||||
) : (
|
||||
<div className="mt-3 grid gap-3 sm:grid-cols-3">
|
||||
<FormField label="Labor Price ($)"><Input type="number" value={serviceForm.laborPrice} onChange={(value) => setServiceForm((prev) => ({ ...prev, laborPrice: Number(value) || 0 }))} icon={<DollarSign className="h-4 w-4" />} /></FormField>
|
||||
<FormField label="Parts Price ($)"><Input type="number" value={serviceForm.partsPrice} onChange={(value) => setServiceForm((prev) => ({ ...prev, partsPrice: Number(value) || 0 }))} icon={<Package className="h-4 w-4" />} /></FormField>
|
||||
<FormField label="Total Price ($)">
|
||||
<div className="flex h-10 items-center rounded-lg border border-gray-300 bg-gray-50 px-3 text-sm font-medium text-gray-900 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100">
|
||||
<Tag className="mr-2 h-4 w-4 text-gray-400" />
|
||||
{(serviceForm.laborPrice + serviceForm.partsPrice).toFixed(2)}
|
||||
</div>
|
||||
</FormField>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="mt-4 flex justify-end gap-2">
|
||||
<button onClick={resetServiceForm} className="inline-flex items-center gap-1.5 rounded-lg border border-gray-300 px-4 py-2 text-sm font-medium text-gray-700 transition-colors hover:bg-gray-100 dark:border-gray-600 dark:text-gray-300 dark:hover:bg-gray-800"><X className="h-4 w-4" />Cancel</button>
|
||||
<button onClick={handleSaveService} className="inline-flex items-center gap-1.5 rounded-lg bg-green-600 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-green-700"><Check className="h-4 w-4" />{editingService ? 'Update' : 'Add'}</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{filteredServices.length === 0 ? (
|
||||
<div className="rounded-2xl border border-dashed border-gray-300 px-6 py-14 text-center dark:border-gray-700">
|
||||
<Wrench className="mx-auto h-10 w-10 text-gray-300 dark:text-gray-600" />
|
||||
<p className="mt-3 text-sm text-gray-500 dark:text-gray-400">{serviceSearch ? 'No catalog services matched your search. Try a broader name or category.' : 'No services are in the catalog yet. Add reusable labor and parts packages to build quotes more efficiently.'}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
{filteredServices.map((service) => (
|
||||
<ServiceRow
|
||||
key={service.id}
|
||||
service={service}
|
||||
onEdit={() => startEditService(service)}
|
||||
onDelete={() => handleDeleteService(service)}
|
||||
onToggleActive={() => handleToggleServiceActive(service)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,578 @@
|
||||
import { useEffect, useMemo, useState, type ReactNode } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Building2, DollarSign, FileImage, CheckCircle2, Truck, Upload, X } from 'lucide-react';
|
||||
import { pb, APP_NAME, useAuth } from '../lib/pocketbase';
|
||||
import type { ShopSettings } from '../types';
|
||||
import { getAddressLabel, getRoleLabel, loadSettings, loadSettingsForUser, saveSettingsForUser, isSetupComplete, uploadLogoFile, removeLogoFile } from '../lib/settings';
|
||||
import { useSettingsStore } from '../store/settings';
|
||||
import { useToast } from '../components/ui/Toast';
|
||||
import { logError } from '../lib/userMessages';
|
||||
|
||||
type StepId = 'business' | 'pricing' | 'quotes' | 'review';
|
||||
|
||||
const STEPS: { id: StepId; label: string; icon: React.ComponentType<{ className?: string }> }[] = [
|
||||
{ id: 'business', label: 'Business Profile', icon: Building2 },
|
||||
{ id: 'pricing', label: 'Pricing Defaults', icon: DollarSign },
|
||||
{ id: 'quotes', label: 'Quote & Branding', icon: FileImage },
|
||||
{ id: 'review', label: 'Review', icon: CheckCircle2 },
|
||||
];
|
||||
|
||||
function SectionCard({ title, hint, children }: { title: string; hint?: string; children: ReactNode }) {
|
||||
return (
|
||||
<div className="rounded-2xl border border-gray-200 bg-white p-6 shadow-sm dark:border-gray-800 dark:bg-gray-900">
|
||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100">{title}</h2>
|
||||
{hint && <p className="mt-1 text-sm text-gray-500 dark:text-gray-400">{hint}</p>}
|
||||
<div className="mt-5 space-y-4">{children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FormField({ label, hint, children }: { label: string; hint?: string; children: ReactNode }) {
|
||||
return (
|
||||
<div className="space-y-1.5">
|
||||
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">{label}</label>
|
||||
{children}
|
||||
{hint && <p className="text-xs text-gray-400 dark:text-gray-500">{hint}</p>}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Input({ id, value, onChange, type = 'text', placeholder }: { id?: string; value: string | number; onChange: (value: string) => void; type?: string; placeholder?: string }) {
|
||||
return (
|
||||
<input
|
||||
id={id}
|
||||
type={type}
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder={placeholder}
|
||||
className="w-full rounded-xl border border-gray-300 bg-white px-3 py-2.5 text-sm text-gray-900 shadow-sm focus:border-green-500 focus:outline-none focus:ring-2 focus:ring-green-500/20 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-100"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function Textarea({ id, value, onChange, rows = 3, placeholder }: { id?: string; value: string; onChange: (value: string) => void; rows?: number; placeholder?: string }) {
|
||||
return (
|
||||
<textarea
|
||||
id={id}
|
||||
value={value}
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
rows={rows}
|
||||
placeholder={placeholder}
|
||||
className="w-full rounded-xl border border-gray-300 bg-white px-3 py-2.5 text-sm text-gray-900 shadow-sm focus:border-green-500 focus:outline-none focus:ring-2 focus:ring-green-500/20 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-100"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function ChecklistCard({ items }: { items: string[] }) {
|
||||
return (
|
||||
<div className="rounded-2xl border border-amber-200 bg-amber-50 p-4 dark:border-amber-900/60 dark:bg-amber-950/20">
|
||||
<p className="text-sm font-semibold text-amber-900 dark:text-amber-200">Complete this step before continuing</p>
|
||||
<ul className="mt-3 space-y-2 text-sm text-amber-800 dark:text-amber-300">
|
||||
{items.map((item) => (
|
||||
<li key={item} className="flex items-start gap-2">
|
||||
<span className="mt-0.5 inline-block h-1.5 w-1.5 rounded-full bg-amber-500" />
|
||||
<span>{item}</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function focusField(fieldId?: string) {
|
||||
if (!fieldId || typeof document === 'undefined') return;
|
||||
const element = document.getElementById(fieldId) as HTMLInputElement | HTMLTextAreaElement | null;
|
||||
element?.focus();
|
||||
}
|
||||
|
||||
export default function Setup() {
|
||||
const navigate = useNavigate();
|
||||
const { user } = useAuth();
|
||||
const { showToast } = useToast();
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [stepIndex, setStepIndex] = useState(0);
|
||||
const [settings, setSettings] = useState<ShopSettings>(loadSettings);
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
|
||||
async function init() {
|
||||
setLoading(true);
|
||||
const next = await loadSettingsForUser(user?.id);
|
||||
if (!active) return;
|
||||
if (isSetupComplete(next)) {
|
||||
navigate('/', { replace: true });
|
||||
return;
|
||||
}
|
||||
if (next.businessType === 'shop' || next.businessType === 'home') {
|
||||
next.businessType = next.businessType === 'home' ? 'mobile' : 'advisor';
|
||||
}
|
||||
setSettings(next);
|
||||
useSettingsStore.getState().setSettings(next);
|
||||
setLoading(false);
|
||||
}
|
||||
|
||||
void init();
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [navigate, user?.id]);
|
||||
|
||||
const currentStep = STEPS[stepIndex];
|
||||
const roleLabel = getRoleLabel(settings);
|
||||
const businessAddressLabel = getAddressLabel(settings);
|
||||
const businessAddressHint = settings.businessType === 'mobile' || settings.businessType === 'home'
|
||||
? 'Enter the city, region, or service radius you cover.'
|
||||
: 'Enter the address that should appear on customer-facing documents.';
|
||||
const roleNameLabel = `Default ${roleLabel} Name`;
|
||||
|
||||
const applyBusinessTypePreset = (businessType: ShopSettings['businessType']) => {
|
||||
setSettings((prev) => {
|
||||
const next = { ...prev, businessType };
|
||||
|
||||
if (!prev.taxLabel.trim()) next.taxLabel = 'Tax';
|
||||
|
||||
if (!prev.footerMessage.trim() || prev.footerMessage === 'Thank you for choosing our shop for your automotive care!') {
|
||||
next.footerMessage = businessType === 'mobile' || businessType === 'home'
|
||||
? 'Thank you for choosing our mobile automotive service.'
|
||||
: 'Thank you for choosing our shop for your automotive care!';
|
||||
}
|
||||
|
||||
if (!prev.headerMessage.trim() || prev.headerMessage === 'Hi {customerName}, here is the explanation of the services and repairs that your technician has recommended for your {vehicleInfo}.') {
|
||||
next.headerMessage = businessType === 'advisor'
|
||||
? 'Hi {customerName}, here is the explanation of the services and repairs that your service advisor has prepared for your {vehicleInfo}.'
|
||||
: 'Hi {customerName}, here is the explanation of the services and repairs that your technician has recommended for your {vehicleInfo}.';
|
||||
}
|
||||
|
||||
if (!prev.quoteFooterNote.trim() || prev.quoteFooterNote === 'This quote is valid for {days} days from the date above.') {
|
||||
next.quoteFooterNote = businessType === 'mobile' || businessType === 'home'
|
||||
? 'This service quote is valid for {days} days from the date above.'
|
||||
: 'This quote is valid for {days} days from the date above.';
|
||||
}
|
||||
|
||||
if (!prev.paymentTerms.trim() || prev.paymentTerms === 'Due on Receipt') {
|
||||
next.paymentTerms = businessType === 'mobile' || businessType === 'home'
|
||||
? 'Payment due at service completion'
|
||||
: 'Due on Receipt';
|
||||
}
|
||||
|
||||
useSettingsStore.getState().setSettings(next);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const updateSetting = <K extends keyof ShopSettings>(key: K, value: ShopSettings[K]) => {
|
||||
setSettings((prev) => {
|
||||
const next = { ...prev, [key]: value };
|
||||
useSettingsStore.getState().setSettings(next);
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const stepValid = useMemo(() => {
|
||||
switch (currentStep.id) {
|
||||
case 'business':
|
||||
return Boolean(
|
||||
settings.businessName.trim() &&
|
||||
settings.businessPhone.trim() &&
|
||||
settings.serviceAdvisor.trim()
|
||||
);
|
||||
case 'pricing':
|
||||
return Number(settings.defaultLaborRate) > 0;
|
||||
default:
|
||||
return true;
|
||||
}
|
||||
}, [currentStep.id, settings]);
|
||||
|
||||
const stepIssues = useMemo<{ id: string; message: string }[]>(() => {
|
||||
switch (currentStep.id) {
|
||||
case 'business': {
|
||||
const issues: { id: string; message: string }[] = [];
|
||||
if (!settings.businessName.trim()) issues.push({ id: 'setup-business-name', message: 'Enter the business name shown on quotes and invoices.' });
|
||||
if (!settings.businessPhone.trim()) issues.push({ id: 'setup-business-phone', message: 'Enter the primary business phone number customers should call.' });
|
||||
if (!settings.serviceAdvisor.trim()) issues.push({ id: 'setup-role-name', message: `Enter the default ${roleLabel.toLowerCase()} name used on quotes and repair orders.` });
|
||||
return issues;
|
||||
}
|
||||
case 'pricing': {
|
||||
const issues: { id: string; message: string }[] = [];
|
||||
if (Number(settings.defaultLaborRate) <= 0) issues.push({ id: 'setup-labor-rate', message: 'Set a default labor rate greater than $0 per hour.' });
|
||||
if (Number(settings.taxRate) < 0) issues.push({ id: 'setup-tax-rate', message: 'Tax rate cannot be negative.' });
|
||||
if (!settings.taxLabel.trim()) issues.push({ id: 'setup-tax-label', message: 'Enter the label used for tax on customer documents.' });
|
||||
return issues;
|
||||
}
|
||||
case 'quotes': {
|
||||
const issues: { id: string; message: string }[] = [];
|
||||
if (Number(settings.quoteExpiryDays) <= 0) issues.push({ id: 'setup-quote-expiry', message: 'Set a quote expiration period greater than 0 days.' });
|
||||
if (!settings.quoteFooterNote.trim()) issues.push({ id: 'setup-quote-footer', message: 'Add a quote footer note for customer-facing documents.' });
|
||||
return issues;
|
||||
}
|
||||
default:
|
||||
return [] as { id: string; message: string }[];
|
||||
}
|
||||
}, [currentStep.id, roleLabel, settings]);
|
||||
|
||||
const setupProgress = useMemo(() => {
|
||||
const checks = [
|
||||
Boolean(settings.businessName.trim()),
|
||||
Boolean(settings.businessPhone.trim()),
|
||||
Boolean(settings.serviceAdvisor.trim()),
|
||||
Number(settings.defaultLaborRate) > 0,
|
||||
Number(settings.quoteExpiryDays) > 0,
|
||||
Boolean(settings.paymentTerms.trim()),
|
||||
];
|
||||
const completed = checks.filter(Boolean).length;
|
||||
return Math.round((completed / checks.length) * 100);
|
||||
}, [settings]);
|
||||
|
||||
const handleLogoUpload = async (file: File | null) => {
|
||||
if (!file) return;
|
||||
if (file.size > 500 * 1024) {
|
||||
showToast('Logo must be under 500KB.', 'error');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const base64 = await uploadLogoFile(user?.id ?? '', file);
|
||||
updateSetting('logoUrl', base64);
|
||||
} catch {
|
||||
showToast('Failed to upload logo', 'error');
|
||||
}
|
||||
};
|
||||
|
||||
const handleNext = () => {
|
||||
if (!stepValid) {
|
||||
focusField(stepIssues[0]?.id);
|
||||
return;
|
||||
}
|
||||
setStepIndex((prev) => Math.min(prev + 1, STEPS.length - 1));
|
||||
};
|
||||
|
||||
const handleFinish = async () => {
|
||||
if (!isSetupComplete(settings)) {
|
||||
showToast('Complete the required business profile fields before finishing setup.', 'error');
|
||||
focusField('setup-business-name');
|
||||
return;
|
||||
}
|
||||
|
||||
setSaving(true);
|
||||
try {
|
||||
await saveSettingsForUser(user?.id, settings);
|
||||
useSettingsStore.getState().setSettings(settings);
|
||||
|
||||
const selectedRole = settings.businessType === 'mobile' ? 'mobile' : 'advisor';
|
||||
if (user?.id) {
|
||||
try {
|
||||
await pb.collection('users').update(user.id, { role: selectedRole });
|
||||
await pb.collection('users').authRefresh();
|
||||
} catch (roleErr) {
|
||||
logError('Failed to update user role', roleErr);
|
||||
}
|
||||
}
|
||||
|
||||
showToast('Setup complete.', 'success');
|
||||
navigate('/', { replace: true });
|
||||
} catch (err) {
|
||||
logError('Failed to finish setup', err);
|
||||
showToast('Setup was saved locally. You can continue, but cloud sync could not be completed.', 'info');
|
||||
navigate('/', { replace: true });
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-gray-50 px-4 dark:bg-gray-950">
|
||||
<div className="flex items-center gap-3 text-sm text-gray-500 dark:text-gray-400">
|
||||
<div className="h-5 w-5 animate-spin rounded-full border-2 border-green-600 border-t-transparent" />
|
||||
Loading setup...
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-[radial-gradient(circle_at_top_left,_rgba(22,163,74,0.10),_transparent_32%),radial-gradient(circle_at_top_right,_rgba(15,23,42,0.08),_transparent_28%)] bg-gray-50 px-4 py-10 dark:bg-[radial-gradient(circle_at_top_left,_rgba(34,197,94,0.16),_transparent_30%),radial-gradient(circle_at_top_right,_rgba(148,163,184,0.08),_transparent_24%)] dark:bg-gray-950">
|
||||
<div className="mx-auto max-w-5xl">
|
||||
<div className="mb-8 flex flex-col gap-6 lg:flex-row lg:items-end lg:justify-between">
|
||||
<div>
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-green-600 dark:text-green-400">
|
||||
First-Run Setup
|
||||
</p>
|
||||
<h1 className="mt-2 text-3xl font-semibold text-gray-900 dark:text-white">
|
||||
Set up your business profile
|
||||
</h1>
|
||||
<p className="mt-2 max-w-2xl text-sm text-gray-500 dark:text-gray-400">
|
||||
These details appear on quotes, repair orders, invoices, and other customer-facing documents.
|
||||
</p>
|
||||
</div>
|
||||
<div className="rounded-2xl border border-gray-200 bg-white/90 px-5 py-4 shadow-sm backdrop-blur dark:border-gray-800 dark:bg-gray-900/80">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-gray-400 dark:text-gray-500">Setup Progress</p>
|
||||
<div className="mt-2 flex items-end gap-3">
|
||||
<span className="text-3xl font-semibold text-gray-900 dark:text-white">{setupProgress}%</span>
|
||||
<span className="pb-1 text-sm text-gray-500 dark:text-gray-400">workflow defaults ready</span>
|
||||
</div>
|
||||
<div className="mt-3 h-2.5 overflow-hidden rounded-full bg-gray-200 dark:bg-gray-800">
|
||||
<div className="h-full rounded-full bg-green-600 transition-all" style={{ width: `${setupProgress}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-8 grid gap-3 sm:grid-cols-4">
|
||||
{STEPS.map((step, index) => {
|
||||
const active = index === stepIndex;
|
||||
const complete = index < stepIndex;
|
||||
return (
|
||||
<div
|
||||
key={step.id}
|
||||
className={`rounded-2xl border px-4 py-3 ${
|
||||
active
|
||||
? 'border-green-500 bg-green-50 dark:border-green-600 dark:bg-green-900/20'
|
||||
: complete
|
||||
? 'border-gray-300 bg-white dark:border-gray-700 dark:bg-gray-900'
|
||||
: 'border-gray-200 bg-white/80 dark:border-gray-800 dark:bg-gray-900/60'
|
||||
}`}
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<step.icon className={`h-4 w-4 ${active ? 'text-green-600 dark:text-green-400' : 'text-gray-400'}`} />
|
||||
<span className="text-sm font-medium text-gray-900 dark:text-gray-100">{step.label}</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
<div className="space-y-6">
|
||||
{currentStep.id === 'business' && (
|
||||
<SectionCard title="Business Profile" hint="Set the core information used throughout the app.">
|
||||
<FormField label="Primary Workflow" hint={`Select how you will use ${APP_NAME} day to day. This determines your app experience and layout.`}>
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => applyBusinessTypePreset('advisor')}
|
||||
className={`relative rounded-xl border-2 p-5 text-left transition-all duration-200 hover:shadow-md ${
|
||||
settings.businessType === 'advisor'
|
||||
? 'border-green-500 bg-green-50 shadow-sm dark:border-green-600 dark:bg-green-900/20'
|
||||
: 'border-gray-200 bg-white hover:border-gray-300 dark:border-gray-700 dark:bg-gray-800/50 dark:hover:border-gray-600'
|
||||
}`}
|
||||
>
|
||||
{settings.businessType === 'advisor' && (
|
||||
<span className="absolute right-3 top-3 flex h-6 w-6 items-center justify-center rounded-full bg-green-500">
|
||||
<CheckCircle2 className="h-4 w-4 text-white" />
|
||||
</span>
|
||||
)}
|
||||
<Building2 className="mb-3 h-8 w-8 text-green-600 dark:text-green-400" />
|
||||
<h3 className="font-semibold text-gray-900 dark:text-white">Shop Desk / Service Advisor</h3>
|
||||
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||
Full desktop workflow for managing quotes, repair orders, customers, and your team.
|
||||
</p>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => applyBusinessTypePreset('mobile')}
|
||||
className={`relative rounded-xl border-2 p-5 text-left transition-all duration-200 hover:shadow-md ${
|
||||
settings.businessType === 'mobile'
|
||||
? 'border-green-500 bg-green-50 shadow-sm dark:border-green-600 dark:bg-green-900/20'
|
||||
: 'border-gray-200 bg-white hover:border-gray-300 dark:border-gray-700 dark:bg-gray-800/50 dark:hover:border-gray-600'
|
||||
}`}
|
||||
>
|
||||
{settings.businessType === 'mobile' && (
|
||||
<span className="absolute right-3 top-3 flex h-6 w-6 items-center justify-center rounded-full bg-green-500">
|
||||
<CheckCircle2 className="h-4 w-4 text-white" />
|
||||
</span>
|
||||
)}
|
||||
<Truck className="mb-3 h-8 w-8 text-green-600 dark:text-green-400" />
|
||||
<h3 className="font-semibold text-gray-900 dark:text-white">Standalone Mobile Mechanic</h3>
|
||||
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||
Full owner tools in a mobile-first layout, optimized for field work.
|
||||
</p>
|
||||
</button>
|
||||
</div>
|
||||
</FormField>
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<FormField label="Business Name">
|
||||
<Input id="setup-business-name" value={settings.businessName} onChange={(v) => updateSetting('businessName', v)} placeholder="Example Auto Repair" />
|
||||
</FormField>
|
||||
<FormField label="Business Phone">
|
||||
<Input id="setup-business-phone" value={settings.businessPhone} onChange={(v) => updateSetting('businessPhone', v)} placeholder="(555) 555-5555" />
|
||||
</FormField>
|
||||
<FormField label={businessAddressLabel} hint={businessAddressHint}>
|
||||
<Textarea id="setup-business-address" value={settings.businessAddress} onChange={(v) => updateSetting('businessAddress', v)} rows={3} placeholder="123 Main St, Knoxville, TN or Greater Knoxville area" />
|
||||
</FormField>
|
||||
<FormField label={roleNameLabel}>
|
||||
<Input id="setup-role-name" value={settings.serviceAdvisor} onChange={(v) => updateSetting('serviceAdvisor', v)} placeholder="Alex Rivera" />
|
||||
</FormField>
|
||||
</div>
|
||||
|
||||
{stepIssues.length > 0 && <ChecklistCard items={stepIssues.map((issue) => issue.message)} />}
|
||||
</SectionCard>
|
||||
)}
|
||||
|
||||
{currentStep.id === 'pricing' && (
|
||||
<SectionCard title="Pricing Defaults" hint="These defaults apply when creating quotes and repair orders.">
|
||||
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<FormField label="Default Labor Rate ($/hr)">
|
||||
<Input id="setup-labor-rate" type="number" value={settings.defaultLaborRate} onChange={(v) => updateSetting('defaultLaborRate', Number(v) || 0)} />
|
||||
</FormField>
|
||||
<FormField label="Tax Rate (%)">
|
||||
<Input id="setup-tax-rate" type="number" value={settings.taxRate} onChange={(v) => updateSetting('taxRate', Number(v) || 0)} />
|
||||
</FormField>
|
||||
<FormField label="Tax Label">
|
||||
<Input id="setup-tax-label" value={settings.taxLabel} onChange={(v) => updateSetting('taxLabel', v)} placeholder="Tax" />
|
||||
</FormField>
|
||||
<FormField label="Shop Charge Rate (%)">
|
||||
<Input type="number" value={settings.shopChargeRate} onChange={(v) => updateSetting('shopChargeRate', Number(v) || 0)} />
|
||||
</FormField>
|
||||
<FormField label="Shop Charge Cap ($)">
|
||||
<Input type="number" value={settings.shopChargeCap} onChange={(v) => updateSetting('shopChargeCap', Number(v) || 0)} />
|
||||
</FormField>
|
||||
<FormField label="Payment Terms">
|
||||
<Input value={settings.paymentTerms} onChange={(v) => updateSetting('paymentTerms', v)} placeholder="Due on Receipt" />
|
||||
</FormField>
|
||||
<FormField label="Default Travel Fee ($)" hint="Preset fee for mobile/home diagnostic visits.">
|
||||
<Input type="number" value={settings.defaultTravelFee} onChange={(v) => updateSetting('defaultTravelFee', Number(v) || 0)} />
|
||||
</FormField>
|
||||
</div>
|
||||
|
||||
<FormField label="Travel Fee Explanation">
|
||||
<Textarea
|
||||
value={settings.travelFeeExplanation}
|
||||
onChange={(v) => updateSetting('travelFeeExplanation', v)}
|
||||
rows={2}
|
||||
placeholder="Explain the travel or diagnostic fee on customer-facing documents."
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField label="Shop Charge Explanation">
|
||||
<Textarea
|
||||
value={settings.shopChargeExplanation}
|
||||
onChange={(v) => updateSetting('shopChargeExplanation', v)}
|
||||
rows={3}
|
||||
placeholder="Explain the purpose of the shop charge on customer quotes."
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
{stepIssues.length > 0 && <ChecklistCard items={stepIssues.map((issue) => issue.message)} />}
|
||||
</SectionCard>
|
||||
)}
|
||||
|
||||
{currentStep.id === 'quotes' && (
|
||||
<SectionCard title="Quote & Branding" hint="These settings shape the professional appearance of your customer-facing documents.">
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<FormField label="Quote Expiration (days)">
|
||||
<Input id="setup-quote-expiry" type="number" value={settings.quoteExpiryDays} onChange={(v) => updateSetting('quoteExpiryDays', Number(v) || 30)} />
|
||||
</FormField>
|
||||
<FormField label="PDF Accent Color">
|
||||
<div className="flex items-center gap-3">
|
||||
<input
|
||||
type="color"
|
||||
value={settings.pdfAccentColor}
|
||||
onChange={(e) => updateSetting('pdfAccentColor', e.target.value)}
|
||||
className="h-10 w-14 rounded-lg border border-gray-300 bg-white p-1 dark:border-gray-700 dark:bg-gray-800"
|
||||
/>
|
||||
<span className="text-sm font-mono text-gray-600 dark:text-gray-400">{settings.pdfAccentColor}</span>
|
||||
</div>
|
||||
</FormField>
|
||||
</div>
|
||||
|
||||
<FormField label="Quote Footer Note" hint="Shown on generated quotes.">
|
||||
<Textarea
|
||||
id="setup-quote-footer"
|
||||
value={settings.quoteFooterNote}
|
||||
onChange={(v) => updateSetting('quoteFooterNote', v)}
|
||||
rows={3}
|
||||
placeholder="This quote is valid for {days} days from the date above."
|
||||
/>
|
||||
</FormField>
|
||||
|
||||
<FormField label="Logo Upload" hint="Optional. PNG or JPG under 500KB.">
|
||||
<div className="flex items-center gap-4">
|
||||
{settings.logoUrl ? (
|
||||
<div className="relative rounded-xl border border-gray-200 bg-white p-3 dark:border-gray-700 dark:bg-gray-800">
|
||||
<img src={settings.logoUrl} alt="Business logo" className="h-16 w-auto object-contain" />
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
updateSetting('logoUrl', '');
|
||||
if (user?.id) removeLogoFile(user.id).catch(() => {});
|
||||
}}
|
||||
className="absolute -right-2 -top-2 rounded-full bg-red-500 p-1 text-white hover:bg-red-600"
|
||||
aria-label="Remove logo"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<label className="inline-flex cursor-pointer items-center gap-2 rounded-xl border border-dashed border-gray-300 bg-white px-4 py-3 text-sm text-gray-600 hover:border-green-500 hover:text-green-600 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-300 dark:hover:border-green-500 dark:hover:text-green-400">
|
||||
<Upload className="h-4 w-4" />
|
||||
Upload Logo
|
||||
<input type="file" accept="image/*" className="hidden" onChange={(e) => handleLogoUpload(e.target.files?.[0] || null)} />
|
||||
</label>
|
||||
)}
|
||||
</div>
|
||||
</FormField>
|
||||
|
||||
{stepIssues.length > 0 && <ChecklistCard items={stepIssues.map((issue) => issue.message)} />}
|
||||
</SectionCard>
|
||||
)}
|
||||
|
||||
{currentStep.id === 'review' && (
|
||||
<SectionCard title="Review Setup" hint="Confirm the details that will appear across your workflow.">
|
||||
<div className="grid gap-4 sm:grid-cols-2">
|
||||
<div>
|
||||
<p className="text-xs font-medium uppercase tracking-wide text-gray-400">Business</p>
|
||||
<p className="mt-1 text-sm text-gray-700 dark:text-gray-300">{settings.businessName || 'Not set'}</p>
|
||||
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">{settings.businessPhone || 'No phone number'}</p>
|
||||
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">{settings.businessAddress || 'No address or service area'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs font-medium uppercase tracking-wide text-gray-400">Defaults</p>
|
||||
<p className="mt-1 text-sm text-gray-700 dark:text-gray-300">{roleLabel}: {settings.serviceAdvisor || 'Not set'}</p>
|
||||
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">Labor Rate: ${Number(settings.defaultLaborRate || 0).toFixed(2)}/hr</p>
|
||||
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">Tax: {settings.taxLabel} {Number(settings.taxRate || 0).toFixed(2)}%</p>
|
||||
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">Payment Terms: {settings.paymentTerms || 'Not set'}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!isSetupComplete(settings) && <ChecklistCard items={['Complete all required business profile fields before finishing setup.']} />}
|
||||
</SectionCard>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-8 flex items-center justify-between">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setStepIndex((prev) => Math.max(prev - 1, 0))}
|
||||
disabled={stepIndex === 0 || saving}
|
||||
className="rounded-xl border border-gray-300 bg-white px-4 py-2.5 text-sm font-medium text-gray-700 transition-colors hover:bg-gray-50 disabled:cursor-not-allowed disabled:opacity-50 dark:border-gray-700 dark:bg-gray-900 dark:text-gray-200 dark:hover:bg-gray-800"
|
||||
>
|
||||
Back
|
||||
</button>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
{stepIndex < STEPS.length - 1 ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleNext}
|
||||
disabled={!stepValid || saving}
|
||||
className="rounded-xl bg-green-600 px-5 py-2.5 text-sm font-medium text-white transition-colors hover:bg-green-700 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
Continue
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleFinish}
|
||||
disabled={saving}
|
||||
className="rounded-xl bg-green-600 px-5 py-2.5 text-sm font-medium text-white transition-colors hover:bg-green-700 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{saving ? 'Finishing...' : 'Finish Setup'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,203 @@
|
||||
import { useEffect, useState, type FormEvent } from 'react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import { Eye, EyeOff, Lock, Mail, User, Wrench } from 'lucide-react';
|
||||
import { pb } from '../lib/pocketbase';
|
||||
import type { TechnicianInvite } from '../lib/technicianInvites';
|
||||
import { acceptTechnicianInvite, fetchInviteByToken } from '../lib/technicianInvites';
|
||||
import { logError } from '../lib/userMessages';
|
||||
|
||||
export default function TechnicianInvite() {
|
||||
const { token } = useParams<{ token: string }>();
|
||||
const navigate = useNavigate();
|
||||
const [invite, setInvite] = useState<TechnicianInvite | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const [displayName, setDisplayName] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [confirmPassword, setConfirmPassword] = useState('');
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [success, setSuccess] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
async function loadInvite() {
|
||||
setLoading(true);
|
||||
if (!token) {
|
||||
setInvite(null);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
const found = await fetchInviteByToken(token);
|
||||
if (!active) return;
|
||||
setInvite(found);
|
||||
setDisplayName(found?.displayName ?? '');
|
||||
setLoading(false);
|
||||
}
|
||||
void loadInvite();
|
||||
return () => {
|
||||
active = false;
|
||||
};
|
||||
}, [token]);
|
||||
|
||||
const handleSubmit = async (e: FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
setSuccess(null);
|
||||
if (!invite || !token) return;
|
||||
if (!password) { setError('Enter a password.'); return; }
|
||||
if (password.length < 8) { setError('Password must be at least 8 characters.'); return; }
|
||||
if (password !== confirmPassword) { setError('Passwords do not match.'); return; }
|
||||
|
||||
setSubmitting(true);
|
||||
try {
|
||||
const user = await pb.collection('users').create({
|
||||
email: invite.email,
|
||||
password,
|
||||
passwordConfirm: confirmPassword,
|
||||
name: displayName.trim(),
|
||||
displayName: displayName.trim(),
|
||||
role: 'technician',
|
||||
shopUserId: invite.shopUserId,
|
||||
emailVisibility: true,
|
||||
});
|
||||
|
||||
await acceptTechnicianInvite(invite, token, user.id);
|
||||
|
||||
try {
|
||||
await pb.collection('users').requestVerification(invite.email);
|
||||
} catch (err) { logError('requestVerification failed (non-critical)', err); }
|
||||
|
||||
try {
|
||||
await pb.collection('users').authWithPassword(invite.email, password);
|
||||
navigate('/technician', { replace: true });
|
||||
} catch {
|
||||
setSuccess('Technician account created. Check your email, verify the account, then sign in.');
|
||||
}
|
||||
} catch (err) {
|
||||
logError('Failed to accept technician invite', err);
|
||||
setError('Unable to create technician account. The invite may be expired or the email may already be registered.');
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-gradient-to-br from-gray-50 to-gray-100 px-4 dark:from-gray-950 dark:to-gray-900">
|
||||
<div className="w-full max-w-md">
|
||||
<div className="mb-8 text-center">
|
||||
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-2xl bg-green-600 shadow-lg shadow-green-600/20 dark:bg-green-700">
|
||||
<Wrench className="h-8 w-8 text-white" />
|
||||
</div>
|
||||
<h1 className="text-3xl font-bold tracking-tight text-gray-900 dark:text-white">
|
||||
Join the Bay Team
|
||||
</h1>
|
||||
<p className="mt-1.5 text-sm text-gray-500 dark:text-gray-400">
|
||||
Create your technician login for assigned repair orders.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border border-gray-200 bg-white p-8 shadow-xl shadow-gray-200/50 dark:border-gray-800 dark:bg-gray-900 dark:shadow-black/20">
|
||||
{loading ? (
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="h-8 w-8 animate-spin rounded-full border-4 border-green-500 border-t-transparent" />
|
||||
</div>
|
||||
) : !invite ? (
|
||||
<div className="text-center">
|
||||
<p className="text-base font-semibold text-gray-900 dark:text-white">Invite unavailable</p>
|
||||
<p className="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||
This technician invite is expired, revoked, or has already been used.
|
||||
</p>
|
||||
<button
|
||||
onClick={() => navigate('/login')}
|
||||
className="mt-6 rounded-lg bg-green-600 px-4 py-2 text-sm font-medium text-white hover:bg-green-700"
|
||||
>
|
||||
Go to sign in
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<form onSubmit={handleSubmit} className="space-y-4" noValidate>
|
||||
{success && (
|
||||
<div className="rounded-lg border border-green-200 bg-green-50 px-4 py-3 text-sm text-green-700 dark:border-green-800/50 dark:bg-green-950/50 dark:text-green-400">
|
||||
{success}
|
||||
</div>
|
||||
)}
|
||||
{error && (
|
||||
<div className="rounded-lg 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">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-gray-700 dark:text-gray-300">Email</label>
|
||||
<div className="relative">
|
||||
<Mail className="pointer-events-none absolute left-3 top-1/2 h-5 w-5 -translate-y-1/2 text-gray-400" />
|
||||
<input
|
||||
value={invite.email}
|
||||
readOnly
|
||||
className="w-full rounded-lg border border-gray-300 bg-gray-50 py-2.5 pl-10 pr-3 text-sm text-gray-600 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-300"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-gray-700 dark:text-gray-300">Display Name</label>
|
||||
<div className="relative">
|
||||
<User className="pointer-events-none absolute left-3 top-1/2 h-5 w-5 -translate-y-1/2 text-gray-400" />
|
||||
<input
|
||||
value={displayName}
|
||||
onChange={(e) => setDisplayName(e.target.value)}
|
||||
className="w-full rounded-lg border border-gray-300 bg-white py-2.5 pl-10 pr-3 text-sm text-gray-900 focus:border-green-500 focus:outline-none focus:ring-2 focus:ring-green-500/20 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-100"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-gray-700 dark:text-gray-300">Password</label>
|
||||
<div className="relative">
|
||||
<Lock className="pointer-events-none absolute left-3 top-1/2 h-5 w-5 -translate-y-1/2 text-gray-400" />
|
||||
<input
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="At least 8 characters"
|
||||
className="w-full rounded-lg border border-gray-300 bg-white py-2.5 pl-10 pr-10 text-sm text-gray-900 focus:border-green-500 focus:outline-none focus:ring-2 focus:ring-green-500/20 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-100"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowPassword((prev) => !prev)}
|
||||
className="absolute right-2.5 top-1/2 -translate-y-1/2 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
|
||||
>
|
||||
{showPassword ? <EyeOff className="h-5 w-5" /> : <Eye className="h-5 w-5" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1.5 block text-sm font-medium text-gray-700 dark:text-gray-300">Confirm Password</label>
|
||||
<div className="relative">
|
||||
<Lock className="pointer-events-none absolute left-3 top-1/2 h-5 w-5 -translate-y-1/2 text-gray-400" />
|
||||
<input
|
||||
type={showPassword ? 'text' : 'password'}
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
placeholder="Repeat your password"
|
||||
className="w-full rounded-lg border border-gray-300 bg-white py-2.5 pl-10 pr-3 text-sm text-gray-900 focus:border-green-500 focus:outline-none focus:ring-2 focus:ring-green-500/20 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-100"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={submitting}
|
||||
className="flex w-full items-center justify-center rounded-lg bg-green-600 px-4 py-2.5 text-sm font-semibold text-white hover:bg-green-700 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
>
|
||||
{submitting ? 'Creating account…' : 'Create Technician Account'}
|
||||
</button>
|
||||
</form>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useNavigate, useSearchParams, Link } from 'react-router-dom';
|
||||
import { CheckCircle2, AlertTriangle, Loader2, LogIn } from 'lucide-react';
|
||||
import { pb } from '../lib/pocketbase';
|
||||
|
||||
type Status = 'loading' | 'success' | 'expired' | 'network-error';
|
||||
|
||||
export default function Verify() {
|
||||
const [searchParams] = useSearchParams();
|
||||
const navigate = useNavigate();
|
||||
const token = searchParams.get('token');
|
||||
const [status, setStatus] = useState<Status>('loading');
|
||||
|
||||
useEffect(() => {
|
||||
if (!token) {
|
||||
setStatus('expired');
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
|
||||
async function confirm() {
|
||||
try {
|
||||
await pb.collection('users').confirmVerification(token!);
|
||||
if (cancelled) return;
|
||||
setStatus('success');
|
||||
|
||||
// If already signed in, navigate to settings after a short delay.
|
||||
if (pb.authStore.isValid) {
|
||||
setTimeout(() => navigate('/settings', { replace: true }), 1500);
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
if (cancelled) return;
|
||||
const msg = err instanceof Error ? err.message : '';
|
||||
// 400-level errors typically mean expired/invalid token
|
||||
if (msg.includes('400') || msg.includes('invalid') || msg.includes('expired')) {
|
||||
setStatus('expired');
|
||||
} else {
|
||||
setStatus('network-error');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void confirm();
|
||||
return () => { cancelled = true; };
|
||||
}, [navigate, token]);
|
||||
|
||||
if (status === 'loading') {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-gray-50 px-4 dark:bg-gray-950">
|
||||
<div className="text-center">
|
||||
<Loader2 className="mx-auto h-10 w-10 animate-spin text-green-600" />
|
||||
<p className="mt-4 text-sm text-gray-500 dark:text-gray-400">
|
||||
Verifying your email address...
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (status === 'success') {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-gray-50 px-4 dark:bg-gray-950">
|
||||
<div className="w-full max-w-md text-center">
|
||||
<div className="mx-auto mb-6 flex h-20 w-20 items-center justify-center rounded-2xl bg-green-100 dark:bg-green-900/30">
|
||||
<CheckCircle2 className="h-10 w-10 text-green-600 dark:text-green-400" />
|
||||
</div>
|
||||
<h1 className="text-2xl font-semibold text-gray-900 dark:text-white">
|
||||
Account verified
|
||||
</h1>
|
||||
<p className="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||
{pb.authStore.isValid
|
||||
? 'Your account is now verified. Redirecting to settings...'
|
||||
: 'Your email has been confirmed. Sign in to continue.'}
|
||||
</p>
|
||||
{!pb.authStore.isValid && (
|
||||
<div className="mt-6">
|
||||
<Link
|
||||
to="/login"
|
||||
className="inline-flex items-center gap-2 rounded-xl bg-green-600 px-5 py-2.5 text-sm font-medium text-white transition-colors hover:bg-green-700"
|
||||
>
|
||||
<LogIn className="h-4 w-4" />
|
||||
Sign In
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (status === 'expired') {
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-gray-50 px-4 dark:bg-gray-950">
|
||||
<div className="w-full max-w-md text-center">
|
||||
<div className="mx-auto mb-6 flex h-20 w-20 items-center justify-center rounded-2xl bg-amber-100 dark:bg-amber-900/30">
|
||||
<AlertTriangle className="h-10 w-10 text-amber-600 dark:text-amber-400" />
|
||||
</div>
|
||||
<h1 className="text-2xl font-semibold text-gray-900 dark:text-white">
|
||||
Link expired or invalid
|
||||
</h1>
|
||||
<p className="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||
{!token
|
||||
? 'No verification token was provided. Use the link from your email.'
|
||||
: 'This verification link is no longer valid. You may need to request a new one.'}
|
||||
</p>
|
||||
<div className="mt-6 flex justify-center gap-3">
|
||||
<Link
|
||||
to="/login"
|
||||
className="inline-flex items-center gap-2 rounded-xl border border-gray-300 bg-white px-5 py-2.5 text-sm font-medium text-gray-700 transition-colors hover:bg-gray-50 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700"
|
||||
>
|
||||
Sign In
|
||||
</Link>
|
||||
<Link
|
||||
to="/settings"
|
||||
className="inline-flex items-center gap-2 rounded-xl bg-green-600 px-5 py-2.5 text-sm font-medium text-white transition-colors hover:bg-green-700"
|
||||
>
|
||||
Resend Email
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen items-center justify-center bg-gray-50 px-4 dark:bg-gray-950">
|
||||
<div className="w-full max-w-md text-center">
|
||||
<div className="mx-auto mb-6 flex h-20 w-20 items-center justify-center rounded-2xl bg-red-100 dark:bg-red-900/30">
|
||||
<AlertTriangle className="h-10 w-10 text-red-600 dark:text-red-400" />
|
||||
</div>
|
||||
<h1 className="text-2xl font-semibold text-gray-900 dark:text-white">
|
||||
Unable to verify
|
||||
</h1>
|
||||
<p className="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||
We could not verify your email right now. Check your connection and try the link again, or sign in and request a new verification email.
|
||||
</p>
|
||||
<div className="mt-6">
|
||||
<Link
|
||||
to="/login"
|
||||
className="inline-flex items-center gap-2 rounded-xl bg-green-600 px-5 py-2.5 text-sm font-medium text-white transition-colors hover:bg-green-700"
|
||||
>
|
||||
<LogIn className="h-4 w-4" />
|
||||
Sign In
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,324 @@
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Copy, Eye, Mail, RefreshCw, ShieldCheck, User, UserPlus, X } from 'lucide-react';
|
||||
import { pb } from '../../lib/pocketbase';
|
||||
import type { TechnicianUser } from '../../lib/technicianData';
|
||||
import { fetchTechnicianUsers } from '../../lib/technicianData';
|
||||
import type { TechnicianInvite } from '../../lib/technicianInvites';
|
||||
import {
|
||||
createTechnicianInvite,
|
||||
fetchTechnicianInvites,
|
||||
revokeTechnicianInvite,
|
||||
} from '../../lib/technicianInvites';
|
||||
import { ListSkeleton } from '../../components/ui/ListSkeleton';
|
||||
import { ListError } from '../../components/ui/ListError';
|
||||
|
||||
export default function AdvisorTeam() {
|
||||
const navigate = useNavigate();
|
||||
const userId = pb.authStore.model?.id as string | undefined;
|
||||
const [technicians, setTechnicians] = useState<TechnicianUser[]>([]);
|
||||
const [invites, setInvites] = useState<TechnicianInvite[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [techsError, setTechsError] = useState(false);
|
||||
const [invitesError, setInvitesError] = useState(false);
|
||||
const [showInviteForm, setShowInviteForm] = useState(false);
|
||||
const [email, setEmail] = useState('');
|
||||
const [displayName, setDisplayName] = useState('');
|
||||
const [creating, setCreating] = useState(false);
|
||||
const [message, setMessage] = useState<{ type: 'success' | 'error'; text: string } | null>(null);
|
||||
const [lastInviteUrl, setLastInviteUrl] = useState('');
|
||||
|
||||
const loadTeam = useCallback(async () => {
|
||||
if (!userId) return;
|
||||
setLoading(true);
|
||||
setTechsError(false);
|
||||
setInvitesError(false);
|
||||
try {
|
||||
const [techs, nextInvites] = await Promise.all([
|
||||
fetchTechnicianUsers(userId),
|
||||
fetchTechnicianInvites(userId),
|
||||
]);
|
||||
setTechnicians(techs);
|
||||
setInvites(nextInvites);
|
||||
} catch {
|
||||
setTechsError(true);
|
||||
setInvitesError(true);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [userId]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadTeam();
|
||||
}, [loadTeam]);
|
||||
|
||||
const copyText = async (value: string) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(value);
|
||||
setMessage({ type: 'success', text: 'Invite link copied.' });
|
||||
} catch {
|
||||
setMessage({ type: 'error', text: 'Copy failed. Select and copy the link manually.' });
|
||||
}
|
||||
};
|
||||
|
||||
const handleCreateInvite = async () => {
|
||||
if (!userId) return;
|
||||
if (!email.trim()) {
|
||||
setMessage({ type: 'error', text: 'Enter the technician email.' });
|
||||
return;
|
||||
}
|
||||
|
||||
setCreating(true);
|
||||
setMessage(null);
|
||||
try {
|
||||
const created = await createTechnicianInvite(userId, email, displayName);
|
||||
setEmail('');
|
||||
setDisplayName('');
|
||||
setShowInviteForm(false);
|
||||
setLastInviteUrl(created.inviteUrl);
|
||||
await loadTeam();
|
||||
await copyText(created.inviteUrl);
|
||||
} catch {
|
||||
setMessage({ type: 'error', text: 'Invite could not be created. Check PocketBase setup and try again.' });
|
||||
} finally {
|
||||
setCreating(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleRevoke = async (invite: TechnicianInvite) => {
|
||||
if (!window.confirm(`Revoke invite for ${invite.email}?`)) return;
|
||||
try {
|
||||
await revokeTechnicianInvite(invite.id);
|
||||
await loadTeam();
|
||||
setMessage({ type: 'success', text: 'Invite revoked.' });
|
||||
} catch {
|
||||
setMessage({ type: 'error', text: 'Invite could not be revoked.' });
|
||||
}
|
||||
};
|
||||
|
||||
const pendingInvites = invites.filter((invite) => invite.status === 'pending');
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-6xl space-y-6">
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">Team</h1>
|
||||
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||
Invite technicians and manage who can see assigned bay work.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
onClick={loadTeam}
|
||||
className="inline-flex items-center gap-1.5 rounded-lg border border-gray-200 bg-white px-3 py-2 text-sm font-medium text-gray-600 hover:bg-gray-50 dark:border-gray-800 dark:bg-gray-900 dark:text-gray-400 dark:hover:bg-gray-800"
|
||||
>
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
Refresh
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setShowInviteForm(true)}
|
||||
className="inline-flex items-center gap-1.5 rounded-lg bg-green-600 px-4 py-2 text-sm font-medium text-white hover:bg-green-700"
|
||||
>
|
||||
<UserPlus className="h-4 w-4" />
|
||||
Invite Technician
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{message && (
|
||||
<div className={`rounded-lg px-4 py-3 text-sm font-medium ${
|
||||
message.type === 'success'
|
||||
? 'bg-green-50 text-green-700 dark:bg-green-900/30 dark:text-green-400'
|
||||
: 'bg-red-50 text-red-700 dark:bg-red-900/30 dark:text-red-400'
|
||||
}`}>
|
||||
{message.text}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{lastInviteUrl && (
|
||||
<div className="rounded-xl border border-green-200 bg-green-50 p-4 dark:border-green-900/50 dark:bg-green-900/20">
|
||||
<p className="text-sm font-medium text-green-800 dark:text-green-300">Latest invite link</p>
|
||||
<div className="mt-2 flex gap-2">
|
||||
<input
|
||||
readOnly
|
||||
value={lastInviteUrl}
|
||||
className="min-w-0 flex-1 rounded-lg border border-green-200 bg-white px-3 py-2 text-xs text-gray-700 dark:border-green-900/50 dark:bg-gray-900 dark:text-gray-300"
|
||||
/>
|
||||
<button
|
||||
onClick={() => copyText(lastInviteUrl)}
|
||||
className="inline-flex items-center gap-1 rounded-lg bg-green-600 px-3 py-2 text-xs font-medium text-white hover:bg-green-700"
|
||||
>
|
||||
<Copy className="h-3.5 w-3.5" />
|
||||
Copy
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="grid gap-4 sm:grid-cols-3">
|
||||
<MetricCard label="Technicians" value={technicians.length} />
|
||||
<MetricCard label="Pending Invites" value={pendingInvites.length} />
|
||||
<MetricCard label="Active Access" value={technicians.length + pendingInvites.length} />
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 lg:grid-cols-2">
|
||||
<section className="rounded-2xl border border-gray-200 bg-white p-5 shadow-sm dark:border-gray-800 dark:bg-gray-900">
|
||||
<div className="flex items-center gap-2">
|
||||
<ShieldCheck className="h-5 w-5 text-green-600 dark:text-green-400" />
|
||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">Technician Accounts</h2>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<ListSkeleton rows={3} />
|
||||
) : techsError ? (
|
||||
<ListError title="Unable to load technicians" message="Failed to load technician accounts." onRetry={loadTeam} />
|
||||
) : technicians.length === 0 ? (
|
||||
<EmptyBlock text="No technicians have accepted an invite yet." />
|
||||
) : (
|
||||
<div className="mt-4 space-y-2">
|
||||
{technicians.map((tech) => (
|
||||
<div key={tech.id} className="flex items-center gap-3 rounded-xl border border-gray-100 bg-gray-50 p-3 dark:border-gray-800 dark:bg-gray-800/50">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-green-100 dark:bg-green-900/30">
|
||||
<User className="h-5 w-5 text-green-600 dark:text-green-400" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="truncate text-sm font-medium text-gray-900 dark:text-white">{tech.displayName || 'Technician'}</p>
|
||||
<p className="truncate text-xs text-gray-500 dark:text-gray-400">{tech.email}</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => navigate(`/technician?view_as=${tech.id}`)}
|
||||
className="shrink-0 inline-flex items-center gap-1 rounded-lg bg-blue-50 px-3 py-1.5 text-xs font-medium text-blue-600 hover:bg-blue-100 dark:bg-blue-900/20 dark:text-blue-400 dark:hover:bg-blue-900/40"
|
||||
>
|
||||
<Eye className="h-3.5 w-3.5" />
|
||||
View Portal
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
<section className="rounded-2xl border border-gray-200 bg-white p-5 shadow-sm dark:border-gray-800 dark:bg-gray-900">
|
||||
<div className="flex items-center gap-2">
|
||||
<Mail className="h-5 w-5 text-green-600 dark:text-green-400" />
|
||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">Invites</h2>
|
||||
</div>
|
||||
|
||||
{loading ? (
|
||||
<ListSkeleton rows={3} />
|
||||
) : invitesError ? (
|
||||
<ListError title="Unable to load invites" message="Failed to load technician invites." onRetry={loadTeam} />
|
||||
) : invites.length === 0 ? (
|
||||
<EmptyBlock text="No technician invites created yet." />
|
||||
) : (
|
||||
<div className="mt-4 space-y-2">
|
||||
{invites.map((invite) => (
|
||||
<div key={invite.id} className="rounded-xl border border-gray-100 bg-gray-50 p-3 dark:border-gray-800 dark:bg-gray-800/50">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-sm font-medium text-gray-900 dark:text-white">{invite.displayName || invite.email}</p>
|
||||
<p className="truncate text-xs text-gray-500 dark:text-gray-400">{invite.email}</p>
|
||||
<p className="mt-1 text-xs text-gray-400 dark:text-gray-500">Expires {formatDate(invite.expiresAt)}</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<InviteBadge status={invite.status} />
|
||||
{invite.status === 'pending' && (
|
||||
<button
|
||||
onClick={() => handleRevoke(invite)}
|
||||
className="rounded-lg p-1 text-gray-400 hover:bg-red-50 hover:text-red-600 dark:hover:bg-red-900/20 dark:hover:text-red-400"
|
||||
title="Revoke invite"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
|
||||
{showInviteForm && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4">
|
||||
<div className="w-full max-w-md rounded-2xl border border-gray-200 bg-white p-6 shadow-xl dark:border-gray-800 dark:bg-gray-900">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">Invite Technician</h3>
|
||||
<button onClick={() => setShowInviteForm(false)} className="rounded-lg p-1 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300">
|
||||
<X className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 space-y-4">
|
||||
<label className="block">
|
||||
<span className="mb-1.5 block text-sm font-medium text-gray-700 dark:text-gray-300">Technician Email</span>
|
||||
<input
|
||||
type="email"
|
||||
value={email}
|
||||
onChange={(e) => setEmail(e.target.value)}
|
||||
placeholder="tech@example.com"
|
||||
className="w-full rounded-xl border border-gray-300 bg-white px-3 py-2.5 text-sm text-gray-900 focus:border-green-500 focus:outline-none focus:ring-2 focus:ring-green-500/20 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-100"
|
||||
/>
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="mb-1.5 block text-sm font-medium text-gray-700 dark:text-gray-300">Display Name</span>
|
||||
<input
|
||||
value={displayName}
|
||||
onChange={(e) => setDisplayName(e.target.value)}
|
||||
placeholder="Alex Rivera"
|
||||
className="w-full rounded-xl border border-gray-300 bg-white px-3 py-2.5 text-sm text-gray-900 focus:border-green-500 focus:outline-none focus:ring-2 focus:ring-green-500/20 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-100"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="mt-5 flex gap-3">
|
||||
<button
|
||||
onClick={() => setShowInviteForm(false)}
|
||||
className="flex-1 rounded-xl border border-gray-200 bg-white px-4 py-2.5 text-sm font-medium text-gray-700 hover:bg-gray-50 dark:border-gray-800 dark:bg-gray-900 dark:text-gray-300 dark:hover:bg-gray-800"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
onClick={handleCreateInvite}
|
||||
disabled={creating}
|
||||
className="flex-1 rounded-xl bg-green-600 px-4 py-2.5 text-sm font-medium text-white hover:bg-green-700 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
{creating ? 'Creating…' : 'Create Invite'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MetricCard({ label, value }: { label: string; value: number }) {
|
||||
return (
|
||||
<div className="rounded-2xl border border-gray-200 bg-white p-4 shadow-sm dark:border-gray-800 dark:bg-gray-900">
|
||||
<p className="text-xs font-medium uppercase tracking-wider text-gray-500 dark:text-gray-400">{label}</p>
|
||||
<p className="mt-2 text-3xl font-bold text-gray-900 dark:text-white">{value}</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function EmptyBlock({ text }: { text: string }) {
|
||||
return <p className="py-10 text-center text-sm text-gray-500 dark:text-gray-400">{text}</p>;
|
||||
}
|
||||
|
||||
function InviteBadge({ status }: { status: TechnicianInvite['status'] }) {
|
||||
const classes = {
|
||||
pending: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/40 dark:text-yellow-300',
|
||||
accepted: 'bg-green-100 text-green-800 dark:bg-green-900/40 dark:text-green-300',
|
||||
revoked: 'bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-400',
|
||||
}[status];
|
||||
return <span className={`rounded-full px-2 py-0.5 text-xs font-medium ${classes}`}>{status}</span>;
|
||||
}
|
||||
|
||||
function formatDate(value: string): string {
|
||||
if (!value) return '—';
|
||||
const d = new Date(value);
|
||||
if (Number.isNaN(d.getTime())) return '—';
|
||||
return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
|
||||
}
|
||||
@@ -0,0 +1,372 @@
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||
import { Download, RefreshCw } from 'lucide-react';
|
||||
import { pb } from '../../lib/pocketbase';
|
||||
import type { TechnicianAssignment, TechnicianUser } from '../../lib/technicianData';
|
||||
import { fetchAllAssignmentsForShop, fetchTechnicianUsers } from '../../lib/technicianData';
|
||||
import { ListSkeleton } from '../../components/ui/ListSkeleton';
|
||||
import { ListError } from '../../components/ui/ListError';
|
||||
|
||||
interface CsvRow {
|
||||
techName: string;
|
||||
date: string;
|
||||
roNumber: string;
|
||||
vehicle: string;
|
||||
clockIn: string;
|
||||
clockOut: string;
|
||||
minutes: number;
|
||||
status: string;
|
||||
}
|
||||
|
||||
function getWeekDefaults(): { from: string; to: string } {
|
||||
const today = new Date();
|
||||
const dayOfWeek = today.getDay();
|
||||
const monday = new Date(today);
|
||||
monday.setDate(today.getDate() - (dayOfWeek === 0 ? 6 : dayOfWeek - 1));
|
||||
const sunday = new Date(monday);
|
||||
sunday.setDate(monday.getDate() + 6);
|
||||
|
||||
const fmt = (d: Date): string => {
|
||||
const y = d.getFullYear();
|
||||
const m = String(d.getMonth() + 1).padStart(2, '0');
|
||||
const day = String(d.getDate()).padStart(2, '0');
|
||||
return `${y}-${m}-${day}`;
|
||||
};
|
||||
|
||||
return { from: fmt(monday), to: fmt(sunday) };
|
||||
}
|
||||
|
||||
function csvEscape(value: string | number): string {
|
||||
const s = typeof value === 'number' ? String(value) : value;
|
||||
if (s.includes('"') || s.includes(',') || s.includes('\n')) {
|
||||
return `"${s.replace(/"/g, '""')}"`;
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
function formatTime(iso: string): string {
|
||||
if (!iso) return '';
|
||||
const d = new Date(iso);
|
||||
if (Number.isNaN(d.getTime())) return '';
|
||||
return d.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit', hour12: true });
|
||||
}
|
||||
|
||||
function formatDate(value: string): string {
|
||||
if (!value) return '';
|
||||
const d = new Date(value);
|
||||
if (Number.isNaN(d.getTime())) return '';
|
||||
return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
|
||||
}
|
||||
|
||||
function ymd(date: Date): string {
|
||||
const y = date.getFullYear();
|
||||
const m = String(date.getMonth() + 1).padStart(2, '0');
|
||||
const d = String(date.getDate()).padStart(2, '0');
|
||||
return `${y}-${m}-${d}`;
|
||||
}
|
||||
|
||||
function buildTechLookup(techs: TechnicianUser[]): Map<string, string> {
|
||||
const map = new Map<string, string>();
|
||||
for (const t of techs) {
|
||||
map.set(t.id, t.displayName || t.email || t.id);
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
export default function TimeCards() {
|
||||
const userId = pb.authStore.model?.id as string | undefined;
|
||||
const week = useMemo(() => getWeekDefaults(), []);
|
||||
const [dateFrom, setDateFrom] = useState(week.from);
|
||||
const [dateTo, setDateTo] = useState(week.to);
|
||||
const [assignments, setAssignments] = useState<TechnicianAssignment[]>([]);
|
||||
const [techLookup, setTechLookup] = useState<Map<string, string>>(new Map());
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState(false);
|
||||
const [generating, setGenerating] = useState(false);
|
||||
|
||||
const loadData = useCallback(async () => {
|
||||
if (!userId) return;
|
||||
setLoading(true);
|
||||
setError(false);
|
||||
try {
|
||||
const [techs, allAssignments] = await Promise.all([
|
||||
fetchTechnicianUsers(userId),
|
||||
fetchAllAssignmentsForShop(userId),
|
||||
]);
|
||||
setTechLookup(buildTechLookup(techs));
|
||||
setAssignments(allAssignments);
|
||||
} catch {
|
||||
setError(true);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [userId]);
|
||||
|
||||
useEffect(() => {
|
||||
void loadData();
|
||||
}, [loadData]);
|
||||
|
||||
// Filter assignments whose clock entries fall within the selected date range
|
||||
const rows = useMemo((): CsvRow[] => {
|
||||
if (!dateFrom || !dateTo) return [];
|
||||
|
||||
const fromDate = ymd(new Date(dateFrom + 'T00:00:00'));
|
||||
const toDate = ymd(new Date(dateTo + 'T23:59:59'));
|
||||
|
||||
const results: CsvRow[] = [];
|
||||
|
||||
for (const a of assignments) {
|
||||
for (const entry of a.clockEntries) {
|
||||
if (!entry.start) continue;
|
||||
const startDate = new Date(entry.start);
|
||||
if (Number.isNaN(startDate.getTime())) continue;
|
||||
const entryDate = ymd(startDate);
|
||||
if (entryDate < fromDate || entryDate > toDate) continue;
|
||||
|
||||
const techName = techLookup.get(a.technicianUserId) || a.technicianUserId;
|
||||
|
||||
let endDisplay = '';
|
||||
let mins = 0;
|
||||
if (entry.end) {
|
||||
endDisplay = formatTime(entry.end);
|
||||
mins = entry.minutes ?? 0;
|
||||
}
|
||||
// If still running, show "—" for clock out and compute elapsed
|
||||
if (!entry.end) {
|
||||
mins = entry.minutes ?? Math.max(0, Math.round((Date.now() - startDate.getTime()) / 60000));
|
||||
}
|
||||
|
||||
results.push({
|
||||
techName,
|
||||
date: formatDate(entry.start),
|
||||
roNumber: a.roNumber,
|
||||
vehicle: a.vehicleInfo,
|
||||
clockIn: formatTime(entry.start),
|
||||
clockOut: entry.end ? endDisplay : '—',
|
||||
minutes: mins,
|
||||
status: a.status,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}, [assignments, techLookup, dateFrom, dateTo]);
|
||||
|
||||
const buildCsv = useCallback((): string => {
|
||||
const headers = [
|
||||
'Technician Name',
|
||||
'Date',
|
||||
'RO#',
|
||||
'Vehicle',
|
||||
'Clock In',
|
||||
'Clock Out',
|
||||
'Minutes',
|
||||
'Status',
|
||||
];
|
||||
|
||||
const lines = [headers.map(csvEscape).join(',')];
|
||||
for (const row of rows) {
|
||||
lines.push(
|
||||
[
|
||||
csvEscape(row.techName),
|
||||
csvEscape(row.date),
|
||||
csvEscape(row.roNumber),
|
||||
csvEscape(row.vehicle),
|
||||
csvEscape(row.clockIn),
|
||||
csvEscape(row.clockOut),
|
||||
csvEscape(row.minutes),
|
||||
csvEscape(row.status),
|
||||
].join(','),
|
||||
);
|
||||
}
|
||||
return lines.join('\n');
|
||||
}, [rows]);
|
||||
|
||||
const handleDownload = useCallback(() => {
|
||||
setGenerating(true);
|
||||
try {
|
||||
const csvContent = buildCsv();
|
||||
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `timecard-${dateFrom}-to-${dateTo}.csv`;
|
||||
a.click();
|
||||
URL.revokeObjectURL(url);
|
||||
} finally {
|
||||
setGenerating(false);
|
||||
}
|
||||
}, [buildCsv, dateFrom, dateTo]);
|
||||
|
||||
const hasData = rows.length > 0;
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-7xl space-y-6">
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900 dark:text-white">Time Cards</h1>
|
||||
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
|
||||
Export technician clock-in / clock-out data as CSV.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={loadData}
|
||||
className="inline-flex items-center gap-1.5 rounded-lg border border-gray-200 bg-white px-3 py-2 text-sm font-medium text-gray-600 hover:bg-gray-50 dark:border-gray-800 dark:bg-gray-900 dark:text-gray-400 dark:hover:bg-gray-800"
|
||||
>
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Date range controls */}
|
||||
<div className="rounded-2xl border border-gray-200 bg-white p-5 shadow-sm dark:border-gray-800 dark:bg-gray-900">
|
||||
<div className="flex flex-col gap-4 sm:flex-row sm:items-end">
|
||||
<label className="block">
|
||||
<span className="mb-1.5 block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
From
|
||||
</span>
|
||||
<input
|
||||
type="date"
|
||||
value={dateFrom}
|
||||
onChange={(e) => setDateFrom(e.target.value)}
|
||||
className="rounded-xl border border-gray-300 bg-white px-3 py-2 text-sm text-gray-900 focus:border-green-500 focus:outline-none focus:ring-2 focus:ring-green-500/20 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-100"
|
||||
/>
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="mb-1.5 block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
To
|
||||
</span>
|
||||
<input
|
||||
type="date"
|
||||
value={dateTo}
|
||||
onChange={(e) => setDateTo(e.target.value)}
|
||||
className="rounded-xl border border-gray-300 bg-white px-3 py-2 text-sm text-gray-900 focus:border-green-500 focus:outline-none focus:ring-2 focus:ring-green-500/20 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-100"
|
||||
/>
|
||||
</label>
|
||||
<button
|
||||
onClick={handleDownload}
|
||||
disabled={!hasData || generating}
|
||||
className="inline-flex items-center gap-1.5 rounded-xl bg-green-600 px-4 py-2 text-sm font-medium text-white hover:bg-green-700 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
>
|
||||
<Download className="h-4 w-4" />
|
||||
{generating ? 'Generating...' : 'Download CSV'}
|
||||
</button>
|
||||
</div>
|
||||
<p className="mt-3 text-xs text-gray-500 dark:text-gray-400">
|
||||
{loading
|
||||
? 'Loading data...'
|
||||
: error
|
||||
? 'Failed to load data. Try again.'
|
||||
: `${rows.length} clock-entry row${rows.length !== 1 ? 's' : ''} in range`}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Preview table */}
|
||||
{loading ? (
|
||||
<ListSkeleton rows={5} />
|
||||
) : error ? (
|
||||
<ListError
|
||||
title="Unable to load time-card data"
|
||||
message="Failed to load assignments or technician data."
|
||||
onRetry={loadData}
|
||||
/>
|
||||
) : (
|
||||
<div className="overflow-hidden rounded-2xl border border-gray-200 bg-white shadow-sm dark:border-gray-800 dark:bg-gray-900">
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-left text-sm">
|
||||
<thead className="border-b border-gray-200 bg-gray-50 dark:border-gray-800 dark:bg-gray-800/50">
|
||||
<tr>
|
||||
<th className="px-4 py-3 font-medium text-gray-700 dark:text-gray-300">
|
||||
Technician Name
|
||||
</th>
|
||||
<th className="px-4 py-3 font-medium text-gray-700 dark:text-gray-300">
|
||||
Date
|
||||
</th>
|
||||
<th className="px-4 py-3 font-medium text-gray-700 dark:text-gray-300">
|
||||
RO#
|
||||
</th>
|
||||
<th className="px-4 py-3 font-medium text-gray-700 dark:text-gray-300">
|
||||
Vehicle
|
||||
</th>
|
||||
<th className="px-4 py-3 font-medium text-gray-700 dark:text-gray-300">
|
||||
Clock In
|
||||
</th>
|
||||
<th className="px-4 py-3 font-medium text-gray-700 dark:text-gray-300">
|
||||
Clock Out
|
||||
</th>
|
||||
<th className="px-4 py-3 font-medium text-gray-700 dark:text-gray-300">
|
||||
Minutes
|
||||
</th>
|
||||
<th className="px-4 py-3 font-medium text-gray-700 dark:text-gray-300">
|
||||
Status
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100 dark:divide-gray-800">
|
||||
{rows.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={8} className="px-4 py-10 text-center text-sm text-gray-500 dark:text-gray-400">
|
||||
No clock entries found in the selected date range.
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
rows.map((row, idx) => (
|
||||
<tr
|
||||
key={idx}
|
||||
className="hover:bg-gray-50 dark:hover:bg-gray-800/30"
|
||||
>
|
||||
<td className="px-4 py-2.5 text-gray-900 dark:text-white">
|
||||
{row.techName}
|
||||
</td>
|
||||
<td className="px-4 py-2.5 whitespace-nowrap text-gray-600 dark:text-gray-400">
|
||||
{row.date}
|
||||
</td>
|
||||
<td className="px-4 py-2.5 whitespace-nowrap text-gray-600 dark:text-gray-400">
|
||||
{row.roNumber}
|
||||
</td>
|
||||
<td className="px-4 py-2.5 text-gray-600 dark:text-gray-400">
|
||||
{row.vehicle}
|
||||
</td>
|
||||
<td className="px-4 py-2.5 whitespace-nowrap text-gray-600 dark:text-gray-400">
|
||||
{row.clockIn}
|
||||
</td>
|
||||
<td className="px-4 py-2.5 whitespace-nowrap text-gray-600 dark:text-gray-400">
|
||||
{row.clockOut}
|
||||
</td>
|
||||
<td className="px-4 py-2.5 whitespace-nowrap text-gray-900 dark:text-white">
|
||||
{row.minutes}
|
||||
</td>
|
||||
<td className="px-4 py-2.5 whitespace-nowrap">
|
||||
<StatusBadge status={row.status} />
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusBadge({ status }: { status: string }) {
|
||||
const classes: Record<string, string> = {
|
||||
pending: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/40 dark:text-yellow-300',
|
||||
in_progress: 'bg-blue-100 text-blue-800 dark:bg-blue-900/40 dark:text-blue-300',
|
||||
waiting_parts: 'bg-orange-100 text-orange-800 dark:bg-orange-900/40 dark:text-orange-300',
|
||||
completed: 'bg-green-100 text-green-800 dark:bg-green-900/40 dark:text-green-300',
|
||||
};
|
||||
|
||||
const label = status.replace(/_/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
|
||||
|
||||
return (
|
||||
<span
|
||||
className={`rounded-full px-2 py-0.5 text-xs font-medium ${
|
||||
classes[status] || 'bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-400'
|
||||
}`}
|
||||
>
|
||||
{label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,273 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useTechnicianContext, useTechnicianNav } from '../../components/technician/TechnicianContext';
|
||||
import {
|
||||
Wrench,
|
||||
ClipboardList,
|
||||
CheckCircle2,
|
||||
AlertTriangle,
|
||||
Play,
|
||||
Timer,
|
||||
} from 'lucide-react';
|
||||
import type { TechnicianAssignment } from '../../lib/technicianData';
|
||||
import {
|
||||
fetchAllTechnicianAssignments,
|
||||
clockElapsedMinutes,
|
||||
} from '../../lib/technicianData';
|
||||
import { formatElapsed } from '../../lib/format';
|
||||
import { ListSkeleton } from '../../components/ui/ListSkeleton';
|
||||
import { ListError } from '../../components/ui/ListError';
|
||||
|
||||
export default function TechnicianDashboard() {
|
||||
const { navigateTech } = useTechnicianNav();
|
||||
const { effectiveUserId } = useTechnicianContext();
|
||||
const [assignments, setAssignments] = useState<TechnicianAssignment[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [, setTick] = useState(0);
|
||||
|
||||
const load = async () => {
|
||||
if (!effectiveUserId) return;
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const data = await fetchAllTechnicianAssignments(effectiveUserId);
|
||||
setAssignments(data);
|
||||
} catch {
|
||||
setError('Failed to load your assignments. Please try again.');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, [effectiveUserId]);
|
||||
|
||||
useEffect(() => {
|
||||
const running = assignments.some((a) =>
|
||||
a.clockEntries.some((e) => !e.end),
|
||||
);
|
||||
if (!running) return;
|
||||
const id = setInterval(() => setTick((t) => t + 1), 5000);
|
||||
return () => clearInterval(id);
|
||||
}, [assignments]);
|
||||
|
||||
const active = assignments.filter(
|
||||
(a) => a.status === 'in_progress' || a.status === 'pending',
|
||||
);
|
||||
const inProgress = assignments.filter((a) => a.status === 'in_progress');
|
||||
const waitingParts = assignments.filter((a) => a.status === 'waiting_parts');
|
||||
const completed = assignments.filter((a) => a.status === 'completed');
|
||||
const todayCompleted = completed.filter((a) => {
|
||||
const d = new Date(a.updated);
|
||||
const now = new Date();
|
||||
return d.toDateString() === now.toDateString();
|
||||
});
|
||||
|
||||
const totalElapsed = assignments.reduce(
|
||||
(sum, a) => sum + clockElapsedMinutes(a.clockEntries),
|
||||
0,
|
||||
);
|
||||
|
||||
const runningCount = assignments.filter((a) =>
|
||||
a.clockEntries.some((e) => !e.end),
|
||||
).length;
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="mx-auto max-w-4xl px-4 py-8">
|
||||
<ListSkeleton rows={4} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
<div className="mx-auto max-w-4xl px-4 py-8">
|
||||
<ListError title="Unable to load dashboard" message={error} onRetry={load} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (assignments.length === 0) {
|
||||
return (
|
||||
<div className="mx-auto max-w-4xl px-4 py-16 text-center">
|
||||
<div className="mx-auto flex h-16 w-16 items-center justify-center rounded-full bg-gray-100 dark:bg-gray-800">
|
||||
<Wrench className="h-8 w-8 text-gray-400 dark:text-gray-500" />
|
||||
</div>
|
||||
<h2 className="mt-6 text-xl font-semibold text-gray-900 dark:text-white">
|
||||
No Assigned Jobs
|
||||
</h2>
|
||||
<p className="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||
Your advisor hasn't assigned any work yet. Check back soon or
|
||||
reach out to the front desk.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-4xl space-y-6">
|
||||
{/* Stats row */}
|
||||
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
|
||||
<StatCard
|
||||
icon={ClipboardList}
|
||||
label="Active"
|
||||
value={active.length}
|
||||
color="blue"
|
||||
/>
|
||||
<StatCard
|
||||
icon={Play}
|
||||
label="In Bay"
|
||||
value={inProgress.length}
|
||||
color="yellow"
|
||||
/>
|
||||
<StatCard
|
||||
icon={AlertTriangle}
|
||||
label="Waiting Parts"
|
||||
value={waitingParts.length}
|
||||
color="orange"
|
||||
/>
|
||||
<StatCard
|
||||
icon={CheckCircle2}
|
||||
label="Completed Today"
|
||||
value={todayCompleted.length}
|
||||
color="green"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Clock status */}
|
||||
<div className="rounded-2xl border border-gray-200 bg-white p-5 shadow-sm dark:border-gray-800 dark:bg-gray-900">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div
|
||||
className={`flex h-10 w-10 items-center justify-center rounded-full ${
|
||||
runningCount > 0
|
||||
? 'bg-green-100 text-green-600 dark:bg-green-900/30 dark:text-green-400'
|
||||
: 'bg-gray-100 text-gray-400 dark:bg-gray-800 dark:text-gray-500'
|
||||
}`}
|
||||
>
|
||||
<Timer className="h-5 w-5" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm font-medium text-gray-900 dark:text-white">
|
||||
{runningCount > 0
|
||||
? `${runningCount} clock${runningCount > 1 ? 's' : ''} running`
|
||||
: 'All clocks stopped'}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||
{formatElapsed(totalElapsed)} total tracked
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Active jobs list */}
|
||||
{active.length > 0 && (
|
||||
<div>
|
||||
<h2 className="mb-3 text-sm font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">
|
||||
Active Jobs
|
||||
</h2>
|
||||
<div className="space-y-3">
|
||||
{active.map((job) => {
|
||||
const running = job.clockEntries.some((e) => !e.end);
|
||||
return (
|
||||
<button
|
||||
key={job.id}
|
||||
onClick={() => navigateTech(`/technician/jobs/${job.id}`)}
|
||||
className="w-full rounded-2xl border border-gray-200 bg-white p-5 text-left shadow-sm transition-colors hover:border-green-300 hover:shadow-md dark:border-gray-800 dark:bg-gray-900 dark:hover:border-green-700"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-lg font-bold text-gray-900 dark:text-white">
|
||||
{job.roNumber || '—'}
|
||||
</span>
|
||||
{job.status === 'in_progress' && (
|
||||
<span className="inline-flex items-center rounded-full bg-yellow-100 px-2 py-0.5 text-xs font-medium text-yellow-800 dark:bg-yellow-900/40 dark:text-yellow-300">
|
||||
In Progress
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="mt-1 text-sm text-gray-600 dark:text-gray-400">
|
||||
{job.vehicleInfo || 'No vehicle info'}
|
||||
</p>
|
||||
{job.serviceLocation && (
|
||||
<p className="mt-0.5 text-xs text-gray-400 dark:text-gray-500">
|
||||
{job.serviceLocation}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col items-end gap-2">
|
||||
{running ? (
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-green-100 px-2.5 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 In
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-xs text-gray-400 dark:text-gray-500">
|
||||
{formatElapsed(
|
||||
clockElapsedMinutes(job.clockEntries),
|
||||
)}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Quick action: View all jobs */}
|
||||
<div className="pt-2">
|
||||
<button
|
||||
onClick={() => navigateTech('/technician/jobs')}
|
||||
className="flex w-full items-center justify-center gap-2 rounded-xl border border-dashed border-gray-300 p-4 text-sm font-medium text-gray-600 transition-colors hover:border-green-400 hover:text-green-600 dark:border-gray-700 dark:text-gray-400 dark:hover:border-green-600 dark:hover:text-green-400"
|
||||
>
|
||||
<ClipboardList className="h-4 w-4" />
|
||||
View All Assigned Jobs ({assignments.length})
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StatCard({
|
||||
icon: Icon,
|
||||
label,
|
||||
value,
|
||||
color,
|
||||
}: {
|
||||
icon: React.ComponentType<{ className?: string }>;
|
||||
label: string;
|
||||
value: number;
|
||||
color: 'blue' | 'yellow' | 'orange' | 'green';
|
||||
}) {
|
||||
const colorMap: Record<string, string> = {
|
||||
blue: 'bg-blue-100 text-blue-600 dark:bg-blue-900/30 dark:text-blue-400',
|
||||
yellow:
|
||||
'bg-yellow-100 text-yellow-600 dark:bg-yellow-900/30 dark:text-yellow-400',
|
||||
orange:
|
||||
'bg-orange-100 text-orange-600 dark:bg-orange-900/30 dark:text-orange-400',
|
||||
green:
|
||||
'bg-green-100 text-green-600 dark:bg-green-900/30 dark:text-green-400',
|
||||
};
|
||||
return (
|
||||
<div className="rounded-2xl border border-gray-200 bg-white p-4 shadow-sm dark:border-gray-800 dark:bg-gray-900">
|
||||
<div
|
||||
className={`mx-auto flex h-10 w-10 items-center justify-center rounded-full ${colorMap[color]}`}
|
||||
>
|
||||
<Icon className="h-5 w-5" />
|
||||
</div>
|
||||
<p className="mt-2 text-center text-2xl font-bold text-gray-900 dark:text-white">
|
||||
{value}
|
||||
</p>
|
||||
<p className="text-center text-xs text-gray-500 dark:text-gray-400">
|
||||
{label}
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,521 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useParams } from 'react-router-dom';
|
||||
import { pb } from '../../lib/pocketbase';
|
||||
import { useTechnicianNav } from '../../components/technician/TechnicianContext';
|
||||
import {
|
||||
ArrowLeft,
|
||||
Clock,
|
||||
Play,
|
||||
Square,
|
||||
CheckCircle2,
|
||||
MessageSquare,
|
||||
MapPin,
|
||||
Wrench,
|
||||
} from 'lucide-react';
|
||||
import type { TechnicianAssignment, SanitizedService } from '../../lib/technicianData';
|
||||
import {
|
||||
fetchTechnicianAssignment,
|
||||
updateServiceStatus,
|
||||
updateServiceSubStatus,
|
||||
clockStart,
|
||||
clockStop,
|
||||
clockElapsedMinutes,
|
||||
submitAdvisorRequest,
|
||||
} from '../../lib/technicianData';
|
||||
import { useToast } from '../../components/ui/Toast';
|
||||
import { formatElapsed, formatClockTime } from '../../lib/format';
|
||||
import { logError } from '../../lib/userMessages';
|
||||
|
||||
const SERVICE_STATUS_CONFIG: Record<
|
||||
string,
|
||||
{ label: string; colors: string }
|
||||
> = {
|
||||
pending: {
|
||||
label: 'Pending',
|
||||
colors: 'bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-400',
|
||||
},
|
||||
in_progress: {
|
||||
label: 'In Progress',
|
||||
colors:
|
||||
'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/40 dark:text-yellow-300',
|
||||
},
|
||||
completed: {
|
||||
label: 'Completed',
|
||||
colors:
|
||||
'bg-green-100 text-green-800 dark:bg-green-900/40 dark:text-green-300',
|
||||
},
|
||||
};
|
||||
|
||||
export default function TechnicianJobDetail() {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const { navigateTech } = useTechnicianNav();
|
||||
const { showToast } = useToast();
|
||||
const [assignment, setAssignment] = useState<TechnicianAssignment | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [requestText, setRequestText] = useState('');
|
||||
const [requestType, setRequestType] = useState<'parts' | 'help' | 'info'>('parts');
|
||||
const [sendingRequest, setSendingRequest] = useState(false);
|
||||
|
||||
const load = async () => {
|
||||
if (!id) return;
|
||||
setLoading(true);
|
||||
const data = await fetchTechnicianAssignment(id);
|
||||
setAssignment(data);
|
||||
setLoading(false);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
}, [id]);
|
||||
|
||||
const handleServiceStatus = async (serviceId: string, newStatus: SanitizedService['status']) => {
|
||||
if (!id || !assignment) return;
|
||||
try {
|
||||
await updateServiceStatus(id, serviceId, newStatus);
|
||||
} catch (err: any) {
|
||||
if (err?.name === 'StaleWriteError') {
|
||||
showToast('This job was just edited. Reloading…', 'error');
|
||||
setTimeout(() => window.location.reload(), 1200);
|
||||
return;
|
||||
}
|
||||
showToast('Failed to update service status.', 'error');
|
||||
logError('handleServiceStatus', err);
|
||||
return;
|
||||
}
|
||||
|
||||
// Reload to get fresh data
|
||||
await load();
|
||||
|
||||
// If all services are now completed, sync the parent RO status
|
||||
const updated = await fetchTechnicianAssignment(id);
|
||||
if (updated && updated.repairOrderId && updated.services.every((s) => s.status === 'completed')) {
|
||||
try {
|
||||
await pb.collection('repairOrders').update(updated.repairOrderId, {
|
||||
status: 'waiting_pickup',
|
||||
});
|
||||
} catch {
|
||||
// non-critical — the assignment is recorded regardless
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleClockToggle = async () => {
|
||||
if (!id || !assignment) return;
|
||||
const running = assignment.clockEntries.some((e) => !e.end);
|
||||
try {
|
||||
if (running) {
|
||||
await clockStop(id);
|
||||
} else {
|
||||
await clockStart(id);
|
||||
}
|
||||
} catch (err: any) {
|
||||
if (err?.name === 'StaleWriteError') {
|
||||
showToast('This job was just edited. Reloading…', 'error');
|
||||
setTimeout(() => window.location.reload(), 1200);
|
||||
return;
|
||||
}
|
||||
showToast('Failed to toggle clock.', 'error');
|
||||
logError('handleClockToggle', err);
|
||||
return;
|
||||
}
|
||||
await load();
|
||||
};
|
||||
|
||||
const handleSubmitRequest = async () => {
|
||||
if (!id || !requestText.trim()) return;
|
||||
setSendingRequest(true);
|
||||
try {
|
||||
await submitAdvisorRequest(id, {
|
||||
type: requestType,
|
||||
message: requestText.trim(),
|
||||
});
|
||||
setRequestText('');
|
||||
await load();
|
||||
} catch (err: any) {
|
||||
if (err?.name === 'StaleWriteError') {
|
||||
showToast('This job was just edited. Reloading…', 'error');
|
||||
setTimeout(() => window.location.reload(), 1200);
|
||||
return;
|
||||
}
|
||||
showToast('Failed to send request.', 'error');
|
||||
logError('handleSubmitRequest', err);
|
||||
} finally {
|
||||
setSendingRequest(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center py-24">
|
||||
<div className="animate-spin h-8 w-8 border-4 border-green-500 border-t-transparent rounded-full" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!assignment) {
|
||||
return (
|
||||
<div className="mx-auto max-w-4xl px-4 py-16 text-center">
|
||||
<div className="mx-auto flex h-14 w-14 items-center justify-center rounded-full bg-gray-100 dark:bg-gray-800">
|
||||
<Wrench className="h-7 w-7 text-gray-400 dark:text-gray-500" />
|
||||
</div>
|
||||
<h2 className="mt-5 text-xl font-semibold text-gray-900 dark:text-white">
|
||||
Job Not Found
|
||||
</h2>
|
||||
<p className="mt-2 text-sm text-gray-500 dark:text-gray-400">
|
||||
This assignment may have been removed or you don't have access.
|
||||
</p>
|
||||
<button
|
||||
onClick={() => navigateTech('/technician/jobs')}
|
||||
className="mt-6 inline-flex items-center gap-2 rounded-xl bg-green-600 px-5 py-2.5 text-sm font-medium text-white hover:bg-green-700"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
Back to Jobs
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const running = assignment.clockEntries.some((e) => !e.end);
|
||||
const elapsed = clockElapsedMinutes(assignment.clockEntries);
|
||||
const completedServices = assignment.services.filter(
|
||||
(s) => s.status === 'completed',
|
||||
).length;
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-4xl space-y-5">
|
||||
{/* Back button */}
|
||||
<button
|
||||
onClick={() => navigateTech('/technician/jobs')}
|
||||
className="inline-flex items-center gap-1.5 text-sm font-medium text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"
|
||||
>
|
||||
<ArrowLeft className="h-4 w-4" />
|
||||
All Jobs
|
||||
</button>
|
||||
|
||||
{/* Vehicle header */}
|
||||
<div className="rounded-2xl border border-gray-200 bg-white p-6 shadow-sm dark:border-gray-800 dark:bg-gray-900">
|
||||
<div className="flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between">
|
||||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<h1 className="text-xl font-bold text-gray-900 dark:text-white">
|
||||
{assignment.roNumber || 'Unknown RO'}
|
||||
</h1>
|
||||
<StatusBadge status={assignment.status} />
|
||||
</div>
|
||||
<p className="mt-1 text-lg text-gray-700 dark:text-gray-300">
|
||||
{assignment.vehicleInfo || 'No vehicle info'}
|
||||
</p>
|
||||
<div className="mt-2 flex flex-wrap gap-4 text-sm text-gray-500 dark:text-gray-400">
|
||||
{assignment.vin && <span>VIN: {assignment.vin}</span>}
|
||||
{assignment.mileage && (
|
||||
<span>Mileage: {assignment.mileage}</span>
|
||||
)}
|
||||
</div>
|
||||
{assignment.serviceLocation && (
|
||||
<div className="mt-2 flex items-center gap-1.5 text-sm text-gray-500 dark:text-gray-400">
|
||||
<MapPin className="h-3.5 w-3.5" />
|
||||
{assignment.serviceLocation}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Clock */}
|
||||
<div className="flex flex-col items-end gap-2">
|
||||
<div className="text-right">
|
||||
<p className="text-2xl font-bold tabular-nums text-gray-900 dark:text-white">
|
||||
{formatElapsed(elapsed)}
|
||||
</p>
|
||||
<p className="text-xs text-gray-400 dark:text-gray-500">
|
||||
{running ? 'Running' : 'Stopped'}
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleClockToggle}
|
||||
className={`inline-flex items-center gap-1.5 rounded-xl px-4 py-2 text-sm font-medium transition-colors ${
|
||||
running
|
||||
? 'bg-red-100 text-red-700 hover:bg-red-200 dark:bg-red-900/30 dark:text-red-400 dark:hover:bg-red-900/50'
|
||||
: 'bg-green-100 text-green-700 hover:bg-green-200 dark:bg-green-900/30 dark:text-green-400 dark:hover:bg-green-900/50'
|
||||
}`}
|
||||
>
|
||||
{running ? (
|
||||
<>
|
||||
<Square className="h-4 w-4 fill-current" />
|
||||
Stop
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Play className="h-4 w-4 fill-current" />
|
||||
Start
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Services */}
|
||||
<div className="rounded-2xl border border-gray-200 bg-white p-5 shadow-sm dark:border-gray-800 dark:bg-gray-900">
|
||||
<h2 className="mb-1 text-sm font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">
|
||||
Services
|
||||
</h2>
|
||||
<p className="mb-4 text-xs text-gray-400 dark:text-gray-500">
|
||||
{completedServices}/{assignment.services.length} completed
|
||||
</p>
|
||||
<div className="space-y-3">
|
||||
{assignment.services.map((svc) => (
|
||||
<div
|
||||
key={svc.id}
|
||||
className="rounded-xl border border-gray-100 bg-gray-50 p-4 dark:border-gray-800 dark:bg-gray-800/50"
|
||||
>
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="font-medium text-gray-900 dark:text-white">
|
||||
{svc.name}
|
||||
</p>
|
||||
{svc.description && (
|
||||
<p className="mt-0.5 text-sm text-gray-500 dark:text-gray-400">
|
||||
{svc.description}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<StatusBadge status={svc.status} />
|
||||
</div>
|
||||
|
||||
{svc.technicianNotes && (
|
||||
<div className="mt-2 rounded-lg bg-white p-3 text-xs text-gray-600 dark:bg-gray-800 dark:text-gray-400">
|
||||
{svc.technicianNotes}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Service actions */}
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
{svc.status === 'pending' && (
|
||||
<ServiceActionButton
|
||||
label="Start"
|
||||
onClick={() => handleServiceStatus(svc.id, 'in_progress')}
|
||||
color="yellow"
|
||||
/>
|
||||
)}
|
||||
{svc.status === 'in_progress' && (
|
||||
<>
|
||||
<ServiceActionButton
|
||||
label="Mark Complete"
|
||||
onClick={() =>
|
||||
handleServiceStatus(svc.id, 'completed')
|
||||
}
|
||||
color="green"
|
||||
/>
|
||||
<ServiceActionButton
|
||||
label="Parts Ordered"
|
||||
onClick={async () => {
|
||||
if (!id) return;
|
||||
try {
|
||||
await updateServiceSubStatus(
|
||||
id,
|
||||
svc.id,
|
||||
'parts_ordered',
|
||||
);
|
||||
await load();
|
||||
} catch (err: any) {
|
||||
if (err?.name === 'StaleWriteError') {
|
||||
showToast('This job was just edited. Reloading…', 'error');
|
||||
setTimeout(() => window.location.reload(), 1200);
|
||||
return;
|
||||
}
|
||||
showToast('Failed to update sub-status.', 'error');
|
||||
logError('updateServiceSubStatus', err);
|
||||
}
|
||||
}}
|
||||
color="orange"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
{svc.status === 'completed' && (
|
||||
<span className="inline-flex items-center gap-1 text-xs text-green-600 dark:text-green-400">
|
||||
<CheckCircle2 className="h-3.5 w-3.5" />
|
||||
Done
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Advisor Requests */}
|
||||
<div className="rounded-2xl border border-gray-200 bg-white p-5 shadow-sm dark:border-gray-800 dark:bg-gray-900">
|
||||
<div className="flex items-center gap-2">
|
||||
<MessageSquare className="h-4 w-4 text-gray-400" />
|
||||
<h2 className="text-sm font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">
|
||||
Advisor Requests
|
||||
</h2>
|
||||
</div>
|
||||
|
||||
{assignment.advisorRequests.length > 0 && (
|
||||
<div className="mt-3 space-y-2">
|
||||
{assignment.advisorRequests.map((req) => (
|
||||
<div
|
||||
key={req.id}
|
||||
className="rounded-lg border border-gray-100 bg-gray-50 p-3 dark:border-gray-800 dark:bg-gray-800/50"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ${
|
||||
req.type === 'parts'
|
||||
? 'bg-blue-100 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400'
|
||||
: req.type === 'help'
|
||||
? 'bg-red-100 text-red-700 dark:bg-red-900/30 dark:text-red-400'
|
||||
: 'bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-400'
|
||||
}`}
|
||||
>
|
||||
{req.type}
|
||||
</span>
|
||||
<span
|
||||
className={`text-xs ${
|
||||
req.status === 'resolved'
|
||||
? 'text-green-600 dark:text-green-400'
|
||||
: 'text-yellow-600 dark:text-yellow-400'
|
||||
}`}
|
||||
>
|
||||
{req.status}
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-1 text-sm text-gray-700 dark:text-gray-300">
|
||||
{req.message}
|
||||
</p>
|
||||
<p className="mt-0.5 text-xs text-gray-400 dark:text-gray-500">
|
||||
{new Date(req.createdAt).toLocaleString()}
|
||||
</p>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* New request form */}
|
||||
<div className="mt-4 space-y-3">
|
||||
<div className="flex gap-2">
|
||||
{(['parts', 'help', 'info'] as const).map((type) => (
|
||||
<button
|
||||
key={type}
|
||||
onClick={() => setRequestType(type)}
|
||||
className={`rounded-lg px-3 py-1.5 text-xs font-medium transition-colors ${
|
||||
requestType === type
|
||||
? 'bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-400'
|
||||
: 'bg-gray-100 text-gray-500 hover:bg-gray-200 dark:bg-gray-800 dark:text-gray-400 dark:hover:bg-gray-700'
|
||||
}`}
|
||||
>
|
||||
{type === 'parts' ? 'Parts' : type === 'help' ? 'Help' : 'Info'}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<textarea
|
||||
value={requestText}
|
||||
onChange={(e) => setRequestText(e.target.value)}
|
||||
placeholder="Describe what you need…"
|
||||
rows={2}
|
||||
className="w-full rounded-xl border border-gray-200 bg-white p-3 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"
|
||||
/>
|
||||
<button
|
||||
onClick={handleSubmitRequest}
|
||||
disabled={!requestText.trim() || sendingRequest}
|
||||
className="inline-flex items-center gap-1.5 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-50"
|
||||
>
|
||||
{sendingRequest ? 'Sending…' : 'Send Request'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Clock history */}
|
||||
{assignment.clockEntries.length > 0 && (
|
||||
<div className="rounded-2xl border border-gray-200 bg-white p-5 shadow-sm dark:border-gray-800 dark:bg-gray-900">
|
||||
<div className="flex items-center gap-2">
|
||||
<Clock className="h-4 w-4 text-gray-400" />
|
||||
<h2 className="text-sm font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">
|
||||
Time Log
|
||||
</h2>
|
||||
</div>
|
||||
<div className="mt-3 space-y-1.5">
|
||||
{assignment.clockEntries.map((entry, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="flex items-center justify-between text-sm"
|
||||
>
|
||||
<span className="text-gray-500 dark:text-gray-400">
|
||||
{formatClockTime(entry.start)}
|
||||
{entry.end && ` → ${formatClockTime(entry.end)}`}
|
||||
{!entry.end && (
|
||||
<span className="ml-1 text-green-500">(running)</span>
|
||||
)}
|
||||
</span>
|
||||
<span className="tabular-nums text-gray-700 dark:text-gray-300">
|
||||
{entry.minutes != null
|
||||
? formatElapsed(entry.minutes)
|
||||
: entry.end
|
||||
? formatElapsed(
|
||||
Math.round(
|
||||
(new Date(entry.end).getTime() -
|
||||
new Date(entry.start).getTime()) /
|
||||
60000,
|
||||
),
|
||||
)
|
||||
: formatElapsed(
|
||||
Math.round(
|
||||
(Date.now() -
|
||||
new Date(entry.start).getTime()) /
|
||||
60000,
|
||||
),
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusBadge({
|
||||
status,
|
||||
}: {
|
||||
status: string;
|
||||
}) {
|
||||
const cfg = SERVICE_STATUS_CONFIG[status] ?? {
|
||||
label: status,
|
||||
colors: 'bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-400',
|
||||
};
|
||||
return (
|
||||
<span
|
||||
className={`inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium ${cfg.colors}`}
|
||||
>
|
||||
{cfg.label}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function ServiceActionButton({
|
||||
label,
|
||||
onClick,
|
||||
color,
|
||||
}: {
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
color: 'green' | 'yellow' | 'orange';
|
||||
}) {
|
||||
const colorMap: Record<string, string> = {
|
||||
green:
|
||||
'bg-green-100 text-green-700 hover:bg-green-200 dark:bg-green-900/30 dark:text-green-400 dark:hover:bg-green-900/50',
|
||||
yellow:
|
||||
'bg-yellow-100 text-yellow-700 hover:bg-yellow-200 dark:bg-yellow-900/30 dark:text-yellow-400 dark:hover:bg-yellow-900/50',
|
||||
orange:
|
||||
'bg-orange-100 text-orange-700 hover:bg-orange-200 dark:bg-orange-900/30 dark:text-orange-400 dark:hover:bg-orange-900/50',
|
||||
};
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className={`inline-flex items-center gap-1 rounded-lg px-3 py-1.5 text-xs font-medium transition-colors ${colorMap[color]}`}
|
||||
>
|
||||
{label}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { pb } from '../../lib/pocketbase';
|
||||
import { useTechnicianContext } from '../../components/technician/TechnicianContext';
|
||||
import { User, Sun, Moon, LogOut, Mail, Tag } from 'lucide-react';
|
||||
|
||||
export default function TechnicianProfile() {
|
||||
const navigate = useNavigate();
|
||||
const { isImpersonating, impersonatedTech } = useTechnicianContext();
|
||||
const userEmail = isImpersonating ? (impersonatedTech?.email || '') : (pb.authStore.model?.email as string);
|
||||
const displayName = isImpersonating ? (impersonatedTech?.displayName || '') : (pb.authStore.model?.displayName as string);
|
||||
|
||||
const [dark, setDark] = useState(() => {
|
||||
if (typeof window === 'undefined') return false;
|
||||
return localStorage.getItem('spq-dark-mode') === 'true';
|
||||
});
|
||||
|
||||
const toggleDark = () => {
|
||||
const next = !dark;
|
||||
setDark(next);
|
||||
document.documentElement.classList.toggle('dark', next);
|
||||
localStorage.setItem('spq-dark-mode', String(next));
|
||||
};
|
||||
|
||||
const handleLogout = () => {
|
||||
pb.authStore.clear();
|
||||
navigate('/login');
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-2xl space-y-5">
|
||||
{/* Avatar card */}
|
||||
<div className="rounded-2xl border border-gray-200 bg-white p-8 text-center shadow-sm dark:border-gray-800 dark:bg-gray-900">
|
||||
<div className="mx-auto flex h-20 w-20 items-center justify-center rounded-full bg-green-100 dark:bg-green-900/30">
|
||||
<User className="h-10 w-10 text-green-600 dark:text-green-400" />
|
||||
</div>
|
||||
<h1 className="mt-4 text-xl font-bold text-gray-900 dark:text-white">
|
||||
{displayName || 'Technician'}
|
||||
</h1>
|
||||
{displayName && userEmail && (
|
||||
<p className="mt-0.5 text-sm text-gray-500 dark:text-gray-400">
|
||||
{userEmail}
|
||||
</p>
|
||||
)}
|
||||
<span className="mt-3 inline-flex items-center rounded-full bg-gray-100 px-3 py-1 text-xs font-medium text-gray-600 dark:bg-gray-800 dark:text-gray-400">
|
||||
Technician
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Account info */}
|
||||
<div className="rounded-2xl border border-gray-200 bg-white p-5 shadow-sm dark:border-gray-800 dark:bg-gray-900">
|
||||
<h2 className="mb-3 text-sm font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">
|
||||
Account
|
||||
</h2>
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-9 w-9 items-center justify-center rounded-full bg-gray-100 dark:bg-gray-800">
|
||||
<Mail className="h-4 w-4 text-gray-500 dark:text-gray-400" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">Email</p>
|
||||
<p className="text-sm font-medium text-gray-900 dark:text-white">
|
||||
{userEmail || '—'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{displayName && (
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-9 w-9 items-center justify-center rounded-full bg-gray-100 dark:bg-gray-800">
|
||||
<Tag className="h-4 w-4 text-gray-500 dark:text-gray-400" />
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400">
|
||||
Display Name
|
||||
</p>
|
||||
<p className="text-sm font-medium text-gray-900 dark:text-white">
|
||||
{displayName}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Preferences */}
|
||||
<div className="rounded-2xl border border-gray-200 bg-white p-5 shadow-sm dark:border-gray-800 dark:bg-gray-900">
|
||||
<h2 className="mb-3 text-sm font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">
|
||||
Preferences
|
||||
</h2>
|
||||
<button
|
||||
onClick={toggleDark}
|
||||
className="flex w-full items-center justify-between rounded-xl p-3 transition-colors hover:bg-gray-50 dark:hover:bg-gray-800/50"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-9 w-9 items-center justify-center rounded-full bg-gray-100 dark:bg-gray-800">
|
||||
{dark ? (
|
||||
<Sun className="h-4 w-4 text-yellow-500" />
|
||||
) : (
|
||||
<Moon className="h-4 w-4 text-gray-500" />
|
||||
)}
|
||||
</div>
|
||||
<div className="text-left">
|
||||
<p className="text-sm font-medium text-gray-900 dark:text-white">
|
||||
Dark Mode
|
||||
</p>
|
||||
<p className="text-xs text-gray-500 dark:text-gray-400">
|
||||
{dark ? 'On' : 'Off'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
className={`h-6 w-10 rounded-full transition-colors ${
|
||||
dark ? 'bg-green-500' : 'bg-gray-300 dark:bg-gray-600'
|
||||
} relative`}
|
||||
>
|
||||
<div
|
||||
className={`absolute top-0.5 h-5 w-5 rounded-full bg-white shadow-sm transition-all ${
|
||||
dark ? 'left-[18px]' : 'left-0.5'
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Logout */}
|
||||
<button
|
||||
onClick={handleLogout}
|
||||
className="flex w-full items-center justify-center gap-2 rounded-2xl border border-red-200 bg-white p-4 text-sm font-medium text-red-600 transition-colors hover:bg-red-50 dark:border-red-900/50 dark:bg-gray-900 dark:text-red-400 dark:hover:bg-red-900/20"
|
||||
>
|
||||
<LogOut className="h-4 w-4" />
|
||||
Logout
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user