initial commit

This commit is contained in:
ray
2026-07-12 10:01:39 -04:00
commit fc8290d668
185 changed files with 38831 additions and 0 deletions
+592
View File
@@ -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>
);
}
+694
View File
@@ -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>
);
}
+332
View File
@@ -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
+318
View File
@@ -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">
&copy; {new Date().getFullYear()} {APP_NAME}. All rights reserved.
</p>
</div>
</div>
);
}
+238
View File
@@ -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>
);
}
+684
View File
@@ -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>
);
}
+913
View File
@@ -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
+943
View File
@@ -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 || '—'} &middot; {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>
);
}
+414
View File
@@ -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
+578
View File
@@ -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>
);
}
+203
View File
@@ -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>
);
}
+150
View File
@@ -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>
);
}
+324
View File
@@ -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' });
}
+372
View File
@@ -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>
);
}
+323
View File
@@ -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>
);
}
+135
View File
@@ -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>
);
}