added full settings options

This commit is contained in:
Ray
2026-07-12 14:42:44 -04:00
parent fc8290d668
commit e239dd91b7
23 changed files with 1439 additions and 188 deletions
+23 -2
View File
@@ -8,7 +8,7 @@ import AdvisorLayout from './components/advisor/AdvisorLayout';
import MobileLayout from './components/mobile/MobileLayout'; import MobileLayout from './components/mobile/MobileLayout';
import TechnicianShell from './components/technician/TechnicianLayout'; import TechnicianShell from './components/technician/TechnicianLayout';
import Login from './pages/Login'; import Login from './pages/Login';
import { isSetupComplete, loadSettingsForUser } from './lib/settings'; import { isSetupComplete, loadSettings, loadSettingsForUser } from './lib/settings';
import { getCurrentUserRole, isTechnicianRole, isOwnerRole, type UserRole } from './lib/rbx'; import { getCurrentUserRole, isTechnicianRole, isOwnerRole, type UserRole } from './lib/rbx';
import { useSettingsStore } from './store/settings'; import { useSettingsStore } from './store/settings';
import { TechnicianProvider } from './components/technician/TechnicianContext'; import { TechnicianProvider } from './components/technician/TechnicianContext';
@@ -54,6 +54,22 @@ function RoleHomeRedirect() {
return <Navigate to={getRoleHome(getCurrentUserRole())} replace />; return <Navigate to={getRoleHome(getCurrentUserRole())} replace />;
} }
function applySavedTheme() {
const theme = loadSettings().appTheme;
const systemDark = typeof window !== 'undefined' && window.matchMedia('(prefers-color-scheme: dark)').matches;
const dark = theme === 'dark' || (theme === 'system' && systemDark);
document.documentElement.classList.toggle('dark', dark);
localStorage.setItem('spq-dark-mode', String(dark));
}
function OwnerHome() {
const defaultLandingPage = useSettingsStore((s) => s.settings.defaultLandingPage);
if (defaultLandingPage && defaultLandingPage !== '/') {
return <Navigate to={defaultLandingPage} replace />;
}
return <Dashboard />;
}
function OwnerLayout() { function OwnerLayout() {
const role = getCurrentUserRole(); const role = getCurrentUserRole();
if (isTechnicianRole(role)) { if (isTechnicianRole(role)) {
@@ -116,6 +132,7 @@ function RequireSetup({ children }: { children: React.ReactNode }) {
const settings = await loadSettingsForUser(user?.id); const settings = await loadSettingsForUser(user?.id);
if (!active) return; if (!active) return;
useSettingsStore.getState().setSettings(settings); useSettingsStore.getState().setSettings(settings);
applySavedTheme();
setSetupReady(isSetupComplete(settings)); setSetupReady(isSetupComplete(settings));
} }
@@ -190,7 +207,7 @@ function AppRoutes() {
<Route path="/quote/approve/:token" element={<QuoteApprove />} /> <Route path="/quote/approve/:token" element={<QuoteApprove />} />
<Route path="/invite/technician/:token" element={<TechnicianInvite />} /> <Route path="/invite/technician/:token" element={<TechnicianInvite />} />
<Route element={<OwnerLayout />}> <Route element={<OwnerLayout />}>
<Route path="/" element={<Dashboard />} /> <Route path="/" element={<OwnerHome />} />
<Route path="/quote" element={<QuoteGenerator />} /> <Route path="/quote" element={<QuoteGenerator />} />
<Route path="/service-catalog" element={<ServiceCatalog />} /> <Route path="/service-catalog" element={<ServiceCatalog />} />
<Route path="/customers" element={<Customers />} /> <Route path="/customers" element={<Customers />} />
@@ -224,6 +241,10 @@ function AppRoutes() {
} }
function App() { function App() {
useEffect(() => {
applySavedTheme();
}, []);
return ( return (
<BrowserRouter> <BrowserRouter>
<ToastProvider> <ToastProvider>
+36 -15
View File
@@ -18,6 +18,8 @@ import { formatCurrency } from '../lib/format';
import { getStatusConfig, SERVICE_SUB_STATUS_CONFIG } from '../lib/status'; import { getStatusConfig, SERVICE_SUB_STATUS_CONFIG } from '../lib/status';
import { logError } from '../lib/userMessages'; import { logError } from '../lib/userMessages';
import { composeName, parseName, findMatchingCustomer } from '../lib/customerName'; import { composeName, parseName, findMatchingCustomer } from '../lib/customerName';
import { formatCustomerNamePart, formatMileage, formatVehicleInfo, formatVin } from '../lib/dataFormatting';
import { spellCheckProps } from '../lib/spellcheck';
import { pb } from '../lib/pocketbase'; import { pb } from '../lib/pocketbase';
import type { CustomerWithVehicles } from '../pages/Customers'; import type { CustomerWithVehicles } from '../pages/Customers';
@@ -277,7 +279,7 @@ function ServiceRow({
export interface ROFormProps { export interface ROFormProps {
editRO: RepairOrder | null; editRO: RepairOrder | null;
existingRoNumbers: Set<string>; existingRoNumbers: Set<string>;
settings: { taxRate: number; shopChargeRate: number; shopChargeCap: number; defaultLaborRate: number }; settings: { taxEnabled?: boolean; taxRate: number; shopChargeRate: number; shopChargeCap: number; defaultLaborRate: number };
customers?: CustomerWithVehicles[]; // for customer matching / auto-create customers?: CustomerWithVehicles[]; // for customer matching / auto-create
onSave: (data: Partial<RepairOrder>) => Promise<void> | void; onSave: (data: Partial<RepairOrder>) => Promise<void> | void;
onCancel: () => void; onCancel: () => void;
@@ -336,6 +338,8 @@ export default function ROForm({
const [catalogSearch, setCatalogSearch] = useState(''); const [catalogSearch, setCatalogSearch] = useState('');
const [catalogLoaded, setCatalogLoaded] = useState(false); const [catalogLoaded, setCatalogLoaded] = useState(false);
const userId = pb.authStore.model?.id; const userId = pb.authStore.model?.id;
const appSettings = useSettings();
const internalNotesSpellCheck = spellCheckProps(appSettings, appSettings.spellCheckInternalNotes);
const isEditing = !!editRO; const isEditing = !!editRO;
@@ -459,18 +463,25 @@ export default function ROForm({
/* ── Customer matching (debounced) ─── */ /* ── Customer matching (debounced) ─── */
const runMatching = useCallback(() => { const runMatching = useCallback(() => {
if (appSettings.duplicateCustomerWarning === 'off') {
setSuggestedCustomer(null);
return;
}
if (!customerId && !dismissedMatch) { if (!customerId && !dismissedMatch) {
const matchFirstName = appSettings.duplicateCustomerWarning === 'phone_only' ? '' : firstName;
const matchMiddleName = appSettings.duplicateCustomerWarning === 'phone_only' ? '' : middleName;
const matchLastName = appSettings.duplicateCustomerWarning === 'phone_only' ? '' : lastName;
const match = findMatchingCustomer(customers, { const match = findMatchingCustomer(customers, {
firstName, firstName: matchFirstName,
middleName, middleName: matchMiddleName,
lastName, lastName: matchLastName,
phone: customerPhone, phone: customerPhone,
}); });
setSuggestedCustomer(match); setSuggestedCustomer(match);
} else { } else {
setSuggestedCustomer(null); setSuggestedCustomer(null);
} }
}, [firstName, middleName, lastName, customerPhone, customerId, customers, dismissedMatch]); }, [appSettings.duplicateCustomerWarning, firstName, middleName, lastName, customerPhone, customerId, customers, dismissedMatch]);
useEffect(() => { useEffect(() => {
if (isEditing) return; // don't match while editing an existing RO if (isEditing) return; // don't match while editing an existing RO
@@ -501,18 +512,25 @@ export default function ROForm({
/* ── Auto-create customer in PB ─── */ /* ── Auto-create customer in PB ─── */
const ensureCustomerRecord = useCallback(async (): Promise<string> => { const ensureCustomerRecord = useCallback(async (): Promise<string> => {
if (customerId) return customerId; if (customerId) return customerId;
if (!appSettings.autoCreateCustomerFromRO) return '';
const uid = pb.authStore.model?.id; const uid = pb.authStore.model?.id;
if (!uid) return ''; if (!uid) return '';
const name = composeName(firstName, middleName, lastName); const formattedFirstName = formatCustomerNamePart(firstName, appSettings);
const formattedMiddleName = formatCustomerNamePart(middleName, appSettings);
const formattedLastName = formatCustomerNamePart(lastName, appSettings);
const name = composeName(formattedFirstName, formattedMiddleName, formattedLastName);
if (!name.trim()) return ''; if (!name.trim()) return '';
// Double-check for an existing customer // Double-check for an existing customer
const matchFirstName = appSettings.duplicateCustomerWarning === 'phone_only' ? '' : firstName;
const matchMiddleName = appSettings.duplicateCustomerWarning === 'phone_only' ? '' : middleName;
const matchLastName = appSettings.duplicateCustomerWarning === 'phone_only' ? '' : lastName;
const existingMatch = findMatchingCustomer(customers, { const existingMatch = findMatchingCustomer(customers, {
firstName, firstName: matchFirstName,
middleName, middleName: matchMiddleName,
lastName, lastName: matchLastName,
phone: customerPhone, phone: customerPhone,
}); });
if (existingMatch) { if (existingMatch) {
@@ -535,7 +553,7 @@ export default function ROForm({
logError('Failed to create customer record', err); logError('Failed to create customer record', err);
return ''; return '';
} }
}, [customerId, firstName, middleName, lastName, customerPhone, customers]); }, [appSettings, customerId, firstName, middleName, lastName, customerPhone, customers]);
const handleUseExisting = handleUseExistingCustomer; const handleUseExisting = handleUseExistingCustomer;
const handleDismiss = handleDismissMatch; const handleDismiss = handleDismissMatch;
@@ -673,7 +691,10 @@ export default function ROForm({
}); });
const handleSubmit = async () => { const handleSubmit = async () => {
const name = composeName(firstName, middleName, lastName); const formattedFirstName = formatCustomerNamePart(firstName, appSettings);
const formattedMiddleName = formatCustomerNamePart(middleName, appSettings);
const formattedLastName = formatCustomerNamePart(lastName, appSettings);
const name = composeName(formattedFirstName, formattedMiddleName, formattedLastName);
if (!name.trim()) return; if (!name.trim()) return;
setSaving(true); setSaving(true);
try { try {
@@ -704,9 +725,9 @@ export default function ROForm({
customerId: cId || customerId, customerId: cId || customerId,
customerName: name, customerName: name,
customerPhone: customerPhone.trim(), customerPhone: customerPhone.trim(),
vehicleInfo: vehicleInfo.trim(), vehicleInfo: formatVehicleInfo(vehicleInfo, appSettings),
vin: vin.trim(), vin: formatVin(vin, appSettings),
mileage: mileage.trim(), mileage: formatMileage(mileage, appSettings),
advisorName: advisorName.trim(), advisorName: advisorName.trim(),
roNumber: roNumber.trim() || uniqueRONumber(existingRoNumbers), roNumber: roNumber.trim() || uniqueRONumber(existingRoNumbers),
notes: notes.trim(), notes: notes.trim(),
@@ -734,7 +755,6 @@ export default function ROForm({
} }
}; };
const appSettings = useSettings();
const defaultAdvisor = appSettings.serviceAdvisor; const defaultAdvisor = appSettings.serviceAdvisor;
const roleLabel = getRoleLabel(appSettings); const roleLabel = getRoleLabel(appSettings);
const customerTypeLabels = getCustomerTypeLabels(appSettings); const customerTypeLabels = getCustomerTypeLabels(appSettings);
@@ -1266,6 +1286,7 @@ export default function ROForm({
onChange={(e) => setNotes(e.target.value)} onChange={(e) => setNotes(e.target.value)}
rows={3} rows={3}
placeholder="Customer concerns, technician instructions, etc." placeholder="Customer concerns, technician instructions, etc."
{...internalNotesSpellCheck}
className="mt-1 w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100" className="mt-1 w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100"
/> />
</div> </div>
@@ -7,6 +7,7 @@ import { formatPhoneInput } from '../../lib/phone';
import { logError } from '../../lib/userMessages'; import { logError } from '../../lib/userMessages';
import { isValidDate, todayISO } from '../../lib/appointments'; import { isValidDate, todayISO } from '../../lib/appointments';
import { composeName, parseName, findMatchingCustomer } from '../../lib/customerName'; import { composeName, parseName, findMatchingCustomer } from '../../lib/customerName';
import { spellCheckProps } from '../../lib/spellcheck';
import type { CustomerWithVehicles, CustomerFormData } from '../../pages/Customers'; import type { CustomerWithVehicles, CustomerFormData } from '../../pages/Customers';
import type { Appointment } from '../../types'; import type { Appointment } from '../../types';
@@ -70,6 +71,7 @@ function AppointmentModalImpl({
customers, customers,
}: Props) { }: Props) {
const setupSettings = useSettings(); const setupSettings = useSettings();
const customerNotesSpellCheck = spellCheckProps(setupSettings, setupSettings.spellCheckCustomerNotes);
const roleLabel = getRoleLabel(setupSettings); const roleLabel = getRoleLabel(setupSettings);
const showLocationFields = showServiceLocationFields(setupSettings); const showLocationFields = showServiceLocationFields(setupSettings);
const [form, setForm] = useState<AppointmentFormData>({ ...EMPTY_FORM, advisorName: loadSettings().serviceAdvisor }); const [form, setForm] = useState<AppointmentFormData>({ ...EMPTY_FORM, advisorName: loadSettings().serviceAdvisor });
@@ -530,7 +532,7 @@ function AppointmentModalImpl({
<section className="rounded-2xl border border-gray-200 bg-white p-5 shadow-sm dark:border-gray-700 dark:bg-gray-900"> <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> <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..." /> <textarea value={form.notes} onChange={(e) => handleChange('notes', e.target.value)} rows={6} {...customerNotesSpellCheck} 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> </section>
</div> </div>
</div> </div>
+3 -1
View File
@@ -4,6 +4,7 @@ import { loadSettings, getRoleLabel, showServiceLocationFields, getCustomerTypeL
import { useSettings } from '../../store/settings'; import { useSettings } from '../../store/settings';
import { formatPhoneInput } from '../../lib/phone'; import { formatPhoneInput } from '../../lib/phone';
import { generateRONumber } from '../../lib/appointments'; import { generateRONumber } from '../../lib/appointments';
import { spellCheckProps } from '../../lib/spellcheck';
import type { Appointment, CustomerType } from '../../types'; import type { Appointment, CustomerType } from '../../types';
import type { CustomerWithVehicles } from '../../pages/Customers'; import type { CustomerWithVehicles } from '../../pages/Customers';
@@ -40,6 +41,7 @@ function CheckInModalImpl({
customers, customers,
}: Props) { }: Props) {
const setupSettings = useSettings(); const setupSettings = useSettings();
const customerNotesSpellCheck = spellCheckProps(setupSettings, setupSettings.spellCheckCustomerNotes);
const roleLabel = getRoleLabel(setupSettings); const roleLabel = getRoleLabel(setupSettings);
const customerTypeLabels = getCustomerTypeLabels(setupSettings); const customerTypeLabels = getCustomerTypeLabels(setupSettings);
const showLocationFields = showServiceLocationFields(setupSettings); const showLocationFields = showServiceLocationFields(setupSettings);
@@ -280,7 +282,7 @@ function CheckInModalImpl({
<section className="rounded-2xl border border-gray-200 bg-white p-5 shadow-sm dark:border-gray-700 dark:bg-gray-900"> <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> <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" /> <textarea value={concerns} onChange={(e) => setConcerns(e.target.value)} rows={7} placeholder="Describe the issue or reason for service..." {...customerNotesSpellCheck} 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>} {appointment?.serviceType && <p className="mt-2 text-xs text-gray-400">Service type: {appointment.serviceType}</p>}
</section> </section>
</div> </div>
@@ -2,6 +2,8 @@ import { useState, useEffect } from 'react';
import { X } from 'lucide-react'; import { X } from 'lucide-react';
import { formatPhoneInput } from '../../lib/phone'; import { formatPhoneInput } from '../../lib/phone';
import { composeName } from '../../lib/customerName'; import { composeName } from '../../lib/customerName';
import { spellCheckProps } from '../../lib/spellcheck';
import { useSettings } from '../../store/settings';
import type { CustomerFormData, CustomerWithVehicles } from './types'; import type { CustomerFormData, CustomerWithVehicles } from './types';
export function CustomerFormModal({ export function CustomerFormModal({
@@ -15,6 +17,8 @@ export function CustomerFormModal({
onClose: () => void; onClose: () => void;
onSave: (data: CustomerFormData) => Promise<void>; onSave: (data: CustomerFormData) => Promise<void>;
}) { }) {
const settings = useSettings();
const customerNotesSpellCheck = spellCheckProps(settings, settings.spellCheckCustomerNotes);
const [form, setForm] = useState<CustomerFormData>({ const [form, setForm] = useState<CustomerFormData>({
name: '', name: '',
firstName: '', firstName: '',
@@ -185,6 +189,7 @@ export function CustomerFormModal({
onChange={(e) => setForm({ ...form, notes: e.target.value })} onChange={(e) => setForm({ ...form, notes: e.target.value })}
placeholder="Any additional notes about this customer..." placeholder="Any additional notes about this customer..."
rows={3} rows={3}
{...customerNotesSpellCheck}
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" 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> </div>
@@ -233,10 +233,14 @@ export function CustomerInfoPanel() {
// ── Duplicate detection ── // ── Duplicate detection ──
// When the user types first/last name or phone, check for matches // When the user types first/last name or phone, check for matches
const checkDuplicates = useCallback(async (firstName: string, lastName: string, phone: string) => { const checkDuplicates = useCallback(async (firstName: string, lastName: string, phone: string) => {
if (settings.duplicateCustomerWarning === 'off') {
setDuplicateWarning(null);
return;
}
if (isLinked) return; if (isLinked) return;
const nameQuery = `${firstName} ${lastName}`.trim(); const nameQuery = `${firstName} ${lastName}`.trim();
const phoneDigits = phone.replace(/\D/g, ''); const phoneDigits = phone.replace(/\D/g, '');
if (!nameQuery && phoneDigits.length < 4) { if ((settings.duplicateCustomerWarning === 'phone_only' || !nameQuery) && phoneDigits.length < 4) {
setDuplicateWarning(null); setDuplicateWarning(null);
return; return;
} }
@@ -254,7 +258,7 @@ export function CustomerInfoPanel() {
const matched = allCustomers.filter((c: any) => { const matched = allCustomers.filter((c: any) => {
const cName = (c.name || '').toLowerCase(); const cName = (c.name || '').toLowerCase();
const cPhone = (c.phone || '').replace(/\D/g, ''); const cPhone = (c.phone || '').replace(/\D/g, '');
if (nameQuery && cName.includes(nameLc)) return true; if (settings.duplicateCustomerWarning === 'name_phone' && nameQuery && cName.includes(nameLc)) return true;
if (phoneDigits.length >= 4 && cPhone.includes(phoneDigits)) return true; if (phoneDigits.length >= 4 && cPhone.includes(phoneDigits)) return true;
return false; return false;
}); });
@@ -263,7 +267,7 @@ export function CustomerInfoPanel() {
const best = matched[0] as any; const best = matched[0] as any;
setDuplicateWarning({ setDuplicateWarning({
customer: { id: best.id, name: best.name, phone: best.phone || '', vehicleCount: 0 }, customer: { id: best.id, name: best.name, phone: best.phone || '', vehicleCount: 0 },
message: nameQuery && best.name.toLowerCase().includes(nameLc) ? 'name' : 'phone', message: settings.duplicateCustomerWarning === 'name_phone' && nameQuery && best.name.toLowerCase().includes(nameLc) ? 'name' : 'phone',
}); });
} else { } else {
setDuplicateWarning(null); setDuplicateWarning(null);
@@ -271,7 +275,7 @@ export function CustomerInfoPanel() {
} catch { } catch {
setDuplicateWarning(null); setDuplicateWarning(null);
} }
}, [isLinked]); }, [isLinked, settings.duplicateCustomerWarning]);
// Debounced duplicate check // Debounced duplicate check
const handleNameOrPhoneChange = useCallback((field: string, _val: string) => { const handleNameOrPhoneChange = useCallback((field: string, _val: string) => {
+47 -35
View File
@@ -15,6 +15,7 @@ import { sanitizeServices } from '../../lib/quoteHelpers';
import { logError } from '../../lib/userMessages'; import { logError } from '../../lib/userMessages';
import { openEmailDraft, openSmsDraft } from '../../lib/contactLinks'; import { openEmailDraft, openSmsDraft } from '../../lib/contactLinks';
import { syncApprovedQuoteServicesToRO } from '../../lib/quoteRoSync'; import { syncApprovedQuoteServicesToRO } from '../../lib/quoteRoSync';
import { formatCustomerName, formatCustomerNamePart, formatMileage, formatVehicleInfo, formatVin } from '../../lib/dataFormatting';
/* ── Helper: find or create a customer record in the customers collection ── */ /* ── Helper: find or create a customer record in the customers collection ── */
async function ensureCustomerRecord(customerInfo: { async function ensureCustomerRecord(customerInfo: {
@@ -87,6 +88,17 @@ export function QuoteSummary({ editId, repairOriginId }: { editId?: string | nul
const tax = t.tax; const tax = t.tax;
const grandTotal = t.total; const grandTotal = t.total;
const originRepairOrderId = customerInfo.repairOrderId || repairOriginId || ''; const originRepairOrderId = customerInfo.repairOrderId || repairOriginId || '';
const showTax = settings.taxEnabled !== false && settings.taxRate > 0;
const formattedCustomerInfo = {
...customerInfo,
firstName: formatCustomerNamePart(customerInfo.firstName, settings),
middleName: formatCustomerNamePart(customerInfo.middleName, settings),
lastName: formatCustomerNamePart(customerInfo.lastName, settings),
name: formatCustomerName(customerInfo.name, settings),
vehicleInfo: formatVehicleInfo(customerInfo.vehicleInfo, settings),
vin: formatVin(customerInfo.vin, settings),
mileage: formatMileage(customerInfo.mileage, settings),
};
const handleSave = async () => { const handleSave = async () => {
setSaving(true); setSaving(true);
@@ -95,11 +107,11 @@ export function QuoteSummary({ editId, repairOriginId }: { editId?: string | nul
let customerId = customerInfo.customerId; let customerId = customerInfo.customerId;
if (!customerId) { if (!customerId) {
customerId = await ensureCustomerRecord({ customerId = await ensureCustomerRecord({
name: customerInfo.name, name: formattedCustomerInfo.name,
firstName: customerInfo.firstName, firstName: formattedCustomerInfo.firstName,
lastName: customerInfo.lastName, lastName: formattedCustomerInfo.lastName,
phone: customerInfo.phone, phone: formattedCustomerInfo.phone,
email: customerInfo.email, email: formattedCustomerInfo.email,
}); });
if (customerId) { if (customerId) {
useQuoteStore.getState().setCustomerInfo({ customerId }); useQuoteStore.getState().setCustomerInfo({ customerId });
@@ -109,13 +121,13 @@ export function QuoteSummary({ editId, repairOriginId }: { editId?: string | nul
const data: Record<string, any> = { const data: Record<string, any> = {
userId: pb.authStore.model?.id, userId: pb.authStore.model?.id,
customerId: customerId || '', customerId: customerId || '',
customerName: customerInfo.name, customerName: formattedCustomerInfo.name,
customerPhone: customerInfo.phone, customerPhone: formattedCustomerInfo.phone,
vehicleInfo: customerInfo.vehicleInfo, vehicleInfo: formattedCustomerInfo.vehicleInfo,
vin: customerInfo.vin, vin: formattedCustomerInfo.vin,
mileage: customerInfo.mileage, mileage: formattedCustomerInfo.mileage,
repairOrderNumber: customerInfo.roNumber, repairOrderNumber: formattedCustomerInfo.roNumber,
serviceAdvisor: customerInfo.serviceAdvisor, serviceAdvisor: formattedCustomerInfo.serviceAdvisor,
services: sanitizeServices(services), services: sanitizeServices(services),
discountValue: discount.value, discountValue: discount.value,
discountType: discount.type, discountType: discount.type,
@@ -188,23 +200,23 @@ export function QuoteSummary({ editId, repairOriginId }: { editId?: string | nul
if (!quoteId) { if (!quoteId) {
// Ensure customer record exists first // Ensure customer record exists first
const customerId = customerInfo.customerId || (await ensureCustomerRecord({ const customerId = customerInfo.customerId || (await ensureCustomerRecord({
name: customerInfo.name, name: formattedCustomerInfo.name,
firstName: customerInfo.firstName, firstName: formattedCustomerInfo.firstName,
lastName: customerInfo.lastName, lastName: formattedCustomerInfo.lastName,
phone: customerInfo.phone, phone: formattedCustomerInfo.phone,
email: customerInfo.email, email: formattedCustomerInfo.email,
})) || undefined; })) || undefined;
const data: Record<string, any> = { const data: Record<string, any> = {
userId: pb.authStore.model?.id, userId: pb.authStore.model?.id,
customerId: customerId || '', customerId: customerId || '',
customerName: customerInfo.name, customerName: formattedCustomerInfo.name,
customerPhone: customerInfo.phone, customerPhone: formattedCustomerInfo.phone,
vehicleInfo: customerInfo.vehicleInfo, vehicleInfo: formattedCustomerInfo.vehicleInfo,
vin: customerInfo.vin, vin: formattedCustomerInfo.vin,
mileage: customerInfo.mileage, mileage: formattedCustomerInfo.mileage,
repairOrderNumber: customerInfo.roNumber, repairOrderNumber: formattedCustomerInfo.roNumber,
serviceAdvisor: customerInfo.serviceAdvisor, serviceAdvisor: formattedCustomerInfo.serviceAdvisor,
services: sanitizeServices(services), services: sanitizeServices(services),
discountValue: discount.value, discountValue: discount.value,
discountType: discount.type, discountType: discount.type,
@@ -238,13 +250,13 @@ export function QuoteSummary({ editId, repairOriginId }: { editId?: string | nul
const data: Record<string, any> = { const data: Record<string, any> = {
userId: pb.authStore.model?.id, userId: pb.authStore.model?.id,
customerId: customerInfo.customerId || '', customerId: customerInfo.customerId || '',
customerName: customerInfo.name, customerName: formattedCustomerInfo.name,
customerPhone: customerInfo.phone, customerPhone: formattedCustomerInfo.phone,
vehicleInfo: customerInfo.vehicleInfo, vehicleInfo: formattedCustomerInfo.vehicleInfo,
vin: customerInfo.vin, vin: formattedCustomerInfo.vin,
mileage: customerInfo.mileage, mileage: formattedCustomerInfo.mileage,
repairOrderNumber: customerInfo.roNumber, repairOrderNumber: formattedCustomerInfo.roNumber,
serviceAdvisor: customerInfo.serviceAdvisor, serviceAdvisor: formattedCustomerInfo.serviceAdvisor,
services: sanitizeServices(services), services: sanitizeServices(services),
discountValue: discount.value, discountValue: discount.value,
discountType: discount.type, discountType: discount.type,
@@ -366,7 +378,7 @@ export function QuoteSummary({ editId, repairOriginId }: { editId?: string | nul
<span className="text-gray-900 dark:text-gray-100">${split.approved.shopCharge.toFixed(2)}</span> <span className="text-gray-900 dark:text-gray-100">${split.approved.shopCharge.toFixed(2)}</span>
</div> </div>
)} )}
{settings.taxRate > 0 && ( {showTax && (
<div className="flex justify-between"> <div className="flex justify-between">
<span className="text-gray-500 dark:text-gray-400">Tax ({settings.taxRate}%)</span> <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> <span className="text-gray-900 dark:text-gray-100">${split.approved.tax.toFixed(2)}</span>
@@ -393,7 +405,7 @@ export function QuoteSummary({ editId, repairOriginId }: { editId?: string | nul
<span className="text-gray-900 dark:text-gray-100">${split.pending.shopCharge.toFixed(2)}</span> <span className="text-gray-900 dark:text-gray-100">${split.pending.shopCharge.toFixed(2)}</span>
</div> </div>
)} )}
{settings.taxRate > 0 && ( {showTax && (
<div className="flex justify-between"> <div className="flex justify-between">
<span className="text-gray-500 dark:text-gray-400">Tax ({settings.taxRate}%)</span> <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> <span className="text-gray-900 dark:text-gray-100">${split.pending.tax.toFixed(2)}</span>
@@ -426,7 +438,7 @@ export function QuoteSummary({ editId, repairOriginId }: { editId?: string | nul
<span className="text-gray-900 dark:text-gray-100">${split.combined.shopCharge.toFixed(2)}</span> <span className="text-gray-900 dark:text-gray-100">${split.combined.shopCharge.toFixed(2)}</span>
</div> </div>
)} )}
{settings.taxRate > 0 && ( {showTax && (
<div className="flex justify-between"> <div className="flex justify-between">
<span className="text-gray-500 dark:text-gray-400">Tax @ {settings.taxRate}%</span> <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> <span className="text-gray-900 dark:text-gray-100">${split.combined.tax.toFixed(2)}</span>
@@ -475,7 +487,7 @@ export function QuoteSummary({ editId, repairOriginId }: { editId?: string | nul
{shopCharge > 0 && ( {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> <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 && ( {showTax && (
<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="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"> <div className="border-t border-gray-200 dark:border-gray-600 pt-2 mt-2 flex justify-between">
@@ -3,6 +3,7 @@ import { Trash2, Sparkles, Loader2 } from 'lucide-react';
import { PRIORITY_COLORS } from './types'; import { PRIORITY_COLORS } from './types';
import { useSettings } from '../../store/settings'; import { useSettings } from '../../store/settings';
import type { QuoteAuthorizationMethod, QuoteService } from '../../types'; import type { QuoteAuthorizationMethod, QuoteService } from '../../types';
import { spellCheckProps } from '../../lib/spellcheck';
interface ServiceRowProps { interface ServiceRowProps {
service: QuoteService; service: QuoteService;
@@ -32,6 +33,8 @@ export const ServiceRow = memo(function ServiceRow({
onUpdate, onUpdate,
}: ServiceRowProps) { }: ServiceRowProps) {
const settings = useSettings(); const settings = useSettings();
const serviceTextSpellCheck = spellCheckProps(settings, settings.spellCheckServiceText);
const internalNotesSpellCheck = spellCheckProps(settings, settings.spellCheckInternalNotes);
return ( return (
<div className={`rounded-lg transition-colors ${!service.approved && service.customerDecision === 'declined' ? 'opacity-50' : ''}`}> <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"> <div className="flex items-center gap-3 px-4 py-2.5 hover:bg-gray-50 dark:hover:bg-gray-800/50 rounded-lg">
@@ -213,6 +216,7 @@ export const ServiceRow = memo(function ServiceRow({
value={service.recommendation || ''} value={service.recommendation || ''}
onChange={(e) => onUpdate({ recommendation: e.target.value })} onChange={(e) => onUpdate({ recommendation: e.target.value })}
placeholder="Recommendation reason (e.g., worn to 3mm, due at 60K)" placeholder="Recommendation reason (e.g., worn to 3mm, due at 60K)"
{...serviceTextSpellCheck}
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" 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"
/> />
@@ -263,6 +267,7 @@ export const ServiceRow = memo(function ServiceRow({
value={service.aftermarketPartsList || ''} value={service.aftermarketPartsList || ''}
onChange={(e) => onUpdate({ aftermarketPartsList: e.target.value })} onChange={(e) => onUpdate({ aftermarketPartsList: e.target.value })}
placeholder="e.g., Duralast Gold Ceramic Pads, Cardone Reman Calipers" placeholder="e.g., Duralast Gold Ceramic Pads, Cardone Reman Calipers"
{...serviceTextSpellCheck}
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" 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>
@@ -298,6 +303,7 @@ export const ServiceRow = memo(function ServiceRow({
onChange={(e) => onUpdate({ explanation: e.target.value })} onChange={(e) => onUpdate({ explanation: e.target.value })}
placeholder="AI-generated explanation will appear here. Click the Sparkles icon to generate." placeholder="AI-generated explanation will appear here. Click the Sparkles icon to generate."
rows={3} rows={3}
{...serviceTextSpellCheck}
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" 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"
/> />
@@ -354,6 +360,7 @@ export const ServiceRow = memo(function ServiceRow({
onChange={(e) => onUpdate({ technicianNotes: e.target.value })} onChange={(e) => onUpdate({ technicianNotes: e.target.value })}
placeholder="Internal notes — not shown to customer..." placeholder="Internal notes — not shown to customer..."
rows={2} rows={2}
{...internalNotesSpellCheck}
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" 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> </details>
@@ -1,6 +1,7 @@
import { useState, useCallback } from 'react'; import { useState, useCallback } from 'react';
import { FileText } from 'lucide-react'; import { FileText } from 'lucide-react';
import { useQuoteStore } from '../../store/quote'; import { useQuoteStore } from '../../store/quote';
import { useSettings } from '../../store/settings';
import { useToast } from '../ui/Toast'; import { useToast } from '../ui/Toast';
import { aiWriteExplanation } from '../../lib/ai'; import { aiWriteExplanation } from '../../lib/ai';
import { logError } from '../../lib/userMessages'; import { logError } from '../../lib/userMessages';
@@ -14,6 +15,7 @@ const authorizationOptions: Array<{ value: QuoteAuthorizationMethod; label: stri
]; ];
export function ServicesTable() { export function ServicesTable() {
const settings = useSettings();
const { const {
services, services,
removeService, removeService,
@@ -38,11 +40,17 @@ export function ServicesTable() {
serviceName: svc.name, serviceName: svc.name,
recommendation: svc.recommendation || '', recommendation: svc.recommendation || '',
technicianNotes: svc.technicianNotes, technicianNotes: svc.technicianNotes,
vehicleInfo: customerInfo.vehicleInfo,
mileage: parseInt(customerInfo.mileage, 10) || undefined,
tone: settings.aiWriteTone,
length: settings.aiWriteLength,
useVehicleContext: settings.aiWriteUseVehicleContext,
safetyWording: settings.aiWriteSafetyWording,
}); });
if (result) { if (result) {
updateService(svc.id, { updateService(svc.id, {
explanation: result.explanation, explanation: result.explanation,
priority: result.level as QuoteService['priority'], ...(settings.aiWriteAutoSetPriority ? { priority: result.level as QuoteService['priority'] } : {}),
}); });
setExpanded((prev) => ({ ...prev, [svc.id]: true })); setExpanded((prev) => ({ ...prev, [svc.id]: true }));
showToast(`AI explanation generated for ${svc.name}`, 'success'); showToast(`AI explanation generated for ${svc.name}`, 'success');
@@ -55,7 +63,7 @@ export function ServicesTable() {
} finally { } finally {
setAiLoading((prev) => ({ ...prev, [svc.id]: false })); setAiLoading((prev) => ({ ...prev, [svc.id]: false }));
} }
}, [updateService, showToast]); }, [customerInfo.mileage, customerInfo.vehicleInfo, settings.aiWriteAutoSetPriority, settings.aiWriteLength, settings.aiWriteSafetyWording, settings.aiWriteTone, settings.aiWriteUseVehicleContext, updateService, showToast]);
if (services.length === 0) { if (services.length === 0) {
return ( return (
+115 -27
View File
@@ -16,7 +16,7 @@ export interface PriorityResult {
} }
export interface ExplanationResult { export interface ExplanationResult {
level: string; level: 'CRITICAL_SAFETY' | 'SAFETY_CONCERN' | 'RECOMMENDED' | 'MAINTENANCE' | 'OPTIONAL';
explanation: string; explanation: string;
} }
@@ -27,6 +27,10 @@ export interface ExplanationParams {
vehicleInfo?: string; vehicleInfo?: string;
mileage?: number; mileage?: number;
maintenanceInterval?: number; maintenanceInterval?: number;
tone?: 'professional' | 'friendly' | 'concise' | 'detailed';
length?: 'short' | 'standard' | 'detailed';
useVehicleContext?: boolean;
safetyWording?: 'conservative' | 'balanced' | 'direct';
} }
export interface CatalogDescriptionParams { export interface CatalogDescriptionParams {
@@ -113,6 +117,42 @@ function stripCodeBlocks(text: string): string {
return text.trim().replace(/```json\s*/gi, '').replace(/```\s*/g, '').trim(); return text.trim().replace(/```json\s*/gi, '').replace(/```\s*/g, '').trim();
} }
const EXPLANATION_PRIORITIES = new Set([
'CRITICAL_SAFETY',
'SAFETY_CONCERN',
'RECOMMENDED',
'MAINTENANCE',
'OPTIONAL',
]);
function normalizeExplanationPriority(value: unknown): ExplanationResult['level'] {
if (typeof value !== 'string') return 'RECOMMENDED';
const normalized = value.trim().toUpperCase().replace(/[\s-]+/g, '_');
if (EXPLANATION_PRIORITIES.has(normalized)) return normalized as ExplanationResult['level'];
if (normalized === 'CRITICAL') return 'CRITICAL_SAFETY';
if (normalized === 'PREVENTIVE') return 'MAINTENANCE';
return 'RECOMMENDED';
}
function explanationLengthInstruction(length: ExplanationParams['length']): string {
if (length === 'short') return 'Write 1-2 concise sentences.';
if (length === 'detailed') return 'Write 3-5 sentences with enough context to help the customer understand the recommendation.';
return 'Write 2-4 sentences.';
}
function explanationToneInstruction(tone: ExplanationParams['tone']): string {
if (tone === 'friendly') return 'Tone: friendly, reassuring, plain-language, professional, and not salesy.';
if (tone === 'concise') return 'Tone: concise, direct, professional, and not salesy. Avoid extra background details.';
if (tone === 'detailed') return 'Tone: professional, helpful, explanatory, and not salesy. Include useful context without adding unsupported facts.';
return 'Tone: professional, calm, helpful, confident, concise, and not salesy.';
}
function safetyWordingInstruction(safetyWording: ExplanationParams['safetyWording']): string {
if (safetyWording === 'conservative') return 'Safety wording: conservative. Avoid urgent language unless the source facts clearly state an immediate unsafe condition.';
if (safetyWording === 'direct') return 'Safety wording: direct. Clearly state supported safety risks, but do not exaggerate or imply facts not provided.';
return 'Safety wording: balanced. Explain supported risks clearly without scare tactics.';
}
// ─── 1. Generate Priorities ───────────────────────────────── // ─── 1. Generate Priorities ─────────────────────────────────
/** /**
@@ -167,64 +207,112 @@ ${JSON.stringify(servicesToAnalyze)}`;
export async function aiWriteExplanation( export async function aiWriteExplanation(
params: ExplanationParams, params: ExplanationParams,
): Promise<ExplanationResult | null> { ): Promise<ExplanationResult | null> {
const { serviceName, recommendation, technicianNotes, vehicleInfo, mileage, maintenanceInterval } = params; const {
serviceName,
recommendation,
technicianNotes,
vehicleInfo,
mileage,
maintenanceInterval,
tone = 'professional',
length = 'standard',
useVehicleContext = true,
safetyWording = 'balanced',
} = params;
if (!serviceName || !recommendation) return null; if (!serviceName || !recommendation) return null;
let additionalContext = ''; const additionalContext: string[] = [];
const mileageNum = mileage ?? 0; const mileageNum = mileage ?? 0;
if (technicianNotes) { if (technicianNotes) {
additionalContext += `\nTechnician's internal notes: "${technicianNotes}"`; additionalContext.push(`Technician's internal notes: "${technicianNotes}"`);
} }
if (vehicleInfo) { if (useVehicleContext && vehicleInfo) {
additionalContext += `\nVehicle: ${vehicleInfo}`; additionalContext.push(`Vehicle: ${vehicleInfo}`);
} }
if (maintenanceInterval && mileageNum > 0) { if (useVehicleContext && maintenanceInterval && mileageNum > 0) {
const lastInterval = Math.floor(mileageNum / maintenanceInterval) * maintenanceInterval; const lastInterval = Math.floor(mileageNum / maintenanceInterval) * maintenanceInterval;
const nextInterval = lastInterval + maintenanceInterval; const nextInterval = lastInterval + maintenanceInterval;
const milesUntilNext = nextInterval - mileageNum; const milesUntilNext = nextInterval - mileageNum;
const occurrences = Math.floor(mileageNum / maintenanceInterval); const occurrences = Math.floor(mileageNum / maintenanceInterval);
additionalContext += `\nCurrent mileage: ${mileageNum.toLocaleString()} miles`; additionalContext.push(`Current mileage: ${mileageNum.toLocaleString()} miles`);
additionalContext += `\nService interval: every ${maintenanceInterval.toLocaleString()} miles.`; additionalContext.push(`Service interval: every ${maintenanceInterval.toLocaleString()} miles.`);
if (occurrences >= 1) { if (occurrences >= 1) {
const s = ['th', 'st', 'nd', 'rd']; const s = ['th', 'st', 'nd', 'rd'];
const v = occurrences % 100; const v = occurrences % 100;
const ordinal = occurrences + (s[(v - 20) % 10] || s[v] || s[0]); const ordinal = occurrences + (s[(v - 20) % 10] || s[v] || s[0]);
additionalContext += ` This would be the ${ordinal} time this service is performed.`; additionalContext.push(`This is the ${ordinal} interval for this service, assuming it has followed this repeating mileage schedule.`);
} }
if (milesUntilNext === 0) { if (milesUntilNext === 0) {
additionalContext += ` Service is due now. Do NOT say past due — it is exactly at its interval.`; additionalContext.push(`Service is due now. Do NOT say past due — it is exactly at its interval.`);
} else if (milesUntilNext > 0) { } else if (milesUntilNext > 0) {
additionalContext += ` Next interval at ${nextInterval.toLocaleString()} miles (${milesUntilNext.toLocaleString()} from now).`; additionalContext.push(`Next interval at ${nextInterval.toLocaleString()} miles (${milesUntilNext.toLocaleString()} from now).`);
} else { } else {
additionalContext += ` Service was due at ${lastInterval.toLocaleString()} miles (${Math.abs(milesUntilNext).toLocaleString()} ago).`; additionalContext.push(`Service was due at ${lastInterval.toLocaleString()} miles (${Math.abs(milesUntilNext).toLocaleString()} ago).`);
} }
} else if (mileageNum > 0) { } else if (useVehicleContext && mileageNum > 0) {
additionalContext += `\nVehicle mileage (context only — do NOT mention unless the service is mileage-based): ${mileageNum.toLocaleString()} miles`; additionalContext.push(`Vehicle mileage (optional context only): ${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 : ''} const systemPrompt = `You write customer-facing automotive repair quote explanations.
Your task is to: Rules:
1. Determine the appropriate recommendation level based on the reason and any additional context provided (CRITICAL, RECOMMENDED, OPTIONAL, PREVENTIVE, or MAINTENANCE) - Use only the provided source facts. Do not invent diagnosis, measurements, causes, symptoms, part conditions, or inspection findings.
2. Write a professional explanation as if a master technician is explaining to a customer - Translate technician/internal notes into customer-friendly language. Do not mention "internal notes", quote private shorthand, or say "the technician wrote".
3. If technician notes are provided, incorporate that specific information into the explanation - Use vehicle year/make/model and mileage only when directly relevant to the service explanation. Do not mention mileage just because it was provided.
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. - Mileage may support maintenance-interval or preventive-maintenance explanations, but it must not be used as evidence that a part is worn, leaking, dirty, unsafe, or failing unless that condition is explicitly provided.
- Do not say "manufacturer recommended", "factory required", or "OEM interval" unless that source is explicitly provided. If only interval data is provided, say "based on the service interval".
- Explain safety implications only when supported by the service or source facts. Be honest without scare tactics.
- Use qualifying words like "may", "can", "could", or "potentially" unless the facts clearly state an immediate unsafe condition.
- Do not guarantee outcomes. Use wording like "helps", "is intended to", or "reduces the risk of".
- Do not mention pricing, approval, scheduling, warranty, discounts, or authorization.
- ${explanationToneInstruction(tone)}
- ${safetyWordingInstruction(safetyWording)}
- ${explanationLengthInstruction(length)} Do not simply restate the service name.
Response format: Priority definitions:
LEVEL: [Choose one: CRITICAL, RECOMMENDED, OPTIONAL, PREVENTIVE, MAINTENANCE] - CRITICAL_SAFETY: Immediate safety risk or vehicle should not be driven without repair, supported by explicit facts.
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.]`; - SAFETY_CONCERN: Safety-related system concern, but not stated as immediate failure.
- RECOMMENDED: Repair or service recommended to correct a problem or prevent worsening damage.
- MAINTENANCE: Mileage/time/service-interval based upkeep with no diagnosed failure.
- OPTIONAL: Convenience, comfort, cosmetic, or low-urgency service.
const raw = await callDeepSeek(systemPrompt, `${serviceName}\n${recommendation}${additionalContext}`, 400); Return ONLY valid JSON with this shape:
{"priority":"CRITICAL_SAFETY|SAFETY_CONCERN|RECOMMENDED|MAINTENANCE|OPTIONAL","explanation":"2-4 customer-facing sentences"}`;
const userPrompt = JSON.stringify({
serviceName,
recommendation,
technicianNotes: technicianNotes || '',
vehicleInfo: useVehicleContext ? vehicleInfo || '' : '',
mileage: useVehicleContext && mileageNum > 0 ? mileageNum : null,
maintenanceInterval: useVehicleContext ? maintenanceInterval || null : null,
aiWriteSettings: { tone, length, useVehicleContext, safetyWording },
contextNotes: additionalContext,
});
const raw = await callDeepSeek(systemPrompt, userPrompt, 400);
if (!raw) return null; if (!raw) return null;
const levelMatch = raw.match(/LEVEL:\s*(CRITICAL|RECOMMENDED|OPTIONAL|PREVENTIVE|MAINTENANCE)/i); try {
const parsed = JSON.parse(stripCodeBlocks(raw)) as { priority?: unknown; level?: unknown; explanation?: unknown };
if (typeof parsed.explanation === 'string' && parsed.explanation.trim()) {
return {
level: normalizeExplanationPriority(parsed.priority ?? parsed.level),
explanation: parsed.explanation.trim(),
};
}
} catch {
// Fall back to the legacy text parser below for non-JSON model responses.
}
const levelMatch = raw.match(/(?:LEVEL|PRIORITY):\s*(CRITICAL_SAFETY|SAFETY_CONCERN|RECOMMENDED|MAINTENANCE|OPTIONAL|CRITICAL|PREVENTIVE)/i);
const explanationMatch = raw.match(/EXPLANATION:\s*(.+)/s); const explanationMatch = raw.match(/EXPLANATION:\s*(.+)/s);
if (levelMatch && explanationMatch) { if (levelMatch && explanationMatch) {
return { return {
level: levelMatch[1].toUpperCase(), level: normalizeExplanationPriority(levelMatch[1]),
explanation: explanationMatch[1].trim(), explanation: explanationMatch[1].trim(),
}; };
} }
+209
View File
@@ -0,0 +1,209 @@
import type { ShopSettings } from '../types';
import type { CustomerFormData, VehicleFormData } from '../components/customers/types';
type DataFormattingSettings = Pick<
ShopSettings,
| 'customerNameFormat'
| 'vehicleInfoFormat'
| 'vinUppercase'
| 'licensePlateFormat'
| 'mileageFormat'
>;
const ACRONYMS = new Set(['ABS', 'AC', 'A/C', 'AWD', 'BMW', 'EV', 'FWD', 'GMC', 'GT', 'HD', 'HVAC', 'LX', 'SE', 'SUV', 'TRD', 'VW']);
const SERVICE_ACRONYMS = new Set([...ACRONYMS, 'API', 'ATF', 'CAN', 'DEF', 'DTC', 'EGR', 'EVAP', 'HV', 'MAF', 'MAP', 'NOX', 'OBD', 'OBD-II', 'OEM', 'PCV', 'PCM', 'TPMS', 'VIN']);
function collapseWhitespace(value: string): string {
return value.trim().replace(/\s+/g, ' ');
}
function smartWord(value: string): string {
if (!value) return value;
const upper = value.toUpperCase();
if (ACRONYMS.has(upper)) return upper;
if (/^[IVXLCDM]+$/i.test(value) && value.length <= 4) return upper;
return value
.split(/([-'\/])/)
.map((part) => {
if (/^[-'\/]$/.test(part) || !part) return part;
const lower = part.toLowerCase();
if (lower.startsWith('mc') && lower.length > 2) {
return `Mc${lower.charAt(2).toUpperCase()}${lower.slice(3)}`;
}
return `${lower.charAt(0).toUpperCase()}${lower.slice(1)}`;
})
.join('');
}
function serviceWord(value: string, preserveAcronyms: boolean): string {
if (!value) return value;
const upper = value.toUpperCase();
if (preserveAcronyms && SERVICE_ACRONYMS.has(upper)) return upper;
if (/^\d+[a-z]+$/i.test(value)) return upper;
if (/^[A-Z0-9-]{2,}$/.test(value)) return value;
return smartWord(value);
}
function collapseServiceWhitespace(value: string, trim: boolean): string {
const cleaned = value.replace(/[ \t]+/g, ' ').replace(/\n{3,}/g, '\n\n');
return trim ? cleaned.trim() : cleaned;
}
function sentenceCase(value: string, settings: ServiceFormattingSettings): string {
const cleaned = collapseServiceWhitespace(value, settings.trimServiceText);
if (!cleaned) return cleaned;
const lower = cleaned.toLowerCase();
return lower.replace(/(^|[.!?]\s+)([a-z])/g, (_match, prefix: string, letter: string) => `${prefix}${letter.toUpperCase()}`);
}
function ensureFinalPeriod(value: string, enabled: boolean): string {
if (!enabled) return value;
const trimmed = value.trimEnd();
if (!trimmed || /[.!?:;]$/.test(trimmed)) return value;
return `${trimmed}.`;
}
function uppercasePartLikeTokens(value: string, enabled: boolean): string {
if (!enabled) return value;
return value.replace(/\b(?=[a-z0-9-]*\d)(?=[a-z0-9-]*[a-z])[a-z0-9-]{3,}\b/gi, (token) => token.toUpperCase());
}
type ServiceFormattingSettings = Pick<
ShopSettings,
| 'serviceNameFormat'
| 'serviceDescriptionFormat'
| 'serviceRecommendationFormat'
| 'serviceCategoryFormat'
| 'trimServiceText'
| 'addPeriodToServiceDescriptions'
| 'preserveServiceAcronyms'
| 'uppercasePartNumbers'
>;
export function smartTitleCase(value: string): string {
return collapseWhitespace(value)
.split(' ')
.map(smartWord)
.join(' ');
}
export function formatServiceName(value: string, settings: ServiceFormattingSettings): string {
const cleaned = collapseServiceWhitespace(value, settings.trimServiceText);
if (settings.serviceNameFormat === 'uppercase') return cleaned.toUpperCase();
if (settings.serviceNameFormat === 'sentence') return sentenceCase(cleaned, settings);
if (settings.serviceNameFormat === 'smart_title') {
return cleaned.split(' ').map((word) => serviceWord(word, settings.preserveServiceAcronyms)).join(' ');
}
return cleaned;
}
export function formatServiceCategory(value: string, settings: ServiceFormattingSettings): string {
const cleaned = collapseServiceWhitespace(value, settings.trimServiceText);
if (settings.serviceCategoryFormat === 'uppercase') return cleaned.toUpperCase();
if (settings.serviceCategoryFormat === 'smart_title') {
return cleaned.split(' ').map((word) => serviceWord(word, settings.preserveServiceAcronyms)).join(' ');
}
return cleaned;
}
export function formatServiceDescription(value: string, settings: ServiceFormattingSettings): string {
let cleaned = collapseServiceWhitespace(value, settings.trimServiceText);
if (settings.serviceDescriptionFormat === 'sentence' || settings.serviceDescriptionFormat === 'smart_cleanup') {
cleaned = sentenceCase(cleaned, settings);
}
if (settings.serviceDescriptionFormat === 'smart_cleanup') {
cleaned = uppercasePartLikeTokens(cleaned, settings.uppercasePartNumbers);
}
return ensureFinalPeriod(cleaned, settings.addPeriodToServiceDescriptions && settings.serviceDescriptionFormat !== 'preserve');
}
export function formatServiceRecommendation(value: string, settings: ServiceFormattingSettings): string {
let cleaned = collapseServiceWhitespace(value, settings.trimServiceText);
if (settings.serviceRecommendationFormat === 'sentence' || settings.serviceRecommendationFormat === 'smart_cleanup') {
cleaned = sentenceCase(cleaned, settings);
}
if (settings.serviceRecommendationFormat === 'smart_cleanup') {
cleaned = uppercasePartLikeTokens(cleaned, settings.uppercasePartNumbers);
}
return ensureFinalPeriod(cleaned, settings.addPeriodToServiceDescriptions && settings.serviceRecommendationFormat !== 'preserve');
}
export function formatServiceTextFields<T extends { name?: string; category?: string; description?: string; explanation?: string; recommendation?: string; recommendationReason?: string }>(service: T, settings: ServiceFormattingSettings): T {
return {
...service,
...(service.name !== undefined ? { name: formatServiceName(service.name, settings) } : {}),
...(service.category !== undefined ? { category: formatServiceCategory(service.category, settings) } : {}),
...(service.description !== undefined ? { description: formatServiceDescription(service.description, settings) } : {}),
...(service.explanation !== undefined ? { explanation: formatServiceDescription(service.explanation, settings) } : {}),
...(service.recommendation !== undefined ? { recommendation: formatServiceRecommendation(service.recommendation, settings) } : {}),
...(service.recommendationReason !== undefined ? { recommendationReason: formatServiceRecommendation(service.recommendationReason, settings) } : {}),
};
}
export function formatCustomerNamePart(value: string, settings: Pick<ShopSettings, 'customerNameFormat'>): string {
const cleaned = collapseWhitespace(value);
if (settings.customerNameFormat === 'uppercase') return cleaned.toUpperCase();
if (settings.customerNameFormat === 'smart_title') return smartTitleCase(cleaned);
return cleaned;
}
export function formatCustomerName(value: string, settings: Pick<ShopSettings, 'customerNameFormat'>): string {
return formatCustomerNamePart(value, settings);
}
export function formatVehicleInfo(value: string, settings: Pick<ShopSettings, 'vehicleInfoFormat'>): string {
const cleaned = collapseWhitespace(value);
if (settings.vehicleInfoFormat === 'smart_title') return smartTitleCase(cleaned);
return cleaned;
}
export function formatVin(value: string, settings: Pick<ShopSettings, 'vinUppercase'>): string {
const cleaned = value.trim().replace(/\s+/g, '');
return settings.vinUppercase ? cleaned.toUpperCase() : cleaned;
}
export function formatLicensePlate(value: string, settings: Pick<ShopSettings, 'licensePlateFormat'>): string {
const cleaned = collapseWhitespace(value);
return settings.licensePlateFormat === 'uppercase' ? cleaned.toUpperCase() : cleaned;
}
export function formatMileage(value: string, settings: Pick<ShopSettings, 'mileageFormat'>): string {
const cleaned = value.trim();
if (settings.mileageFormat === 'preserve') return cleaned;
const digits = cleaned.replace(/\D/g, '');
if (!digits) return '';
if (settings.mileageFormat === 'digits_only') return digits;
return Number(digits).toLocaleString();
}
export function formatCustomerFormData(data: CustomerFormData, settings: DataFormattingSettings): CustomerFormData {
const firstName = formatCustomerNamePart(data.firstName, settings);
const middleName = formatCustomerNamePart(data.middleName, settings);
const lastName = formatCustomerNamePart(data.lastName, settings);
const name = formatCustomerName(data.name || [firstName, middleName, lastName].filter(Boolean).join(' '), settings);
return {
...data,
name,
firstName,
middleName,
lastName,
phone: data.phone.trim(),
email: data.email.trim().toLowerCase(),
address: collapseWhitespace(data.address),
notes: data.notes.trim(),
};
}
export function formatVehicleFormData(data: VehicleFormData, settings: DataFormattingSettings): VehicleFormData {
return {
...data,
year: data.year.trim(),
make: formatVehicleInfo(data.make, settings),
model: formatVehicleInfo(data.model, settings),
vin: formatVin(data.vin, settings),
licensePlate: formatLicensePlate(data.licensePlate, settings),
mileage: formatMileage(data.mileage, settings),
color: formatVehicleInfo(data.color, settings),
engine: collapseWhitespace(data.engine),
};
}
+8 -7
View File
@@ -592,6 +592,7 @@ export async function generateQuotePDF(
let displayDiscount = 0; let displayDiscount = 0;
let displayTax = 0; let displayTax = 0;
let displayTotal = 0; let displayTotal = 0;
const showTax = appearance.showTaxLine && settings.taxEnabled !== false && settings.taxRate > 0;
if (hasMixed) { if (hasMixed) {
const split = computeSplitQuoteTotals(services, quote.discount || { value: 0, type: 'dollar' }, settings); const split = computeSplitQuoteTotals(services, quote.discount || { value: 0, type: 'dollar' }, settings);
@@ -616,7 +617,7 @@ export async function generateQuotePDF(
if (discountValue > 0) { if (discountValue > 0) {
displayDiscount = discountType === 'percent' ? servicesTotal * (discountValue / 100) : discountValue; displayDiscount = discountType === 'percent' ? servicesTotal * (discountValue / 100) : discountValue;
} }
const taxRate = settings.taxRate || 0; const taxRate = settings.taxEnabled === false ? 0 : settings.taxRate || 0;
displayTax = (servicesTotal - displayDiscount + displayShopCharge) * (taxRate / 100); displayTax = (servicesTotal - displayDiscount + displayShopCharge) * (taxRate / 100);
displayTotal = servicesTotal - displayDiscount + displayShopCharge + displayTax; displayTotal = servicesTotal - displayDiscount + displayShopCharge + displayTax;
} }
@@ -636,13 +637,13 @@ export async function generateQuotePDF(
sumRows += 1; // combined subtotal sumRows += 1; // combined subtotal
if (appearance.showDiscountLine && displayDiscount > 0) sumRows += 1; // combined discount if (appearance.showDiscountLine && displayDiscount > 0) sumRows += 1; // combined discount
if (appearance.showShopChargeLine && displayShopCharge > 0) sumRows += 1; // combined capped shop charge if (appearance.showShopChargeLine && displayShopCharge > 0) sumRows += 1; // combined capped shop charge
if (appearance.showTaxLine && settings.taxRate > 0) sumRows += 1; // combined recalculated tax if (showTax) sumRows += 1; // combined recalculated tax
sumRows += 2; // separator + grand total sumRows += 2; // separator + grand total
} else { } else {
sumRows = 1; // subtotal sumRows = 1; // subtotal
if (appearance.showDiscountLine && displayDiscount > 0) sumRows++; if (appearance.showDiscountLine && displayDiscount > 0) sumRows++;
if (appearance.showShopChargeLine && displayShopCharge > 0) sumRows++; if (appearance.showShopChargeLine && displayShopCharge > 0) sumRows++;
if (appearance.showTaxLine && settings.taxRate > 0) sumRows++; if (showTax) sumRows++;
sumRows += 2; // separator + total sumRows += 2; // separator + total
} }
const sumContentH = (sumRows * getLineHeight(9, 1.2)) + 14 + (hasMixed ? mixedSectionGap * 2 : 0); const sumContentH = (sumRows * getLineHeight(9, 1.2)) + 14 + (hasMixed ? mixedSectionGap * 2 : 0);
@@ -676,7 +677,7 @@ export async function generateQuotePDF(
doc.text(`$${approvedTotals.shopCharge.toFixed(2)}`, sumRight, y, { align: 'right' }); doc.text(`$${approvedTotals.shopCharge.toFixed(2)}`, sumRight, y, { align: 'right' });
y += getLineHeight(9); y += getLineHeight(9);
} }
if (appearance.showTaxLine && settings.taxRate > 0) { if (showTax) {
applyTextStyle(doc, appearance.totals, DG); applyTextStyle(doc, appearance.totals, DG);
doc.text(settings.taxLabel || 'Tax', sumX, y); doc.text(settings.taxLabel || 'Tax', sumX, y);
doc.setTextColor(...BL); doc.setTextColor(...BL);
@@ -702,7 +703,7 @@ export async function generateQuotePDF(
doc.text(`$${pendingTotals.shopCharge.toFixed(2)}`, sumRight, y, { align: 'right' }); doc.text(`$${pendingTotals.shopCharge.toFixed(2)}`, sumRight, y, { align: 'right' });
y += getLineHeight(9); y += getLineHeight(9);
} }
if (appearance.showTaxLine && settings.taxRate > 0) { if (showTax) {
applyTextStyle(doc, appearance.totals, DG); applyTextStyle(doc, appearance.totals, DG);
doc.text(settings.taxLabel || 'Tax', sumX, y); doc.text(settings.taxLabel || 'Tax', sumX, y);
doc.setTextColor(...BL); doc.setTextColor(...BL);
@@ -739,7 +740,7 @@ export async function generateQuotePDF(
y += getLineHeight(9); y += getLineHeight(9);
} }
if (appearance.showTaxLine && settings.taxRate > 0) { if (showTax) {
applyTextStyle(doc, appearance.totals, DG); applyTextStyle(doc, appearance.totals, DG);
doc.text(`${settings.taxLabel || 'Tax'} @ ${settings.taxRate}%`, sumX, y); doc.text(`${settings.taxLabel || 'Tax'} @ ${settings.taxRate}%`, sumX, y);
doc.setTextColor(...GR); doc.setTextColor(...GR);
@@ -779,7 +780,7 @@ export async function generateQuotePDF(
} }
// Tax // Tax
if (appearance.showTaxLine && settings.taxRate > 0) { if (showTax) {
applyTextStyle(doc, appearance.totals, DG); applyTextStyle(doc, appearance.totals, DG);
doc.text(settings.taxLabel || 'Tax', sumX, y); doc.text(settings.taxLabel || 'Tax', sumX, y);
doc.setTextColor(...BL); doc.setTextColor(...BL);
+64
View File
@@ -96,6 +96,7 @@ export const DEFAULT_SETTINGS: ShopSettings = {
businessAddress: '', businessAddress: '',
businessPhone: '', businessPhone: '',
serviceAdvisor: '', serviceAdvisor: '',
taxEnabled: true,
taxRate: 0, taxRate: 0,
taxLabel: 'Tax', taxLabel: 'Tax',
shopChargeRate: 3, shopChargeRate: 3,
@@ -120,6 +121,69 @@ export const DEFAULT_SETTINGS: ShopSettings = {
showAdvisorOnQuote: true, showAdvisorOnQuote: true,
showTechnicianOnQuote: false, showTechnicianOnQuote: false,
defaultTechnician: '', defaultTechnician: '',
aiWriteTone: 'professional',
aiWriteLength: 'standard',
aiWriteUseVehicleContext: true,
aiWriteSafetyWording: 'balanced',
aiWriteAutoSetPriority: true,
customerNameFormat: 'smart_title',
vehicleInfoFormat: 'smart_title',
serviceNameFormat: 'smart_title',
serviceDescriptionFormat: 'smart_cleanup',
serviceRecommendationFormat: 'smart_cleanup',
serviceCategoryFormat: 'smart_title',
trimServiceText: true,
addPeriodToServiceDescriptions: true,
preserveServiceAcronyms: true,
uppercasePartNumbers: true,
applyServiceFormattingToCatalog: true,
applyServiceFormattingToQuotes: true,
applyServiceFormattingToROs: true,
spellCheckEnabled: true,
spellCheckLanguage: 'browser',
spellCheckServiceText: true,
spellCheckCustomerNotes: true,
spellCheckQuoteMessages: true,
spellCheckInternalNotes: true,
vinUppercase: true,
licensePlateFormat: 'uppercase',
mileageFormat: 'commas',
autoCreateCustomerFromRO: true,
syncCustomerEditsToActiveROs: true,
duplicateCustomerWarning: 'name_phone',
defaultLandingPage: '/',
appTheme: 'system',
appDensity: 'comfortable',
defaultQuoteStatus: 'draft',
defaultROStatus: 'active',
requireMileageForRO: false,
requireVinForRO: false,
requirePlateForRO: false,
requireAdvisorForQuote: true,
laborRounding: 'exact',
partsMarkupPercent: 0,
travelFeeMode: 'flat',
defaultSmsTemplate: 'Hi {customerName}, your quote for {vehicleInfo} is ready to review: {quoteLink}',
defaultEmailTemplate: 'Hi {customerName},\n\nYour quote for {vehicleInfo} is ready to review. Please let us know if you have any questions.\n\nThank you.',
quoteFollowUpTemplate: 'Hi {customerName}, just following up on the quote for your {vehicleInfo}. Let us know if you would like us to move forward.',
showLaborPartsSplitOnPdf: false,
technicianPriceEditingEnabled: false,
techniciansSeeOnlyAssignedROs: true,
techniciansCanAddRecommendations: true,
techniciansCanMarkServicesComplete: true,
techniciansCanUploadPhotos: true,
techniciansCanAddInternalNotes: true,
techniciansCanSeePrices: true,
techniciansCanSeeCustomerContact: false,
techniciansCanSeeDeclinedWork: true,
advisorsCanApplyDiscounts: true,
maxAdvisorDiscountPercent: 10,
requireApprovalForPriceOverrides: true,
requireAdvisorApprovalBeforeSendingQuote: false,
hideCostProfitFromNonOwners: true,
ownerOnlyCatalogDeletion: true,
requireReasonForPriceChange: true,
requireReasonForDeletingService: true,
quotePdfAppearance: DEFAULT_QUOTE_PDF_APPEARANCE, quotePdfAppearance: DEFAULT_QUOTE_PDF_APPEARANCE,
}; };
+8
View File
@@ -0,0 +1,8 @@
import type { ShopSettings } from '../types';
export function spellCheckProps(settings: ShopSettings, enabled: boolean) {
return {
spellCheck: settings.spellCheckEnabled && enabled,
lang: settings.spellCheckLanguage === 'browser' ? undefined : settings.spellCheckLanguage,
};
}
+11 -6
View File
@@ -19,10 +19,12 @@ export interface TotalsBreakdown {
total: number; total: number;
} }
type TaxAndFeeSettings = Pick<ShopSettings, 'taxRate' | 'shopChargeRate' | 'shopChargeCap'> & Partial<Pick<ShopSettings, 'taxEnabled'>>;
export interface QuoteTotalsInput { export interface QuoteTotalsInput {
services: QuoteService[]; services: QuoteService[];
discount: { value: number; type: 'dollar' | 'percent' }; discount: { value: number; type: 'dollar' | 'percent' };
settings: Pick<ShopSettings, 'taxRate' | 'shopChargeRate' | 'shopChargeCap'>; settings: TaxAndFeeSettings;
} }
// Determine which services count toward totals. // Determine which services count toward totals.
@@ -69,7 +71,8 @@ export function computeQuoteTotals(input: QuoteTotalsInput): TotalsBreakdown {
); );
const taxableBase = afterDiscount + shopCharge; const taxableBase = afterDiscount + shopCharge;
const tax = taxableBase * ((settings.taxRate || 0) / 100); const taxRate = settings.taxEnabled === false ? 0 : settings.taxRate || 0;
const tax = taxableBase * (taxRate / 100);
const total = taxableBase + tax; const total = taxableBase + tax;
return { return {
@@ -92,7 +95,7 @@ export function computeQuoteTotals(input: QuoteTotalsInput): TotalsBreakdown {
export function computeSplitQuoteTotals( export function computeSplitQuoteTotals(
services: QuoteService[], services: QuoteService[],
discount: { value: number; type: 'dollar' | 'percent' }, discount: { value: number; type: 'dollar' | 'percent' },
settings: Pick<ShopSettings, 'taxRate' | 'shopChargeRate' | 'shopChargeCap'>, settings: TaxAndFeeSettings,
): { ): {
approved: TotalsBreakdown; approved: TotalsBreakdown;
pending: TotalsBreakdown; pending: TotalsBreakdown;
@@ -124,7 +127,8 @@ export function computeSplitQuoteTotals(
const cap = settings.shopChargeCap ?? 69.95; const cap = settings.shopChargeCap ?? 69.95;
const shopCharge = Math.min(chargeable * ((settings.shopChargeRate || 0) / 100), cap); const shopCharge = Math.min(chargeable * ((settings.shopChargeRate || 0) / 100), cap);
const taxableBase = afterDiscount + shopCharge; const taxableBase = afterDiscount + shopCharge;
const tax = taxableBase * ((settings.taxRate || 0) / 100); const taxRate = settings.taxEnabled === false ? 0 : settings.taxRate || 0;
const tax = taxableBase * (taxRate / 100);
const total = taxableBase + tax; const total = taxableBase + tax;
const combined: TotalsBreakdown = { const combined: TotalsBreakdown = {
@@ -144,7 +148,7 @@ export function computeSplitQuoteTotals(
export interface ROTotalsInput { export interface ROTotalsInput {
services: ROService[]; services: ROService[];
discount?: { value: number; type: 'dollar' | 'percent' }; discount?: { value: number; type: 'dollar' | 'percent' };
settings: Pick<ShopSettings, 'taxRate' | 'shopChargeRate' | 'shopChargeCap'>; settings: TaxAndFeeSettings;
} }
// RO totals — labor-hours × labor-rate + parts-cost (+ flat total for full-price // RO totals — labor-hours × labor-rate + parts-cost (+ flat total for full-price
@@ -192,7 +196,8 @@ export function computeROTotals(input: ROTotalsInput): TotalsBreakdown {
); );
const taxableBase = afterDiscount + shopCharge; const taxableBase = afterDiscount + shopCharge;
const tax = taxableBase * ((settings.taxRate || 0) / 100); const taxRate = settings.taxEnabled === false ? 0 : settings.taxRate || 0;
const tax = taxableBase * (taxRate / 100);
const total = taxableBase + tax; const total = taxableBase + tax;
return { return {
+23 -16
View File
@@ -5,6 +5,8 @@ import { pb } from '../lib/pocketbase';
import { useToast } from '../components/ui/Toast'; import { useToast } from '../components/ui/Toast';
import { getSetupRequiredMessage, isMissingCollectionError, logError } from '../lib/userMessages'; import { getSetupRequiredMessage, isMissingCollectionError, logError } from '../lib/userMessages';
import { customerWriteSchema } from '../schemas/customer'; import { customerWriteSchema } from '../schemas/customer';
import { useSettings } from '../store/settings';
import { formatCustomerFormData, formatVehicleFormData } from '../lib/dataFormatting';
import { import {
formatVehicleLabel, formatVehicleLabel,
vehicleKey, vehicleKey,
@@ -41,6 +43,7 @@ import type {
export default function Customers() { export default function Customers() {
const { showToast } = useToast(); const { showToast } = useToast();
const settings = useSettings();
const [searchParams, setSearchParams] = useSearchParams(); const [searchParams, setSearchParams] = useSearchParams();
const initialSearch = (() => { const initialSearch = (() => {
const q = searchParams.get('search'); const q = searchParams.get('search');
@@ -303,14 +306,15 @@ export default function Customers() {
const handleAddCustomer = useCallback( const handleAddCustomer = useCallback(
async (data: CustomerFormData) => { async (data: CustomerFormData) => {
const parsed = customerWriteSchema.safeParse({ ...data, userId }); const formatted = formatCustomerFormData(data, settings);
const parsed = customerWriteSchema.safeParse({ ...formatted, userId });
if (!parsed.success) { if (!parsed.success) {
showToast(parsed.error.issues[0].message, 'error'); showToast(parsed.error.issues[0].message, 'error');
return; return;
} }
try { try {
await pb.collection('customers').create({ await pb.collection('customers').create({
...data, ...formatted,
userId, userId,
}); });
showToast('Customer added successfully', 'success'); showToast('Customer added successfully', 'success');
@@ -321,20 +325,21 @@ export default function Customers() {
showToast(isMissingCollectionError(err) ? getSetupRequiredMessage() : 'Customer could not be saved. Please try again.', 'error'); showToast(isMissingCollectionError(err) ? getSetupRequiredMessage() : 'Customer could not be saved. Please try again.', 'error');
} }
}, },
[userId, showToast, fetchAll] [settings, userId, showToast, fetchAll]
); );
const handleEditCustomer = useCallback( const handleEditCustomer = useCallback(
async (data: CustomerFormData) => { async (data: CustomerFormData) => {
if (!editingCustomer) return; if (!editingCustomer) return;
const parsed = customerWriteSchema.safeParse(data); const formatted = formatCustomerFormData(data, settings);
const parsed = customerWriteSchema.safeParse(formatted);
if (!parsed.success) { if (!parsed.success) {
showToast(parsed.error.issues[0].message, 'error'); showToast(parsed.error.issues[0].message, 'error');
return; return;
} }
try { try {
await pb.collection('customers').update(editingCustomer.id, data); await pb.collection('customers').update(editingCustomer.id, formatted);
if (userId) { if (userId && settings.syncCustomerEditsToActiveROs) {
const linkedActiveROs = await pb.collection('repairOrders').getFullList({ const linkedActiveROs = await pb.collection('repairOrders').getFullList({
filter: activeROFilterForCustomer(userId, editingCustomer.id, editingCustomer.name), filter: activeROFilterForCustomer(userId, editingCustomer.id, editingCustomer.name),
fields: 'id', fields: 'id',
@@ -343,9 +348,9 @@ export default function Customers() {
await Promise.all(linkedActiveROs.map((ro) => await Promise.all(linkedActiveROs.map((ro) =>
pb.collection('repairOrders').update(ro.id, { pb.collection('repairOrders').update(ro.id, {
customerId: editingCustomer.id, customerId: editingCustomer.id,
customerName: data.name.trim(), customerName: formatted.name.trim(),
customerPhone: data.phone.trim(), customerPhone: formatted.phone.trim(),
customerEmail: data.email.trim(), customerEmail: formatted.email.trim(),
}) })
)); ));
} }
@@ -354,7 +359,7 @@ export default function Customers() {
fetchAll(); fetchAll();
if (selectedCustomer?.id === editingCustomer.id) { if (selectedCustomer?.id === editingCustomer.id) {
setSelectedCustomer((prev) => setSelectedCustomer((prev) =>
prev ? { ...prev, ...data } : null prev ? { ...prev, ...formatted } : null
); );
} }
} catch (err) { } catch (err) {
@@ -362,7 +367,7 @@ export default function Customers() {
showToast(isMissingCollectionError(err) ? getSetupRequiredMessage() : 'Customer could not be updated. Please try again.', 'error'); showToast(isMissingCollectionError(err) ? getSetupRequiredMessage() : 'Customer could not be updated. Please try again.', 'error');
} }
}, },
[editingCustomer, userId, showToast, fetchAll, selectedCustomer] [editingCustomer, settings, userId, showToast, fetchAll, selectedCustomer]
); );
const handleDeleteCustomer = useCallback(async () => { const handleDeleteCustomer = useCallback(async () => {
@@ -395,9 +400,10 @@ export default function Customers() {
const handleAddVehicle = useCallback( const handleAddVehicle = useCallback(
async (data: VehicleFormData) => { async (data: VehicleFormData) => {
if (!selectedCustomer || !userId) return; if (!selectedCustomer || !userId) return;
const formatted = formatVehicleFormData(data, settings);
try { try {
await pb.collection('vehicles').create({ await pb.collection('vehicles').create({
...data, ...formatted,
customerId: selectedCustomer.id, customerId: selectedCustomer.id,
userId, userId,
}); });
@@ -414,18 +420,19 @@ export default function Customers() {
return prev; return prev;
}); });
}, },
[selectedCustomer, userId, showToast, fetchAll, handleViewCustomer] [selectedCustomer, settings, userId, showToast, fetchAll, handleViewCustomer]
); );
const handleEditVehicle = useCallback( const handleEditVehicle = useCallback(
async (data: VehicleFormData) => { async (data: VehicleFormData) => {
if (!editingVehicle || !selectedCustomer || !userId) return; if (!editingVehicle || !selectedCustomer || !userId) return;
const formatted = formatVehicleFormData(data, settings);
try { try {
if (editingVehicle.id) { if (editingVehicle.id) {
await pb.collection('vehicles').update(editingVehicle.id, data); await pb.collection('vehicles').update(editingVehicle.id, formatted);
} else { } else {
await pb.collection('vehicles').create({ await pb.collection('vehicles').create({
...data, ...formatted,
customerId: selectedCustomer.id, customerId: selectedCustomer.id,
userId, userId,
}); });
@@ -441,7 +448,7 @@ export default function Customers() {
fetchAll(); fetchAll();
if (selectedCustomer) handleViewCustomer(selectedCustomer.id); if (selectedCustomer) handleViewCustomer(selectedCustomer.id);
}, },
[editingVehicle, selectedCustomer, userId, showToast, fetchAll, handleViewCustomer] [editingVehicle, selectedCustomer, settings, userId, showToast, fetchAll, handleViewCustomer]
); );
const handleDeleteVehicle = useCallback(async () => { const handleDeleteVehicle = useCallback(async () => {
+8 -5
View File
@@ -13,6 +13,7 @@ import { useToast } from '../components/ui/Toast';
import type { QuoteService, CustomerInfo } from '../types'; import type { QuoteService, CustomerInfo } from '../types';
import { logError } from '../lib/userMessages'; import { logError } from '../lib/userMessages';
import { normalizeCustomerInfo, formatQuoteCurrency, formatQuoteDate } from '../lib/quoteHelpers'; import { normalizeCustomerInfo, formatQuoteCurrency, formatQuoteDate } from '../lib/quoteHelpers';
import { formatCustomerName, formatMileage, formatServiceTextFields, formatVehicleInfo, formatVin } from '../lib/dataFormatting';
/* ── Helper: convert legacy flat customer fields to CustomerInfo ── */ /* ── Helper: convert legacy flat customer fields to CustomerInfo ── */
function toCustomerInfo(opts: { function toCustomerInfo(opts: {
@@ -420,6 +421,7 @@ export default function QuoteGenerator() {
const settings = useSettingsStore.getState().settings; const settings = useSettingsStore.getState().settings;
const laborRate = settings.defaultLaborRate || 95; const laborRate = settings.defaultLaborRate || 95;
const formattedCustomerName = formatCustomerName(ci.name, settings);
let svcs: any[] = []; let svcs: any[] = [];
const rawSvcs = q.services; const rawSvcs = q.services;
if (typeof rawSvcs === 'string') { try { svcs = JSON.parse(rawSvcs); } catch (err) { logError('Failed to parse quote-to-RO svcs JSON', err); svcs = []; } } if (typeof rawSvcs === 'string') { try { svcs = JSON.parse(rawSvcs); } catch (err) { logError('Failed to parse quote-to-RO svcs JSON', err); svcs = []; } }
@@ -430,7 +432,7 @@ export default function QuoteGenerator() {
.map((s: any, i: number) => { .map((s: any, i: number) => {
const price = parseFloat(String(s.price ?? 0)) || 0; const price = parseFloat(String(s.price ?? 0)) || 0;
const laborHours = price / laborRate; const laborHours = price / laborRate;
return { const service = {
id: s.id || `rosvc-${i}`, id: s.id || `rosvc-${i}`,
name: s.name || '', name: s.name || '',
description: s.explanation || '', description: s.explanation || '',
@@ -444,6 +446,7 @@ export default function QuoteGenerator() {
applyShopCharge: s.applyShopCharge !== false, applyShopCharge: s.applyShopCharge !== false,
technicianNotes: s.technicianNotes || '', technicianNotes: s.technicianNotes || '',
}; };
return settings.applyServiceFormattingToROs ? formatServiceTextFields(service, settings) : service;
}); });
const roNumber = uniqueRONumber(existing); const roNumber = uniqueRONumber(existing);
@@ -452,11 +455,11 @@ export default function QuoteGenerator() {
userId, userId,
quoteId: q.id, quoteId: q.id,
customerId: q.customerId || '', customerId: q.customerId || '',
customerName: ci.name, customerName: formattedCustomerName,
customerPhone: ci.phone, customerPhone: ci.phone,
vehicleInfo: ci.vehicleInfo, vehicleInfo: formatVehicleInfo(ci.vehicleInfo, settings),
vin: ci.vin, vin: formatVin(ci.vin, settings),
mileage: ci.mileage, mileage: formatMileage(ci.mileage, settings),
advisorName: ci.serviceAdvisor, advisorName: ci.serviceAdvisor,
roNumber, roNumber,
notes: `Converted from quote #${q.id.slice(0, 8)}`, notes: `Converted from quote #${q.id.slice(0, 8)}`,
+21 -9
View File
@@ -23,6 +23,8 @@ import { downloadROPDF, printROPDF } from '../lib/roPdf';
import { useSettings } from '../store/settings'; import { useSettings } from '../store/settings';
import { logError } from '../lib/userMessages'; import { logError } from '../lib/userMessages';
import { parseRepairOrderServices, syncAssignmentsForROServices } from '../lib/technicianData'; import { parseRepairOrderServices, syncAssignmentsForROServices } from '../lib/technicianData';
import { formatServiceTextFields } from '../lib/dataFormatting';
import { spellCheckProps } from '../lib/spellcheck';
type TabId = 'details' | 'services' | 'financial' | 'history' | 'timeline'; type TabId = 'details' | 'services' | 'financial' | 'history' | 'timeline';
@@ -166,12 +168,14 @@ function PricingBadge({ mode }: { mode: 'full' | 'split' }) {
return <span className="inline-flex items-center rounded-full bg-blue-100 px-2 py-0.5 text-[10px] font-medium text-blue-700 dark:bg-blue-900/30 dark:text-blue-300">Split</span>; return <span className="inline-flex items-center rounded-full bg-blue-100 px-2 py-0.5 text-[10px] font-medium text-blue-700 dark:bg-blue-900/30 dark:text-blue-300">Split</span>;
} }
function EditableServiceRow({ service, index, onChange, onRemove, defaultLaborRate = 145 }: { function EditableServiceRow({ service, index, onChange, onRemove, defaultLaborRate = 145, spellCheck, lang }: {
service: Partial<ROService>; service: Partial<ROService>;
index: number; index: number;
onChange: (index: number, field: string, value: string | number | boolean) => void; onChange: (index: number, field: string, value: string | number | boolean) => void;
onRemove: (index: number) => void; onRemove: (index: number) => void;
defaultLaborRate?: number; defaultLaborRate?: number;
spellCheck?: boolean;
lang?: string;
}) { }) {
const pricingMode = service.pricingMode || 'full'; const pricingMode = service.pricingMode || 'full';
const isSplit = pricingMode === 'split'; const isSplit = pricingMode === 'split';
@@ -246,7 +250,7 @@ function EditableServiceRow({ service, index, onChange, onRemove, defaultLaborRa
</div> </div>
<div className="sm:col-span-2 lg:col-span-1"> <div className="sm:col-span-2 lg:col-span-1">
<label className="block text-xs text-gray-500 dark:text-gray-400">Description</label> <label className="block text-xs text-gray-500 dark:text-gray-400">Description</label>
<textarea value={service.description || ''} onChange={(e) => onChange(index, 'description', e.target.value)} placeholder="Description..." rows={1} className="mt-0.5 w-full rounded border border-gray-300 bg-white px-2 py-1.5 text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-gray-100" /> <textarea value={service.description || ''} onChange={(e) => onChange(index, 'description', e.target.value)} placeholder="Description..." rows={1} spellCheck={spellCheck} lang={lang} className="mt-0.5 w-full rounded border border-gray-300 bg-white px-2 py-1.5 text-sm dark:border-gray-600 dark:bg-gray-700 dark:text-gray-100" />
</div> </div>
{pricingMode === 'full' ? ( {pricingMode === 'full' ? (
<> <>
@@ -305,9 +309,11 @@ function Input({ value, onChange, placeholder, type = 'text', icon }: { value: s
); );
} }
function Textarea({ value, onChange, placeholder, rows = 3 }: { value: string; onChange: (value: string) => void; placeholder?: string; rows?: number }) { function Textarea({ value, onChange, placeholder, rows = 3, spellCheck, lang }: { value: string; onChange: (value: string) => void; placeholder?: string; rows?: number; spellCheck?: boolean; lang?: string }) {
return ( return (
<textarea value={value} onChange={(e) => onChange(e.target.value)} placeholder={placeholder} rows={rows} <textarea value={value} onChange={(e) => onChange(e.target.value)} placeholder={placeholder} rows={rows}
spellCheck={spellCheck}
lang={lang}
className="w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm text-gray-900 placeholder-gray-400 focus:border-green-500 focus:outline-none focus:ring-2 focus:ring-green-500/20 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100" className="w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm text-gray-900 placeholder-gray-400 focus:border-green-500 focus:outline-none focus:ring-2 focus:ring-green-500/20 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100"
/> />
); );
@@ -321,6 +327,8 @@ export default function RODetailModal() {
const navigate = useNavigate(); const navigate = useNavigate();
const { showToast } = useToast(); const { showToast } = useToast();
const settings = useSettings(); const settings = useSettings();
const serviceTextSpellCheck = spellCheckProps(settings, settings.spellCheckServiceText);
const internalNotesSpellCheck = spellCheckProps(settings, settings.spellCheckInternalNotes);
const statusConfig = useMemo(() => getStatusConfig(settings), [settings]); const statusConfig = useMemo(() => getStatusConfig(settings), [settings]);
const modalBackground = (location.state as { backgroundLocation?: unknown })?.backgroundLocation; const modalBackground = (location.state as { backgroundLocation?: unknown })?.backgroundLocation;
@@ -864,7 +872,8 @@ export default function RODetailModal() {
if (!ro) return; if (!ro) return;
setSaving(true); setSaving(true);
try { try {
const cleaned = localServices.map((s) => ({ const cleaned = localServices.map((s) => {
const service = {
id: s.id, id: s.id,
name: s.name, name: s.name,
description: s.description || '', description: s.description || '',
@@ -876,7 +885,9 @@ export default function RODetailModal() {
technician: s.technician || '', technician: s.technician || '',
pricingMode: s.pricingMode || 'full', pricingMode: s.pricingMode || 'full',
applyShopCharge: s.applyShopCharge !== false, applyShopCharge: s.applyShopCharge !== false,
})); };
return settings.applyServiceFormattingToROs ? formatServiceTextFields(service, settings) : service;
});
const payload = { services: JSON.stringify(cleaned) }; const payload = { services: JSON.stringify(cleaned) };
await pb.collection('repairOrders').update(ro.id, payload); await pb.collection('repairOrders').update(ro.id, payload);
@@ -916,7 +927,7 @@ export default function RODetailModal() {
const partsPrice = Number(newServiceForm.partsPrice) || 0; const partsPrice = Number(newServiceForm.partsPrice) || 0;
const isSplit = newServiceForm.pricingMode === 'split'; const isSplit = newServiceForm.pricingMode === 'split';
const description = newServiceForm.description.trim(); const description = newServiceForm.description.trim();
const data = { const data = formatServiceTextFields({
name: newServiceForm.name.trim(), name: newServiceForm.name.trim(),
category: newServiceForm.category.trim(), category: newServiceForm.category.trim(),
description, description,
@@ -927,7 +938,7 @@ export default function RODetailModal() {
price: isSplit ? laborPrice + partsPrice : fullPrice, price: isSplit ? laborPrice + partsPrice : fullPrice,
active: true, active: true,
userId, userId,
}; }, settings.applyServiceFormattingToCatalog ? settings : { ...settings, serviceNameFormat: 'preserve', serviceDescriptionFormat: 'preserve', serviceRecommendationFormat: 'preserve', serviceCategoryFormat: 'preserve', trimServiceText: true, addPeriodToServiceDescriptions: false });
try { try {
const created = await pb.collection('services').create(data) as Record<string, unknown>; const created = await pb.collection('services').create(data) as Record<string, unknown>;
const newSvc: ServiceItem = { const newSvc: ServiceItem = {
@@ -1054,6 +1065,7 @@ export default function RODetailModal() {
onChange={(e) => setReminderNotes(e.target.value)} onChange={(e) => setReminderNotes(e.target.value)}
placeholder="e.g. Next oil change reminder" placeholder="e.g. Next oil change reminder"
rows={2} rows={2}
{...internalNotesSpellCheck}
className="mt-1 w-full rounded border border-blue-300 bg-white px-2 py-1.5 text-sm dark:border-blue-700 dark:bg-gray-800 dark:text-gray-100" className="mt-1 w-full rounded border border-blue-300 bg-white px-2 py-1.5 text-sm dark:border-blue-700 dark:bg-gray-800 dark:text-gray-100"
/> />
</div> </div>
@@ -1315,7 +1327,7 @@ export default function RODetailModal() {
<div className="mt-3"> <div className="mt-3">
<FormField label="Description"> <FormField label="Description">
<div className="space-y-2"> <div className="space-y-2">
<Textarea value={newServiceForm.description} onChange={(v) => setNewServiceForm((p) => ({ ...p, description: v }))} placeholder="Brief description..." rows={2} /> <Textarea value={newServiceForm.description} onChange={(v) => setNewServiceForm((p) => ({ ...p, description: v }))} placeholder="Brief description..." rows={2} {...serviceTextSpellCheck} />
</div> </div>
</FormField> </FormField>
</div> </div>
@@ -1373,7 +1385,7 @@ export default function RODetailModal() {
</div> </div>
<div className="space-y-3"> <div className="space-y-3">
{localServices.map((svc, i) => ( {localServices.map((svc, i) => (
<EditableServiceRow key={i} service={svc} index={i} onChange={handleServiceChange} onRemove={handleRemoveService} defaultLaborRate={settings.defaultLaborRate || 145} /> <EditableServiceRow key={i} service={svc} index={i} onChange={handleServiceChange} onRemove={handleRemoveService} defaultLaborRate={settings.defaultLaborRate || 145} {...serviceTextSpellCheck} />
))} ))}
{localServices.length === 0 && ( {localServices.length === 0 && (
<p className="text-center text-sm text-gray-400">No services on this RO yet.</p> <p className="text-center text-sm text-gray-400">No services on this RO yet.</p>
+11 -4
View File
@@ -7,6 +7,9 @@ import { aiWriteCatalogDescription } from '../lib/ai';
import { logError } from '../lib/userMessages'; import { logError } from '../lib/userMessages';
import { ListSkeleton } from '../components/ui/ListSkeleton'; import { ListSkeleton } from '../components/ui/ListSkeleton';
import { ListError } from '../components/ui/ListError'; import { ListError } from '../components/ui/ListError';
import { useSettings } from '../store/settings';
import { formatServiceTextFields } from '../lib/dataFormatting';
import { spellCheckProps } from '../lib/spellcheck';
type ServicePricingMode = 'full' | 'split'; type ServicePricingMode = 'full' | 'split';
@@ -64,13 +67,15 @@ function Input({ value, onChange, placeholder, type = 'text', icon }: { value: s
); );
} }
function Textarea({ value, onChange, placeholder, rows = 3 }: { value: string; onChange: (value: string) => void; placeholder?: string; rows?: number }) { function Textarea({ value, onChange, placeholder, rows = 3, spellCheck, lang }: { value: string; onChange: (value: string) => void; placeholder?: string; rows?: number; spellCheck?: boolean; lang?: string }) {
return ( return (
<textarea <textarea
value={value} value={value}
onChange={(e) => onChange(e.target.value)} onChange={(e) => onChange(e.target.value)}
placeholder={placeholder} placeholder={placeholder}
rows={rows} rows={rows}
spellCheck={spellCheck}
lang={lang}
className="w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm text-gray-900 placeholder-gray-400 focus:border-green-500 focus:outline-none focus:ring-2 focus:ring-green-500/20 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100" className="w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm text-gray-900 placeholder-gray-400 focus:border-green-500 focus:outline-none focus:ring-2 focus:ring-green-500/20 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100"
/> />
); );
@@ -114,6 +119,8 @@ function ServiceRow({ service, onEdit, onDelete, onToggleActive }: { service: Se
export default function ServiceCatalog() { export default function ServiceCatalog() {
const { showToast } = useToast(); const { showToast } = useToast();
const settings = useSettings();
const serviceTextSpellCheck = spellCheckProps(settings, settings.spellCheckServiceText);
const userId = pb.authStore.model?.id; const userId = pb.authStore.model?.id;
const [services, setServices] = useState<ServiceItem[]>([]); const [services, setServices] = useState<ServiceItem[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
@@ -206,7 +213,7 @@ export default function ServiceCatalog() {
const partsPrice = Number(serviceForm.partsPrice) || 0; const partsPrice = Number(serviceForm.partsPrice) || 0;
const isSplitPricing = serviceForm.pricingMode === 'split'; const isSplitPricing = serviceForm.pricingMode === 'split';
const description = serviceForm.description.trim(); const description = serviceForm.description.trim();
const data = { const data = formatServiceTextFields({
name: serviceForm.name.trim(), name: serviceForm.name.trim(),
category: serviceForm.category.trim(), category: serviceForm.category.trim(),
description, description,
@@ -217,7 +224,7 @@ export default function ServiceCatalog() {
price: isSplitPricing ? laborPrice + partsPrice : fullPrice, price: isSplitPricing ? laborPrice + partsPrice : fullPrice,
active: editingService ? editingService.active : true, active: editingService ? editingService.active : true,
userId, userId,
}; }, settings.applyServiceFormattingToCatalog ? settings : { ...settings, serviceNameFormat: 'preserve', serviceDescriptionFormat: 'preserve', serviceRecommendationFormat: 'preserve', serviceCategoryFormat: 'preserve', trimServiceText: true, addPeriodToServiceDescriptions: false });
try { try {
if (editingService?.id) { if (editingService?.id) {
await pb.collection('services').update(editingService.id, data); await pb.collection('services').update(editingService.id, data);
@@ -332,7 +339,7 @@ export default function ServiceCatalog() {
<div className="mt-3"> <div className="mt-3">
<FormField label="Description"> <FormField label="Description">
<div className="space-y-2"> <div className="space-y-2">
<Textarea value={serviceForm.description} onChange={(value) => setServiceForm((prev) => ({ ...prev, description: value }))} placeholder="Brief description of the service..." rows={2} /> <Textarea value={serviceForm.description} onChange={(value) => setServiceForm((prev) => ({ ...prev, description: value }))} placeholder="Brief description of the service..." rows={2} {...serviceTextSpellCheck} />
<div className="flex justify-end"> <div className="flex justify-end">
<button <button
type="button" type="button"
+662 -37
View File
@@ -9,6 +9,7 @@ import LoadingSpinner from '../components/ui/LoadingSpinner';
import { DEFAULT_QUOTE_PDF_APPEARANCE, getRoleLabel, loadSettings, loadSettingsForUser, saveSettingsForUser, uploadLogoFile, removeLogoFile } from '../lib/settings'; import { DEFAULT_QUOTE_PDF_APPEARANCE, getRoleLabel, loadSettings, loadSettingsForUser, saveSettingsForUser, uploadLogoFile, removeLogoFile } from '../lib/settings';
import { useSettingsStore } from '../store/settings'; import { useSettingsStore } from '../store/settings';
import { parseRichText, hasRichText } from '../lib/richtext'; import { parseRichText, hasRichText } from '../lib/richtext';
import { spellCheckProps } from '../lib/spellcheck';
import { isMissingCollectionError, logError } from '../lib/userMessages'; import { isMissingCollectionError, logError } from '../lib/userMessages';
import { settingsWriteSchema, accountSettingsWriteSchema } from '../schemas/settings'; import { settingsWriteSchema, accountSettingsWriteSchema } from '../schemas/settings';
import { generateQuotePDF } from '../lib/pdf'; import { generateQuotePDF } from '../lib/pdf';
@@ -66,8 +67,13 @@ const TABS: SettingsTab[] = [
{ id: 'tax', label: 'Tax & Fees', group: 'Shop', description: 'Shop charges, tax rates, and pricing rules.', icon: DollarSign }, { id: 'tax', label: 'Tax & Fees', group: 'Shop', description: 'Shop charges, tax rates, and pricing rules.', icon: DollarSign },
{ id: 'quotes', label: 'Quote Defaults', group: 'Quotes', description: 'Default text and quote presentation settings.', icon: MessageSquareText }, { id: 'quotes', label: 'Quote Defaults', group: 'Quotes', description: 'Default text and quote presentation settings.', icon: MessageSquareText },
{ id: 'pdf', label: 'PDF & Branding', group: 'Quotes', description: 'Branding, logo, and exported document styling.', icon: FileImage }, { id: 'pdf', label: 'PDF & Branding', group: 'Quotes', description: 'Branding, logo, and exported document styling.', icon: FileImage },
{ id: 'workflow', label: 'Workflow', group: 'Operations', description: 'Default statuses and required fields for quotes and ROs.', icon: Check },
{ id: 'pricing', label: 'Pricing Rules', group: 'Operations', description: 'Labor rounding, parts markup, and mobile fee rules.', icon: DollarSign },
{ id: 'communication', label: 'Communication', group: 'Operations', description: 'Reusable SMS, email, and follow-up message templates.', icon: MessageSquareText },
{ id: 'roles', label: 'Roles & Permissions', group: 'Operations', description: 'Advisor and technician workflow permissions.', icon: Shield },
{ id: 'business-ops', label: 'Business Ops', group: 'Operations', description: 'Operational defaults used across the shop.', icon: Building2 }, { id: 'business-ops', label: 'Business Ops', group: 'Operations', description: 'Operational defaults used across the shop.', icon: Building2 },
{ id: 'notifications', label: 'Notifications', group: 'Preferences', description: 'Alerts, autosave, and appearance preferences.', icon: Bell }, { id: 'appearance', label: 'Appearance', group: 'Preferences', description: 'Landing page, theme, and layout density.', icon: Moon },
{ id: 'notifications', label: 'Notifications', group: 'Preferences', description: 'Alerts and autosave preferences.', icon: Bell },
]; ];
/* ── Helpers ────────────────────────────────────────── */ /* ── Helpers ────────────────────────────────────────── */
@@ -76,6 +82,18 @@ const TABS: SettingsTab[] = [
// one place across the app. // one place across the app.
const loadLocalSettings = loadSettings; const loadLocalSettings = loadSettings;
function prefersDarkTheme() {
return typeof window !== 'undefined' && window.matchMedia('(prefers-color-scheme: dark)').matches;
}
function applyThemeMode(theme: ShopSettings['appTheme']) {
const dark = theme === 'dark' || (theme === 'system' && prefersDarkTheme());
document.documentElement.classList.toggle('dark', dark);
localStorage.setItem('spq-dark-mode', String(dark));
localStorage.setItem('spq-theme-mode', theme);
return dark;
}
/* ── Sub-components ─────────────────────────────────── */ /* ── Sub-components ─────────────────────────────────── */
function SectionCard({ function SectionCard({
@@ -173,12 +191,16 @@ function Textarea({
placeholder, placeholder,
rows = 3, rows = 3,
hint, hint,
spellCheck,
lang,
}: { }: {
value: string; value: string;
onChange: (val: string) => void; onChange: (val: string) => void;
placeholder?: string; placeholder?: string;
rows?: number; rows?: number;
hint?: string; hint?: string;
spellCheck?: boolean;
lang?: string;
}) { }) {
return ( return (
<div> <div>
@@ -187,6 +209,8 @@ function Textarea({
onChange={(e) => onChange(e.target.value)} onChange={(e) => onChange(e.target.value)}
placeholder={placeholder} placeholder={placeholder}
rows={rows} rows={rows}
spellCheck={spellCheck}
lang={lang}
className="w-full rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 px-3 py-2 text-sm text-gray-900 dark:text-gray-100 placeholder-gray-400 dark:placeholder-gray-500 focus:border-green-500 focus:ring-2 focus:ring-green-500/20 focus:outline-none transition-colors resize-y" className="w-full rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 px-3 py-2 text-sm text-gray-900 dark:text-gray-100 placeholder-gray-400 dark:placeholder-gray-500 focus:border-green-500 focus:ring-2 focus:ring-green-500/20 focus:outline-none transition-colors resize-y"
/> />
{hint && <p className="mt-1 text-xs text-gray-400 dark:text-gray-500">{hint}</p>} {hint && <p className="mt-1 text-xs text-gray-400 dark:text-gray-500">{hint}</p>}
@@ -837,6 +861,239 @@ function BusinessOpsSection({
); );
} }
function DataEntryDefaultsSection({
settings,
updateSetting,
}: {
settings: ShopSettings;
updateSetting: <K extends keyof ShopSettings>(key: K, value: ShopSettings[K]) => void;
}) {
return (
<SectionCard title="Data Entry Defaults" icon={Check}>
<div className="space-y-5">
<div className="grid gap-4 sm:grid-cols-2">
<FormField label="Customer Name Formatting" hint="Smart title case helps prevent all-caps customer names on ROs and customer records.">
<SelectField value={settings.customerNameFormat} onChange={(v) => updateSetting('customerNameFormat', v as ShopSettings['customerNameFormat'])}>
<option value="preserve">Preserve typed</option>
<option value="smart_title">Smart title case</option>
<option value="uppercase">Force uppercase</option>
</SelectField>
</FormField>
<FormField label="Vehicle Info Formatting" hint="Applies to RO vehicle text and customer vehicle make/model fields.">
<SelectField value={settings.vehicleInfoFormat} onChange={(v) => updateSetting('vehicleInfoFormat', v as ShopSettings['vehicleInfoFormat'])}>
<option value="preserve">Preserve typed</option>
<option value="smart_title">Smart title case</option>
</SelectField>
</FormField>
<FormField label="License Plate Formatting" hint="Controls saved customer vehicle plate values.">
<SelectField value={settings.licensePlateFormat} onChange={(v) => updateSetting('licensePlateFormat', v as ShopSettings['licensePlateFormat'])}>
<option value="preserve">Preserve typed</option>
<option value="uppercase">Uppercase</option>
</SelectField>
</FormField>
<FormField label="Mileage Formatting" hint="Applies to RO, quote, and customer vehicle mileage fields when saved.">
<SelectField value={settings.mileageFormat} onChange={(v) => updateSetting('mileageFormat', v as ShopSettings['mileageFormat'])}>
<option value="preserve">Preserve typed</option>
<option value="digits_only">Digits only</option>
<option value="commas">Add commas</option>
</SelectField>
</FormField>
</div>
<div className="rounded-xl border border-gray-200 bg-gray-50 px-4 py-2 dark:border-gray-700 dark:bg-gray-800/50">
<Toggle
label="Uppercase VIN"
description="Store VINs in the standard uppercase format."
enabled={settings.vinUppercase}
onChange={(v) => updateSetting('vinUppercase', v)}
/>
<div className="border-t border-gray-200 dark:border-gray-700">
<Toggle
label="Create customer from RO"
description="Automatically save a customer record when creating a repair order for a new customer."
enabled={settings.autoCreateCustomerFromRO}
onChange={(v) => updateSetting('autoCreateCustomerFromRO', v)}
/>
</div>
<div className="border-t border-gray-200 dark:border-gray-700">
<Toggle
label="Sync customer edits to active ROs"
description="When customer info changes, update linked active repair orders."
enabled={settings.syncCustomerEditsToActiveROs}
onChange={(v) => updateSetting('syncCustomerEditsToActiveROs', v)}
/>
</div>
</div>
<FormField label="Duplicate Customer Warning" hint="Controls how aggressively new customer entry checks for existing customers.">
<SelectField value={settings.duplicateCustomerWarning} onChange={(v) => updateSetting('duplicateCustomerWarning', v as ShopSettings['duplicateCustomerWarning'])}>
<option value="name_phone">Name and phone</option>
<option value="phone_only">Phone only</option>
<option value="off">Off</option>
</SelectField>
</FormField>
<div className="border-t border-gray-200 pt-5 dark:border-gray-700">
<p className="mb-3 text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">
Browser Spell Check
</p>
<div className="grid gap-4 sm:grid-cols-2">
<FormField label="Spell Check Language" hint="Browser default uses the user's browser or operating-system dictionary.">
<SelectField value={settings.spellCheckLanguage} onChange={(v) => updateSetting('spellCheckLanguage', v as ShopSettings['spellCheckLanguage'])}>
<option value="browser">Browser default</option>
<option value="en-US">English (US)</option>
<option value="en-CA">English (Canada)</option>
<option value="en-GB">English (UK)</option>
</SelectField>
</FormField>
</div>
<div className="mt-4 rounded-xl border border-gray-200 bg-gray-50 px-4 py-2 dark:border-gray-700 dark:bg-gray-800/50">
<Toggle
label="Enable browser spell check"
description="Uses the browser's built-in red underline spell checker for supported text fields."
enabled={settings.spellCheckEnabled}
onChange={(v) => updateSetting('spellCheckEnabled', v)}
/>
<div className="border-t border-gray-200 dark:border-gray-700">
<Toggle
label="Spell check service text"
description="Checks service descriptions, recommendations, and catalog service text."
enabled={settings.spellCheckServiceText}
onChange={(v) => updateSetting('spellCheckServiceText', v)}
/>
</div>
<div className="border-t border-gray-200 dark:border-gray-700">
<Toggle
label="Spell check customer notes"
description="Checks customer, vehicle, appointment, and general note fields."
enabled={settings.spellCheckCustomerNotes}
onChange={(v) => updateSetting('spellCheckCustomerNotes', v)}
/>
</div>
<div className="border-t border-gray-200 dark:border-gray-700">
<Toggle
label="Spell check quote messages"
description="Checks quote headers, footers, payment terms, SMS, email, and follow-up templates."
enabled={settings.spellCheckQuoteMessages}
onChange={(v) => updateSetting('spellCheckQuoteMessages', v)}
/>
</div>
<div className="border-t border-gray-200 dark:border-gray-700">
<Toggle
label="Spell check internal RO notes"
description="Checks RO notes, service notes, and reminder notes."
enabled={settings.spellCheckInternalNotes}
onChange={(v) => updateSetting('spellCheckInternalNotes', v)}
/>
</div>
</div>
</div>
<div className="border-t border-gray-200 pt-5 dark:border-gray-700">
<p className="mb-3 text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">
Service Formatting
</p>
<div className="grid gap-4 sm:grid-cols-2">
<FormField label="Service Name Formatting" hint="Controls saved service names in catalog, quotes, and ROs.">
<SelectField value={settings.serviceNameFormat} onChange={(v) => updateSetting('serviceNameFormat', v as ShopSettings['serviceNameFormat'])}>
<option value="preserve">Preserve typed</option>
<option value="smart_title">Smart title case</option>
<option value="uppercase">Uppercase</option>
<option value="sentence">Sentence case</option>
</SelectField>
</FormField>
<FormField label="Service Category Formatting" hint="Controls saved service category names.">
<SelectField value={settings.serviceCategoryFormat} onChange={(v) => updateSetting('serviceCategoryFormat', v as ShopSettings['serviceCategoryFormat'])}>
<option value="preserve">Preserve typed</option>
<option value="smart_title">Smart title case</option>
<option value="uppercase">Uppercase</option>
</SelectField>
</FormField>
<FormField label="Service Description Formatting" hint="Smart cleanup trims spacing, sentence-cases text, uppercases part-like codes, and can add a period.">
<SelectField value={settings.serviceDescriptionFormat} onChange={(v) => updateSetting('serviceDescriptionFormat', v as ShopSettings['serviceDescriptionFormat'])}>
<option value="preserve">Preserve typed</option>
<option value="sentence">Sentence case</option>
<option value="smart_cleanup">Smart cleanup</option>
</SelectField>
</FormField>
<FormField label="Recommendation Formatting" hint="Controls why-recommended and recommendation text saved with services.">
<SelectField value={settings.serviceRecommendationFormat} onChange={(v) => updateSetting('serviceRecommendationFormat', v as ShopSettings['serviceRecommendationFormat'])}>
<option value="preserve">Preserve typed</option>
<option value="sentence">Sentence case</option>
<option value="smart_cleanup">Smart cleanup</option>
</SelectField>
</FormField>
</div>
<div className="mt-4 rounded-xl border border-gray-200 bg-gray-50 px-4 py-2 dark:border-gray-700 dark:bg-gray-800/50">
<Toggle
label="Trim extra service text spaces"
description="Removes leading/trailing spaces and collapses repeated spaces."
enabled={settings.trimServiceText}
onChange={(v) => updateSetting('trimServiceText', v)}
/>
<div className="border-t border-gray-200 dark:border-gray-700">
<Toggle
label="Add period to service descriptions"
description="Adds a final period to sentence-formatted descriptions and recommendations."
enabled={settings.addPeriodToServiceDescriptions}
onChange={(v) => updateSetting('addPeriodToServiceDescriptions', v)}
/>
</div>
<div className="border-t border-gray-200 dark:border-gray-700">
<Toggle
label="Preserve service acronyms"
description="Keeps terms like ABS, OEM, TPMS, VIN, and A/C uppercase during smart title case."
enabled={settings.preserveServiceAcronyms}
onChange={(v) => updateSetting('preserveServiceAcronyms', v)}
/>
</div>
<div className="border-t border-gray-200 dark:border-gray-700">
<Toggle
label="Uppercase part numbers"
description="Uppercases part-like tokens that contain both letters and numbers."
enabled={settings.uppercasePartNumbers}
onChange={(v) => updateSetting('uppercasePartNumbers', v)}
/>
</div>
</div>
<div className="mt-4 rounded-xl border border-gray-200 bg-gray-50 px-4 py-2 dark:border-gray-700 dark:bg-gray-800/50">
<Toggle
label="Apply to catalog services"
description="Format services when they are saved to the reusable service catalog."
enabled={settings.applyServiceFormattingToCatalog}
onChange={(v) => updateSetting('applyServiceFormattingToCatalog', v)}
/>
<div className="border-t border-gray-200 dark:border-gray-700">
<Toggle
label="Apply to quote services"
description="Format service text when quote services are added or edited."
enabled={settings.applyServiceFormattingToQuotes}
onChange={(v) => updateSetting('applyServiceFormattingToQuotes', v)}
/>
</div>
<div className="border-t border-gray-200 dark:border-gray-700">
<Toggle
label="Apply to RO services"
description="Format service text when RO services are saved."
enabled={settings.applyServiceFormattingToROs}
onChange={(v) => updateSetting('applyServiceFormattingToROs', v)}
/>
</div>
</div>
</div>
</div>
</SectionCard>
);
}
/* ── Main Settings Component ────────────────────────── */ /* ── Main Settings Component ────────────────────────── */
export default function Settings() { export default function Settings() {
@@ -953,6 +1210,7 @@ export default function Settings() {
const merged = await loadSettingsForUser(userId); const merged = await loadSettingsForUser(userId);
setSettings(merged); setSettings(merged);
useSettingsStore.getState().setSettings(merged); useSettingsStore.getState().setSettings(merged);
setDarkMode(applyThemeMode(merged.appTheme));
// Load advisors // Load advisors
await loadAdvisors(); await loadAdvisors();
@@ -1166,12 +1424,10 @@ export default function Settings() {
/* ── Dark mode handler ────────────────────────── */ /* ── Dark mode handler ────────────────────────── */
const handleDarkModeToggle = useCallback(() => { const handleThemeChange = useCallback((theme: ShopSettings['appTheme']) => {
const next = !darkMode; updateSetting('appTheme', theme);
setDarkMode(next); setDarkMode(applyThemeMode(theme));
document.documentElement.classList.toggle('dark', next); }, [updateSetting]);
localStorage.setItem('spq-dark-mode', String(next));
}, [darkMode]);
/* ── Notifications save ───────────────────────── */ /* ── Notifications save ───────────────────────── */
@@ -1243,6 +1499,7 @@ export default function Settings() {
const showsGlobalSave = activeTab !== 'notifications'; const showsGlobalSave = activeTab !== 'notifications';
const handlePrimarySave = activeTab === 'account' ? saveAccountSettings : saveAllSettings; const handlePrimarySave = activeTab === 'account' ? saveAccountSettings : saveAllSettings;
const primarySaveLabel = activeTab === 'account' ? 'Save Account' : 'Save'; const primarySaveLabel = activeTab === 'account' ? 'Save Account' : 'Save';
const quoteMessageSpellCheck = spellCheckProps(settings, settings.spellCheckQuoteMessages);
const tabNavigation = ( const tabNavigation = (
<aside className="flex flex-col h-full"> <aside className="flex flex-col h-full">
@@ -1580,6 +1837,14 @@ export default function Settings() {
{activeTab === 'tax' && ( {activeTab === 'tax' && (
<SectionCard title="Tax & Fees" icon={DollarSign}> <SectionCard title="Tax & Fees" icon={DollarSign}>
<div className="space-y-5"> <div className="space-y-5">
<div className="rounded-xl border border-gray-200 bg-gray-50 px-4 py-2 dark:border-gray-700 dark:bg-gray-800/50">
<Toggle
label="Enable tax by default"
description="Apply the configured tax rate to quotes and repair orders. Turn this off to keep the rate saved but calculate tax as $0."
enabled={settings.taxEnabled}
onChange={(v) => updateSetting('taxEnabled', v)}
/>
</div>
<div className="grid gap-4 sm:grid-cols-2"> <div className="grid gap-4 sm:grid-cols-2">
<FormField label="Tax Rate (%)"> <FormField label="Tax Rate (%)">
<Input <Input
@@ -1624,6 +1889,7 @@ export default function Settings() {
onChange={(v) => updateSetting('shopChargeExplanation', v)} onChange={(v) => updateSetting('shopChargeExplanation', v)}
placeholder="e.g. A 3% shop charge is added to cover shop supplies..." placeholder="e.g. A 3% shop charge is added to cover shop supplies..."
rows={3} rows={3}
{...quoteMessageSpellCheck}
/> />
</FormField> </FormField>
{!isModal && ( {!isModal && (
@@ -1655,6 +1921,7 @@ export default function Settings() {
onChange={(v) => updateSetting('headerMessage', v)} onChange={(v) => updateSetting('headerMessage', v)}
placeholder="Hi {customerName}, here is the explanation..." placeholder="Hi {customerName}, here is the explanation..."
rows={3} rows={3}
{...quoteMessageSpellCheck}
/> />
</FormField> </FormField>
<FormField label="Footer Message" hint="Wrap text in {accent}…{/accent}, {muted}…{/muted}, or {bold}…{/bold} to style a portion of it."> <FormField label="Footer Message" hint="Wrap text in {accent}…{/accent}, {muted}…{/muted}, or {bold}…{/bold} to style a portion of it.">
@@ -1663,6 +1930,7 @@ export default function Settings() {
onChange={(v) => updateSetting('footerMessage', v)} onChange={(v) => updateSetting('footerMessage', v)}
placeholder="Thank you for choosing our shop!" placeholder="Thank you for choosing our shop!"
rows={2} rows={2}
{...quoteMessageSpellCheck}
/> />
<RichTextPreview text={settings.footerMessage} accentHex={settings.pdfAccentColor} /> <RichTextPreview text={settings.footerMessage} accentHex={settings.pdfAccentColor} />
</FormField> </FormField>
@@ -1672,9 +1940,68 @@ export default function Settings() {
onChange={(v) => updateSetting('quoteFooterNote', v)} onChange={(v) => updateSetting('quoteFooterNote', v)}
placeholder="All prices include parts and labor unless otherwise noted." placeholder="All prices include parts and labor unless otherwise noted."
rows={2} rows={2}
{...quoteMessageSpellCheck}
/> />
<RichTextPreview text={settings.quoteFooterNote} accentHex={settings.pdfAccentColor} /> <RichTextPreview text={settings.quoteFooterNote} accentHex={settings.pdfAccentColor} />
</FormField> </FormField>
<div className="border-t border-gray-200 pt-5 dark:border-gray-700">
<p className="mb-3 text-xs font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">
AI Write
</p>
<div className="grid gap-4 sm:grid-cols-2">
<FormField label="AI Write Tone" hint="Controls the overall voice used for customer-facing explanations.">
<SelectField
value={settings.aiWriteTone}
onChange={(v) => updateSetting('aiWriteTone', v as ShopSettings['aiWriteTone'])}
>
<option value="professional">Professional</option>
<option value="friendly">Friendly</option>
<option value="concise">Concise</option>
<option value="detailed">Detailed</option>
</SelectField>
</FormField>
<FormField label="Explanation Length" hint="Sets the typical sentence count for AI-generated explanations.">
<SelectField
value={settings.aiWriteLength}
onChange={(v) => updateSetting('aiWriteLength', v as ShopSettings['aiWriteLength'])}
>
<option value="short">Short</option>
<option value="standard">Standard</option>
<option value="detailed">Detailed</option>
</SelectField>
</FormField>
<FormField label="Safety Wording" hint="Controls how strongly risk and urgency are phrased when facts support it.">
<SelectField
value={settings.aiWriteSafetyWording}
onChange={(v) => updateSetting('aiWriteSafetyWording', v as ShopSettings['aiWriteSafetyWording'])}
>
<option value="conservative">Conservative</option>
<option value="balanced">Balanced</option>
<option value="direct">Direct</option>
</SelectField>
</FormField>
<div className="rounded-xl border border-gray-200 bg-gray-50 px-4 py-2 dark:border-gray-700 dark:bg-gray-800/50">
<Toggle
label="Use vehicle and mileage context"
description="Allow AI Write to use vehicle and mileage only when directly relevant."
enabled={settings.aiWriteUseVehicleContext}
onChange={(v) => updateSetting('aiWriteUseVehicleContext', v)}
/>
<div className="border-t border-gray-200 dark:border-gray-700">
<Toggle
label="Auto-set service priority"
description="Allow AI Write to update the service priority badge."
enabled={settings.aiWriteAutoSetPriority}
onChange={(v) => updateSetting('aiWriteAutoSetPriority', v)}
/>
</div>
</div>
</div>
</div>
{!isModal && ( {!isModal && (
<div className="flex justify-end pt-2"> <div className="flex justify-end pt-2">
<button <button
@@ -1709,6 +2036,277 @@ export default function Settings() {
</div> </div>
)} )}
{/* ─── Workflow Tab ───────────────────────────── */}
{activeTab === 'workflow' && (
<SectionCard title="Workflow Defaults" icon={Check}>
<div className="space-y-5">
<div className="grid gap-4 sm:grid-cols-2">
<FormField label="Default Quote Status" hint="Status assigned when a new quote is created.">
<SelectField value={settings.defaultQuoteStatus} onChange={(v) => updateSetting('defaultQuoteStatus', v as ShopSettings['defaultQuoteStatus'])}>
<option value="draft">Draft</option>
<option value="sent">Ready / Sent</option>
</SelectField>
</FormField>
<FormField label="Default RO Status" hint="Status assigned when a new repair order is created.">
<SelectField value={settings.defaultROStatus} onChange={(v) => updateSetting('defaultROStatus', v as ShopSettings['defaultROStatus'])}>
<option value="active">Active</option>
<option value="in_progress">In Progress</option>
<option value="waiting_parts">Waiting Parts</option>
</SelectField>
</FormField>
</div>
<div className="rounded-xl border border-gray-200 bg-gray-50 px-4 py-2 dark:border-gray-700 dark:bg-gray-800/50">
<Toggle
label="Require advisor on quotes"
description="Warn before saving a quote without a service advisor."
enabled={settings.requireAdvisorForQuote}
onChange={(v) => updateSetting('requireAdvisorForQuote', v)}
/>
<div className="border-t border-gray-200 dark:border-gray-700">
<Toggle
label="Require mileage on ROs"
description="Use this for shops that need mileage captured before an RO is saved."
enabled={settings.requireMileageForRO}
onChange={(v) => updateSetting('requireMileageForRO', v)}
/>
</div>
<div className="border-t border-gray-200 dark:border-gray-700">
<Toggle
label="Require VIN on ROs"
description="Use this when RO paperwork should always include the VIN."
enabled={settings.requireVinForRO}
onChange={(v) => updateSetting('requireVinForRO', v)}
/>
</div>
<div className="border-t border-gray-200 dark:border-gray-700">
<Toggle
label="Require license plate on ROs"
description="Use this when plate number is part of your intake process."
enabled={settings.requirePlateForRO}
onChange={(v) => updateSetting('requirePlateForRO', v)}
/>
</div>
</div>
</div>
</SectionCard>
)}
{/* ─── Pricing Rules Tab ───────────────────────── */}
{activeTab === 'pricing' && (
<SectionCard title="Pricing Rules" icon={DollarSign}>
<div className="space-y-5">
<div className="grid gap-4 sm:grid-cols-2">
<FormField label="Labor Rounding" hint="Controls how calculated labor hours should be rounded when pricing rules are applied.">
<SelectField value={settings.laborRounding} onChange={(v) => updateSetting('laborRounding', v as ShopSettings['laborRounding'])}>
<option value="exact">Exact</option>
<option value="nearest_0_1">Nearest 0.1 hour</option>
<option value="nearest_0_25">Nearest 0.25 hour</option>
<option value="nearest_0_5">Nearest 0.5 hour</option>
</SelectField>
</FormField>
<FormField label="Parts Markup (%)" hint="Default markup to apply when turning part cost into sell price.">
<Input
type="number"
value={settings.partsMarkupPercent}
onChange={(v) => updateSetting('partsMarkupPercent', Number(v) || 0)}
prefix={<Percent className="h-4 w-4" />}
/>
</FormField>
<FormField label="Travel Fee Mode" hint="How mobile-service travel fees should be calculated.">
<SelectField value={settings.travelFeeMode} onChange={(v) => updateSetting('travelFeeMode', v as ShopSettings['travelFeeMode'])}>
<option value="flat">Flat fee</option>
<option value="per_mile">Per mile</option>
<option value="zone">Zone based</option>
</SelectField>
</FormField>
<FormField label="Default Travel Fee" hint="Base value used with the selected travel-fee mode.">
<Input
type="number"
value={settings.defaultTravelFee}
onChange={(v) => updateSetting('defaultTravelFee', Number(v) || 0)}
prefix={<DollarIcon className="h-4 w-4" />}
/>
</FormField>
</div>
</div>
</SectionCard>
)}
{/* ─── Communication Tab ───────────────────────── */}
{activeTab === 'communication' && (
<SectionCard title="Communication Templates" icon={MessageSquareText}>
<div className="space-y-5">
<FormField label="Default SMS Template" hint="Available placeholders: {customerName}, {vehicleInfo}, {quoteLink}, {businessName}.">
<Textarea
value={settings.defaultSmsTemplate}
onChange={(v) => updateSetting('defaultSmsTemplate', v)}
rows={3}
{...quoteMessageSpellCheck}
/>
</FormField>
<FormField label="Default Email Template" hint="Used as the starting point for quote or RO customer emails.">
<Textarea
value={settings.defaultEmailTemplate}
onChange={(v) => updateSetting('defaultEmailTemplate', v)}
rows={5}
{...quoteMessageSpellCheck}
/>
</FormField>
<FormField label="Quote Follow-Up Template" hint="Used when following up on a quote that has not been approved yet.">
<Textarea
value={settings.quoteFollowUpTemplate}
onChange={(v) => updateSetting('quoteFollowUpTemplate', v)}
rows={4}
{...quoteMessageSpellCheck}
/>
</FormField>
</div>
</SectionCard>
)}
{/* ─── Roles & Permissions Tab ────────────────── */}
{activeTab === 'roles' && (
<div className="space-y-6">
<SectionCard title="Technician Access" icon={Wrench}>
<div className="space-y-1 divide-y divide-gray-200 rounded-xl border border-gray-200 bg-gray-50 px-4 py-2 dark:divide-gray-700 dark:border-gray-700 dark:bg-gray-800/50">
<Toggle
label="Technicians see only assigned ROs"
description="Keeps technician dashboards focused on work assigned to them."
enabled={settings.techniciansSeeOnlyAssignedROs}
onChange={(v) => updateSetting('techniciansSeeOnlyAssignedROs', v)}
/>
<Toggle
label="Technicians can add recommendations"
description="Allows techs to add new recommended work from their workflow."
enabled={settings.techniciansCanAddRecommendations}
onChange={(v) => updateSetting('techniciansCanAddRecommendations', v)}
/>
<Toggle
label="Technicians can mark services complete"
description="Allows techs to update service completion status."
enabled={settings.techniciansCanMarkServicesComplete}
onChange={(v) => updateSetting('techniciansCanMarkServicesComplete', v)}
/>
<Toggle
label="Technicians can upload photos"
description="Allows inspection or job photos to be attached from technician screens."
enabled={settings.techniciansCanUploadPhotos}
onChange={(v) => updateSetting('techniciansCanUploadPhotos', v)}
/>
<Toggle
label="Technicians can add internal notes"
description="Allows private notes that are not intended for customer-facing documents."
enabled={settings.techniciansCanAddInternalNotes}
onChange={(v) => updateSetting('techniciansCanAddInternalNotes', v)}
/>
<Toggle
label="Technicians can see prices"
description="Shows quote and RO prices in technician-facing workflows."
enabled={settings.techniciansCanSeePrices}
onChange={(v) => updateSetting('techniciansCanSeePrices', v)}
/>
<Toggle
label="Allow technicians to edit prices"
description="When enabled, technician-facing workflows may allow labor, parts, or service prices to be changed."
enabled={settings.technicianPriceEditingEnabled}
onChange={(v) => updateSetting('technicianPriceEditingEnabled', v)}
/>
<Toggle
label="Technicians can see customer contact info"
description="Shows customer phone, email, and address to technicians when available."
enabled={settings.techniciansCanSeeCustomerContact}
onChange={(v) => updateSetting('techniciansCanSeeCustomerContact', v)}
/>
<Toggle
label="Technicians can see declined work"
description="Shows declined or postponed services in technician views."
enabled={settings.techniciansCanSeeDeclinedWork}
onChange={(v) => updateSetting('techniciansCanSeeDeclinedWork', v)}
/>
</div>
</SectionCard>
<SectionCard title="Advisor Access" icon={User}>
<div className="space-y-5">
<div className="space-y-1 divide-y divide-gray-200 rounded-xl border border-gray-200 bg-gray-50 px-4 py-2 dark:divide-gray-700 dark:border-gray-700 dark:bg-gray-800/50">
<Toggle
label="Advisors can apply discounts"
description="Allows advisor users to discount quotes or repair orders."
enabled={settings.advisorsCanApplyDiscounts}
onChange={(v) => updateSetting('advisorsCanApplyDiscounts', v)}
/>
<Toggle
label="Require advisor approval before sending quotes"
description="Keeps technician-created or draft quotes from being sent without advisor review."
enabled={settings.requireAdvisorApprovalBeforeSendingQuote}
onChange={(v) => updateSetting('requireAdvisorApprovalBeforeSendingQuote', v)}
/>
</div>
<FormField label="Max Advisor Discount (%)" hint="The largest discount an advisor should be allowed to apply without owner intervention.">
<Input
type="number"
value={settings.maxAdvisorDiscountPercent}
onChange={(v) => updateSetting('maxAdvisorDiscountPercent', Number(v) || 0)}
prefix={<Percent className="h-4 w-4" />}
/>
</FormField>
</div>
</SectionCard>
<SectionCard title="Pricing & Approval" icon={DollarSign}>
<div className="space-y-1 divide-y divide-gray-200 rounded-xl border border-gray-200 bg-gray-50 px-4 py-2 dark:divide-gray-700 dark:border-gray-700 dark:bg-gray-800/50">
<Toggle
label="Require approval for price overrides"
description="Flags manual price changes for owner or advisor approval."
enabled={settings.requireApprovalForPriceOverrides}
onChange={(v) => updateSetting('requireApprovalForPriceOverrides', v)}
/>
<Toggle
label="Hide cost and profit from non-owners"
description="Keeps margin-sensitive fields out of advisor and technician views."
enabled={settings.hideCostProfitFromNonOwners}
onChange={(v) => updateSetting('hideCostProfitFromNonOwners', v)}
/>
<Toggle
label="Show labor and parts split on customer PDFs"
description="Displays labor and parts separately on customer-facing PDFs when split pricing is available."
enabled={settings.showLaborPartsSplitOnPdf}
onChange={(v) => updateSetting('showLaborPartsSplitOnPdf', v)}
/>
</div>
</SectionCard>
<SectionCard title="Catalog Control" icon={Wrench}>
<div className="space-y-1 divide-y divide-gray-200 rounded-xl border border-gray-200 bg-gray-50 px-4 py-2 dark:divide-gray-700 dark:border-gray-700 dark:bg-gray-800/50">
<Toggle
label="Owner-only catalog deletion"
description="Prevents non-owner users from deleting reusable service catalog records."
enabled={settings.ownerOnlyCatalogDeletion}
onChange={(v) => updateSetting('ownerOnlyCatalogDeletion', v)}
/>
</div>
</SectionCard>
<SectionCard title="Audit Rules" icon={Shield}>
<div className="space-y-1 divide-y divide-gray-200 rounded-xl border border-gray-200 bg-gray-50 px-4 py-2 dark:divide-gray-700 dark:border-gray-700 dark:bg-gray-800/50">
<Toggle
label="Require reason for price changes"
description="Prompts users to explain manual changes to approved or quoted prices."
enabled={settings.requireReasonForPriceChange}
onChange={(v) => updateSetting('requireReasonForPriceChange', v)}
/>
<Toggle
label="Require reason when deleting services"
description="Prompts users to explain why a service was removed from a quote or RO."
enabled={settings.requireReasonForDeletingService}
onChange={(v) => updateSetting('requireReasonForDeletingService', v)}
/>
</div>
</SectionCard>
</div>
)}
{/* ─── Business Ops Tab ───────────────────────── */} {/* ─── Business Ops Tab ───────────────────────── */}
{activeTab === 'business-ops' && ( {activeTab === 'business-ops' && (
<div className="space-y-6"> <div className="space-y-6">
@@ -1719,6 +2317,10 @@ export default function Settings() {
saving={saving} saving={saving}
isModal={isModal} isModal={isModal}
/> />
<DataEntryDefaultsSection
settings={settings}
updateSetting={updateSetting}
/>
<SectionCard title="Service Catalog" icon={Wrench}> <SectionCard title="Service Catalog" icon={Wrench}>
<div className="rounded-xl border border-dashed border-gray-300 bg-gray-50 p-4 dark:border-gray-700 dark:bg-gray-800/50"> <div className="rounded-xl border border-dashed border-gray-300 bg-gray-50 p-4 dark:border-gray-700 dark:bg-gray-800/50">
<p className="text-sm font-medium text-gray-900 dark:text-gray-100">Service Catalog now lives in its own workspace.</p> <p className="text-sm font-medium text-gray-900 dark:text-gray-100">Service Catalog now lives in its own workspace.</p>
@@ -1737,6 +2339,59 @@ export default function Settings() {
</div> </div>
)} )}
{/* ─── Appearance Tab ─────────────────────────── */}
{activeTab === 'appearance' && (
<SectionCard title="Appearance" icon={Moon}>
<div className="space-y-5">
<div className="grid gap-4 sm:grid-cols-2">
<FormField label="Default Landing Page" hint="Where owner/advisor users go when opening the app root.">
<SelectField value={settings.defaultLandingPage} onChange={(v) => updateSetting('defaultLandingPage', v as ShopSettings['defaultLandingPage'])}>
<option value="/">Dashboard</option>
<option value="/quote">Quote Generator</option>
<option value="/repair-orders">Repair Orders</option>
<option value="/customers">Customers</option>
<option value="/appointments">Appointments</option>
<option value="/financial">Financial</option>
</SelectField>
</FormField>
<FormField label="Theme" hint="System follows the device/browser preference.">
<SelectField value={settings.appTheme} onChange={(v) => handleThemeChange(v as ShopSettings['appTheme'])}>
<option value="system">System</option>
<option value="light">Light</option>
<option value="dark">Dark</option>
</SelectField>
</FormField>
<FormField label="Layout Density" hint="Compact mode gives data-heavy screens more room.">
<SelectField value={settings.appDensity} onChange={(v) => updateSetting('appDensity', v as ShopSettings['appDensity'])}>
<option value="comfortable">Comfortable</option>
<option value="compact">Compact</option>
</SelectField>
</FormField>
</div>
<div className="rounded-lg bg-gray-50 dark:bg-gray-750 border border-gray-200 dark:border-gray-700 p-3">
<div className="flex items-center gap-2 text-sm">
<Moon className="h-4 w-4 text-gray-400" />
<span className="text-gray-600 dark:text-gray-400">
Current rendered theme:{' '}
<span className="font-medium text-gray-900 dark:text-gray-100">
{darkMode ? 'Dark' : 'Light'}
</span>
</span>
<span
className={`ml-auto inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ${
darkMode
? 'bg-indigo-100 text-indigo-700 dark:bg-indigo-900/30 dark:text-indigo-300'
: 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300'
}`}
>
{settings.appTheme === 'system' ? `System (${darkMode ? 'Dark' : 'Light'})` : settings.appTheme}
</span>
</div>
</div>
</div>
</SectionCard>
)}
{/* ─── Notifications Tab ──────────────────────── */} {/* ─── Notifications Tab ──────────────────────── */}
{activeTab === 'notifications' && ( {activeTab === 'notifications' && (
<div className="space-y-6"> <div className="space-y-6">
@@ -1766,36 +2421,6 @@ export default function Settings() {
</div> </div>
</SectionCard> </SectionCard>
<SectionCard title="Appearance" icon={Moon}>
<div className="space-y-4">
<Toggle
label="Dark Mode"
description="Toggle dark mode for the entire application"
enabled={darkMode}
onChange={handleDarkModeToggle}
/>
<div className="rounded-lg bg-gray-50 dark:bg-gray-750 border border-gray-200 dark:border-gray-700 p-3">
<div className="flex items-center gap-2 text-sm">
<Moon className="h-4 w-4 text-gray-400" />
<span className="text-gray-600 dark:text-gray-400">
Current theme:{' '}
<span className="font-medium text-gray-900 dark:text-gray-100">
{darkMode ? 'Dark' : 'Light'}
</span>
</span>
<span
className={`ml-auto inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ${
darkMode
? 'bg-indigo-100 text-indigo-700 dark:bg-indigo-900/30 dark:text-indigo-300'
: 'bg-amber-100 text-amber-700 dark:bg-amber-900/30 dark:text-amber-300'
}`}
>
{darkMode ? 'Dark' : 'Light'}
</span>
</div>
</div>
</div>
</SectionCard>
</div> </div>
)} )}
</> </>
+64
View File
@@ -6,6 +6,7 @@ export const settingsWriteSchema = z.object({
businessAddress: z.string().optional().default(''), businessAddress: z.string().optional().default(''),
businessPhone: z.string().min(1, 'Business phone is required'), businessPhone: z.string().min(1, 'Business phone is required'),
serviceAdvisor: z.string().min(1, 'Service advisor name is required'), serviceAdvisor: z.string().min(1, 'Service advisor name is required'),
taxEnabled: z.boolean().optional().default(true),
taxRate: z.number().nonnegative().default(0), taxRate: z.number().nonnegative().default(0),
taxLabel: z.string().optional().default('Tax'), taxLabel: z.string().optional().default('Tax'),
shopChargeRate: z.number().nonnegative().default(3), shopChargeRate: z.number().nonnegative().default(3),
@@ -26,6 +27,69 @@ export const settingsWriteSchema = z.object({
serviceLocationFieldsEnabled: z.boolean().optional().default(false), serviceLocationFieldsEnabled: z.boolean().optional().default(false),
defaultTravelFee: z.number().nonnegative().default(0), defaultTravelFee: z.number().nonnegative().default(0),
travelFeeExplanation: z.string().optional().default(''), travelFeeExplanation: z.string().optional().default(''),
aiWriteTone: z.enum(['professional', 'friendly', 'concise', 'detailed']).optional().default('professional'),
aiWriteLength: z.enum(['short', 'standard', 'detailed']).optional().default('standard'),
aiWriteUseVehicleContext: z.boolean().optional().default(true),
aiWriteSafetyWording: z.enum(['conservative', 'balanced', 'direct']).optional().default('balanced'),
aiWriteAutoSetPriority: z.boolean().optional().default(true),
customerNameFormat: z.enum(['preserve', 'smart_title', 'uppercase']).optional().default('smart_title'),
vehicleInfoFormat: z.enum(['preserve', 'smart_title']).optional().default('smart_title'),
serviceNameFormat: z.enum(['preserve', 'smart_title', 'uppercase', 'sentence']).optional().default('smart_title'),
serviceDescriptionFormat: z.enum(['preserve', 'sentence', 'smart_cleanup']).optional().default('smart_cleanup'),
serviceRecommendationFormat: z.enum(['preserve', 'sentence', 'smart_cleanup']).optional().default('smart_cleanup'),
serviceCategoryFormat: z.enum(['preserve', 'smart_title', 'uppercase']).optional().default('smart_title'),
trimServiceText: z.boolean().optional().default(true),
addPeriodToServiceDescriptions: z.boolean().optional().default(true),
preserveServiceAcronyms: z.boolean().optional().default(true),
uppercasePartNumbers: z.boolean().optional().default(true),
applyServiceFormattingToCatalog: z.boolean().optional().default(true),
applyServiceFormattingToQuotes: z.boolean().optional().default(true),
applyServiceFormattingToROs: z.boolean().optional().default(true),
spellCheckEnabled: z.boolean().optional().default(true),
spellCheckLanguage: z.enum(['browser', 'en-US', 'en-CA', 'en-GB']).optional().default('browser'),
spellCheckServiceText: z.boolean().optional().default(true),
spellCheckCustomerNotes: z.boolean().optional().default(true),
spellCheckQuoteMessages: z.boolean().optional().default(true),
spellCheckInternalNotes: z.boolean().optional().default(true),
vinUppercase: z.boolean().optional().default(true),
licensePlateFormat: z.enum(['preserve', 'uppercase']).optional().default('uppercase'),
mileageFormat: z.enum(['preserve', 'digits_only', 'commas']).optional().default('commas'),
autoCreateCustomerFromRO: z.boolean().optional().default(true),
syncCustomerEditsToActiveROs: z.boolean().optional().default(true),
duplicateCustomerWarning: z.enum(['name_phone', 'phone_only', 'off']).optional().default('name_phone'),
defaultLandingPage: z.enum(['/', '/quote', '/repair-orders', '/customers', '/appointments', '/financial']).optional().default('/'),
appTheme: z.enum(['light', 'dark', 'system']).optional().default('system'),
appDensity: z.enum(['comfortable', 'compact']).optional().default('comfortable'),
defaultQuoteStatus: z.enum(['draft', 'sent']).optional().default('draft'),
defaultROStatus: z.enum(['active', 'in_progress', 'waiting_parts']).optional().default('active'),
requireMileageForRO: z.boolean().optional().default(false),
requireVinForRO: z.boolean().optional().default(false),
requirePlateForRO: z.boolean().optional().default(false),
requireAdvisorForQuote: z.boolean().optional().default(true),
laborRounding: z.enum(['exact', 'nearest_0_1', 'nearest_0_25', 'nearest_0_5']).optional().default('exact'),
partsMarkupPercent: z.number().nonnegative().default(0),
travelFeeMode: z.enum(['flat', 'per_mile', 'zone']).optional().default('flat'),
defaultSmsTemplate: z.string().optional().default(''),
defaultEmailTemplate: z.string().optional().default(''),
quoteFollowUpTemplate: z.string().optional().default(''),
showLaborPartsSplitOnPdf: z.boolean().optional().default(false),
technicianPriceEditingEnabled: z.boolean().optional().default(false),
techniciansSeeOnlyAssignedROs: z.boolean().optional().default(true),
techniciansCanAddRecommendations: z.boolean().optional().default(true),
techniciansCanMarkServicesComplete: z.boolean().optional().default(true),
techniciansCanUploadPhotos: z.boolean().optional().default(true),
techniciansCanAddInternalNotes: z.boolean().optional().default(true),
techniciansCanSeePrices: z.boolean().optional().default(true),
techniciansCanSeeCustomerContact: z.boolean().optional().default(false),
techniciansCanSeeDeclinedWork: z.boolean().optional().default(true),
advisorsCanApplyDiscounts: z.boolean().optional().default(true),
maxAdvisorDiscountPercent: z.number().nonnegative().default(10),
requireApprovalForPriceOverrides: z.boolean().optional().default(true),
requireAdvisorApprovalBeforeSendingQuote: z.boolean().optional().default(false),
hideCostProfitFromNonOwners: z.boolean().optional().default(true),
ownerOnlyCatalogDeletion: z.boolean().optional().default(true),
requireReasonForPriceChange: z.boolean().optional().default(true),
requireReasonForDeletingService: z.boolean().optional().default(true),
}); });
export type SettingsWrite = z.infer<typeof settingsWriteSchema>; export type SettingsWrite = z.infer<typeof settingsWriteSchema>;
+14 -4
View File
@@ -3,6 +3,7 @@ import { persist } from 'zustand/middleware';
import type { QuoteService, CustomerInfo, QuoteAuthorizationMethod } from '../types'; import type { QuoteService, CustomerInfo, QuoteAuthorizationMethod } from '../types';
import { loadSettings } from '../lib/settings'; import { loadSettings } from '../lib/settings';
import { billableServices } from '../lib/totals'; import { billableServices } from '../lib/totals';
import { formatServiceTextFields } from '../lib/dataFormatting';
interface QuoteState { interface QuoteState {
customerInfo: CustomerInfo; customerInfo: CustomerInfo;
@@ -61,7 +62,9 @@ export const useQuoteStore = create<QuoteState>()(
addService: (service) => set((s) => { addService: (service) => set((s) => {
if (s.services.find((x) => x.id === service.id)) return s; if (s.services.find((x) => x.id === service.id)) return s;
return { services: [...s.services, service] }; const settings = loadSettings();
const nextService = settings.applyServiceFormattingToQuotes ? formatServiceTextFields(service, settings) : service;
return { services: [...s.services, nextService] };
}), }),
removeService: (id) => set((s) => ({ services: s.services.filter((x) => x.id !== id) })), removeService: (id) => set((s) => ({ services: s.services.filter((x) => x.id !== id) })),
@@ -102,9 +105,16 @@ export const useQuoteStore = create<QuoteState>()(
}), }),
})), })),
updateService: (id, updates) => set((s) => ({ updateService: (id, updates) => set((s) => {
services: s.services.map((x) => (x.id === id ? { ...x, ...updates } : x)), const settings = loadSettings();
})), return {
services: s.services.map((x) => {
if (x.id !== id) return x;
const next = { ...x, ...updates };
return settings.applyServiceFormattingToQuotes ? formatServiceTextFields(next, settings) : next;
}),
};
}),
clearServices: () => set({ services: [] }), clearServices: () => set({ services: [] }),
setDiscount: (value, type) => set({ discount: { value, type } }), setDiscount: (value, type) => set({ discount: { value, type } }),
+66
View File
@@ -171,6 +171,7 @@ export interface ShopSettings {
businessAddress: string; businessAddress: string;
businessPhone: string; businessPhone: string;
serviceAdvisor: string; serviceAdvisor: string;
taxEnabled: boolean;
taxRate: number; taxRate: number;
shopChargeRate: number; shopChargeRate: number;
shopChargeCap: number; // max dollar amount for shop charge (default 69.95) shopChargeCap: number; // max dollar amount for shop charge (default 69.95)
@@ -199,6 +200,71 @@ export interface ShopSettings {
showAdvisorOnQuote: boolean; // show advisor name on PDF quotes showAdvisorOnQuote: boolean; // show advisor name on PDF quotes
showTechnicianOnQuote: boolean; // show technician name on PDF quotes showTechnicianOnQuote: boolean; // show technician name on PDF quotes
defaultTechnician: string; // default technician name used on quotes defaultTechnician: string; // default technician name used on quotes
// ── AI Write Options ──
aiWriteTone: 'professional' | 'friendly' | 'concise' | 'detailed';
aiWriteLength: 'short' | 'standard' | 'detailed';
aiWriteUseVehicleContext: boolean;
aiWriteSafetyWording: 'conservative' | 'balanced' | 'direct';
aiWriteAutoSetPriority: boolean;
// ── Data Entry Formatting ──
customerNameFormat: 'preserve' | 'smart_title' | 'uppercase';
vehicleInfoFormat: 'preserve' | 'smart_title';
serviceNameFormat: 'preserve' | 'smart_title' | 'uppercase' | 'sentence';
serviceDescriptionFormat: 'preserve' | 'sentence' | 'smart_cleanup';
serviceRecommendationFormat: 'preserve' | 'sentence' | 'smart_cleanup';
serviceCategoryFormat: 'preserve' | 'smart_title' | 'uppercase';
trimServiceText: boolean;
addPeriodToServiceDescriptions: boolean;
preserveServiceAcronyms: boolean;
uppercasePartNumbers: boolean;
applyServiceFormattingToCatalog: boolean;
applyServiceFormattingToQuotes: boolean;
applyServiceFormattingToROs: boolean;
spellCheckEnabled: boolean;
spellCheckLanguage: 'browser' | 'en-US' | 'en-CA' | 'en-GB';
spellCheckServiceText: boolean;
spellCheckCustomerNotes: boolean;
spellCheckQuoteMessages: boolean;
spellCheckInternalNotes: boolean;
vinUppercase: boolean;
licensePlateFormat: 'preserve' | 'uppercase';
mileageFormat: 'preserve' | 'digits_only' | 'commas';
autoCreateCustomerFromRO: boolean;
syncCustomerEditsToActiveROs: boolean;
duplicateCustomerWarning: 'name_phone' | 'phone_only' | 'off';
defaultLandingPage: '/' | '/quote' | '/repair-orders' | '/customers' | '/appointments' | '/financial';
appTheme: 'light' | 'dark' | 'system';
appDensity: 'comfortable' | 'compact';
defaultQuoteStatus: 'draft' | 'sent';
defaultROStatus: 'active' | 'in_progress' | 'waiting_parts';
requireMileageForRO: boolean;
requireVinForRO: boolean;
requirePlateForRO: boolean;
requireAdvisorForQuote: boolean;
laborRounding: 'exact' | 'nearest_0_1' | 'nearest_0_25' | 'nearest_0_5';
partsMarkupPercent: number;
travelFeeMode: 'flat' | 'per_mile' | 'zone';
defaultSmsTemplate: string;
defaultEmailTemplate: string;
quoteFollowUpTemplate: string;
showLaborPartsSplitOnPdf: boolean;
technicianPriceEditingEnabled: boolean;
techniciansSeeOnlyAssignedROs: boolean;
techniciansCanAddRecommendations: boolean;
techniciansCanMarkServicesComplete: boolean;
techniciansCanUploadPhotos: boolean;
techniciansCanAddInternalNotes: boolean;
techniciansCanSeePrices: boolean;
techniciansCanSeeCustomerContact: boolean;
techniciansCanSeeDeclinedWork: boolean;
advisorsCanApplyDiscounts: boolean;
maxAdvisorDiscountPercent: number;
requireApprovalForPriceOverrides: boolean;
requireAdvisorApprovalBeforeSendingQuote: boolean;
hideCostProfitFromNonOwners: boolean;
ownerOnlyCatalogDeletion: boolean;
requireReasonForPriceChange: boolean;
requireReasonForDeletingService: boolean;
quotePdfAppearance: QuotePdfAppearance; quotePdfAppearance: QuotePdfAppearance;
} }