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
+237
View File
@@ -0,0 +1,237 @@
import { BrowserRouter, Routes, Route, Navigate, Outlet, useLocation, useSearchParams, type Location } from 'react-router-dom';
import { lazy, Suspense, useEffect, useState } from 'react';
import { pb, useAuth, useIsLoggedIn } from './lib/pocketbase';
import { loadSettingsPage } from './lib/routePreload';
import { ToastProvider } from './components/ui/Toast';
import { OfflineBanner } from './components/OfflineBanner';
import AdvisorLayout from './components/advisor/AdvisorLayout';
import MobileLayout from './components/mobile/MobileLayout';
import TechnicianShell from './components/technician/TechnicianLayout';
import Login from './pages/Login';
import { isSetupComplete, loadSettingsForUser } from './lib/settings';
import { getCurrentUserRole, isTechnicianRole, isOwnerRole, type UserRole } from './lib/rbx';
import { useSettingsStore } from './store/settings';
import { TechnicianProvider } from './components/technician/TechnicianContext';
const Dashboard = lazy(() => import('./pages/Dashboard'));
const QuoteGenerator = lazy(() => import('./pages/QuoteGenerator'));
const ServiceCatalog = lazy(() => import('./pages/ServiceCatalog'));
const Customers = lazy(() => import('./pages/Customers'));
const RepairOrders = lazy(() => import('./pages/RepairOrders'));
const RODetailModal = lazy(() => import('./pages/RODetailModal'));
const Appointments = lazy(() => import('./pages/Appointments'));
const FinancialDashboard = lazy(() => import('./pages/FinancialDashboard'));
const Invoices = lazy(() => import('./pages/Invoices'));
const Settings = lazy(loadSettingsPage);
const Setup = lazy(() => import('./pages/Setup'));
const Verify = lazy(() => import('./pages/Verify'));
const QuoteApprove = lazy(() => import('./pages/QuoteApprove'));
const TechnicianInvite = lazy(() => import('./pages/TechnicianInvite'));
const AdvisorTeam = lazy(() => import('./pages/advisor/AdvisorTeam'));
const TimeCards = lazy(() => import('./pages/advisor/TimeCards'));
const TechnicianDashboard = lazy(() => import('./pages/technician/TechnicianDashboard'));
const TechnicianJobs = lazy(() => import('./pages/technician/TechnicianJobs'));
const TechnicianJobDetail = lazy(() => import('./pages/technician/TechnicianJobDetail'));
const TechnicianProfile = lazy(() => import('./pages/technician/TechnicianProfile'));
const Privacy = lazy(() => import('./pages/Privacy'));
function RequireAuth({ children }: { children: React.ReactNode }) {
// Subscribe to the authStore so the route flips to /login live when the
// token expires or the user logs out — no waiting for a page reload.
const isLoggedIn = useIsLoggedIn();
if (!isLoggedIn) {
return <Navigate to="/login" replace />;
}
return <>{children}</>;
}
function getRoleHome(role: UserRole): string {
if (role === 'technician') return '/technician';
return '/';
}
function RoleHomeRedirect() {
return <Navigate to={getRoleHome(getCurrentUserRole())} replace />;
}
function OwnerLayout() {
const role = getCurrentUserRole();
if (isTechnicianRole(role)) {
return <Navigate to="/technician" replace />;
}
const Shell = role === 'mobile' ? MobileLayout : AdvisorLayout;
return (
<RequireAuth>
<RequireSetup>
<Shell>
<Outlet />
</Shell>
</RequireSetup>
</RequireAuth>
);
}
function TechnicianRouteGuard() {
const role = getCurrentUserRole();
const [searchParams] = useSearchParams();
const viewAs = searchParams.get('view_as');
const authUserId = pb.authStore.model?.id as string | undefined;
const isOwnerViewing = isOwnerRole(role) && !!viewAs && viewAs !== authUserId;
if (!isTechnicianRole(role) && !isOwnerViewing) {
return <Navigate to="/" replace />;
}
return (
<RequireAuth>
<TechnicianProvider>
<TechnicianShell>
<Outlet />
</TechnicianShell>
</TechnicianProvider>
</RequireAuth>
);
}
function AdvisorOnly({ children }: { children: React.ReactNode }) {
const role = getCurrentUserRole();
if (role !== 'advisor') return <Navigate to="/" replace />;
return <>{children}</>;
}
function RequireSetup({ children }: { children: React.ReactNode }) {
const { user } = useAuth();
const location = useLocation();
const [setupReady, setSetupReady] = useState<boolean | null>(null);
const isTechnician = isTechnicianRole(getCurrentUserRole());
useEffect(() => {
if (isTechnician) {
setSetupReady(true);
return;
}
let active = true;
async function checkSetup() {
const settings = await loadSettingsForUser(user?.id);
if (!active) return;
useSettingsStore.getState().setSettings(settings);
setSetupReady(isSetupComplete(settings));
}
setSetupReady(null);
void checkSetup();
return () => {
active = false;
};
}, [isTechnician, user?.id]);
if (setupReady === null) return <PageLoader />;
if (!setupReady) {
return <Navigate to="/setup" replace state={{ from: location }} />;
}
return <>{children}</>;
}
function SetupRoute() {
if (isTechnicianRole(getCurrentUserRole())) {
return <Navigate to="/technician" replace />;
}
return (
<RequireAuth>
<Setup />
</RequireAuth>
);
}
function SettingsModalRoute() {
const role = getCurrentUserRole();
if (isTechnicianRole(role)) {
return <Navigate to="/technician" replace />;
}
return (
<RequireAuth>
<Settings />
</RequireAuth>
);
}
function ROModalRoute() {
const role = getCurrentUserRole();
if (isTechnicianRole(role)) {
return <Navigate to="/technician" replace />;
}
return (
<RequireAuth>
<RODetailModal />
</RequireAuth>
);
}
const PageLoader = () => (
<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>
);
function AppRoutes() {
const location = useLocation();
const state = location.state as { backgroundLocation?: Location } | null;
const backgroundLocation = state?.backgroundLocation;
return (
<Suspense fallback={<PageLoader />}>
<Routes location={backgroundLocation || location}>
<Route path="/login" element={<Login />} />
<Route path="/setup" element={<SetupRoute />} />
<Route path="/verify" element={<Verify />} />
<Route path="/quote/approve/:token" element={<QuoteApprove />} />
<Route path="/invite/technician/:token" element={<TechnicianInvite />} />
<Route element={<OwnerLayout />}>
<Route path="/" element={<Dashboard />} />
<Route path="/quote" element={<QuoteGenerator />} />
<Route path="/service-catalog" element={<ServiceCatalog />} />
<Route path="/customers" element={<Customers />} />
<Route path="/repair-orders" element={<RepairOrders />} />
<Route path="/repair-orders/:id" element={<RODetailModal />} />
<Route path="/appointments" element={<Appointments />} />
<Route path="/financial" element={<FinancialDashboard />} />
<Route path="/invoices" element={<Invoices />} />
<Route path="/team" element={<AdvisorOnly><AdvisorTeam /></AdvisorOnly>} />
<Route path="/time-cards" element={<AdvisorOnly><TimeCards /></AdvisorOnly>} />
<Route path="/settings" element={<Settings />} />
<Route path="/privacy" element={<AdvisorOnly><Privacy /></AdvisorOnly>} />
</Route>
<Route element={<TechnicianRouteGuard />}>
<Route path="/technician" element={<TechnicianDashboard />} />
<Route path="/technician/jobs" element={<TechnicianJobs />} />
<Route path="/technician/jobs/:id" element={<TechnicianJobDetail />} />
<Route path="/technician/profile" element={<TechnicianProfile />} />
</Route>
<Route path="*" element={<RoleHomeRedirect />} />
</Routes>
{backgroundLocation && (
<Routes>
<Route path="/settings" element={<SettingsModalRoute />} />
<Route path="/repair-orders/:id" element={<ROModalRoute />} />
</Routes>
)}
</Suspense>
);
}
function App() {
return (
<BrowserRouter>
<ToastProvider>
<OfflineBanner />
<AppRoutes />
</ToastProvider>
</BrowserRouter>
);
}
export default App;
+210
View File
@@ -0,0 +1,210 @@
import { useState, useMemo } from 'react';
import { Search, X, UserPlus, User, Car, ChevronLeft, Phone, Mail } from 'lucide-react';
import { formatPhoneDisplay } from '../lib/phone';
import type { CustomerWithVehicles } from '../pages/Customers';
export interface CustomerPickerProps {
customers: CustomerWithVehicles[];
selectedCustomerId: string | null;
onSelect: (customerId: string | null) => void;
onCreateNew: () => void;
disabled?: boolean;
}
function customerMatch(customer: CustomerWithVehicles, query: string): boolean {
const q = query.toLowerCase();
const name = customer.name?.toLowerCase() || '';
const phone = customer.phone || '';
const email = customer.email?.toLowerCase() || '';
return name.includes(q) || phone.includes(q) || email.includes(q);
}
export default function CustomerPicker({
customers,
selectedCustomerId,
onSelect,
onCreateNew,
disabled,
}: CustomerPickerProps) {
const [open, setOpen] = useState(false);
const [search, setSearch] = useState('');
const selected = selectedCustomerId
? customers.find((c) => c.id === selectedCustomerId)
: null;
const filtered = useMemo(() => {
if (!search.trim()) return customers;
return customers.filter((c) => customerMatch(c, search));
}, [customers, search]);
const handleClose = () => {
setOpen(false);
setSearch('');
};
const handleSelect = (id: string) => {
onSelect(id);
handleClose();
};
const handleClear = () => {
onSelect(null);
handleClose();
};
return (
<>
{/* Trigger display */}
<div className="relative">
{selected ? (
<div className="rounded-lg border border-gray-200 bg-gray-50 p-3 dark:border-gray-700 dark:bg-gray-800/50">
<div className="flex items-start justify-between gap-2">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<User className="h-4 w-4 shrink-0 text-gray-400" />
<p className="truncate text-sm font-medium text-gray-900 dark:text-white">{selected.name}</p>
</div>
<div className="mt-1 flex flex-wrap gap-x-3 gap-y-0.5 text-xs text-gray-500 dark:text-gray-400">
{selected.phone && (
<span className="inline-flex items-center gap-1">
<Phone className="h-3 w-3" />
{formatPhoneDisplay(selected.phone)}
</span>
)}
{selected.email && (
<span className="inline-flex items-center gap-1 truncate">
<Mail className="h-3 w-3 shrink-0" />
{selected.email}
</span>
)}
{selected.vehicleCount > 0 && (
<span className="inline-flex items-center gap-1">
<Car className="h-3 w-3" />
{selected.vehicleCount} vehicle{selected.vehicleCount !== 1 ? 's' : ''}
</span>
)}
</div>
</div>
<button
type="button"
onClick={() => setOpen(true)}
className="shrink-0 rounded-lg border border-gray-300 bg-white px-2.5 py-1 text-xs font-medium text-gray-700 hover:bg-gray-50 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600"
>
Change
</button>
</div>
</div>
) : (
<button
type="button"
disabled={disabled}
onClick={() => setOpen(true)}
className="inline-flex w-full items-center justify-center gap-2 rounded-lg border border-dashed border-gray-300 bg-white px-4 py-3 text-sm font-medium text-gray-500 hover:border-gray-400 hover:text-gray-700 disabled:opacity-50 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-400 dark:hover:border-gray-500 dark:hover:text-gray-300"
>
<UserPlus className="h-4 w-4" />
Select Customer
</button>
)}
</div>
{/* Modal overlay */}
{open && (
<div className="fixed inset-0 z-50">
<button
type="button"
aria-label="Close"
className="absolute inset-0 bg-gray-950/45 backdrop-blur-sm"
onClick={handleClose}
/>
<div className="absolute inset-0 flex items-center justify-center p-3 sm:p-6">
<div className="relative flex h-[min(70vh,40rem)] w-full max-w-xl flex-col overflow-hidden rounded-3xl border border-gray-200 bg-gray-50 shadow-2xl dark:border-gray-700 dark:bg-gray-950">
{/* Header */}
<div className="flex items-center gap-3 border-b border-gray-200 bg-white px-5 py-4 dark:border-gray-700 dark:bg-gray-900">
<button
type="button"
onClick={handleClose}
className="rounded-lg p-1.5 text-gray-400 hover:bg-gray-100 hover:text-gray-600 dark:hover:bg-gray-800 dark:hover:text-gray-300"
>
<ChevronLeft className="h-4 w-4" />
</button>
<h2 className="text-base font-semibold text-gray-900 dark:text-white">Select Customer</h2>
</div>
{/* Search */}
<div className="relative mx-5 mt-4">
<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={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Search by name, phone, or email..."
className="w-full rounded-lg border border-gray-300 bg-white py-2 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>
{/* Customer list */}
<div className="flex-1 overflow-y-auto px-5 py-3">
{filtered.length === 0 ? (
<div className="flex flex-col items-center justify-center py-10 text-center">
<User className="mb-2 h-8 w-8 text-gray-300 dark:text-gray-600" />
<p className="text-sm text-gray-500 dark:text-gray-400">
{search ? 'No customers match your search.' : 'No customers yet.'}
</p>
</div>
) : (
<div className="space-y-1.5">
{filtered.map((customer) => (
<button
key={customer.id}
type="button"
onClick={() => handleSelect(customer.id)}
className={`flex w-full items-start gap-3 rounded-lg border px-3 py-2.5 text-left transition-colors ${
customer.id === selectedCustomerId
? 'border-green-300 bg-green-50 dark:border-green-600 dark:bg-green-900/20'
: 'border-gray-200 bg-white hover:bg-gray-50 dark:border-gray-700 dark:bg-gray-800 dark:hover:bg-gray-700/50'
}`}
>
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-gray-100 dark:bg-gray-700">
<User className="h-4 w-4 text-gray-500 dark:text-gray-400" />
</div>
<div className="min-w-0 flex-1">
<p className="text-sm font-medium text-gray-900 dark:text-white">{customer.name}</p>
<div className="mt-0.5 flex flex-wrap gap-x-2 text-xs text-gray-500 dark:text-gray-400">
{customer.phone && <span>{formatPhoneDisplay(customer.phone)}</span>}
{customer.vehicleCount > 0 && <span>{customer.vehicleCount} vehicle{customer.vehicleCount !== 1 ? 's' : ''}</span>}
</div>
</div>
</button>
))}
</div>
)}
</div>
{/* Create new button */}
<div className="border-t border-gray-200 bg-white px-5 py-4 dark:border-gray-700 dark:bg-gray-900">
<button
type="button"
onClick={() => { handleClose(); onCreateNew(); }}
className="inline-flex w-full items-center justify-center gap-2 rounded-lg bg-green-600 px-4 py-2.5 text-sm font-medium text-white hover:bg-green-700"
>
<UserPlus className="h-4 w-4" />
Create New Customer
</button>
<button
type="button"
onClick={handleClear}
className="mt-2 inline-flex w-full items-center justify-center gap-2 rounded-lg border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700"
>
<X className="h-4 w-4" />
No customer selected
</button>
</div>
</div>
</div>
</div>
)}
</>
);
}
+53
View File
@@ -0,0 +1,53 @@
import { Component, type ErrorInfo, type ReactNode } from 'react';
import { logError, reportError } from '../lib/userMessages';
interface Props { children: ReactNode; }
interface State { error: Error | null; }
export class ErrorBoundary extends Component<Props, State> {
state: State = { error: null };
static getDerivedStateFromError(error: Error): State {
return { error };
}
componentDidCatch(error: Error, info: ErrorInfo) {
logError('[ErrorBoundary] uncaught render error', error);
reportError(error, { componentStack: info.componentStack });
}
handleReset = () => this.setState({ error: null });
render() {
if (this.state.error) {
return (
<div className="flex min-h-screen flex-col items-center justify-center gap-4 bg-gray-50 px-4 dark:bg-gray-950">
<div className="mx-auto flex h-16 w-16 items-center justify-center rounded-2xl bg-red-600">
<span className="text-2xl font-bold text-white">!</span>
</div>
<h1 className="text-xl font-semibold text-gray-900 dark:text-white">
Something went wrong
</h1>
<p className="max-w-sm text-center text-sm text-gray-500 dark:text-gray-400">
{this.state.error.message || 'An unexpected error occurred.'}
</p>
<div className="flex gap-3">
<button
onClick={this.handleReset}
className="rounded-lg bg-green-600 px-5 py-2.5 text-sm font-medium text-white hover:bg-green-700"
>
Try again
</button>
<button
onClick={() => window.location.reload()}
className="rounded-lg border border-gray-300 bg-white px-5 py-2.5 text-sm font-medium text-gray-700 hover:bg-gray-50 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-200"
>
Reload page
</button>
</div>
</div>
);
}
return this.props.children;
}
}
+185
View File
@@ -0,0 +1,185 @@
import { useState, useEffect, type ReactNode } from "react";
import {
LayoutDashboard,
FileText,
Settings,
Users,
Wrench,
Package,
Calendar,
DollarSign,
Receipt,
UserCog,
Clock,
Sun,
Moon,
Menu,
X,
LogOut,
ChevronLeft,
ChevronRight,
} from "lucide-react";
import { NavLink, useLocation, useNavigate } from "react-router-dom";
import { pb } from "../lib/pocketbase";
import { preloadSettingsPage, preloadTimeCards } from "../lib/routePreload";
interface LayoutProps {
children: ReactNode;
}
const navItems = [
{ to: "/", label: "Dashboard", icon: LayoutDashboard },
{ to: "/quote", label: "Quote Generator", icon: FileText },
{ to: "/service-catalog", label: "Service Catalog", icon: Package },
{ to: "/customers", label: "Customers", icon: Users },
{ to: "/repair-orders", label: "Repair Orders", icon: Wrench },
{ to: "/appointments", label: "Appointments", icon: Calendar },
{ to: "/invoices", label: "Invoices", icon: Receipt },
{ to: "/financial", label: "Financial", icon: DollarSign },
{ to: "/time-cards", label: "Time Cards", icon: Clock },
{ to: "/team", label: "Team", icon: UserCog },
{ to: "/settings", label: "Settings", icon: Settings },
];
export default function Layout({ children }: LayoutProps) {
const navigate = useNavigate();
const location = useLocation();
const [sidebarOpen, setSidebarOpen] = useState(false);
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
const [dark, setDark] = useState(() => {
if (typeof window === "undefined") return false;
return localStorage.getItem("spq-dark-mode") === "true";
});
useEffect(() => {
const root = document.documentElement;
if (dark) {
root.classList.add("dark");
} else {
root.classList.remove("dark");
}
localStorage.setItem("spq-dark-mode", String(dark));
}, [dark]);
const toggleDark = () => setDark((prev) => !prev);
const handleLogout = () => {
pb.authStore.clear();
navigate("/login");
};
const userEmail = pb.authStore.model?.email as string | undefined;
const handleNavClick = () => {
setSidebarOpen(false);
};
const navContent = (
<nav className="flex flex-1 flex-col gap-1 px-3 py-4 overflow-y-auto">
{navItems.map((item) => (
<NavLink
key={item.to}
to={item.to}
state={item.to === "/settings" ? { backgroundLocation: (location.state as { backgroundLocation?: unknown } | null)?.backgroundLocation || location } : undefined}
end={item.to === "/"}
onClick={handleNavClick}
onMouseEnter={item.to === "/settings" ? preloadSettingsPage : item.to === "/time-cards" ? preloadTimeCards : undefined}
onFocus={item.to === "/settings" ? preloadSettingsPage : item.to === "/time-cards" ? preloadTimeCards : undefined}
className={({ isActive }) =>
`flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium transition-colors ${
isActive
? "bg-green-50 text-green-700 dark:bg-green-900/30 dark:text-green-400"
: "text-gray-600 hover:bg-gray-100 hover:text-gray-900 dark:text-gray-400 dark:hover:bg-gray-800 dark:hover:text-gray-100"
}`
}
>
<item.icon className="h-5 w-5 shrink-0" />
{!sidebarCollapsed && <span>{item.label}</span>}
</NavLink>
))}
</nav>
);
return (
<div className="flex h-screen overflow-hidden bg-gray-50 dark:bg-gray-950">
{sidebarOpen && (
<div
className="fixed inset-0 z-40 bg-black/50 lg:hidden"
onClick={() => setSidebarOpen(false)}
/>
)}
<aside
className={`fixed inset-y-0 left-0 z-50 flex w-60 flex-col border-r border-gray-200 bg-white shadow-lg transition-transform duration-300 ease-in-out dark:border-gray-800 dark:bg-gray-900 lg:static lg:shadow-none ${
sidebarOpen ? "translate-x-0" : "-translate-x-full"
} ${
sidebarCollapsed ? "lg:w-16" : "lg:w-60"
} lg:translate-x-0`}
>
<div className="flex h-16 items-center justify-between border-b border-gray-200 px-4 dark:border-gray-800">
{sidebarCollapsed ? (
<span className="mx-auto text-xl font-bold text-green-600">
SQ
</span>
) : (
<span className="text-lg font-bold text-gray-900 dark:text-white">
Shop<span className="text-green-600">Pro</span>Quote
</span>
)}
<button
onClick={() => setSidebarOpen(false)}
className="rounded-lg p-1 text-gray-500 hover:bg-gray-100 hover:text-gray-700 dark:text-gray-400 dark:hover:bg-gray-800 dark:hover:text-gray-200 lg:hidden"
>
<X className="h-5 w-5" />
</button>
</div>
{navContent}
<div className="hidden border-t border-gray-200 px-3 py-3 dark:border-gray-800 lg:block">
<button
onClick={() => setSidebarCollapsed((prev) => !prev)}
className="flex w-full items-center justify-center rounded-lg px-3 py-2 text-sm text-gray-500 hover:bg-gray-100 hover:text-gray-700 dark:text-gray-400 dark:hover:bg-gray-800 dark:hover:text-gray-200"
title={sidebarCollapsed ? "Expand sidebar" : "Collapse sidebar"}
>
{sidebarCollapsed ? (
<ChevronRight className="h-4 w-4" />
) : (
<ChevronLeft className="h-4 w-4" />
)}
</button>
</div>
</aside>
<div className="flex flex-1 flex-col overflow-hidden">
<header className="flex h-16 shrink-0 items-center gap-4 border-b border-gray-200 bg-white px-4 dark:border-gray-800 dark:bg-gray-900">
<button
onClick={() => setSidebarOpen(true)}
className="rounded-lg p-1.5 text-gray-500 hover:bg-gray-100 hover:text-gray-700 dark:text-gray-400 dark:hover:bg-gray-800 dark:hover:text-gray-200 lg:hidden"
>
<Menu className="h-5 w-5" />
</button>
<div className="flex-1" />
<button
onClick={toggleDark}
className="rounded-lg p-2 text-gray-500 hover:bg-gray-100 hover:text-gray-700 dark:text-gray-400 dark:hover:bg-gray-800 dark:hover:text-gray-200"
title={dark ? "Switch to light mode" : "Switch to dark mode"}
>
{dark ? <Sun className="h-5 w-5" /> : <Moon className="h-5 w-5" />}
</button>
{userEmail && (
<span className="hidden text-sm text-gray-600 dark:text-gray-400 sm:block">
{userEmail}
</span>
)}
<button
onClick={handleLogout}
className="flex items-center gap-1.5 rounded-lg px-3 py-2 text-sm font-medium text-gray-600 hover:bg-red-50 hover:text-red-600 dark:text-gray-400 dark:hover:bg-red-900/20 dark:hover:text-red-400"
>
<LogOut className="h-4 w-4" />
<span className="hidden sm:inline">Logout</span>
</button>
</header>
<main className="flex-1 overflow-auto p-6">{children}</main>
</div>
</div>
);
}
+24
View File
@@ -0,0 +1,24 @@
import { useEffect, useState } from 'react';
export function OfflineBanner() {
const [online, setOnline] = useState(navigator.onLine);
useEffect(() => {
const on = () => setOnline(true);
const off = () => setOnline(false);
window.addEventListener('online', on);
window.addEventListener('offline', off);
return () => {
window.removeEventListener('online', on);
window.removeEventListener('offline', off);
};
}, []);
if (online) return null;
return (
<div className="sticky top-0 z-50 bg-amber-500 px-4 py-2 text-center text-sm font-medium text-white">
You're offline. Some features may be unavailable.
</div>
);
}
File diff suppressed because it is too large Load Diff
+78
View File
@@ -0,0 +1,78 @@
import { BarChart3, FileText, Settings2, Users } from 'lucide-react';
import { Link } from 'react-router-dom';
interface SetupBannerProps {
collectionName: string;
}
const setupBannerContent: Record<string, {
title: string;
description: string;
detail: string;
cta: string;
icon: typeof FileText;
}> = {
invoices: {
title: 'Complete setup before creating invoices',
description: 'Finish your business profile and pricing defaults so invoices are ready for customer use.',
detail: 'Your invoice records will reflect your shop information, payment terms, and document branding after setup is complete.',
cta: 'Finish Business Setup',
icon: FileText,
},
repairOrders: {
title: 'Complete setup before using financial reporting',
description: 'Finish setup so dashboard totals and customer-facing financial details reflect your business defaults.',
detail: 'Once configured, this dashboard will report on repair order activity using your labor, tax, and fee settings.',
cta: 'Review Setup',
icon: BarChart3,
},
service_advisors: {
title: 'Complete setup before managing staff defaults',
description: 'Finish setup to define the primary advisor or technician details used throughout your workflow.',
detail: 'This helps keep quotes, repair orders, and other records consistent before you add more team members.',
cta: 'Finish Setup',
icon: Users,
},
};
export default function SetupBanner({ collectionName }: SetupBannerProps) {
const content = setupBannerContent[collectionName] ?? {
title: 'Complete setup before using this section',
description: 'Finish your first-run setup so customer-facing documents and workflow defaults are in place.',
detail: 'Setup ensures this area uses your business profile, pricing defaults, and professional document settings.',
cta: 'Finish Setup',
icon: Settings2,
};
const Icon = content.icon;
return (
<div className="rounded-2xl border border-slate-200 bg-white p-6 shadow-sm dark:border-slate-800 dark:bg-slate-900">
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div className="flex items-start gap-4">
<div className="flex h-12 w-12 shrink-0 items-center justify-center rounded-2xl bg-slate-100 dark:bg-slate-800">
<Icon className="h-6 w-6 text-slate-600 dark:text-slate-300" />
</div>
<div>
<h3 className="text-sm font-semibold text-slate-900 dark:text-slate-100">
{content.title}
</h3>
<p className="mt-1 text-sm text-slate-600 dark:text-slate-400">
{content.description}
</p>
<p className="mt-2 text-xs text-slate-500 dark:text-slate-500">
{content.detail}
</p>
</div>
</div>
<Link
to="/setup"
className="inline-flex items-center gap-2 rounded-lg border border-slate-300 bg-white px-3 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50 dark:border-slate-700 dark:bg-slate-800 dark:text-slate-200 dark:hover:bg-slate-700"
>
<Settings2 className="h-4 w-4" />
{content.cta}
</Link>
</div>
</div>
);
}
+10
View File
@@ -0,0 +1,10 @@
import type { ReactNode } from 'react';
import Layout from '../Layout';
interface AdvisorLayoutProps {
children: ReactNode;
}
export default function AdvisorLayout({ children }: AdvisorLayoutProps) {
return <Layout>{children}</Layout>;
}
+287
View File
@@ -0,0 +1,287 @@
import { useEffect, useState } from 'react';
import { pb } from '../../lib/pocketbase';
import { UserPlus, User, X } from 'lucide-react';
import type { RepairOrder } from '../../types';
import type { TechnicianAssignment, TechnicianUser } from '../../lib/technicianData';
import {
fetchTechnicianUsers,
fetchAssignmentsForRO,
createTechnicianAssignment,
clockElapsedMinutes,
} from '../../lib/technicianData';
import { formatElapsed } from '../../lib/format';
interface AssignmentPanelProps {
ro: RepairOrder;
}
export default function AssignmentPanel({ ro }: AssignmentPanelProps) {
const userId = pb.authStore.model?.id;
const [technicians, setTechnicians] = useState<TechnicianUser[]>([]);
const [assignments, setAssignments] = useState<TechnicianAssignment[]>([]);
const [loadingTechs, setLoadingTechs] = useState(true);
const [showModal, setShowModal] = useState(false);
const [selectedTechId, setSelectedTechId] = useState('');
const [selectedServiceIds, setSelectedServiceIds] = useState<string[]>([]);
const [creating, setCreating] = useState(false);
const [error, setError] = useState('');
const load = async () => {
if (!userId) return;
setLoadingTechs(true);
const [techs, assns] = await Promise.all([
fetchTechnicianUsers(userId),
fetchAssignmentsForRO(ro.id),
]);
setTechnicians(techs);
setAssignments(assns);
setLoadingTechs(false);
};
useEffect(() => {
load();
}, [ro.id, userId]);
const openModal = () => {
setSelectedTechId('');
setSelectedServiceIds(ro.services.map((s) => s.id));
setError('');
setShowModal(true);
};
const closeModal = () => {
setShowModal(false);
setSelectedTechId('');
setSelectedServiceIds([]);
setError('');
};
const toggleService = (id: string) => {
setSelectedServiceIds((prev) =>
prev.includes(id) ? prev.filter((sid) => sid !== id) : [...prev, id],
);
};
const handleCreate = async () => {
if (!selectedTechId) {
setError('Select a technician.');
return;
}
if (selectedServiceIds.length === 0) {
setError('Select at least one service.');
return;
}
if (!userId) return;
setCreating(true);
setError('');
try {
await createTechnicianAssignment(ro, selectedTechId, selectedServiceIds, userId);
closeModal();
await load();
} catch {
setError('Failed to create assignment. Try again.');
} finally {
setCreating(false);
}
};
return (
<div className="mt-6 rounded-xl border border-gray-200 bg-white p-5 dark:border-gray-800 dark:bg-gray-900">
<div className="flex items-center justify-between">
<div>
<h3 className="text-sm font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">
Technician Assignments
</h3>
{assignments.length > 0 && (
<p className="mt-0.5 text-xs text-gray-400 dark:text-gray-500">
{assignments.length} assignment{assignments.length > 1 ? 's' : ''}
</p>
)}
</div>
{technicians.length > 0 && (
<button
onClick={openModal}
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"
>
<UserPlus className="h-3.5 w-3.5" />
Assign
</button>
)}
</div>
{/* Loading */}
{loadingTechs && (
<div className="mt-3 flex items-center justify-center py-4">
<div className="animate-spin h-5 w-5 border-2 border-green-500 border-t-transparent rounded-full" />
</div>
)}
{/* No technicians */}
{!loadingTechs && technicians.length === 0 && (
<div className="mt-3 rounded-lg bg-gray-50 p-4 text-center dark:bg-gray-800/50">
<User className="mx-auto h-6 w-6 text-gray-400 dark:text-gray-500" />
<p className="mt-2 text-sm text-gray-500 dark:text-gray-400">
No technician accounts available yet.
</p>
<p className="text-xs text-gray-400 dark:text-gray-500">
Technician invites come in a future update.
</p>
</div>
)}
{/* Existing assignments */}
{!loadingTechs && assignments.length > 0 && (
<div className="mt-3 space-y-2">
{assignments.map((a) => {
const running = a.clockEntries.some((e) => !e.end);
const tech = technicians.find((t) => t.id === a.technicianUserId);
const done = a.services.filter((s) => s.status === 'completed').length;
return (
<div
key={a.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 justify-between gap-2">
<div className="flex items-center gap-2 min-w-0">
<div className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-green-100 dark:bg-green-900/30">
<User className="h-3.5 w-3.5 text-green-600 dark:text-green-400" />
</div>
<div className="min-w-0">
<p className="text-sm font-medium text-gray-900 dark:text-white truncate">
{tech?.displayName || tech?.email || 'Technician'}
</p>
<p className="text-xs text-gray-400 dark:text-gray-500">
{done}/{a.services.length} services · {formatElapsed(clockElapsedMinutes(a.clockEntries))}
</p>
</div>
</div>
<div className="flex items-center gap-2 shrink-0">
{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" />
Live
</span>
)}
<StatusBadge status={a.status} />
</div>
</div>
</div>
);
})}
</div>
)}
{/* Assign modal */}
{showModal && (
<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">
<h4 className="text-base font-semibold text-gray-900 dark:text-white">
Assign Technician
</h4>
<button
onClick={closeModal}
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>
{/* Technician select */}
<div className="mt-4">
<label className="mb-1 block text-xs font-medium text-gray-500 dark:text-gray-400">
Technician
</label>
<select
value={selectedTechId}
onChange={(e) => setSelectedTechId(e.target.value)}
className="w-full rounded-xl border border-gray-200 bg-white px-3 py-2.5 text-sm text-gray-900 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:focus:border-green-600 dark:focus:ring-green-900/30"
>
<option value="">Select a technician</option>
{technicians.map((t) => (
<option key={t.id} value={t.id}>
{t.displayName || t.email}
</option>
))}
</select>
</div>
{/* Services */}
<div className="mt-4">
<label className="mb-1 block text-xs font-medium text-gray-500 dark:text-gray-400">
Services to assign
</label>
<div className="max-h-48 space-y-1.5 overflow-y-auto rounded-xl border border-gray-200 p-2 dark:border-gray-800">
{ro.services.map((svc) => (
<label
key={svc.id}
className="flex cursor-pointer items-center gap-2 rounded-lg px-2 py-1.5 text-sm hover:bg-gray-50 dark:hover:bg-gray-800/50"
>
<input
type="checkbox"
checked={selectedServiceIds.includes(svc.id)}
onChange={() => toggleService(svc.id)}
className="rounded border-gray-300 text-green-600 focus:ring-green-500 dark:border-gray-600"
/>
<span className="text-gray-700 dark:text-gray-300">
{svc.name}
</span>
</label>
))}
</div>
</div>
{error && (
<p className="mt-3 text-xs text-red-600 dark:text-red-400">{error}</p>
)}
<div className="mt-5 flex gap-3">
<button
onClick={closeModal}
className="flex-1 rounded-xl border border-gray-200 bg-white px-4 py-2.5 text-sm font-medium text-gray-700 transition-colors 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={handleCreate}
disabled={creating}
className="flex-1 rounded-xl bg-green-600 px-4 py-2.5 text-sm font-medium text-white transition-colors hover:bg-green-700 disabled:cursor-not-allowed disabled:opacity-50"
>
{creating ? 'Creating…' : 'Assign'}
</button>
</div>
</div>
</div>
)}
</div>
);
}
function StatusBadge({ status }: { status: string }) {
const 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',
},
waiting_parts: {
label: 'Waiting Parts',
colors: 'bg-orange-100 text-orange-800 dark:bg-orange-900/40 dark:text-orange-300',
},
completed: {
label: 'Completed',
colors: 'bg-green-100 text-green-800 dark:bg-green-900/40 dark:text-green-300',
},
};
const cfg = config[status] ?? config.pending;
return (
<span className={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ${cfg.colors}`}>
{cfg.label}
</span>
);
}
@@ -0,0 +1,641 @@
import { memo, useEffect, useState, useCallback, useRef } from 'react';
import { Save, X, User, Check } from 'lucide-react';
import { pb } from '../../lib/pocketbase';
import { loadSettings, getRoleLabel, showServiceLocationFields } from '../../lib/settings';
import { useSettings } from '../../store/settings';
import { formatPhoneInput } from '../../lib/phone';
import { logError } from '../../lib/userMessages';
import { isValidDate, todayISO } from '../../lib/appointments';
import { composeName, parseName, findMatchingCustomer } from '../../lib/customerName';
import type { CustomerWithVehicles, CustomerFormData } from '../../pages/Customers';
import type { Appointment } from '../../types';
interface AppointmentFormData {
customerId: string;
firstName: string;
middleName: string;
lastName: string;
customerName: string; // composed for payload
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 type CreateAppointmentFormData = AppointmentFormData;
export const EMPTY_FORM: AppointmentFormData = {
customerId: '',
firstName: '',
middleName: '',
lastName: '',
customerName: '',
customerPhone: '',
customerEmail: '',
vehicleInfo: '',
vin: '',
date: '',
time: '',
arrivalWindowMinutes: 0,
tripMiles: 0,
durationMinutes: 60,
advisorName: '',
serviceType: '',
serviceLocation: '',
notes: '',
};
interface Props {
isOpen: boolean;
onClose: () => void;
onSave: (data: AppointmentFormData) => Promise<void>;
appointment: Appointment | null;
customers: CustomerWithVehicles[];
}
function AppointmentModalImpl({
isOpen,
onClose,
onSave,
appointment,
customers,
}: Props) {
const setupSettings = useSettings();
const roleLabel = getRoleLabel(setupSettings);
const showLocationFields = showServiceLocationFields(setupSettings);
const [form, setForm] = useState<AppointmentFormData>({ ...EMPTY_FORM, advisorName: loadSettings().serviceAdvisor });
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const [showCustomerModal, setShowCustomerModal] = useState(false);
const [showCustomerModalFromMatch, setShowCustomerModalFromMatch] = useState(false);
// ── Customer matching ──
const [suggestedCustomer, setSuggestedCustomer] = useState<CustomerWithVehicles | null>(null);
const [dismissedMatch, setDismissedMatch] = useState(false);
const matchTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// Populate form from appointment being edited
useEffect(() => {
if (appointment) {
const parsed = parseName(appointment.customerName || '');
setForm({
customerId: appointment.customerId || '',
firstName: parsed.firstName,
middleName: parsed.middleName,
lastName: parsed.lastName,
customerName: appointment.customerName || '',
customerPhone: appointment.customerPhone || '',
customerEmail: appointment.customerEmail || '',
vehicleInfo: appointment.vehicleInfo || '',
vin: appointment.vin || '',
date: appointment.date || '',
time: appointment.time || '',
durationMinutes: appointment.durationMinutes ?? 60,
advisorName: appointment.advisorName || '',
serviceType: appointment.serviceType || '',
serviceLocation: appointment.serviceLocation || '',
tripMiles: appointment.tripMiles || 0,
arrivalWindowMinutes: appointment.arrivalWindowMinutes || 0,
notes: appointment.notes || '',
});
setSuggestedCustomer(null);
setDismissedMatch(false);
} else {
setForm({ ...EMPTY_FORM, advisorName: loadSettings().serviceAdvisor });
setSuggestedCustomer(null);
setDismissedMatch(false);
}
setError(null);
}, [appointment, isOpen]);
// ── Customer matching logic (debounced) ──
const runMatching = useCallback(() => {
if (!form.customerId && !dismissedMatch) {
const match = findMatchingCustomer(customers, {
firstName: form.firstName,
middleName: form.middleName,
lastName: form.lastName,
phone: form.customerPhone,
});
setSuggestedCustomer(match);
} else {
setSuggestedCustomer(null);
}
}, [form.firstName, form.middleName, form.lastName, form.customerPhone, form.customerId, customers, dismissedMatch]);
useEffect(() => {
if (appointment) return; // don't match while editing an existing appointment
if (matchTimeoutRef.current) clearTimeout(matchTimeoutRef.current);
matchTimeoutRef.current = setTimeout(runMatching, 400);
return () => {
if (matchTimeoutRef.current) clearTimeout(matchTimeoutRef.current);
};
}, [runMatching, appointment]);
const handleUseExistingCustomer = useCallback(() => {
if (!suggestedCustomer) return;
const parsed = parseName(suggestedCustomer.name);
setForm((prev) => ({
...prev,
customerId: suggestedCustomer.id,
firstName: parsed.firstName,
middleName: parsed.middleName,
lastName: parsed.lastName,
customerName: suggestedCustomer.name,
customerPhone: suggestedCustomer.phone || prev.customerPhone,
customerEmail: suggestedCustomer.email || prev.customerEmail,
}));
setSuggestedCustomer(null);
setDismissedMatch(false);
}, [suggestedCustomer]);
const handleDismissMatch = useCallback(() => {
setSuggestedCustomer(null);
setDismissedMatch(true);
}, []);
// ── Create new customer in PB ──
const ensureCustomerRecord = useCallback(async (): Promise<string> => {
// If we already have a customerId, return it
if (form.customerId) return form.customerId;
const uid = pb.authStore.model?.id;
if (!uid) return '';
const name = composeName(form.firstName, form.middleName, form.lastName);
if (!name.trim()) return '';
// Check once more for an existing customer in case one was added since mount
const existingMatch = findMatchingCustomer(customers, {
firstName: form.firstName,
middleName: form.middleName,
lastName: form.lastName,
phone: form.customerPhone,
});
if (existingMatch) {
// Auto-pick the match
setForm((prev) => ({ ...prev, customerId: existingMatch.id }));
return existingMatch.id;
}
// Create the customer record
try {
const record = await pb.collection('customers').create({
name,
phone: form.customerPhone,
email: form.customerEmail,
address: '',
notes: '',
userId: uid,
});
const newId = record.id;
setForm((prev) => ({ ...prev, customerId: newId }));
return newId;
} catch (err) {
logError('Failed to create customer record', err);
return '';
}
}, [form, customers]);
const handleCustomerCreated = async (data: CustomerFormData) => {
const uid = pb.authStore.model?.id;
if (!uid) return;
const name = composeName(data.firstName, data.middleName, data.lastName) || data.name;
const record = await pb.collection('customers').create({
name,
firstName: data.firstName || '',
middleName: data.middleName || '',
lastName: data.lastName || '',
phone: data.phone || '',
email: data.email || '',
address: data.address || '',
notes: data.notes || '',
userId: uid,
});
const c: CustomerWithVehicles = {
...record as unknown as import('../../pages/Customers').CustomerRecord,
vehicles: [],
vehicleCount: 0,
};
const parsed = parseName(name);
handleChange('customerId', c.id);
handleChange('firstName', parsed.firstName);
handleChange('middleName', parsed.middleName);
handleChange('lastName', parsed.lastName);
handleChange('customerName', name);
handleChange('customerPhone', c.phone || '');
handleChange('customerEmail', c.email || '');
setShowCustomerModal(false);
setShowCustomerModalFromMatch(false);
setSuggestedCustomer(null);
setDismissedMatch(false);
};
const handleChange = (field: keyof AppointmentFormData, value: string | number) => {
setForm((prev) => {
const next = { ...prev, [field]: value };
// When name fields change, recompose customerName
if (field === 'firstName' || field === 'middleName' || field === 'lastName') {
const newFirst = field === 'firstName' ? String(value) : prev.firstName;
const newMiddle = field === 'middleName' ? String(value) : prev.middleName;
const newLast = field === 'lastName' ? String(value) : prev.lastName;
const composed = composeName(newFirst, newMiddle, newLast);
if (composed !== prev.customerName) {
next.customerName = composed;
}
}
return next;
});
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError(null);
const name = composeName(form.firstName, form.middleName, form.lastName);
if (!name.trim()) {
setError('Please enter at least the customer\'s first and last name.');
return;
}
if (!form.date) {
setError('Date is required.');
return;
}
if (!isValidDate(form.date)) {
setError('Please enter a valid appointment date.');
return;
}
if (form.date < todayISO()) {
setError('Appointment date cannot be in the past.');
return;
}
if (!form.time) {
setError('Time is required.');
return;
}
setSaving(true);
try {
// Auto-create customer record if one doesn't exist yet
const customerId = await ensureCustomerRecord();
const payload: AppointmentFormData = {
...form,
customerId: customerId || form.customerId,
customerName: name,
};
await onSave(payload);
onClose();
} catch (err: unknown) {
logError('Failed to save appointment', err);
setError('Appointment could not be saved. Please try again.');
} finally {
setSaving(false);
}
};
if (!isOpen) return null;
const name = composeName(form.firstName, form.middleName, form.lastName);
return (
<>
<div className="fixed inset-0 z-50 flex items-start justify-center overflow-y-auto p-4 pt-8">
<div
className="absolute inset-0 bg-black/50 backdrop-blur-sm"
onClick={onClose}
/>
<div className="relative z-10 w-full max-w-3xl overflow-hidden rounded-2xl border border-gray-200 bg-white shadow-2xl dark:border-gray-700 dark:bg-gray-900">
<div className="flex items-start justify-between border-b border-gray-200 bg-gradient-to-r from-emerald-50 via-white to-white px-6 py-5 dark:border-gray-700 dark:from-emerald-950/20 dark:via-gray-900 dark:to-gray-900">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-emerald-700 dark:text-emerald-400">
Appointment Details
</p>
<h2 className="mt-1 text-xl font-semibold tracking-tight text-gray-900 dark:text-white">
{appointment ? 'Edit Appointment' : 'New Appointment'}
</h2>
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
Capture customer, vehicle, and schedule details in one clean service record.
</p>
</div>
<button
onClick={onClose}
className="rounded-lg p-1.5 text-gray-400 transition-colors hover:bg-white/70 hover:text-gray-600 dark:hover:bg-gray-800 dark:hover:text-gray-300"
>
<X className="h-5 w-5" />
</button>
</div>
<form onSubmit={handleSubmit} className="space-y-6 bg-gray-50/70 px-6 py-6 dark:bg-gray-950/40">
{error && (
<div className="rounded-lg border border-red-200 bg-red-50 p-3 text-sm text-red-700 dark:border-red-800 dark:bg-red-900/20 dark:text-red-400">
{error}
</div>
)}
{/* ── Customer match suggestion banner ── */}
{suggestedCustomer && !form.customerId && (
<div className="rounded-xl border border-emerald-200 bg-emerald-50 p-4 dark:border-emerald-700 dark:bg-emerald-900/20">
<div className="flex items-start gap-3">
<div className="mt-0.5 flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-emerald-100 dark:bg-emerald-800">
<User className="h-4 w-4 text-emerald-600 dark:text-emerald-300" />
</div>
<div className="min-w-0 flex-1">
<p className="text-sm font-medium text-emerald-800 dark:text-emerald-200">
Existing customer found
</p>
<p className="mt-0.5 text-sm text-emerald-700 dark:text-emerald-300">
<span className="font-semibold">{suggestedCustomer.name}</span>
{suggestedCustomer.phone && (
<> &middot; {suggestedCustomer.phone}</>
)}
{suggestedCustomer.email && (
<> &middot; {suggestedCustomer.email}</>
)}
</p>
<div className="mt-2 flex items-center gap-2">
<button
type="button"
onClick={handleUseExistingCustomer}
className="inline-flex items-center gap-1.5 rounded-lg bg-emerald-600 px-3 py-1.5 text-xs font-medium text-white hover:bg-emerald-700"
>
<Check className="h-3.5 w-3.5" />
Use Existing Customer
</button>
<button
type="button"
onClick={handleDismissMatch}
className="rounded-lg border border-emerald-300 bg-white px-3 py-1.5 text-xs font-medium text-emerald-700 hover:bg-emerald-50 dark:border-emerald-600 dark:bg-gray-800 dark:text-emerald-300 dark:hover:bg-emerald-900/30"
>
Continue as New
</button>
</div>
</div>
</div>
</div>
)}
<div className="grid grid-cols-1 gap-6 xl:grid-cols-[1.15fr_0.85fr]">
<div className="space-y-6">
<section className="rounded-2xl border border-gray-200 bg-white p-5 shadow-sm dark:border-gray-700 dark:bg-gray-900">
<h3 className="mb-4 text-sm font-semibold text-gray-700 dark:text-gray-300">Customer Information</h3>
{/* First / Middle / Last name row */}
<div className="mb-3 grid grid-cols-1 gap-3 sm:grid-cols-3">
<div>
<label className="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">
First Name <span className="text-red-500">*</span>
</label>
<input
type="text"
value={form.firstName}
onChange={(e) => handleChange('firstName', e.target.value)}
className="w-full rounded-xl border border-gray-300 px-3 py-2.5 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"
placeholder="John"
/>
</div>
<div>
<label className="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">
Middle
</label>
<input
type="text"
value={form.middleName}
onChange={(e) => handleChange('middleName', e.target.value)}
className="w-full rounded-xl border border-gray-300 px-3 py-2.5 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"
placeholder="Michael"
/>
</div>
<div>
<label className="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">
Last Name <span className="text-red-500">*</span>
</label>
<input
type="text"
value={form.lastName}
onChange={(e) => handleChange('lastName', e.target.value)}
className="w-full rounded-xl border border-gray-300 px-3 py-2.5 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"
placeholder="Doe"
/>
</div>
</div>
{/* Phone + Email */}
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<div>
<label className="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">Phone</label>
<input type="tel" value={formatPhoneInput(form.customerPhone)} onChange={(e) => handleChange('customerPhone', e.target.value.replace(/\D/g, ''))} className="w-full rounded-xl border border-gray-300 px-3 py-2.5 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" placeholder="(555) 123-4567" />
</div>
<div>
<label className="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">Email</label>
<input type="email" value={form.customerEmail} onChange={(e) => handleChange('customerEmail', e.target.value)} className="w-full rounded-xl border border-gray-300 px-3 py-2.5 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" placeholder="john@example.com" />
</div>
</div>
{/* Selected customer indicator */}
{form.customerId && (
<div className="mt-3 flex items-center gap-2 rounded-lg border border-emerald-200 bg-emerald-50/50 px-3 py-2 dark:border-emerald-700 dark:bg-emerald-900/10">
<Check className="h-4 w-4 shrink-0 text-emerald-500" />
<span className="text-xs text-emerald-700 dark:text-emerald-300">
Linked to customer record: <strong>{name || form.customerName}</strong>
</span>
</div>
)}
</section>
<section className="rounded-2xl border border-gray-200 bg-white p-5 shadow-sm dark:border-gray-700 dark:bg-gray-900">
<h3 className="mb-4 text-sm font-semibold text-gray-700 dark:text-gray-300">Vehicle Information</h3>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div className="sm:col-span-2">
<label className="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">Vehicle Info</label>
<input type="text" value={form.vehicleInfo} onChange={(e) => handleChange('vehicleInfo', e.target.value)} className="w-full rounded-xl border border-gray-300 px-3 py-2.5 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" placeholder="2020 Toyota Camry" />
</div>
<div>
<label className="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">VIN</label>
<input type="text" value={form.vin} onChange={(e) => handleChange('vin', e.target.value.toUpperCase())} className="w-full rounded-xl border border-gray-300 px-3 py-2.5 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" placeholder="1HGBH41JXMN109186" />
</div>
<div>
<label className="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">Duration (min)</label>
<input type="number" min="0" value={form.durationMinutes} onChange={(e) => handleChange('durationMinutes', parseInt(e.target.value, 10) || 0)} className="w-full rounded-xl border border-gray-300 px-3 py-2.5 text-sm text-gray-900 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:focus:border-emerald-400" />
</div>
{showLocationFields && (
<div>
<label className="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">Trip Miles</label>
<input type="number" min="0" step="0.1" value={form.tripMiles || ''} onChange={(e) => handleChange('tripMiles', parseFloat(e.target.value) || 0)} className="w-full rounded-xl border border-gray-300 px-3 py-2.5 text-sm text-gray-900 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:focus:border-emerald-400" placeholder="0" />
</div>
)}
</div>
</section>
</div>
<div className="space-y-6">
<section className="rounded-2xl border border-gray-200 bg-white p-5 shadow-sm dark:border-gray-700 dark:bg-gray-900">
<h3 className="mb-4 text-sm font-semibold text-gray-700 dark:text-gray-300">Schedule & Service</h3>
<div className="grid grid-cols-1 gap-4">
<div>
<label className="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">Date <span className="text-red-500">*</span></label>
<input type="date" value={form.date} onChange={(e) => handleChange('date', e.target.value)} className="w-full rounded-xl border border-gray-300 px-3 py-2.5 text-sm text-gray-900 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:focus:border-emerald-400" />
</div>
<div>
<label className="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">Time <span className="text-red-500">*</span></label>
<input type="time" value={form.time} onChange={(e) => handleChange('time', e.target.value)} className="w-full rounded-xl border border-gray-300 px-3 py-2.5 text-sm text-gray-900 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:focus:border-emerald-400" />
</div>
{showLocationFields && (
<div>
<label className="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">Arrival Window</label>
<select value={form.arrivalWindowMinutes} onChange={(e) => handleChange('arrivalWindowMinutes', parseInt(e.target.value, 10) || 0)} className="w-full rounded-xl border border-gray-300 px-3 py-2.5 text-sm text-gray-900 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:focus:border-emerald-400">
<option value={0}>Exact time</option>
<option value={60}>&#xb1;1 hour</option>
<option value={120}>&#xb1;2 hours</option>
<option value={180}>&#xb1;3 hours</option>
<option value={240}>&#xb1;4 hours</option>
</select>
</div>
)}
<div>
<label className="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">{roleLabel}</label>
<input
type="text"
value={form.advisorName}
onChange={(e) => handleChange('advisorName', e.target.value)}
placeholder={loadSettings().serviceAdvisor || `${roleLabel} name`}
className={`w-full rounded-xl border px-3 py-2.5 text-sm shadow-sm transition-colors focus:outline-none focus:ring-2 focus:ring-emerald-500/20 ${
loadSettings().serviceAdvisor && form.advisorName === loadSettings().serviceAdvisor
? 'border-gray-200 bg-gray-50 text-gray-400 placeholder-gray-400 focus:border-emerald-500 dark:border-gray-700 dark:bg-gray-800/50 dark:text-gray-500 dark:placeholder-gray-500 dark:focus:border-emerald-400'
: 'border-gray-300 bg-white text-gray-900 placeholder-gray-400 focus:border-emerald-500 dark:border-gray-600 dark:bg-gray-800 dark:text-white dark:placeholder-gray-500 dark:focus:border-emerald-400'
}`}
/>
</div>
<div>
<label className="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">Service Type</label>
<input type="text" value={form.serviceType} onChange={(e) => handleChange('serviceType', e.target.value)} className="w-full rounded-xl border border-gray-300 px-3 py-2.5 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" placeholder="Oil Change, Brake Service, etc." />
</div>
{showLocationFields && (
<div>
<label className="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">Service Address</label>
<input type="text" value={form.serviceLocation} onChange={(e) => handleChange('serviceLocation', e.target.value)} className="w-full rounded-xl border border-gray-300 px-3 py-2.5 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" placeholder="123 Oak St, Knoxville, TN" />
</div>
)}
</div>
</section>
<section className="rounded-2xl border border-gray-200 bg-white p-5 shadow-sm dark:border-gray-700 dark:bg-gray-900">
<label className="block text-sm font-semibold text-gray-700 dark:text-gray-300">Notes</label>
<textarea value={form.notes} onChange={(e) => handleChange('notes', e.target.value)} rows={6} className="mt-3 w-full rounded-xl border border-gray-300 px-3 py-2.5 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" placeholder="Any additional notes..." />
</section>
</div>
</div>
<div className="flex items-center justify-between border-t border-gray-200 bg-white px-1 pt-5 dark:border-gray-700 dark:bg-transparent">
<p className="text-sm text-gray-500 dark:text-gray-400">
Confirm the service slot and customer details before saving.
</p>
<div className="flex items-center gap-3">
<button
type="button"
onClick={onClose}
className="rounded-xl border border-gray-300 px-4 py-2.5 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
type="submit"
disabled={saving || !form.firstName.trim() || !form.lastName.trim()}
className="inline-flex items-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 disabled:cursor-not-allowed disabled:opacity-50 dark:focus:ring-offset-gray-800"
>
{saving ? (
<>
<svg className="h-4 w-4 animate-spin" viewBox="0 0 24 24" fill="none">
<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-8v4a4 4 0 00-4 4H4z" />
</svg>
Saving...
</>
) : (
<>
<Save className="h-4 w-4" />
{appointment ? 'Update' : 'Create'}
</>
)}
</button>
</div>
</div>
</form>
</div>
</div>
{/* Create Customer Modal */}
{(showCustomerModal || showCustomerModalFromMatch) && (
<div className="fixed inset-0 z-[60] flex items-center justify-center bg-black/50 p-4">
<div className="w-full max-w-lg rounded-xl bg-white shadow-xl dark:bg-gray-800">
<div className="flex items-center justify-between border-b border-gray-200 px-6 py-4 dark:border-gray-700">
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">Create New Customer</h2>
<button onClick={() => { setShowCustomerModal(false); setShowCustomerModalFromMatch(false); }} className="rounded-lg p-1.5 text-gray-400 hover:bg-gray-100 hover:text-gray-600"><X className="h-5 w-5" /></button>
</div>
<div className="space-y-4 p-6">
<div className="grid grid-cols-3 gap-3">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">First <span className="text-red-500">*</span></label>
<input type="text" id="new-customer-first" defaultValue={form.firstName} className="mt-1 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-700 dark:text-gray-100" placeholder="John" />
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">Middle</label>
<input type="text" id="new-customer-middle" defaultValue={form.middleName} className="mt-1 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-700 dark:text-gray-100" placeholder="Michael" />
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">Last <span className="text-red-500">*</span></label>
<input type="text" id="new-customer-last" defaultValue={form.lastName} className="mt-1 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-700 dark:text-gray-100" placeholder="Doe" />
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">Phone</label>
<input type="tel" id="new-customer-phone" defaultValue={form.customerPhone} className="mt-1 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-700 dark:text-gray-100" placeholder="Phone" />
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">Email</label>
<input type="email" id="new-customer-email" defaultValue={form.customerEmail} className="mt-1 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-700 dark:text-gray-100" placeholder="Email" />
</div>
</div>
<div className="flex justify-end gap-3 border-t border-gray-200 pt-4 dark:border-gray-700">
<button type="button" onClick={() => { setShowCustomerModal(false); setShowCustomerModalFromMatch(false); }} className="rounded-lg border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-300">Cancel</button>
<button type="button" onClick={() => {
const firstInput = document.getElementById('new-customer-first') as HTMLInputElement;
const middleInput = document.getElementById('new-customer-middle') as HTMLInputElement;
const lastInput = document.getElementById('new-customer-last') as HTMLInputElement;
const phoneInput = document.getElementById('new-customer-phone') as HTMLInputElement;
const emailInput = document.getElementById('new-customer-email') as HTMLInputElement;
const firstName = firstInput?.value?.trim() || '';
const middleName = middleInput?.value?.trim() || '';
const lastName = lastInput?.value?.trim() || '';
const name = composeName(firstName, middleName, lastName);
if (!name) return;
handleCustomerCreated({
name,
firstName,
middleName,
lastName,
phone: phoneInput?.value || '',
email: emailInput?.value || '',
address: '',
notes: '',
}).catch(() => {});
}} className="rounded-lg bg-green-600 px-4 py-2 text-sm font-medium text-white hover:bg-green-700">Create Customer</button>
</div>
</div>
</div>
</div>
)}
</>);
}
export const AppointmentModal = memo(AppointmentModalImpl);
@@ -0,0 +1,205 @@
import { memo } from 'react';
import {
CalendarDays,
User,
Car,
Wrench,
UserCheck,
Clock,
MapPin,
Phone,
Mail,
LogIn,
Edit3,
Trash2,
Check,
Ban,
UserX,
} from 'lucide-react';
import type { Appointment } from '../../types';
import { formatPhoneDisplay } from '../../lib/phone';
import { formatDateDisplay, formatArrivalWindow, formatDurationShort } from '../../lib/appointments';
import { StatusBadge } from './StatusBadge';
interface Props {
appointment: Appointment;
onEdit: (appt: Appointment) => void;
onDelete: (appt: Appointment) => void;
onStatusChange: (appt: Appointment, status: Appointment['status']) => void;
onCheckIn: (appt: Appointment) => void;
}
function AppointmentRowImpl({
appointment,
onEdit,
onDelete,
onStatusChange,
onCheckIn,
}: Props) {
const statusAccent: Record<Appointment['status'], string> = {
scheduled: 'before:bg-blue-500',
checked_in: 'before:bg-amber-500',
completed: 'before:bg-emerald-500',
cancelled: 'before:bg-red-500',
no_show: 'before:bg-gray-500',
};
const statusGlow: Record<Appointment['status'], string> = {
scheduled: 'hover:shadow-blue-100/80 dark:hover:shadow-blue-950/20',
checked_in: 'hover:shadow-amber-100/80 dark:hover:shadow-amber-950/20',
completed: 'hover:shadow-emerald-100/80 dark:hover:shadow-emerald-950/20',
cancelled: 'hover:shadow-red-100/80 dark:hover:shadow-red-950/20',
no_show: 'hover:shadow-gray-200/80 dark:hover:shadow-black/20',
};
const statusActions: {
label: string;
status: Appointment['status'];
icon: React.ComponentType<{ className?: string }>;
color: string;
}[] = [];
if (appointment.status === 'checked_in') {
statusActions.push({ label: 'Complete', status: 'completed', icon: Check, color: 'text-green-600 hover:bg-green-50 dark:hover:bg-green-900/20' });
}
if (appointment.status !== 'cancelled' && appointment.status !== 'no_show') {
if (appointment.status !== 'completed') {
statusActions.push({ label: 'Cancel', status: 'cancelled', icon: Ban, color: 'text-red-600 hover:bg-red-50 dark:hover:bg-red-900/20' });
}
statusActions.push({ label: 'No Show', status: 'no_show', icon: UserX, color: 'text-gray-600 hover:bg-gray-100 dark:hover:bg-gray-700' });
}
return (
<div className={`group relative overflow-hidden rounded-2xl border border-gray-200/90 bg-white px-4 py-2.5 shadow-sm shadow-gray-200/60 transition-all before:absolute before:inset-y-0 before:left-0 before:w-1 before:content-[''] hover:-translate-y-0.5 hover:shadow-lg dark:border-gray-700 dark:bg-gray-800 dark:shadow-black/10 dark:hover:border-gray-600 ${statusAccent[appointment.status] || statusAccent.scheduled} ${statusGlow[appointment.status] || statusGlow.scheduled}`}>
<div className="flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between">
<div className="flex-1 min-w-0">
<div className="mb-1.5 flex flex-wrap items-center gap-2 text-sm text-gray-500 dark:text-gray-400">
<span className="inline-flex items-center gap-1 rounded-full bg-emerald-50 px-2.5 py-1 text-[11px] font-semibold uppercase tracking-[0.16em] text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-300">
<CalendarDays className="h-3.5 w-3.5 shrink-0" />
Service Slot
</span>
<StatusBadge status={appointment.status} />
</div>
<h3 className="truncate text-base font-semibold tracking-tight text-gray-900 dark:text-white">
<User className="mr-1.5 inline h-4 w-4 text-gray-400" />
{appointment.customerName || 'Unknown'}
</h3>
<div className="mt-1.5 flex flex-wrap gap-1.5 text-xs text-gray-500 dark:text-gray-400">
<span className="inline-flex items-center gap-1 rounded-full border border-gray-200 bg-gray-50 px-2.5 py-0.5 dark:border-gray-700 dark:bg-gray-700/60">
<span className="font-medium uppercase tracking-[0.12em] text-gray-400 dark:text-gray-500">Date</span>
<span className="font-semibold text-gray-700 dark:text-gray-200">{appointment.date ? formatDateDisplay(appointment.date) : 'No date set'}</span>
</span>
<span className="inline-flex items-center gap-1 rounded-full border border-gray-200 bg-gray-50 px-2.5 py-0.5 dark:border-gray-700 dark:bg-gray-700/60">
<span className="font-medium uppercase tracking-[0.12em] text-gray-400 dark:text-gray-500">Time</span>
<span className="font-semibold text-gray-700 dark:text-gray-200">{appointment.time ? formatArrivalWindow(appointment.time, appointment.arrivalWindowMinutes) : 'No time set'}</span>
</span>
<span className="inline-flex items-center gap-1 rounded-full border border-gray-200 bg-gray-50 px-2.5 py-0.5 dark:border-gray-700 dark:bg-gray-700/60">
<span className="font-medium uppercase tracking-[0.12em] text-gray-400 dark:text-gray-500">VIN</span>
<span className="font-semibold text-gray-700 dark:text-gray-200">{appointment.vin || 'No VIN set'}</span>
</span>
</div>
<div className="mt-1.5 flex flex-wrap gap-1.5 text-sm text-gray-600 dark:text-gray-300">
{appointment.vehicleInfo && (
<span className="inline-flex items-center gap-1 rounded-full border border-gray-200 bg-gray-50 px-2.5 py-0.5 dark:border-gray-700 dark:bg-gray-700/60">
<Car className="h-3.5 w-3.5" />
{appointment.vehicleInfo}
</span>
)}
{appointment.serviceType && (
<span className="inline-flex items-center gap-1 rounded-full border border-gray-200 bg-gray-50 px-2.5 py-0.5 dark:border-gray-700 dark:bg-gray-700/60">
<Wrench className="h-3.5 w-3.5" />
{appointment.serviceType}
</span>
)}
{appointment.advisorName && (
<span className="inline-flex items-center gap-1 rounded-full border border-gray-200 bg-gray-50 px-2.5 py-0.5 dark:border-gray-700 dark:bg-gray-700/60">
<UserCheck className="h-3.5 w-3.5" />
{appointment.advisorName}
</span>
)}
{appointment.durationMinutes != null && appointment.durationMinutes > 0 && (
<span className="inline-flex items-center gap-1 rounded-full border border-amber-200 bg-amber-50 px-2.5 py-0.5 text-amber-800 dark:border-amber-900/60 dark:bg-amber-900/20 dark:text-amber-300">
<Clock className="h-3.5 w-3.5" />
{formatDurationShort(appointment.durationMinutes)}
</span>
)}
{appointment.serviceLocation && (
<span className="inline-flex items-center gap-1 rounded-full border border-sky-200 bg-sky-50 px-2.5 py-0.5 text-sky-800 dark:border-sky-900/60 dark:bg-sky-900/20 dark:text-sky-300">
<MapPin className="h-3.5 w-3.5" />
{appointment.serviceLocation}
</span>
)}
{typeof appointment.tripMiles === 'number' && appointment.tripMiles > 0 && (
<span className="inline-flex items-center gap-1 rounded-full border border-purple-200 bg-purple-50 px-2.5 py-0.5 text-purple-800 dark:border-purple-900/60 dark:bg-purple-900/20 dark:text-purple-300">
<Car className="h-3.5 w-3.5" />
{appointment.tripMiles} mi
</span>
)}
</div>
{appointment.notes && (
<p className="mt-1.5 rounded-xl border border-dashed border-gray-200 bg-gray-50/70 px-3 py-1 text-sm italic text-gray-500 line-clamp-2 dark:border-gray-700 dark:bg-gray-900/40 dark:text-gray-400">
{appointment.notes}
</p>
)}
<div className="mt-1.5 flex flex-wrap gap-2 text-xs text-gray-400 dark:text-gray-500">
{appointment.customerPhone && (
<span className="inline-flex items-center gap-1">
<Phone className="h-3 w-3" />
{formatPhoneDisplay(appointment.customerPhone)}
</span>
)}
{appointment.customerEmail && (
<span className="inline-flex items-center gap-1">
<Mail className="h-3 w-3" />
{appointment.customerEmail}
</span>
)}
</div>
</div>
<div className="flex shrink-0 flex-wrap items-center gap-1 self-start rounded-xl border border-gray-200 bg-gray-50/80 p-0.5 dark:border-gray-700 dark:bg-gray-900/60">
{appointment.status === 'scheduled' && (
<button
onClick={() => onCheckIn(appointment)}
title="Check In (Create RO)"
className="rounded-lg p-1.5 text-yellow-600 transition-colors hover:bg-yellow-50 dark:hover:bg-yellow-900/20"
>
<LogIn className="h-4 w-4" />
</button>
)}
{statusActions.map((action) => (
<button
key={action.status}
onClick={() => onStatusChange(appointment, action.status)}
title={action.label}
className={`rounded-lg p-1.5 transition-colors ${action.color}`}
>
<action.icon className="h-4 w-4" />
</button>
))}
<button
onClick={() => onEdit(appointment)}
title="Edit"
className="rounded-lg p-1.5 text-blue-600 transition-colors hover:bg-blue-50 dark:hover:bg-blue-900/20"
>
<Edit3 className="h-4 w-4" />
</button>
<button
onClick={() => onDelete(appointment)}
title="Delete"
className="rounded-lg p-1.5 text-red-600 transition-colors hover:bg-red-50 dark:hover:bg-red-900/20"
>
<Trash2 className="h-4 w-4" />
</button>
</div>
</div>
</div>
);
}
export const AppointmentRow = memo(AppointmentRowImpl);
@@ -0,0 +1,316 @@
import { memo, useEffect, useState } from 'react';
import { LogIn, UserCheck, UserX, X } from 'lucide-react';
import { loadSettings, getRoleLabel, showServiceLocationFields, getCustomerTypeLabels } from '../../lib/settings';
import { useSettings } from '../../store/settings';
import { formatPhoneInput } from '../../lib/phone';
import { generateRONumber } from '../../lib/appointments';
import type { Appointment, CustomerType } from '../../types';
import type { CustomerWithVehicles } from '../../pages/Customers';
interface CheckInData {
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;
}
interface Props {
isOpen: boolean;
onClose: () => void;
onSubmit: (data: CheckInData) => void;
appointment: Appointment | null;
customers: CustomerWithVehicles[];
}
function CheckInModalImpl({
isOpen,
onClose,
onSubmit,
appointment,
customers,
}: Props) {
const setupSettings = useSettings();
const roleLabel = getRoleLabel(setupSettings);
const customerTypeLabels = getCustomerTypeLabels(setupSettings);
const showLocationFields = showServiceLocationFields(setupSettings);
const [customerName, setCustomerName] = useState('');
const [customerPhone, setCustomerPhone] = useState('');
const [customerEmail, setCustomerEmail] = useState('');
const [vehicleInfo, setVehicleInfo] = useState('');
const [vin, setVin] = useState('');
const [mileage, setMileage] = useState('');
const [advisorName, setAdvisorName] = useState(() => loadSettings().serviceAdvisor);
const [roNumber, setRoNumber] = useState('');
const [concerns, setConcerns] = useState('');
const [estimatedHours, setEstimatedHours] = useState(1);
const [estimatedMinutes, setEstimatedMinutes] = useState(0);
const [customerType, setCustomerType] = useState<CustomerType>('drop-off');
const [saving, setSaving] = useState(false);
const [serviceLocation, setServiceLocation] = useState('');
const [travelFee, setTravelFee] = useState<number>(() => loadSettings().defaultTravelFee || 0);
const [tripMiles, setTripMiles] = useState<number>(0);
useEffect(() => {
if (isOpen && appointment) {
setCustomerName(appointment.customerName || '');
setCustomerPhone(appointment.customerPhone || '');
setCustomerEmail(appointment.customerEmail || '');
setVehicleInfo(appointment.vehicleInfo || '');
setVin(appointment.vin || '');
setMileage('');
const linkedCustomer = appointment.customerId
? customers.find((c) => c.id === appointment.customerId)
: null;
if (linkedCustomer && linkedCustomer.vehicles.length > 0) {
const v = linkedCustomer.vehicles[0];
const label = [v.year, v.make, v.model].filter(Boolean).join(' ');
if (label && !appointment.vehicleInfo) setVehicleInfo(label);
if (v.vin && !appointment.vin) setVin(v.vin);
if (v.mileage) setMileage(v.mileage);
}
setAdvisorName(appointment.advisorName || '');
setRoNumber(generateRONumber());
setConcerns(appointment.notes || '');
const totalMinutes = appointment.durationMinutes ?? 60;
setEstimatedHours(Math.floor(totalMinutes / 60));
setEstimatedMinutes(totalMinutes % 60);
setCustomerType('drop-off');
setServiceLocation(appointment.serviceLocation || '');
setTravelFee(loadSettings().defaultTravelFee || 0);
setTripMiles(appointment.tripMiles || 0);
}
}, [isOpen, appointment]);
const handleSubmit = async () => {
if (!customerName.trim() || !vehicleInfo.trim()) return;
setSaving(true);
try {
onSubmit({
customerName: customerName.trim(),
customerPhone: customerPhone.trim(),
customerEmail: customerEmail.trim(),
vehicleInfo: vehicleInfo.trim(),
vin: vin.trim(),
mileage: mileage.trim(),
advisorName: advisorName.trim(),
roNumber: roNumber.trim() || generateRONumber(),
notes: concerns.trim(),
estimatedDuration: estimatedHours * 60 + estimatedMinutes,
customerType,
serviceLocation: serviceLocation.trim(),
travelFee: Number(travelFee) || 0,
tripMiles: Number(tripMiles) || 0,
});
onClose();
} finally {
setSaving(false);
}
};
if (!isOpen) return null;
return (
<div className="fixed inset-0 z-50 flex items-start justify-center overflow-y-auto bg-black/55 p-4 pt-8 backdrop-blur-sm">
<div className="w-full max-w-3xl overflow-hidden rounded-2xl border border-gray-200 bg-white shadow-2xl dark:border-gray-700 dark:bg-gray-900">
<div className="flex items-start justify-between border-b border-gray-200 bg-gradient-to-r from-amber-50 via-white to-white px-6 py-5 dark:border-gray-700 dark:from-amber-950/30 dark:via-gray-900 dark:to-gray-900">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-amber-600 dark:text-amber-400">
Appointment Check In
</p>
<h2 className="mt-1 text-xl font-semibold text-gray-900 dark:text-white">
Check In &mdash; Create RO
</h2>
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
Convert appointment into an active repair order
</p>
</div>
<button
onClick={onClose}
className="rounded-lg p-2 text-gray-400 transition-colors hover:bg-white/70 hover:text-gray-600 dark:hover:bg-gray-800 dark:hover:text-gray-300"
>
<X className="h-5 w-5" />
</button>
</div>
<div className="space-y-6 bg-gray-50/70 px-6 py-6 dark:bg-gray-950/40">
<div className="grid grid-cols-1 gap-6 xl:grid-cols-[1.15fr_0.85fr]">
<div className="space-y-6">
<section className="rounded-2xl border border-gray-200 bg-white p-5 shadow-sm dark:border-gray-700 dark:bg-gray-900">
<h3 className="mb-4 text-sm font-semibold text-gray-700 dark:text-gray-300">Customer Information</h3>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div className="sm:col-span-2">
<label className="block text-xs font-medium text-gray-500 dark:text-gray-400">Customer Name *</label>
<input value={customerName} onChange={(e) => setCustomerName(e.target.value)} className="mt-1.5 w-full rounded-xl border border-gray-300 bg-white px-3 py-2.5 text-sm shadow-sm transition-colors focus:border-amber-500 focus:outline-none focus:ring-2 focus:ring-amber-500/20 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100" />
</div>
<div>
<label className="block text-xs font-medium text-gray-500 dark:text-gray-400">Phone</label>
<input value={formatPhoneInput(customerPhone)} onChange={(e) => setCustomerPhone(e.target.value.replace(/\D/g, ''))} className="mt-1.5 w-full rounded-xl border border-gray-300 bg-white px-3 py-2.5 text-sm shadow-sm transition-colors focus:border-amber-500 focus:outline-none focus:ring-2 focus:ring-amber-500/20 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100" />
</div>
<div>
<label className="block text-xs font-medium text-gray-500 dark:text-gray-400">Email</label>
<input value={customerEmail} onChange={(e) => setCustomerEmail(e.target.value)} className="mt-1.5 w-full rounded-xl border border-gray-300 bg-white px-3 py-2.5 text-sm shadow-sm transition-colors focus:border-amber-500 focus:outline-none focus:ring-2 focus:ring-amber-500/20 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100" />
</div>
</div>
</section>
<section className="rounded-2xl border border-gray-200 bg-white p-5 shadow-sm dark:border-gray-700 dark:bg-gray-900">
<h3 className="mb-4 text-sm font-semibold text-gray-700 dark:text-gray-300">Vehicle Information</h3>
{appointment?.customerId && (() => {
const c = customers.find((c) => c.id === appointment.customerId);
if (c && c.vehicles.length === 0 && !vehicleInfo) {
return (
<p className="mb-3 rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-700 dark:border-amber-700 dark:bg-amber-900/20 dark:text-amber-400">
This customer has no vehicles on file. Please add vehicle information below to proceed with check-in.
</p>
);
}
return null;
})()}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div className="sm:col-span-2">
<label className="block text-xs font-medium text-gray-500 dark:text-gray-400">Vehicle Info *</label>
<input value={vehicleInfo} onChange={(e) => setVehicleInfo(e.target.value)} placeholder="2020 Honda Civic" className="mt-1.5 w-full rounded-xl border border-gray-300 bg-white px-3 py-2.5 text-sm shadow-sm transition-colors focus:border-amber-500 focus:outline-none focus:ring-2 focus:ring-amber-500/20 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100" />
</div>
<div>
<label className="block text-xs font-medium text-gray-500 dark:text-gray-400">VIN</label>
<input value={vin} onChange={(e) => setVin(e.target.value)} placeholder="1HGBH41JXMN109186" className="mt-1.5 w-full rounded-xl border border-gray-300 bg-white px-3 py-2.5 text-sm shadow-sm transition-colors focus:border-amber-500 focus:outline-none focus:ring-2 focus:ring-amber-500/20 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100" />
</div>
<div>
<label className="block text-xs font-medium text-gray-500 dark:text-gray-400">Mileage / Odometer</label>
<input value={mileage} onChange={(e) => setMileage(e.target.value)} placeholder="45,000" className="mt-1.5 w-full rounded-xl border border-gray-300 bg-white px-3 py-2.5 text-sm shadow-sm transition-colors focus:border-amber-500 focus:outline-none focus:ring-2 focus:ring-amber-500/20 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100" />
</div>
</div>
</section>
</div>
<div className="space-y-6">
{showLocationFields && (
<section className="rounded-2xl border border-gray-200 bg-white p-5 shadow-sm dark:border-gray-700 dark:bg-gray-900">
<h3 className="mb-4 text-sm font-semibold text-gray-700 dark:text-gray-300">Service Location</h3>
<div className="grid grid-cols-1 gap-4">
<div>
<label className="block text-xs font-medium text-gray-500 dark:text-gray-400">Job Address or Location</label>
<input value={serviceLocation} onChange={(e) => setServiceLocation(e.target.value)} placeholder="123 Oak St, Knoxville, TN" className="mt-1.5 w-full rounded-xl border border-gray-300 bg-white px-3 py-2.5 text-sm shadow-sm transition-colors focus:border-amber-500 focus:outline-none focus:ring-2 focus:ring-amber-500/20 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100" />
</div>
<div>
<label className="block text-xs font-medium text-gray-500 dark:text-gray-400">Travel / Diagnostic Fee</label>
<div className="mt-1.5 flex items-center gap-2">
<span className="text-sm text-gray-400">$</span>
<input type="number" min="0" step="0.01" value={travelFee || ''} onChange={(e) => setTravelFee(parseFloat(e.target.value) || 0)} placeholder={(setupSettings.defaultTravelFee || 0).toFixed(2)} className="w-full rounded-xl border border-gray-300 bg-white px-3 py-2.5 text-sm shadow-sm transition-colors focus:border-amber-500 focus:outline-none focus:ring-2 focus:ring-amber-500/20 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100" />
</div>
</div>
<div>
<label className="block text-xs font-medium text-gray-500 dark:text-gray-400">Trip Miles</label>
<div className="mt-1.5 flex items-center gap-2">
<span className="text-sm text-gray-400">mi</span>
<input type="number" min="0" step="0.1" value={tripMiles || ''} onChange={(e) => setTripMiles(parseFloat(e.target.value) || 0)} placeholder="0" className="w-full rounded-xl border border-gray-300 bg-white px-3 py-2.5 text-sm shadow-sm transition-colors focus:border-amber-500 focus:outline-none focus:ring-2 focus:ring-amber-500/20 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100" />
</div>
</div>
</div>
</section>
)}
<section className="rounded-2xl border border-gray-200 bg-white p-5 shadow-sm dark:border-gray-700 dark:bg-gray-900">
<h3 className="mb-4 text-sm font-semibold text-gray-700 dark:text-gray-300">Service Details</h3>
<div className="grid grid-cols-1 gap-4">
<div>
<label className="block text-xs font-medium text-gray-500 dark:text-gray-400">{roleLabel}</label>
{(() => {
const defaultAdv = loadSettings().serviceAdvisor;
const isDefault = defaultAdv && advisorName === defaultAdv;
return (
<input
value={advisorName}
onChange={(e) => setAdvisorName(e.target.value)}
placeholder={defaultAdv || `${roleLabel} name`}
className={`mt-1.5 w-full rounded-xl border px-3 py-2.5 text-sm shadow-sm transition-colors focus:outline-none focus:ring-2 focus:ring-amber-500/20 ${
isDefault
? 'border-gray-200 bg-gray-50 text-gray-400 focus:border-amber-500 dark:border-gray-700 dark:bg-gray-800/50 dark:text-gray-500'
: 'border-gray-300 bg-white text-gray-900 focus:border-amber-500 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100'
}`}
/>
);
})()}
</div>
<div>
<label className="block text-xs font-medium text-gray-500 dark:text-gray-400">RO Number</label>
<input value={roNumber} onChange={(e) => setRoNumber(e.target.value)} placeholder="RO-250629-0001" className="mt-1.5 w-full rounded-xl border border-gray-300 bg-white px-3 py-2.5 text-sm font-mono shadow-sm transition-colors focus:border-amber-500 focus:outline-none focus:ring-2 focus:ring-amber-500/20 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100" />
</div>
<div>
<label className="block text-xs font-medium text-gray-500 dark:text-gray-400">Estimated Duration</label>
<div className="mt-1.5 grid grid-cols-2 gap-3">
<div>
<input type="number" min="0" value={estimatedHours} onChange={(e) => setEstimatedHours(parseInt(e.target.value) || 0)} className="w-full rounded-xl border border-gray-300 bg-white px-3 py-2.5 text-sm shadow-sm transition-colors focus:border-amber-500 focus:outline-none focus:ring-2 focus:ring-amber-500/20 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100" />
<p className="mt-1 text-xs text-gray-400">Hours</p>
</div>
<div>
<input type="number" min="0" max="59" step="5" value={estimatedMinutes} onChange={(e) => setEstimatedMinutes(Math.min(parseInt(e.target.value) || 0, 59))} className="w-full rounded-xl border border-gray-300 bg-white px-3 py-2.5 text-sm shadow-sm transition-colors focus:border-amber-500 focus:outline-none focus:ring-2 focus:ring-amber-500/20 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100" />
<p className="mt-1 text-xs text-gray-400">Minutes</p>
</div>
</div>
</div>
<div>
<label className="block text-xs font-medium text-gray-500 dark:text-gray-400">{customerTypeLabels.groupLabel}</label>
<div className="mt-1.5 grid grid-cols-1 gap-3 sm:grid-cols-2">
<label className="flex cursor-pointer items-center gap-2 rounded-xl border border-gray-300 bg-white px-4 py-3 text-sm shadow-sm transition-colors hover:bg-gray-50 dark:border-gray-600 dark:bg-gray-800 dark:hover:bg-gray-700">
<input type="radio" name="ci-customerType" value="drop-off" checked={customerType === 'drop-off'} onChange={() => setCustomerType('drop-off')} className="text-amber-600 focus:ring-amber-500" />
<UserX className="h-4 w-4 text-gray-400" />
{customerTypeLabels.primary}
</label>
<label className="flex cursor-pointer items-center gap-2 rounded-xl border border-gray-300 bg-white px-4 py-3 text-sm shadow-sm transition-colors hover:bg-gray-50 dark:border-gray-600 dark:bg-gray-800 dark:hover:bg-gray-700">
<input type="radio" name="ci-customerType" value="waiter" checked={customerType === 'waiter'} onChange={() => setCustomerType('waiter')} className="text-amber-600 focus:ring-amber-500" />
<UserCheck className="h-4 w-4 text-amber-500" />
{customerTypeLabels.secondary}
</label>
</div>
</div>
</div>
</section>
<section className="rounded-2xl border border-gray-200 bg-white p-5 shadow-sm dark:border-gray-700 dark:bg-gray-900">
<label className="block text-sm font-semibold text-gray-700 dark:text-gray-300">Customer Concerns / Notes</label>
<textarea value={concerns} onChange={(e) => setConcerns(e.target.value)} rows={7} placeholder="Describe the issue or reason for service..." className="mt-3 w-full rounded-xl border border-gray-300 bg-white px-3 py-2.5 text-sm shadow-sm transition-colors focus:border-amber-500 focus:outline-none focus:ring-2 focus:ring-amber-500/20 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100" />
{appointment?.serviceType && <p className="mt-2 text-xs text-gray-400">Service type: {appointment.serviceType}</p>}
</section>
</div>
</div>
</div>
<div className="flex items-center justify-between border-t border-gray-200 bg-white px-6 py-4 dark:border-gray-700 dark:bg-gray-900">
<p className="text-sm text-gray-500 dark:text-gray-400">
Review the appointment details before creating the repair order.
</p>
<div className="flex items-center gap-3">
<button
onClick={onClose}
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 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700"
>
Cancel
</button>
<button
onClick={handleSubmit}
disabled={saving || !customerName.trim() || !vehicleInfo.trim()}
className="inline-flex items-center gap-2 rounded-xl bg-gradient-to-r from-amber-600 to-yellow-500 px-5 py-2.5 text-sm font-medium text-white shadow-sm transition-all hover:from-amber-700 hover:to-yellow-600 disabled:cursor-not-allowed disabled:opacity-50"
>
<LogIn className="h-4 w-4" />
{saving ? 'Checking In...' : 'Check In & Create RO'}
</button>
</div>
</div>
</div>
</div>
);
}
export const CheckInModal = memo(CheckInModalImpl);
+98
View File
@@ -0,0 +1,98 @@
import { memo, useState } from 'react';
import { ChevronDown, ChevronRight, CalendarDays } from 'lucide-react';
import type { Appointment } from '../../types';
import { todayISO, formatDateDisplay } from '../../lib/appointments';
import { AppointmentRow } from './AppointmentRow';
interface Props {
date: string;
appointments: Appointment[];
onEdit: (appt: Appointment) => void;
onDelete: (appt: Appointment) => void;
onStatusChange: (appt: Appointment, status: Appointment['status']) => void;
onCheckIn: (appt: Appointment) => void;
defaultExpanded: boolean;
}
function DateGroupImpl({
date,
appointments,
onEdit,
onDelete,
onStatusChange,
onCheckIn,
defaultExpanded,
}: Props) {
const [expanded, setExpanded] = useState(defaultExpanded);
const isToday = date === todayISO();
const dateLabel = isToday
? `Today \u2014 ${formatDateDisplay(date)}`
: formatDateDisplay(date);
const checkedInCount = appointments.filter((appt) => appt.status === 'checked_in').length;
const completedCount = appointments.filter((appt) => appt.status === 'completed').length;
const noShowCount = appointments.filter((appt) => appt.status === 'no_show').length;
return (
<div className="overflow-hidden rounded-2xl border border-gray-200 bg-white shadow-sm shadow-gray-200/60 dark:border-gray-700 dark:bg-gray-800/60 dark:shadow-black/10">
<button
onClick={() => setExpanded((p) => !p)}
className="flex w-full items-center justify-between bg-gradient-to-r from-white via-emerald-50/60 to-white px-5 py-4 text-left transition-colors hover:from-emerald-50 hover:via-emerald-50 hover:to-white dark:from-gray-800 dark:via-emerald-950/20 dark:to-gray-800 dark:hover:via-emerald-950/30"
>
<div className="flex items-center gap-3">
{expanded ? (
<ChevronDown className="h-4 w-4 text-gray-400" />
) : (
<ChevronRight className="h-4 w-4 text-gray-400" />
)}
<CalendarDays className="h-4 w-4 text-emerald-500 dark:text-emerald-400" />
<span className="text-sm font-semibold tracking-tight text-gray-900 dark:text-white">
{dateLabel}
</span>
</div>
<span className="rounded-full border border-emerald-200 bg-white/80 px-2.5 py-1 text-xs font-medium text-emerald-700 shadow-sm dark:border-emerald-900/50 dark:bg-gray-800 dark:text-emerald-300">
{appointments.length} {appointments.length === 1 ? 'appointment' : 'appointments'}
</span>
</button>
{expanded && (
<div className="border-t border-gray-200 bg-white px-5 py-3 dark:border-gray-700 dark:bg-gray-800/80">
<div className="flex flex-wrap gap-2 text-xs font-medium">
<span className="rounded-full border border-gray-200 bg-gray-50 px-2.5 py-1 text-gray-600 dark:border-gray-700 dark:bg-gray-900/60 dark:text-gray-300">
Total: {appointments.length}
</span>
{checkedInCount > 0 && (
<span className="rounded-full border border-yellow-200 bg-yellow-50 px-2.5 py-1 text-yellow-700 dark:border-yellow-900/40 dark:bg-yellow-900/20 dark:text-yellow-300">
{checkedInCount} checked in
</span>
)}
{completedCount > 0 && (
<span className="rounded-full border border-green-200 bg-green-50 px-2.5 py-1 text-green-700 dark:border-green-900/40 dark:bg-green-900/20 dark:text-green-300">
{completedCount} completed
</span>
)}
{noShowCount > 0 && (
<span className="rounded-full border border-gray-200 bg-gray-50 px-2.5 py-1 text-gray-600 dark:border-gray-700 dark:bg-gray-900/60 dark:text-gray-300">
{noShowCount} no show
</span>
)}
</div>
<div className="mt-4 space-y-3">
{appointments.map((appt) => (
<AppointmentRow
key={appt.id}
appointment={appt}
onEdit={onEdit}
onDelete={onDelete}
onStatusChange={onStatusChange}
onCheckIn={onCheckIn}
/>
))}
</div>
</div>
)}
</div>
);
}
export const DateGroup = memo(DateGroupImpl);
@@ -0,0 +1,104 @@
import { memo, useState } from 'react';
import { AlertTriangle, Trash2 } from 'lucide-react';
import type { Appointment } from '../../types';
import { formatDateDisplay, formatTimeShort } from '../../lib/appointments';
interface Props {
isOpen: boolean;
onClose: () => void;
onConfirm: () => Promise<void>;
appointment: Appointment | null;
}
function DeleteDialogImpl({
isOpen,
onClose,
onConfirm,
appointment,
}: Props) {
const [deleting, setDeleting] = useState(false);
if (!isOpen || !appointment) return null;
const handleDelete = async () => {
setDeleting(true);
try {
await onConfirm();
onClose();
} catch {
} finally {
setDeleting(false);
}
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
<div className="absolute inset-0 bg-black/50 backdrop-blur-sm" onClick={onClose} />
<div className="relative z-10 w-full max-w-md overflow-hidden rounded-2xl border border-red-200 bg-white shadow-2xl dark:border-red-900/40 dark:bg-gray-900">
<div className="border-b border-red-100 bg-gradient-to-r from-red-50 via-white to-white px-6 py-5 dark:border-red-900/30 dark:from-red-950/20 dark:via-gray-900 dark:to-gray-900">
<div className="flex items-start gap-4">
<div className="flex h-11 w-11 items-center justify-center rounded-2xl bg-red-100 dark:bg-red-900/30">
<AlertTriangle className="h-5 w-5 text-red-600 dark:text-red-400" />
</div>
<div>
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-red-600 dark:text-red-400">Destructive Action</p>
<h3 className="mt-1 text-lg font-semibold tracking-tight text-gray-900 dark:text-white">Delete Appointment</h3>
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
Remove this appointment record from the schedule. This action cannot be undone.
</p>
</div>
</div>
</div>
<div className="px-6 py-5">
<div className="rounded-2xl border border-gray-200 bg-gray-50/80 p-4 dark:border-gray-700 dark:bg-gray-800/50">
<p className="text-xs font-medium uppercase tracking-[0.14em] text-gray-400 dark:text-gray-500">Appointment</p>
<p className="mt-1 text-base font-semibold text-gray-900 dark:text-white">
{appointment.customerName || 'Unknown Customer'}
</p>
<div className="mt-2 flex flex-wrap gap-2 text-sm text-gray-500 dark:text-gray-400">
{appointment.date && <span>{formatDateDisplay(appointment.date)}</span>}
{appointment.time && <span>{formatTimeShort(appointment.time)}</span>}
{appointment.serviceType && <span>{appointment.serviceType}</span>}
</div>
</div>
<div className="mt-5 flex items-center justify-between gap-3 border-t border-gray-200 pt-4 dark:border-gray-700">
<p className="text-sm text-gray-500 dark:text-gray-400">This permanently removes the appointment from your schedule.</p>
<div className="flex items-center gap-3">
<button
onClick={onClose}
disabled={deleting}
className="rounded-xl border border-gray-300 px-4 py-2.5 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={handleDelete}
disabled={deleting}
className="inline-flex items-center gap-2 rounded-xl bg-gradient-to-r from-red-600 to-red-500 px-4 py-2.5 text-sm font-medium text-white shadow-sm transition-colors hover:from-red-700 hover:to-red-600 focus:outline-none focus:ring-2 focus:ring-red-500 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 dark:focus:ring-offset-gray-800"
>
{deleting ? (
<>
<svg className="h-4 w-4 animate-spin" viewBox="0 0 24 24" fill="none">
<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-8v4a4 4 0 00-4 4H4z" />
</svg>
Deleting...
</>
) : (
<>
<Trash2 className="h-4 w-4" />
Delete
</>
)}
</button>
</div>
</div>
</div>
</div>
</div>
);
}
export const DeleteDialog = memo(DeleteDialogImpl);
+121
View File
@@ -0,0 +1,121 @@
import { CalendarDays, Calendar, AlertTriangle, RefreshCw, Plus } from 'lucide-react';
function formatDateDisplay(iso: string): string {
if (!iso) return '\u2014';
const d = new Date(iso + (iso.includes('T') ? '' : 'T00:00:00'));
return new Intl.DateTimeFormat('en-US', {
weekday: 'long',
month: 'long',
day: 'numeric',
year: 'numeric',
}).format(d);
}
export function EmptyDayState({ date, onCreate }: { date: string; onCreate: () => void }) {
return (
<div className="rounded-xl border border-dashed border-gray-300 bg-white/50 p-8 text-center dark:border-gray-600 dark:bg-gray-800/30">
<CalendarDays className="mx-auto mb-3 h-8 w-8 text-gray-300 dark:text-gray-600" />
<p className="text-sm text-gray-500 dark:text-gray-400">
No appointments are scheduled for{' '}
<span className="font-medium text-gray-700 dark:text-gray-300">{formatDateDisplay(date)}</span>
</p>
<button
onClick={onCreate}
className="mt-3 inline-flex items-center gap-1.5 rounded-lg border border-gray-300 bg-white px-3 py-1.5 text-xs font-medium text-gray-700 shadow-sm transition-colors hover:bg-gray-50 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700"
>
<Plus className="h-3.5 w-3.5" />
Add Appointment
</button>
</div>
);
}
export function EmptyState({ onCreate }: { onCreate: () => void }) {
return (
<div className="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="bg-gradient-to-r from-emerald-50 via-white to-purple-50 px-8 py-10 text-center dark:from-emerald-950/20 dark:via-gray-900 dark:to-purple-950/20">
<div className="mx-auto mb-6 flex h-24 w-24 items-center justify-center rounded-2xl bg-white shadow-sm dark:bg-gray-800">
<Calendar className="h-12 w-12 text-emerald-500 dark:text-emerald-400" />
</div>
<h3 className="mb-2 text-xl font-semibold tracking-tight text-gray-900 dark:text-white">
No appointments scheduled
</h3>
<p className="mx-auto mb-8 max-w-sm text-sm text-gray-500 dark:text-gray-400">
Create appointments to manage upcoming visits, prepare vehicle write-ups, and keep the day organized before work begins.
</p>
<button
onClick={onCreate}
className="inline-flex items-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" />
Create Appointment
</button>
</div>
</div>
);
}
export function ErrorState({ message, onRetry }: { message: string; onRetry: () => void }) {
return (
<div className="overflow-hidden rounded-3xl border border-red-200 bg-white shadow-sm shadow-red-100/60 dark:border-red-900/50 dark:bg-gray-900 dark:shadow-black/10">
<div className="px-8 py-10 text-center">
<div className="mx-auto mb-6 flex h-24 w-24 items-center justify-center rounded-2xl bg-red-50 dark:bg-red-900/20">
<AlertTriangle className="h-12 w-12 text-red-500 dark:text-red-400" />
</div>
<h3 className="mb-2 text-xl font-semibold tracking-tight text-gray-900 dark:text-white">
Unable to load appointments
</h3>
<p className="mx-auto mb-8 max-w-sm text-sm text-gray-500 dark:text-gray-400">{message}</p>
<button
onClick={onRetry}
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 shadow-sm transition-colors hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:ring-offset-2 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700 dark:focus:ring-offset-gray-900"
>
<RefreshCw className="h-4 w-4" />
Retry
</button>
</div>
</div>
);
}
export function AppointmentsSkeleton() {
return (
<div className="animate-pulse space-y-4">
{Array.from({ length: 3 }).map((_, i) => (
<div
key={i}
className="rounded-xl border border-gray-200 bg-white dark:border-gray-700 dark:bg-gray-800"
>
<div className="flex items-center justify-between px-5 py-3">
<div className="flex items-center gap-3">
<div className="h-4 w-4 rounded bg-gray-200 dark:bg-gray-700" />
<div className="h-4 w-48 rounded bg-gray-200 dark:bg-gray-700" />
</div>
<div className="h-5 w-20 rounded-full bg-gray-200 dark:bg-gray-700" />
</div>
<div className="border-t border-gray-200 px-5 py-4 dark:border-gray-700">
<div className="space-y-3">
{Array.from({ length: 2 }).map((_, j) => (
<div
key={j}
className="rounded-lg border border-gray-200 p-4 dark:border-gray-700"
>
<div className="mb-3 flex items-center gap-2">
<div className="h-4 w-4 rounded bg-gray-200 dark:bg-gray-700" />
<div className="h-4 w-24 rounded bg-gray-200 dark:bg-gray-700" />
<div className="h-5 w-20 rounded-full bg-gray-200 dark:bg-gray-700" />
</div>
<div className="h-5 w-40 rounded bg-gray-200 dark:bg-gray-700" />
<div className="mt-2 flex gap-4">
<div className="h-4 w-32 rounded bg-gray-200 dark:bg-gray-700" />
<div className="h-4 w-24 rounded bg-gray-200 dark:bg-gray-700" />
</div>
</div>
))}
</div>
</div>
</div>
))}
</div>
);
}
@@ -0,0 +1,655 @@
import { memo, useEffect, useRef, useState } from 'react';
import { Sparkles, Upload, X, Check } from 'lucide-react';
import { pb } from '../../lib/pocketbase';
import { formatPhoneDisplay, formatPhoneInput } from '../../lib/phone';
import { formatDateDisplay, normalizeTimeToHHMM, todayISO } from '../../lib/appointments';
import { logError } from '../../lib/userMessages';
import { useToast } from '../ui/Toast';
import type { CustomerWithVehicles } from '../../pages/Customers';
export interface ScannedAppt {
appointmentDate: string;
appointmentTime: string;
durationMinutes: number;
customerName: string;
customerPhone: string;
vin: string;
vehicleInfo: string;
serviceType: string;
matchedCustomerId?: string;
matchedCustomerName?: string;
}
type ScanStep = 'idle' | 'uploading' | 'ocr' | 'ai_extracting' | 'review' | 'importing';
interface Props {
isOpen: boolean;
onClose: () => void;
onImport: (appts: ScannedAppt[]) => Promise<void>;
customers: CustomerWithVehicles[];
}
function ScanScreenshotModalImpl({
isOpen,
onClose,
onImport,
customers,
}: Props) {
const { showToast } = useToast();
const [step, setStep] = useState<ScanStep>('idle');
const [progress, setProgress] = useState<string>('');
const [progressPct, setProgressPct] = useState(0);
const [imageFile, setImageFile] = useState<File | null>(null);
const [imagePreview, setImagePreview] = useState<string | null>(null);
const [editAppointments, setEditAppointments] = useState<ScannedAppt[]>([]);
const [selectedDate, setSelectedDate] = useState(todayISO());
const selectedDateRef = useRef(selectedDate);
selectedDateRef.current = selectedDate;
const [error, setError] = useState<string | null>(null);
const dropRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (isOpen) {
setStep('idle');
setProgress('');
setProgressPct(0);
setImageFile(null);
setImagePreview(null);
setEditAppointments([]);
setSelectedDate(todayISO());
setError(null);
}
}, [isOpen]);
const handleFile = (file: File | null) => {
if (!file) return;
if (!file.type.startsWith('image/')) {
setError('Please upload an image file (PNG, JPG, etc.)');
return;
}
setError(null);
setImageFile(file);
const reader = new FileReader();
reader.onload = (e) => setImagePreview(e.target!.result as string);
reader.readAsDataURL(file);
};
const preprocessImage = (file: File): Promise<Blob> => {
return new Promise((resolve, reject) => {
const img = new Image();
img.onload = () => {
const canvas = document.createElement('canvas');
const scale = 2.5;
canvas.width = Math.round(img.width * scale);
canvas.height = Math.round(img.height * scale);
const ctx = canvas.getContext('2d')!;
ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
const data = imageData.data;
const contrast = 1.15;
const factor = (259 * (contrast * 255 + 255)) / (255 * (259 - contrast * 255));
for (let i = 0; i < data.length; i += 4) {
data[i] = Math.min(255, Math.max(0, factor * (data[i] - 128) + 128));
data[i + 1] = Math.min(255, Math.max(0, factor * (data[i + 1] - 128) + 128));
data[i + 2] = Math.min(255, Math.max(0, factor * (data[i + 2] - 128) + 128));
}
ctx.putImageData(imageData, 0, 0);
canvas.toBlob((blob) => {
if (blob) resolve(blob);
else reject(new Error('Failed to process image'));
}, 'image/png', 0.95);
};
img.onerror = () => reject(new Error('Failed to load image'));
img.src = URL.createObjectURL(file);
});
};
const handleExtract = async () => {
if (!imageFile) return;
setError(null);
try {
setStep('ocr');
setProgress('Initializing OCR engine...');
setProgress('Preprocessing image...');
const processedBlob = await preprocessImage(imageFile);
const { default: Tesseract } = await import('tesseract.js');
setProgress('Running OCR...');
const result = await Tesseract.recognize(processedBlob, 'eng', {
logger: (m: any) => {
if (m.status === 'recognizing text') {
const pct = Math.round((m.progress || 0) * 100);
setProgressPct(pct);
setProgress(`OCR: ${pct}%`);
}
},
});
const ocrText = result.data.text;
if (!ocrText.trim()) {
setError('No text could be extracted from the image. Try a clearer screenshot.');
setStep('idle');
return;
}
setStep('ai_extracting');
setProgress('Extracting with AI...');
setProgressPct(0);
const aiResponse = await fetch('/deepseek/v1/chat/completions', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
model: 'deepseek-v4-flash',
messages: [
{
role: 'system',
content: `You are a high-precision appointment extraction engine for an automotive repair shop.
Return ONLY valid JSON.
Do not use markdown.
Do not wrap the JSON in code fences.
Do not add commentary outside the JSON.
RULES:
- The appointment date is supplied by the application. Do NOT extract, infer, or return appointment dates from OCR text.
- Extract every real appointment visible in the OCR text.
- Do not invent appointments.
- Do not duplicate the same appointment.
- If a field is missing or uncertain, use an empty string for text fields.
- If duration is missing, use 60.
- Keep one object per appointment.
FIELD RULES:
- appointmentTime: convert to 24-hour HH:MM format. Fix obvious OCR time damage when clear.
- durationMinutes: convert hours to minutes. Examples: "0.5 hrs"=30, "1.2 hrs"=72, "1.5 hr"=90.
- customerName: extract the person's full name only.
- customerPhone: return only the best 10-digit phone number.
- vin: extract a 17-character VIN only when reasonably recoverable. Fix obvious OCR substitutions such as O->0, Q->0, I->1, L->1, S->5, B->8.
- vehicleInfo: format as "YEAR MAKE MODEL" when possible.
- serviceType: extract the appointment purpose or requested work.
QUALITY RULES:
- Prefer accuracy over completeness.
- Use empty strings instead of guessing.
- Output must be parseable JSON matching the schema exactly.
JSON SCHEMA:
{"appointments":[{"appointmentTime":"HH:MM","durationMinutes":60,"customerName":"","customerPhone":"","vin":"","vehicleInfo":"","serviceType":""}]}`,
},
{
role: 'user',
content: `Extract all appointments from this OCR text. Fix obvious OCR-garbled times like "ESCAM" to "8:00AM" when clear:
${ocrText}`,
},
],
temperature: 0,
thinking: { type: 'disabled' },
max_tokens: 2000,
}),
});
if (!aiResponse.ok) {
const errText = await aiResponse.text();
throw new Error(`AI extraction failed: ${aiResponse.status} ${errText}`);
}
const aiData = await aiResponse.json();
const content = aiData.choices?.[0]?.message?.content || '';
let extracted: ScannedAppt[] = [];
try {
const parsed = JSON.parse(content);
extracted = parsed.appointments || parsed;
} catch {
const jsonMatch = content.match(/`(?:json)?\s*([\s\S]*?)`/);
if (jsonMatch) {
const parsed = JSON.parse(jsonMatch[1]);
extracted = parsed.appointments || parsed;
} else {
const arrayMatch = content.match(/\[\s*\{.*\}\s*\]/s);
const objMatch = content.match(/\{\s*"appointments"\s*:.*\}/s);
if (objMatch) {
const parsed = JSON.parse(objMatch[0]);
extracted = parsed.appointments || parsed;
} else if (arrayMatch) {
extracted = JSON.parse(arrayMatch[0]);
} else {
throw new Error('Could not parse AI response into appointments');
}
}
}
if (!Array.isArray(extracted) || extracted.length === 0) {
throw new Error('No appointments were detected in the screenshot');
}
extracted = extracted.map((a) => ({
appointmentDate: selectedDateRef.current,
appointmentTime: normalizeTimeToHHMM(a.appointmentTime || ''),
durationMinutes: Number(a.durationMinutes) || 60,
customerName: a.customerName || '',
customerPhone: a.customerPhone || '',
vin: (a.vin || '').toUpperCase(),
vehicleInfo: a.vehicleInfo || '',
serviceType: a.serviceType || '',
}));
const matched = extracted.map((a) => {
const phoneDigits = (a.customerPhone || '').replace(/\D/g, '');
let match: CustomerWithVehicles | undefined;
if (phoneDigits.length >= 7) {
match = customers.find((c) => (c.phone || '').replace(/\D/g, '') === phoneDigits);
}
if (!match && a.customerName.trim()) {
const nameLower = a.customerName.trim().toLowerCase();
match = customers.find((c) => c.name.toLowerCase() === nameLower);
}
return {
...a,
matchedCustomerId: match?.id,
matchedCustomerName: match?.name,
} as ScannedAppt;
});
setEditAppointments(JSON.parse(JSON.stringify(matched)));
setStep('review');
setProgress('');
} catch (err: unknown) {
logError('Failed to extract appointment data', err);
setError('Could not read that screenshot. Please try a clearer image.');
setStep('idle');
}
};
const updateEditAppt = (index: number, field: keyof ScannedAppt, value: string | number) => {
setEditAppointments((prev) => {
const updated = [...prev];
updated[index] = { ...updated[index], [field]: value };
return updated;
});
};
const handleImportAll = async () => {
setStep('importing');
setProgress('Importing appointments...');
setError(null);
try {
await onImport(editAppointments);
showToast(`Successfully imported ${editAppointments.length} appointment(s)`, 'success');
onClose();
} catch (err: unknown) {
logError('Failed to import appointments', err);
setError('Appointments could not be imported. Please try again.');
setStep('review');
}
};
const handleDrop = (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
const file = e.dataTransfer.files?.[0] || null;
handleFile(file);
};
if (!isOpen) return null;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
<div className="absolute inset-0 bg-black/50 backdrop-blur-sm" onClick={onClose} />
<div className="relative z-10 flex max-h-[90vh] w-full max-w-3xl flex-col overflow-hidden rounded-2xl border border-gray-200 bg-white shadow-2xl dark:border-gray-700 dark:bg-gray-900">
<div className="flex items-start justify-between border-b border-gray-200 bg-gradient-to-r from-purple-50 via-white to-white px-6 py-5 dark:border-gray-700 dark:from-purple-950/20 dark:via-gray-900 dark:to-gray-900">
<div className="flex items-start gap-3">
<div className="mt-0.5 rounded-xl bg-purple-100 p-2.5 dark:bg-purple-900/30">
<Sparkles className="h-5 w-5 text-purple-500" />
</div>
<div>
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-purple-600 dark:text-purple-400">AI Import</p>
<h2 className="mt-1 text-xl font-semibold tracking-tight text-gray-900 dark:text-white">Scan Screenshot</h2>
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">Upload a schedule screenshot, review the extracted appointments, and import them cleanly.</p>
</div>
</div>
<button
onClick={onClose}
disabled={step === 'importing'}
className="rounded-lg p-1.5 text-gray-400 transition-colors hover:bg-gray-100 hover:text-gray-600 disabled:opacity-50 dark:hover:bg-gray-700 dark:hover:text-gray-300"
>
<X className="h-5 w-5" />
</button>
</div>
<div className="flex-1 overflow-y-auto bg-gray-50/70 px-6 py-5 dark:bg-gray-950/40">
{error && (
<div className="mb-4 rounded-lg border border-red-200 bg-red-50 p-3 text-sm text-red-700 dark:border-red-800 dark:bg-red-900/20 dark:text-red-400">
{error}
</div>
)}
{(step === 'idle' || step === 'uploading') && (
<div className="space-y-4">
<div>
<label className="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">
Appointment Date <span className="text-gray-400">(all appointments in screenshot)</span>
</label>
<input
type="date"
value={selectedDate}
onChange={(e) => {
const nextDate = e.target.value;
setSelectedDate(nextDate);
setEditAppointments((prev) => prev.map((appt) => ({ ...appt, appointmentDate: nextDate })));
}}
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm text-gray-900 transition-colors focus:border-purple-500 focus:outline-none focus:ring-2 focus:ring-purple-500/20 dark:border-gray-600 dark:bg-gray-700 dark:text-white dark:focus:border-purple-400"
/>
</div>
<div
ref={dropRef}
onDragOver={(e) => { e.preventDefault(); e.stopPropagation(); }}
onDrop={handleDrop}
onClick={() => {
const input = document.createElement('input');
input.type = 'file';
input.accept = 'image/*';
input.onchange = (e) => {
const file = (e.target as HTMLInputElement).files?.[0] || null;
handleFile(file);
};
input.click();
}}
className={`flex cursor-pointer flex-col items-center justify-center rounded-xl border-2 border-dashed p-8 transition-colors ${
imagePreview
? 'border-purple-400 bg-purple-50/50 dark:border-purple-600 dark:bg-purple-900/10'
: 'border-gray-300 bg-gray-50 hover:border-purple-400 hover:bg-purple-50/50 dark:border-gray-600 dark:bg-gray-700/50 dark:hover:border-purple-500 dark:hover:bg-purple-900/10'
}`}
>
{imagePreview ? (
<div className="w-full space-y-3">
<img
src={imagePreview}
alt="Screenshot preview"
className="max-h-64 w-full rounded-lg object-contain shadow-sm"
/>
<p className="text-center text-xs text-gray-500 dark:text-gray-400">
Click or drop another image to replace
</p>
</div>
) : (
<>
<Upload className="mb-3 h-10 w-10 text-gray-400" />
<p className="text-sm font-medium text-gray-700 dark:text-gray-300">
Drop a screenshot here, or click to browse
</p>
<p className="mt-1 text-xs text-gray-400 dark:text-gray-500">
Supported: PNG, JPG, WEBP
</p>
</>
)}
</div>
</div>
)}
{step === 'ocr' && (
<div className="flex flex-col items-center justify-center py-12">
<div className="mb-4">
<svg className="h-12 w-12 animate-spin text-purple-500" viewBox="0 0 24 24" fill="none">
<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-8v4a4 4 0 00-4 4H4z" />
</svg>
</div>
<p className="text-sm font-medium text-gray-700 dark:text-gray-300">{progress}</p>
<div className="mt-4 h-2 w-64 overflow-hidden rounded-full bg-gray-200 dark:bg-gray-700">
<div
className="h-full rounded-full bg-purple-500 transition-all duration-300"
style={{ width: `${progressPct}%` }}
/>
</div>
<p className="mt-2 text-xs text-gray-400">{progressPct}%</p>
</div>
)}
{step === 'ai_extracting' && (
<div className="flex flex-col items-center justify-center py-12">
<div className="mb-4">
<Sparkles className="h-12 w-12 animate-pulse text-purple-500" />
</div>
<p className="text-sm font-medium text-gray-700 dark:text-gray-300">{progress}</p>
<div className="mt-4 h-2 w-64 overflow-hidden rounded-full bg-gray-200 dark:bg-gray-700">
<div className="h-full w-full animate-pulse rounded-full bg-purple-500" />
</div>
</div>
)}
{step === 'review' && (
<div className="space-y-5">
<div className="rounded-2xl border border-purple-100 bg-white p-4 shadow-sm dark:border-purple-900/40 dark:bg-gray-900">
<div className="flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.16em] text-purple-600 dark:text-purple-400">Review Extracted Results</p>
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
Found {editAppointments.length} appointment{editAppointments.length !== 1 ? 's' : ''}. Review and edit before importing.
</p>
</div>
<div className="w-full lg:max-w-xs">
<label className="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">
Appointment Date <span className="text-gray-400">(applies to all imported appointments)</span>
</label>
<input
type="date"
value={selectedDate}
onChange={(e) => {
const nextDate = e.target.value;
setSelectedDate(nextDate);
setEditAppointments((prev) => prev.map((appt) => ({ ...appt, appointmentDate: nextDate })));
}}
className="w-full rounded-xl border border-gray-300 px-3 py-2.5 text-sm text-gray-900 shadow-sm transition-colors focus:border-purple-500 focus:outline-none focus:ring-2 focus:ring-purple-500/20 dark:border-gray-600 dark:bg-gray-800 dark:text-white dark:focus:border-purple-400"
/>
</div>
</div>
</div>
{editAppointments.map((appt, idx) => (
<div
key={idx}
className="rounded-2xl border border-gray-200 bg-white p-5 shadow-sm dark:border-gray-700 dark:bg-gray-900"
>
<div className="mb-4 flex items-center justify-between">
<span className="rounded-full bg-purple-50 px-2.5 py-1 text-xs font-semibold uppercase tracking-[0.14em] text-purple-700 dark:bg-purple-900/30 dark:text-purple-300">
Appointment #{idx + 1}
</span>
<span className="text-xs text-gray-400 dark:text-gray-500">Import date: {formatDateDisplay(appt.appointmentDate)}</span>
</div>
{appt.customerName && (
<div className="mb-3 flex items-center justify-between gap-2 rounded-lg border border-gray-100 bg-gray-50 px-3 py-2 dark:border-gray-700 dark:bg-gray-800/50">
<div className="flex items-center gap-2 text-xs">
{appt.matchedCustomerId ? (
<span className="inline-flex items-center gap-1 rounded-full bg-green-100 px-2 py-0.5 font-medium text-green-700 dark:bg-green-900/30 dark:text-green-400">
<Check className="h-3 w-3" /> Matched: {appt.matchedCustomerName || 'Customer'}
</span>
) : (
<span className="inline-flex items-center gap-1 rounded-full bg-amber-100 px-2 py-0.5 font-medium text-amber-700 dark:bg-amber-900/30 dark:text-amber-400">
Not in database
</span>
)}
{appt.customerPhone && <span className="text-gray-400">{formatPhoneDisplay(appt.customerPhone)}</span>}
</div>
{!appt.matchedCustomerId && (
<button
type="button"
onClick={async () => {
const uid = pb.authStore.model?.id;
if (!uid) return;
const record = await pb.collection('customers').create({
name: appt.customerName.trim(),
phone: appt.customerPhone || '',
email: '',
address: '',
notes: '',
userId: uid,
});
if (appt.vin || appt.vehicleInfo) {
const parts = (appt.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 = appt.vehicleInfo || '';
}
try {
await pb.collection('vehicles').create({
userId: uid,
customerId: record.id,
make, model, year,
vin: appt.vin || '',
licensePlate: '', mileage: '', color: '', engine: '',
});
} catch {}
}
setEditAppointments((prev) => prev.map((a, i) =>
i === idx ? { ...a, matchedCustomerId: record.id, matchedCustomerName: appt.customerName.trim() } : a
));
showToast('Customer added to database.', 'success');
}}
className="rounded-lg bg-green-600 px-2.5 py-1 text-xs font-medium text-white hover:bg-green-700"
>
Add to Database
</button>
)}
</div>
)}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
<div>
<label className="mb-1 block text-xs font-medium uppercase tracking-[0.12em] text-gray-500 dark:text-gray-400">Time</label>
<input
type="time"
value={appt.appointmentTime}
onChange={(e) => updateEditAppt(idx, 'appointmentTime', e.target.value)}
className="w-full rounded-xl border border-gray-300 px-3 py-2.5 text-sm text-gray-900 shadow-sm focus:border-purple-500 focus:outline-none focus:ring-2 focus:ring-purple-500/20 dark:border-gray-600 dark:bg-gray-800 dark:text-white"
/>
</div>
<div>
<label className="mb-1 block text-xs font-medium uppercase tracking-[0.12em] text-gray-500 dark:text-gray-400">Duration (min)</label>
<input
type="number"
value={appt.durationMinutes}
onChange={(e) => updateEditAppt(idx, 'durationMinutes', parseInt(e.target.value) || 60)}
className="w-full rounded-xl border border-gray-300 px-3 py-2.5 text-sm text-gray-900 shadow-sm focus:border-purple-500 focus:outline-none focus:ring-2 focus:ring-purple-500/20 dark:border-gray-600 dark:bg-gray-800 dark:text-white"
/>
</div>
<div className="sm:col-span-2">
<label className="mb-1 block text-xs font-medium uppercase tracking-[0.12em] text-gray-500 dark:text-gray-400">Customer Name</label>
<input
type="text"
value={appt.customerName}
onChange={(e) => updateEditAppt(idx, 'customerName', e.target.value)}
className="w-full rounded-xl border border-gray-300 px-3 py-2.5 text-sm text-gray-900 shadow-sm focus:border-purple-500 focus:outline-none focus:ring-2 focus:ring-purple-500/20 dark:border-gray-600 dark:bg-gray-800 dark:text-white"
/>
</div>
<div>
<label className="mb-1 block text-xs font-medium uppercase tracking-[0.12em] text-gray-500 dark:text-gray-400">Phone</label>
<input
type="text"
value={formatPhoneInput(appt.customerPhone)}
onChange={(e) => updateEditAppt(idx, 'customerPhone', e.target.value.replace(/\D/g, ''))}
className="w-full rounded-xl border border-gray-300 px-3 py-2.5 text-sm text-gray-900 shadow-sm focus:border-purple-500 focus:outline-none focus:ring-2 focus:ring-purple-500/20 dark:border-gray-600 dark:bg-gray-800 dark:text-white"
/>
</div>
<div>
<label className="mb-1 block text-xs font-medium uppercase tracking-[0.12em] text-gray-500 dark:text-gray-400">VIN</label>
<input
type="text"
value={appt.vin}
onChange={(e) => updateEditAppt(idx, 'vin', e.target.value.toUpperCase())}
className="w-full rounded-xl border border-gray-300 px-3 py-2.5 text-sm text-gray-900 shadow-sm focus:border-purple-500 focus:outline-none focus:ring-2 focus:ring-purple-500/20 dark:border-gray-600 dark:bg-gray-800 dark:text-white"
/>
</div>
<div className="sm:col-span-2">
<label className="mb-1 block text-xs font-medium uppercase tracking-[0.12em] text-gray-500 dark:text-gray-400">Vehicle Info</label>
<input
type="text"
value={appt.vehicleInfo}
onChange={(e) => updateEditAppt(idx, 'vehicleInfo', e.target.value)}
className="w-full rounded-xl border border-gray-300 px-3 py-2.5 text-sm text-gray-900 shadow-sm focus:border-purple-500 focus:outline-none focus:ring-2 focus:ring-purple-500/20 dark:border-gray-600 dark:bg-gray-800 dark:text-white"
/>
</div>
<div className="sm:col-span-2">
<label className="mb-1 block text-xs font-medium uppercase tracking-[0.12em] text-gray-500 dark:text-gray-400">Service Type</label>
<input
type="text"
value={appt.serviceType}
onChange={(e) => updateEditAppt(idx, 'serviceType', e.target.value)}
className="w-full rounded-xl border border-gray-300 px-3 py-2.5 text-sm text-gray-900 shadow-sm focus:border-purple-500 focus:outline-none focus:ring-2 focus:ring-purple-500/20 dark:border-gray-600 dark:bg-gray-800 dark:text-white"
/>
</div>
</div>
</div>
))}
</div>
)}
{step === 'importing' && (
<div className="flex flex-col items-center justify-center py-12">
<div className="mb-4">
<svg className="h-12 w-12 animate-spin text-purple-500" viewBox="0 0 24 24" fill="none">
<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-8v4a4 4 0 00-4 4H4z" />
</svg>
</div>
<p className="text-sm font-medium text-gray-700 dark:text-gray-300">{progress}</p>
</div>
)}
</div>
<div className="flex items-center justify-end gap-3 border-t border-gray-200 px-6 py-4 dark:border-gray-700">
{(step === 'idle' || step === 'uploading') && (
<>
<button
onClick={onClose}
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={handleExtract}
disabled={!imageFile}
className="inline-flex items-center gap-2 rounded-lg bg-gradient-to-r from-purple-600 to-purple-500 px-5 py-2 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 disabled:cursor-not-allowed disabled:opacity-50 dark:focus:ring-offset-gray-800"
>
<Sparkles className="h-4 w-4" />
Extract Appointments
</button>
</>
)}
{step === 'review' && (
<>
<button
onClick={() => { setStep('idle'); setError(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"
>
Back
</button>
<button
onClick={handleImportAll}
className="inline-flex items-center gap-2 rounded-lg bg-gradient-to-r from-purple-600 to-purple-500 px-5 py-2 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-800"
>
<Upload className="h-4 w-4" />
Import All ({editAppointments.length})
</button>
</>
)}
{(step === 'ocr' || step === 'ai_extracting' || step === 'importing') && (
<p className="text-xs text-gray-400 dark:text-gray-500">{progress}</p>
)}
</div>
</div>
</div>
);
}
export const ScanScreenshotModal = memo(ScanScreenshotModalImpl);
@@ -0,0 +1,32 @@
import { memo } from 'react';
import type { Appointment } from '../../types';
function StatusBadgeImpl({ status }: { status: Appointment['status'] }) {
const colors: Record<string, string> = {
scheduled: 'bg-blue-100 text-blue-800 dark:bg-blue-900/40 dark:text-blue-300',
checked_in: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/40 dark:text-yellow-300',
completed: 'bg-green-100 text-green-800 dark:bg-green-900/40 dark:text-green-300',
cancelled: 'bg-red-100 text-red-800 dark:bg-red-900/40 dark:text-red-300',
no_show: 'bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-400',
};
const labels: Record<string, string> = {
scheduled: 'Scheduled',
checked_in: 'Checked In',
completed: 'Completed',
cancelled: 'Cancelled',
no_show: 'No Show',
};
return (
<span
className={`inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium ${
colors[status] || colors.scheduled
}`}
>
{labels[status] || status}
</span>
);
}
export const StatusBadge = memo(StatusBadgeImpl);
+10
View File
@@ -0,0 +1,10 @@
export { StatusBadge } from './StatusBadge';
export { AppointmentRow } from './AppointmentRow';
export { DateGroup } from './DateGroup';
export { AppointmentModal } from './AppointmentModal';
export { DeleteDialog } from './DeleteDialog';
export { ScanScreenshotModal } from './ScanScreenshotModal';
export { CheckInModal } from './CheckInModal';
export { EmptyDayState, EmptyState, ErrorState, AppointmentsSkeleton } from './EmptyStates';
export type { CreateAppointmentFormData } from './AppointmentModal';
export type { ScannedAppt } from './ScanScreenshotModal';
+85
View File
@@ -0,0 +1,85 @@
import { memo } from 'react';
import { Phone, Mail, Car, FileText, Pencil, Trash2 } from 'lucide-react';
import { formatPhoneDisplay } from '../../lib/phone';
import { formatVehicleLabel } from '../../lib/customers';
import type { CustomerWithVehicles } from './types';
interface Props {
customer: CustomerWithVehicles;
onView: (id: string) => void;
onEdit: (c: CustomerWithVehicles) => void;
onDelete: (c: CustomerWithVehicles) => void;
}
function CustomerCardImpl({ customer, onView, onEdit, onDelete }: Props) {
return (
<div className="rounded-xl bg-white p-5 shadow-sm ring-1 ring-gray-200 transition-all hover:shadow-md dark:bg-gray-800 dark:ring-gray-700">
<div className="mb-3 flex items-start justify-between">
<div className="min-w-0 flex-1">
<h3
className="cursor-pointer truncate text-lg font-semibold text-gray-900 hover:text-green-600 dark:text-white dark:hover:text-green-400"
onClick={() => onView(customer.id)}
>
{customer.name}
</h3>
<p className="text-xs font-mono text-gray-400 dark:text-gray-500">
ID: {customer.id.slice(0, 8)}
</p>
<div className="mt-1 flex flex-wrap items-center gap-x-3 gap-y-1 text-sm text-gray-500 dark:text-gray-400">
{customer.phone && (
<span className="inline-flex items-center gap-1">
<Phone className="h-3.5 w-3.5" />
{formatPhoneDisplay(customer.phone)}
</span>
)}
{customer.email && (
<span className="inline-flex items-center gap-1 truncate">
<Mail className="h-3.5 w-3.5 shrink-0" />
{customer.email}
</span>
)}
</div>
</div>
</div>
<div className="mb-3 flex items-center gap-1.5 text-sm text-gray-500 dark:text-gray-400">
<Car className="h-4 w-4" />
<span>
{customer.vehicleCount === 0
? 'No vehicles'
: `${customer.vehicleCount} vehicle${customer.vehicleCount !== 1 ? 's' : ''}`}
</span>
{customer.vehicles.length > 0 && (
<span className="truncate text-gray-400 dark:text-gray-500">
&mdash; {formatVehicleLabel(customer.vehicles[0])}
</span>
)}
</div>
<div className="flex items-center gap-2 border-t border-gray-100 pt-3 dark:border-gray-700">
<button
onClick={() => onView(customer.id)}
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"
>
<FileText className="h-3.5 w-3.5" />
View Details
</button>
<button
onClick={() => onEdit(customer)}
className="inline-flex items-center gap-1.5 rounded-lg border border-gray-300 bg-white px-3 py-1.5 text-xs font-medium text-gray-700 transition-colors hover:bg-gray-50 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600"
>
<Pencil className="h-3.5 w-3.5" />
Edit
</button>
<button
onClick={() => onDelete(customer)}
className="ml-auto inline-flex items-center gap-1.5 rounded-lg px-3 py-1.5 text-xs font-medium text-red-600 transition-colors hover:bg-red-50 dark:text-red-400 dark:hover:bg-red-900/20"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</div>
</div>
);
}
export const CustomerCard = memo(CustomerCardImpl);
@@ -0,0 +1,467 @@
import { useState, memo, Fragment } from 'react';
import {
Phone,
Mail,
MapPin,
ChevronLeft,
Pencil,
Plus,
FileText,
Car,
Trash2,
} from 'lucide-react';
import { formatPhoneDisplay } from '../../lib/phone';
import {
formatVehicleLabel,
vehicleKey,
getRoStatusLabel,
getRoStatusColor,
formatCustomerDate,
parseQuoteServices,
} from '../../lib/customers';
import type {
CustomerWithVehicles,
VehicleRecord,
RepairOrderRecord,
QuoteRecord,
} from './types';
interface Props {
customer: CustomerWithVehicles;
vehicles: VehicleRecord[];
repairOrders: RepairOrderRecord[];
repairOrdersLoading: boolean;
quotes: QuoteRecord[];
quotesLoading: boolean;
onBack: () => void;
onEdit: (c: CustomerWithVehicles) => void;
onAddVehicle: () => void;
onEditVehicle: (v: VehicleRecord) => void;
onDeleteVehicle: (v: VehicleRecord) => void;
}
function CustomerDetailViewImpl({
customer,
vehicles,
repairOrders,
repairOrdersLoading,
quotes,
quotesLoading,
onBack,
onEdit,
onAddVehicle,
onEditVehicle,
onDeleteVehicle,
}: Props) {
const [expandedQuoteId, setExpandedQuoteId] = useState<string | null>(null);
const toggleQuote = (id: string) =>
setExpandedQuoteId(expandedQuoteId === id ? null : id);
function formatCurrency(n: number): string {
return '$' + n.toFixed(2);
}
return (
<div>
{/* Back + Actions */}
<div className="mb-6 flex flex-wrap items-center justify-between gap-3">
<button
onClick={onBack}
className="inline-flex items-center gap-1.5 text-sm font-medium text-gray-600 hover:text-gray-900 dark:text-gray-400 dark:hover:text-gray-200"
>
<ChevronLeft className="h-4 w-4" />
Back to Customers
</button>
<div className="flex items-center gap-2">
<button
onClick={() => onEdit(customer)}
className="inline-flex items-center gap-1.5 rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm font-medium text-gray-700 transition-colors hover:bg-gray-50 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600"
>
<Pencil className="h-4 w-4" />
Edit Profile
</button>
</div>
</div>
{/* Customer Info Card */}
<div className="mb-6 rounded-xl bg-white p-6 shadow-sm ring-1 ring-gray-200 dark:bg-gray-800 dark:ring-gray-700">
<h2 className="text-2xl font-bold text-gray-900 dark:text-white">{customer.name}</h2>
<p className="mt-0.5 font-mono text-sm text-gray-400 dark:text-gray-500">
Customer ID: {customer.id}
</p>
<div className="mt-3 grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
{customer.phone && (
<div className="flex items-center gap-2 text-sm text-gray-600 dark:text-gray-400">
<Phone className="h-4 w-4 shrink-0 text-gray-400" />
{formatPhoneDisplay(customer.phone)}
</div>
)}
{customer.email && (
<div className="flex items-center gap-2 text-sm text-gray-600 dark:text-gray-400">
<Mail className="h-4 w-4 shrink-0 text-gray-400" />
{customer.email}
</div>
)}
{customer.address && (
<div className="flex items-center gap-2 text-sm text-gray-600 dark:text-gray-400">
<MapPin className="h-4 w-4 shrink-0 text-gray-400" />
{customer.address}
</div>
)}
</div>
{customer.notes && (
<p className="mt-4 border-t border-gray-100 pt-4 text-sm text-gray-500 dark:border-gray-700 dark:text-gray-400">
{customer.notes}
</p>
)}
</div>
{/* Vehicles Section */}
<div className="rounded-xl bg-white p-6 shadow-sm ring-1 ring-gray-200 dark:bg-gray-800 dark:ring-gray-700">
<div className="mb-4 flex items-center justify-between">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
Vehicles ({vehicles.length})
</h3>
<button
onClick={onAddVehicle}
className="inline-flex items-center gap-1.5 rounded-lg bg-green-600 px-3 py-1.5 text-sm font-medium text-white transition-colors hover:bg-green-700"
>
<Plus className="h-4 w-4" />
Add Vehicle
</button>
</div>
{vehicles.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 text-center">
<Car className="mb-3 h-12 w-12 text-gray-300 dark:text-gray-600" />
<p className="text-sm text-gray-500 dark:text-gray-400">
No vehicles recorded for this customer.
</p>
<button
onClick={onAddVehicle}
className="mt-4 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"
>
<Plus className="h-4 w-4" />
Add First Vehicle
</button>
</div>
) : (
<div className="overflow-hidden rounded-lg border border-gray-200 dark:border-gray-700">
<div className="hidden overflow-x-auto md:block">
<table className="w-full text-left text-sm">
<thead className="bg-gray-50 text-xs uppercase tracking-wide text-gray-500 dark:bg-gray-900/60 dark:text-gray-400">
<tr>
<th className="px-4 py-3 font-semibold">Vehicle</th>
<th className="px-4 py-3 font-semibold">VIN</th>
<th className="px-4 py-3 font-semibold">Plate</th>
<th className="px-4 py-3 font-semibold">Mileage</th>
<th className="px-4 py-3 font-semibold">Engine</th>
<th className="px-4 py-3 text-right font-semibold">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100 dark:divide-gray-700">
{vehicles.map((v, index) => (
<tr key={v.id || `${vehicleKey(v)}-${index}`} className="hover:bg-gray-50 dark:hover:bg-gray-700/50">
<td className="px-4 py-3">
<p className="font-semibold text-gray-900 dark:text-white">{formatVehicleLabel(v)}</p>
{v.color && <p className="text-xs text-gray-500 dark:text-gray-400">{v.color}</p>}
</td>
<td className="px-4 py-3 font-mono text-xs text-gray-600 dark:text-gray-300">{v.vin || '—'}</td>
<td className="px-4 py-3 text-gray-600 dark:text-gray-300">{v.licensePlate || '—'}</td>
<td className="px-4 py-3 text-gray-600 dark:text-gray-300">{v.mileage || '—'}</td>
<td className="px-4 py-3 text-gray-600 dark:text-gray-300">{v.engine || '—'}</td>
<td className="px-4 py-3">
<div className="flex items-center justify-end gap-1">
<button
onClick={() => onEditVehicle(v)}
className="rounded-lg p-2 text-gray-500 hover:bg-gray-100 hover:text-gray-800 dark:text-gray-400 dark:hover:bg-gray-700 dark:hover:text-gray-100"
title="Edit vehicle"
>
<Pencil className="h-4 w-4" />
</button>
<button
onClick={() => onDeleteVehicle(v)}
className="rounded-lg p-2 text-gray-400 hover:bg-red-50 hover:text-red-600 dark:hover:bg-red-900/20 dark:hover:text-red-400"
title="Remove vehicle"
>
<Trash2 className="h-4 w-4" />
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
<div className="divide-y divide-gray-100 dark:divide-gray-700 md:hidden">
{vehicles.map((v, index) => (
<div key={v.id || `${vehicleKey(v)}-${index}`} className="p-4">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<p className="font-semibold text-gray-900 dark:text-white">{formatVehicleLabel(v)}</p>
{v.color && <p className="text-xs text-gray-500 dark:text-gray-400">{v.color}</p>}
</div>
<div className="flex shrink-0 gap-1">
<button
onClick={() => onEditVehicle(v)}
className="rounded-lg p-1.5 text-gray-500 hover:bg-gray-100 hover:text-gray-800 dark:text-gray-400 dark:hover:bg-gray-700 dark:hover:text-gray-100"
title="Edit vehicle"
>
<Pencil className="h-4 w-4" />
</button>
<button
onClick={() => onDeleteVehicle(v)}
className="rounded-lg p-1.5 text-gray-400 hover:bg-red-50 hover:text-red-600 dark:hover:bg-red-900/20 dark:hover:text-red-400"
title="Remove vehicle"
>
<Trash2 className="h-4 w-4" />
</button>
</div>
</div>
<div className="mt-3 grid grid-cols-2 gap-2 text-xs text-gray-500 dark:text-gray-400">
<p><span className="font-medium">VIN:</span> {v.vin || '—'}</p>
<p><span className="font-medium">Plate:</span> {v.licensePlate || '—'}</p>
<p><span className="font-medium">Mileage:</span> {v.mileage || '—'}</p>
<p><span className="font-medium">Engine:</span> {v.engine || '—'}</p>
</div>
</div>
))}
</div>
</div>
)}
</div>
{/* Repair Orders Section */}
<div className="mt-6 rounded-xl bg-white p-6 shadow-sm ring-1 ring-gray-200 dark:bg-gray-800 dark:ring-gray-700">
<div className="mb-4 flex items-center justify-between">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
Repair Orders ({repairOrders.length})
</h3>
</div>
{repairOrdersLoading ? (
<div className="flex items-center justify-center py-8">
<div className="h-5 w-5 animate-spin rounded-full border-2 border-green-500 border-t-transparent" />
<span className="ml-3 text-sm text-gray-500 dark:text-gray-400">
Loading repair orders...
</span>
</div>
) : repairOrders.length === 0 ? (
<div className="flex flex-col items-center justify-center py-8 text-center">
<FileText className="mb-3 h-10 w-10 text-gray-300 dark:text-gray-600" />
<p className="text-sm text-gray-500 dark:text-gray-400">
No repair orders have been linked to this customer yet.
</p>
</div>
) : (
<div className="overflow-x-auto">
<table className="w-full text-left text-sm">
<thead>
<tr className="border-b border-gray-200 dark:border-gray-700">
<th className="pb-3 pr-4 font-semibold text-gray-700 dark:text-gray-300">RO #</th>
<th className="pb-3 pr-4 font-semibold text-gray-700 dark:text-gray-300">Date</th>
<th className="pb-3 pr-4 font-semibold text-gray-700 dark:text-gray-300">Vehicle</th>
<th className="pb-3 font-semibold text-gray-700 dark:text-gray-300">Status</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100 dark:divide-gray-700">
{repairOrders.map((ro) => {
const statusLabel = getRoStatusLabel(ro);
const statusColor = getRoStatusColor(ro);
return (
<tr key={ro.id} className="group hover:bg-gray-50 dark:hover:bg-gray-700/50">
<td className="py-3 pr-4 font-medium text-gray-900 dark:text-white">
{ro.roNumber || '\u2014'}
</td>
<td className="py-3 pr-4 text-gray-600 dark:text-gray-400">
{formatCustomerDate(ro.createdAt)}
</td>
<td className="py-3 pr-4 text-gray-600 dark:text-gray-400">
{ro.vehicleInfo || '\u2014'}
</td>
<td className="py-3">
<span
className={
'inline-flex items-center gap-1 rounded-full px-2.5 py-0.5 text-xs font-medium ' +
statusColor
}
>
{statusLabel}
</span>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</div>
{/* Quotes Section */}
<div className="mt-6 rounded-xl bg-white p-6 shadow-sm ring-1 ring-gray-200 dark:bg-gray-800 dark:ring-gray-700">
<div className="mb-4 flex items-center justify-between">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
Quotes ({quotes.length})
</h3>
</div>
{quotesLoading ? (
<div className="flex items-center justify-center py-8">
<div className="h-5 w-5 animate-spin rounded-full border-2 border-green-500 border-t-transparent" />
<span className="ml-3 text-sm text-gray-500 dark:text-gray-400">
Loading quotes...
</span>
</div>
) : quotes.length === 0 ? (
<div className="flex flex-col items-center justify-center py-8 text-center">
<FileText className="mb-3 h-10 w-10 text-gray-300 dark:text-gray-600" />
<p className="text-sm text-gray-500 dark:text-gray-400">
No saved quotes are on file for this customer yet.
</p>
</div>
) : (
<div className="overflow-x-auto">
<table className="w-full text-left text-sm">
<thead>
<tr className="border-b border-gray-200 dark:border-gray-700">
<th className="pb-3 pr-4 font-semibold text-gray-700 dark:text-gray-300">Date</th>
<th className="pb-3 pr-4 font-semibold text-gray-700 dark:text-gray-300">Vehicle</th>
<th className="pb-3 pr-4 font-semibold text-gray-700 dark:text-gray-300">Total</th>
<th className="pb-3 pr-4 font-semibold text-gray-700 dark:text-gray-300">Status</th>
<th className="pb-3 font-semibold text-gray-700 dark:text-gray-300">Advisor</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100 dark:divide-gray-700">
{quotes.map((q) => {
const isExpanded = expandedQuoteId === q.id;
const services = parseQuoteServices(q.services);
const totalServices = services.reduce((sum, s) => sum + s.price, 0);
const qStatusClass =
q.status === 'converted'
? 'bg-green-50 text-green-700 dark:bg-green-900/30 dark:text-green-400'
: q.status === 'sent'
? 'bg-blue-50 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400'
: q.status === 'declined'
? 'bg-red-50 text-red-700 dark:bg-red-900/30 dark:text-red-400'
: 'bg-gray-50 text-gray-700 dark:bg-gray-900/30 dark:text-gray-400';
return (
<Fragment key={q.id}>
<tr
className="group cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-700/50"
onClick={() => toggleQuote(q.id)}
>
<td className="py-3 pr-4 text-gray-600 dark:text-gray-400">
{formatCustomerDate(q.createdAt)}
</td>
<td className="py-3 pr-4 text-gray-600 dark:text-gray-400">
{q.vehicleInfo || '\u2014'}
</td>
<td className="py-3 pr-4 font-medium text-gray-900 dark:text-white">
{q.total ? formatCurrency(q.total) : '\u2014'}
</td>
<td className="py-3 pr-4">
<span
className={
'inline-flex items-center gap-1 rounded-full px-2.5 py-0.5 text-xs font-medium ' +
qStatusClass
}
>
{q.status || 'Unknown'}
</span>
</td>
<td className="py-3 text-gray-600 dark:text-gray-400">
{q.serviceAdvisor || '\u2014'}
</td>
</tr>
<tr key={q.id + '-detail'}>
<td colSpan={5} className="p-0">
{isExpanded && (
<div className="border-t border-gray-100 bg-gray-50 px-6 py-4 dark:border-gray-700 dark:bg-gray-800/50">
{services.length > 0 && (
<div className="mb-3 space-y-1.5">
<p className="mb-1.5 text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
Services
</p>
{services.map((s, i) => (
<div
key={i}
className="flex items-center justify-between gap-2 text-sm"
>
<div className="flex items-center gap-2 min-w-0">
<span className="truncate text-gray-700 dark:text-gray-300">
{s.name}
</span>
{s.customerDecision && s.customerDecision !== 'pending' && (
<span
className={
'shrink-0 rounded-full px-1.5 py-0.5 text-[10px] font-medium ' +
(s.customerDecision === 'approved'
? 'bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-400'
: 'bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-400')
}
>
{s.customerDecision === 'approved'
? 'Approved'
: 'Declined'}
</span>
)}
</div>
<span className="shrink-0 font-medium text-gray-900 dark:text-white">
{formatCurrency(s.price)}
</span>
</div>
))}
{services.length > 1 && (
<div className="flex items-center justify-between border-t border-gray-200 pt-1.5 text-sm dark:border-gray-600">
<span className="font-semibold text-gray-700 dark:text-gray-300">
Subtotal
</span>
<span className="font-semibold text-gray-900 dark:text-white">
{formatCurrency(totalServices)}
</span>
</div>
)}
</div>
)}
{q.notes && (
<div className="mb-2">
<p className="mb-0.5 text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
Notes
</p>
<p className="text-sm text-gray-600 dark:text-gray-400">
{q.notes}
</p>
</div>
)}
{q.vin && (
<p className="text-xs text-gray-400 dark:text-gray-500">
VIN: {q.vin}
</p>
)}
<button
onClick={(e) => {
e.stopPropagation();
toggleQuote(q.id);
}}
className="mt-3 text-xs font-medium text-green-600 hover:text-green-700 dark:text-green-400 dark:hover:text-green-300"
>
Collapse
</button>
</div>
)}
</td>
</tr>
</Fragment>
);
})}
</tbody>
</table>
</div>
)}
</div>
</div>
);
}
export const CustomerDetailView = memo(CustomerDetailViewImpl);
@@ -0,0 +1,175 @@
import { memo } from 'react';
import { Car, Eye, Mail, Pencil, Phone, Trash2 } from 'lucide-react';
import { formatPhoneDisplay } from '../../lib/phone';
import { formatCustomerDate, formatVehicleLabel } from '../../lib/customers';
import type { CustomerWithVehicles } from './types';
interface Props {
customers: CustomerWithVehicles[];
onView: (id: string) => void;
onEdit: (customer: CustomerWithVehicles) => void;
onDelete: (customer: CustomerWithVehicles) => void;
}
function primaryVehicleText(customer: CustomerWithVehicles): string {
const vehicle = customer.vehicles[0];
return vehicle ? formatVehicleLabel(vehicle) : 'No vehicle on file';
}
function secondaryVehicleText(customer: CustomerWithVehicles): string {
const vehicle = customer.vehicles[0];
if (!vehicle) return '';
const details = [vehicle.vin ? `VIN ${vehicle.vin}` : '', vehicle.licensePlate ? `Plate ${vehicle.licensePlate}` : ''].filter(Boolean);
return details.join(' · ');
}
function CustomerDirectoryTableImpl({ customers, onView, onEdit, onDelete }: Props) {
return (
<div className="overflow-hidden rounded-xl bg-white shadow-sm ring-1 ring-gray-200 dark:bg-gray-800 dark:ring-gray-700">
<div className="hidden overflow-x-auto md:block">
<table className="w-full text-left text-sm">
<thead className="bg-gray-50 text-xs uppercase tracking-wide text-gray-500 dark:bg-gray-900/60 dark:text-gray-400">
<tr>
<th className="px-4 py-3 font-semibold">Customer</th>
<th className="px-4 py-3 font-semibold">Phone</th>
<th className="px-4 py-3 font-semibold">Email</th>
<th className="px-4 py-3 font-semibold">Primary Vehicle</th>
<th className="px-4 py-3 text-center font-semibold">Vehicles</th>
<th className="px-4 py-3 font-semibold">Updated</th>
<th className="px-4 py-3 text-right font-semibold">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100 dark:divide-gray-700">
{customers.map((customer) => {
const vehicleDetails = secondaryVehicleText(customer);
return (
<tr key={customer.id} className="group hover:bg-green-50/50 dark:hover:bg-gray-700/50">
<td className="px-4 py-3">
<button
type="button"
onClick={() => onView(customer.id)}
className="text-left font-semibold text-gray-900 hover:text-green-700 dark:text-white dark:hover:text-green-400"
>
{customer.name}
</button>
<p className="mt-0.5 font-mono text-xs text-gray-400 dark:text-gray-500">ID {customer.id.slice(0, 8)}</p>
</td>
<td className="px-4 py-3 text-gray-600 dark:text-gray-300">
{customer.phone ? formatPhoneDisplay(customer.phone) : <span className="text-gray-400">Missing</span>}
</td>
<td className="max-w-[220px] px-4 py-3 text-gray-600 dark:text-gray-300">
{customer.email ? <span className="block truncate">{customer.email}</span> : <span className="text-gray-400">Missing</span>}
</td>
<td className="px-4 py-3">
<p className="font-medium text-gray-800 dark:text-gray-100">{primaryVehicleText(customer)}</p>
{vehicleDetails && <p className="mt-0.5 max-w-[260px] truncate text-xs text-gray-500 dark:text-gray-400">{vehicleDetails}</p>}
</td>
<td className="px-4 py-3 text-center">
<span className="inline-flex min-w-8 items-center justify-center rounded-full bg-gray-100 px-2.5 py-1 text-xs font-semibold text-gray-700 dark:bg-gray-700 dark:text-gray-200">
{customer.vehicleCount}
</span>
</td>
<td className="px-4 py-3 text-gray-500 dark:text-gray-400">{formatCustomerDate(customer.updated || customer.created)}</td>
<td className="px-4 py-3">
<div className="flex items-center justify-end gap-1">
<button
type="button"
onClick={() => onView(customer.id)}
className="rounded-lg p-2 text-gray-500 hover:bg-green-100 hover:text-green-700 dark:text-gray-400 dark:hover:bg-green-900/30 dark:hover:text-green-300"
title="View customer"
>
<Eye className="h-4 w-4" />
</button>
<button
type="button"
onClick={() => onEdit(customer)}
className="rounded-lg p-2 text-gray-500 hover:bg-gray-100 hover:text-gray-800 dark:text-gray-400 dark:hover:bg-gray-700 dark:hover:text-gray-100"
title="Edit customer"
>
<Pencil className="h-4 w-4" />
</button>
<button
type="button"
onClick={() => onDelete(customer)}
className="rounded-lg p-2 text-gray-400 hover:bg-red-50 hover:text-red-600 dark:hover:bg-red-900/20 dark:hover:text-red-400"
title="Delete customer"
>
<Trash2 className="h-4 w-4" />
</button>
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
<div className="divide-y divide-gray-100 dark:divide-gray-700 md:hidden">
{customers.map((customer) => {
const vehicleDetails = secondaryVehicleText(customer);
return (
<div key={customer.id} className="p-4">
<div className="flex items-start justify-between gap-3">
<button
type="button"
onClick={() => onView(customer.id)}
className="min-w-0 text-left"
>
<p className="truncate font-semibold text-gray-900 dark:text-white">{customer.name}</p>
<p className="mt-1 flex items-center gap-1.5 text-xs text-gray-500 dark:text-gray-400">
<Car className="h-3.5 w-3.5" />
<span className="truncate">{primaryVehicleText(customer)}</span>
</p>
</button>
<span className="shrink-0 rounded-full bg-gray-100 px-2.5 py-1 text-xs font-semibold text-gray-700 dark:bg-gray-700 dark:text-gray-200">
{customer.vehicleCount} vehicle{customer.vehicleCount !== 1 ? 's' : ''}
</span>
</div>
<div className="mt-3 space-y-1.5 text-sm text-gray-600 dark:text-gray-300">
{customer.phone && (
<p className="flex items-center gap-2">
<Phone className="h-3.5 w-3.5 text-gray-400" />
{formatPhoneDisplay(customer.phone)}
</p>
)}
{customer.email && (
<p className="flex items-center gap-2">
<Mail className="h-3.5 w-3.5 text-gray-400" />
<span className="truncate">{customer.email}</span>
</p>
)}
{vehicleDetails && <p className="text-xs text-gray-500 dark:text-gray-400">{vehicleDetails}</p>}
</div>
<div className="mt-4 flex items-center gap-2">
<button
type="button"
onClick={() => onView(customer.id)}
className="rounded-lg bg-green-600 px-3 py-1.5 text-xs font-medium text-white hover:bg-green-700"
>
View
</button>
<button
type="button"
onClick={() => onEdit(customer)}
className="rounded-lg border border-gray-300 px-3 py-1.5 text-xs font-medium text-gray-700 hover:bg-gray-50 dark:border-gray-600 dark:text-gray-300 dark:hover:bg-gray-700"
>
Edit
</button>
<button
type="button"
onClick={() => onDelete(customer)}
className="ml-auto rounded-lg p-1.5 text-red-500 hover:bg-red-50 dark:hover:bg-red-900/20"
>
<Trash2 className="h-4 w-4" />
</button>
</div>
</div>
);
})}
</div>
</div>
);
}
export const CustomerDirectoryTable = memo(CustomerDirectoryTableImpl);
@@ -0,0 +1,216 @@
import { useState, useEffect } from 'react';
import { X } from 'lucide-react';
import { formatPhoneInput } from '../../lib/phone';
import { composeName } from '../../lib/customerName';
import type { CustomerFormData, CustomerWithVehicles } from './types';
export function CustomerFormModal({
open,
customer,
onClose,
onSave,
}: {
open: boolean;
customer: CustomerWithVehicles | null;
onClose: () => void;
onSave: (data: CustomerFormData) => Promise<void>;
}) {
const [form, setForm] = useState<CustomerFormData>({
name: '',
firstName: '',
middleName: '',
lastName: '',
phone: '',
email: '',
address: '',
notes: '',
});
const [saving, setSaving] = useState(false);
const [error, setError] = useState('');
useEffect(() => {
if (open) {
const fullName = customer?.name || '';
const parts = fullName.split(/\s+/).filter(Boolean);
const firstName = parts[0] || '';
const lastName = parts.length > 1 ? parts[parts.length - 1] : '';
const middleName = parts.length > 2 ? parts.slice(1, -1).join(' ') : '';
setForm({
name: fullName,
firstName,
middleName,
lastName,
phone: customer?.phone || '',
email: customer?.email || '',
address: customer?.address || '',
notes: customer?.notes || '',
});
setError('');
}
}, [open, customer]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
const name = composeName(form.firstName, form.middleName, form.lastName) || form.name;
if (!name.trim()) {
setError('Customer name is required.');
return;
}
setSaving(true);
setError('');
try {
await onSave({ ...form, name });
} catch (err) {
setError('Customer could not be saved. Please try again.');
} finally {
setSaving(false);
}
};
if (!open) return null;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
<div className="w-full max-w-lg rounded-xl bg-white shadow-xl dark:bg-gray-800">
<div className="flex items-center justify-between border-b border-gray-200 px-6 py-4 dark:border-gray-700">
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">
{customer ? 'Edit Customer' : 'Add Customer'}
</h2>
<button
onClick={onClose}
className="rounded-lg p-1.5 text-gray-400 hover:bg-gray-100 hover:text-gray-600 dark:hover:bg-gray-700 dark:hover:text-gray-300"
>
<X className="h-5 w-5" />
</button>
</div>
<form onSubmit={handleSubmit} className="space-y-4 p-6">
<div className="grid grid-cols-3 gap-3">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">
First <span className="text-red-500">*</span>
</label>
<input
type="text"
value={form.firstName}
onChange={(e) => {
const firstName = e.target.value;
const name = composeName(firstName, form.middleName, form.lastName);
setForm({ ...form, firstName, name });
}}
placeholder="John"
className="mt-1 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-700 dark:text-gray-100 dark:placeholder-gray-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">
Middle
</label>
<input
type="text"
value={form.middleName}
onChange={(e) => {
const middleName = e.target.value;
const name = composeName(form.firstName, middleName, form.lastName);
setForm({ ...form, middleName, name });
}}
placeholder="Michael"
className="mt-1 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-700 dark:text-gray-100 dark:placeholder-gray-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">
Last <span className="text-red-500">*</span>
</label>
<input
type="text"
value={form.lastName}
onChange={(e) => {
const lastName = e.target.value;
const name = composeName(form.firstName, form.middleName, lastName);
setForm({ ...form, lastName, name });
}}
placeholder="Doe"
className="mt-1 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-700 dark:text-gray-100 dark:placeholder-gray-500"
/>
</div>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">
Phone
</label>
<input
type="tel"
value={formatPhoneInput(form.phone)}
onChange={(e) => setForm({ ...form, phone: e.target.value.replace(/\D/g, '') })}
placeholder="555-555-5555"
className="mt-1 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-700 dark:text-gray-100 dark:placeholder-gray-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">
Email
</label>
<input
type="email"
value={form.email}
onChange={(e) => setForm({ ...form, email: e.target.value })}
placeholder="john@example.com"
className="mt-1 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-700 dark:text-gray-100 dark:placeholder-gray-500"
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">
Address
</label>
<input
type="text"
value={form.address}
onChange={(e) => setForm({ ...form, address: e.target.value })}
placeholder="123 Main St, City, State ZIP"
className="mt-1 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-700 dark:text-gray-100 dark:placeholder-gray-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">
Notes
</label>
<textarea
value={form.notes}
onChange={(e) => setForm({ ...form, notes: e.target.value })}
placeholder="Any additional notes about this customer..."
rows={3}
className="mt-1 w-full resize-y 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-700 dark:text-gray-100 dark:placeholder-gray-500"
/>
</div>
{error && (
<p className="text-sm text-red-600 dark:text-red-400">{error}</p>
)}
<div className="flex justify-end gap-3 border-t border-gray-200 pt-4 dark:border-gray-700">
<button
type="button"
onClick={onClose}
className="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-700 dark:text-gray-300 dark:hover:bg-gray-600"
>
Cancel
</button>
<button
type="submit"
disabled={saving}
className="inline-flex items-center gap-2 rounded-lg bg-green-600 px-4 py-2 text-sm font-medium text-white shadow-sm transition-colors hover:bg-green-700 disabled:cursor-not-allowed disabled:opacity-50"
>
{saving ? 'Saving...' : customer ? 'Save Changes' : 'Add Customer'}
</button>
</div>
</form>
</div>
</div>
);
}
@@ -0,0 +1,71 @@
import { useState } from 'react';
import { AlertTriangle } from 'lucide-react';
export function DeleteConfirmModal({
open,
customerName,
onClose,
onConfirm,
}: {
open: boolean;
customerName: string;
onClose: () => void;
onConfirm: () => Promise<void>;
}) {
const [deleting, setDeleting] = useState(false);
const [error, setError] = useState('');
const handleDelete = async () => {
setDeleting(true);
setError('');
try {
await onConfirm();
} catch (err) {
setError('Item could not be deleted. Please try again.');
} finally {
setDeleting(false);
}
};
if (!open) return null;
return (
<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-xl dark:bg-gray-800">
<div className="mb-6 text-center">
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-red-50 dark:bg-red-900/20">
<AlertTriangle className="h-8 w-8 text-red-500" />
</div>
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
Delete Customer
</h3>
<p className="mt-2 text-sm text-gray-500 dark:text-gray-400">
Are you sure you want to delete <strong>{customerName}</strong>? This will also remove
all associated vehicles. This action cannot be undone.
</p>
</div>
{error && (
<p className="mb-4 text-sm text-red-600 dark:text-red-400">{error}</p>
)}
<div className="flex justify-end gap-3">
<button
onClick={onClose}
disabled={deleting}
className="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-700 dark:text-gray-300 dark:hover:bg-gray-600"
>
Cancel
</button>
<button
onClick={handleDelete}
disabled={deleting}
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:cursor-not-allowed disabled:opacity-50"
>
{deleting ? 'Deleting...' : 'Delete'}
</button>
</div>
</div>
</div>
);
}
+67
View File
@@ -0,0 +1,67 @@
import { Search, Users, UserPlus } from 'lucide-react';
import { ListError } from '../ui/ListError';
export function CustomersSkeleton() {
return (
<div className="animate-pulse space-y-4">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
{Array.from({ length: 6 }).map((_, i) => (
<div
key={i}
className="rounded-xl bg-white p-5 ring-1 ring-gray-200 dark:bg-gray-800 dark:ring-gray-700"
>
<div className="mb-3 h-5 w-3/4 rounded bg-gray-200 dark:bg-gray-700" />
<div className="mb-2 h-4 w-1/2 rounded bg-gray-200 dark:bg-gray-700" />
<div className="h-4 w-1/3 rounded bg-gray-200 dark:bg-gray-700" />
</div>
))}
</div>
</div>
);
}
export function EmptyState({ searchTerm, onCreate }: { searchTerm: string; onCreate: () => void }) {
if (searchTerm) {
return (
<div className="flex flex-col items-center justify-center py-20 text-center">
<div className="mb-6 flex h-24 w-24 items-center justify-center rounded-full bg-blue-50 dark:bg-blue-900/20">
<Search className="h-12 w-12 text-blue-500 dark:text-blue-400" />
</div>
<h3 className="mb-2 text-lg font-semibold text-gray-900 dark:text-white">
No matching customers
</h3>
<p className="mb-2 max-w-sm text-sm text-gray-500 dark:text-gray-400">
No customer records matched &ldquo;{searchTerm}&rdquo;.
</p>
<p className="mb-8 text-sm text-gray-400 dark:text-gray-500">
Try searching by customer name, phone number, email address, vehicle, or VIN.
</p>
</div>
);
}
return (
<div className="flex flex-col items-center justify-center py-20 text-center">
<div className="mb-6 flex h-24 w-24 items-center justify-center rounded-full bg-green-50 dark:bg-green-900/20">
<Users className="h-12 w-12 text-green-500 dark:text-green-400" />
</div>
<h3 className="mb-2 text-lg font-semibold text-gray-900 dark:text-white">
No customer records on file
</h3>
<p className="mb-8 max-w-sm text-sm text-gray-500 dark:text-gray-400">
Add your first customer record to keep contact details, vehicles, quotes, and repair history organized in one place.
</p>
<button
onClick={onCreate}
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>
);
}
export function ErrorState({ message, onRetry }: { message: string; onRetry: () => void }) {
return <ListError title="Unable to load customers" message={message} onRetry={onRetry} />;
}
@@ -0,0 +1,217 @@
import { useState, useEffect } from 'react';
import { X } from 'lucide-react';
import type { VehicleFormData, VehicleRecord } from './types';
export function VehicleFormModal({
open,
vehicle,
customerName,
onClose,
onSave,
}: {
open: boolean;
vehicle: VehicleRecord | null;
customerName: string;
onClose: () => void;
onSave: (data: VehicleFormData) => Promise<void>;
}) {
const [form, setForm] = useState<VehicleFormData>({
make: '',
model: '',
year: '',
vin: '',
licensePlate: '',
mileage: '',
color: '',
engine: '',
});
const [saving, setSaving] = useState(false);
const [error, setError] = useState('');
useEffect(() => {
if (open) {
setForm({
make: vehicle?.make || '',
model: vehicle?.model || '',
year: vehicle?.year || '',
vin: vehicle?.vin || '',
licensePlate: vehicle?.licensePlate || '',
mileage: vehicle?.mileage || '',
color: vehicle?.color || '',
engine: vehicle?.engine || '',
});
setError('');
}
}, [open, vehicle]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!form.make.trim() || !form.model.trim()) {
setError('Make and model are required.');
return;
}
setSaving(true);
setError('');
try {
await onSave(form);
} catch (err) {
setError('Vehicle could not be saved. Please try again.');
} finally {
setSaving(false);
}
};
if (!open) return null;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
<div className="w-full max-w-lg rounded-xl bg-white shadow-xl dark:bg-gray-800">
<div className="flex items-center justify-between border-b border-gray-200 px-6 py-4 dark:border-gray-700">
<div>
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">
{vehicle ? 'Edit Vehicle' : 'Add Vehicle'}
</h2>
<p className="text-xs text-gray-500 dark:text-gray-400">
{customerName}
</p>
</div>
<button
onClick={onClose}
className="rounded-lg p-1.5 text-gray-400 hover:bg-gray-100 hover:text-gray-600 dark:hover:bg-gray-700 dark:hover:text-gray-300"
>
<X className="h-5 w-5" />
</button>
</div>
<form onSubmit={handleSubmit} className="space-y-4 p-6">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">
Year
</label>
<input
type="text"
value={form.year}
onChange={(e) => setForm({ ...form, year: e.target.value })}
placeholder="2020"
className="mt-1 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-700 dark:text-gray-100 dark:placeholder-gray-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">
Make <span className="text-red-500">*</span>
</label>
<input
type="text"
value={form.make}
onChange={(e) => setForm({ ...form, make: e.target.value })}
placeholder="Honda"
className="mt-1 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-700 dark:text-gray-100 dark:placeholder-gray-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">
Model <span className="text-red-500">*</span>
</label>
<input
type="text"
value={form.model}
onChange={(e) => setForm({ ...form, model: e.target.value })}
placeholder="Civic"
className="mt-1 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-700 dark:text-gray-100 dark:placeholder-gray-500"
/>
</div>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">
VIN
</label>
<input
type="text"
value={form.vin}
onChange={(e) => setForm({ ...form, vin: e.target.value })}
placeholder="1HGBH41JXMN109186"
maxLength={17}
className="mt-1 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-700 dark:text-gray-100 dark:placeholder-gray-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">
License Plate
</label>
<input
type="text"
value={form.licensePlate}
onChange={(e) => setForm({ ...form, licensePlate: e.target.value })}
placeholder="ABC-1234"
className="mt-1 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-700 dark:text-gray-100 dark:placeholder-gray-500"
/>
</div>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">
Mileage
</label>
<input
type="text"
value={form.mileage}
onChange={(e) => setForm({ ...form, mileage: e.target.value })}
placeholder="45,000"
className="mt-1 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-700 dark:text-gray-100 dark:placeholder-gray-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">
Color
</label>
<input
type="text"
value={form.color}
onChange={(e) => setForm({ ...form, color: e.target.value })}
placeholder="Silver"
className="mt-1 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-700 dark:text-gray-100 dark:placeholder-gray-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">
Engine
</label>
<input
type="text"
value={form.engine}
onChange={(e) => setForm({ ...form, engine: e.target.value })}
placeholder="2.0L I4"
className="mt-1 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-700 dark:text-gray-100 dark:placeholder-gray-500"
/>
</div>
</div>
{error && (
<p className="text-sm text-red-600 dark:text-red-400">{error}</p>
)}
<div className="flex justify-end gap-3 border-t border-gray-200 pt-4 dark:border-gray-700">
<button
type="button"
onClick={onClose}
className="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-700 dark:text-gray-300 dark:hover:bg-gray-600"
>
Cancel
</button>
<button
type="submit"
disabled={saving}
className="inline-flex items-center gap-2 rounded-lg bg-green-600 px-4 py-2 text-sm font-medium text-white shadow-sm transition-colors hover:bg-green-700 disabled:cursor-not-allowed disabled:opacity-50"
>
{saving ? 'Saving...' : vehicle ? 'Save Changes' : 'Add Vehicle'}
</button>
</div>
</form>
</div>
</div>
);
}
+16
View File
@@ -0,0 +1,16 @@
export { CustomersSkeleton, EmptyState, ErrorState } from './EmptyStates';
export { CustomerCard } from './CustomerCard';
export { CustomerDirectoryTable } from './CustomerDirectoryTable';
export { CustomerFormModal } from './CustomerFormModal';
export { VehicleFormModal } from './VehicleFormModal';
export { DeleteConfirmModal } from './DeleteConfirmModal';
export { CustomerDetailView } from './CustomerDetailView';
export type {
CustomerRecord,
VehicleRecord,
RepairOrderRecord,
QuoteRecord,
CustomerWithVehicles,
CustomerFormData,
VehicleFormData,
} from './types';
+86
View File
@@ -0,0 +1,86 @@
export interface CustomerRecord {
id: string;
name: string;
phone: string;
email: string;
address: string;
notes: string;
userId: string;
created: string;
updated: string;
}
export interface VehicleRecord {
id: string;
customerId: string;
userId?: string;
make: string;
model: string;
year: string;
vin: string;
licensePlate: string;
mileage: string;
color: string;
engine: string;
}
export interface RepairOrderRecord {
id: string;
roNumber: string;
customerId: string;
customerName: string;
vehicleInfo: string;
vin: string;
mileage: string;
status: string;
workStatus: string;
createdAt: string;
completedTime: string;
writeupTime: string;
technician: string;
notes: string;
services: string;
total: number;
}
export interface QuoteRecord {
id: string;
customerId: string;
customerName: string;
vehicleInfo: string;
vin: string;
mileage: string;
status: string;
createdAt: string;
total: number;
serviceAdvisor: string;
services: string;
notes: string;
}
export interface CustomerWithVehicles extends CustomerRecord {
vehicles: VehicleRecord[];
vehicleCount: number;
}
export interface CustomerFormData {
name: string;
firstName: string;
middleName: string;
lastName: string;
phone: string;
email: string;
address: string;
notes: string;
}
export interface VehicleFormData {
make: string;
model: string;
year: string;
vin: string;
licensePlate: string;
mileage: string;
color: string;
engine: string;
}
+130
View File
@@ -0,0 +1,130 @@
import { useState, useEffect, type ReactNode } from 'react';
import {
LayoutDashboard,
Calendar,
Wrench,
FileText,
Users,
Receipt,
Package,
DollarSign,
Clock,
Settings,
Sun,
Moon,
LogOut,
} from 'lucide-react';
import { NavLink, useLocation, useNavigate } from 'react-router-dom';
import { pb } from '../../lib/pocketbase';
import { preloadSettingsPage, preloadTimeCards } from '../../lib/routePreload';
interface MobileLayoutProps {
children: ReactNode;
}
const navItems = [
{ to: '/', label: 'Field', icon: LayoutDashboard },
{ to: '/appointments', label: 'Agenda', icon: Calendar },
{ to: '/repair-orders', label: 'Jobs', icon: Wrench },
{ to: '/quote', label: 'Quote', icon: FileText },
{ to: '/customers', label: 'Customers', icon: Users },
{ to: '/invoices', label: 'Invoices', icon: Receipt },
{ to: '/service-catalog', label: 'Catalog', icon: Package },
{ to: '/financial', label: 'Financial', icon: DollarSign },
{ to: '/time-cards', label: 'Time Cards', icon: Clock },
{ to: '/settings', label: 'Settings', icon: Settings },
];
export default function MobileLayout({ children }: MobileLayoutProps) {
const navigate = useNavigate();
const location = useLocation();
const [dark, setDark] = useState(() => {
if (typeof window === 'undefined') return false;
return localStorage.getItem('spq-dark-mode') === 'true';
});
useEffect(() => {
const root = document.documentElement;
if (dark) {
root.classList.add('dark');
} else {
root.classList.remove('dark');
}
localStorage.setItem('spq-dark-mode', String(dark));
}, [dark]);
const toggleDark = () => setDark((prev) => !prev);
const handleLogout = () => {
pb.authStore.clear();
navigate('/login');
};
const userEmail = pb.authStore.model?.email as string | undefined;
const navClasses =
'flex shrink-0 items-center gap-1.5 rounded-full px-3 py-1.5 text-xs font-medium whitespace-nowrap transition-colors';
return (
<div className="min-h-screen bg-gray-50 dark:bg-gray-950">
<header className="sticky top-0 z-30 flex h-14 items-center gap-2 border-b border-gray-200 bg-white px-3 dark:border-gray-800 dark:bg-gray-900">
<span className="shrink-0 text-base font-bold text-gray-900 dark:text-white">
Shop<span className="text-green-600">Pro</span>Quote
</span>
<div className="min-w-0 flex-1" />
<button
onClick={toggleDark}
className="rounded-lg p-1.5 text-gray-500 hover:bg-gray-100 hover:text-gray-700 dark:text-gray-400 dark:hover:bg-gray-800 dark:hover:text-gray-200"
title={dark ? 'Switch to light mode' : 'Switch to dark mode'}
>
{dark ? <Sun className="h-4 w-4" /> : <Moon className="h-4 w-4" />}
</button>
{userEmail && (
<span className="hidden max-w-[120px] truncate text-xs text-gray-500 dark:text-gray-400 sm:block">
{userEmail}
</span>
)}
<button
onClick={handleLogout}
className="rounded-lg p-1.5 text-gray-500 hover:bg-red-50 hover:text-red-600 dark:text-gray-400 dark:hover:bg-red-900/20 dark:hover:text-red-400"
title="Logout"
>
<LogOut className="h-4 w-4" />
</button>
</header>
<nav className="sticky top-14 z-20 flex gap-1 overflow-x-auto border-b border-gray-200 bg-white px-2 py-2 dark:border-gray-800 dark:bg-gray-900">
{navItems.map((item) => (
<NavLink
key={item.to}
to={item.to}
end={item.to === '/'}
state={
item.to === '/settings'
? {
backgroundLocation:
(location.state as { backgroundLocation?: unknown } | null)
?.backgroundLocation || location,
}
: undefined
}
onMouseEnter={item.to === '/settings' ? preloadSettingsPage : item.to === '/time-cards' ? preloadTimeCards : undefined}
onFocus={item.to === '/settings' ? preloadSettingsPage : item.to === '/time-cards' ? preloadTimeCards : undefined}
className={({ isActive }) =>
`${navClasses} ${
isActive
? 'bg-green-600 text-white shadow-sm'
: 'bg-gray-100 text-gray-600 hover:bg-gray-200 dark:bg-gray-800 dark:text-gray-400 dark:hover:bg-gray-700'
}`
}
>
<item.icon className="h-3.5 w-3.5" />
<span>{item.label}</span>
</NavLink>
))}
</nav>
<main className="p-3 sm:p-4">{children}</main>
</div>
);
}
@@ -0,0 +1,458 @@
import { useState, useEffect, useRef, useCallback } from 'react';
import { Search, X, Check, UserRound, AlertTriangle } from 'lucide-react';
import { useQuoteStore } from '../../store/quote';
import { useSettings } from '../../store/settings';
import { getRoleLabel, showServiceLocationFields } from '../../lib/settings';
import { formatPhoneInput } from '../../lib/phone';
import { pb } from '../../lib/pocketbase';
import { logError } from '../../lib/userMessages';
interface CustomerSearchResult {
id: string;
name: string;
phone: string;
vehicleCount: number;
}
export function CustomerInfoPanel() {
const { customerInfo, setCustomerInfo } = useQuoteStore();
const settings = useSettings();
const defaultAdvisor = settings.serviceAdvisor;
const roleLabel = getRoleLabel(settings);
const showLocationFields = showServiceLocationFields(settings);
const isDefaultAdvisor = (val: string) => Boolean(defaultAdvisor && val === defaultAdvisor);
// Customer search
const [searchQuery, setSearchQuery] = useState('');
const [searchResults, setSearchResults] = useState<CustomerSearchResult[]>([]);
const [searching, setSearching] = useState(false);
const [showDropdown, setShowDropdown] = useState(false);
const searchRef = useRef<HTMLDivElement | null>(null);
const debounceRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
// Duplicate detection for new customer entry
const [duplicateWarning, setDuplicateWarning] = useState<{
customer: CustomerSearchResult;
message: 'name' | 'phone';
} | null>(null);
const dupeDebounceRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
const isLinked = Boolean(customerInfo.customerId);
// ── Search customers ──
const doSearch = useCallback(async (q: string) => {
if (!q.trim()) {
setSearchResults([]);
setShowDropdown(false);
return;
}
setSearching(true);
try {
const searchClean = q.toLowerCase().trim();
// Fetch all customers for this user (list rule scopes by userId) and filter client-side
const result = await pb.collection('customers').getList(1, 500, {
sort: 'name',
fields: 'id,name,phone',
batch: 500,
});
// Client-side filter by name or phone
const isNumeric = /^\d+$/.test(searchClean);
const matched = (result.items as any[]).filter((c: any) => {
if (isNumeric) {
return (c.phone || '').replace(/\D/g, '').includes(searchClean);
}
return (c.name || '').toLowerCase().includes(searchClean);
});
// Fetch vehicle count for top matches (limit to 10 for display)
const top = matched.slice(0, 10);
const enriched: CustomerSearchResult[] = await Promise.all(
top.map(async (r: any) => {
let vehicleCount = 0;
try {
const vResult = await pb.collection('vehicles').getList(1, 1, {
filter: `customerId = '${r.id.replace(/'/g, "\\'")}'`,
fields: 'id',
batch: 1,
});
vehicleCount = vResult.totalItems;
} catch { /* ignore */ }
return { id: r.id, name: r.name, phone: r.phone, vehicleCount };
})
);
setSearchResults(enriched);
setShowDropdown(enriched.length > 0);
} catch (err) {
logError('Customer search failed', err);
setSearchResults([]);
} finally {
setSearching(false);
}
}, []);
// Debounced search
const handleSearchInput = useCallback((val: string) => {
setSearchQuery(val);
if (debounceRef.current) clearTimeout(debounceRef.current);
debounceRef.current = setTimeout(() => doSearch(val), 300);
}, [doSearch]);
// Close dropdown on outside click
useEffect(() => {
const handler = (e: MouseEvent) => {
if (searchRef.current && !searchRef.current.contains(e.target as Node)) {
setShowDropdown(false);
}
};
document.addEventListener('mousedown', handler);
return () => document.removeEventListener('mousedown', handler);
}, []);
// Cleanup debounce on unmount
useEffect(() => {
return () => {
if (debounceRef.current) clearTimeout(debounceRef.current);
if (dupeDebounceRef.current) clearTimeout(dupeDebounceRef.current);
};
}, []);
// ── Select existing customer ──
const selectCustomer = useCallback(async (c: CustomerSearchResult) => {
const userId = pb.authStore.model?.id;
try {
const full = await pb.collection('customers').getOne(c.id) as any;
// Try fetching vehicle from vehicles collection first
let vehicleInfo = '';
let vin = '';
let mileage = '';
let roNumber = '';
try {
const vResult = await pb.collection('vehicles').getList(1, 1, {
filter: `customerId = '${c.id.replace(/'/g, "\\'")}'`,
sort: '-created',
fields: 'year,make,model,vin,mileage',
});
if (vResult.items.length > 0) {
const v = vResult.items[0] as any;
vehicleInfo = [v.year, v.make, v.model].filter(Boolean).join(' ');
vin = v.vin || '';
mileage = v.mileage || '';
}
} catch {
// vehicles collection may not exist on older installs
}
// Fallback: look up vehicle info from recent quotes or ROs by customer name
if (!vehicleInfo && userId) {
const escapedName = (full.name || c.name || '').replace(/'/g, "\\'");
const nameFilter = `customerName = '${escapedName}' && userId = '${userId}'`;
// Try quotes
try {
const qResult = await pb.collection('quotes').getList(1, 1, {
filter: nameFilter,
sort: '-createdAt',
fields: 'vehicleInfo,vin,mileage,repairOrderNumber',
});
if (qResult.items.length > 0) {
const q = qResult.items[0] as any;
vehicleInfo = q.vehicleInfo || '';
vin = q.vin || '';
mileage = q.mileage || '';
roNumber = q.repairOrderNumber || '';
}
} catch { /* ignore */ }
// Try repair orders (only if still no vehicle info)
if (!vehicleInfo) {
try {
const roResult = await pb.collection('repairOrders').getList(1, 1, {
filter: nameFilter,
sort: '-createdAt',
fields: 'vehicleInfo,vin,mileage,roNumber',
});
if (roResult.items.length > 0) {
const ro = roResult.items[0] as any;
vehicleInfo = ro.vehicleInfo || '';
vin = ro.vin || '';
mileage = ro.mileage || '';
if (!roNumber) roNumber = ro.roNumber || '';
}
} catch { /* ignore */ }
}
}
const parts = (full.name || c.name || '').trim().split(/\s+/);
const middle = parts.length > 2 ? parts.slice(1, -1).join(' ') : '';
const last = parts.length > 1 ? parts[parts.length - 1] : '';
const current = useQuoteStore.getState().customerInfo;
const hasROOrigin = Boolean(current.repairOrderId);
setCustomerInfo({
customerId: c.id,
firstName: parts[0] || '',
middleName: middle,
lastName: last,
name: full.name || c.name || '',
phone: full.phone || c.phone || '',
email: full.email || '',
vehicleInfo: hasROOrigin ? current.vehicleInfo || vehicleInfo : vehicleInfo,
vin: hasROOrigin ? current.vin || vin : vin,
mileage: hasROOrigin ? current.mileage || mileage : mileage,
roNumber: hasROOrigin ? current.roNumber || roNumber : roNumber,
repairOrderId: current.repairOrderId,
});
} catch (e) {
logError('Failed to load customer record', e);
const parts = c.name.trim().split(/\s+/);
setCustomerInfo({
customerId: c.id,
firstName: parts[0] || '',
lastName: parts.slice(1).join(' ') || '',
name: c.name,
phone: c.phone,
});
}
setSearchQuery(c.name);
setShowDropdown(false);
setDuplicateWarning(null);
}, [setCustomerInfo]);
const clearCustomer = useCallback(() => {
setCustomerInfo({
customerId: undefined,
firstName: '',
lastName: '',
name: '',
});
setSearchQuery('');
setSearchResults([]);
setShowDropdown(false);
setDuplicateWarning(null);
}, [setCustomerInfo]);
// ── Duplicate detection ──
// When the user types first/last name or phone, check for matches
const checkDuplicates = useCallback(async (firstName: string, lastName: string, phone: string) => {
if (isLinked) return;
const nameQuery = `${firstName} ${lastName}`.trim();
const phoneDigits = phone.replace(/\D/g, '');
if (!nameQuery && phoneDigits.length < 4) {
setDuplicateWarning(null);
return;
}
try {
// Fetch all customers for this user and filter client-side
const result = await pb.collection('customers').getList(1, 500, {
fields: 'id,name,phone',
batch: 500,
});
const allCustomers = result.items as any[];
// Match by name or phone
const nameLc = nameQuery.toLowerCase();
const matched = allCustomers.filter((c: any) => {
const cName = (c.name || '').toLowerCase();
const cPhone = (c.phone || '').replace(/\D/g, '');
if (nameQuery && cName.includes(nameLc)) return true;
if (phoneDigits.length >= 4 && cPhone.includes(phoneDigits)) return true;
return false;
});
if (matched.length > 0) {
const best = matched[0] as any;
setDuplicateWarning({
customer: { id: best.id, name: best.name, phone: best.phone || '', vehicleCount: 0 },
message: nameQuery && best.name.toLowerCase().includes(nameLc) ? 'name' : 'phone',
});
} else {
setDuplicateWarning(null);
}
} catch {
setDuplicateWarning(null);
}
}, [isLinked]);
// Debounced duplicate check
const handleNameOrPhoneChange = useCallback((field: string, _val: string) => {
if (field === 'firstName' || field === 'lastName' || field === 'phone') {
if (dupeDebounceRef.current) clearTimeout(dupeDebounceRef.current);
// We'll check after the state updates — use a short delay
dupeDebounceRef.current = setTimeout(() => {
const state = useQuoteStore.getState().customerInfo;
checkDuplicates(state.firstName, state.lastName, state.phone);
}, 600);
}
}, [checkDuplicates]);
// Custom setter that also triggers duplicate check
const setInfo = useCallback((info: Partial<typeof customerInfo>) => {
setCustomerInfo(info);
handleNameOrPhoneChange(Object.keys(info)[0] || '', String(Object.values(info)[0] || ''));
}, [setCustomerInfo, handleNameOrPhoneChange]);
// ── Render helpers ──
const nameField = (key: 'firstName' | 'middleName' | 'lastName', label: string, placeholder: string, span: string) => {
const val = customerInfo[key] as string;
return (
<div className={span}>
<label className="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">{label}</label>
<input
value={val}
onChange={(e) => setInfo({ [key]: e.target.value })}
placeholder={placeholder}
disabled={isLinked}
className={`w-full rounded-lg border px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-green-500/20 disabled:cursor-not-allowed disabled:opacity-50 ${
isLinked
? 'border-green-200 bg-green-50 text-gray-500 dark:border-green-800 dark:bg-green-900/20 dark:text-gray-400'
: 'border-gray-300 bg-white text-gray-900 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100 placeholder-gray-400 focus:border-green-500'
}`}
/>
</div>
);
};
const field = (key: keyof typeof customerInfo, label: string, placeholder: string, span = 'col-span-1') => {
const isPhone = key === 'phone';
const val = customerInfo[key] as string;
const displayVal = isPhone ? formatPhoneInput(val) : val;
return (
<div className={span}>
<label className="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">{label}</label>
<input
value={displayVal}
onChange={(e) => setInfo({ [key]: isPhone ? e.target.value.replace(/\D/g, '') : e.target.value })}
placeholder={placeholder}
disabled={key === 'serviceAdvisor' && isDefaultAdvisor(val) && !val}
className={`w-full rounded-lg border px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-green-500/20 ${
key === 'serviceAdvisor' && isDefaultAdvisor(val)
? 'border-gray-200 bg-gray-50 text-gray-400 dark:border-gray-700 dark:bg-gray-800/50 dark:text-gray-500 focus:border-green-500'
: 'border-gray-300 bg-white text-gray-900 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100 placeholder-gray-400 focus:border-green-500'
}`}
/>
</div>
);
};
return (
<div className="rounded-xl border border-gray-200 bg-white p-5 shadow-sm dark:border-gray-800 dark:bg-gray-900">
<h2 className="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-4">Customer Information</h2>
{/* ── Customer Search / Selector ── */}
<div ref={searchRef} className="relative mb-4">
<div className="relative">
{isLinked ? (
<div className="flex items-center gap-2 rounded-lg border border-green-300 bg-green-50 px-3 py-2.5 dark:border-green-700 dark:bg-green-900/20">
<UserRound className="h-4 w-4 shrink-0 text-green-600 dark:text-green-400" />
<span className="flex-1 text-sm font-medium text-green-800 dark:text-green-300 truncate">
{customerInfo.name}
</span>
{customerInfo.phone && (
<span className="text-xs text-green-600 dark:text-green-400 shrink-0">
{formatPhoneInput(customerInfo.phone)}
</span>
)}
<button
onClick={clearCustomer}
className="rounded p-0.5 text-green-600 hover:bg-green-200 dark:text-green-400 dark:hover:bg-green-800/40"
title="Clear customer selection"
>
<X className="h-3.5 w-3.5" />
</button>
</div>
) : (
<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) => handleSearchInput(e.target.value)}
onFocus={() => { if (searchResults.length > 0) setShowDropdown(true); }}
placeholder="Search existing customers by name or phone..."
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 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"
/>
{searching && (
<div className="absolute right-3 top-1/2 h-4 w-4 -translate-y-1/2 animate-spin rounded-full border-2 border-green-500 border-t-transparent" />
)}
</div>
)}
</div>
{/* Dropdown */}
{showDropdown && searchResults.length > 0 && (
<div className="absolute left-0 right-0 top-full z-20 mt-1 max-h-56 overflow-y-auto rounded-lg border border-gray-200 bg-white shadow-lg dark:border-gray-700 dark:bg-gray-800">
{searchResults.map((c) => (
<button
key={c.id}
onClick={() => selectCustomer(c)}
className="flex w-full items-center gap-3 px-4 py-2.5 text-left text-sm hover:bg-gray-50 dark:hover:bg-gray-700/50 border-b border-gray-100 dark:border-gray-700 last:border-0"
>
<UserRound className="h-4 w-4 shrink-0 text-gray-400" />
<div className="flex-1 min-w-0">
<span className="font-medium text-gray-900 dark:text-gray-100">{c.name}</span>
{c.phone && (
<span className="ml-2 text-xs text-gray-500 dark:text-gray-400">
{formatPhoneInput(c.phone)}
</span>
)}
</div>
{c.vehicleCount > 0 && (
<span className="shrink-0 text-xs text-gray-400">{c.vehicleCount} vehicle{c.vehicleCount !== 1 ? 's' : ''}</span>
)}
</button>
))}
</div>
)}
</div>
{/* ── Duplicate Warning ── */}
{duplicateWarning && !isLinked && (
<div className="mb-4 flex items-start gap-3 rounded-lg border border-amber-200 bg-amber-50 p-3 dark:border-amber-800 dark:bg-amber-900/20">
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0 text-amber-600 dark:text-amber-400" />
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-amber-800 dark:text-amber-300">
Existing customer found
</p>
<p className="text-xs text-amber-700 dark:text-amber-400 mt-0.5">
{duplicateWarning.customer.name} {duplicateWarning.customer.phone && `(${formatPhoneInput(duplicateWarning.customer.phone)})`} already exists.
</p>
<div className="mt-2 flex gap-2">
<button
onClick={() => selectCustomer(duplicateWarning.customer)}
className="inline-flex items-center gap-1 rounded-lg bg-amber-600 px-3 py-1.5 text-xs font-medium text-white hover:bg-amber-700 transition-colors"
>
<Check className="h-3 w-3" /> Use Existing
</button>
<button
onClick={() => setDuplicateWarning(null)}
className="inline-flex items-center gap-1 rounded-lg border border-amber-300 bg-white px-3 py-1.5 text-xs font-medium text-amber-700 hover:bg-amber-50 transition-colors dark:border-amber-700 dark:bg-transparent dark:text-amber-400 dark:hover:bg-amber-900/20"
>
Create New
</button>
</div>
</div>
</div>
)}
{/* ── Name Fields (hidden when linked) ── */}
{!isLinked && (
<div className="grid grid-cols-1 sm:grid-cols-4 gap-4 mb-4">
{nameField('firstName', 'First Name *', 'John', 'sm:col-span-2')}
{nameField('middleName', 'Middle', '(optional)', 'sm:col-span-1')}
{nameField('lastName', 'Last Name *', 'Doe', 'sm:col-span-1')}
</div>
)}
{/* ── Other fields ── */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{field('phone', 'Phone', '(555) 555-5555')}
{field('vehicleInfo', 'Vehicle', '2020 Honda Civic')}
{field('vin', 'VIN', '1HGBH41JXMN109186')}
{field('mileage', 'Mileage', '45,000')}
{field('roNumber', 'RO #', 'RO-2024-001')}
{field('serviceAdvisor', roleLabel, `${roleLabel} name`, 'col-span-1 sm:col-span-2 lg:col-span-1')}
{showLocationFields && field('serviceLocation', 'Service Location', '123 Oak St, Knoxville, TN or driveway', 'col-span-1 sm:col-span-2')}
</div>
</div>
);
}
@@ -0,0 +1,543 @@
import { useState } from 'react';
import {
Download, Printer, Save, Image, Share2, Check, Copy, Loader2,
} from 'lucide-react';
import { useNavigate } from 'react-router-dom';
import { pb } from '../../lib/pocketbase';
import { useQuoteStore } from '../../store/quote';
import { useSettings } from '../../store/settings';
import { useToast } from '../ui/Toast';
import { downloadQuotePDF, printQuotePDF, generateQuotePDF } from '../../lib/pdf';
import { downloadPdfAsImages } from '../../lib/pdf-to-images';
import { computeQuoteTotals, computeSplitQuoteTotals } from '../../lib/totals';
import { quoteWriteSchema } from '../../schemas/quote';
import { sanitizeServices } from '../../lib/quoteHelpers';
import { logError } from '../../lib/userMessages';
import { openEmailDraft, openSmsDraft } from '../../lib/contactLinks';
import { syncApprovedQuoteServicesToRO } from '../../lib/quoteRoSync';
/* ── Helper: find or create a customer record in the customers collection ── */
async function ensureCustomerRecord(customerInfo: {
name: string; firstName: string; lastName: string; phone: string; email?: string;
}): Promise<string | undefined> {
if (!customerInfo.name.trim()) return undefined;
const userId = pb.authStore.model?.id;
if (!userId) return undefined;
// Check for existing customer by phone (strongest match)
const phoneDigits = customerInfo.phone.replace(/\D/g, '');
if (phoneDigits.length >= 4) {
try {
const existing = await pb.collection('customers').getFirstListItem(
`phone ~ "${phoneDigits}" && userId = "${userId}"`,
{ fields: 'id', batch: 1 }
);
if (existing) return existing.id;
} catch { /* not found — proceed to create */ }
}
// Check by name
const nameQuery = customerInfo.name.replace(/"/g, '');
try {
const existing = await pb.collection('customers').getFirstListItem(
`name = "${nameQuery}" && userId = "${userId}"`,
{ fields: 'id', batch: 1 }
);
if (existing) return existing.id;
} catch { /* not found — proceed to create */ }
// Create new customer record
try {
const created = await pb.collection('customers').create({
name: customerInfo.name,
firstName: customerInfo.firstName || customerInfo.name.split(' ')[0] || '',
lastName: customerInfo.lastName || customerInfo.name.split(' ').slice(1).join(' ') || '',
phone: customerInfo.phone,
email: customerInfo.email || '',
address: '',
notes: '',
userId,
});
return created.id;
} catch (err) {
logError('Failed to create customer record', err);
return undefined;
}
}
export function QuoteSummary({ editId, repairOriginId }: { editId?: string | null; repairOriginId?: string | null }) {
const { services, discount, setDiscount, customerInfo } = useQuoteStore();
const [saving, setSaving] = useState(false);
const [generating, setGenerating] = useState(false);
const [sharing, setSharing] = useState(false);
const [sendingNotify, setSendingNotify] = useState<'email' | 'sms' | null>(null);
const [shareUrl, setShareUrl] = useState<string | null>(null);
const navigate = useNavigate();
const { showToast } = useToast();
const settings = useSettings();
const hasApproved = services.some((s) => s.customerDecision === 'approved');
const hasPending = services.some((s) => s.customerDecision === 'pending');
const hasMixed = hasApproved && hasPending;
const t = computeQuoteTotals({ services, discount, settings });
const split = hasMixed ? computeSplitQuoteTotals(services, discount, settings) : null;
const sub = t.subtotal;
const discVal = t.discountAmount;
const shopCharge = t.shopCharge;
const tax = t.tax;
const grandTotal = t.total;
const originRepairOrderId = customerInfo.repairOrderId || repairOriginId || '';
const handleSave = async () => {
setSaving(true);
try {
// Auto-create or link customer record
let customerId = customerInfo.customerId;
if (!customerId) {
customerId = await ensureCustomerRecord({
name: customerInfo.name,
firstName: customerInfo.firstName,
lastName: customerInfo.lastName,
phone: customerInfo.phone,
email: customerInfo.email,
});
if (customerId) {
useQuoteStore.getState().setCustomerInfo({ customerId });
}
}
const data: Record<string, any> = {
userId: pb.authStore.model?.id,
customerId: customerId || '',
customerName: customerInfo.name,
customerPhone: customerInfo.phone,
vehicleInfo: customerInfo.vehicleInfo,
vin: customerInfo.vin,
mileage: customerInfo.mileage,
repairOrderNumber: customerInfo.roNumber,
serviceAdvisor: customerInfo.serviceAdvisor,
services: sanitizeServices(services),
discountValue: discount.value,
discountType: discount.type,
subtotal: sub,
tax,
total: grandTotal,
status: 'draft',
lastUpdated: new Date().toISOString(),
...(originRepairOrderId ? { repairOrderId: originRepairOrderId } : {}),
};
const parsed = quoteWriteSchema.safeParse(data);
if (!parsed.success) {
console.warn('Quote schema validation failed:', JSON.stringify(parsed.error.issues, null, 2));
showToast(parsed.error.issues[0].message, 'error');
setSaving(false);
return;
}
if (editId) {
await pb.collection('quotes').update(editId, data);
showToast('Quote updated successfully', 'success');
} else {
data.createdAt = new Date().toISOString();
await pb.collection('quotes').create(data);
showToast('Quote saved successfully', 'success');
}
// ── Sync approved quote services back to originating RO ──
if (originRepairOrderId) {
try {
const syncedCount = await syncApprovedQuoteServicesToRO({ pb, repairOrderId: originRepairOrderId, quoteServices: services, settings });
if (syncedCount > 0) showToast(`Synced ${syncedCount} approved service(s) to RO`, 'success');
} catch (err) {
logError('Failed to sync approved services to RO', err);
showToast('Sync to RO failed: ' + String(err), 'error');
}
}
navigate('/');
} catch (err) {
logError('Failed to save quote', err);
showToast('Quote could not be saved. Please check the customer and services, then try again.', 'error');
} finally {
setSaving(false);
}
};
const handlePdf = async (mode: 'download' | 'print' | 'images') => {
setGenerating(true);
try {
const quote = { id: '', customerInfo, services, discount, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), status: 'draft' as const };
if (mode === 'download') await downloadQuotePDF(quote as any, settings);
else if (mode === 'print') await printQuotePDF(quote as any, settings);
else {
const blob = await generateQuotePDF(quote as any, settings);
await downloadPdfAsImages(blob, customerInfo.name || 'Customer');
}
} catch (err: any) {
logError('Failed to export quote', err);
showToast('Quote could not be exported. Please try again.', 'error');
} finally {
setGenerating(false);
}
};
const ensureShareToken = async (): Promise<{ quoteId: string; token: string; url: string } | null> => {
try {
let quoteId = editId;
if (!quoteId) {
// Ensure customer record exists first
const customerId = customerInfo.customerId || (await ensureCustomerRecord({
name: customerInfo.name,
firstName: customerInfo.firstName,
lastName: customerInfo.lastName,
phone: customerInfo.phone,
email: customerInfo.email,
})) || undefined;
const data: Record<string, any> = {
userId: pb.authStore.model?.id,
customerId: customerId || '',
customerName: customerInfo.name,
customerPhone: customerInfo.phone,
vehicleInfo: customerInfo.vehicleInfo,
vin: customerInfo.vin,
mileage: customerInfo.mileage,
repairOrderNumber: customerInfo.roNumber,
serviceAdvisor: customerInfo.serviceAdvisor,
services: sanitizeServices(services),
discountValue: discount.value,
discountType: discount.type,
subtotal: sub,
tax,
total: grandTotal,
status: 'sent',
lastUpdated: new Date().toISOString(),
...(originRepairOrderId ? { repairOrderId: originRepairOrderId } : {}),
createdAt: new Date().toISOString(),
};
const parsed = quoteWriteSchema.safeParse(data);
if (!parsed.success) {
console.warn('Quote schema validation failed in ensureShareToken:', JSON.stringify(parsed.error.issues, null, 2));
showToast(parsed.error.issues[0].message, 'error');
return null;
}
const created = await pb.collection('quotes').create(data);
quoteId = created.id;
// ── Sync approved quote services back to originating RO ──
if (originRepairOrderId) {
try {
const syncedCount = await syncApprovedQuoteServicesToRO({ pb, repairOrderId: originRepairOrderId, quoteServices: services, settings });
if (syncedCount > 0) showToast(`Synced ${syncedCount} approved service(s) to RO`, 'success');
} catch (err) {
logError('Failed to sync approved services to RO on share', err);
}
}
} else {
const data: Record<string, any> = {
userId: pb.authStore.model?.id,
customerId: customerInfo.customerId || '',
customerName: customerInfo.name,
customerPhone: customerInfo.phone,
vehicleInfo: customerInfo.vehicleInfo,
vin: customerInfo.vin,
mileage: customerInfo.mileage,
repairOrderNumber: customerInfo.roNumber,
serviceAdvisor: customerInfo.serviceAdvisor,
services: sanitizeServices(services),
discountValue: discount.value,
discountType: discount.type,
subtotal: sub,
tax,
total: grandTotal,
status: 'sent',
lastUpdated: new Date().toISOString(),
...(originRepairOrderId ? { repairOrderId: originRepairOrderId } : {}),
};
const parsed = quoteWriteSchema.safeParse(data);
if (!parsed.success) {
console.warn('Quote schema validation failed in ensureShareToken update:', JSON.stringify(parsed.error.issues, null, 2));
showToast(parsed.error.issues[0].message, 'error');
return null;
}
await pb.collection('quotes').update(quoteId, data);
if (originRepairOrderId) {
try {
const syncedCount = await syncApprovedQuoteServicesToRO({ pb, repairOrderId: originRepairOrderId, quoteServices: services, settings });
if (syncedCount > 0) showToast(`Synced ${syncedCount} approved service(s) to RO`, 'success');
} catch (err) {
logError('Failed to sync approved services to RO on share update', err);
}
}
}
const record = await pb.collection('quotes').getOne(quoteId);
let token = (record as any).shareToken;
if (!token) {
token = crypto.randomUUID();
await pb.collection('quotes').update(quoteId, {
shareToken: token,
lastUpdated: new Date().toISOString(),
});
}
const url = `${window.location.origin}/quote/approve/${token}`;
return { quoteId, token, url };
} catch (err) {
logError('Failed to ensure share token', err);
return null;
}
};
const handleShare = async () => {
if (!customerInfo.name || services.length === 0) return;
setSharing(true);
setShareUrl(null);
try {
const result = await ensureShareToken();
if (!result) {
showToast('Could not create share link. Please try again.', 'error');
return;
}
await navigator.clipboard.writeText(result.url);
setShareUrl(result.url);
showToast('Share link copied to clipboard', 'success');
} finally {
setSharing(false);
}
};
const handleSendEmail = async () => {
if (!customerInfo.name || services.length === 0) return;
setSendingNotify('email');
try {
const result = await ensureShareToken();
if (!result) return;
const to = customerInfo.phone;
const subject = `Service Estimate for ${customerInfo.vehicleInfo || 'your vehicle'}`;
const body = [
`Hi ${customerInfo.name},`,
``,
`Here is the link to review and approve your service estimate:`,
result.url,
``,
`Please review each service and submit your decisions online.`,
].join('\n');
openEmailDraft(to, subject, body);
showToast('Opened email draft.', 'success');
} finally {
setSendingNotify(null);
}
};
const handleSendSms = async () => {
if (!customerInfo.name || services.length === 0) return;
setSendingNotify('sms');
try {
const result = await ensureShareToken();
if (!result) return;
const to = customerInfo.phone;
const body = `Hi ${customerInfo.name}, review and approve your service estimate here: ${result.url}`;
openSmsDraft(to, body);
showToast('Opened SMS draft.', 'success');
} finally {
setSendingNotify(null);
}
};
return (
<div className="rounded-xl border border-gray-200 bg-gray-50 dark:border-gray-700 dark:bg-gray-800/50 p-5">
<h3 className="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-4">Quote Summary</h3>
<div className="space-y-2 text-sm">
{split ? (
<div className="space-y-3">
{/* ── Approved Service Totals ── */}
<div className="rounded-lg border border-green-200 bg-green-50/60 dark:border-green-800 dark:bg-green-900/15 px-3 py-2.5">
<p className="text-xs font-bold uppercase tracking-wider text-green-700 dark:text-green-400 mb-2">Already Approved Work</p>
<div className="space-y-1 text-xs">
<div className="flex justify-between">
<span className="text-gray-500 dark:text-gray-400">Subtotal</span>
<span className="font-medium text-gray-900 dark:text-gray-100">${split.approved.subtotal.toFixed(2)}</span>
</div>
{split.approved.shopCharge > 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">${split.approved.shopCharge.toFixed(2)}</span>
</div>
)}
{settings.taxRate > 0 && (
<div className="flex justify-between">
<span className="text-gray-500 dark:text-gray-400">Tax ({settings.taxRate}%)</span>
<span className="text-gray-900 dark:text-gray-100">${split.approved.tax.toFixed(2)}</span>
</div>
)}
<div className="border-t border-green-200 dark:border-green-700 pt-1 mt-1 flex justify-between font-semibold text-green-700 dark:text-green-400">
<span>Authorized Total</span>
<span>${split.approved.total.toFixed(2)}</span>
</div>
</div>
</div>
{/* ── Additional Recommendations ── */}
<div className="rounded-lg border border-blue-200 bg-blue-50/60 dark:border-blue-800 dark:bg-blue-900/15 px-3 py-2.5">
<p className="text-xs font-bold uppercase tracking-wider text-blue-700 dark:text-blue-400 mb-2">Additional Recommendations</p>
<div className="space-y-1 text-xs">
<div className="flex justify-between">
<span className="text-gray-500 dark:text-gray-400">Subtotal</span>
<span className="font-medium text-gray-900 dark:text-gray-100">${split.pending.subtotal.toFixed(2)}</span>
</div>
{split.pending.shopCharge > 0 && (
<div className="flex justify-between">
<span className="text-gray-500 dark:text-gray-400">Estimated Shop Charge</span>
<span className="text-gray-900 dark:text-gray-100">${split.pending.shopCharge.toFixed(2)}</span>
</div>
)}
{settings.taxRate > 0 && (
<div className="flex justify-between">
<span className="text-gray-500 dark:text-gray-400">Tax ({settings.taxRate}%)</span>
<span className="text-gray-900 dark:text-gray-100">${split.pending.tax.toFixed(2)}</span>
</div>
)}
<div className="border-t border-blue-200 dark:border-blue-700 pt-1 mt-1 flex justify-between font-semibold text-blue-700 dark:text-blue-400">
<span>Add-On Total If Approved</span>
<span>${split.pending.total.toFixed(2)}</span>
</div>
</div>
</div>
{/* ── Combined Total if All Approved ── */}
<div className="rounded-lg border border-gray-200 bg-white px-3 py-2.5 dark:border-gray-700 dark:bg-gray-800">
<p className="mb-2 text-xs font-bold uppercase tracking-wider text-gray-700 dark:text-gray-300">Total If All Work Is Approved</p>
<div className="space-y-1 text-xs">
<div className="flex justify-between">
<span className="text-gray-500 dark:text-gray-400">All Services Subtotal</span>
<span className="font-medium text-gray-900 dark:text-gray-100">${split.combined.subtotal.toFixed(2)}</span>
</div>
{split.combined.discountAmount > 0 && (
<div className="flex justify-between text-green-600 dark:text-green-400">
<span>Discount</span>
<span>-${split.combined.discountAmount.toFixed(2)}</span>
</div>
)}
{split.combined.shopCharge > 0 && (
<div className="flex justify-between">
<span className="text-gray-500 dark:text-gray-400">Shop Charge, capped at ${settings.shopChargeCap.toFixed(2)}</span>
<span className="text-gray-900 dark:text-gray-100">${split.combined.shopCharge.toFixed(2)}</span>
</div>
)}
{settings.taxRate > 0 && (
<div className="flex justify-between">
<span className="text-gray-500 dark:text-gray-400">Tax @ {settings.taxRate}%</span>
<span className="text-gray-900 dark:text-gray-100">${split.combined.tax.toFixed(2)}</span>
</div>
)}
<div className="border-t border-gray-200 pt-1.5 mt-1.5 flex justify-between font-semibold dark:border-gray-700">
<span className="text-gray-900 dark:text-white">Total If Approved</span>
<span className="text-green-600 dark:text-green-400">${split.combined.total.toFixed(2)}</span>
</div>
</div>
</div>
</div>
) : (
<div className="flex justify-between">
<span className="text-gray-500 dark:text-gray-400">Subtotal</span>
<span className="font-medium text-gray-900 dark:text-gray-100">${sub.toFixed(2)}</span>
</div>
)}
{/* ── Discount ── */}
<div className="flex items-center justify-between gap-2">
<span className="text-gray-500 dark:text-gray-400 shrink-0">Discount</span>
<div className="flex items-center gap-1">
<select
value={discount.type}
onChange={(e) => setDiscount(discount.value, e.target.value as any)}
className="text-xs rounded border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-600 dark:text-gray-300 py-0.5 px-1"
>
<option value="dollar">$</option>
<option value="percent">%</option>
</select>
<input
type="number"
value={discount.value || ''}
onChange={(e) => setDiscount(Number(e.target.value) || 0, discount.type)}
placeholder="0"
className="w-16 text-right rounded border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 px-2 py-0.5 text-xs text-gray-900 dark:text-gray-100"
/>
</div>
</div>
{!split && (
<>
{discVal > 0 && (
<div className="flex justify-between text-green-600"><span></span><span>${discVal.toFixed(2)}</span></div>
)}
{shopCharge > 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">${shopCharge.toFixed(2)}</span></div>
)}
{settings.taxRate > 0 && (
<div className="flex justify-between"><span className="text-gray-500 dark:text-gray-400">Tax ({settings.taxRate}%)</span><span className="text-gray-900 dark:text-gray-100">${tax.toFixed(2)}</span></div>
)}
<div className="border-t border-gray-200 dark:border-gray-600 pt-2 mt-2 flex justify-between">
<span className="font-semibold text-gray-900 dark:text-white">TOTAL</span>
<span className="font-bold text-lg text-green-600 dark:text-green-400">${grandTotal.toFixed(2)}</span>
</div>
</>
)}
</div>
<div className="mt-5 space-y-2">
<button onClick={() => handlePdf('download')} disabled={generating || services.length === 0} className="w-full flex items-center justify-center gap-2 rounded-lg bg-green-600 px-4 py-2.5 text-sm font-semibold text-white hover:bg-green-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors">
<Download className="h-4 w-4" /> {generating ? 'Generating...' : 'Download PDF'}
</button>
<button onClick={() => handlePdf('print')} disabled={generating || services.length === 0} className="w-full flex items-center justify-center gap-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 px-4 py-2.5 text-sm 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">
<Printer className="h-4 w-4" /> Print
</button>
<button onClick={() => handlePdf('images')} disabled={generating || services.length === 0} className="w-full flex items-center justify-center gap-2 rounded-lg border border-blue-300 dark:border-blue-700 bg-blue-50 dark:bg-blue-900/20 px-4 py-2.5 text-sm font-medium text-blue-700 dark:text-blue-400 hover:bg-blue-100 dark:hover:bg-blue-900/30 disabled:opacity-50 disabled:cursor-not-allowed transition-colors">
<Image className="h-4 w-4" /> {generating ? 'Generating...' : 'Export as Images'}
</button>
<button onClick={handleSave} disabled={saving || !customerInfo.name} className="w-full flex items-center justify-center gap-2 rounded-lg border border-green-300 dark:border-green-700 bg-green-50 dark:bg-green-900/20 px-4 py-2.5 text-sm font-medium text-green-700 dark:text-green-400 hover:bg-green-100 dark:hover:bg-green-900/30 disabled:opacity-50 disabled:cursor-not-allowed transition-colors">
<Save className="h-4 w-4" /> {saving ? 'Saving...' : 'Save Quote'}
</button>
<button
onClick={handleShare}
disabled={sharing || !customerInfo.name || services.length === 0}
className="w-full flex items-center justify-center gap-2 rounded-lg border border-purple-300 dark:border-purple-700 bg-purple-50 dark:bg-purple-900/20 px-4 py-2.5 text-sm 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"
>
<Share2 className="h-4 w-4" /> {sharing ? 'Creating Link...' : 'Share with Customer'}
</button>
{shareUrl && (
<div className="rounded-lg border border-purple-200 bg-purple-50/50 dark:border-purple-800 dark:bg-purple-900/10 px-3 py-2">
<div className="flex items-center gap-2">
<Check className="h-3.5 w-3.5 text-purple-600 dark:text-purple-400 shrink-0" />
<p className="text-xs text-purple-700 dark:text-purple-300 break-all">{shareUrl}</p>
</div>
<div className="mt-2 flex gap-2">
<button
onClick={handleSendEmail}
disabled={sendingNotify !== null}
className="flex items-center justify-center gap-1.5 rounded-md border border-green-300 dark:border-green-700 bg-green-50 dark:bg-green-900/20 px-3 py-1.5 text-xs font-medium text-green-700 dark:text-green-400 hover:bg-green-100 dark:hover:bg-green-900/30 disabled:opacity-50 transition-colors flex-1"
>
{sendingNotify === 'email' ? <Loader2 className="h-3 w-3 animate-spin" /> : null}
Send Email
</button>
<button
onClick={handleSendSms}
disabled={sendingNotify !== null}
className="flex items-center justify-center gap-1.5 rounded-md border border-blue-300 dark:border-blue-700 bg-blue-50 dark:bg-blue-900/20 px-3 py-1.5 text-xs font-medium text-blue-700 dark:text-blue-400 hover:bg-blue-100 dark:hover:bg-blue-900/30 disabled:opacity-50 transition-colors flex-1"
>
{sendingNotify === 'sms' ? <Loader2 className="h-3 w-3 animate-spin" /> : null}
Send Text
</button>
</div>
<button
onClick={() => { navigator.clipboard.writeText(shareUrl); showToast('Link copied', 'success'); }}
className="mt-1.5 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 transition-colors"
>
<Copy className="h-3 w-3" /> Copy again
</button>
</div>
)}
</div>
</div>
);
}
@@ -0,0 +1,9 @@
import { RECENT_STATUS_COLORS, RECENT_STATUS_LABELS } from './types';
export function RecentStatusBadge({ status }: { status: string }) {
return (
<span className={`inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium ${RECENT_STATUS_COLORS[status] || RECENT_STATUS_COLORS.draft}`}>
{RECENT_STATUS_LABELS[status] || status}
</span>
);
}
@@ -0,0 +1,365 @@
import { memo } from 'react';
import { Trash2, Sparkles, Loader2 } from 'lucide-react';
import { PRIORITY_COLORS } from './types';
import { useSettings } from '../../store/settings';
import type { QuoteAuthorizationMethod, QuoteService } from '../../types';
interface ServiceRowProps {
service: QuoteService;
expanded: boolean;
aiLoading: boolean;
customerName: string;
authorizationMethod: QuoteAuthorizationMethod;
onToggleExpand: () => void;
onAiWrite: () => void;
onToggleApproved: () => void;
onToggleDeclined: () => void;
onRemove: () => void;
onUpdate: (updates: Partial<QuoteService>) => void;
}
export const ServiceRow = memo(function ServiceRow({
service,
expanded,
aiLoading,
customerName,
authorizationMethod,
onToggleExpand,
onAiWrite,
onToggleApproved,
onToggleDeclined,
onRemove,
onUpdate,
}: ServiceRowProps) {
const settings = useSettings();
return (
<div className={`rounded-lg transition-colors ${!service.approved && service.customerDecision === 'declined' ? 'opacity-50' : ''}`}>
<div className="flex items-center gap-3 px-4 py-2.5 hover:bg-gray-50 dark:hover:bg-gray-800/50 rounded-lg">
<button
onClick={onToggleApproved}
className={`shrink-0 w-5 h-5 rounded border-2 flex items-center justify-center transition-colors ${
service.approved ? 'bg-green-600 border-green-600 text-white' : 'border-gray-300 dark:border-gray-600 text-gray-300 dark:text-gray-600'
}`}
>
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={3} d="M5 13l4 4L19 7" /></svg>
</button>
<button
onClick={onToggleDeclined}
className={`shrink-0 w-5 h-5 rounded border-2 flex items-center justify-center transition-colors ${
service.customerDecision === 'declined' ? 'bg-red-600 border-red-600 text-white' : 'border-gray-300 dark:border-gray-600 text-gray-300 dark:text-gray-600'
}`}
>
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={3} d="M6 6l12 12M18 6L6 18" /></svg>
</button>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-gray-900 dark:text-gray-100 truncate">{service.name}</p>
</div>
<span
onClick={onToggleExpand}
className="text-sm font-semibold text-green-600 dark:text-green-400 whitespace-nowrap cursor-pointer hover:underline"
title="Click to edit"
>${service.price.toFixed(2)}</span>
<button
onClick={onAiWrite}
disabled={aiLoading}
title="AI Write Explanation"
className="shrink-0 p-1.5 rounded-lg text-purple-500 hover:text-purple-600 hover:bg-purple-50 dark:hover:bg-purple-900/20 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{aiLoading ? <Loader2 className="h-4 w-4 animate-spin" /> : <Sparkles className="h-4 w-4" />}
</button>
<button
onClick={onToggleExpand}
className={`shrink-0 p-1.5 rounded-lg text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors ${expanded ? 'rotate-180' : ''}`}
>
<svg className="h-3 w-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" /></svg>
</button>
<button onClick={onRemove} className="shrink-0 p-1.5 rounded-lg text-gray-400 hover:text-red-500 hover:bg-red-50 dark:hover:bg-red-900/20 transition-colors">
<Trash2 className="h-4 w-4" />
</button>
</div>
{expanded && (
<div className="px-4 pb-3 pl-12 space-y-2">
{/* Row 1: Priority + Pricing Mode + Price + Shop Charge */}
<div className="flex gap-2 items-center flex-wrap">
{service.priority && (
<span className={`inline-block text-xs font-semibold px-2 py-0.5 rounded-full border ${PRIORITY_COLORS[service.priority] || PRIORITY_COLORS.MAINTENANCE}`}>
{service.priority.replace(/_/g, ' ')}
</span>
)}
{/* Pricing mode toggle */}
<div className="flex rounded-lg border border-gray-300 dark:border-gray-600 overflow-hidden text-xs">
<button
type="button"
onClick={() => {
if (service.pricingMode !== 'full') {
onUpdate({ pricingMode: 'full' });
}
}}
className={`px-2.5 py-1 font-medium transition-colors ${
(!service.pricingMode || service.pricingMode === 'full')
? 'bg-blue-600 text-white'
: 'bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-400 hover:bg-gray-200 dark:hover:bg-gray-600'
}`}
>
Full
</button>
<button
type="button"
onClick={() => {
if (service.pricingMode !== 'split') {
const rate = settings.defaultLaborRate || 95;
const hours = rate > 0 ? Math.round((service.price / rate) * 100) / 100 : 0;
onUpdate({ pricingMode: 'split', laborHours: hours, laborRate: rate, partsCost: 0 });
}
}}
className={`px-2.5 py-1 font-medium transition-colors ${
service.pricingMode === 'split'
? 'bg-blue-600 text-white'
: 'bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-400 hover:bg-gray-200 dark:hover:bg-gray-600'
}`}
>
Split
</button>
</div>
{service.pricingMode === 'split' ? (
<>
<div className="flex items-center gap-1">
<label className="text-xs text-gray-500 dark:text-gray-400">Hrs</label>
<input
type="number"
min="0"
step="0.1"
value={service.laborHours ?? ''}
onChange={(e) => {
const hrs = parseFloat(e.target.value) || 0;
const rate = service.laborRate || settings.defaultLaborRate || 95;
const parts = service.partsCost || 0;
onUpdate({ laborHours: hrs, price: Math.round((hrs * rate + parts) * 100) / 100 });
}}
className="w-14 rounded border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 px-2 py-1 text-sm text-gray-900 dark:text-gray-100 focus:border-green-500 focus:ring-1 focus:ring-green-500/20 focus:outline-none"
/>
</div>
<span className="text-xs text-gray-400">×</span>
<div className="flex items-center gap-1">
<label className="text-xs text-gray-500 dark:text-gray-400">$</label>
<input
type="number"
min="0"
step="1"
value={service.laborRate ?? ''}
onChange={(e) => {
const rate = parseFloat(e.target.value) || 0;
const hrs = service.laborHours || 0;
const parts = service.partsCost || 0;
onUpdate({ laborRate: rate, price: Math.round((hrs * rate + parts) * 100) / 100 });
}}
className="w-16 rounded border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 px-2 py-1 text-sm text-gray-900 dark:text-gray-100 focus:border-green-500 focus:ring-1 focus:ring-green-500/20 focus:outline-none"
/>
</div>
<span className="text-xs text-gray-400">+</span>
<div className="flex items-center gap-1">
<label className="text-xs text-gray-500 dark:text-gray-400">Parts</label>
<input
type="number"
min="0"
step="0.01"
value={service.partsCost ?? ''}
onChange={(e) => {
const parts = parseFloat(e.target.value) || 0;
const hrs = service.laborHours || 0;
const rate = service.laborRate || settings.defaultLaborRate || 95;
onUpdate({ partsCost: parts, price: Math.round((hrs * rate + parts) * 100) / 100 });
}}
className="w-20 rounded border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 px-2 py-1 text-sm text-gray-900 dark:text-gray-100 focus:border-green-500 focus:ring-1 focus:ring-green-500/20 focus:outline-none"
/>
</div>
<span className="text-xs font-semibold text-green-600 dark:text-green-400">
= ${service.price.toFixed(2)}
</span>
</>
) : (
<div className="flex items-center gap-1">
<label className="text-xs text-gray-500 dark:text-gray-400">$</label>
<input
type="number"
min="0"
step="0.01"
value={service.price}
onChange={(e) => onUpdate({ price: parseFloat(e.target.value) || 0 })}
className="w-24 rounded border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 px-2 py-1 text-sm text-gray-900 dark:text-gray-100 focus:border-green-500 focus:ring-1 focus:ring-green-500/20 focus:outline-none"
/>
</div>
)}
{/* Shop charge toggle */}
<label className="flex items-center gap-1.5 text-xs text-gray-600 dark:text-gray-400 cursor-pointer select-none">
<input
type="checkbox"
checked={service.applyShopCharge !== false}
onChange={(e) => onUpdate({ applyShopCharge: e.target.checked })}
className="h-3.5 w-3.5 rounded border-gray-300 text-green-600 focus:ring-green-500"
/>
Shop charge
</label>
{service.customerDecision === 'approved' && (
<span className="text-xs bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-300 px-2 py-0.5 rounded-full font-medium">Approved</span>
)}
{service.customerDecision === 'declined' && (
<span className="text-xs bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-300 px-2 py-0.5 rounded-full font-medium">Declined</span>
)}
</div>
{/* Recommendation */}
<input
value={service.recommendation || ''}
onChange={(e) => onUpdate({ recommendation: e.target.value })}
placeholder="Recommendation reason (e.g., worn to 3mm, due at 60K)"
className="w-full rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 px-3 py-2 text-sm text-gray-900 dark:text-gray-100 placeholder-gray-400 focus:border-green-500 focus:ring-2 focus:ring-green-500/20 focus:outline-none"
/>
{/* Quick Parts Toggle */}
<div className="pt-1">
<label className="text-xs font-medium text-gray-600 dark:text-gray-400 block mb-1.5">Quick Parts Toggle:</label>
<div className="flex flex-wrap gap-1.5">
<button
type="button"
onClick={() => onUpdate({ noPartsRequired: !service.noPartsRequired, partsNotInStock: false, aftermarketAvailable: false })}
className={`px-2 py-1 text-xs rounded border font-medium transition-colors ${service.noPartsRequired ? 'bg-gray-600 text-white border-gray-600' : 'bg-white text-gray-800 border-gray-300 hover:bg-gray-50 dark:bg-gray-800 dark:text-gray-300 dark:border-gray-600 dark:hover:bg-gray-700'}`}
>Labor Only</button>
<button
type="button"
onClick={() => onUpdate({ noPartsRequired: false, partsNotInStock: false, aftermarketAvailable: false })}
className={`px-2 py-1 text-xs rounded border font-medium transition-colors ${!service.noPartsRequired && !service.partsNotInStock && !service.aftermarketAvailable ? 'bg-blue-600 text-white border-blue-600' : 'bg-white text-gray-800 border-gray-300 hover:bg-blue-50 dark:bg-gray-800 dark:text-gray-300 dark:border-gray-600 dark:hover:bg-blue-900/30'}`}
>In Stock</button>
<button
type="button"
onClick={() => onUpdate({ partsNotInStock: !service.partsNotInStock, noPartsRequired: false, aftermarketAvailable: false })}
className={`px-2 py-1 text-xs rounded border font-medium transition-colors ${service.partsNotInStock ? 'bg-orange-600 text-white border-orange-600' : 'bg-white text-gray-800 border-gray-300 hover:bg-orange-50 dark:bg-gray-800 dark:text-gray-300 dark:border-gray-600 dark:hover:bg-orange-900/30'}`}
>Need Order</button>
<button
type="button"
onClick={() => onUpdate({ aftermarketAvailable: !service.aftermarketAvailable, noPartsRequired: false, partsNotInStock: false })}
className={`px-2 py-1 text-xs rounded border font-medium transition-colors ${service.aftermarketAvailable ? 'bg-green-600 text-white border-green-600' : 'bg-white text-gray-800 border-gray-300 hover:bg-green-50 dark:bg-gray-800 dark:text-gray-300 dark:border-gray-600 dark:hover:bg-green-900/30'}`}
>Aftermarket</button>
</div>
</div>
{/* Parts Status Visual Display */}
{service.noPartsRequired ? (
<div className="p-3 bg-gray-50 dark:bg-gray-900/20 rounded-md border border-gray-200 dark:border-gray-800">
<p className="text-sm font-bold text-gray-700 dark:text-gray-300">Labor-Only Service</p>
<p className="text-xs text-gray-600 dark:text-gray-400">No parts required for this service.</p>
</div>
) : service.aftermarketAvailable ? (
<div className="p-3 bg-green-50 dark:bg-green-900/20 rounded-md border border-green-200 dark:border-green-800">
<p className="text-sm font-bold text-green-700 dark:text-green-300">Aftermarket Parts Will Be Used</p>
{service.aftermarketPartsList && (
<p className="text-sm text-green-600 dark:text-green-400 mt-1"><strong>Parts:</strong> {service.aftermarketPartsList}</p>
)}
<p className="text-xs text-green-600 dark:text-green-400 mt-1">Aftermarket parts will be used for this service.</p>
<div className="mt-2">
<label className="text-xs text-green-600 dark:text-green-400">Parts List:</label>
<input
type="text"
value={service.aftermarketPartsList || ''}
onChange={(e) => onUpdate({ aftermarketPartsList: e.target.value })}
placeholder="e.g., Duralast Gold Ceramic Pads, Cardone Reman Calipers"
className="w-full mt-1 rounded border border-green-300 dark:border-green-700 bg-white dark:bg-gray-800 px-2 py-1.5 text-xs text-gray-900 dark:text-gray-100 focus:border-green-500 focus:ring-1 focus:ring-green-500/20 focus:outline-none"
/>
</div>
</div>
) : service.partsNotInStock ? (
<div className="p-3 bg-orange-50 dark:bg-orange-900/20 rounded-md border border-orange-200 dark:border-orange-800">
<p className="text-sm font-bold text-orange-700 dark:text-orange-300">Parts Status: Need to Order</p>
{service.partsDeliveryTime && (
<p className="text-sm text-orange-600 dark:text-orange-400 mb-1"><strong>Estimated Delivery:</strong> {service.partsDeliveryTime}</p>
)}
<p className="text-xs text-orange-600 dark:text-orange-400">Parts for this service are not currently in stock and will need to be ordered.</p>
<div className="mt-2">
<label className="text-xs text-orange-600 dark:text-orange-400">Estimated Delivery Time:</label>
<input
type="text"
value={service.partsDeliveryTime || ''}
onChange={(e) => onUpdate({ partsDeliveryTime: e.target.value })}
placeholder="e.g., 2-3 business days"
className="w-full mt-1 rounded border border-orange-300 dark:border-orange-700 bg-white dark:bg-gray-800 px-2 py-1.5 text-xs text-gray-900 dark:text-gray-100 focus:border-orange-500 focus:ring-1 focus:ring-orange-500/20 focus:outline-none"
/>
</div>
</div>
) : (
<div className="p-3 bg-blue-50 dark:bg-blue-900/20 rounded-md border border-blue-200 dark:border-blue-800">
<p className="text-sm font-bold text-blue-700 dark:text-blue-300">Parts In Stock</p>
<p className="text-xs text-blue-600 dark:text-blue-400">All required parts are available and ready for installation.</p>
</div>
)}
{/* Customer Explanation */}
<textarea
value={service.explanation || ''}
onChange={(e) => onUpdate({ explanation: e.target.value })}
placeholder="AI-generated explanation will appear here. Click the Sparkles icon to generate."
rows={3}
className="w-full rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 px-3 py-2 text-sm text-gray-900 dark:text-gray-100 placeholder-gray-400 focus:border-green-500 focus:ring-2 focus:ring-green-500/20 focus:outline-none resize-y"
/>
{/* Customer Decision */}
<div className="pt-1 border-t border-gray-200 dark:border-gray-700">
<label className="text-xs font-medium text-gray-600 dark:text-gray-400 block mb-1.5">Customer Decision:</label>
<div className="flex flex-wrap gap-1.5">
<button
type="button"
onClick={() => onUpdate({ customerDecision: 'approved', approved: true, authorizedBy: service.authorizedBy || customerName || '', authorizedAt: new Date().toISOString(), authorizedMethod: authorizationMethod })}
className={`px-2 py-1 text-xs rounded border font-medium transition-colors ${service.customerDecision === 'approved' ? 'bg-green-600 text-white border-green-600' : 'bg-white text-gray-800 border-gray-300 hover:bg-green-50 hover:border-green-400 dark:bg-gray-800 dark:text-gray-300 dark:border-gray-600'}`}
>Approve</button>
<button
type="button"
onClick={() => onUpdate({ customerDecision: 'declined', approved: false, authorizedBy: service.authorizedBy || customerName || '', authorizedAt: new Date().toISOString(), authorizedMethod: authorizationMethod })}
className={`px-2 py-1 text-xs rounded border font-medium transition-colors ${service.customerDecision === 'declined' ? 'bg-red-600 text-white border-red-600' : 'bg-white text-gray-800 border-gray-300 hover:bg-red-50 hover:border-red-400 dark:bg-gray-800 dark:text-gray-300 dark:border-gray-600'}`}
>Decline</button>
<button
type="button"
onClick={() => onUpdate({ customerDecision: 'pending', approved: false, authorizedBy: undefined, authorizedAt: undefined, authorizedMethod: undefined })}
className={`px-2 py-1 text-xs rounded border font-medium transition-colors ${(!service.customerDecision || service.customerDecision === 'pending') ? 'bg-yellow-600 text-white border-yellow-600' : 'bg-white text-gray-800 border-gray-300 hover:bg-yellow-50 dark:bg-gray-800 dark:text-gray-300 dark:border-gray-600'}`}
>Pending</button>
</div>
{(service.customerDecision === 'approved' || service.customerDecision === 'declined') && (
<div className="mt-2 grid gap-2">
<div>
<label className="text-[10px] font-medium text-gray-500 dark:text-gray-400">Authorized by</label>
<input
type="text"
value={service.authorizedBy || ''}
onChange={(e) => onUpdate({ authorizedBy: e.target.value })}
placeholder={customerName || 'Customer name'}
className="w-full mt-0.5 rounded border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 px-2 py-1 text-xs text-gray-900 dark:text-gray-100 focus:border-green-500 focus:ring-1 focus:ring-green-500/20 focus:outline-none"
/>
</div>
{service.authorizedAt && (
<div>
<span className="text-[10px] text-gray-400 dark:text-gray-500">
{service.customerDecision === 'approved' ? 'Approved' : 'Declined'} on {new Date(service.authorizedAt).toLocaleDateString()} at {new Date(service.authorizedAt).toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })}
</span>
</div>
)}
</div>
)}
</div>
{/* Technician Notes (internal only) */}
<div className="border-t border-gray-200 dark:border-gray-700 pt-2">
<details className="text-xs" open={!!service.technicianNotes}>
<summary className="text-gray-400 dark:text-gray-500 cursor-pointer hover:text-gray-600 dark:hover:text-gray-300 select-none">Tech Notes (Internal)</summary>
<textarea
value={service.technicianNotes || ''}
onChange={(e) => onUpdate({ technicianNotes: e.target.value })}
placeholder="Internal notes — not shown to customer..."
rows={2}
className="mt-1.5 w-full rounded border border-amber-200 dark:border-amber-800 bg-amber-50 dark:bg-amber-900/20 px-3 py-2 text-xs text-amber-800 dark:text-amber-200 placeholder-amber-400 dark:placeholder-amber-600 focus:border-amber-500 focus:ring-1 focus:ring-amber-500/20 focus:outline-none resize-y"
/>
</details>
</div>
</div>
)}
</div>
);
});
@@ -0,0 +1,113 @@
import { useState, useEffect, useRef } from 'react';
import { Search, Plus } from 'lucide-react';
import { pb } from '../../lib/pocketbase';
import { useQuoteStore } from '../../store/quote';
import { loadSettings } from '../../lib/settings';
import type { Service } from '../../types';
export function ServiceSearch() {
const [query, setQuery] = useState('');
const [results, setResults] = useState<Service[]>([]);
const [allServices, setAllServices] = useState<Service[]>([]);
const [open, setOpen] = useState(false);
const [loading, setLoading] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
const dropdownRef = useRef<HTMLDivElement>(null);
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const { addService } = useQuoteStore();
useEffect(() => {
const load = async () => {
try {
const userId = pb.authStore.model?.id;
if (!userId) return;
const records = await pb.collection('services').getFullList({ filter: `userId="${userId}"`, batch: 500 });
setAllServices(records.map((r: any) => ({ id: r.id, name: r.name, price: r.price || 0, explanation: r.explanation, recommendation: r.recommendation, applyShopCharge: r.applyShopCharge, laborHours: r.laborHours || undefined, laborRate: r.laborRate || undefined, partsCost: r.partsCost || undefined, pricingMode: r.pricingMode || undefined })));
} catch {}
};
load();
}, []);
useEffect(() => {
if (debounceRef.current) clearTimeout(debounceRef.current);
if (!query.trim()) { setResults([]); setLoading(false); return; }
setLoading(true);
debounceRef.current = setTimeout(() => {
const filtered = allServices
.filter((s) => s.name.toLowerCase().includes(query.toLowerCase()))
.slice(0, 8);
setResults(filtered);
setLoading(false);
}, 120);
return () => {
if (debounceRef.current) clearTimeout(debounceRef.current);
};
}, [query, allServices]);
useEffect(() => {
const handler = (e: MouseEvent) => {
if (dropdownRef.current && !dropdownRef.current.contains(e.target as Node) && inputRef.current && !inputRef.current.contains(e.target as Node)) {
setOpen(false);
}
};
document.addEventListener('mousedown', handler);
return () => document.removeEventListener('mousedown', handler);
}, []);
const handleAdd = (service: Service) => {
addService({ ...service, selected: true, approved: false, customerDecision: 'pending', priority: undefined, applyShopCharge: true, pricingMode: service.pricingMode || loadSettings().defaultPricingMode });
setQuery('');
setOpen(false);
};
return (
<div className="relative">
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-400" />
<input
ref={inputRef}
value={query}
onChange={(e) => { setQuery(e.target.value); setOpen(true); }}
onFocus={() => { if (query) setOpen(true); }}
placeholder="Search services..."
className="w-full rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 pl-10 pr-4 py-2.5 text-sm text-gray-900 dark:text-gray-100 placeholder-gray-400 focus:border-green-500 focus:ring-2 focus:ring-green-500/20 focus:outline-none"
/>
</div>
{open && (query.trim() || loading) && (
<div ref={dropdownRef} className="absolute z-50 mt-1 w-full rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 shadow-xl max-h-64 overflow-y-auto">
{loading ? (
<div className="p-4 text-center text-sm text-gray-400">Searching...</div>
) : results.length === 0 ? (
<div className="p-4 text-center text-sm text-gray-400">No matching services</div>
) : (
results.map((s) => (
<button
key={s.id}
onClick={() => handleAdd(s)}
className="w-full text-left px-4 py-2.5 text-sm hover:bg-gray-50 dark:hover:bg-gray-700 flex justify-between items-center"
>
<span className="text-gray-900 dark:text-gray-100">{s.name}</span>
<span className="text-green-600 dark:text-green-400 font-medium">${s.price.toFixed(2)}</span>
</button>
))
)}
{query.trim() && (
<button
onClick={() => {
const customService: Service = {
id: `custom-${Date.now()}`,
name: query.trim(),
price: 0,
};
handleAdd(customService);
}}
className="w-full text-left px-4 py-2.5 text-sm text-green-600 dark:text-green-400 hover:bg-gray-50 dark:hover:bg-gray-700 border-t border-gray-100 dark:border-gray-700 font-medium"
>
<Plus className="inline h-3 w-3 mr-1" /> Add &ldquo;{query}&rdquo; as custom service
</button>
)}
</div>
)}
</div>
);
}
@@ -0,0 +1,161 @@
import { useState, useCallback } from 'react';
import { FileText } from 'lucide-react';
import { useQuoteStore } from '../../store/quote';
import { useToast } from '../ui/Toast';
import { aiWriteExplanation } from '../../lib/ai';
import { logError } from '../../lib/userMessages';
import { ServiceRow } from './ServiceRow';
import type { QuoteAuthorizationMethod, QuoteService } from '../../types';
const authorizationOptions: Array<{ value: QuoteAuthorizationMethod; label: string }> = [
{ value: 'in_person', label: 'In Person' },
{ value: 'text', label: 'Text' },
{ value: 'phone', label: 'Call' },
];
export function ServicesTable() {
const {
services,
removeService,
customerInfo,
currentWorkAuthorizationMethod,
additionalWorkAuthorizationMethod,
setCurrentWorkAuthorizationMethod,
setAdditionalWorkAuthorizationMethod,
} = useQuoteStore();
const updateService = useQuoteStore((s) => s.updateService);
const [expanded, setExpanded] = useState<Record<string, boolean>>({});
const [aiLoading, setAiLoading] = useState<Record<string, boolean>>({});
const { showToast } = useToast();
const approved = services.filter((s) => s.customerDecision === 'approved');
const pending = services.filter((s) => s.customerDecision === 'pending');
const declined = services.filter((s) => s.customerDecision === 'declined');
const handleAiWrite = useCallback(async (svc: QuoteService) => {
setAiLoading((prev) => ({ ...prev, [svc.id]: true }));
try {
const result = await aiWriteExplanation({
serviceName: svc.name,
recommendation: svc.recommendation || '',
technicianNotes: svc.technicianNotes,
});
if (result) {
updateService(svc.id, {
explanation: result.explanation,
priority: result.level as QuoteService['priority'],
});
setExpanded((prev) => ({ ...prev, [svc.id]: true }));
showToast(`AI explanation generated for ${svc.name}`, 'success');
} else {
showToast('AI Write returned no result', 'error');
}
} catch (err) {
logError('Failed to generate AI explanation', err);
showToast('AI explanation could not be generated. Please try again.', 'error');
} finally {
setAiLoading((prev) => ({ ...prev, [svc.id]: false }));
}
}, [updateService, showToast]);
if (services.length === 0) {
return (
<div className="rounded-xl border border-dashed border-gray-300 dark:border-gray-700 p-8 text-center">
<FileText className="mx-auto h-8 w-8 text-gray-300 dark:text-gray-600" />
<p className="mt-2 text-sm text-gray-400">No services have been added to this quote yet. Search the catalog above to add billable work items.</p>
</div>
);
}
const handleApproveAll = () => {
const now = new Date().toISOString();
const notApproved = services.filter((s) => s.customerDecision !== 'approved');
notApproved.forEach((s) => updateService(s.id, { approved: true, customerDecision: 'approved', authorizedBy: s.authorizedBy || customerInfo.name || '', authorizedAt: now, authorizedMethod: additionalWorkAuthorizationMethod }));
};
const decideService = (service: QuoteService, decision: 'approved' | 'declined') => {
const isAdditionalRecommendation = service.customerDecision === 'pending';
const method = isAdditionalRecommendation ? additionalWorkAuthorizationMethod : currentWorkAuthorizationMethod;
updateService(service.id, {
customerDecision: decision,
approved: decision === 'approved',
authorizedBy: service.authorizedBy || customerInfo.name || '',
authorizedAt: new Date().toISOString(),
authorizedMethod: method,
});
};
const resetServiceDecision = (service: QuoteService) => {
updateService(service.id, { customerDecision: 'pending', approved: false, authorizedBy: undefined, authorizedAt: undefined, authorizedMethod: undefined });
};
const notApprovedCount = services.filter((s) => s.customerDecision !== 'approved').length;
const makeRowProps = (s: QuoteService) => ({
service: s,
expanded: expanded[s.id] ?? false,
aiLoading: aiLoading[s.id] ?? false,
customerName: customerInfo.name,
authorizationMethod: s.customerDecision === 'pending' ? additionalWorkAuthorizationMethod : currentWorkAuthorizationMethod,
onToggleExpand: () => setExpanded((prev) => ({ ...prev, [s.id]: !prev[s.id] })),
onAiWrite: () => handleAiWrite(s),
onToggleApproved: () => s.approved ? resetServiceDecision(s) : decideService(s, 'approved'),
onToggleDeclined: () => s.customerDecision === 'declined' ? resetServiceDecision(s) : decideService(s, 'declined'),
onRemove: () => removeService(s.id),
onUpdate: (updates: Partial<QuoteService>) => updateService(s.id, updates),
});
return (
<div className="space-y-1">
<div className="mb-3 grid gap-2 rounded-xl border border-gray-200 bg-gray-50 p-3 dark:border-gray-700 dark:bg-gray-900/40 sm:grid-cols-2">
<label className="text-xs font-medium text-gray-600 dark:text-gray-300">
Already approved/declined work authorized by
<select
value={currentWorkAuthorizationMethod}
onChange={(e) => setCurrentWorkAuthorizationMethod(e.target.value as QuoteAuthorizationMethod)}
className="mt-1 w-full rounded border border-gray-300 bg-white px-2 py-1.5 text-xs text-gray-900 focus:border-green-500 focus:outline-none focus:ring-1 focus:ring-green-500/20 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100"
>
{authorizationOptions.map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}
</select>
</label>
<label className="text-xs font-medium text-gray-600 dark:text-gray-300">
Additional recommendations authorized by
<select
value={additionalWorkAuthorizationMethod}
onChange={(e) => setAdditionalWorkAuthorizationMethod(e.target.value as QuoteAuthorizationMethod)}
className="mt-1 w-full rounded border border-gray-300 bg-white px-2 py-1.5 text-xs text-gray-900 focus:border-green-500 focus:outline-none focus:ring-1 focus:ring-green-500/20 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100"
>
{authorizationOptions.map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}
</select>
</label>
</div>
{notApprovedCount > 0 && (
<div className="flex justify-end mb-2">
<button
onClick={handleApproveAll}
className="px-3 py-1 text-xs font-medium rounded border border-green-300 dark:border-green-700 text-green-700 dark:text-green-400 bg-green-50 dark:bg-green-900/20 hover:bg-green-100 dark:hover:bg-green-900/40 transition-colors"
>
Approve All ({notApprovedCount})
</button>
</div>
)}
{approved.length > 0 && (
<div>
<p className="text-xs font-medium text-green-600 dark:text-green-400 uppercase tracking-wider mb-2 px-1">Approved ({approved.length})</p>
{approved.map((s) => <ServiceRow key={s.id} {...makeRowProps(s)} />)}
</div>
)}
{pending.length > 0 && (
<div className={approved.length > 0 ? 'mt-4' : ''}>
<p className="text-xs font-medium text-gray-400 uppercase tracking-wider mb-2 px-1">Additional Recommendations ({pending.length})</p>
{pending.map((s) => <ServiceRow key={s.id} {...makeRowProps(s)} />)}
</div>
)}
{declined.length > 0 && (
<div className={approved.length > 0 || pending.length > 0 ? 'mt-4' : ''}>
<p className="text-xs font-medium text-red-500 uppercase tracking-wider mb-2 px-1">Declined ({declined.length})</p>
{declined.map((s) => <ServiceRow key={s.id} {...makeRowProps(s)} />)}
</div>
)}
</div>
);
}
+16
View File
@@ -0,0 +1,16 @@
export type {
QuoteRecord,
RecentQuoteFilter,
RecentQuoteSort,
} from './types';
export {
RECENT_QUOTE_FILTERS,
PRIORITY_COLORS,
} from './types';
export { RecentStatusBadge } from './RecentStatusBadge';
export { CustomerInfoPanel } from './CustomerInfoPanel';
export { ServiceSearch } from './ServiceSearch';
export { ServiceRow } from './ServiceRow';
export { ServicesTable } from './ServicesTable';
export { QuoteSummary } from './QuoteSummary';
+47
View File
@@ -0,0 +1,47 @@
/* ── Types & Constants ──────────────────────────────── */
export interface QuoteRecord {
id: string;
customerName: string;
vehicleInfo: string;
roNumber: string;
repairOrderNumber?: string;
total: number;
approvedTotal: number;
declinedTotal: number;
hasPending: boolean;
status: 'draft' | 'sent' | 'approved' | 'declined';
createdAt: string;
lastUpdated?: string;
}
export type RecentQuoteFilter = 'all' | 'draft' | 'pending' | 'approved' | 'declined';
export type RecentQuoteSort = 'newest' | 'oldest' | 'highest-total' | 'customer-asc';
export const RECENT_QUOTE_FILTERS: { value: RecentQuoteFilter; label: string }[] = [
{ value: 'all', label: 'All' },
{ value: 'draft', label: 'Draft' },
{ value: 'pending', label: 'Pending' },
{ value: 'approved', label: 'Approved' },
{ value: 'declined', label: 'Declined' },
];
export const PRIORITY_COLORS: Record<string, string> = {
CRITICAL_SAFETY: 'bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-300 border-red-300 dark:border-red-700',
SAFETY_CONCERN: 'bg-orange-100 dark:bg-orange-900/30 text-orange-700 dark:text-orange-300 border-orange-300 dark:border-orange-700',
RECOMMENDED: 'bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300 border-blue-300 dark:border-blue-700',
MAINTENANCE: 'bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-300 border-green-300 dark:border-green-700',
OPTIONAL: 'bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-400 border-gray-300 dark:border-gray-600',
};
export const RECENT_STATUS_COLORS: Record<string, string> = {
approved: 'bg-green-100 text-green-800 dark:bg-green-900/40 dark:text-green-300',
sent: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/40 dark:text-yellow-300',
pending: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/40 dark:text-yellow-300',
declined: 'bg-red-100 text-red-800 dark:bg-red-900/40 dark:text-red-300',
draft: 'bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-400',
};
export const RECENT_STATUS_LABELS: Record<string, string> = {
approved: 'Approved', sent: 'Pending', pending: 'Pending', declined: 'Declined', draft: 'Draft',
};
@@ -0,0 +1,103 @@
import { memo } from 'react';
import { CheckSquare, Square, ChevronRight } from 'lucide-react';
import { getStatusConfig } from '../../lib/status';
import { useSettings } from '../../store/settings';
import { formatDateTime, formatCurrency } from '../../lib/format';
import { isVoided } from '../../lib/roEvents';
import {
getCompletedMetricDate,
displayCustomerName,
ratingLabel,
} from '../../lib/repairOrders';
import type { RepairOrder, CustomerType } from '../../types';
import { CustomerTypeBadge } from './CustomerTypeBadge';
import { StatusBadge } from './StatusBadge';
import { TechNames } from './TechNames';
export const CompletedRow = memo(function CompletedRow({
ro,
selected,
onToggleSelect,
onOpen,
onToggleCustomerType,
techNames,
}: {
ro: RepairOrder;
selected: boolean;
onToggleSelect: () => void;
onOpen: () => void;
onToggleCustomerType: (id: string, currentType?: CustomerType) => void;
techNames: string[];
}) {
const voided = isVoided(ro);
const completedDate = getCompletedMetricDate(ro);
const rowClass = `
cursor-pointer transition-colors hover:bg-gray-50 dark:hover:bg-gray-700/50
${voided ? 'opacity-50 hover:opacity-70' : ''}
${selected ? 'ring-2 ring-inset ring-blue-400' : ''}
`;
return (
<tr
onClick={(e) => {
const target = e.target as HTMLElement;
if (target.closest('[data-no-open]') || target.closest('button')) return;
onOpen();
}}
className={rowClass}
>
<td className="px-2 py-2.5" data-no-open>
<button
onClick={(e) => { e.stopPropagation(); onToggleSelect(); }}
className="text-gray-400 hover:text-blue-600 dark:hover:text-blue-400"
title={selected ? 'Deselect' : 'Select for bulk action'}
>
{selected
? <CheckSquare className="h-4 w-4 text-blue-600 dark:text-blue-400" />
: <Square className="h-4 w-4" />}
</button>
</td>
<td className="px-3 py-2.5 font-mono text-xs text-gray-500 dark:text-gray-400">
{ro.roNumber || `#${ro.id.slice(0, 8)}`}
</td>
<td className="px-3 py-2.5">
<div className="flex items-center gap-2">
<span className="font-medium text-gray-900 dark:text-white">{displayCustomerName(ro.customerName)}</span>
<CustomerTypeBadge
customerType={ro.customerType}
onClick={() => onToggleCustomerType(ro.id, ro.customerType)}
/>
</div>
</td>
<td className="max-w-[220px] px-3 py-2.5 text-gray-600 dark:text-gray-400">
<div className="space-y-0.5">
<p className="truncate">{ro.vehicleInfo || '—'}</p>
{ro.serviceLocation && (
<p className="truncate text-xs text-sky-600 dark:text-sky-400">{ro.serviceLocation}</p>
)}
{typeof ro.tripMiles === 'number' && ro.tripMiles > 0 && (
<p className="text-xs text-purple-600 dark:text-purple-400">{ro.tripMiles} mi trip</p>
)}
</div>
</td>
<td className="whitespace-nowrap px-3 py-2.5 text-sm text-gray-600 dark:text-gray-400">
{completedDate ? formatDateTime(completedDate) : '—'}
</td>
<td className="px-3 py-2.5">
<StatusBadge status={voided ? 'voided' : (ro.status || '')} config={getStatusConfig(useSettings())} />
</td>
<td className="whitespace-nowrap px-3 py-2.5 text-sm font-medium text-gray-900 dark:text-white">
{formatCurrency(ro.total || 0)}
</td>
<td className="whitespace-nowrap px-3 py-2.5 text-sm text-gray-600 dark:text-gray-400">
{ratingLabel(ro.satisfaction)}
</td>
<td className="whitespace-nowrap px-3 py-2.5 text-center">
<TechNames names={techNames} />
</td>
<td className="px-3 py-2.5 text-right">
<ChevronRight className="h-4 w-4 text-gray-400" />
</td>
</tr>
);
});
@@ -0,0 +1,36 @@
import { UserCheck, UserX } from 'lucide-react';
import { getCustomerTypeLabels } from '../../lib/settings';
import { useSettings } from '../../store/settings';
import type { CustomerType } from '../../types';
export function CustomerTypeBadge({
customerType,
onClick,
}: {
customerType?: CustomerType;
onClick: () => void;
}) {
const isWaiter = customerType === 'waiter';
const customerTypeLabels = getCustomerTypeLabels(useSettings());
return (
<button
onClick={(e) => {
e.stopPropagation();
onClick();
}}
className={`inline-flex cursor-pointer items-center gap-1 rounded-full px-2 py-0.5 text-xs font-medium transition-colors ${
isWaiter
? 'bg-purple-100 text-purple-800 hover:bg-purple-200 dark:bg-purple-900/40 dark:text-purple-300 dark:hover:bg-purple-900/60'
: 'bg-gray-100 text-gray-600 hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-400 dark:hover:bg-gray-600'
}`}
title={`Click to toggle to ${isWaiter ? customerTypeLabels.primary : customerTypeLabels.secondary}`}
>
{isWaiter ? (
<UserCheck className="h-3 w-3" />
) : (
<UserX className="h-3 w-3" />
)}
{isWaiter ? customerTypeLabels.secondary : customerTypeLabels.primary}
</button>
);
}
@@ -0,0 +1,49 @@
import { Wrench, Plus } from 'lucide-react';
import type { TabId } from './types';
export function EmptyState({
tab,
onCreate,
}: {
tab: TabId;
onCreate: () => void;
}) {
const messages: Record<TabId, { title: string; desc: string }> = {
active: {
title: 'No active repair orders',
desc: 'There are no open repair orders in this view. Create a new order when a vehicle is checked in for service.',
},
all: {
title: 'No repair orders on file',
desc: 'Create your first repair order to track work performed, labor, parts, and customer authorization status.',
},
completed: {
title: 'No completed repair orders',
desc: 'Closed repair orders will appear here once work has been completed and finalized.',
},
};
const msg = messages[tab];
return (
<div className="flex flex-col items-center justify-center py-20 text-center">
<div className="mb-6 flex h-24 w-24 items-center justify-center rounded-full bg-blue-50 dark:bg-blue-900/20">
<Wrench className="h-12 w-12 text-blue-500 dark:text-blue-400" />
</div>
<h3 className="mb-2 text-lg font-semibold text-gray-900 dark:text-white">
{msg.title}
</h3>
<p className="mb-8 max-w-sm text-sm text-gray-500 dark:text-gray-400">
{msg.desc}
</p>
{tab !== 'completed' && (
<button
onClick={onCreate}
className="inline-flex items-center gap-2 rounded-lg bg-blue-600 px-5 py-2.5 text-sm font-medium text-white shadow-sm transition-colors hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 dark:focus:ring-offset-gray-900"
>
<Plus className="h-4 w-4" />
Create Repair Order
</button>
)}
</div>
);
}
@@ -0,0 +1,11 @@
import { ListError } from '../ui/ListError';
export function ErrorState({
message,
onRetry,
}: {
message: string;
onRetry: () => void;
}) {
return <ListError title="Unable to load repair orders" message={message} onRetry={onRetry} />;
}
+118
View File
@@ -0,0 +1,118 @@
import { memo } from 'react';
import { CheckSquare, Square, ChevronRight } from 'lucide-react';
import { getStatusConfig } from '../../lib/status';
import { useSettings } from '../../store/settings';
import { formatDateTime } from '../../lib/format';
import { isVoided } from '../../lib/roEvents';
import {
getUrgencyClass,
displayCustomerName,
formatDueDateTime,
} from '../../lib/repairOrders';
import type { RepairOrder, CustomerType } from '../../types';
import { CustomerTypeBadge } from './CustomerTypeBadge';
import { StatusBadge } from './StatusBadge';
import { TimeRemainingCell } from './TimeRemainingCell';
import { RowProgressBar } from './RowProgressBar';
import { TechNames } from './TechNames';
export const FragmentRow = memo(function FragmentRow({
ro,
now,
selected,
onToggleSelect,
onOpen,
onToggleCustomerType,
techNames,
}: {
ro: RepairOrder;
now: number;
selected: boolean;
onToggleSelect: () => void;
onOpen: () => void;
onToggleCustomerType: (id: string, currentType?: CustomerType) => void;
techNames: string[];
}) {
const urgency = getUrgencyClass(ro, now);
const voided = isVoided(ro);
const rowClass = `
cursor-pointer transition-colors
${voided
? 'opacity-50 hover:opacity-70'
: urgency === 'urgent'
? 'bg-red-50 hover:bg-red-100 dark:bg-red-900/15 dark:hover:bg-red-900/25'
: 'hover:bg-gray-50 dark:hover:bg-gray-700/50'
}
${selected ? 'ring-2 ring-inset ring-blue-400' : ''}
`;
return (
<tr
onClick={(e) => {
const target = e.target as HTMLElement;
if (target.closest('[data-no-open]') || target.closest('button')) return;
onOpen();
}}
className={rowClass}
>
<td className="px-2 py-2.5" data-no-open>
<button
onClick={(e) => { e.stopPropagation(); onToggleSelect(); }}
className="text-gray-400 hover:text-blue-600 dark:hover:text-blue-400"
title={selected ? 'Deselect' : 'Select for bulk action'}
>
{selected
? <CheckSquare className="h-4 w-4 text-blue-600 dark:text-blue-400" />
: <Square className="h-4 w-4" />}
</button>
</td>
<td className="px-3 py-2.5 font-mono text-xs text-gray-500 dark:text-gray-400">
{ro.roNumber || `#${ro.id.slice(0, 8)}`}
</td>
<td className="px-3 py-2.5">
<div className="flex items-center gap-2">
<span className="font-medium text-gray-900 dark:text-white">
{displayCustomerName(ro.customerName)}
</span>
<CustomerTypeBadge
customerType={ro.customerType}
onClick={() => onToggleCustomerType(ro.id, ro.customerType)}
/>
</div>
</td>
<td className="max-w-[200px] px-3 py-2.5 text-gray-600 dark:text-gray-400">
<div className="space-y-0.5">
<p className="truncate">{ro.vehicleInfo || '—'}</p>
{ro.serviceLocation && (
<p className="truncate text-xs text-sky-600 dark:text-sky-400">{ro.serviceLocation}</p>
)}
{typeof ro.tripMiles === 'number' && ro.tripMiles > 0 && (
<p className="text-xs text-purple-600 dark:text-purple-400">{ro.tripMiles} mi trip</p>
)}
</div>
</td>
<td className="px-3 py-2.5">
<StatusBadge status={voided ? 'voided' : (ro.status || '')} config={getStatusConfig(useSettings())} />
</td>
<td className="whitespace-nowrap px-3 py-2.5 text-sm text-gray-600 dark:text-gray-400">
{formatDateTime(ro.writeupTime || ro.created)}
</td>
<td className="whitespace-nowrap px-3 py-2.5 text-sm text-gray-600 dark:text-gray-400">
{formatDueDateTime(ro)}
</td>
<td className="whitespace-nowrap px-3 py-2.5">
<TimeRemainingCell ro={ro} now={now} />
</td>
<td className="whitespace-nowrap px-3 py-2.5">
<RowProgressBar ro={ro} />
</td>
<td className="whitespace-nowrap px-3 py-2.5 text-center">
<TechNames names={techNames} />
</td>
<td className="px-3 py-2.5 text-right">
<ChevronRight className="h-4 w-4 text-gray-400" />
</td>
</tr>
);
});
@@ -0,0 +1,29 @@
export function LoadingSkeleton() {
return (
<div className="animate-pulse space-y-4">
<div className="flex gap-2">
{Array.from({ length: 3 }).map((_, i) => (
<div
key={i}
className="h-9 w-24 rounded-lg bg-gray-200 dark:bg-gray-700"
/>
))}
</div>
<div className="rounded-xl bg-white ring-1 ring-gray-200 dark:bg-gray-800 dark:ring-gray-700">
<div className="space-y-3 p-6">
{Array.from({ length: 5 }).map((_, i) => (
<div key={i} className="flex gap-4">
<div className="h-4 w-16 rounded bg-gray-200 dark:bg-gray-700" />
<div className="h-4 flex-1 rounded bg-gray-200 dark:bg-gray-700" />
<div className="h-4 w-28 rounded bg-gray-200 dark:bg-gray-700" />
<div className="h-4 w-20 rounded bg-gray-200 dark:bg-gray-700" />
<div className="h-4 w-24 rounded bg-gray-200 dark:bg-gray-700" />
<div className="h-4 w-16 rounded bg-gray-200 dark:bg-gray-700" />
<div className="h-4 w-24 rounded bg-gray-200 dark:bg-gray-700" />
</div>
))}
</div>
</div>
</div>
);
}
+64
View File
@@ -0,0 +1,64 @@
import { X } from 'lucide-react';
import ROForm from '../ROForm';
import type { RepairOrder } from '../../types';
import type { CustomerWithVehicles } from '../../pages/Customers';
export function ROModal({
isOpen,
onClose,
onSave,
editRO,
existingRoNumbers,
settings,
customers,
}: {
isOpen: boolean;
onClose: () => void;
onSave: (data: Partial<RepairOrder>) => void;
editRO: RepairOrder | null;
existingRoNumbers: Set<string>;
settings: { taxRate: number; shopChargeRate: number; shopChargeCap: number; defaultLaborRate: number };
customers?: CustomerWithVehicles[];
}) {
if (!isOpen) return null;
const isEditing = !!editRO;
return (
<div className="fixed inset-0 z-50 flex items-start justify-center overflow-y-auto bg-black/50 p-4 pt-10">
<div className="w-full max-w-3xl rounded-2xl bg-white shadow-2xl dark:bg-gray-900">
{/* Header */}
<div className="flex items-center justify-between border-b border-gray-200 px-6 py-4 dark:border-gray-700">
<div>
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">
{isEditing ? 'Edit Repair Order' : 'Create Repair Order'}
</h2>
<p className="text-sm text-gray-500 dark:text-gray-400">
{isEditing
? `Editing RO #${editRO?.roNumber || ''}`
: 'Enter customer and vehicle information'}
</p>
</div>
<button
onClick={onClose}
className="rounded-lg p-2 text-gray-400 hover:bg-gray-100 hover:text-gray-600 dark:hover:bg-gray-800 dark:hover:text-gray-300"
>
<X className="h-5 w-5" />
</button>
</div>
{/* Body */}
<div className="px-6 py-5">
<ROForm
editRO={editRO}
existingRoNumbers={existingRoNumbers}
settings={settings}
customers={customers}
onSave={(data) => { onSave(data); onClose(); }}
onCancel={onClose}
/>
</div>
</div>
</div>
);
}
@@ -0,0 +1,19 @@
import { roProgress } from '../../lib/status';
import type { RepairOrder } from '../../types';
export function RowProgressBar({ ro }: { ro: RepairOrder }) {
const { done, total, fraction } = roProgress(ro);
if (total === 0) {
return <span className="text-xs text-gray-300 dark:text-gray-600"></span>;
}
const pct = Math.round(fraction * 100);
const barColor = pct === 100 ? 'bg-green-500' : pct >= 50 ? 'bg-blue-500' : 'bg-gray-300 dark:bg-gray-600';
return (
<div className="flex items-center gap-2" title={`${done}/${total} services done`}>
<div className="h-1.5 w-16 overflow-hidden rounded-full bg-gray-200 dark:bg-gray-700">
<div className={`h-full transition-all ${barColor}`} style={{ width: `${pct}%` }} />
</div>
<span className="text-xs tabular-nums text-gray-500 dark:text-gray-400">{done}/{total}</span>
</div>
);
}
@@ -0,0 +1,28 @@
import { getStatusConfig } from '../../lib/status';
import { useSettings } from '../../store/settings';
export function StatusBadge({
status,
config,
}: {
status: string;
config: Record<string, { label: string; colors: string }>;
}) {
const cfg = 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>
);
}
// Alternate version that derives config from settings (convenience)
export function StatusBadgeAuto({ status }: { status: string }) {
return <StatusBadge status={status} config={getStatusConfig(useSettings())} />;
}
+39
View File
@@ -0,0 +1,39 @@
import { TABS } from './types';
import type { TabId } from './types';
export function TabButton({
tab,
active,
count,
onClick,
}: {
tab: TabId;
active: boolean;
count?: number;
onClick: () => void;
}) {
const label = TABS.find((t) => t.id === tab)?.label || tab;
return (
<button
onClick={onClick}
className={`relative inline-flex items-center gap-2 rounded-lg px-4 py-2 text-sm font-medium transition-all ${
active
? 'bg-blue-600 text-white shadow-sm'
: 'bg-white text-gray-600 ring-1 ring-gray-300 hover:bg-gray-50 dark:bg-gray-800 dark:text-gray-400 dark:ring-gray-700 dark:hover:bg-gray-700'
}`}
>
{label}
{count !== undefined && (
<span
className={`inline-flex items-center justify-center rounded-full px-2 py-0.5 text-xs ${
active
? 'bg-white/20 text-white'
: 'bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-400'
}`}
>
{count}
</span>
)}
</button>
);
}
+20
View File
@@ -0,0 +1,20 @@
export function TechNames({ names }: { names: string[] }) {
if (names.length === 0) return <span className="text-xs text-gray-300 dark:text-gray-600"></span>;
const label = names.join(', ');
const visible = names.slice(0, 2);
const extra = names.length - visible.length;
return (
<div className="flex max-w-[10rem] flex-wrap items-center justify-center gap-1" title={label}>
{visible.map((name, index) => (
<span key={`${name}-${index}`} className="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">
{name}
</span>
))}
{extra > 0 && (
<span className="rounded-full bg-gray-100 px-2 py-0.5 text-xs font-medium text-gray-600 dark:bg-gray-700 dark:text-gray-300">
+{extra}
</span>
)}
</div>
);
}
@@ -0,0 +1,30 @@
import { AlertTriangle } from 'lucide-react';
import { formatTimeRemaining, getUrgencyClass } from '../../lib/repairOrders';
import type { RepairOrder } from '../../types';
export function TimeRemainingCell({
ro,
now,
}: {
ro: RepairOrder;
now: number;
}) {
const text = formatTimeRemaining(ro, now);
const urgency = getUrgencyClass(ro, now);
const colorClass =
urgency === 'urgent'
? 'text-red-600 dark:text-red-400 font-semibold'
: urgency === 'warning'
? 'text-amber-600 dark:text-amber-400 font-medium'
: 'text-gray-600 dark:text-gray-400';
return (
<span className={`inline-flex items-center gap-1 text-sm ${colorClass}`}>
{urgency === 'urgent' && (
<AlertTriangle className="h-3.5 w-3.5 text-red-500" />
)}
{text}
</span>
);
}
+21
View File
@@ -0,0 +1,21 @@
export type {
TabId,
CompletedRangeFilter,
CompletedStatusFilter,
CompletedRatingFilter,
SavedView,
} from './types';
export { TABS, SAVED_VIEWS_KEY, loadSavedViews, persistSavedViews } from './types';
export { StatusBadge } from './StatusBadge';
export { RowProgressBar } from './RowProgressBar';
export { TechNames } from './TechNames';
export { CustomerTypeBadge } from './CustomerTypeBadge';
export { TimeRemainingCell } from './TimeRemainingCell';
export { LoadingSkeleton } from './LoadingSkeleton';
export { EmptyState } from './EmptyState';
export { ErrorState } from './ErrorState';
export { TabButton } from './TabButton';
export { ROModal } from './ROModal';
export { FragmentRow } from './FragmentRow';
export { CompletedRow } from './CompletedRow';
+37
View File
@@ -0,0 +1,37 @@
/* ── Types & Constants ──────────────────────────────── */
export type TabId = 'active' | 'all' | 'completed';
export type CompletedRangeFilter = 'all' | 'today' | 'this_week' | 'this_month';
export type CompletedStatusFilter = 'all' | 'completed' | 'final_close';
export type CompletedRatingFilter = 'all' | 'rated' | 'unrated';
export const TABS: { id: TabId; label: string }[] = [
{ id: 'active', label: 'Active' },
{ id: 'all', label: 'All' },
{ id: 'completed', label: 'Completed' },
];
/* ── Saved-view preset shape ─────────────────────────── */
export interface SavedView {
id: string;
label: string;
statusChips: string[];
waiterOnly: boolean;
showVoided: boolean;
search: string;
}
export const SAVED_VIEWS_KEY = 'spq-ro-saved-views';
export function loadSavedViews(): SavedView[] {
try {
const raw = localStorage.getItem(SAVED_VIEWS_KEY);
if (raw) return JSON.parse(raw) as SavedView[];
} catch { /* corrupt JSON — ignore */ }
return [];
}
export function persistSavedViews(views: SavedView[]) {
try { localStorage.setItem(SAVED_VIEWS_KEY, JSON.stringify(views)); } catch { /* ignore */ }
}
@@ -0,0 +1,95 @@
import { createContext, useContext, useEffect, useState, useCallback, type ReactNode } from 'react';
import { useSearchParams, useNavigate } from 'react-router-dom';
import { pb } from '../../lib/pocketbase';
interface ImpersonatedTechInfo {
id: string;
email: string;
displayName?: string;
}
interface TechnicianContextValue {
effectiveUserId: string;
isImpersonating: boolean;
impersonatedTech: ImpersonatedTechInfo | null;
stopImpersonation: () => void;
}
const TechnicianContext = createContext<TechnicianContextValue>({
effectiveUserId: '',
isImpersonating: false,
impersonatedTech: null,
stopImpersonation: () => {},
});
export function TechnicianProvider({ children }: { children: ReactNode }) {
const [searchParams] = useSearchParams();
const navigate = useNavigate();
const viewAs = searchParams.get('view_as');
const authUserId = pb.authStore.model?.id as string | undefined;
const [impersonatedTech, setImpersonatedTech] = useState<ImpersonatedTechInfo | null>(null);
const isImpersonating = !!viewAs && viewAs !== authUserId;
const effectiveUserId = isImpersonating ? viewAs! : (authUserId || '');
useEffect(() => {
if (isImpersonating && viewAs) {
pb.collection('users').getOne(viewAs).then((user) => {
setImpersonatedTech({
id: user.id,
email: user.email as string,
displayName: user.displayName as string | undefined,
});
}).catch(() => {
setImpersonatedTech(null);
});
} else {
setImpersonatedTech(null);
}
}, [isImpersonating, viewAs]);
const stopImpersonation = useCallback(() => {
navigate('/team');
}, [navigate]);
return (
<TechnicianContext.Provider
value={{
effectiveUserId,
isImpersonating,
impersonatedTech,
stopImpersonation,
}}
>
{children}
</TechnicianContext.Provider>
);
}
export function useTechnicianContext(): TechnicianContextValue {
return useContext(TechnicianContext);
}
export function useTechnicianNav() {
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const viewAs = searchParams.get('view_as');
const buildTechUrl = useCallback(
(path: string): string => {
if (!viewAs) return path;
const separator = path.includes('?') ? '&' : '?';
return `${path}${separator}view_as=${encodeURIComponent(viewAs)}`;
},
[viewAs],
);
const navigateTech = useCallback(
(path: string, opts?: { replace?: boolean; state?: unknown }) => {
navigate(buildTechUrl(path), opts);
},
[navigate, buildTechUrl],
);
return { buildTechUrl, navigateTech };
}
@@ -0,0 +1,115 @@
import { useState, useEffect, useMemo, type ReactNode } from 'react';
import { LayoutDashboard, ClipboardList, User, Sun, Moon, LogOut, Eye, X } from 'lucide-react';
import { useTechnicianContext, useTechnicianNav } from './TechnicianContext';
import { NavLink, useNavigate } from 'react-router-dom';
import { pb } from '../../lib/pocketbase';
interface TechnicianLayoutProps {
children: ReactNode;
}
export default function TechnicianLayout({ children }: TechnicianLayoutProps) {
const navigate = useNavigate();
const { isImpersonating, impersonatedTech, stopImpersonation } = useTechnicianContext();
const { buildTechUrl } = useTechnicianNav();
const navItems = useMemo(
() => [
{ to: buildTechUrl('/technician'), label: 'Bay', icon: LayoutDashboard },
{ to: buildTechUrl('/technician/jobs'), label: 'Jobs', icon: ClipboardList },
{ to: buildTechUrl('/technician/profile'), label: 'Profile', icon: User },
],
[buildTechUrl],
);
const [dark, setDark] = useState(() => {
if (typeof window === 'undefined') return false;
return localStorage.getItem('spq-dark-mode') === 'true';
});
useEffect(() => {
const root = document.documentElement;
if (dark) {
root.classList.add('dark');
} else {
root.classList.remove('dark');
}
localStorage.setItem('spq-dark-mode', String(dark));
}, [dark]);
const toggleDark = () => setDark((prev) => !prev);
const handleLogout = () => {
pb.authStore.clear();
navigate('/login');
};
const userEmail = pb.authStore.model?.email as string | undefined;
return (
<div className="flex min-h-screen flex-col bg-gray-50 dark:bg-gray-950">
<header className="sticky top-0 z-30 flex h-14 items-center gap-2 border-b border-gray-200 bg-white px-4 dark:border-gray-800 dark:bg-gray-900">
<span className="text-base font-bold text-gray-900 dark:text-white">
Shop<span className="text-green-600">Pro</span>Quote{' '}
<span className="text-xs font-normal text-gray-400 dark:text-gray-500">Bay</span>
</span>
<div className="min-w-0 flex-1" />
{userEmail && (
<span className="hidden max-w-[120px] truncate text-xs text-gray-500 dark:text-gray-400 sm:block">
{userEmail}
</span>
)}
<button
onClick={toggleDark}
className="rounded-lg p-2 text-gray-500 hover:bg-gray-100 hover:text-gray-700 dark:text-gray-400 dark:hover:bg-gray-800 dark:hover:text-gray-200"
title={dark ? 'Switch to light mode' : 'Switch to dark mode'}
>
{dark ? <Sun className="h-4 w-4" /> : <Moon className="h-4 w-4" />}
</button>
<button
onClick={handleLogout}
className="rounded-lg p-2 text-gray-500 hover:bg-red-50 hover:text-red-600 dark:text-gray-400 dark:hover:bg-red-900/20 dark:hover:text-red-400"
title="Logout"
>
<LogOut className="h-4 w-4" />
</button>
</header>
{isImpersonating && (
<div className="flex items-center gap-2 bg-blue-600 px-4 py-2 text-sm text-white">
<Eye className="h-4 w-4 shrink-0" />
<span className="min-w-0 flex-1 truncate">
Viewing <strong>{impersonatedTech?.displayName || impersonatedTech?.email || 'technician'}</strong>'s bay
</span>
<button
onClick={stopImpersonation}
className="flex shrink-0 items-center gap-1 rounded-lg bg-blue-700 px-3 py-1 text-xs font-medium hover:bg-blue-800"
>
<X className="h-3.5 w-3.5" />
Exit View
</button>
</div>
)}
<main className="flex-1 overflow-auto p-4 sm:p-6">{children}</main>
<nav className="sticky bottom-0 z-30 flex border-t border-gray-200 bg-white dark:border-gray-800 dark:bg-gray-900">
{navItems.map((item) => (
<NavLink
key={item.to}
to={item.to}
end={item.to === '/technician'}
className={({ isActive }) =>
`flex flex-1 flex-col items-center gap-0.5 py-3 text-xs font-medium transition-colors ${
isActive
? 'text-green-600 dark:text-green-400'
: 'text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200'
}`
}
>
<item.icon className="h-5 w-5" />
<span>{item.label}</span>
</NavLink>
))}
</nav>
</div>
);
}
+41
View File
@@ -0,0 +1,41 @@
import type { LucideIcon } from 'lucide-react';
interface ListEmptyProps {
icon?: LucideIcon;
title: string;
message?: string;
actionLabel?: string;
onAction?: () => void;
className?: string;
}
export function ListEmpty({
icon: Icon,
title,
message,
actionLabel,
onAction,
className = '',
}: ListEmptyProps) {
return (
<div className={`flex flex-col items-center justify-center py-20 text-center ${className}`}>
{Icon && (
<div className="mb-6 flex h-24 w-24 items-center justify-center rounded-full bg-blue-50 dark:bg-blue-900/20">
<Icon className="h-12 w-12 text-blue-500 dark:text-blue-400" />
</div>
)}
<h3 className="mb-2 text-lg font-semibold text-gray-900 dark:text-white">{title}</h3>
{message && (
<p className="mb-8 max-w-sm text-sm text-gray-500 dark:text-gray-400">{message}</p>
)}
{actionLabel && onAction && (
<button
onClick={onAction}
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"
>
{actionLabel}
</button>
)}
</div>
);
}
+26
View File
@@ -0,0 +1,26 @@
import { AlertTriangle, RefreshCw } from 'lucide-react';
interface ListErrorProps {
title?: string;
message: string;
onRetry: () => void;
}
export function ListError({ title = 'Unable to load', message, onRetry }: ListErrorProps) {
return (
<div className="flex flex-col items-center justify-center py-20 text-center">
<div className="mb-6 flex h-24 w-24 items-center justify-center rounded-full bg-red-50 dark:bg-red-900/20">
<AlertTriangle className="h-12 w-12 text-red-500 dark:text-red-400" />
</div>
<h3 className="mb-2 text-lg font-semibold text-gray-900 dark:text-white">{title}</h3>
<p className="mb-8 max-w-sm text-sm text-gray-500 dark:text-gray-400">{message}</p>
<button
onClick={onRetry}
className="inline-flex items-center gap-2 rounded-lg border border-gray-300 bg-white px-5 py-2.5 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 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700 dark:focus:ring-offset-gray-900"
>
<RefreshCw className="h-4 w-4" />
Retry
</button>
</div>
);
}
+52
View File
@@ -0,0 +1,52 @@
export function ListSkeleton({ rows = 8, className = '' }: { rows?: number; className?: string }) {
return (
<div className={`animate-pulse space-y-3 ${className}`}>
{Array.from({ length: rows }).map((_, i) => (
<div key={i} className="h-16 rounded-lg bg-gray-200 dark:bg-gray-800" />
))}
</div>
);
}
export function CardSkeleton({ count = 4 }: { count?: number }) {
return (
<div className="animate-pulse">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
{Array.from({ length: count }).map((_, i) => (
<div
key={i}
className="flex items-center gap-4 rounded-xl bg-white p-5 ring-1 ring-gray-200 dark:bg-gray-800 dark:ring-gray-700"
>
<div className="h-12 w-12 rounded-lg bg-gray-200 dark:bg-gray-700" />
<div className="flex-1 space-y-2">
<div className="h-3 w-20 rounded bg-gray-200 dark:bg-gray-700" />
<div className="h-6 w-16 rounded bg-gray-200 dark:bg-gray-700" />
</div>
</div>
))}
</div>
</div>
);
}
export function TableSkeleton({ rows = 5 }: { rows?: number }) {
return (
<div className="animate-pulse rounded-xl bg-white ring-1 ring-gray-200 dark:bg-gray-800 dark:ring-gray-700">
<div className="border-b border-gray-200 px-6 py-4 dark:border-gray-700">
<div className="h-6 w-40 rounded bg-gray-200 dark:bg-gray-700" />
</div>
<div className="space-y-3 p-6">
{Array.from({ length: rows }).map((_, i) => (
<div key={i} className="flex gap-4">
<div className="h-4 w-20 rounded bg-gray-200 dark:bg-gray-700" />
<div className="h-4 flex-1 rounded bg-gray-200 dark:bg-gray-700" />
<div className="h-4 w-24 rounded bg-gray-200 dark:bg-gray-700" />
<div className="h-4 w-20 rounded bg-gray-200 dark:bg-gray-700" />
<div className="h-4 w-28 rounded bg-gray-200 dark:bg-gray-700" />
<div className="h-4 w-24 rounded bg-gray-200 dark:bg-gray-700" />
</div>
))}
</div>
</div>
);
}
+29
View File
@@ -0,0 +1,29 @@
export default function LoadingSpinner({ text }: { text?: string }) {
return (
<div className="flex flex-col items-center justify-center gap-3">
<svg
className="h-8 w-8 animate-spin text-green-600"
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-8v4a4 4 0 00-4 4H4z"
/>
</svg>
{text && (
<p className="text-sm text-gray-500 dark:text-gray-400">{text}</p>
)}
</div>
);
}
+110
View File
@@ -0,0 +1,110 @@
import { createContext, useContext, useState, useCallback, useEffect, useRef, type ReactNode } from 'react';
import { CheckCircle, XCircle, Info, X } from 'lucide-react';
type ToastVariant = 'success' | 'error' | 'info';
interface Toast {
id: string;
message: string;
variant: ToastVariant;
}
interface ToastContextValue {
showToast: (message: string, variant?: ToastVariant) => void;
removeToast: (id: string) => void;
toasts: Toast[];
}
const ToastContext = createContext<ToastContextValue | null>(null);
const variantStyles: Record<ToastVariant, { bg: string; border: string; icon: ReactNode }> = {
success: {
bg: 'bg-green-50 dark:bg-green-900/30',
border: 'border-green-400 dark:border-green-600',
icon: <CheckCircle className="h-5 w-5 text-green-500 dark:text-green-400" />,
},
error: {
bg: 'bg-red-50 dark:bg-red-900/30',
border: 'border-red-400 dark:border-red-600',
icon: <XCircle className="h-5 w-5 text-red-500 dark:text-red-400" />,
},
info: {
bg: 'bg-blue-50 dark:bg-blue-900/30',
border: 'border-blue-400 dark:border-blue-600',
icon: <Info className="h-5 w-5 text-blue-500 dark:text-blue-400" />,
},
};
export function ToastProvider({ children }: { children: ReactNode }) {
const [toasts, setToasts] = useState<Toast[]>([]);
const removeToast = useCallback((id: string) => {
setToasts((prev) => prev.filter((t) => t.id !== id));
}, []);
const showToast = useCallback(
(message: string, variant: ToastVariant = 'success') => {
const id = `${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;
setToasts((prev) => [...prev, { id, message, variant }]);
},
[]
);
return (
<ToastContext.Provider value={{ showToast, removeToast, toasts }}>
{children}
{/* Toast container - fixed bottom-right */}
<div className="fixed bottom-4 right-4 z-50 flex flex-col gap-2 max-w-sm w-full pointer-events-none">
{toasts.map((toast) => (
<ToastItem
key={toast.id}
toast={toast}
onDismiss={() => removeToast(toast.id)}
/>
))}
</div>
</ToastContext.Provider>
);
}
function ToastItem({ toast, onDismiss }: { toast: Toast; onDismiss: () => void }) {
const { bg, border, icon } = variantStyles[toast.variant];
return (
<div
className={`pointer-events-auto flex items-start gap-3 rounded-lg border p-4 shadow-lg ${bg} ${border} animate-slide-in`}
role="alert"
>
<span className="mt-0.5 shrink-0">{icon}</span>
<p className="flex-1 text-sm text-gray-800 dark:text-gray-100">{toast.message}</p>
<button
onClick={onDismiss}
className="shrink-0 rounded p-0.5 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 transition-colors"
aria-label="Dismiss"
>
<X className="h-4 w-4" />
</button>
</div>
);
}
export function useToast(): ToastContextValue {
const ctx = useContext(ToastContext);
if (!ctx) {
throw new Error('useToast must be used within a <ToastProvider>');
}
return ctx;
}
// Also export a standalone auto-dismiss wrapper (for use outside a provider, though it expects one)
export function AutoDismissToast({ toast, onDismiss }: { toast: Toast; onDismiss: () => void }) {
const timerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
timerRef.current = window.setTimeout(onDismiss, 3000);
return () => clearTimeout(timerRef.current ?? undefined);
}, [onDismiss]);
return <ToastItem toast={toast} onDismiss={onDismiss} />;
}
@@ -0,0 +1,75 @@
import { describe, it, expect } from 'vitest';
import { render, screen } from '@testing-library/react';
import { ListSkeleton, CardSkeleton, TableSkeleton } from '../ListSkeleton';
import { ListError } from '../ListError';
import { ListEmpty } from '../ListEmpty';
import { Wrench } from 'lucide-react';
describe('ListSkeleton', () => {
it('renders default row count', () => {
const { container } = render(<ListSkeleton />);
const bars = container.querySelectorAll('.h-16');
expect(bars.length).toBe(8);
});
it('renders custom row count', () => {
const { container } = render(<ListSkeleton rows={3} />);
const bars = container.querySelectorAll('.h-16');
expect(bars.length).toBe(3);
});
});
describe('CardSkeleton', () => {
it('renders default card count', () => {
const { container } = render(<CardSkeleton />);
const cards = container.querySelectorAll('.rounded-xl');
expect(cards.length).toBe(4);
});
it('renders custom card count', () => {
const { container } = render(<CardSkeleton count={2} />);
const cards = container.querySelectorAll('.rounded-xl');
expect(cards.length).toBe(2);
});
});
describe('TableSkeleton', () => {
it('renders default row count', () => {
const { container } = render(<TableSkeleton />);
const rows = container.querySelectorAll('.flex.gap-4');
expect(rows.length).toBe(5);
});
});
describe('ListError', () => {
it('renders title, message, and retry button', () => {
const onRetry = () => {};
render(<ListError title="Test error" message="Something broke" onRetry={onRetry} />);
expect(screen.getByText('Test error')).toBeTruthy();
expect(screen.getByText('Something broke')).toBeTruthy();
expect(screen.getByText('Retry')).toBeTruthy();
});
it('renders default title when not provided', () => {
render(<ListError message="No title" onRetry={() => {}} />);
expect(screen.getByText('Unable to load')).toBeTruthy();
});
});
describe('ListEmpty', () => {
it('renders title, message, and action button', () => {
const onAction = () => {};
render(
<ListEmpty icon={Wrench} title="Nothing here" message="No items found." actionLabel="Create" onAction={onAction} />,
);
expect(screen.getByText('Nothing here')).toBeTruthy();
expect(screen.getByText('No items found.')).toBeTruthy();
expect(screen.getByText('Create')).toBeTruthy();
});
it('renders without action when not provided', () => {
render(<ListEmpty title="Just a title" />);
expect(screen.getByText('Just a title')).toBeTruthy();
expect(screen.queryByRole('button')).toBeNull();
});
});
+62
View File
@@ -0,0 +1,62 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { renderHook, waitFor, act } from '@testing-library/react';
import { usePagedList, type PagedResult } from '../usePagedList';
function fakePage<T>(items: T[], page: number, totalPages: number): PagedResult<T> {
return { items, page, perPage: 10, totalItems: totalPages * 10, totalPages };
}
describe('usePagedList', () => {
beforeEach(() => {
vi.spyOn(console, 'error').mockImplementation(() => {});
});
it('loads the first page on mount', async () => {
const fetchPage = vi.fn().mockResolvedValue(fakePage([1, 2], 1, 3));
const { result } = renderHook(() => usePagedList(fetchPage, 10));
await waitFor(() => expect(result.current.loading).toBe(false));
expect(result.current.items).toEqual([1, 2]);
expect(result.current.hasMore).toBe(true);
expect(fetchPage).toHaveBeenCalledWith(1, 10);
});
it('loadMore appends items and advances page', async () => {
const fetchPage = vi.fn()
.mockResolvedValueOnce(fakePage([1, 2], 1, 3))
.mockResolvedValueOnce(fakePage([3, 4], 2, 3));
const { result } = renderHook(() => usePagedList(fetchPage, 10));
await waitFor(() => expect(result.current.loading).toBe(false));
act(() => { result.current.loadMore(); });
await waitFor(() => expect(result.current.items).toEqual([1, 2, 3, 4]));
expect(fetchPage).toHaveBeenCalledTimes(2);
});
it('surfaces error state', async () => {
const fetchPage = vi.fn().mockRejectedValue(new Error('network'));
const { result } = renderHook(() => usePagedList(fetchPage, 10));
await waitFor(() => expect(result.current.loading).toBe(false));
expect(result.current.error).toBe(true);
expect(result.current.items).toEqual([]);
});
it('refresh resets to page 1', async () => {
const fetchPage = vi.fn()
.mockResolvedValueOnce(fakePage([1], 1, 2))
.mockResolvedValueOnce(fakePage([5], 1, 1));
const { result } = renderHook(() => usePagedList(fetchPage, 10));
await waitFor(() => expect(result.current.loading).toBe(false));
expect(result.current.items).toEqual([1]);
act(() => { result.current.refresh(); });
await waitFor(() => expect(result.current.items).toEqual([5]));
});
});
+82
View File
@@ -0,0 +1,82 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { logError } from '../lib/userMessages';
export interface PagedResult<T> {
items: T[];
page: number;
perPage: number;
totalItems: number;
totalPages: number;
}
export function usePagedList<T>(
fetchPage: (page: number, perPage: number) => Promise<PagedResult<T>>,
perPage = 50,
) {
const [items, setItems] = useState<T[]>([]);
const [page, setPage] = useState(1);
const [totalItems, setTotalItems] = useState(0);
const [totalPages, setTotalPages] = useState(1);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(false);
// Store fetchPage in a ref so the effect only fires once — prevents infinite
// re-fetch loops when callers pass inline arrow functions as fetchPage.
const fetchRef = useRef(fetchPage);
fetchRef.current = fetchPage;
const load = useCallback(async (p: number, append: boolean) => {
setLoading(true);
setError(false);
try {
const result = await fetchRef.current(p, perPage);
setItems(prev => append ? [...prev, ...result.items] : result.items);
setPage(result.page);
setTotalItems(result.totalItems);
setTotalPages(result.totalPages || 1);
} catch (err) {
logError('usePagedList fetch failed', err);
setError(true);
} finally {
setLoading(false);
}
}, [perPage]);
useEffect(() => { load(1, false); }, [load]);
const loadMore = useCallback(() => {
if (page < totalPages && !loading) load(page + 1, true);
}, [page, totalPages, loading, load]);
const refresh = useCallback(() => load(1, false), [load]);
const setItemsDirect = useCallback((updater: T[] | ((prev: T[]) => T[])) => {
setItems(updater);
}, []);
const adjustTotal = useCallback((delta: number) => {
setTotalItems((prev) => Math.max(0, prev + delta));
}, []);
const replaceItem = useCallback((id: string, newItem: T, idFn: (item: T) => string) => {
setItems((prev) => {
const idx = prev.findIndex((item) => idFn(item) === id);
if (idx === -1) return prev;
const next = [...prev];
next[idx] = newItem;
return next;
});
}, []);
const removeItem = useCallback((id: string, idFn: (item: T) => string) => {
setItems((prev) => {
const idx = prev.findIndex((item) => idFn(item) === id);
if (idx === -1) return prev;
const next = [...prev];
next.splice(idx, 1);
return next;
});
}, []);
return { items, setItems: setItemsDirect, loading, error, hasMore: page < totalPages, totalItems, totalPages, loadMore, refresh, adjustTotal, replaceItem, removeItem };
}
+24
View File
@@ -0,0 +1,24 @@
@import "tailwindcss";
@custom-variant dark (&:where(.dark, .dark *));
@theme {
--color-primary: #16a34a;
--color-primary-dark: #15803d;
--color-surface: #ffffff;
--color-surface-dark: #1a1a2e;
--color-card: #f8fafc;
--color-card-dark: #16213e;
}
body {
@apply bg-gray-50 text-gray-900 dark:bg-gray-950 dark:text-gray-100 antialiased;
}
@keyframes toast-slide-in {
from { transform: translateX(100%); opacity: 0; }
to { transform: translateX(0); opacity: 1; }
}
.animate-slide-in {
animation: toast-slide-in 0.3s ease-out;
}
+37
View File
@@ -0,0 +1,37 @@
import { describe, it, expect } from 'vitest';
// Unit tests for PDF helper functions
// Since jsPDF requires a browser environment, we test the pure utility functions
describe('PDF utility functions', () => {
it('should format dates correctly', () => {
const dateStr = '2024-01-15T10:00:00.000Z';
const result = new Date(dateStr).toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
});
expect(result).toBe('Jan 15, 2024');
});
it('should calculate line height from font size', () => {
const fontSize = 10;
const multiplier = 1.2;
const lineHeight = Math.ceil(fontSize * multiplier);
expect(lineHeight).toBe(12);
});
it('should return default accent color for invalid hex', () => {
const hex = 'not-a-color';
const isValid = /^#[0-9a-fA-F]{6}$/.test(hex);
expect(isValid).toBe(false);
});
it('should validate correct hex colors', () => {
expect(/^#[0-9a-fA-F]{6}$/.test('#16a34a')).toBe(true);
expect(/^#[0-9a-fA-F]{6}$/.test('#ffffff')).toBe(true);
expect(/^#[0-9a-fA-F]{6}$/.test('#000000')).toBe(true);
expect(/^#[0-9a-fA-F]{6}$/.test('#fff')).toBe(false);
expect(/^#[0-9a-fA-F]{6}$/.test('16a34a')).toBe(false);
});
});
+68
View File
@@ -0,0 +1,68 @@
import { describe, expect, it } from 'vitest';
import { mergeApprovedQuoteServicesIntoRO } from '../quoteRoSync';
import type { QuoteService, ROService } from '../../types';
const quoteService = (overrides: Partial<QuoteService> = {}): QuoteService => ({
id: 'quote-service-1',
name: 'Brake Pads',
price: 240,
selected: true,
approved: true,
customerDecision: 'approved',
applyShopCharge: true,
...overrides,
});
const roService = (overrides: Partial<ROService> = {}): ROService => ({
id: 'ro-service-1',
name: 'Brake Pads',
description: 'Existing work',
laborHours: 1,
laborRate: 100,
partsCost: 60,
total: 160,
status: 'in_progress',
technician: 'Sam',
applyShopCharge: true,
...overrides,
});
describe('quote RO sync', () => {
it('updates an originating RO service by source id and preserves workflow fields', () => {
const result = mergeApprovedQuoteServicesIntoRO(
[roService()],
[quoteService({ id: 'ro-ro-1-ro-service-1', sourceROServiceId: 'ro-service-1', price: 275, applyShopCharge: false })],
{ defaultLaborRate: 125 },
'ro-1',
);
expect(result.syncedCount).toBe(1);
expect(result.services).toHaveLength(1);
expect(result.services[0]).toMatchObject({
id: 'ro-service-1',
total: 275,
status: 'in_progress',
technician: 'Sam',
applyShopCharge: false,
});
});
it('adds newly approved recommendations as full-price RO services', () => {
const result = mergeApprovedQuoteServicesIntoRO(
[],
[quoteService({ id: 'new-rec', name: 'Brake Flush', price: 180 })],
{ defaultLaborRate: 120 },
'ro-1',
);
expect(result.services).toHaveLength(1);
expect(result.services[0]).toMatchObject({
id: 'qs-new-rec',
name: 'Brake Flush',
laborHours: 1.5,
laborRate: 120,
total: 180,
applyShopCharge: true,
});
});
});
+52
View File
@@ -0,0 +1,52 @@
import { describe, it, expect } from 'vitest';
import { parseRichText, hasRichText } from '../richtext';
describe('parseRichText', () => {
it('returns empty for empty input', () => {
expect(parseRichText('')).toEqual([]);
});
it('returns a single plain segment for plain text', () => {
expect(parseRichText('hello world')).toEqual([{ text: 'hello world', style: 'plain' }]);
});
it('extracts an accent segment', () => {
expect(parseRichText('a {accent}b{/accent} c')).toEqual([
{ text: 'a ', style: 'plain' },
{ text: 'b', style: 'accent' },
{ text: ' c', style: 'plain' },
]);
});
it('handles muted and accent side by side', () => {
expect(parseRichText('{accent}red{/accent} {muted}grey{/muted}')).toEqual([
{ text: 'red', style: 'accent' },
{ text: ' ', style: 'plain' },
{ text: 'grey', style: 'muted' },
]);
});
it('preserves unknown template placeholders verbatim', () => {
expect(parseRichText('valid for {days} days')).toEqual([
{ text: 'valid for {days} days', style: 'plain' },
]);
});
it('renders stray closing tags literally (merged with adjacent plain)', () => {
expect(parseRichText('oops {/accent}')).toEqual([
{ text: 'oops {/accent}', style: 'plain' },
]);
});
it('handles bold nested under accent', () => {
expect(parseRichText('{accent}a {bold}b{/bold}{/accent}')).toEqual([
{ text: 'a ', style: 'accent' },
{ text: 'b', style: 'accent' },
]);
});
it('detects rich text presence', () => {
expect(hasRichText('plain text')).toBe(false);
expect(hasRichText('{accent}hi{/accent}')).toBe(true);
});
});
+117
View File
@@ -0,0 +1,117 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
const mockGetOne = vi.fn();
const mockUpdate = vi.fn();
vi.mock('../pocketbase', () => ({
pb: {
collection: () => ({
getOne: mockGetOne,
update: mockUpdate,
}),
},
}));
import { StaleWriteError, mutateAssignment } from '../technicianData';
function fakeRecord(overrides: Record<string, unknown> = {}) {
return {
id: 'assign-1',
shopUserId: 'shop-1',
technicianUserId: 'tech-1',
repairOrderId: 'ro-1',
roNumber: 'RO-001',
vehicleInfo: '2024 Ford F-150',
vin: '1FTFW1E51MFA12345',
mileage: '45000',
serviceLocation: '',
status: 'in_progress',
services: JSON.stringify([
{ id: 'svc-1', name: 'Oil Change', description: '', status: 'pending', subStatus: '', technicianNotes: '' },
]),
internalNotes: '',
clockEntries: '[]',
inspectionChecklist: '[]',
advisorRequests: '[]',
created: '2026-07-06T10:00:00.000Z',
updated: '2026-07-06T10:00:00.000Z',
...overrides,
};
}
describe('StaleWriteError', () => {
it('has the correct name', () => {
const err = new StaleWriteError();
expect(err.name).toBe('StaleWriteError');
});
it('has the default message', () => {
const err = new StaleWriteError();
expect(err.message).toContain('just edited');
});
it('accepts a custom message', () => {
const err = new StaleWriteError('custom');
expect(err.message).toBe('custom');
});
});
describe('mutateAssignment', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('applies patch and __expectedUpdated on success', async () => {
const record = fakeRecord();
mockGetOne.mockResolvedValue(record);
mockUpdate.mockResolvedValue(record);
const result = await mutateAssignment('assign-1', (_current) => ({
status: 'completed',
}));
expect(mockGetOne).toHaveBeenCalledWith('assign-1');
expect(mockUpdate).toHaveBeenCalledWith('assign-1', {
status: 'completed',
__expectedUpdated: record.updated,
});
expect(result).toBeDefined();
expect(result.id).toBe('assign-1');
});
it('throws StaleWriteError on 409 conflict', async () => {
mockGetOne.mockResolvedValue(fakeRecord());
mockUpdate.mockRejectedValue({ status: 409, message: 'optimistic lock' });
await expect(
mutateAssignment('assign-1', () => ({ status: 'completed' })),
).rejects.toThrow(StaleWriteError);
});
it('re-throws non-409 errors', async () => {
mockGetOne.mockResolvedValue(fakeRecord());
const networkError = new Error('Network error');
mockUpdate.mockRejectedValue(networkError);
await expect(
mutateAssignment('assign-1', () => ({ status: 'completed' })),
).rejects.toThrow('Network error');
});
it('uses the mutator result as the patch', async () => {
const record = fakeRecord();
mockGetOne.mockResolvedValue(record);
mockUpdate.mockResolvedValue(record);
await mutateAssignment('assign-1', (current) => ({
services: JSON.stringify(
current.services.map((s: { id: string }) =>
s.id === 'svc-1' ? { ...s, status: 'completed' } : s,
),
),
}));
const patch = mockUpdate.mock.calls[0][1];
expect(JSON.parse(patch.services)[0].status).toBe('completed');
});
});
+30
View File
@@ -0,0 +1,30 @@
import { describe, expect, it } from 'vitest';
import { computeSplitQuoteTotals } from '../totals';
import type { QuoteService } from '../../types';
const service = (id: string, price: number, customerDecision: QuoteService['customerDecision'], applyShopCharge?: boolean): QuoteService => ({
id,
name: `Service ${id}`,
price,
selected: true,
approved: customerDecision === 'approved',
customerDecision,
...(applyShopCharge === undefined ? {} : { applyShopCharge }),
});
describe('quote totals', () => {
it('applies shop charge to approved transferred services unless explicitly disabled', () => {
const totals = computeSplitQuoteTotals(
[
service('approved', 100, 'approved'),
service('pending', 50, 'pending', false),
],
{ value: 0, type: 'dollar' },
{ taxRate: 0, shopChargeRate: 3, shopChargeCap: 69.95 },
);
expect(totals.approved.shopCharge).toBe(3);
expect(totals.approved.total).toBe(103);
expect(totals.pending.shopCharge).toBe(0);
});
});
+19
View File
@@ -0,0 +1,19 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { swallow, swallowAsync } from '../userMessages';
describe('swallow / swallowAsync', () => {
beforeEach(() => { vi.spyOn(console, 'error').mockImplementation(() => {}); });
it('returns value on success', () => {
expect(swallow<number | string>('ctx', 'fallback', () => 42)).toBe(42);
});
it('returns fallback on throw', () => {
expect(swallow('ctx', 'fallback', () => { throw new Error('x'); })).toBe('fallback');
});
it('async returns value on success', async () => {
expect(await swallowAsync<number | string>('ctx', 'fallback', async () => 7)).toBe(7);
});
it('async returns fallback on rejection', async () => {
expect(await swallowAsync('ctx', 'fallback', async () => { throw new Error('x'); })).toBe('fallback');
});
});
+312
View File
@@ -0,0 +1,312 @@
/**
* AI service module — calls /deepseek/v1/chat/completions
* Ported from original api.js
*/
import { pb } from './pocketbase';
export interface ServiceItem {
name: string;
recommendation?: string;
}
export interface PriorityResult {
name: string;
rank: number;
priority_reason: string;
}
export interface ExplanationResult {
level: string;
explanation: string;
}
export interface ExplanationParams {
serviceName: string;
recommendation: string;
technicianNotes?: string;
vehicleInfo?: string;
mileage?: number;
maintenanceInterval?: number;
}
export interface CatalogDescriptionParams {
serviceName: string;
category?: string;
existingDescription?: string;
}
export interface SuggestVehicle {
vehicleInfo: string;
mileage: number;
vin?: string;
}
export interface CatalogItem {
name: string;
category: string;
price: number;
maintenanceInterval?: number;
}
export interface SuggestResult {
name: string;
reason: string;
}
// ─── Internal helper ────────────────────────────────────────
const AI_ENABLED = import.meta.env.VITE_AI_ENABLED !== 'false';
async function callDeepSeek(
systemPrompt: string,
userContent: string,
maxTokens = 500,
temperature = 0,
): Promise<string | null> {
if (!AI_ENABLED) return null;
try {
const res = await fetch('/deepseek/v1/chat/completions', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: pb.authStore.token ? `Bearer ${pb.authStore.token}` : '',
},
body: JSON.stringify({
model: 'deepseek-v4-flash',
messages: [
{ role: 'system', content: systemPrompt },
{ role: 'user', content: userContent },
],
temperature,
thinking: { type: 'disabled' as const },
max_tokens: maxTokens,
}),
});
if (res.status === 401) {
console.error('DeepSeek proxy: unauthorized (PB token invalid or missing)');
return null;
}
if (res.status === 429) {
console.error('DeepSeek proxy: rate limited');
return null;
}
if (!res.ok) {
console.error(`DeepSeek proxy error: ${res.status} ${res.statusText}`);
return null;
}
const data = await res.json();
const content = data?.choices?.[0]?.message?.content;
if (!content) {
console.error('No content in DeepSeek response');
return null;
}
return content;
} catch (err) {
console.error('DeepSeek call failed:', err);
return null;
}
}
function stripCodeBlocks(text: string): string {
return text.trim().replace(/```json\s*/gi, '').replace(/```\s*/g, '').trim();
}
// ─── 1. Generate Priorities ─────────────────────────────────
/**
* Sends a list of services to the AI for priority ranking.
* Safety-first: CRITICAL_SAFETY > SAFETY_CONCERN > RECOMMENDED > MAINTENANCE > OPTIONAL
*/
export async function getPriorityAnalysis(
services: ServiceItem[],
): Promise<PriorityResult[] | null> {
if (!services?.length) return null;
const servicesToAnalyze = services.map((s) => ({
name: s.name,
recommendation: s.recommendation,
}));
const systemPrompt =
'You are an expert automotive service advisor. Prioritize services with customer safety first. Return ONLY a valid JSON array, no markdown, no explanation outside the JSON.';
const userPrompt = `You are an expert automotive service advisor. Your primary goal is to ensure customer safety. Based on the following list of services, you must create a prioritized list. The prioritization has two tiers:
1. **Customer Safety:** First, identify all services that are critical for customer safety. These include, but are not limited to, issues with brakes, tires, steering, suspension, and anything that could lead to a loss of vehicle control. Rank these items amongst themselves.
2. **Vehicle Health:** After all safety-critical items are listed, list the remaining services. Prioritize these based on their importance to the vehicle's long-term health and preventing expensive future repairs (e.g., timing belts, oil leaks are more important than cabin air filters).
Provide your response ONLY as a single JSON array of objects, with the final ranking reflected in the 'rank' key (from 1 to N). Each object must have three keys: 'name' (the original service name), 'rank' (the final calculated rank), and 'priority_reason' (a brief, one-sentence explanation for why it has that priority, mentioning if it is a safety concern).
Do not add any other text or explanation outside of the JSON array.
Here is the list of services to analyze:
${JSON.stringify(servicesToAnalyze)}`;
const raw = await callDeepSeek(systemPrompt, userPrompt);
if (!raw) return null;
try {
const jsonText = stripCodeBlocks(raw);
const analysis: PriorityResult[] = JSON.parse(jsonText);
analysis.sort((a, b) => a.rank - b.rank);
return analysis;
} catch (err) {
console.error('Failed to parse priority analysis:', err, 'Raw:', raw);
return null;
}
}
// ─── 2. AI Write Explanation ────────────────────────────────
/**
* Generates a professional customer-facing explanation for a service.
* Incorporates vehicle context, mileage, technician notes.
*/
export async function aiWriteExplanation(
params: ExplanationParams,
): Promise<ExplanationResult | null> {
const { serviceName, recommendation, technicianNotes, vehicleInfo, mileage, maintenanceInterval } = params;
if (!serviceName || !recommendation) return null;
let additionalContext = '';
const mileageNum = mileage ?? 0;
if (technicianNotes) {
additionalContext += `\nTechnician's internal notes: "${technicianNotes}"`;
}
if (vehicleInfo) {
additionalContext += `\nVehicle: ${vehicleInfo}`;
}
if (maintenanceInterval && mileageNum > 0) {
const lastInterval = Math.floor(mileageNum / maintenanceInterval) * maintenanceInterval;
const nextInterval = lastInterval + maintenanceInterval;
const milesUntilNext = nextInterval - mileageNum;
const occurrences = Math.floor(mileageNum / maintenanceInterval);
additionalContext += `\nCurrent mileage: ${mileageNum.toLocaleString()} miles`;
additionalContext += `\nService interval: every ${maintenanceInterval.toLocaleString()} miles.`;
if (occurrences >= 1) {
const s = ['th', 'st', 'nd', 'rd'];
const v = occurrences % 100;
const ordinal = occurrences + (s[(v - 20) % 10] || s[v] || s[0]);
additionalContext += ` This would be the ${ordinal} time this service is performed.`;
}
if (milesUntilNext === 0) {
additionalContext += ` Service is due now. Do NOT say past due — it is exactly at its interval.`;
} else if (milesUntilNext > 0) {
additionalContext += ` Next interval at ${nextInterval.toLocaleString()} miles (${milesUntilNext.toLocaleString()} from now).`;
} else {
additionalContext += ` Service was due at ${lastInterval.toLocaleString()} miles (${Math.abs(milesUntilNext).toLocaleString()} ago).`;
}
} else if (mileageNum > 0) {
additionalContext += `\nVehicle mileage (context only — do NOT mention unless the service is mileage-based): ${mileageNum.toLocaleString()} miles`;
}
const systemPrompt = `A customer's vehicle needs a service called "${serviceName}". The technician's reason for this recommendation is: "${recommendation}".${additionalContext ? '\n\nADDITIONAL CONTEXT FROM THE TECHNICIAN:' + additionalContext : ''}
Your task is to:
1. Determine the appropriate recommendation level based on the reason and any additional context provided (CRITICAL, RECOMMENDED, OPTIONAL, PREVENTIVE, or MAINTENANCE)
2. Write a professional explanation as if a master technician is explaining to a customer
3. If technician notes are provided, incorporate that specific information into the explanation
4. If mileage and interval data are provided, mention whether the service is due now, approaching, or past due. If mileage is provided WITHOUT interval data, do NOT mention the mileage unless the service itself is mileage-based.
Response format:
LEVEL: [Choose one: CRITICAL, RECOMMENDED, OPTIONAL, PREVENTIVE, MAINTENANCE]
EXPLANATION: [Write 2-4 sentences explaining the service, safety implications, and consequences of neglect using qualifying words like "potentially", "may", "could". Frame risks helpfully, not alarmingly.]`;
const raw = await callDeepSeek(systemPrompt, `${serviceName}\n${recommendation}${additionalContext}`, 400);
if (!raw) return null;
const levelMatch = raw.match(/LEVEL:\s*(CRITICAL|RECOMMENDED|OPTIONAL|PREVENTIVE|MAINTENANCE)/i);
const explanationMatch = raw.match(/EXPLANATION:\s*(.+)/s);
if (levelMatch && explanationMatch) {
return {
level: levelMatch[1].toUpperCase(),
explanation: explanationMatch[1].trim(),
};
}
// Fallback: use raw response as explanation
console.warn('Could not parse AI response format, using raw');
return { level: 'RECOMMENDED', explanation: raw.trim() };
}
// ─── 2b. AI Write Catalog Description ───────────────────────
export async function aiWriteCatalogDescription(
params: CatalogDescriptionParams,
): Promise<string | null> {
const { serviceName, category, existingDescription } = params;
if (!serviceName.trim()) return null;
const context = JSON.stringify({
serviceName: serviceName.trim(),
category: category?.trim() || '',
existingDescription: existingDescription?.trim() || '',
});
const systemPrompt = `You write short, professional service catalog descriptions for an automotive repair shop. Return ONLY the description text, no quotes, labels, markdown, or bullet points. Keep it to 1-3 sentences and make it suitable for reuse in customer-facing quotes and internal catalog lists.`;
const userPrompt = `Write a concise service description using this context: ${context}`;
const raw = await callDeepSeek(systemPrompt, userPrompt, 220, 0.4);
return raw?.trim() || null;
}
// ─── 3. AI Suggest Services ─────────────────────────────────
/**
* Suggests additional services based on vehicle info, mileage, and service catalog.
* Only recommends services from the provided catalog.
*/
export async function aiSuggestServices(
vehicle: SuggestVehicle,
selectedServices: string[],
catalog: CatalogItem[],
): Promise<SuggestResult[] | null> {
if (!catalog?.length) return null;
const userData = JSON.stringify({
vehicle: vehicle.vehicleInfo || 'Not specified',
mileage: vehicle.mileage || 'Not specified',
mileageNumber: vehicle.mileage || 0,
vin: vehicle.vin || 'Not specified',
alreadySelectedServices: selectedServices,
availableServicesCatalog: catalog,
});
const systemPrompt = `You are an ASE-certified master technician recommending preventive maintenance services. You ONLY know the vehicle year/make/model, mileage, and which services are already selected. You have NOT inspected this vehicle and do NOT know its actual condition.
RULES:
- ONLY suggest services based on MILEAGE-BASED MAINTENANCE INTERVALS or COMPLEMENTARY PAIRINGS to already-selected services.
- NEVER invent or assume a condition. Do NOT say anything is "leaking," "worn," "cracked," "failing," "low," "dirty," or "due" unless referencing a manufacturer mileage interval.
- Every reason MUST cite either: (a) a mileage milestone, or (b) a known complementary pairing.
MILEAGE-BASED RECOMMENDATIONS:
- 30K-60K miles: transmission service, coolant flush, brake fluid exchange, cabin/air filters, spark plugs
- 60K-90K miles: timing belt, water pump, major fluid services, serpentine belt
- 90K-120K miles: major scheduled maintenance, drive belts, hoses
Return ONLY a JSON array of objects with "name" (exact catalog service name) and "reason" (1 sentence explaining why). No markdown, no intro.`;
const userContent = `Vehicle data and catalog:\n${userData}\n\nSuggest up to 3 additional services.`;
const raw = await callDeepSeek(systemPrompt, userContent, 500, 0.2);
if (!raw) return null;
try {
const jsonText = stripCodeBlocks(raw);
const suggestions: { name: string; reason: string }[] = JSON.parse(jsonText);
// Cross-reference against catalog — only return catalog matches
const catalogNames = new Set(catalog.map((c) => c.name.toLowerCase()));
return suggestions.filter((s) => catalogNames.has(s.name.toLowerCase()));
} catch (err) {
console.error('Failed to parse AI suggestions:', err, 'Raw:', raw);
return null;
}
}
+219
View File
@@ -0,0 +1,219 @@
export function generateRONumber(): string {
const now = new Date();
const y = now.getFullYear().toString().slice(2);
const m = String(now.getMonth() + 1).padStart(2, '0');
const d = String(now.getDate()).padStart(2, '0');
const seq = String(Math.floor(Math.random() * 9999)).padStart(4, '0');
return `RO-${y}${m}${d}-${seq}`;
}
export function formatDateDisplay(iso: string): string {
if (!iso) return '\u2014';
const d = new Date(iso + (iso.includes('T') ? '' : 'T00:00:00'));
return new Intl.DateTimeFormat('en-US', {
weekday: 'long',
month: 'long',
day: 'numeric',
year: 'numeric',
}).format(d);
}
export function formatTimeShort(time: string): string {
if (!time) return '';
const [h, m] = time.split(':').map(Number);
if (isNaN(h) || isNaN(m)) return time;
const ampm = h >= 12 ? 'PM' : 'AM';
const h12 = h % 12 || 12;
return `${h12}:${m.toString().padStart(2, '0')} ${ampm}`;
}
export function formatArrivalWindow(time: string, windowMinutes?: number): string {
if (!time) return '';
const [h, m] = time.split(':').map(Number);
if (isNaN(h) || isNaN(m)) return formatTimeShort(time);
const startMin = h * 60 + m;
const endMin = startMin + (windowMinutes ?? 0);
const endH = Math.floor(endMin / 60) % 24;
const endM = endMin % 60;
const ampm = endH >= 12 ? 'PM' : 'AM';
const h12 = h % 12 || 12;
const start = `${h12}:${m.toString().padStart(2, '0')} ${ampm}`;
if (!windowMinutes || windowMinutes <= 0) return start;
const endAmpm = endH >= 12 ? 'PM' : 'AM';
const endH12 = endH % 12 || 12;
return `${start} \u2013 ${endH12}:${endM.toString().padStart(2, '0')} ${endAmpm}`;
}
export function formatDurationShort(minutes?: number): string {
if (minutes == null || minutes <= 0) return '';
const hrs = Math.floor(minutes / 60);
const mins = minutes % 60;
if (hrs === 0) return `${mins}m`;
if (mins === 0) return `${hrs}h`;
return `${hrs}h ${mins}m`;
}
export function splitAppointmentDateTime(value: string): { date: string; time: string } {
if (!value) return { date: '', time: '' };
if (value.includes('T')) {
const d = new Date(value);
const date = `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
const time = `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`;
return { date, time };
}
return { date: value, time: '' };
}
export function combineAppointmentDateTime(date: string, time: string): string {
if (!date) return '';
if (!time) return date;
return `${date}T${time}:00`;
}
export function extractVinFromNotes(notes: string): string {
const match = notes.match(/vin[:\s]*([A-HJ-NPR-Z0-9]{17})/i);
return match ? match[1].toUpperCase() : '';
}
export function extractDurationFromNotes(notes: string): number {
const match = notes.match(/(\d+)\s*(?:hr|hour|hours)/i);
return match ? parseInt(match[1], 10) * 60 : 60;
}
export function extractServiceLocationFromNotes(notes: string): string {
const match = notes.match(/location[:\s]+(.+)/i);
return match ? match[1].trim() : '';
}
export function extractTravelFeeFromNotes(notes: string): number {
const match = notes.match(/travel\s*fee[:\s]*\$?([\d.]+)/i);
return match ? parseFloat(match[1]) : 0;
}
export function extractTripMilesFromNotes(notes: string): number {
const match = notes.match(/(\d+)\s*miles?/i);
return match ? parseInt(match[1], 10) : 0;
}
export function extractArrivalWindowFromNotes(notes: string): number {
const match = notes.match(/window[:\s]*(\d+)/i);
return match ? parseInt(match[1], 10) : 0;
}
export function stripStructuredAppointmentNotes(notes: string): string {
return notes
.replace(/vin[:\s]*[A-HJ-NPR-Z0-9]{17}/gi, '')
.replace(/location[:\s]+.+/gi, '')
.replace(/travel\s*fee[:\s]*\$?[\d.]+/gi, '')
.replace(/\d+\s*miles?/gi, '')
.replace(/window[:\s]*\d+/gi, '')
.replace(/\d+\s*(?:hr|hour|hours)/gi, '')
.replace(/\s{2,}/g, ' ')
.trim();
}
export function normalizeTimeToHHMM(time: string): string {
if (!time) return '';
if (/^\d{2}:\d{2}$/.test(time)) return time;
const cleaned = time.replace(/[^0-9a-zA-Z:]/g, ' ').trim();
const match = cleaned.match(/(\d{1,2})\s*[:.]?\s*(\d{0,2})\s*(am|pm)/i);
if (match) {
let hours = parseInt(match[1], 10);
const minutes = match[2] ? parseInt(match[2].padEnd(2, '0'), 10) : 0;
const isPM = match[3].toLowerCase() === 'pm';
if (isPM && hours !== 12) hours += 12;
if (!isPM && hours === 12) hours = 0;
return `${String(hours).padStart(2, '0')}:${String(minutes).padStart(2, '0')}`;
}
const hMatch = cleaned.match(/(\d{1,2})/);
return hMatch ? `${String(parseInt(hMatch[1], 10)).padStart(2, '0')}:00` : '';
}
export function getDateString(dateStr: string): string {
if (!dateStr) return '';
if (dateStr.includes('T')) return dateStr.split('T')[0];
return dateStr;
}
export function todayISO(): string {
const d = new Date();
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
}
export function tomorrowISO(): string {
const d = new Date();
d.setDate(d.getDate() + 1);
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
}
export function weekEndISO(): string {
const d = new Date();
const dayOfWeek = d.getDay();
const diff = dayOfWeek === 0 ? 0 : 7 - dayOfWeek;
d.setDate(d.getDate() + diff);
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}`;
}
export function isValidDate(str: string): boolean {
if (!str) return false;
const d = new Date(str + 'T00:00:00');
return !isNaN(d.getTime());
}
export function buildAppointmentPayload(
data: Record<string, unknown>,
extra: Record<string, unknown>,
): Record<string, unknown> {
const s = (v: unknown): string => (v ?? '').toString();
const n = (v: unknown, d: number): number => { const x = Number(v); return isNaN(x) ? d : x; };
const services: Record<string, unknown>[] = [];
return {
customerId: s(data.customerId),
customerName: s(data.customerName),
customerPhone: s(data.customerPhone).replace(/\D/g, ''),
customerEmail: s(data.customerEmail),
vehicleInfo: s(data.vehicleInfo),
vin: s(data.vin).toUpperCase(),
date: s(data.date),
time: s(data.time),
arrivalWindowMinutes: n(data.arrivalWindowMinutes, 0),
tripMiles: n(data.tripMiles, 0),
durationMinutes: n(data.durationMinutes, 60),
advisorName: s(data.advisorName),
serviceType: s(data.serviceType),
serviceLocation: s(data.serviceLocation),
notes: s(data.notes),
services: JSON.stringify(services),
...extra,
};
}
export function normalizeAppointmentRecord(record: any): import('../types').Appointment {
try {
if (record.services && typeof record.services === 'string') {
record.services = JSON.parse(record.services);
}
} catch {}
return {
id: record.id || '',
customerId: record.customerId || '',
customerName: record.customerName || '',
customerPhone: record.customerPhone || '',
customerEmail: record.customerEmail || '',
vehicleInfo: record.vehicleInfo || '',
vin: record.vin || '',
date: getDateString(record.date || ''),
time: record.time || '',
durationMinutes: record.durationMinutes ?? 60,
advisorName: record.advisorName || '',
serviceType: record.serviceType || '',
serviceLocation: record.serviceLocation || '',
status: record.status || 'scheduled',
notes: record.notes || '',
repairOrderId: record.repairOrderId || '',
arrivalWindowMinutes: record.arrivalWindowMinutes ?? 0,
tripMiles: record.tripMiles ?? 0,
created: record.created || '',
updated: record.updated || '',
};
}
+10
View File
@@ -0,0 +1,10 @@
export function openEmailDraft(to: string, subject: string, body: string) {
const href = `mailto:${encodeURIComponent(to)}?subject=${encodeURIComponent(subject)}&body=${encodeURIComponent(body)}`;
window.location.href = href;
}
export function openSmsDraft(to: string, body: string) {
const numbersOnly = to.replace(/\D/g, '');
const href = `sms:${numbersOnly}?&body=${encodeURIComponent(body)}`;
window.location.href = href;
}
+109
View File
@@ -0,0 +1,109 @@
/**
* Helpers for first / middle / last name handling across appointment & RO forms.
*/
/**
* Parse a full-name string into first, middle, and last parts.
* Heuristic: first word → firstName, last word → lastName, rest → middleName.
* If there's only one word, it goes into firstName.
* If there are two words, they are firstName and lastName.
* If three or more, middle is everything between first and last.
*/
export function parseName(
fullName: string,
): { firstName: string; middleName: string; lastName: string } {
const parts = fullName.trim().split(/\s+/).filter(Boolean);
if (parts.length === 0) return { firstName: '', middleName: '', lastName: '' };
if (parts.length === 1) return { firstName: parts[0], middleName: '', lastName: '' };
if (parts.length === 2) return { firstName: parts[0], middleName: '', lastName: parts[1] };
return {
firstName: parts[0],
middleName: parts.slice(1, -1).join(' '),
lastName: parts[parts.length - 1],
};
}
/**
* Compose first / middle / last into a single display / storage name.
*/
export function composeName(
firstName: string,
middleName: string,
lastName: string,
): string {
return [firstName, middleName, lastName].filter(Boolean).join(' ').trim();
}
/**
* Search an array of customers for a match against a composite query.
* Returns the best match, or null if none found.
*
* The query object is what the user has typed into the first/middle/last/phone
* fields. We build a full-name from the parts and then search customer
* records by (full name) and (phone).
*/
export interface CustomerMatchQuery {
firstName: string;
middleName: string;
lastName: string;
phone: string;
}
export interface CustomerMatchRecord {
id: string;
name: string;
phone: string;
email: string;
}
/**
* Find the best-matching customer from a list given partial form input.
* Returns null when no reasonable match exists.
*
* Matching logic:
* 1. If phone is provided (3+ digits), try an exact digit-only phone match first.
* 2. If a full name has been typed (first + last), try an exact name match.
* 3. If only a partial name exists, try a simple "starts-with" / "includes"
* on the customer's name.
*/
export function findMatchingCustomer<T extends CustomerMatchRecord>(
customers: T[],
query: CustomerMatchQuery,
): T | null {
const { firstName, middleName, lastName, phone } = query;
// Clean phone for comparison
const cleanPhone = phone.replace(/\D/g, '');
const fullName = composeName(firstName, middleName, lastName).toLowerCase();
// ── Phone match (exact) ──
if (cleanPhone.length >= 3) {
const byPhone = customers.find((c) => c.phone.replace(/\D/g, '') === cleanPhone);
if (byPhone) return byPhone;
}
// ── Full-name match (exact) ──
if (fullName.length > 0 && lastName && firstName) {
const byExactName = customers.find(
(c) => c.name.toLowerCase() === fullName,
);
if (byExactName) return byExactName;
}
// ── Partial name ──
if (fullName.length > 0) {
// try "starts with"
const byStartsWith = customers.find((c) =>
c.name.toLowerCase().startsWith(fullName),
);
if (byStartsWith) return byStartsWith;
// try "includes"
const byIncludes = customers.find((c) =>
c.name.toLowerCase().includes(fullName),
);
if (byIncludes) return byIncludes;
}
return null;
}
+85
View File
@@ -0,0 +1,85 @@
import type { VehicleRecord, RepairOrderRecord } from '../components/customers/types';
export function formatVehicleLabel(v: VehicleRecord): string {
const parts = [v.year, v.make, v.model].filter(Boolean);
return parts.length > 0 ? parts.join(' ') : 'Unknown vehicle';
}
export function vehicleKey(v: Pick<VehicleRecord, 'year' | 'make' | 'model' | 'vin'>): string {
return (v.vin || [v.year, v.make, v.model].filter(Boolean).join(' ')).trim().toLowerCase();
}
export function escapePbString(value: string): string {
return value.replace(/'/g, "\\'");
}
export function activeROFilterForCustomer(userId: string, customerId: string, previousName: string): string {
const activeStatuses = [
"status = 'active'",
"status = 'in_progress'",
"status = 'waiting_parts'",
"status = 'waiting_pickup'",
].join(' || ');
const identityFilters = [
`customerId = '${escapePbString(customerId)}'`,
previousName.trim() ? `customerName = '${escapePbString(previousName.trim())}'` : '',
].filter(Boolean).join(' || ');
return `userId = '${escapePbString(userId)}' && (${identityFilters}) && (${activeStatuses})`;
}
export function getRoStatusLabel(ro: RepairOrderRecord): string {
const s = ro.workStatus || ro.status || '';
switch (s) {
case 'active': case 'open': return 'Open';
case 'in-progress': case 'in_progress': return 'In Progress';
case 'waiter': case 'waiting_parts': return 'Waiting Parts';
case 'completed': return 'Completed';
case 'final_close':
case 'delivered': return 'Final Close';
case 'cancelled': return 'Cancelled';
default: return s || 'Unknown';
}
}
export function getRoStatusColor(ro: RepairOrderRecord): string {
const s = ro.workStatus || ro.status || '';
switch (s) {
case 'active': case 'open':
return 'bg-blue-50 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400';
case 'in-progress': case 'in_progress':
return 'bg-yellow-50 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400';
case 'waiter': case 'waiting_parts':
return 'bg-orange-50 text-orange-700 dark:bg-orange-900/30 dark:text-orange-400';
case 'completed': case 'final_close': case 'delivered':
return 'bg-green-50 text-green-700 dark:bg-green-900/30 dark:text-green-400';
case 'cancelled':
return 'bg-red-50 text-red-700 dark:bg-red-900/30 dark:text-red-400';
default:
return 'bg-gray-50 text-gray-700 dark:bg-gray-900/30 dark:text-gray-400';
}
}
export function formatCustomerDate(dateStr: string): string {
if (!dateStr) return '\u2014';
try {
const d = new Date(dateStr);
return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
} catch {
return dateStr;
}
}
export function parseQuoteServices(raw: unknown): { name: string; price: number; customerDecision: string }[] {
if (!raw) return [];
let items = raw;
if (typeof raw === 'string') {
try { items = JSON.parse(raw); } catch { return []; }
}
if (!Array.isArray(items)) return [];
return items.map((s: any) => ({
name: s.name || 'Service',
price: typeof s.price === 'number' ? s.price : 0,
customerDecision: s.customerDecision || 'pending',
}));
}
+58
View File
@@ -0,0 +1,58 @@
// Shared format helpers — single source of truth so the RO page, RO form,
// quote page, invoice page, etc. all agree on currency / date formatting.
export function formatCurrency(amount: number): string {
const n = Number.isFinite(amount) ? amount : 0;
return new Intl.NumberFormat('en-US', {
style: 'currency',
currency: 'USD',
}).format(n);
}
export function formatNumber(value: number, fractionDigits = 0): string {
const n = Number.isFinite(value) ? value : 0;
return new Intl.NumberFormat('en-US', {
minimumFractionDigits: fractionDigits,
maximumFractionDigits: fractionDigits,
}).format(n);
}
export function formatDate(iso: string | undefined | null): string {
if (!iso) return '—';
const d = new Date(iso);
if (isNaN(d.getTime())) return '—';
return new Intl.DateTimeFormat('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
}).format(d);
}
export function formatDateTime(iso: string | undefined | null): string {
if (!iso) return '—';
const d = new Date(iso);
if (isNaN(d.getTime())) return '—';
return d.toLocaleString('en-US', {
month: 'short',
day: 'numeric',
hour: 'numeric',
minute: '2-digit',
});
}
// Short HH:MM for compact clocks / timelines.
export function formatClockTime(iso: string | undefined | null): string {
if (!iso) return '—';
const d = new Date(iso);
if (isNaN(d.getTime())) return '—';
return d.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' });
}
// Human-friendly elapsed minutes/hours, e.g. "1h 23m".
export function formatElapsed(minutes: number): string {
if (!Number.isFinite(minutes) || minutes <= 0) return '0m';
const h = Math.floor(minutes / 60);
const m = Math.floor(minutes % 60);
if (h > 0) return `${h}h ${m}m`;
return `${m}m`;
}
+182
View File
@@ -0,0 +1,182 @@
/**
* pdf-to-images.ts
* ─────────────────────────────────────────────────────────────────────────────
* Converts every page of an already-built jsPDF document into a separate PNG
* image and triggers a browser download for each one.
*
* Requires pdfjs-dist (Mozilla's PDF.js) to be installed.
* Worker must be configured in the app entry point — see main.tsx.
*
* Usage:
* import { downloadPdfAsImages } from '../lib/pdf-to-images';
*
* const doc = new jsPDF();
* // ... build PDF content ...
* await downloadPdfAsImages(doc, 'Jon Smith');
* // → downloads: Jon_Smith_Page1.png, Jon_Smith_Page2.png, ...
*/
import type jsPDF from 'jspdf';
import type * as pdfjsLib from 'pdfjs-dist';
import { ensurePdfWorker } from './pdfjs-worker';
/**
* Render every page of a finished PDF as separate PNGs and
* automatically trigger a download for each one.
*
* Accepts either a finished jsPDF instance OR a Blob containing PDF bytes.
* This dual-input makes it convenient to use with generateQuotePDF()
* which returns a Blob — no need to re-parse or rebuild the doc.
*
* @param pdfInput A completed jsPDF instance (call .output('arraybuffer'))
* OR a Blob/ArrayBuffer containing raw PDF bytes.
* @param customerName Used in the download filename; spaces become underscores.
*
* @example
* // With a jsPDF instance:
* const doc = new jsPDF();
* doc.text('Hello', 10, 10);
* await downloadPdfAsImages(doc, 'Jon Smith');
*
* // With a Blob from generateQuotePDF():
* const blob = await generateQuotePDF(quote, settings);
* await downloadPdfAsImages(blob, 'Jon Smith');
*/
export async function downloadPdfAsImages(
pdfInput: jsPDF | Blob,
customerName: string,
): Promise<void> {
// ───────────────────────────────────────────────────────────────────────────
// 1. Obtain the raw PDF bytes as an ArrayBuffer
// ───────────────────────────────────────────────────────────────────────────
// Two paths:
// a) jsPDF instance → doc.output('arraybuffer')
// b) Blob (any Bag) → read it as ArrayBuffer via .arrayBuffer()
let arrayBuffer: ArrayBuffer;
if (pdfInput instanceof Blob) {
// generateQuotePDF() returns a Blob — read its bytes directly.
arrayBuffer = await pdfInput.arrayBuffer();
} else {
// It's a jsPDF instance — serialise in-memory.
arrayBuffer = pdfInput.output('arraybuffer');
}
// ───────────────────────────────────────────────────────────────────────────
// 2. Load the PDF binary into pdf.js so we can access individual pages
// ───────────────────────────────────────────────────────────────────────────
await ensurePdfWorker();
const { getDocument } = await import('pdfjs-dist');
const pdf: pdfjsLib.PDFDocumentProxy =
await getDocument({ data: arrayBuffer }).promise;
// ───────────────────────────────────────────────────────────────────────────
// 3. Build the base filename from the customer's name
// "Jon Smith" → "Jon_Smith"
// ───────────────────────────────────────────────────────────────────────────
// Replace all whitespace (spaces, tabs, etc.) with underscores for clean filenames.
const baseName: string = customerName.replace(/\s+/g, '_');
// ───────────────────────────────────────────────────────────────────────────
// 4. Iterate over every page of the PDF (page numbers are 1-indexed)
// ───────────────────────────────────────────────────────────────────────────
for (let pageNum = 1; pageNum <= pdf.numPages; pageNum++) {
// ── 4a. Load the individual page object ────────────────────────────────
const page: pdfjsLib.PDFPageProxy = await pdf.getPage(pageNum);
// ── 4b. Choose a viewport scale ───────────────────────────────────────
// Scale 2 → "Retina" quality (2× the base 72 DPI = 144 DPI).
// Increase to 3 or 4 for print-quality images (at the cost of larger
// file sizes and slower rendering). Decrease to 1 for smaller files.
const scale = 2;
const viewport: pdfjsLib.PageViewport = page.getViewport({ scale });
// ── 4c. Create an off-screen <canvas> element ─────────────────────────
// This canvas is never attached to the DOM — no visual flash during
// the export process.
const canvas: HTMLCanvasElement = document.createElement('canvas');
canvas.width = viewport.width;
canvas.height = viewport.height;
// ── 4d. Obtain the 2D rendering context ───────────────────────────────
const ctx: CanvasRenderingContext2D | null = canvas.getContext('2d');
if (!ctx) {
throw new Error(
'Failed to get 2D rendering context from off-screen canvas. ' +
'This should never happen in a browser environment.',
);
}
// ── 4e. Render the PDF page onto the canvas ───────────────────────────
// The viewport already accounts for the page's intrinsic rotation,
// so we pass it directly as the rendering transform.
const renderTask: pdfjsLib.RenderTask = page.render({
canvasContext: ctx,
viewport,
canvas,
} as any);
// ── 4f. Await the rendering to complete ───────────────────────────────
// renderTask.promise resolves once all drawing commands are done.
await renderTask.promise;
// ───────────────────────────────────────────────────────────────────────
// 5. Export the canvas as a PNG Blob
// ───────────────────────────────────────────────────────────────────────
// canvas.toBlob() is callback-based; we wrap it in a Promise for clean
// async/await usage.
const blob: Blob = await new Promise<Blob>((resolve, reject) => {
canvas.toBlob(
(b: Blob | null) => {
if (b) {
resolve(b);
} else {
reject(
new Error(
'canvas.toBlob() returned null — the canvas may have been ' +
'tainted by cross-origin data or the browser denied the operation.',
),
);
}
},
'image/png', // MIME type — forces PNG output
);
});
// ───────────────────────────────────────────────────────────────────────
// 6. Trigger a browser download for this page's image
// ───────────────────────────────────────────────────────────────────────
// 6a. Build the filename: "Jon_Smith_Page1.png"
const fileName: string = `${baseName}_Page${pageNum}.png`;
// 6b. Create a temporary blob URL pointing to the PNG data.
const url: string = URL.createObjectURL(blob);
// 6c. Create a temporary <a> element whose href points to the blob
// URL, set the download attribute, and programmatically click it.
const anchor: HTMLAnchorElement = document.createElement('a');
anchor.href = url;
anchor.download = fileName;
// 6d. Append the anchor to the DOM briefly so the browser treats the
// click as a real user gesture — required by some browsers for
// programmatic downloads to work.
document.body.appendChild(anchor);
anchor.click();
// 6e. Clean up: remove the anchor and release the blob URL to prevent
// memory leaks.
document.body.removeChild(anchor);
URL.revokeObjectURL(url);
// ───────────────────────────────────────────────────────────────────────
// 7. Release pdf.js page resources
// ───────────────────────────────────────────────────────────────────────
// page.cleanup() frees the internal render cache for this page.
page.cleanup();
}
// All pages have been downloaded as individual PNG images.
// The original jsPDF doc instance is untouched so you can still call
// doc.save('quote.pdf') if you also want the standard PDF download.
}
+837
View File
@@ -0,0 +1,837 @@
import type { jsPDF } from 'jspdf';
import type { Quote, QuotePdfAppearance, QuotePdfTextStyle, QuoteService, ShopSettings } from '../types';
import { billableServices, computeSplitQuoteTotals } from './totals';
import type { TotalsBreakdown } from './totals';
import { parseRichText, hasRichText, type RichSegment } from './richtext';
import { DEFAULT_QUOTE_PDF_APPEARANCE, getProviderLabel } from './settings';
// ─── Layout Constants ─────────────────────────────────────────────────────────
const PAGE_W = 612;
const PAGE_H = 792;
const MARGIN = 40;
const CW = PAGE_W - MARGIN * 2;
const CONTENT_BOTTOM = PAGE_H - MARGIN; // 752 — bottom of content area (above footer)
const FOOTER_HEIGHT = 50; // reserved for payment terms, separator + footer text + page number
type ColorTuple = [number, number, number];
const BL: ColorTuple = [18, 18, 18];
const DG: ColorTuple = [64, 64, 64];
const MG: ColorTuple = [80, 80, 80];
const GR: ColorTuple = [22, 163, 74];
const BU: ColorTuple = [37, 99, 235];
const OR: ColorTuple = [234, 88, 12];
function authorizationText(svc: QuoteService, appearance: QuotePdfAppearance): string {
const methodLabels: Record<string, string> = {
in_person: appearance.labels.authorizedInPerson,
phone: appearance.labels.authorizedByCall,
email: appearance.labels.authorizedByEmail,
text: appearance.labels.authorizedByText,
signature: appearance.labels.authorizedWithSignature,
online: appearance.labels.authorizedOnline,
};
const methodLabel = svc.authorizedMethod ? methodLabels[svc.authorizedMethod] : '';
if (!methodLabel) return svc.authorizedBy || '';
return svc.authorizedBy ? `${methodLabel}: ${svc.authorizedBy}` : methodLabel;
}
function quotePdfAppearance(settings: ShopSettings): QuotePdfAppearance {
const incoming = settings.quotePdfAppearance || DEFAULT_QUOTE_PDF_APPEARANCE;
return {
...DEFAULT_QUOTE_PDF_APPEARANCE,
...incoming,
sectionHeader: { ...DEFAULT_QUOTE_PDF_APPEARANCE.sectionHeader, ...incoming.sectionHeader },
serviceTitle: { ...DEFAULT_QUOTE_PDF_APPEARANCE.serviceTitle, ...incoming.serviceTitle },
serviceDescription: { ...DEFAULT_QUOTE_PDF_APPEARANCE.serviceDescription, ...incoming.serviceDescription },
recommendation: { ...DEFAULT_QUOTE_PDF_APPEARANCE.recommendation, ...incoming.recommendation },
priority: { ...DEFAULT_QUOTE_PDF_APPEARANCE.priority, ...incoming.priority },
authorization: { ...DEFAULT_QUOTE_PDF_APPEARANCE.authorization, ...incoming.authorization },
totals: { ...DEFAULT_QUOTE_PDF_APPEARANCE.totals, ...incoming.totals },
footer: { ...DEFAULT_QUOTE_PDF_APPEARANCE.footer, ...incoming.footer },
colors: { ...DEFAULT_QUOTE_PDF_APPEARANCE.colors, ...incoming.colors },
labels: { ...DEFAULT_QUOTE_PDF_APPEARANCE.labels, ...incoming.labels },
};
}
function applyTextStyle(doc: jsPDF, style: QuotePdfTextStyle, color: ColorTuple): void {
doc.setTextColor(...color);
doc.setFont(style.font, style.weight);
doc.setFontSize(style.size);
}
function spacingAmount(spacing: QuotePdfAppearance['serviceSpacing'], compact: number, normal: number, spacious: number): number {
if (spacing === 'compact') return compact;
if (spacing === 'spacious') return spacious;
return normal;
}
function hexToRgb(hex: string): ColorTuple {
if (!/^#[0-9a-fA-F]{6}$/.test(hex)) return GR;
const c = hex.replace('#', '');
const r = parseInt(c.substring(0, 2), 16);
const g = parseInt(c.substring(2, 4), 16);
const b = parseInt(c.substring(4, 6), 16);
if (isNaN(r) || isNaN(g) || isNaN(b)) return GR;
return [r, g, b];
}
// ─── Helpers ──────────────────────────────────────────────────────────────────
function fmtDate(dateStr?: string): string {
const d = dateStr ? new Date(dateStr) : new Date();
return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
}
function getLineHeight(fontSize: number, multiplier = 1.2): number {
return Math.ceil(fontSize * multiplier);
}
// ─── Inline-rich-text drawing ─────────────────────────────────────────────────
// Renders parsed {accent}/{muted}/{bold} segments horizontally with one
// color/font per segment. Supports left / center / right alignment so the
// footer line can keep its baseline layout whatever the markup is.
//
// `baseFont` / `baseSize` set the defaults applied before each segment; the
// accent color (`accent: ColorTuple`), the muted color (DG), the bold weight,
// and the base color (`baseColor: ColorTuple`) all toggle per segment.
//
// Word-wrapping is the caller's job — this draws a single line. The footer
// strings we use it on are short enough that no manual wrap is needed; if a
// user over-stuffs paymentTerms the right edge will clip (same as today).
interface DrawRichTextOptions {
doc: jsPDF;
segments: RichSegment[];
x: number;
y: number;
align: 'left' | 'center' | 'right';
baseColor: ColorTuple;
accent: ColorTuple;
font: QuotePdfTextStyle['font'];
size: number;
}
function drawRichText({ doc, segments, x, y, align, baseColor, accent, font, size }: DrawRichTextOptions): void {
doc.setFont(font, 'normal');
doc.setFontSize(size);
// Pre-compute the total width so we can anchor the first segment correctly
// for center/right alignment.
const widths = segments.map((s) => doc.getTextWidth(s.text));
const totalW = widths.reduce((a, b) => a + b, 0);
let cursor = align === 'right' ? x - totalW : align === 'center' ? x - totalW / 2 : x;
segments.forEach((seg, i) => {
const w = widths[i];
// Style → color + weight. `bold` stacks with accent/muted (the parser
// already collapsed the nesting, so we only ever see one style here).
let color: ColorTuple = baseColor;
let weight: 'normal' | 'bold' = 'normal';
if (seg.style === 'accent') color = accent;
else if (seg.style === 'muted') color = DG;
else if (seg.style === 'bold') { color = baseColor; weight = 'bold'; }
doc.setTextColor(...color);
doc.setFont(font, weight);
doc.text(seg.text, cursor, y);
cursor += w;
});
}
// ─── Page-break: add a new page, return to content-start y ────────────────────
// Does NOT draw the full header — only a thin continuation marker.
// Caller is responsible for redrawing section headers after the break.
function newPage(doc: jsPDF, _currentY: number): number {
doc.addPage();
return MARGIN; // start content at top margin on new page
}
// ─── Draw footer on a specific page ───────────────────────────────────────────
function drawFooter(doc: jsPDF, pageNum: number, totalPages: number, settings: ShopSettings, _shopChargeAmount: number) {
doc.setPage(pageNum);
const ACCENT: ColorTuple = settings.pdfAccentColor ? hexToRgb(settings.pdfAccentColor) : GR;
const appearance = quotePdfAppearance(settings);
// Payment terms — bottom-right, last page only, above the separator.
// Supports inline rich-text markup: {accent}…{/accent}, {muted}…{/muted},
// {bold}…{/bold}. When the string contains no tags we use the fast single
// draw to keep the byte-for-byte output identical to before.
if (appearance.showPaymentTerms && pageNum === totalPages && settings.paymentTerms) {
const label = `${appearance.labels.paymentTerms} `;
const full = label + settings.paymentTerms;
const py = PAGE_H - MARGIN - FOOTER_HEIGHT + 4; // 706 — within footer area, clear of content
if (hasRichText(full)) {
drawRichText({
doc,
segments: parseRichText(full),
x: PAGE_W - MARGIN,
y: py,
align: 'right',
baseColor: MG,
accent: ACCENT,
font: appearance.footer.font,
size: appearance.footer.size,
});
} else {
applyTextStyle(doc, appearance.footer, MG);
doc.text(full, PAGE_W - MARGIN, py, { align: 'right' });
}
}
// Separator line
const footerY = PAGE_H - MARGIN - FOOTER_HEIGHT + 12; // 714
doc.setDrawColor(...hexToRgb(appearance.colors.separator));
doc.setLineWidth(1);
doc.line(MARGIN, footerY, PAGE_W - MARGIN, footerY);
// Footer messages — same rich-text support as above.
if (appearance.showFooterMessage) {
const fm = settings.footerMessage || 'Thank you for choosing us for your automotive needs!';
if (hasRichText(fm)) {
drawRichText({
doc, segments: parseRichText(fm),
x: PAGE_W / 2, y: footerY + 15, align: 'center',
baseColor: hexToRgb(appearance.colors.footer), accent: ACCENT, font: appearance.footer.font, size: appearance.footer.size,
});
} else {
applyTextStyle(doc, appearance.footer, hexToRgb(appearance.colors.footer));
doc.text(fm, PAGE_W / 2, footerY + 15, { align: 'center' });
}
}
if (appearance.showQuoteFooterNote) {
const qfn = (settings.quoteFooterNote || 'This quote is valid for {days} days from the date above.').replace('{days}', String(settings.quoteExpiryDays || 30));
if (hasRichText(qfn)) {
drawRichText({
doc, segments: parseRichText(qfn),
x: PAGE_W / 2, y: footerY + 27, align: 'center',
baseColor: hexToRgb(appearance.colors.footer), accent: ACCENT, font: appearance.footer.font, size: appearance.footer.size,
});
} else {
applyTextStyle(doc, appearance.footer, hexToRgb(appearance.colors.footer));
doc.text(qfn, PAGE_W / 2, footerY + 27, { align: 'center' });
}
}
// Page number
doc.setTextColor(...MG);
doc.setFont('helvetica', 'bold');
doc.setFontSize(12);
doc.text(`Page ${pageNum} of ${totalPages}`, PAGE_W - MARGIN, PAGE_H - 10, { align: 'right' });
}
// ─── Main Generator ───────────────────────────────────────────────────────────
export async function generateQuotePDF(
quote: Quote,
settings: ShopSettings,
): Promise<Blob> {
const { jsPDF } = await import('jspdf');
const doc = new jsPDF('p', 'pt', 'letter');
const services: QuoteService[] = quote.services;
const customerName = quote.customerInfo.name || '';
if (!customerName || services.length === 0) {
throw new Error('Customer name and at least one service required');
}
// Pre-compute which services count toward totals
const billableIds = new Set(billableServices(services).map((s: any) => s.id));
const businessName = settings.businessName || 'Auto Repair Shop';
const businessAddress = settings.businessAddress || '123 Auto Lane\nKnoxville, TN 12345';
const businessPhone = settings.businessPhone || '(865) 555-0123';
const serviceAdvisor = quote.customerInfo.serviceAdvisor || settings.serviceAdvisor || '';
const technicianName = settings.defaultTechnician || '';
const providerLabel = getProviderLabel(settings);
const vehicleInfo = quote.customerInfo.vehicleInfo || '';
const roNumber = quote.customerInfo.roNumber || '';
const mileage = quote.customerInfo.mileage || '';
const vin = quote.customerInfo.vin || '';
const customerFirstName = customerName.split(' ')[0];
const isCompact = settings.pdfStyle === 'compact';
const appearance = quotePdfAppearance(settings);
const serviceGap = spacingAmount(appearance.serviceSpacing, 4, 8, 14);
const sectionGap = spacingAmount(appearance.sectionSpacing, 10, 16, 24);
const servicePriceDisplay = appearance.showServicePrices ? appearance.priceDisplay : 'hidden';
// ═══════════════════════════════════════════════════════════════════════════
// PAGE 1: FULL HEADER + MESSAGE
// ═══════════════════════════════════════════════════════════════════════════
let y = MARGIN;
const leftColX = MARGIN;
const rightColX = MARGIN + (CW * 0.58);
const colWidth = CW * 0.40;
// Logo
if (settings.logoUrl && settings.showLogoOnPdf) {
const logoW = Math.max(12, appearance.logoWidth || 40);
const logoH = Math.max(8, appearance.logoHeight || (isCompact ? 16 : 20));
const logoX = appearance.logoAlignment === 'center'
? MARGIN + (CW - logoW) / 2
: appearance.logoAlignment === 'right'
? PAGE_W - MARGIN - logoW
: MARGIN;
doc.addImage(settings.logoUrl, 'PNG', logoX, y, logoW, logoH);
y += logoH + (isCompact ? 4 : 6);
}
const businessAddressLines = appearance.showBusinessAddress ? businessAddress
.split('\n')
.flatMap(line => doc.splitTextToSize(line, colWidth)) : [];
const allInfo: [string, string][] = [
['Customer', customerName], ['Vehicle', vehicleInfo], ['RO #', roNumber],
['Mileage', mileage], ['VIN', vin], ['Date', fmtDate(quote.createdAt)],
];
const infoItems = allInfo
.filter(([label, value]) => {
if (!appearance.showCustomerInfo) return false;
if (label === 'RO #' && !appearance.showRoNumber) return false;
if (label === 'Mileage' && !appearance.showMileage) return false;
if (label === 'VIN' && !appearance.showVin) return false;
if (label === 'Date' && !appearance.showQuoteDate) return false;
return value && value !== 'N/A' && value !== '';
})
.map(([label, value]) => ({
label,
valueLines: doc.splitTextToSize(value, colWidth - 20),
}));
const headerTop = y;
const headerPadding = isCompact ? 8 : 14;
let headerHeight = headerPadding;
// Measure left column height
headerHeight += getLineHeight(isCompact ? 7 : 8, 1.3);
headerHeight += getLineHeight(isCompact ? 10 : 11, 1.3);
headerHeight += businessAddressLines.length * getLineHeight(isCompact ? 8 : 9, 1.4);
const providerLineCount = [settings.showAdvisorOnQuote, settings.showTechnicianOnQuote].filter(Boolean).length;
headerHeight += getLineHeight(isCompact ? 8 : 9, 1.4) * ((appearance.showBusinessPhone ? 1 : 0) + providerLineCount);
headerHeight += isCompact ? 6 : 10;
// Measure right column height
let rightHeight = getLineHeight(isCompact ? 7 : 8, 1.3);
infoItems.forEach(({ valueLines }) => {
rightHeight += getLineHeight(isCompact ? 8 : 9, 1.4) + (valueLines.length - 1) * getLineHeight(isCompact ? 8 : 9, 1.4) + (isCompact ? 3 : 6);
});
headerHeight = Math.max(headerHeight, rightHeight + headerPadding * 2);
if (!isCompact && appearance.headerLayout === 'boxed') {
// Light gray background fill
doc.setFillColor(...hexToRgb(appearance.colors.headerBackground));
doc.roundedRect(MARGIN, MARGIN, CW, headerHeight, 4, 4, 'F');
}
// ── Left: service business ──
doc.setTextColor(...MG); doc.setFontSize(isCompact ? 7 : 8); doc.setFont('helvetica', 'bold');
doc.text(providerLabel, leftColX, y);
y += getLineHeight(isCompact ? 7 : 8, 1.3);
doc.setFont('helvetica', 'bold'); doc.setFontSize(isCompact ? 10 : 11); doc.setTextColor(...BL);
doc.text(businessName, leftColX, y);
y += getLineHeight(isCompact ? 10 : 11, 1.3);
doc.setTextColor(...DG); doc.setFont('helvetica', 'normal'); doc.setFontSize(isCompact ? 8 : 9);
businessAddressLines.forEach((line: string) => {
doc.text(line, leftColX, y);
y += getLineHeight(isCompact ? 8 : 9, 1.4);
});
if (businessAddressLines.length > 0) y += isCompact ? 6 : 10;
// ── Provider name(s) on quote: advisor and/or technician ──
if (settings.showAdvisorOnQuote) {
doc.setTextColor(...MG); doc.setFontSize(isCompact ? 7 : 8);
doc.text('Service Advisor', leftColX, y);
doc.setTextColor(...BL); doc.setFontSize(isCompact ? 8 : 9);
doc.text(serviceAdvisor || '—', leftColX + colWidth - 5, y, { align: 'right' });
y += getLineHeight(isCompact ? 8 : 9, 1.4);
}
if (settings.showTechnicianOnQuote) {
doc.setTextColor(...MG); doc.setFontSize(isCompact ? 7 : 8);
doc.text('Technician', leftColX, y);
doc.setTextColor(...BL); doc.setFontSize(isCompact ? 8 : 9);
doc.text(technicianName || '—', leftColX + colWidth - 5, y, { align: 'right' });
y += getLineHeight(isCompact ? 8 : 9, 1.4);
}
if (appearance.showBusinessPhone) {
doc.setTextColor(...MG); doc.setFontSize(isCompact ? 7 : 8);
doc.text('Phone', leftColX, y);
doc.setTextColor(...BL); doc.setFontSize(isCompact ? 8 : 9);
doc.text(businessPhone, leftColX + colWidth - 5, y, { align: 'right' });
}
const leftHeight = y;
// ── Right: CUSTOMER INFORMATION ──
let rY = headerTop;
if (appearance.showCustomerInfo && infoItems.length > 0 && appearance.headerLayout !== 'minimal') {
doc.setTextColor(...MG); doc.setFontSize(isCompact ? 7 : 8); doc.setFont('helvetica', 'bold');
doc.text('CUSTOMER INFORMATION', rightColX, rY);
rY += getLineHeight(isCompact ? 7 : 8, 1.3);
doc.setFont('helvetica', 'normal'); doc.setFontSize(isCompact ? 8 : 9);
infoItems.forEach(({ label, valueLines }) => {
doc.setTextColor(...MG); doc.setFontSize(isCompact ? 7 : 8);
doc.text(label, rightColX, rY);
doc.setTextColor(...BL); doc.setFontSize(isCompact ? 8 : 9);
doc.text(valueLines[0], rightColX + colWidth - 5, rY, { align: 'right' });
rY += getLineHeight(isCompact ? 8 : 9, 1.4);
for (let i = 1; i < valueLines.length; i++) {
doc.text(valueLines[i], rightColX + colWidth - 5, rY, { align: 'right' });
rY += getLineHeight(isCompact ? 8 : 9, 1.4);
}
rY += isCompact ? 3 : 6;
});
}
y = Math.max(leftHeight, rY) + (isCompact ? 10 : 16);
if (!isCompact && appearance.headerLayout === 'boxed') {
// Header border
doc.setDrawColor(...hexToRgb(appearance.colors.separator)); doc.setLineWidth(0.7);
doc.roundedRect(MARGIN, MARGIN, CW, y - MARGIN, 4, 4, 'D');
}
// Separator
doc.setDrawColor(200, 200, 200); doc.line(MARGIN + (isCompact ? 0 : 4), y, PAGE_W - MARGIN - (isCompact ? 0 : 4), y);
y += isCompact ? 12 : 20;
// ── Personalized message ──
if (!isCompact && appearance.showHeaderMessage) {
doc.setTextColor(...MG); doc.setFont('helvetica', 'normal'); doc.setFontSize(10);
const headerMsg = (settings.headerMessage ||
'Hi {customerName}, here is the explanation of the services and repairs that your technician has recommended for your {vehicleInfo}. If you have any questions, please feel free to ask.')
.replace('{customerName}', customerFirstName).replace('{vehicleInfo}', vehicleInfo);
doc.splitTextToSize(headerMsg, CW).forEach((line: string) => {
doc.text(line, MARGIN, y);
y += getLineHeight(10, 1.4);
});
y += 18;
}
// ═══════════════════════════════════════════════════════════════════════════
// SERVICES SECTION — with proper page-break logic
// ═══════════════════════════════════════════════════════════════════════════
let servicesTotal = 0;
const approvedSvcs = services.filter(s => s.customerDecision === 'approved');
const pendingSvcs = appearance.showPendingRecommendations ? services.filter(s => s.customerDecision === 'pending') : [];
const declinedSvcs = appearance.showDeclinedServices ? services.filter(s => s.customerDecision === 'declined') : [];
const approvedColor = hexToRgb(appearance.colors.sectionHeader);
const recommendationsColor = hexToRgb(appearance.colors.recommendationsSection);
const declinedColor = MG;
const separatorColor = hexToRgb(appearance.colors.separator);
interface SvcGroup { label: string | null; services: QuoteService[]; color: ColorTuple | null; }
const groups: SvcGroup[] = [];
if (appearance.sectionOrder === 'combined') {
groups.push({ label: null, services: [...approvedSvcs, ...pendingSvcs, ...declinedSvcs], color: null });
} else if (appearance.sectionOrder === 'recommendations_first') {
if (pendingSvcs.length > 0) groups.push({ label: appearance.labels.recommendationsSection, services: pendingSvcs, color: recommendationsColor });
if (approvedSvcs.length > 0) groups.push({ label: appearance.labels.approvedSection, services: approvedSvcs, color: approvedColor });
if (declinedSvcs.length > 0) groups.push({ label: appearance.labels.declinedSection, services: declinedSvcs, color: declinedColor });
} else {
if (approvedSvcs.length > 0) groups.push({ label: appearance.labels.approvedSection, services: approvedSvcs, color: approvedColor });
if (pendingSvcs.length > 0) groups.push({ label: appearance.labels.recommendationsSection, services: pendingSvcs, color: recommendationsColor });
if (declinedSvcs.length > 0) groups.push({ label: appearance.labels.declinedSection, services: declinedSvcs, color: declinedColor });
}
// If only one section exists, hide the section label for a cleaner look
if (groups.length === 1) groups[0].label = null;
// Track current section for redrawing header after page breaks
let currentGroupLabel: string | null = null;
let currentGroupColor: ColorTuple | null = null;
groups.forEach((group: SvcGroup) => {
currentGroupLabel = group.label;
currentGroupColor = group.color;
// Draw section header
if (group.label && group.color) {
if (y + getLineHeight(appearance.sectionHeader.size, 1.3) + sectionGap > CONTENT_BOTTOM - FOOTER_HEIGHT) y = newPage(doc, y);
applyTextStyle(doc, appearance.sectionHeader, group.color);
doc.text(group.label, MARGIN, y);
y += getLineHeight(appearance.sectionHeader.size, 1.3);
doc.setDrawColor(...group.color); doc.setLineWidth(0.7);
doc.line(MARGIN, y, PAGE_W - MARGIN, y);
y += sectionGap;
}
group.services.forEach((svc: QuoteService, idx: number) => {
// ── Calculate this service's total height ──
let svcH = 0;
if (appearance.showServiceDecisionBadges && (svc.customerDecision === 'approved' || svc.customerDecision === 'declined')) svcH += getLineHeight(8, 1.3);
if (appearance.showAuthorizationLine && (svc.authorizedBy || svc.authorizedMethod)) svcH += getLineHeight(appearance.authorization.size, 1.2);
svcH += getLineHeight(appearance.serviceTitle.size, 1.3); // name + price
if (appearance.showPriority && svc.priority) svcH += getLineHeight(appearance.priority.size);
if (appearance.showPriorityReason && svc.priorityReason) svcH += getLineHeight(appearance.priority.size);
if (appearance.showRecommendation && svc.recommendation) svcH += getLineHeight(appearance.recommendation.size, 1.2);
if (appearance.showServiceDescription && svc.explanation) svcH += doc.splitTextToSize(svc.explanation, CW).length * getLineHeight(appearance.serviceDescription.size, 1.4) + 2;
if (appearance.showPartsStatus && svc.partsNotInStock) { svcH += getLineHeight(10); if (svc.partsDeliveryTime) svcH += getLineHeight(10); }
if (appearance.showAftermarketOptions && svc.aftermarketAvailable) { svcH += getLineHeight(10); if (svc.aftermarketPartsList) svcH += getLineHeight(9); }
svcH += serviceGap + (appearance.serviceSeparators ? 4 : 0);
// ── PAGE BREAK if this service won't fit ──
if (y + svcH > CONTENT_BOTTOM - FOOTER_HEIGHT) {
y = newPage(doc, y);
// Redraw section header on new page
if (currentGroupLabel && currentGroupColor) {
applyTextStyle(doc, appearance.sectionHeader, currentGroupColor);
doc.text(currentGroupLabel, MARGIN, y); y += getLineHeight(appearance.sectionHeader.size, 1.1);
doc.setDrawColor(...currentGroupColor); doc.setLineWidth(1);
doc.line(MARGIN, y, PAGE_W - MARGIN, y); y += sectionGap;
}
}
// ── Render service ──
// Decision badge
if (appearance.showServiceDecisionBadges && svc.customerDecision === 'approved') {
doc.setTextColor(...hexToRgb(appearance.colors.approvedBadge)); doc.setFont('helvetica', 'bold'); doc.setFontSize(8);
doc.text(`\u2713 ${appearance.labels.customerApproved}`, MARGIN, y); y += getLineHeight(8);
} else if (appearance.showServiceDecisionBadges && svc.customerDecision === 'declined') {
doc.setTextColor(...hexToRgb(appearance.colors.declinedBadge)); doc.setFont('helvetica', 'bold'); doc.setFontSize(8);
doc.text(`\u2717 ${appearance.labels.customerDeclined}`, MARGIN, y); y += getLineHeight(8);
}
if (appearance.showAuthorizationLine && (svc.authorizedBy || svc.authorizedMethod)) {
applyTextStyle(doc, appearance.authorization, MG);
doc.text(authorizationText(svc, appearance), MARGIN + 2, y); y += getLineHeight(appearance.authorization.size, 1.2);
}
// Name + price (same baseline)
applyTextStyle(doc, appearance.serviceTitle, hexToRgb(appearance.colors.serviceTitle));
const price = parseFloat(String(svc.price ?? 0)) || 0;
const title = servicePriceDisplay === 'inline' ? `${svc.name} - $${price.toFixed(2)}` : svc.name;
doc.text(title, MARGIN, y);
if (svc.customerDecision === 'declined') {
const nw = doc.getTextWidth(title);
doc.setDrawColor(...separatorColor); doc.setLineWidth(0.5);
doc.line(MARGIN, y - 1, MARGIN + nw, y - 1);
}
// Count toward totals based on billableServices heuristic
if (billableIds.has(svc.id)) servicesTotal += price;
if (servicePriceDisplay === 'right') doc.text(`$${price.toFixed(2)}`, PAGE_W - MARGIN, y, { align: 'right' });
y += getLineHeight(appearance.serviceTitle.size, 1.3);
// Priority
if (appearance.showPriority && svc.priority) {
const pc: Record<string, { icon: string; label: string }> = {
CRITICAL_SAFETY: { icon: '[CRITICAL]', label: 'CRITICAL SAFETY PRIORITY' },
SAFETY_CONCERN: { icon: '[SAFETY]', label: 'SAFETY CONCERN PRIORITY' },
RECOMMENDED: { icon: '[RECOMMENDED]', label: 'RECOMMENDED PRIORITY' },
MAINTENANCE: { icon: '[MAINTENANCE]', label: 'MAINTENANCE PRIORITY' },
OPTIONAL: { icon: '[OPTIONAL]', label: 'OPTIONAL PRIORITY' },
};
const c = pc[svc.priority] || pc.RECOMMENDED;
applyTextStyle(doc, appearance.priority, DG);
doc.text(`${c.icon} ${c.label}`, MARGIN, y); y += getLineHeight(9);
if (appearance.showPriorityReason && svc.priorityReason) {
doc.setFont(appearance.priority.font, 'normal'); doc.setFontSize(appearance.priority.size);
doc.setTextColor(...hexToRgb(appearance.colors.description));
doc.text(`${appearance.labels.priorityReason} ${svc.priorityReason}`, MARGIN, y); y += getLineHeight(appearance.priority.size);
}
}
// Recommendation
if (appearance.showRecommendation && svc.recommendation) {
applyTextStyle(doc, appearance.recommendation, hexToRgb(appearance.colors.recommendation));
const recommendationText = svc.recommendationReason || svc.recommendation || '';
doc.text(appearance.recommendationTransform === 'uppercase' ? recommendationText.toUpperCase() : recommendationText, MARGIN, y);
y += getLineHeight(appearance.recommendation.size, 1.2);
}
// Explanation
if (appearance.showServiceDescription && svc.explanation) {
applyTextStyle(doc, appearance.serviceDescription, hexToRgb(appearance.colors.description));
doc.splitTextToSize(svc.explanation, CW).forEach((line: string) => {
doc.text(line, MARGIN, y); y += getLineHeight(appearance.serviceDescription.size, 1.4);
});
y += serviceGap;
}
// Parts status flags
if (appearance.showPartsStatus && svc.partsNotInStock) {
doc.setTextColor(...OR); doc.setFont('helvetica', 'bold'); doc.setFontSize(9);
doc.text('NOTE: Parts for this service are not in stock and will need to be ordered.', MARGIN, y);
y += getLineHeight(9);
if (svc.partsDeliveryTime) {
doc.text(`Estimated Delivery: ${svc.partsDeliveryTime}`, MARGIN, y); y += getLineHeight(9);
}
}
if (appearance.showAftermarketOptions && svc.aftermarketAvailable) {
doc.setTextColor(...DG); doc.setFont('helvetica', 'bold'); doc.setFontSize(9);
doc.text('NOTE: Aftermarket parts are available for this service.', MARGIN, y);
y += getLineHeight(9);
if (svc.aftermarketPartsList) {
doc.setFont('helvetica', 'normal'); doc.setFontSize(8);
doc.text(`Aftermarket Options: ${svc.aftermarketPartsList}`, MARGIN, y); y += getLineHeight(8);
}
}
// Separator between services
if (appearance.serviceSeparators && idx < group.services.length - 1) {
doc.setDrawColor(...separatorColor); doc.setLineWidth(0.5);
doc.line(MARGIN, y + 3, PAGE_W - MARGIN, y + 3);
}
y += serviceGap;
});
});
// ═══════════════════════════════════════════════════════════════════════════
// PRICING SUMMARY — with page-break if needed
// ═══════════════════════════════════════════════════════════════════════════
const discountValue = parseFloat(String(quote.discount.value)) || 0;
const discountType = quote.discount.type || 'dollar';
// Determine whether we have a mix of approved + pending (RO transfer scenario)
const nonDeclined = services.filter(s => s.customerDecision !== 'declined');
const hasMixed = nonDeclined.some(s => s.customerDecision === 'approved') && nonDeclined.some(s => s.customerDecision === 'pending');
// Compute split totals when mixed; fall back to current billable-only totals
let approvedTotals: TotalsBreakdown | null = null;
let pendingTotals: TotalsBreakdown | null = null;
let combinedTotals: TotalsBreakdown | null = null;
let displaySubtotal = servicesTotal;
let displayShopCharge = 0;
let displayDiscount = 0;
let displayTax = 0;
let displayTotal = 0;
if (hasMixed) {
const split = computeSplitQuoteTotals(services, quote.discount || { value: 0, type: 'dollar' }, settings);
approvedTotals = split.approved;
pendingTotals = split.pending;
combinedTotals = split.combined;
displaySubtotal = split.combined.subtotal;
displayDiscount = split.combined.discountAmount;
displayShopCharge = split.combined.shopCharge;
displayTax = split.combined.tax;
displayTotal = split.combined.total;
} else {
// Existing billable-only totals (backward compat)
const shopChargeableSubtotal = services.reduce(
(acc, s) => (billableIds.has(s.id) && s.applyShopCharge !== false)
? acc + (parseFloat(String(s.price)) || 0)
: acc, 0);
const shopChargeRate = settings.shopChargeRate || 0;
const shopChargeCap = settings.shopChargeCap ?? 69.95;
displayShopCharge = Math.min(shopChargeableSubtotal * (shopChargeRate / 100), shopChargeCap);
if (discountValue > 0) {
displayDiscount = discountType === 'percent' ? servicesTotal * (discountValue / 100) : discountValue;
}
const taxRate = settings.taxRate || 0;
displayTax = (servicesTotal - displayDiscount + displayShopCharge) * (taxRate / 100);
displayTotal = servicesTotal - displayDiscount + displayShopCharge + displayTax;
}
if (appearance.showTotalsSummary) {
const sumX = PAGE_W - MARGIN - (CW * 0.45);
const sumW = CW * 0.45;
const sumRight = PAGE_W - MARGIN;
const bp = 10;
const mixedSectionGap = 3;
// Count rows for height calculation
let sumRows = 0;
if (hasMixed) {
sumRows += 4; // approved: subtotal + shopCharge (cond) + tax (cond) + approved total
sumRows += 4; // pending: subtotal + shopCharge (cond) + tax (cond) + if approved
sumRows += 1; // combined subtotal
if (appearance.showDiscountLine && displayDiscount > 0) sumRows += 1; // combined discount
if (appearance.showShopChargeLine && displayShopCharge > 0) sumRows += 1; // combined capped shop charge
if (appearance.showTaxLine && settings.taxRate > 0) sumRows += 1; // combined recalculated tax
sumRows += 2; // separator + grand total
} else {
sumRows = 1; // subtotal
if (appearance.showDiscountLine && displayDiscount > 0) sumRows++;
if (appearance.showShopChargeLine && displayShopCharge > 0) sumRows++;
if (appearance.showTaxLine && settings.taxRate > 0) sumRows++;
sumRows += 2; // separator + total
}
const sumContentH = (sumRows * getLineHeight(9, 1.2)) + 14 + (hasMixed ? mixedSectionGap * 2 : 0);
const sumTotalH = bp + sumContentH + bp;
// Check if summary + shop charge note fits; if not, page break
const noteExtra = displayShopCharge > 0 ? 30 : 0;
if (y + sumTotalH + 14 + noteExtra > CONTENT_BOTTOM - FOOTER_HEIGHT) {
y = newPage(doc, y);
}
y += 14;
const sumTopY = y - bp;
doc.setFillColor(...hexToRgb(appearance.colors.totalsBackground)); doc.setDrawColor(...hexToRgb(appearance.colors.separator)); doc.setLineWidth(0.5);
doc.roundedRect(sumX - bp, sumTopY, sumW + (bp * 2), sumTotalH, 4, 4, 'FD');
y = sumTopY + bp;
if (hasMixed && approvedTotals && pendingTotals && combinedTotals) {
// ── Approved Service Totals ──
doc.setTextColor(...GR); doc.setFont(appearance.totals.font, 'bold'); doc.setFontSize(Math.max(7, appearance.totals.size - 2));
doc.text('ALREADY APPROVED WORK', sumX, y);
applyTextStyle(doc, appearance.totals, BL);
doc.text(`$${approvedTotals.subtotal.toFixed(2)}`, sumRight, y, { align: 'right' });
y += getLineHeight(9);
if (appearance.showShopChargeLine && approvedTotals.shopCharge > 0) {
applyTextStyle(doc, appearance.totals, DG);
doc.text(appearance.labels.shopCharge, sumX, y);
doc.setTextColor(...BL);
doc.text(`$${approvedTotals.shopCharge.toFixed(2)}`, sumRight, y, { align: 'right' });
y += getLineHeight(9);
}
if (appearance.showTaxLine && settings.taxRate > 0) {
applyTextStyle(doc, appearance.totals, DG);
doc.text(settings.taxLabel || 'Tax', sumX, y);
doc.setTextColor(...BL);
doc.text(`$${approvedTotals.tax.toFixed(2)}`, sumRight, y, { align: 'right' });
y += getLineHeight(9);
}
doc.setTextColor(...GR); doc.setFont(appearance.totals.font, 'bold'); doc.setFontSize(appearance.totals.size);
doc.text(appearance.labels.authorizedTotal, sumX, y);
doc.text(`$${approvedTotals.total.toFixed(2)}`, sumRight, y, { align: 'right' });
y += getLineHeight(10) + mixedSectionGap;
// ── Additional Recommendations ──
doc.setTextColor(...BU); doc.setFont(appearance.totals.font, 'bold'); doc.setFontSize(Math.max(7, appearance.totals.size - 2));
doc.text('ADDITIONAL RECOMMENDATIONS', sumX, y);
applyTextStyle(doc, appearance.totals, BL);
doc.text(`$${pendingTotals.subtotal.toFixed(2)}`, sumRight, y, { align: 'right' });
y += getLineHeight(9);
if (appearance.showShopChargeLine && pendingTotals.shopCharge > 0) {
applyTextStyle(doc, appearance.totals, DG);
doc.text(`Estimated ${appearance.labels.shopCharge.toLowerCase()}`, sumX, y);
doc.setTextColor(...BL);
doc.text(`$${pendingTotals.shopCharge.toFixed(2)}`, sumRight, y, { align: 'right' });
y += getLineHeight(9);
}
if (appearance.showTaxLine && settings.taxRate > 0) {
applyTextStyle(doc, appearance.totals, DG);
doc.text(settings.taxLabel || 'Tax', sumX, y);
doc.setTextColor(...BL);
doc.text(`$${pendingTotals.tax.toFixed(2)}`, sumRight, y, { align: 'right' });
y += getLineHeight(9);
}
doc.setTextColor(...BU); doc.setFont(appearance.totals.font, 'bold'); doc.setFontSize(appearance.totals.size);
doc.text(appearance.labels.addOnTotal, sumX, y);
doc.text(`$${pendingTotals.total.toFixed(2)}`, sumRight, y, { align: 'right' });
y += getLineHeight(10) + mixedSectionGap;
// ── Combined: Total if All Approved ──
doc.setTextColor(...DG); doc.setFont(appearance.totals.font, 'bold'); doc.setFontSize(appearance.totals.size);
doc.text(appearance.labels.allWorkSubtotal, sumX, y);
doc.setTextColor(...GR);
doc.text(`$${combinedTotals.subtotal.toFixed(2)}`, sumRight, y, { align: 'right' });
y += getLineHeight(9);
// Discount
if (appearance.showDiscountLine && displayDiscount > 0) {
applyTextStyle(doc, appearance.totals, DG);
doc.text(appearance.labels.discount, sumX, y);
doc.setTextColor(...GR);
doc.text(`\u2212$${displayDiscount.toFixed(2)}`, sumRight, y, { align: 'right' });
y += getLineHeight(9);
}
if (appearance.showShopChargeLine && displayShopCharge > 0) {
applyTextStyle(doc, appearance.totals, DG);
const cap = settings.shopChargeCap ?? 69.95;
doc.text(`${appearance.labels.shopCharge}, capped at $${cap.toFixed(2)}`, sumX, y);
doc.setTextColor(...GR);
doc.text(`$${displayShopCharge.toFixed(2)}`, sumRight, y, { align: 'right' });
y += getLineHeight(9);
}
if (appearance.showTaxLine && settings.taxRate > 0) {
applyTextStyle(doc, appearance.totals, DG);
doc.text(`${settings.taxLabel || 'Tax'} @ ${settings.taxRate}%`, sumX, y);
doc.setTextColor(...GR);
doc.text(`$${displayTax.toFixed(2)}`, sumRight, y, { align: 'right' });
y += getLineHeight(9);
}
// Separator + Grand Total
doc.setDrawColor(...hexToRgb(appearance.colors.separator)); doc.setLineWidth(0.5);
doc.line(sumX, y, sumRight, y); y += getLineHeight(10);
doc.setTextColor(...BL); doc.setFont(appearance.totals.font, 'bold'); doc.setFontSize(Math.max(10, appearance.totals.size));
doc.text(appearance.labels.totalIfAllApproved, sumX, y);
doc.text(`$${displayTotal.toFixed(2)}`, sumRight, y, { align: 'right' });
} else {
// ── Simple single-section pricing summary (existing layout) ──
// Subtotal
applyTextStyle(doc, appearance.totals, DG); doc.text(appearance.labels.subtotal, sumX, y);
doc.setTextColor(...BL); doc.text(`$${displaySubtotal.toFixed(2)}`, sumRight, y, { align: 'right' });
y += getLineHeight(9);
// Discount
if (appearance.showDiscountLine && displayDiscount > 0) {
applyTextStyle(doc, appearance.totals, DG);
doc.text(appearance.labels.discount, sumX, y);
doc.setTextColor(...GR);
doc.text(`\u2212$${displayDiscount.toFixed(2)}`, sumRight, y, { align: 'right' });
y += getLineHeight(9); doc.setTextColor(...BL);
}
// Shop charge
if (appearance.showShopChargeLine && displayShopCharge > 0) {
applyTextStyle(doc, appearance.totals, DG);
doc.text(appearance.labels.shopCharge, sumX, y);
doc.setTextColor(...BL);
doc.text(`$${displayShopCharge.toFixed(2)}`, sumRight, y, { align: 'right' });
y += getLineHeight(9);
}
// Tax
if (appearance.showTaxLine && settings.taxRate > 0) {
applyTextStyle(doc, appearance.totals, DG);
doc.text(settings.taxLabel || 'Tax', sumX, y);
doc.setTextColor(...BL);
doc.text(`$${displayTax.toFixed(2)}`, sumRight, y, { align: 'right' });
y += getLineHeight(9);
}
// Separator + Total
doc.setDrawColor(...hexToRgb(appearance.colors.separator)); doc.setLineWidth(0.5);
doc.line(sumX, y, sumRight, y); y += getLineHeight(10);
doc.setTextColor(...BL); doc.setFont(appearance.totals.font, 'bold'); doc.setFontSize(Math.max(10, appearance.totals.size));
doc.text(appearance.labels.total, sumX, y);
doc.text(`$${displayTotal.toFixed(2)}`, sumRight, y, { align: 'right' });
}
y = sumTopY + sumTotalH + 10;
// Shop charge explanation
if (appearance.showShopChargeLine && displayShopCharge > 0) {
doc.setTextColor(...MG); doc.setFont('helvetica', 'normal'); doc.setFontSize(8);
const note = `* ${settings.shopChargeExplanation || 'Shop Charge covers consumable supplies such as shop rags, cleaning solvents, lubricants, and other miscellaneous materials used during service.'}`;
doc.splitTextToSize(note, sumW).forEach((line: string) => { doc.text(line, sumX, y); y += getLineHeight(8); });
}
}
// ═══════════════════════════════════════════════════════════════════════════
// FOOTERS — draw on every page
// ═══════════════════════════════════════════════════════════════════════════
const totalPages = doc.getNumberOfPages();
for (let i = 1; i <= totalPages; i++) {
drawFooter(doc, i, totalPages, settings, displayShopCharge);
}
return doc.output('blob');
}
// ── Download & Print Helpers ─────────────────────────────────────────────────
export async function downloadQuotePDF(quote: Quote, settings: ShopSettings): Promise<void> {
const blob = await generateQuotePDF(quote, settings);
const sn = (quote.customerInfo.name || 'Customer').replace(/[^a-zA-Z0-9_-]/g, '_').replace(/_+/g, '_').replace(/^_|_$/g, '');
const sr = (quote.customerInfo.roNumber || 'NRO').replace(/[^a-zA-Z0-9_-]/g, '');
const ds = fmtDate(quote.createdAt).replace(/,/g, '').replace(/\s+/g, '_');
const fn = `Quote_${sr}_${sn}_${ds}.pdf`;
const url = URL.createObjectURL(blob);
const a = document.createElement('a'); a.href = url; a.download = fn;
document.body.appendChild(a); a.click(); document.body.removeChild(a);
URL.revokeObjectURL(url);
}
export async function printQuotePDF(quote: Quote, settings: ShopSettings): Promise<void> {
const blob = await generateQuotePDF(quote, settings);
const url = URL.createObjectURL(blob);
const win = window.open(url, '_blank');
if (win) win.addEventListener('load', () => win.print());
}
+11
View File
@@ -0,0 +1,11 @@
let configured = false;
export async function ensurePdfWorker() {
if (configured) return;
const pdfjsLib = await import('pdfjs-dist');
pdfjsLib.GlobalWorkerOptions.workerSrc = new URL(
'pdfjs-dist/build/pdf.worker.min.mjs',
import.meta.url,
).toString();
configured = true;
}
+37
View File
@@ -0,0 +1,37 @@
/**
* Phone formatting utilities.
*
* All phone numbers are stored as raw digits only. This module provides
* formatting for display and input masking — both output "555-555-5555".
*/
/** Format raw digits for display: "5555555555" → "555-555-5555" */
export function formatPhoneDisplay(phone: string | null | undefined): string {
if (!phone) return '';
const digits = phone.replace(/\D/g, '');
if (digits.length === 10) {
return `${digits.slice(0, 3)}-${digits.slice(3, 6)}-${digits.slice(6)}`;
}
if (digits.length === 7) {
return `${digits.slice(0, 3)}-${digits.slice(3)}`;
}
// If already formatted with hyphens or parens, normalise to 555-555-5555
if (digits.length > 10) return formatPhoneDisplay(digits.slice(0, 10));
return digits; // partial entry
}
/**
* Auto-format while the user types. Returns the formatted value suitable for
* setting directly as an input's value.
*
* e.g. user types "5555" → "555-5"
* user types "5555555555" → "555-555-5555"
* user types "555-55" → "555-55"
*/
export function formatPhoneInput(raw: string): string {
const digits = raw.replace(/\D/g, '').slice(0, 10);
if (digits.length === 0) return '';
if (digits.length <= 3) return digits;
if (digits.length <= 6) return `${digits.slice(0, 3)}-${digits.slice(3)}`;
return `${digits.slice(0, 3)}-${digits.slice(3, 6)}-${digits.slice(6)}`;
}
+49
View File
@@ -0,0 +1,49 @@
import { useSyncExternalStore } from 'react';
import PocketBase from 'pocketbase';
const PB_URL = import.meta.env.VITE_PB_URL || '/pb';
export const pb = new PocketBase(PB_URL);
export const APP_NAME = import.meta.env.VITE_APP_NAME || 'ShopProQuote';
pb.autoCancellation(false);
// ─── Reactive auth ────────────────────────────────────────────────────────────
// PocketBase's authStore is event-emitting; we bridge it to React via
// useSyncExternalStore so components re-render on login / logout / token
// expiry. Previously `pb.authStore.isValid` was read once at render, which
// meant a logged-out user (token expired mid-session) could keep using the
// app until a full page reload.
function subscribeAuth(callback: () => void): () => void {
const unsubscribe = pb.authStore.onChange(callback);
return unsubscribe;
}
function getAuthSnapshot() {
return pb.authStore.isValid;
}
function getAuthServerSnapshot() {
// During SSR there's no authStore — default to logged-out.
return false;
}
export function useIsLoggedIn(): boolean {
return useSyncExternalStore(subscribeAuth, getAuthSnapshot, getAuthServerSnapshot);
}
export function useAuth() {
// Subscribe so the hook re-runs whenever the authStore changes; the actual
// values come from pb.authStore directly (cheap, stable reads).
useIsLoggedIn();
return {
user: pb.authStore.model,
isLoggedIn: pb.authStore.isValid,
login: async (email: string, password: string) => await pb.collection('users').authWithPassword(email, password),
logout: () => pb.authStore.clear(),
};
}
export function usePb() {
return pb;
}
+63
View File
@@ -0,0 +1,63 @@
/* ── Pure helpers extracted from QuoteGenerator.tsx ── */
/** Recursively strip null values from service objects (Zod v4 optional() rejects null). */
export function sanitizeServices(svcs: any[]): any[] {
return svcs.map((s) => {
const clean: Record<string, any> = {};
for (const [k, v] of Object.entries(s)) {
if (v === null) continue;
clean[k] = v;
}
return clean;
});
}
export function normalizeCustomerInfo(raw: any) {
if (!raw || typeof raw !== 'object') return null;
const parsedNestedCustomerInfo = (() => {
const nested = raw.customerInfo;
if (!nested) return null;
if (typeof nested === 'string') {
try { return JSON.parse(nested); } catch { return null; }
}
return typeof nested === 'object' ? nested : null;
})();
const pick = (...values: any[]) => {
for (const value of values) {
if (typeof value === 'string' && value.trim()) return value;
if (typeof value === 'number' && Number.isFinite(value)) return String(value);
}
return '';
};
return {
name: pick(raw.name, parsedNestedCustomerInfo?.name, raw.customerName, parsedNestedCustomerInfo?.customerName, raw.customer, parsedNestedCustomerInfo?.customer),
phone: pick(raw.phone, parsedNestedCustomerInfo?.phone, raw.customerPhone, parsedNestedCustomerInfo?.customerPhone, raw.customer_phone, parsedNestedCustomerInfo?.customer_phone),
vehicleInfo: pick(raw.vehicleInfo, parsedNestedCustomerInfo?.vehicleInfo, raw.vehicle, parsedNestedCustomerInfo?.vehicle, raw.vehicle_info, parsedNestedCustomerInfo?.vehicle_info),
vin: pick(raw.vin, parsedNestedCustomerInfo?.vin, raw.VIN, parsedNestedCustomerInfo?.VIN),
mileage: pick(raw.mileage, parsedNestedCustomerInfo?.mileage, raw.miles, parsedNestedCustomerInfo?.miles),
roNumber: pick(raw.roNumber, parsedNestedCustomerInfo?.roNumber, raw.repairOrderNumber, parsedNestedCustomerInfo?.repairOrderNumber, raw.RO, parsedNestedCustomerInfo?.RO, raw.ro, parsedNestedCustomerInfo?.ro, raw.ro_number, parsedNestedCustomerInfo?.ro_number),
repairOrderId: pick(raw.repairOrderId, parsedNestedCustomerInfo?.repairOrderId),
serviceAdvisor: pick(raw.serviceAdvisor, parsedNestedCustomerInfo?.serviceAdvisor, raw.advisorName, parsedNestedCustomerInfo?.advisorName, raw.serviceAdvisorName, parsedNestedCustomerInfo?.serviceAdvisorName, raw.advisor, parsedNestedCustomerInfo?.advisor),
};
}
export function formatQuoteCurrency(amount: number): string {
return new Intl.NumberFormat('en-US', { style: 'currency', currency: 'USD' }).format(amount);
}
export function formatQuoteDate(iso: string | undefined | null): string {
if (!iso) return '—';
try {
return new Intl.DateTimeFormat('en-US', { month: 'short', day: 'numeric', year: 'numeric' }).format(new Date(iso));
} catch { return '—'; }
}
export function recentQuoteDate(quote: { createdAt?: string; lastUpdated?: string }): string {
return quote.createdAt || quote.lastUpdated || '';
}
export function recentQuoteTimestamp(quote: { createdAt?: string; lastUpdated?: string }): number {
const value = recentQuoteDate(quote);
const parsed = value ? new Date(value).getTime() : NaN;
return Number.isNaN(parsed) ? 0 : parsed;
}
+116
View File
@@ -0,0 +1,116 @@
import type { QuoteService, ROService, ShopSettings } from '../types';
type PocketBaseLike = {
collection: (name: string) => {
getOne: (id: string) => Promise<any>;
update: (id: string, data: Record<string, any>) => Promise<any>;
};
};
function parseServices(raw: unknown): ROService[] {
if (Array.isArray(raw)) return raw as ROService[];
if (typeof raw === 'string' && raw.trim()) {
try {
const parsed = JSON.parse(raw);
return Array.isArray(parsed) ? parsed as ROService[] : [];
} catch {
return [];
}
}
return [];
}
export function sourceROServiceId(quoteService: QuoteService, repairOrderId: string): string {
const explicit = (quoteService as QuoteService & { sourceROServiceId?: string }).sourceROServiceId;
if (explicit) return explicit;
const prefix = `ro-${repairOrderId}-`;
return quoteService.id.startsWith(prefix) ? quoteService.id.slice(prefix.length) : '';
}
export function quoteServiceToROService(
quoteService: QuoteService,
settings: Pick<ShopSettings, 'defaultLaborRate'>,
repairOrderId: string,
): ROService {
const price = parseFloat(String(quoteService.price ?? 0)) || 0;
const laborRate = quoteService.laborRate || settings.defaultLaborRate || 95;
const splitTotal = (quoteService.laborHours || 0) * (quoteService.laborRate || laborRate) + (quoteService.partsCost || 0);
const hasSplitPricing = quoteService.pricingMode === 'split' || splitTotal > 0;
const originId = sourceROServiceId(quoteService, repairOrderId);
return {
id: originId || `qs-${quoteService.id}`,
name: quoteService.name || '',
description: quoteService.explanation || quoteService.recommendation || '',
laborHours: hasSplitPricing
? Math.round((quoteService.laborHours || 0) * 100) / 100
: Math.round((price / laborRate) * 100) / 100,
laborRate: hasSplitPricing ? (quoteService.laborRate || laborRate) : laborRate,
partsCost: hasSplitPricing ? (quoteService.partsCost || 0) : 0,
total: price,
status: 'pending',
technician: '',
pricingMode: quoteService.pricingMode || (hasSplitPricing ? 'split' : 'full'),
applyShopCharge: quoteService.applyShopCharge !== false,
technicianNotes: quoteService.technicianNotes,
} as ROService;
}
export function mergeApprovedQuoteServicesIntoRO(
existingServices: ROService[],
quoteServices: QuoteService[],
settings: Pick<ShopSettings, 'defaultLaborRate'>,
repairOrderId: string,
): { services: ROService[]; syncedCount: number } {
const approvedQuoteServices = quoteServices.filter((s) => s.customerDecision === 'approved' || s.approved);
const merged = [...existingServices];
for (const quoteService of approvedQuoteServices) {
const roService = quoteServiceToROService(quoteService, settings, repairOrderId);
const originId = sourceROServiceId(quoteService, repairOrderId);
const existingIdx = merged.findIndex((s) => {
if (originId && s.id === originId) return true;
if (s.id === roService.id) return true;
return !originId && s.name === quoteService.name;
});
if (existingIdx >= 0) {
merged[existingIdx] = {
...merged[existingIdx],
...roService,
id: merged[existingIdx].id || roService.id,
status: merged[existingIdx].status || 'pending',
technician: merged[existingIdx].technician || roService.technician,
};
} else {
merged.push(roService);
}
}
return { services: merged, syncedCount: approvedQuoteServices.length };
}
export async function syncApprovedQuoteServicesToRO({
pb,
repairOrderId,
quoteServices,
settings,
}: {
pb: PocketBaseLike;
repairOrderId: string;
quoteServices: QuoteService[];
settings: Pick<ShopSettings, 'defaultLaborRate'>;
}): Promise<number> {
if (!repairOrderId) return 0;
const approvedQuoteServices = quoteServices.filter((s) => s.customerDecision === 'approved' || s.approved);
if (approvedQuoteServices.length === 0) return 0;
const ro = await pb.collection('repairOrders').getOne(repairOrderId);
const existingServices = parseServices((ro as any).services);
const { services, syncedCount } = mergeApprovedQuoteServicesIntoRO(existingServices, quoteServices, settings, repairOrderId);
await pb.collection('repairOrders').update(repairOrderId, {
services: JSON.stringify(services),
});
return syncedCount;
}
+38
View File
@@ -0,0 +1,38 @@
import React from 'react';
import { Navigate } from 'react-router-dom';
import { pb } from './pocketbase';
import { useSettingsStore } from '../store/settings';
export type UserRole = 'advisor' | 'technician' | 'mobile';
function roleFromModel(): UserRole | null {
const role = pb.authStore.model?.role as string | undefined;
if (role === 'advisor' || role === 'technician' || role === 'mobile') return role;
return null;
}
function roleFromSettings(): UserRole {
const { businessType } = useSettingsStore.getState().settings;
if (businessType === 'advisor' || businessType === 'shop') return 'advisor';
if (businessType === 'mobile' || businessType === 'home') return 'mobile';
return 'advisor';
}
export function getCurrentUserRole(): UserRole {
return roleFromModel() ?? roleFromSettings();
}
export function isOwnerRole(role: UserRole): boolean {
return role === 'advisor' || role === 'mobile';
}
export function isTechnicianRole(role: UserRole): boolean {
return role === 'technician';
}
export function RequireOwnerRole({ children }: { children: React.ReactNode }) {
if (isTechnicianRole(getCurrentUserRole())) {
return React.createElement(Navigate, { to: '/technician', replace: true });
}
return React.createElement(React.Fragment, null, children);
}
+210
View File
@@ -0,0 +1,210 @@
/* ── Pure helper functions extracted from RepairOrders.tsx ── */
import { readPromisedOverride, isVoided } from './roEvents';
import { formatDateTime } from './format';
import { logError } from './userMessages';
import { getStatusConfig } from './status';
import type { RepairOrder, CustomerType, ShopSettings } from '../types';
/* ── Types used by helpers in this module ───────── */
export type CompletedRangeFilter = 'all' | 'today' | 'this_week' | 'this_month';
/* ── Due Time Calc ──────────────────────────────── */
export function calcDueTime(ro: RepairOrder): number | null {
const override = readPromisedOverride(ro);
if (override) {
const t = new Date(override).getTime();
if (!isNaN(t)) return t;
}
const baseTime = ro.writeupTime || ro.created;
const duration = ro.estimatedDuration || 60;
if (baseTime) {
return new Date(baseTime).getTime() + duration * 60000;
}
if (ro.promisedTime) {
return new Date(ro.promisedTime).getTime();
}
return null;
}
export function formatTimeRemaining(ro: RepairOrder, now: number): string {
if (ro.status === 'completed' || ro.status === 'final_close' || ro.status === 'delivered') return '—';
const due = calcDueTime(ro);
if (due === null) return '—';
const diffMs = due - now;
const absMs = Math.abs(diffMs);
const hours = Math.floor(absMs / 3600000);
const minutes = Math.floor((absMs % 3600000) / 60000);
const sign = diffMs < 0 ? '-' : '';
if (hours > 0) {
return `${sign}${hours}h ${minutes}m`;
}
return `${sign}${minutes}m`;
}
export function getUrgencyClass(ro: RepairOrder, now: number): string {
if (isVoided(ro)) return '';
if (ro.status === 'completed' || ro.status === 'final_close' || ro.status === 'delivered') return '';
const due = calcDueTime(ro);
if (due === null) return '';
const diffMs = due - now;
if (diffMs < 0) return 'urgent';
if (diffMs <= 30 * 60000) return 'warning';
return '';
}
export function formatDueDateTime(ro: RepairOrder): string {
const due = calcDueTime(ro);
if (!due) return '—';
return formatDateTime(new Date(due).toISOString());
}
export function getCompletedMetricDate(ro: RepairOrder): string | undefined {
return ro.completedTime || ro.completedDate || ro.updated || ro.created || ro.writeupTime;
}
export function isWithinCompletedRange(ro: RepairOrder, range: CompletedRangeFilter): boolean {
if (range === 'all') return true;
const dateStr = getCompletedMetricDate(ro);
if (!dateStr) return false;
const d = new Date(dateStr);
if (Number.isNaN(d.getTime())) return false;
const now = new Date();
const startToday = new Date(now.getFullYear(), now.getMonth(), now.getDate());
const endToday = new Date(now.getFullYear(), now.getMonth(), now.getDate(), 23, 59, 59, 999);
if (range === 'today') return d >= startToday && d <= endToday;
if (range === 'this_week') {
const start = new Date(startToday);
start.setDate(startToday.getDate() - startToday.getDay());
return d >= start && d <= endToday;
}
const startMonth = new Date(now.getFullYear(), now.getMonth(), 1);
return d >= startMonth && d <= endToday;
}
export function sortCompletedOrders(orders: RepairOrder[]): RepairOrder[] {
return [...orders].sort((a, b) => {
const aVoid = isVoided(a);
const bVoid = isVoided(b);
if (aVoid !== bVoid) return aVoid ? 1 : -1;
return new Date(getCompletedMetricDate(b) || 0).getTime() -
new Date(getCompletedMetricDate(a) || 0).getTime();
});
}
export function ratingLabel(rating?: number): string {
return rating ? `${rating}/5` : 'Unrated';
}
export function displayCustomerName(name?: string): string {
const normalized = (name || '').trim().replace(/\s+/g, ' ');
if (!normalized) return '—';
const delimited = normalized
.split(/\s*(?:\/|,|;|\||&|\+|\s+-\s+|\band\b)\s*/i)
.map((part) => part.trim())
.filter(Boolean);
if (delimited.length > 1) return delimited[0];
const words = normalized.split(' ');
if (words.length % 2 === 0) {
const half = words.length / 2;
const first = words.slice(0, half).join(' ');
const second = words.slice(half).join(' ');
if (first.toLowerCase() === second.toLowerCase()) return first;
}
return normalized;
}
export function sortOrders(orders: RepairOrder[]): RepairOrder[] {
return [...orders].sort((a, b) => {
const aVoid = isVoided(a);
const bVoid = isVoided(b);
if (aVoid !== bVoid) return aVoid ? 1 : -1;
if (aVoid && bVoid) {
return new Date(b.writeupTime || b.created || 0).getTime() -
new Date(a.writeupTime || a.created || 0).getTime();
}
const aDue = calcDueTime(a) ?? 0;
const bDue = calcDueTime(b) ?? 0;
const aIsWaitingParts = a.status === 'waiting_parts';
const bIsWaitingParts = b.status === 'waiting_parts';
const aIsWaitingPickup = a.status === 'waiting_pickup';
const bIsWaitingPickup = b.status === 'waiting_pickup';
if (aIsWaitingParts && !bIsWaitingParts) return 1;
if (!aIsWaitingParts && bIsWaitingParts) return -1;
if (aIsWaitingParts && bIsWaitingParts) return aDue - bDue;
if (aIsWaitingPickup && !bIsWaitingPickup) return 1;
if (!aIsWaitingPickup && bIsWaitingPickup) return -1;
if (aIsWaitingPickup && bIsWaitingPickup) return aDue - bDue;
const aType = a.customerType || 'drop-off';
const bType = b.customerType || 'drop-off';
if (aType !== bType) {
if (aType === 'waiter') return -1;
return 1;
}
return aDue - bDue;
});
}
/* ── Normalization ──────────────────────────────── */
export function normalizeRO(item: any): RepairOrder {
let services = item.services || [];
if (typeof services === 'string' && services.trim()) {
try { services = JSON.parse(services); } catch (err) { logError(`Failed to parse normalizeRO services JSON for RO ${item.id}: ${services}`, err); services = []; }
}
if (!Array.isArray(services)) services = [];
let estimatedDuration: number | undefined;
if (item.estimatedTime && item.estimatedTime !== '') {
const hours = parseFloat(item.estimatedTime);
if (!isNaN(hours)) estimatedDuration = Math.round(hours * 60);
}
let customerType: CustomerType | undefined =
(item.customerType === 'waiter' || item.customerType === 'drop-off')
? item.customerType
: undefined;
let financial: Record<string, any> | undefined;
if (item.financial) {
try {
financial = typeof item.financial === 'string' ? JSON.parse(item.financial) : item.financial;
if (financial && (!customerType) && (financial.customerType === 'waiter' || financial.customerType === 'drop-off')) {
customerType = financial.customerType;
}
} catch (err) { logError('Failed to read promised override from financial blob', err); }
}
const result: Record<string, any> = { ...item, services, estimatedDuration, customerType };
if (typeof item.serviceLocation === 'string') result.serviceLocation = item.serviceLocation;
if (item.travelFee !== undefined && item.travelFee !== null) {
result.travelFee = parseFloat(String(item.travelFee)) || 0;
}
if (item.tripMiles !== undefined && item.tripMiles !== null) {
result.tripMiles = parseFloat(String(item.tripMiles)) || 0;
}
if (financial !== undefined) result.financial = financial;
return result as unknown as RepairOrder;
}
/* ── Status filter chips ────────────────────────── */
export function getStatusChips(settings: ShopSettings): { value: string; label: string; colors: string }[] {
const config = getStatusConfig(settings);
return [
{ value: 'active', label: config.active.label, colors: config.active.colors },
{ value: 'in_progress', label: config.in_progress.label, colors: config.in_progress.colors },
{ value: 'waiting_parts', label: config.waiting_parts.label, colors: config.waiting_parts.colors },
{ value: 'waiting_pickup', label: config.waiting_pickup.label, colors: config.waiting_pickup.colors },
{ value: 'completed', label: config.completed.label, colors: config.completed.colors },
{ value: 'final_close', label: config.final_close.label, colors: config.final_close.colors },
];
}
+101
View File
@@ -0,0 +1,101 @@
// Tiny inline-rich-text parser shared by the PDF generator and the Settings
// preview. Supports two inline tags, written as `{tag}…{/tag}`:
//
// {accent}…{/accent} — primary accent color (PDF uses ShopSettings.pdfAccentColor;
// JSX uses whatever color the caller passes).
// {muted}…{/muted} — muted gray (PDF: DG; JSX: text-gray-400).
// {bold}…{/bold} — bold weight.
//
// Tags can be freely combined and nested one level deep, e.g.:
// "Due on Receipt {accent}(Net 30 available{/accent})"
// "{accent}Net 15{/accent} — {muted}1.5% discount if paid early{/muted}"
//
// Unknown tags are emitted as literal text (so a stray `{days}` placeholder
// in `quoteFooterNote` still renders verbatim — the PDF layer replaces that
// template var before calling us).
//
// The parser returns flat segments; nesting is resolved by merging child
// styles into the parent (e.g. `{accent}{bold}X{/bold}{/accent}` yields one
// `accent+bold` segment). One level of nesting is the practical limit —
// anything more complex belongs in a real templating engine, not settings
// strings.
export type RichStyle = 'plain' | 'accent' | 'muted' | 'bold';
export interface RichSegment {
text: string;
style: RichStyle;
}
const TAG_RE = /\{(accent|muted|bold|\/accent|\/muted|\/bold)\}/g;
export function parseRichText(input: string): RichSegment[] {
if (!input) return [];
TAG_RE.lastIndex = 0;
const out: RichSegment[] = [];
let cursor = 0;
// Open styles stack. Plain is always at the bottom.
const stack: RichStyle[] = ['plain'];
let last: RichSegment | null = null;
const pushText = (text: string) => {
if (!text) return;
const style = stack[stack.length - 1] === 'bold'
? (stack[stack.length - 2] === 'accent' || stack[stack.length - 2] === 'muted'
? (stack[stack.length - 2] as RichStyle)
: 'bold')
: (stack[stack.length - 1] as RichStyle);
if (last && last.style === style) {
last.text += text;
} else {
last = { text, style };
out.push(last);
}
};
let match: RegExpExecArray | null;
while ((match = TAG_RE.exec(input)) !== null) {
if (match.index > cursor) pushText(input.slice(cursor, match.index));
const tag = match[1];
if (tag.startsWith('/')) {
// Pop the matching open tag (if any). If a close shows up without a
// matching open, render it literally so users see their mistake.
const open = tag.slice(1) as RichStyle;
const idx = stack.lastIndexOf(open);
if (idx > 0) {
stack.length = idx;
last = null;
} else {
pushText(match[0]);
}
} else {
// Push only the styles we actually support nesting on. `bold` nests on
// top of accent/muted; accent/muted replace each other (taking the
// newest) and don't nest on themselves.
const open = tag as RichStyle;
if (open === 'bold') {
stack.push('bold');
} else {
// accent / muted — replace any existing accent/muted at the top of
// the stack, otherwise push a new entry above the base.
if (stack.length > 1 && (stack[stack.length - 1] === 'accent' || stack[stack.length - 1] === 'muted')) {
stack[stack.length - 1] = open;
} else {
stack.push(open);
}
}
last = null;
}
cursor = match.index + match[0].length;
}
if (cursor < input.length) pushText(input.slice(cursor));
return out;
}
// Convenience: does a given string contain any rich-tag at all? Lets callers
// skip the segment-based rendering path when there's nothing styled.
export function hasRichText(input: string | undefined | null): boolean {
if (!input) return false;
TAG_RE.lastIndex = 0;
return TAG_RE.test(input);
}
+176
View File
@@ -0,0 +1,176 @@
// RO audit-timeline + soft-feature helpers.
//
// We deliberately store the new audit / promised-override / void / clock
// data inside the existing `financial` JSON blob on the `repairOrders`
// collection. PocketBase already types that column as JSON, so this needs
// no schema migration and works against the running instance today.
//
// All helpers do a fresh read → merge → write to avoid clobbering
// concurrent edits to other keys of the same blob.
import { pb } from './pocketbase';
import { logError } from './userMessages';
import type { ROEvent, ClockEntry, RepairOrder } from '../types';
const KEY_EVENTS = 'events';
const KEY_PROMISED_OVERRIDE = 'promisedTimeOverride';
const KEY_VOIDED = 'voided';
const KEY_CLOCK = 'clockEntries';
// ── Low-level blob read/merge/write ─────────────────────────────────────────
function getBlob(ro: RepairOrder | { id: string; financial?: any }): Record<string, any> {
const raw = (ro as any).financial;
if (!raw) return {};
if (typeof raw === 'string') {
try { return JSON.parse(raw) as Record<string, any>; } catch (err) { logError('getBlob JSON parse failed', err); return {}; }
}
if (typeof raw === 'object') return raw as Record<string, any>;
return {};
}
function parseBlob(raw: any): Record<string, any> {
if (!raw) return {};
if (typeof raw === 'string') {
try { return JSON.parse(raw) as Record<string, any>; } catch (err) { logError('parseBlob JSON parse failed', err); return {}; }
}
if (typeof raw === 'object') return raw as Record<string, any>;
return {};
}
async function fetchFresh(id: string): Promise<Record<string, any>> {
try {
const rec = await pb.collection('repairOrders').getOne(id);
return parseBlob((rec as any).financial);
} catch (err) {
logError('roEvents.fetchFresh: failed to read financial blob', err);
throw err;
}
}
async function writeBlob(id: string, blob: Record<string, any>): Promise<void> {
await pb.collection('repairOrders').update(id, { financial: JSON.stringify(blob) });
}
// ── Events (audit timeline) ──────────────────────────────────────────────────
export function readEvents(ro: RepairOrder): ROEvent[] {
const blob = getBlob(ro);
const ev = blob[KEY_EVENTS];
return Array.isArray(ev) ? (ev as ROEvent[]) : [];
}
function newEventId(): string {
return `${Date.now()}-${Math.random().toString(36).slice(2, 9)}`;
}
export async function appendROEvent(
roId: string,
type: ROEvent['type'],
data?: Record<string, any>,
by?: string,
): Promise<ROEvent> {
const blob = await fetchFresh(roId);
const events: ROEvent[] = Array.isArray(blob[KEY_EVENTS]) ? blob[KEY_EVENTS] : [];
const event: ROEvent = {
id: newEventId(),
type,
at: new Date().toISOString(),
data,
by,
};
// Cap the timeline at the most recent 200 entries to keep the blob small.
const next = [...events, event].slice(-200);
blob[KEY_EVENTS] = next;
await writeBlob(roId, blob);
return event;
}
// ── Promised time override ───────────────────────────────────────────────────
export function readPromisedOverride(ro: RepairOrder): string | undefined {
const blob = getBlob(ro);
const v = blob[KEY_PROMISED_OVERRIDE];
return typeof v === 'string' && v ? v : undefined;
}
export async function setPromisedOverride(
roId: string,
value: string | null,
by?: string,
): Promise<void> {
const blob = await fetchFresh(roId);
if (value === null) delete blob[KEY_PROMISED_OVERRIDE];
else blob[KEY_PROMISED_OVERRIDE] = value;
await writeBlob(roId, blob);
await appendROEvent(roId, 'promised_override', { value }, by);
}
// ── Soft void ───────────────────────────────────────────────────────────────
export function isVoided(ro: RepairOrder): boolean {
const blob = getBlob(ro);
return blob[KEY_VOIDED] === true;
}
export async function setVoided(
roId: string,
voided: boolean,
by?: string,
): Promise<void> {
const blob = await fetchFresh(roId);
if (voided) blob[KEY_VOIDED] = true;
else delete blob[KEY_VOIDED];
await writeBlob(roId, blob);
await appendROEvent(roId, voided ? 'void' : 'unvoid', {}, by);
}
// ── Technician clock (stopwatch) ─────────────────────────────────────────────
export function readClockEntries(ro: RepairOrder): ClockEntry[] {
const blob = getBlob(ro);
const arr = blob[KEY_CLOCK];
return Array.isArray(arr) ? (arr as ClockEntry[]) : [];
}
export function clockRunning(ro: RepairOrder): ClockEntry | undefined {
const entries = readClockEntries(ro);
return entries.find((e) => !e.end);
}
export function clockElapsedMinutes(ro: RepairOrder): number {
const entries = readClockEntries(ro);
const now = Date.now();
return entries.reduce((sum, e) => {
if (!e.start) return sum;
const end = e.end ? new Date(e.end).getTime() : now;
const start = new Date(e.start).getTime();
const mins = Math.max(0, Math.round((end - start) / 60000));
return sum + (e.minutes ?? mins);
}, 0);
}
export async function clockStart(roId: string, by?: string): Promise<void> {
const blob = await fetchFresh(roId);
const arr: ClockEntry[] = Array.isArray(blob[KEY_CLOCK]) ? blob[KEY_CLOCK] : [];
if (arr.some((e) => !e.end)) return; // already running
arr.push({ start: new Date().toISOString() });
blob[KEY_CLOCK] = arr;
await writeBlob(roId, blob);
await appendROEvent(roId, 'clock_start', {}, by);
}
export async function clockStop(roId: string, by?: string): Promise<number> {
const blob = await fetchFresh(roId);
const arr: ClockEntry[] = Array.isArray(blob[KEY_CLOCK]) ? blob[KEY_CLOCK] : [];
const idx = arr.findIndex((e) => !e.end);
if (idx === -1) return 0;
const entry = arr[idx];
const end = new Date().toISOString();
const mins = Math.max(0, Math.round((new Date(end).getTime() - new Date(entry.start).getTime()) / 60000));
arr[idx] = { ...entry, end, minutes: mins };
blob[KEY_CLOCK] = arr;
await writeBlob(roId, blob);
await appendROEvent(roId, 'clock_stop', { elapsedMin: mins }, by);
return mins;
}
+36
View File
@@ -0,0 +1,36 @@
// Shared Repair Order number generation utilities.
//
// Previously `generateRONumber` lived inside src/pages/RepairOrders.tsx and
// wasn't exported, so QuoteGenerator couldn't reuse it when "converting" a
// quote to an RO. Promoted to a shared module so both pages assign RO
// numbers with identical rules.
function pad4(n: number): string {
return String(n).padStart(4, '0');
}
/** Generate a candidate RO number in the `RO-YYMMDD-####` format. */
export function generateRONumber(): string {
const now = new Date();
const y = now.getFullYear().toString().slice(2);
const m = String(now.getMonth() + 1).padStart(2, '0');
const d = String(now.getDate()).padStart(2, '0');
const seq = pad4(Math.floor(Math.random() * 9999));
return `RO-${y}${m}${d}-${seq}`;
}
/**
* Generate a unique RO number, retrying until none of the existing RO numbers
* collide. Useful for client-side generation (the server-side uniqueness
* guard lives in PocketBase via the M5 migration — adds `unique=true` on the
* `roNumber` field). Pass the set of existing numbers to avoid; pass an
* empty set if you only want server-side dedup.
*/
export function uniqueRONumber(existing: Set<string>): string {
for (let i = 0; i < 25; i++) {
const candidate = generateRONumber();
if (!existing.has(candidate)) return candidate;
}
// Extremely unlikely: fall back to a high-entropy suffix.
return `RO-collide-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`;
}
+400
View File
@@ -0,0 +1,400 @@
// Printable Repair Order / Work-Order PDF.
//
// Reuses the same jsPDF layout primitives as src/lib/pdf.ts so the two
// documents look like they come from the same shop. Deliberately simpler
// than the quote PDF — an RO is an internal work order, not a customer-
// facing recommendation, so we skip the personalized header message,
// approval badges, and the "postponed services" section.
import type { jsPDF } from 'jspdf';
import type { RepairOrder, ShopSettings } from '../types';
import { computeROTotals, roServiceTotal } from './totals';
import { formatCurrency, formatDate } from './format';
import { SERVICE_SUB_STATUS_CONFIG, statusLabel, statusLabelForBusiness } from './status';
import { getProviderLabel, getRoleLabel } from './settings';
const PAGE_W = 612;
const PAGE_H = 792;
const MARGIN = 40;
const CW = PAGE_W - MARGIN * 2;
const CONTENT_BOTTOM = PAGE_H - MARGIN;
const FOOTER_HEIGHT = 35;
type ColorTuple = [number, number, number];
const BL: ColorTuple = [18, 18, 18];
const DG: ColorTuple = [64, 64, 64];
const MG: ColorTuple = [80, 80, 80];
const LG: ColorTuple = [220, 220, 220];
const GR: ColorTuple = [22, 163, 74];
const ACCENT: ColorTuple = GR;
function getLineHeight(fontSize: number, multiplier = 1.2): number {
return Math.ceil(fontSize * multiplier);
}
function newPage(doc: jsPDF): number {
doc.addPage();
return MARGIN;
}
function drawFooter(doc: jsPDF, pageNum: number, totalPages: number, settings: ShopSettings) {
doc.setPage(pageNum);
const footerY = PAGE_H - MARGIN - FOOTER_HEIGHT + 5;
doc.setDrawColor(...LG);
doc.setLineWidth(1);
doc.line(MARGIN, footerY, PAGE_W - MARGIN, footerY);
doc.setTextColor(...BL);
doc.setFont('helvetica', 'normal');
doc.setFontSize(9);
const fm = settings.footerMessage || 'Thank you for choosing us for your automotive needs!';
doc.text(fm, PAGE_W / 2, footerY + 15, { align: 'center' });
doc.setTextColor(...MG);
doc.setFont('helvetica', 'bold');
doc.setFontSize(12);
doc.text(`Page ${pageNum} of ${totalPages}`, PAGE_W - MARGIN, PAGE_H - 10, { align: 'right' });
}
export async function generateROPDF(
ro: RepairOrder,
settings: ShopSettings,
): Promise<Blob> {
const { jsPDF } = await import('jspdf');
const doc = new jsPDF('p', 'pt', 'letter');
const businessName = settings.businessName || 'Auto Repair Shop';
const businessPhone = settings.businessPhone || '';
const businessAddress = settings.businessAddress || '';
const providerLabel = getProviderLabel(settings);
const roleLabel = getRoleLabel(settings);
const services = Array.isArray(ro.services) ? ro.services : [];
const totals = computeROTotals({
services: services.map((s) => ({
...s,
id: s.id || '',
name: s.name || '',
description: s.description || '',
laborHours: s.laborHours || 0,
laborRate: s.laborRate || 0,
partsCost: s.partsCost || 0,
total: roServiceTotal({
laborHours: s.laborHours || 0,
laborRate: s.laborRate || 0,
partsCost: s.partsCost || 0,
total: s.total || 0,
}),
status: s.status || 'pending',
technician: s.technician || '',
})) as any,
discount: ro.discount,
settings,
});
let y = MARGIN;
const leftColX = MARGIN;
const rightColX = MARGIN + (CW * 0.58);
const colWidth = CW * 0.40;
// ── Logo ──
if (settings.logoUrl && settings.showLogoOnPdf) {
try {
doc.addImage(settings.logoUrl, 'PNG', MARGIN, y, 40, 18);
y += 24;
} catch { /* ignore logo errors */ }
}
// ── Header band ──
const headerTop = y;
doc.setFillColor(248, 248, 248);
doc.roundedRect(MARGIN, headerTop, CW, 110, 4, 4, 'F');
// Left: shop
doc.setTextColor(...MG);
doc.setFont('helvetica', 'bold');
doc.setFontSize(8);
doc.text(providerLabel, leftColX, y + 14);
doc.setFont('helvetica', 'bold');
doc.setFontSize(11);
doc.setTextColor(...BL);
doc.text(businessName, leftColX, y + 28);
doc.setFont('helvetica', 'normal');
doc.setFontSize(9);
doc.setTextColor(...DG);
let addrY = y + 40;
businessAddress.split('\n').forEach((line: string) => {
doc.splitTextToSize(line, colWidth).forEach((l: string) => {
doc.text(l, leftColX, addrY);
addrY += getLineHeight(9, 1.4);
});
});
if (businessPhone) {
doc.setTextColor(...MG);
doc.setFontSize(8);
doc.text('Phone', leftColX, addrY + 2);
doc.setTextColor(...BL);
doc.setFontSize(9);
doc.text(businessPhone, leftColX + colWidth - 5, addrY + 2, { align: 'right' });
}
// Right: RO header
let rY = headerTop + 14;
doc.setTextColor(...ACCENT);
doc.setFont('helvetica', 'bold');
doc.setFontSize(14);
doc.text('REPAIR ORDER', rightColX, rY);
rY += 16;
doc.setFont('helvetica', 'normal');
doc.setFontSize(8);
doc.setTextColor(...MG);
doc.text('RO #', rightColX, rY);
doc.setTextColor(...BL);
doc.text(ro.roNumber || ro.id.slice(0, 8), rightColX + colWidth - 5, rY, { align: 'right' });
rY += getLineHeight(9, 1.4);
const infoRows: [string, string][] = [
['Status', statusLabelForBusiness(ro.status, settings)],
['Date', formatDate(ro.writeupTime || ro.created)],
['Customer', ro.customerName || '—'],
['Vehicle', ro.vehicleInfo || '—'],
['VIN', ro.vin || '—'],
['Mileage', ro.mileage ? `${ro.mileage} mi` : '—'],
[roleLabel, ro.advisorName || '—'],
...(ro.serviceLocation ? [['Service Location', ro.serviceLocation] as [string, string]] : []),
...(ro.tripMiles && ro.tripMiles > 0 ? [['Trip Miles', `${ro.tripMiles} mi`] as [string, string]] : []),
];
infoRows.forEach(([label, value]) => {
doc.setTextColor(...MG);
doc.setFontSize(8);
doc.text(label, rightColX, rY);
doc.setTextColor(...BL);
doc.setFontSize(9);
doc.splitTextToSize(value || '—', colWidth - 30).forEach((l: string, i: number) => {
doc.text(l, rightColX + colWidth - 5, rY + i * getLineHeight(9, 1.4), { align: 'right' });
});
rY += getLineHeight(9, 1.4);
});
y = Math.max(addrY + 16, rY + 12) + 8;
doc.setDrawColor(...LG);
doc.setLineWidth(0.7);
doc.roundedRect(MARGIN, headerTop, CW, y - MARGIN - 8, 4, 4, 'D');
y += 6;
// ── Services table header ──
if (y + 30 > CONTENT_BOTTOM - FOOTER_HEIGHT) y = newPage(doc);
doc.setFont('helvetica', 'bold');
doc.setFontSize(11);
doc.setTextColor(...ACCENT);
doc.text('SERVICES', MARGIN, y);
y += 6;
doc.setDrawColor(...ACCENT);
doc.setLineWidth(0.7);
doc.line(MARGIN, y, PAGE_W - MARGIN, y);
y += 14;
// Column layout for services
const colName = MARGIN;
const colNameW = CW * 0.40;
const colTech = MARGIN + colNameW;
const colTechW = CW * 0.18;
const colStatus = MARGIN + colNameW + colTechW;
const colStatusW = CW * 0.18;
const colTotal = PAGE_W - MARGIN;
doc.setFont('helvetica', 'bold');
doc.setFontSize(8);
doc.setTextColor(...MG);
doc.text('SERVICE', colName, y);
doc.text('TECH', colTech, y);
doc.text('STATUS', colStatus, y);
doc.text('TOTAL', colTotal, y, { align: 'right' });
y += 6;
doc.setDrawColor(...LG);
doc.setLineWidth(0.5);
doc.line(MARGIN, y, PAGE_W - MARGIN, y);
y += 12;
// Service rows
services.forEach((svc, idx) => {
const nameLines = doc.splitTextToSize(svc.name || '(unnamed)', colNameW - 8);
const detailLines = doc.splitTextToSize(
`${svc.laborHours || 0}h × ${formatCurrency(svc.laborRate || 0)}/hr${svc.partsCost ? ' + parts ' + formatCurrency(svc.partsCost) : ''}`,
colNameW - 8,
);
const rowsUsed = Math.max(nameLines.length, detailLines.length);
const rowH = Math.max(rowsUsed * getLineHeight(9, 1.4) + 4, 14);
if (y + rowH > CONTENT_BOTTOM - FOOTER_HEIGHT) {
y = newPage(doc);
// redraw section header
doc.setFont('helvetica', 'bold');
doc.setFontSize(11);
doc.setTextColor(...ACCENT);
doc.text('SERVICES (cont.)', MARGIN, y);
y += 10;
doc.setDrawColor(...ACCENT);
doc.line(MARGIN, y, PAGE_W - MARGIN, y);
y += 14;
}
const rowTop = y;
doc.setFont('helvetica', 'bold');
doc.setFontSize(9);
doc.setTextColor(...BL);
nameLines.forEach((l: string, i: number) => {
doc.text(l, colName, rowTop + i * getLineHeight(9, 1.4));
});
doc.setFont('helvetica', 'normal');
doc.setFontSize(8);
doc.setTextColor(...MG);
detailLines.forEach((l: string, i: number) => {
doc.text(l, colName, rowTop + nameLines.length * getLineHeight(9, 1.4) + i * getLineHeight(8, 1.4));
});
doc.setTextColor(...BL);
doc.setFontSize(8);
doc.text(svc.technician || '—', colTech, rowTop);
doc.setTextColor(...DG);
const sub = svc.subStatus && SERVICE_SUB_STATUS_CONFIG[svc.subStatus];
const statusText = `${statusLabel(svc.status || 'pending')}${sub && sub.label ? ' · ' + sub.label : ''}`;
doc.splitTextToSize(statusText, colStatusW - 6).forEach((l: string, i: number) => {
doc.text(l, colStatus, rowTop + i * getLineHeight(8, 1.4));
});
doc.setFont('helvetica', 'bold');
doc.setFontSize(10);
doc.setTextColor(...BL);
doc.text(
formatCurrency(
roServiceTotal({
laborHours: svc.laborHours || 0,
laborRate: svc.laborRate || 0,
partsCost: svc.partsCost || 0,
total: svc.total || 0,
}),
),
colTotal, rowTop, { align: 'right' },
);
y = rowTop + rowH;
if (idx < services.length - 1) {
doc.setDrawColor(...LG);
doc.setLineWidth(0.3);
doc.line(MARGIN, y, PAGE_W - MARGIN, y);
y += 8;
}
});
// ── Notes ──
if (ro.notes && ro.notes.trim()) {
if (y + 40 > CONTENT_BOTTOM - FOOTER_HEIGHT) y = newPage(doc);
y += 12;
doc.setFont('helvetica', 'bold');
doc.setFontSize(9);
doc.setTextColor(...MG);
doc.text('NOTES', MARGIN, y);
y += 6;
doc.setDrawColor(...LG);
doc.line(MARGIN, y, PAGE_W - MARGIN, y);
y += 12;
doc.setFont('helvetica', 'normal');
doc.setFontSize(9);
doc.setTextColor(...DG);
doc.splitTextToSize(ro.notes, CW).forEach((l: string) => {
if (y > CONTENT_BOTTOM - FOOTER_HEIGHT) y = newPage(doc);
doc.text(l, MARGIN, y);
y += getLineHeight(9, 1.4);
});
}
// ── Totals summary box ──
const sumX = PAGE_W - MARGIN - (CW * 0.45);
const sumW = CW * 0.45;
const sumRight = PAGE_W - MARGIN;
const bp = 10;
const discountAmount = totals.discountAmount;
const rows: [string, string, ColorTuple?][] = [
['Services Subtotal', formatCurrency(totals.laborTotal + totals.partsTotal), DG],
...(discountAmount > 0 ? [['Discount', `${formatCurrency(discountAmount)}`, GR] as [string, string, ColorTuple]] : []),
...(totals.shopCharge > 0 ? [['Shop Charge', formatCurrency(totals.shopCharge), DG] as [string, string, ColorTuple]] : []),
...(totals.tax > 0 ? [[settings.taxLabel || 'Tax', formatCurrency(totals.tax), DG] as [string, string, ColorTuple]] : []),
];
const sumRows = rows.length + 3;
const sumContentH = sumRows * getLineHeight(9, 1.2) + 14;
const sumTotalH = bp + sumContentH + bp;
if (y + sumTotalH + 14 > CONTENT_BOTTOM - FOOTER_HEIGHT) y = newPage(doc);
y += 14;
const sumTopY = y - bp;
doc.setFillColor(250, 250, 250);
doc.setDrawColor(...LG);
doc.setLineWidth(0.5);
doc.roundedRect(sumX - bp, sumTopY, sumW + bp * 2, sumTotalH, 4, 4, 'FD');
y = sumTopY + bp;
doc.setFont('helvetica', 'bold');
doc.setFontSize(8);
doc.setTextColor(...MG);
doc.text('RO TOTAL', sumX, y);
y += getLineHeight(9);
rows.forEach(([label, value, color]) => {
doc.setFont('helvetica', 'normal');
doc.setFontSize(9);
doc.setTextColor(...(color || DG));
doc.text(label, sumX, y);
doc.setTextColor(...BL);
doc.text(value, sumRight, y, { align: 'right' });
y += getLineHeight(9);
});
doc.setDrawColor(180, 180, 180);
doc.line(sumX, y, sumRight, y);
y += getLineHeight(10);
doc.setFont('helvetica', 'bold');
doc.setFontSize(11);
doc.setTextColor(...BL);
doc.text('TOTAL DUE', sumX, y);
doc.text(formatCurrency(totals.total), sumRight, y, { align: 'right' });
// ── Footer on every page ──
const totalPages = doc.getNumberOfPages();
for (let i = 1; i <= totalPages; i++) {
drawFooter(doc, i, totalPages, settings);
}
return doc.output('blob');
}
export async function downloadROPDF(ro: RepairOrder, settings: ShopSettings): Promise<void> {
const blob = await generateROPDF(ro, settings);
const sn = (ro.customerName || 'Customer').replace(/[^a-zA-Z0-9_-]/g, '_').replace(/_+/g, '_').replace(/^_|_$/g, '');
const sr = (ro.roNumber || 'NRO').replace(/[^a-zA-Z0-9_-]/g, '');
const ds = formatDate(ro.writeupTime || ro.created).replace(/,/g, '').replace(/\s+/g, '_');
const fn = `RO_${sr}_${sn}_${ds}.pdf`;
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = fn;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
}
export async function printROPDF(ro: RepairOrder, settings: ShopSettings): Promise<void> {
const blob = await generateROPDF(ro, settings);
const url = URL.createObjectURL(blob);
const win = window.open(url, '_blank');
if (win) win.addEventListener('load', () => win.print());
else window.location.href = url;
}
+23
View File
@@ -0,0 +1,23 @@
export const loadSettingsPage = () => import('../pages/Settings');
export function preloadSettingsPage() {
void loadSettingsPage();
}
export const loadRODetailModal = () => import('../pages/RODetailModal');
export function preloadRODetailModal() {
void loadRODetailModal();
}
export const loadPrivacyPage = () => import('../pages/Privacy');
export function preloadPrivacyPage() {
void loadPrivacyPage();
}
export const loadTimeCards = () => import('../pages/advisor/TimeCards');
export function preloadTimeCards() {
void loadTimeCards();
}
+303
View File
@@ -0,0 +1,303 @@
// Shared ShopSettings loader — single source of truth for default values
// and localStorage hydration. Previously duplicated in QuoteGenerator.tsx
// (loadSettings) and Settings.tsx (loadLocalSettings); RepairOrders.tsx had
// no settings loader at all and used hardcoded tax/shop-charge rates.
import { pb } from './pocketbase';
import type { QuotePdfAppearance, ShopSettings } from '../types';
import { logError } from './userMessages';
export const DEFAULT_QUOTE_PDF_APPEARANCE: QuotePdfAppearance = {
showBusinessAddress: true,
showBusinessPhone: true,
showCustomerInfo: true,
showVin: true,
showMileage: true,
showRoNumber: true,
showQuoteDate: true,
showHeaderMessage: true,
showFooterMessage: true,
showQuoteFooterNote: true,
showPaymentTerms: true,
showServiceDecisionBadges: true,
showAuthorizationLine: true,
showServicePrices: true,
showTotalsSummary: true,
showShopChargeLine: true,
showDiscountLine: true,
showTaxLine: true,
showPriority: true,
showPriorityReason: true,
showRecommendation: true,
showServiceDescription: true,
showPartsStatus: true,
showAftermarketOptions: true,
showDeclinedServices: true,
showPendingRecommendations: true,
serviceSeparators: true,
headerLayout: 'boxed',
logoAlignment: 'left',
logoWidth: 40,
logoHeight: 20,
priceDisplay: 'right',
sectionOrder: 'approved_first',
serviceSpacing: 'normal',
sectionSpacing: 'normal',
colors: {
sectionHeader: '#16a34a',
serviceTitle: '#121212',
description: '#505050',
recommendation: '#404040',
headerBackground: '#f8f8f8',
separator: '#dcdcdc',
totalsBackground: '#fafafa',
footer: '#121212',
approvedBadge: '#16a34a',
declinedBadge: '#dc2626',
recommendationsSection: '#2563eb',
},
sectionHeader: { font: 'helvetica', size: 12, weight: 'bold' },
serviceTitle: { font: 'helvetica', size: 11, weight: 'bold' },
serviceDescription: { font: 'helvetica', size: 9, weight: 'normal' },
recommendation: { font: 'helvetica', size: 9, weight: 'normal' },
recommendationTransform: 'uppercase',
priority: { font: 'helvetica', size: 9, weight: 'bold' },
authorization: { font: 'helvetica', size: 7, weight: 'normal' },
totals: { font: 'helvetica', size: 9, weight: 'normal' },
footer: { font: 'helvetica', size: 9, weight: 'normal' },
labels: {
approvedSection: 'SERVICES APPROVED',
recommendationsSection: 'ADDITIONAL RECOMMENDATIONS',
declinedSection: 'POSTPONED SERVICES',
customerApproved: 'CUSTOMER APPROVED',
customerDeclined: 'CUSTOMER DECLINED',
priorityReason: 'Reason:',
subtotal: 'Subtotal',
shopCharge: 'Shop charge',
discount: 'Discount',
total: 'TOTAL',
paymentTerms: 'Payment Terms:',
authorizedInPerson: 'Authorized in person',
authorizedByText: 'Authorized by text',
authorizedByCall: 'Authorized by call',
authorizedOnline: 'Authorized online',
authorizedWithSignature: 'Authorized with signature',
authorizedByEmail: 'Authorized via email',
authorizedTotal: 'Authorized Total',
addOnTotal: 'Add-On Total If Approved',
allWorkSubtotal: 'All Work Subtotal',
totalIfAllApproved: 'TOTAL IF ALL WORK APPROVED',
},
};
export const DEFAULT_SETTINGS: ShopSettings = {
businessType: 'shop',
businessName: '',
businessAddress: '',
businessPhone: '',
serviceAdvisor: '',
taxRate: 0,
taxLabel: 'Tax',
shopChargeRate: 3,
shopChargeCap: 69.95,
shopChargeExplanation: 'Shop Charge covers consumable supplies such as shop rags, cleaning solvents, lubricants, and other miscellaneous materials used during service.',
headerMessage: 'Hi {customerName}, here is the explanation of the services and repairs that your technician has recommended for your {vehicleInfo}.',
footerMessage: 'Thank you for choosing our shop for your automotive care!',
quoteFooterNote: 'This quote is valid for {days} days from the date above.',
logoUrl: '',
showLogoOnPdf: true,
quoteExpiryDays: 30,
quoteNumberPrefix: '',
pdfAccentColor: '#16a34a',
pdfStyle: 'professional',
businessHours: '',
defaultLaborRate: 95,
paymentTerms: 'Due on Receipt',
serviceLocationFieldsEnabled: false,
defaultTravelFee: 0,
travelFeeExplanation: 'Travel fee covers the cost of bringing tools and equipment to your location.',
defaultPricingMode: 'full',
showAdvisorOnQuote: true,
showTechnicianOnQuote: false,
defaultTechnician: '',
quotePdfAppearance: DEFAULT_QUOTE_PDF_APPEARANCE,
};
const STORAGE_KEY = 'spq-settings';
export function getRoleLabel(settings: Pick<ShopSettings, 'businessType'>): string {
return settings.businessType === 'advisor' ? 'Service Advisor' : 'Technician';
}
export function getAddressLabel(settings: Pick<ShopSettings, 'businessType'>): string {
return settings.businessType === 'mobile' || settings.businessType === 'home' ? 'Service Area' : 'Business Address';
}
export function getProviderLabel(settings: Pick<ShopSettings, 'businessType'>): string {
return settings.businessType === 'mobile' || settings.businessType === 'home' ? 'MOBILE SERVICE PROVIDER' : 'SERVICE PROVIDER';
}
export function getCustomerTypeLabels(settings: Pick<ShopSettings, 'businessType'>): { primary: string; secondary: string; groupLabel: string } {
if (settings.businessType === 'mobile' || settings.businessType === 'home') {
return {
primary: 'Scheduled Visit',
secondary: 'Customer Waiting',
groupLabel: 'Visit Type',
};
}
return {
primary: 'Drop-Off',
secondary: 'Waiter',
groupLabel: 'Customer Type',
};
}
export function showServiceLocationFields(settings: Pick<ShopSettings, 'businessType' | 'serviceLocationFieldsEnabled'>): boolean {
return settings.businessType === 'mobile' || settings.businessType === 'home' || settings.serviceLocationFieldsEnabled;
}
function mergeSettings(raw: unknown): ShopSettings {
const incoming = typeof raw === 'object' && raw ? raw as Partial<ShopSettings> : {};
const incomingAppearance = typeof incoming.quotePdfAppearance === 'object' && incoming.quotePdfAppearance
? incoming.quotePdfAppearance as Partial<QuotePdfAppearance>
: {};
return {
...DEFAULT_SETTINGS,
...incoming,
quotePdfAppearance: {
...DEFAULT_QUOTE_PDF_APPEARANCE,
...incomingAppearance,
sectionHeader: { ...DEFAULT_QUOTE_PDF_APPEARANCE.sectionHeader, ...incomingAppearance.sectionHeader },
serviceTitle: { ...DEFAULT_QUOTE_PDF_APPEARANCE.serviceTitle, ...incomingAppearance.serviceTitle },
serviceDescription: { ...DEFAULT_QUOTE_PDF_APPEARANCE.serviceDescription, ...incomingAppearance.serviceDescription },
recommendation: { ...DEFAULT_QUOTE_PDF_APPEARANCE.recommendation, ...incomingAppearance.recommendation },
priority: { ...DEFAULT_QUOTE_PDF_APPEARANCE.priority, ...incomingAppearance.priority },
authorization: { ...DEFAULT_QUOTE_PDF_APPEARANCE.authorization, ...incomingAppearance.authorization },
totals: { ...DEFAULT_QUOTE_PDF_APPEARANCE.totals, ...incomingAppearance.totals },
footer: { ...DEFAULT_QUOTE_PDF_APPEARANCE.footer, ...incomingAppearance.footer },
colors: { ...DEFAULT_QUOTE_PDF_APPEARANCE.colors, ...incomingAppearance.colors },
labels: { ...DEFAULT_QUOTE_PDF_APPEARANCE.labels, ...incomingAppearance.labels },
},
};
}
export function loadSettings(): ShopSettings {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (raw) return mergeSettings(JSON.parse(raw));
} catch { /* ignore corrupt localStorage */ }
return { ...DEFAULT_SETTINGS };
}
export function saveSettings(s: ShopSettings): void {
localStorage.setItem(STORAGE_KEY, JSON.stringify(s));
}
export function isSetupComplete(settings: ShopSettings): boolean {
return Boolean(
settings.businessName.trim() &&
settings.businessPhone.trim() &&
settings.serviceAdvisor.trim() &&
Number(settings.defaultLaborRate) > 0
);
}
async function ensureSettingRecord(userId: string) {
const records = await pb.collection('settings').getFullList({ filter: `userId="${userId}"` });
if (records.length > 0) return records[0];
return await pb.collection('settings').create({ userId, name: 'businessSettings', data: DEFAULT_SETTINGS });
}
/**
* If the PB settings record has a `logo` file field, fetch the image
* and convert to a base64 data URL for use in img src and jsPDF addImage.
* Returns null if no logo file exists or on error.
*/
async function pbLogoToBase64(record: Record<string, unknown>): Promise<string | null> {
const logoFilename = record.logo;
if (!logoFilename || typeof logoFilename !== 'string') return null;
try {
const id = String(record.id);
const collectionId = String(record.collectionId);
const url = pb.files.getURL({ id, collectionId } as never, 'logo');
const res = await fetch(url);
if (!res.ok) return null;
const blob = await res.blob();
return new Promise((resolve) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result as string);
reader.onerror = () => resolve(null);
reader.readAsDataURL(blob);
});
} catch {
return null;
}
}
export async function loadSettingsForUser(userId?: string): Promise<ShopSettings> {
const local = loadSettings();
if (!userId) return local;
try {
const records = await pb.collection('settings').getFullList({ filter: `userId="${userId}"` });
if (records.length === 0) return local;
const record = records[0] as Record<string, unknown>;
const cloud = mergeSettings(record.data);
// If PB has a logo file, fetch and convert to base64 for local state.
// This ensures PDF generation (jsPDF addImage) continues to work unchanged.
const logoDataUrl = await pbLogoToBase64(record);
if (logoDataUrl) {
cloud.logoUrl = logoDataUrl;
}
saveSettings(cloud);
return cloud;
} catch (err) {
logError('Failed to load settings for user', err);
return local;
}
}
export async function saveSettingsForUser(userId: string | undefined, settings: ShopSettings): Promise<void> {
saveSettings(settings);
if (!userId) return;
try {
const record = await ensureSettingRecord(userId);
await pb.collection('settings').update(record.id, { data: settings });
} catch (err) {
logError('Failed to save settings for user', err);
throw err;
}
}
/**
* Upload a logo image file to the PB settings record.
* Returns the base64 data URL for local state (Zustand / localStorage).
*/
export async function uploadLogoFile(userId: string, file: File): Promise<string> {
const record = await ensureSettingRecord(userId);
const formData = new FormData();
formData.append('logo', file);
await pb.collection('settings').update(record.id, formData);
// Convert the original File to a base64 data URL for local state.
// PDF generation (jsPDF addImage) expects data URLs.
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result as string);
reader.onerror = reject;
reader.readAsDataURL(file);
});
}
/**
* Remove the logo file from the PB settings record.
*/
export async function removeLogoFile(userId: string): Promise<void> {
const record = await ensureSettingRecord(userId);
await pb.collection('settings').update(record.id, { logo: null });
}
+131
View File
@@ -0,0 +1,131 @@
// Shared status metadata for Repair Orders and their service lines.
// Previously page-local in RepairOrders.tsx — pulled here so other pages
// (Invoices, Customers, FinancialDashboard) can share the same labels and
// colors instead of drifting apart.
import type { RepairOrder, ROService, ServiceSubStatus } from '../types';
import type { ShopSettings } from '../types';
export const STATUS_CONFIG: Record<string, { label: string; colors: string }> = {
active: {
label: 'Active',
colors: 'bg-blue-100 text-blue-800 dark:bg-blue-900/40 dark:text-blue-300',
},
in_progress: {
label: 'In Progress',
colors:
'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/40 dark:text-yellow-300',
},
waiting_parts: {
label: 'Waiting Parts',
colors:
'bg-orange-100 text-orange-800 dark:bg-orange-900/40 dark:text-orange-300',
},
waiting_pickup: {
label: 'Waiting Pick-up',
colors:
'bg-teal-100 text-teal-800 dark:bg-teal-900/40 dark:text-teal-300',
},
completed: {
label: 'Completed',
colors:
'bg-green-100 text-green-800 dark:bg-green-900/40 dark:text-green-300',
},
final_close: {
label: 'Final Close',
colors: 'bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-300',
},
delivered: {
label: 'Final Close',
colors: 'bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-300',
},
// Soft-voided ROs (voided flag in the financial blob). Hidden from the
// default tabs; reached via the "Show voided" filter toggle on the list
// page. We deliberately don't extend the union type in types.ts to keep
// other pages (Invoices, Customers) that switch on `status` exhaustive.
};
export 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 const SERVICE_SUB_STATUS_CONFIG: Record<
ServiceSubStatus,
{ label: string; colors: string; order: number }
> = {
'': {
label: 'Not started',
colors: 'bg-gray-100 text-gray-500 dark:bg-gray-800 dark:text-gray-400',
order: 0,
},
parts_ordered: {
label: 'Parts Ordered',
colors:
'bg-orange-100 text-orange-800 dark:bg-orange-900/40 dark:text-orange-300',
order: 1,
},
in_bay: {
label: 'In Bay',
colors:
'bg-blue-100 text-blue-800 dark:bg-blue-900/40 dark:text-blue-300',
order: 2,
},
qc_passed: {
label: 'QC Passed',
colors:
'bg-green-100 text-green-800 dark:bg-green-900/40 dark:text-green-300',
order: 3,
},
};
export function statusLabel(status: string): string {
return STATUS_CONFIG[status]?.label ?? status;
}
export function getStatusConfig(settings: Pick<ShopSettings, 'businessType'>): Record<string, { label: string; colors: string }> {
if (settings.businessType === 'mobile' || settings.businessType === 'home') {
return {
...STATUS_CONFIG,
waiting_pickup: {
...STATUS_CONFIG.waiting_pickup,
label: 'Ready for Visit',
},
};
}
return STATUS_CONFIG;
}
export function statusLabelForBusiness(status: string, settings: Pick<ShopSettings, 'businessType'>): string {
return getStatusConfig(settings)[status]?.label ?? status;
}
/* ── RO progress helpers (visual progress bar per row) ────────────────────── */
export function roProgress(ro: RepairOrder): {
done: number;
total: number;
fraction: number;
} {
const svcs: ROService[] = Array.isArray(ro.services) ? ro.services : [];
const total = svcs.length;
const done = svcs.filter((s) => s.status === 'completed').length;
const fraction = total > 0 ? done / total : 0;
return { done, total, fraction };
}
+499
View File
@@ -0,0 +1,499 @@
import { pb } from './pocketbase';
import { logError } from './userMessages';
import type { ClockEntry, ROService } from '../types';
import type { PagedResult } from '../hooks/usePagedList';
export class StaleWriteError extends Error {
constructor(message = 'This record was just edited by someone else. Reload and try again.') {
super(message);
this.name = 'StaleWriteError';
}
}
export type TechnicianSubStatus = 'parts_ordered' | 'in_bay' | 'qc_passed' | '';
export interface SanitizedService {
id: string;
name: string;
description: string;
status: 'pending' | 'in_progress' | 'completed';
subStatus?: TechnicianSubStatus;
technicianNotes?: string;
}
export interface AdvisorRequest {
id: string;
type: 'parts' | 'help' | 'info';
message: string;
status: 'pending' | 'resolved';
createdAt: string;
}
export interface TechnicianAssignment {
id: string;
shopUserId: string;
technicianUserId: string;
repairOrderId: string;
roNumber: string;
vehicleInfo: string;
vin: string;
mileage: string;
serviceLocation: string;
status: 'pending' | 'in_progress' | 'waiting_parts' | 'completed';
services: SanitizedService[];
internalNotes: string;
clockEntries: ClockEntry[];
inspectionChecklist: any[];
advisorRequests: AdvisorRequest[];
created: string;
updated: string;
}
function parseJsonField<T>(item: any, key: string, fallback: T): T {
const raw = item[key];
if (!raw) return fallback;
if (typeof raw === 'string') {
try { return JSON.parse(raw) as T; } catch (err) { logError(`parseJsonField(${key}) failed`, err); return fallback; }
}
if (typeof raw === 'object') return raw as T;
return fallback;
}
export function parseRepairOrderServices(raw: unknown): ROService[] {
if (Array.isArray(raw)) return raw as ROService[];
if (typeof raw === 'string' && raw.trim()) {
try {
const parsed = JSON.parse(raw) as unknown;
return Array.isArray(parsed) ? parsed as ROService[] : [];
} catch (err) {
logError('parseRepairOrderServices JSON parse failed', err);
return [];
}
}
return [];
}
function sanitizeServiceForTechnician(
service: Pick<ROService, 'id' | 'name' | 'description' | 'status'> & { technicianNotes?: string },
existing?: SanitizedService,
): SanitizedService {
return {
id: service.id,
name: service.name || existing?.name || '',
description: service.description || existing?.description || '',
status: existing?.status || service.status || 'pending',
subStatus: existing?.subStatus || '',
technicianNotes: existing?.technicianNotes || service.technicianNotes || '',
};
}
export function normalizeAssignment(item: any): TechnicianAssignment {
return {
id: item.id ?? '',
shopUserId: item.shopUserId ?? '',
technicianUserId: item.technicianUserId ?? '',
repairOrderId: item.repairOrderId ?? '',
roNumber: item.roNumber ?? '',
vehicleInfo: item.vehicleInfo ?? '',
vin: item.vin ?? '',
mileage: item.mileage ?? '',
serviceLocation: item.serviceLocation ?? '',
status: item.status ?? 'pending',
services: parseJsonField<SanitizedService[]>(item, 'services', []),
internalNotes: (() => {
const n = item.internalNotes;
if (!n) return '';
if (typeof n === 'string') return n;
if (typeof n === 'object') return JSON.stringify(n);
return '';
})(),
clockEntries: parseJsonField<ClockEntry[]>(item, 'clockEntries', []),
inspectionChecklist: parseJsonField<any[]>(item, 'inspectionChecklist', []),
advisorRequests: parseJsonField<AdvisorRequest[]>(item, 'advisorRequests', []),
created: item.created ?? '',
updated: item.updated ?? '',
};
}
/**
* Fetch-modify-write with optimistic concurrency.
* Fetches the current assignment, calls `mutator` to produce a patch,
* attaches `__expectedUpdated` for the M21 PB hook, and attempts the update.
* Throws `StaleWriteError` on 409 (optimistic lock conflict).
* Returns the fresh record after a successful update.
*/
export async function mutateAssignment<T = TechnicianAssignment>(
assignmentId: string,
mutator: (current: T) => Record<string, unknown>,
): Promise<T> {
const raw = await pb.collection('technicianAssignments').getOne(assignmentId);
const current = normalizeAssignment(raw) as unknown as T;
const patch = mutator(current);
patch.__expectedUpdated = raw.updated;
try {
const updated = await pb.collection('technicianAssignments').update(assignmentId, patch);
return normalizeAssignment(updated) as unknown as T;
} catch (err: any) {
const status = err?.status ?? err?.originalError?.status;
const msg = (err?.message || '').toLowerCase();
if (status === 409 || msg.includes('optimistic') || msg.includes('lock') || msg.includes('modified by another user')) {
throw new StaleWriteError();
}
throw err;
}
}
export async function fetchTechnicianAssignments(
userId: string,
page = 1,
perPage = 50,
): Promise<PagedResult<TechnicianAssignment>> {
const result = await pb.collection('technicianAssignments').getList(page, perPage, {
filter: `technicianUserId = '${userId}'`,
sort: '-id',
});
return {
items: (result.items as any[]).map(normalizeAssignment),
page: result.page,
perPage: result.perPage,
totalItems: result.totalItems,
totalPages: result.totalPages,
};
}
export async function fetchAllTechnicianAssignments(userId: string): Promise<TechnicianAssignment[]> {
const all: TechnicianAssignment[] = [];
let page = 1;
const perPage = 200;
while (true) {
const result = await pb.collection('technicianAssignments').getList(page, perPage, {
filter: `technicianUserId = '${userId}'`,
sort: '-id',
});
all.push(...(result.items as any[]).map(normalizeAssignment));
if (result.page >= result.totalPages) break;
page++;
}
return all;
}
export async function fetchTechnicianAssignment(id: string): Promise<TechnicianAssignment | null> {
try {
const rec = await pb.collection('technicianAssignments').getOne(id);
return normalizeAssignment(rec);
} catch {
return null;
}
}
export async function updateAssignmentStatus(
assignmentId: string,
status: TechnicianAssignment['status'],
): Promise<void> {
await pb.collection('technicianAssignments').update(assignmentId, { status });
}
export async function updateServiceStatus(
assignmentId: string,
serviceId: string,
status: SanitizedService['status'],
): Promise<void> {
await mutateAssignment(assignmentId, (current) => {
const services = current.services.map((s) =>
s.id === serviceId ? { ...s, status } : s,
);
return { services: JSON.stringify(services) };
});
}
export async function updateServiceSubStatus(
assignmentId: string,
serviceId: string,
subStatus: TechnicianSubStatus,
): Promise<void> {
await mutateAssignment(assignmentId, (current) => {
const services = current.services.map((s) =>
s.id === serviceId ? { ...s, subStatus } : s,
);
return { services: JSON.stringify(services) };
});
}
export async function updateServiceNotes(
assignmentId: string,
serviceId: string,
technicianNotes: string,
): Promise<void> {
await mutateAssignment(assignmentId, (current) => {
const services = current.services.map((s) =>
s.id === serviceId ? { ...s, technicianNotes } : s,
);
return { services: JSON.stringify(services) };
});
}
export async function submitAdvisorRequest(
assignmentId: string,
request: Omit<AdvisorRequest, 'id' | 'status' | 'createdAt'>,
): Promise<void> {
await mutateAssignment(assignmentId, (current) => {
const newRequest: AdvisorRequest = {
id: `${Date.now()}-${Math.random().toString(36).slice(2, 9)}`,
...request,
status: 'pending',
createdAt: new Date().toISOString(),
};
const requests = [...current.advisorRequests, newRequest];
return { advisorRequests: JSON.stringify(requests) };
});
}
function findRunningClock(entries: ClockEntry[]): ClockEntry | undefined {
return entries.find((e) => !e.end);
}
export function clockElapsedMinutes(entries: ClockEntry[]): number {
const now = Date.now();
return entries.reduce((sum, e) => {
if (!e.start) return sum;
const end = e.end ? new Date(e.end).getTime() : now;
const start = new Date(e.start).getTime();
const mins = Math.max(0, Math.round((end - start) / 60000));
return sum + (e.minutes ?? mins);
}, 0);
}
export async function clockStart(assignmentId: string): Promise<void> {
const assignment = await fetchTechnicianAssignment(assignmentId);
if (!assignment) throw new Error('Assignment not found');
if (findRunningClock(assignment.clockEntries)) return;
const newEntry: ClockEntry = { start: new Date().toISOString() };
const entries: ClockEntry[] = [...assignment.clockEntries, newEntry];
await pb.collection('technicianAssignments').update(assignmentId, {
clockEntries: JSON.stringify(entries),
__expectedUpdated: assignment.updated,
});
}
export interface AvailableRepairOrder {
id: string;
roNumber: string;
vehicleInfo: string;
vin: string;
mileage: string;
status: string;
serviceCount: number;
}
export async function fetchAvailableRepairOrders(
shopUserId: string,
page = 1,
perPage = 50,
): Promise<PagedResult<AvailableRepairOrder>> {
const result = await pb.collection('repairOrders').getList(page, perPage, {
filter: `userId = '${shopUserId}' && status != 'completed' && status != 'final_close' && status != 'delivered'`,
sort: '-id',
});
return {
items: (result.items as any[]).map((r) => {
const services = parseRepairOrderServices(r.services);
return {
id: r.id ?? '',
roNumber: r.roNumber ?? '',
vehicleInfo: r.vehicleInfo ?? '',
vin: r.vin ?? '',
mileage: r.mileage ?? '',
status: r.status ?? '',
serviceCount: services.length,
};
}),
page: result.page,
perPage: result.perPage,
totalItems: result.totalItems,
totalPages: result.totalPages,
};
}
export async function fetchAllAvailableRepairOrders(shopUserId: string): Promise<AvailableRepairOrder[]> {
const all: AvailableRepairOrder[] = [];
let page = 1;
const perPage = 200;
while (true) {
const result = await pb.collection('repairOrders').getList(page, perPage, {
filter: `userId = '${shopUserId}' && status != 'completed' && status != 'final_close' && status != 'delivered'`,
sort: '-id',
});
for (const r of result.items as any[]) {
const services = parseRepairOrderServices(r.services);
all.push({
id: r.id ?? '',
roNumber: r.roNumber ?? '',
vehicleInfo: r.vehicleInfo ?? '',
vin: r.vin ?? '',
mileage: r.mileage ?? '',
status: r.status ?? '',
serviceCount: services.length,
});
}
if (result.page >= result.totalPages) break;
page++;
}
return all;
}
export interface TechnicianUser {
id: string;
email: string;
displayName: string;
role: string;
}
export async function fetchTechnicianUsers(shopUserId: string): Promise<TechnicianUser[]> {
try {
const records = await pb.collection('users').getFullList({
filter: `role = 'technician' && shopUserId = '${shopUserId}'`,
sort: 'displayName',
batch: 200,
});
return (records as any[]).map((u) => ({
id: u.id ?? '',
email: u.email ?? '',
displayName: u.displayName ?? '',
role: u.role ?? 'technician',
}));
} catch (err) {
logError('[technicianData] fetchTechnicianUsers failed', err);
return [];
}
}
export async function fetchAssignmentsForRO(repairOrderId: string): Promise<TechnicianAssignment[]> {
try {
const records = await pb.collection('technicianAssignments').getFullList({
filter: `repairOrderId = '${repairOrderId}'`,
sort: '-id',
batch: 50,
});
return (records as any[]).map(normalizeAssignment);
} catch (err) {
logError('[technicianData] fetchAssignmentsForRO failed', err);
return [];
}
}
export async function fetchAssignmentsForShop(
shopUserId: string,
page = 1,
perPage = 50,
): Promise<PagedResult<TechnicianAssignment>> {
const result = await pb.collection('technicianAssignments').getList(page, perPage, {
filter: `shopUserId = '${shopUserId}'`,
sort: '-id',
});
return {
items: (result.items as any[]).map(normalizeAssignment),
page: result.page,
perPage: result.perPage,
totalItems: result.totalItems,
totalPages: result.totalPages,
};
}
export async function fetchAllAssignmentsForShop(shopUserId: string): Promise<TechnicianAssignment[]> {
const all: TechnicianAssignment[] = [];
let page = 1;
const perPage = 200;
while (true) {
const result = await pb.collection('technicianAssignments').getList(page, perPage, {
filter: `shopUserId = '${shopUserId}'`,
sort: '-id',
});
all.push(...(result.items as any[]).map(normalizeAssignment));
if (result.page >= result.totalPages) break;
page++;
}
return all;
}
export function sanitizeROForAssignment(
ro: { id: string; roNumber: string; vehicleInfo: string; vin: string; mileage: string; serviceLocation?: string; services: { id: string; name: string; description: string; status: string; technicianNotes?: string }[] },
selectedServiceIds: string[],
shopUserId: string,
technicianUserId: string,
): Omit<TechnicianAssignment, 'id' | 'created' | 'updated'> {
const selectedServices = ro.services.filter((s) => selectedServiceIds.includes(s.id));
const sanitizedServices: SanitizedService[] = selectedServices.map((s) => sanitizeServiceForTechnician({
...s,
status: 'pending' as ROService['status'],
}));
return {
shopUserId,
technicianUserId,
repairOrderId: ro.id,
roNumber: ro.roNumber || '',
vehicleInfo: ro.vehicleInfo || '',
vin: ro.vin || '',
mileage: ro.mileage || '',
serviceLocation: ro.serviceLocation || '',
status: 'pending',
services: sanitizedServices,
internalNotes: '',
clockEntries: [],
inspectionChecklist: [],
advisorRequests: [],
};
}
export async function createTechnicianAssignment(
ro: { id: string; roNumber: string; vehicleInfo: string; vin: string; mileage: string; serviceLocation?: string; services: { id: string; name: string; description: string; status: string; technicianNotes?: string }[] },
technicianUserId: string,
selectedServiceIds: string[],
shopUserId: string,
): Promise<TechnicianAssignment> {
const data = sanitizeROForAssignment(ro, selectedServiceIds, shopUserId, technicianUserId);
const rec = await pb.collection('technicianAssignments').create(data);
return normalizeAssignment(rec);
}
export async function syncAssignmentsForROServices(
repairOrderId: string,
services: ROService[],
): Promise<void> {
const assignments = await fetchAssignmentsForRO(repairOrderId);
if (assignments.length === 0) return;
const source = services.filter((s) => s.id && s.name?.trim());
await Promise.all(assignments.map((assignment) => {
const existingById = new Map(assignment.services.map((s) => [s.id, s]));
const merged = source.map((service) => sanitizeServiceForTechnician(service, existingById.get(service.id)));
return pb.collection('technicianAssignments').update(assignment.id, {
roNumber: assignment.roNumber,
services: JSON.stringify(merged),
});
}));
}
export async function clockStop(assignmentId: string): Promise<number> {
const assignment = await fetchTechnicianAssignment(assignmentId);
if (!assignment) throw new Error('Assignment not found');
const entries = [...assignment.clockEntries];
const idx = entries.findIndex((e) => !e.end);
if (idx === -1) return 0;
const end = new Date().toISOString();
const mins = Math.max(
0,
Math.round((new Date(end).getTime() - new Date(entries[idx].start!).getTime()) / 60000),
);
entries[idx] = { ...entries[idx], end, minutes: mins };
await pb.collection('technicianAssignments').update(assignmentId, {
clockEntries: JSON.stringify(entries),
__expectedUpdated: assignment.updated,
});
return mins;
}
+131
View File
@@ -0,0 +1,131 @@
import { pb } from './pocketbase';
export type TechnicianInviteStatus = 'pending' | 'accepted' | 'revoked';
export interface TechnicianInvite {
id: string;
shopUserId: string;
email: string;
displayName: string;
tokenHash: string;
status: TechnicianInviteStatus;
expiresAt: string;
acceptedByUserId: string;
acceptedAt: string;
created: string;
updated: string;
}
export interface CreatedTechnicianInvite {
invite: TechnicianInvite;
token: string;
inviteUrl: string;
}
function normalizeInvite(item: any): TechnicianInvite {
return {
id: item.id ?? '',
shopUserId: item.shopUserId ?? '',
email: item.email ?? '',
displayName: item.displayName ?? '',
tokenHash: item.tokenHash ?? '',
status: item.status ?? 'pending',
expiresAt: item.expiresAt ?? '',
acceptedByUserId: item.acceptedByUserId ?? '',
acceptedAt: item.acceptedAt ?? '',
created: item.created ?? '',
updated: item.updated ?? '',
};
}
function generateInviteToken(): string {
const bytes = new Uint8Array(32);
crypto.getRandomValues(bytes);
return Array.from(bytes, (b) => b.toString(16).padStart(2, '0')).join('');
}
export async function hashInviteToken(token: string): Promise<string> {
const data = new TextEncoder().encode(token);
const digest = await crypto.subtle.digest('SHA-256', data);
return Array.from(new Uint8Array(digest), (b) => b.toString(16).padStart(2, '0')).join('');
}
function inviteUrlForToken(token: string): string {
return `${window.location.origin}/invite/technician/${token}`;
}
export async function createTechnicianInvite(
shopUserId: string,
email: string,
displayName: string,
): Promise<CreatedTechnicianInvite> {
const token = generateInviteToken();
const tokenHash = await hashInviteToken(token);
const expiresAt = new Date(Date.now() + 7 * 24 * 60 * 60 * 1000).toISOString();
const invite = await pb.collection('technicianInvites').create({
shopUserId,
email: email.trim().toLowerCase(),
displayName: displayName.trim(),
tokenHash,
status: 'pending',
expiresAt,
});
return {
invite: normalizeInvite(invite),
token,
inviteUrl: inviteUrlForToken(token),
};
}
export async function fetchTechnicianInvites(shopUserId: string): Promise<TechnicianInvite[]> {
try {
const records = await pb.collection('technicianInvites').getFullList({
filter: `shopUserId = '${shopUserId}'`,
sort: '-created',
batch: 200,
});
return (records as any[]).map(normalizeInvite);
} catch {
return [];
}
}
export async function fetchInviteByToken(token: string): Promise<TechnicianInvite | null> {
const tokenHash = await hashInviteToken(token);
try {
const data = await pb.send('/api/collections/technicianInvites/records', {
method: 'GET',
query: {
filter: `tokenHash = '${tokenHash}' && status = 'pending'`,
tokenHash,
perPage: 1,
},
});
const item = data?.items?.[0];
return item ? normalizeInvite(item) : null;
} catch {
return null;
}
}
export async function acceptTechnicianInvite(
invite: TechnicianInvite,
token: string,
acceptedByUserId: string,
): Promise<void> {
const tokenHash = await hashInviteToken(token);
await pb.send(`/api/collections/technicianInvites/records/${invite.id}`, {
method: 'PATCH',
query: { tokenHash },
body: {
status: 'accepted',
acceptedByUserId,
acceptedAt: new Date().toISOString(),
},
});
}
export async function revokeTechnicianInvite(inviteId: string): Promise<void> {
await pb.collection('technicianInvites').update(inviteId, { status: 'revoked' });
}
+220
View File
@@ -0,0 +1,220 @@
// Shared pricing logic for Quotes and Repair Orders.
//
// Previously the Quote and RO pages each had their own inline totals math with
// different rules (Quote capped shop charge at $69.95, RO had no cap; Quote
// applied discounts, RO didn't; RO used hardcoded tax/shop-charge rates
// ignoring ShopSettings). This module is the single source of truth so the
// two screens always agree.
import type { QuoteService, ROService, ShopSettings } from '../types';
export interface TotalsBreakdown {
laborTotal: number; // RO only — 0 for quotes
partsTotal: number; // RO only — 0 for quotes
subtotal: number; // pre-discount, pre-shop-charge
discountAmount: number;
shopCharge: number;
taxableBase: number; // subtotal - discount + shopCharge
tax: number;
total: number;
}
export interface QuoteTotalsInput {
services: QuoteService[];
discount: { value: number; type: 'dollar' | 'percent' };
settings: Pick<ShopSettings, 'taxRate' | 'shopChargeRate' | 'shopChargeCap'>;
}
// Determine which services count toward totals.
// When all non-declined services have the same decision (all approved or all
// pending), count all of them. When there's a mix of approved + pending,
// count only the approved ones. Declined services are always excluded.
export function billableServices(services: { customerDecision: string }[]): { customerDecision: string }[] {
const nonDeclined = services.filter((s) => s.customerDecision !== 'declined');
const hasApproved = nonDeclined.some((s) => s.customerDecision === 'approved');
const hasPending = nonDeclined.some((s) => s.customerDecision === 'pending');
return (hasApproved && hasPending)
? nonDeclined.filter((s) => s.customerDecision === 'approved')
: nonDeclined;
}
// Quote totals — flat-price services, excludes only declined services
// (pending + approved both count toward the quote shown to the customer).
export function computeQuoteTotals(input: QuoteTotalsInput): TotalsBreakdown {
const { services, discount, settings } = input;
const billable = billableServices(services) as QuoteService[];
const subtotal = billable.reduce(
(sum, s) => sum + (parseFloat(String(s.price ?? 0)) || 0),
0
);
const discountAmount =
discount.value > 0
? discount.type === 'percent'
? subtotal * (discount.value / 100)
: Math.min(discount.value, subtotal)
: 0;
const afterDiscount = Math.max(0, subtotal - discountAmount);
const chargeable = billable
.filter((s) => s.applyShopCharge !== false)
.reduce((sum, s) => sum + (parseFloat(String(s.price ?? 0)) || 0), 0);
const cap = settings.shopChargeCap ?? 69.95;
const shopCharge = Math.min(
chargeable * ((settings.shopChargeRate || 0) / 100),
cap
);
const taxableBase = afterDiscount + shopCharge;
const tax = taxableBase * ((settings.taxRate || 0) / 100);
const total = taxableBase + tax;
return {
laborTotal: 0,
partsTotal: 0,
subtotal,
discountAmount,
shopCharge,
taxableBase,
tax,
total,
};
}
/**
* Compute separate totals breakdowns for approved vs pending services,
* plus a combined total. Discount is only applied to the combined figure.
* Useful when displaying quotes transferred from an RO (mix of approved + pending).
*/
export function computeSplitQuoteTotals(
services: QuoteService[],
discount: { value: number; type: 'dollar' | 'percent' },
settings: Pick<ShopSettings, 'taxRate' | 'shopChargeRate' | 'shopChargeCap'>,
): {
approved: TotalsBreakdown;
pending: TotalsBreakdown;
combined: TotalsBreakdown;
} {
const approvedServices = services.filter(s => s.customerDecision === 'approved');
const pendingServices = services.filter(s => s.customerDecision === 'pending');
// Compute base subtotals (no discount at the individual level)
const zeroDiscount = { value: 0, type: 'dollar' as const };
const approved = computeQuoteTotals({ services: approvedServices, discount: zeroDiscount, settings });
const pending = computeQuoteTotals({ services: pendingServices, discount: zeroDiscount, settings });
// Combined: compute on ALL non-declined services with discount.
// Can't reuse computeQuoteTotals here because it internally calls
// billableServices which excludes pending when there's a mixed set.
const allNonDeclined = services.filter(s => s.customerDecision !== 'declined') as QuoteService[];
const rawSubtotal = allNonDeclined.reduce((sum, s) => sum + (parseFloat(String(s.price ?? 0)) || 0), 0);
const discountAmount =
discount.value > 0
? discount.type === 'percent'
? rawSubtotal * (discount.value / 100)
: Math.min(discount.value, rawSubtotal)
: 0;
const afterDiscount = Math.max(0, rawSubtotal - discountAmount);
const chargeable = allNonDeclined
.filter((s) => s.applyShopCharge !== false)
.reduce((sum, s) => sum + (parseFloat(String(s.price ?? 0)) || 0), 0);
const cap = settings.shopChargeCap ?? 69.95;
const shopCharge = Math.min(chargeable * ((settings.shopChargeRate || 0) / 100), cap);
const taxableBase = afterDiscount + shopCharge;
const tax = taxableBase * ((settings.taxRate || 0) / 100);
const total = taxableBase + tax;
const combined: TotalsBreakdown = {
laborTotal: 0,
partsTotal: 0,
subtotal: rawSubtotal,
discountAmount,
shopCharge,
taxableBase,
tax,
total,
};
return { approved, pending, combined };
}
export interface ROTotalsInput {
services: ROService[];
discount?: { value: number; type: 'dollar' | 'percent' };
settings: Pick<ShopSettings, 'taxRate' | 'shopChargeRate' | 'shopChargeCap'>;
}
// RO totals — labor-hours × labor-rate + parts-cost (+ flat total for full-price
// services), with optional discount and per-service shop charge (capped, same
// rule as quotes).
export function computeROTotals(input: ROTotalsInput): TotalsBreakdown {
const { services, discount, settings } = input;
const arr = Array.isArray(services) ? services : [];
let laborTotal = 0;
let partsTotal = 0;
for (const s of arr) {
const lh = parseFloat(String(s.laborHours ?? 0)) || 0;
const lr = parseFloat(String(s.laborRate ?? 0)) || 0;
const pc = parseFloat(String(s.partsCost ?? 0)) || 0;
const split = lh * lr;
if (split || pc) {
laborTotal += split;
partsTotal += pc;
} else {
laborTotal += parseFloat(String(s.total ?? 0)) || 0;
}
}
const subtotal = laborTotal + partsTotal;
const discountAmount = discount
? discount.value > 0
? discount.type === 'percent'
? subtotal * (discount.value / 100)
: Math.min(discount.value, subtotal)
: 0
: 0;
const afterDiscount = Math.max(0, subtotal - discountAmount);
const cap = settings.shopChargeCap ?? 69.95;
const chargeable = arr
.filter((s) => s.applyShopCharge !== false)
.reduce((sum, s) => sum + roServiceTotal(s), 0);
const shopCharge = Math.min(
chargeable * ((settings.shopChargeRate || 0) / 100),
cap
);
const taxableBase = afterDiscount + shopCharge;
const tax = taxableBase * ((settings.taxRate || 0) / 100);
const total = taxableBase + tax;
return {
laborTotal,
partsTotal,
subtotal,
discountAmount,
shopCharge,
taxableBase,
tax,
total,
};
}
// Convenience: per-service line total for an ROService.
// Falls back to s.total when split pricing fields are all zero.
export function roServiceTotal(s: {
laborHours?: number;
laborRate?: number;
partsCost?: number;
total?: number;
}): number {
const split = (s.laborHours || 0) * (s.laborRate || 0) + (s.partsCost || 0);
return split || s.total || 0;
}
+41
View File
@@ -0,0 +1,41 @@
export const SETUP_REQUIRED_MESSAGE = 'This feature is not available yet. Finish setup to continue.';
export function getErrorMessage(error: unknown): string {
if (error instanceof Error) return error.message;
if (typeof error === 'string') return error;
return '';
}
export function isMissingCollectionError(error: unknown): boolean {
const message = getErrorMessage(error).toLowerCase();
return message.includes('missing collection') || message.includes('not found') || message.includes('404');
}
export function getSetupRequiredMessage(): string {
return SETUP_REQUIRED_MESSAGE;
}
export function logError(context: string, error: unknown): void {
console.error(context, error);
}
type ErrorContext = Record<string, unknown>;
let errorReporter: ((err: Error, ctx?: ErrorContext) => void) | null = null;
export function setErrorReporter(fn: ((err: Error, ctx?: ErrorContext) => void) | null) {
errorReporter = fn;
}
export function reportError(err: Error, ctx?: ErrorContext) {
try { errorReporter?.(err, ctx); } catch { /* never let reporting throw */ }
}
export function swallow<T>(context: string, fallback: T, fn: () => T): T {
try { return fn(); }
catch (err) { logError(context, err); return fallback; }
}
export async function swallowAsync<T>(context: string, fallback: T, fn: () => Promise<T>): Promise<T> {
try { return await fn(); }
catch (err) { logError(context, err); return fallback; }
}
+38
View File
@@ -0,0 +1,38 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App.tsx'
import { ErrorBoundary } from './components/ErrorBoundary'
import { logError, setErrorReporter } from './lib/userMessages'
// GlitchTip error tracking (self-hosted Sentry-compatible)
const DSN = import.meta.env.VITE_GLITCHTIP_DSN as string | undefined;
if (DSN) {
import('@sentry/react').then((Sentry) => {
Sentry.init({
dsn: DSN,
environment: import.meta.env.VITE_ENV || 'development',
tracesSampleRate: 0.1,
});
setErrorReporter((err, ctx) => {
Sentry.captureException(err, { extra: ctx });
});
}).catch(() => { /* Sentry failed to load — non-critical */ });
}
createRoot(document.getElementById('root')!).render(
<StrictMode>
<ErrorBoundary>
<App />
</ErrorBoundary>
</StrictMode>,
)
// Global listeners — catch promise rejections and cross-origin script errors
// that never reach a React render path.
window.addEventListener('unhandledrejection', (e) => {
logError('[unhandledrejection]', e.reason);
});
window.addEventListener('error', (e) => {
logError('[window error]', e.error ?? e.message);
});
+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>
);
}

Some files were not shown because too many files have changed in this diff Show More