added full settings options
This commit is contained in:
+23
-2
@@ -8,7 +8,7 @@ import AdvisorLayout from './components/advisor/AdvisorLayout';
|
||||
import MobileLayout from './components/mobile/MobileLayout';
|
||||
import TechnicianShell from './components/technician/TechnicianLayout';
|
||||
import Login from './pages/Login';
|
||||
import { isSetupComplete, loadSettingsForUser } from './lib/settings';
|
||||
import { isSetupComplete, loadSettings, loadSettingsForUser } from './lib/settings';
|
||||
import { getCurrentUserRole, isTechnicianRole, isOwnerRole, type UserRole } from './lib/rbx';
|
||||
import { useSettingsStore } from './store/settings';
|
||||
import { TechnicianProvider } from './components/technician/TechnicianContext';
|
||||
@@ -54,6 +54,22 @@ function RoleHomeRedirect() {
|
||||
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() {
|
||||
const role = getCurrentUserRole();
|
||||
if (isTechnicianRole(role)) {
|
||||
@@ -116,6 +132,7 @@ function RequireSetup({ children }: { children: React.ReactNode }) {
|
||||
const settings = await loadSettingsForUser(user?.id);
|
||||
if (!active) return;
|
||||
useSettingsStore.getState().setSettings(settings);
|
||||
applySavedTheme();
|
||||
setSetupReady(isSetupComplete(settings));
|
||||
}
|
||||
|
||||
@@ -190,7 +207,7 @@ function AppRoutes() {
|
||||
<Route path="/quote/approve/:token" element={<QuoteApprove />} />
|
||||
<Route path="/invite/technician/:token" element={<TechnicianInvite />} />
|
||||
<Route element={<OwnerLayout />}>
|
||||
<Route path="/" element={<Dashboard />} />
|
||||
<Route path="/" element={<OwnerHome />} />
|
||||
<Route path="/quote" element={<QuoteGenerator />} />
|
||||
<Route path="/service-catalog" element={<ServiceCatalog />} />
|
||||
<Route path="/customers" element={<Customers />} />
|
||||
@@ -224,6 +241,10 @@ function AppRoutes() {
|
||||
}
|
||||
|
||||
function App() {
|
||||
useEffect(() => {
|
||||
applySavedTheme();
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<BrowserRouter>
|
||||
<ToastProvider>
|
||||
|
||||
+36
-15
@@ -18,6 +18,8 @@ import { formatCurrency } from '../lib/format';
|
||||
import { getStatusConfig, SERVICE_SUB_STATUS_CONFIG } from '../lib/status';
|
||||
import { logError } from '../lib/userMessages';
|
||||
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 type { CustomerWithVehicles } from '../pages/Customers';
|
||||
|
||||
@@ -277,7 +279,7 @@ function ServiceRow({
|
||||
export interface ROFormProps {
|
||||
editRO: RepairOrder | null;
|
||||
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
|
||||
onSave: (data: Partial<RepairOrder>) => Promise<void> | void;
|
||||
onCancel: () => void;
|
||||
@@ -336,6 +338,8 @@ export default function ROForm({
|
||||
const [catalogSearch, setCatalogSearch] = useState('');
|
||||
const [catalogLoaded, setCatalogLoaded] = useState(false);
|
||||
const userId = pb.authStore.model?.id;
|
||||
const appSettings = useSettings();
|
||||
const internalNotesSpellCheck = spellCheckProps(appSettings, appSettings.spellCheckInternalNotes);
|
||||
|
||||
const isEditing = !!editRO;
|
||||
|
||||
@@ -459,18 +463,25 @@ export default function ROForm({
|
||||
|
||||
/* ── Customer matching (debounced) ─── */
|
||||
const runMatching = useCallback(() => {
|
||||
if (appSettings.duplicateCustomerWarning === 'off') {
|
||||
setSuggestedCustomer(null);
|
||||
return;
|
||||
}
|
||||
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, {
|
||||
firstName,
|
||||
middleName,
|
||||
lastName,
|
||||
firstName: matchFirstName,
|
||||
middleName: matchMiddleName,
|
||||
lastName: matchLastName,
|
||||
phone: customerPhone,
|
||||
});
|
||||
setSuggestedCustomer(match);
|
||||
} else {
|
||||
setSuggestedCustomer(null);
|
||||
}
|
||||
}, [firstName, middleName, lastName, customerPhone, customerId, customers, dismissedMatch]);
|
||||
}, [appSettings.duplicateCustomerWarning, firstName, middleName, lastName, customerPhone, customerId, customers, dismissedMatch]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isEditing) return; // don't match while editing an existing RO
|
||||
@@ -501,18 +512,25 @@ export default function ROForm({
|
||||
/* ── Auto-create customer in PB ─── */
|
||||
const ensureCustomerRecord = useCallback(async (): Promise<string> => {
|
||||
if (customerId) return customerId;
|
||||
if (!appSettings.autoCreateCustomerFromRO) return '';
|
||||
|
||||
const uid = pb.authStore.model?.id;
|
||||
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 '';
|
||||
|
||||
// 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, {
|
||||
firstName,
|
||||
middleName,
|
||||
lastName,
|
||||
firstName: matchFirstName,
|
||||
middleName: matchMiddleName,
|
||||
lastName: matchLastName,
|
||||
phone: customerPhone,
|
||||
});
|
||||
if (existingMatch) {
|
||||
@@ -535,7 +553,7 @@ export default function ROForm({
|
||||
logError('Failed to create customer record', err);
|
||||
return '';
|
||||
}
|
||||
}, [customerId, firstName, middleName, lastName, customerPhone, customers]);
|
||||
}, [appSettings, customerId, firstName, middleName, lastName, customerPhone, customers]);
|
||||
|
||||
const handleUseExisting = handleUseExistingCustomer;
|
||||
const handleDismiss = handleDismissMatch;
|
||||
@@ -673,7 +691,10 @@ export default function ROForm({
|
||||
});
|
||||
|
||||
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;
|
||||
setSaving(true);
|
||||
try {
|
||||
@@ -704,9 +725,9 @@ export default function ROForm({
|
||||
customerId: cId || customerId,
|
||||
customerName: name,
|
||||
customerPhone: customerPhone.trim(),
|
||||
vehicleInfo: vehicleInfo.trim(),
|
||||
vin: vin.trim(),
|
||||
mileage: mileage.trim(),
|
||||
vehicleInfo: formatVehicleInfo(vehicleInfo, appSettings),
|
||||
vin: formatVin(vin, appSettings),
|
||||
mileage: formatMileage(mileage, appSettings),
|
||||
advisorName: advisorName.trim(),
|
||||
roNumber: roNumber.trim() || uniqueRONumber(existingRoNumbers),
|
||||
notes: notes.trim(),
|
||||
@@ -734,7 +755,6 @@ export default function ROForm({
|
||||
}
|
||||
};
|
||||
|
||||
const appSettings = useSettings();
|
||||
const defaultAdvisor = appSettings.serviceAdvisor;
|
||||
const roleLabel = getRoleLabel(appSettings);
|
||||
const customerTypeLabels = getCustomerTypeLabels(appSettings);
|
||||
@@ -1266,6 +1286,7 @@ export default function ROForm({
|
||||
onChange={(e) => setNotes(e.target.value)}
|
||||
rows={3}
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -7,6 +7,7 @@ import { formatPhoneInput } from '../../lib/phone';
|
||||
import { logError } from '../../lib/userMessages';
|
||||
import { isValidDate, todayISO } from '../../lib/appointments';
|
||||
import { composeName, parseName, findMatchingCustomer } from '../../lib/customerName';
|
||||
import { spellCheckProps } from '../../lib/spellcheck';
|
||||
import type { CustomerWithVehicles, CustomerFormData } from '../../pages/Customers';
|
||||
import type { Appointment } from '../../types';
|
||||
|
||||
@@ -70,6 +71,7 @@ function AppointmentModalImpl({
|
||||
customers,
|
||||
}: Props) {
|
||||
const setupSettings = useSettings();
|
||||
const customerNotesSpellCheck = spellCheckProps(setupSettings, setupSettings.spellCheckCustomerNotes);
|
||||
const roleLabel = getRoleLabel(setupSettings);
|
||||
const showLocationFields = showServiceLocationFields(setupSettings);
|
||||
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">
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -4,6 +4,7 @@ import { loadSettings, getRoleLabel, showServiceLocationFields, getCustomerTypeL
|
||||
import { useSettings } from '../../store/settings';
|
||||
import { formatPhoneInput } from '../../lib/phone';
|
||||
import { generateRONumber } from '../../lib/appointments';
|
||||
import { spellCheckProps } from '../../lib/spellcheck';
|
||||
import type { Appointment, CustomerType } from '../../types';
|
||||
import type { CustomerWithVehicles } from '../../pages/Customers';
|
||||
|
||||
@@ -40,6 +41,7 @@ function CheckInModalImpl({
|
||||
customers,
|
||||
}: Props) {
|
||||
const setupSettings = useSettings();
|
||||
const customerNotesSpellCheck = spellCheckProps(setupSettings, setupSettings.spellCheckCustomerNotes);
|
||||
const roleLabel = getRoleLabel(setupSettings);
|
||||
const customerTypeLabels = getCustomerTypeLabels(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">
|
||||
<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>}
|
||||
</section>
|
||||
</div>
|
||||
|
||||
@@ -2,6 +2,8 @@ import { useState, useEffect } from 'react';
|
||||
import { X } from 'lucide-react';
|
||||
import { formatPhoneInput } from '../../lib/phone';
|
||||
import { composeName } from '../../lib/customerName';
|
||||
import { spellCheckProps } from '../../lib/spellcheck';
|
||||
import { useSettings } from '../../store/settings';
|
||||
import type { CustomerFormData, CustomerWithVehicles } from './types';
|
||||
|
||||
export function CustomerFormModal({
|
||||
@@ -15,6 +17,8 @@ export function CustomerFormModal({
|
||||
onClose: () => void;
|
||||
onSave: (data: CustomerFormData) => Promise<void>;
|
||||
}) {
|
||||
const settings = useSettings();
|
||||
const customerNotesSpellCheck = spellCheckProps(settings, settings.spellCheckCustomerNotes);
|
||||
const [form, setForm] = useState<CustomerFormData>({
|
||||
name: '',
|
||||
firstName: '',
|
||||
@@ -185,6 +189,7 @@ export function CustomerFormModal({
|
||||
onChange={(e) => setForm({ ...form, notes: e.target.value })}
|
||||
placeholder="Any additional notes about this customer..."
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -233,10 +233,14 @@ export function CustomerInfoPanel() {
|
||||
// ── Duplicate detection ──
|
||||
// When the user types first/last name or phone, check for matches
|
||||
const checkDuplicates = useCallback(async (firstName: string, lastName: string, phone: string) => {
|
||||
if (settings.duplicateCustomerWarning === 'off') {
|
||||
setDuplicateWarning(null);
|
||||
return;
|
||||
}
|
||||
if (isLinked) return;
|
||||
const nameQuery = `${firstName} ${lastName}`.trim();
|
||||
const phoneDigits = phone.replace(/\D/g, '');
|
||||
if (!nameQuery && phoneDigits.length < 4) {
|
||||
if ((settings.duplicateCustomerWarning === 'phone_only' || !nameQuery) && phoneDigits.length < 4) {
|
||||
setDuplicateWarning(null);
|
||||
return;
|
||||
}
|
||||
@@ -254,7 +258,7 @@ export function CustomerInfoPanel() {
|
||||
const matched = allCustomers.filter((c: any) => {
|
||||
const cName = (c.name || '').toLowerCase();
|
||||
const cPhone = (c.phone || '').replace(/\D/g, '');
|
||||
if (nameQuery && cName.includes(nameLc)) return true;
|
||||
if (settings.duplicateCustomerWarning === 'name_phone' && nameQuery && cName.includes(nameLc)) return true;
|
||||
if (phoneDigits.length >= 4 && cPhone.includes(phoneDigits)) return true;
|
||||
return false;
|
||||
});
|
||||
@@ -263,7 +267,7 @@ export function CustomerInfoPanel() {
|
||||
const best = matched[0] as any;
|
||||
setDuplicateWarning({
|
||||
customer: { id: best.id, name: best.name, phone: best.phone || '', vehicleCount: 0 },
|
||||
message: nameQuery && best.name.toLowerCase().includes(nameLc) ? 'name' : 'phone',
|
||||
message: settings.duplicateCustomerWarning === 'name_phone' && nameQuery && best.name.toLowerCase().includes(nameLc) ? 'name' : 'phone',
|
||||
});
|
||||
} else {
|
||||
setDuplicateWarning(null);
|
||||
@@ -271,7 +275,7 @@ export function CustomerInfoPanel() {
|
||||
} catch {
|
||||
setDuplicateWarning(null);
|
||||
}
|
||||
}, [isLinked]);
|
||||
}, [isLinked, settings.duplicateCustomerWarning]);
|
||||
|
||||
// Debounced duplicate check
|
||||
const handleNameOrPhoneChange = useCallback((field: string, _val: string) => {
|
||||
|
||||
@@ -15,6 +15,7 @@ import { sanitizeServices } from '../../lib/quoteHelpers';
|
||||
import { logError } from '../../lib/userMessages';
|
||||
import { openEmailDraft, openSmsDraft } from '../../lib/contactLinks';
|
||||
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 ── */
|
||||
async function ensureCustomerRecord(customerInfo: {
|
||||
@@ -87,6 +88,17 @@ export function QuoteSummary({ editId, repairOriginId }: { editId?: string | nul
|
||||
const tax = t.tax;
|
||||
const grandTotal = t.total;
|
||||
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 () => {
|
||||
setSaving(true);
|
||||
@@ -95,11 +107,11 @@ export function QuoteSummary({ editId, repairOriginId }: { editId?: string | nul
|
||||
let customerId = customerInfo.customerId;
|
||||
if (!customerId) {
|
||||
customerId = await ensureCustomerRecord({
|
||||
name: customerInfo.name,
|
||||
firstName: customerInfo.firstName,
|
||||
lastName: customerInfo.lastName,
|
||||
phone: customerInfo.phone,
|
||||
email: customerInfo.email,
|
||||
name: formattedCustomerInfo.name,
|
||||
firstName: formattedCustomerInfo.firstName,
|
||||
lastName: formattedCustomerInfo.lastName,
|
||||
phone: formattedCustomerInfo.phone,
|
||||
email: formattedCustomerInfo.email,
|
||||
});
|
||||
if (customerId) {
|
||||
useQuoteStore.getState().setCustomerInfo({ customerId });
|
||||
@@ -109,13 +121,13 @@ export function QuoteSummary({ editId, repairOriginId }: { editId?: string | nul
|
||||
const data: Record<string, any> = {
|
||||
userId: pb.authStore.model?.id,
|
||||
customerId: customerId || '',
|
||||
customerName: customerInfo.name,
|
||||
customerPhone: customerInfo.phone,
|
||||
vehicleInfo: customerInfo.vehicleInfo,
|
||||
vin: customerInfo.vin,
|
||||
mileage: customerInfo.mileage,
|
||||
repairOrderNumber: customerInfo.roNumber,
|
||||
serviceAdvisor: customerInfo.serviceAdvisor,
|
||||
customerName: formattedCustomerInfo.name,
|
||||
customerPhone: formattedCustomerInfo.phone,
|
||||
vehicleInfo: formattedCustomerInfo.vehicleInfo,
|
||||
vin: formattedCustomerInfo.vin,
|
||||
mileage: formattedCustomerInfo.mileage,
|
||||
repairOrderNumber: formattedCustomerInfo.roNumber,
|
||||
serviceAdvisor: formattedCustomerInfo.serviceAdvisor,
|
||||
services: sanitizeServices(services),
|
||||
discountValue: discount.value,
|
||||
discountType: discount.type,
|
||||
@@ -188,23 +200,23 @@ export function QuoteSummary({ editId, repairOriginId }: { editId?: string | nul
|
||||
if (!quoteId) {
|
||||
// Ensure customer record exists first
|
||||
const customerId = customerInfo.customerId || (await ensureCustomerRecord({
|
||||
name: customerInfo.name,
|
||||
firstName: customerInfo.firstName,
|
||||
lastName: customerInfo.lastName,
|
||||
phone: customerInfo.phone,
|
||||
email: customerInfo.email,
|
||||
name: formattedCustomerInfo.name,
|
||||
firstName: formattedCustomerInfo.firstName,
|
||||
lastName: formattedCustomerInfo.lastName,
|
||||
phone: formattedCustomerInfo.phone,
|
||||
email: formattedCustomerInfo.email,
|
||||
})) || undefined;
|
||||
|
||||
const data: Record<string, any> = {
|
||||
userId: pb.authStore.model?.id,
|
||||
customerId: customerId || '',
|
||||
customerName: customerInfo.name,
|
||||
customerPhone: customerInfo.phone,
|
||||
vehicleInfo: customerInfo.vehicleInfo,
|
||||
vin: customerInfo.vin,
|
||||
mileage: customerInfo.mileage,
|
||||
repairOrderNumber: customerInfo.roNumber,
|
||||
serviceAdvisor: customerInfo.serviceAdvisor,
|
||||
customerName: formattedCustomerInfo.name,
|
||||
customerPhone: formattedCustomerInfo.phone,
|
||||
vehicleInfo: formattedCustomerInfo.vehicleInfo,
|
||||
vin: formattedCustomerInfo.vin,
|
||||
mileage: formattedCustomerInfo.mileage,
|
||||
repairOrderNumber: formattedCustomerInfo.roNumber,
|
||||
serviceAdvisor: formattedCustomerInfo.serviceAdvisor,
|
||||
services: sanitizeServices(services),
|
||||
discountValue: discount.value,
|
||||
discountType: discount.type,
|
||||
@@ -238,13 +250,13 @@ export function QuoteSummary({ editId, repairOriginId }: { editId?: string | nul
|
||||
const data: Record<string, any> = {
|
||||
userId: pb.authStore.model?.id,
|
||||
customerId: customerInfo.customerId || '',
|
||||
customerName: customerInfo.name,
|
||||
customerPhone: customerInfo.phone,
|
||||
vehicleInfo: customerInfo.vehicleInfo,
|
||||
vin: customerInfo.vin,
|
||||
mileage: customerInfo.mileage,
|
||||
repairOrderNumber: customerInfo.roNumber,
|
||||
serviceAdvisor: customerInfo.serviceAdvisor,
|
||||
customerName: formattedCustomerInfo.name,
|
||||
customerPhone: formattedCustomerInfo.phone,
|
||||
vehicleInfo: formattedCustomerInfo.vehicleInfo,
|
||||
vin: formattedCustomerInfo.vin,
|
||||
mileage: formattedCustomerInfo.mileage,
|
||||
repairOrderNumber: formattedCustomerInfo.roNumber,
|
||||
serviceAdvisor: formattedCustomerInfo.serviceAdvisor,
|
||||
services: sanitizeServices(services),
|
||||
discountValue: discount.value,
|
||||
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>
|
||||
</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">${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>
|
||||
</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">${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>
|
||||
</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">${split.combined.tax.toFixed(2)}</span>
|
||||
@@ -475,7 +487,7 @@ export function QuoteSummary({ editId, repairOriginId }: { editId?: string | nul
|
||||
{shopCharge > 0 && (
|
||||
<div className="flex justify-between"><span className="text-gray-500 dark:text-gray-400">Shop Charge</span><span className="text-gray-900 dark:text-gray-100">${shopCharge.toFixed(2)}</span></div>
|
||||
)}
|
||||
{settings.taxRate > 0 && (
|
||||
{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="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 { useSettings } from '../../store/settings';
|
||||
import type { QuoteAuthorizationMethod, QuoteService } from '../../types';
|
||||
import { spellCheckProps } from '../../lib/spellcheck';
|
||||
|
||||
interface ServiceRowProps {
|
||||
service: QuoteService;
|
||||
@@ -32,6 +33,8 @@ export const ServiceRow = memo(function ServiceRow({
|
||||
onUpdate,
|
||||
}: ServiceRowProps) {
|
||||
const settings = useSettings();
|
||||
const serviceTextSpellCheck = spellCheckProps(settings, settings.spellCheckServiceText);
|
||||
const internalNotesSpellCheck = spellCheckProps(settings, settings.spellCheckInternalNotes);
|
||||
return (
|
||||
<div className={`rounded-lg transition-colors ${!service.approved && service.customerDecision === 'declined' ? 'opacity-50' : ''}`}>
|
||||
<div className="flex items-center gap-3 px-4 py-2.5 hover:bg-gray-50 dark:hover:bg-gray-800/50 rounded-lg">
|
||||
@@ -213,6 +216,7 @@ export const ServiceRow = memo(function ServiceRow({
|
||||
value={service.recommendation || ''}
|
||||
onChange={(e) => onUpdate({ recommendation: e.target.value })}
|
||||
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"
|
||||
/>
|
||||
|
||||
@@ -263,6 +267,7 @@ export const ServiceRow = memo(function ServiceRow({
|
||||
value={service.aftermarketPartsList || ''}
|
||||
onChange={(e) => onUpdate({ aftermarketPartsList: e.target.value })}
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
@@ -298,6 +303,7 @@ export const ServiceRow = memo(function ServiceRow({
|
||||
onChange={(e) => onUpdate({ explanation: e.target.value })}
|
||||
placeholder="AI-generated explanation will appear here. Click the Sparkles icon to generate."
|
||||
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"
|
||||
/>
|
||||
|
||||
@@ -354,6 +360,7 @@ export const ServiceRow = memo(function ServiceRow({
|
||||
onChange={(e) => onUpdate({ technicianNotes: e.target.value })}
|
||||
placeholder="Internal notes — not shown to customer..."
|
||||
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"
|
||||
/>
|
||||
</details>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
import { FileText } from 'lucide-react';
|
||||
import { useQuoteStore } from '../../store/quote';
|
||||
import { useSettings } from '../../store/settings';
|
||||
import { useToast } from '../ui/Toast';
|
||||
import { aiWriteExplanation } from '../../lib/ai';
|
||||
import { logError } from '../../lib/userMessages';
|
||||
@@ -14,6 +15,7 @@ const authorizationOptions: Array<{ value: QuoteAuthorizationMethod; label: stri
|
||||
];
|
||||
|
||||
export function ServicesTable() {
|
||||
const settings = useSettings();
|
||||
const {
|
||||
services,
|
||||
removeService,
|
||||
@@ -38,11 +40,17 @@ export function ServicesTable() {
|
||||
serviceName: svc.name,
|
||||
recommendation: svc.recommendation || '',
|
||||
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) {
|
||||
updateService(svc.id, {
|
||||
explanation: result.explanation,
|
||||
priority: result.level as QuoteService['priority'],
|
||||
...(settings.aiWriteAutoSetPriority ? { priority: result.level as QuoteService['priority'] } : {}),
|
||||
});
|
||||
setExpanded((prev) => ({ ...prev, [svc.id]: true }));
|
||||
showToast(`AI explanation generated for ${svc.name}`, 'success');
|
||||
@@ -55,7 +63,7 @@ export function ServicesTable() {
|
||||
} finally {
|
||||
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) {
|
||||
return (
|
||||
|
||||
+115
-27
@@ -16,7 +16,7 @@ export interface PriorityResult {
|
||||
}
|
||||
|
||||
export interface ExplanationResult {
|
||||
level: string;
|
||||
level: 'CRITICAL_SAFETY' | 'SAFETY_CONCERN' | 'RECOMMENDED' | 'MAINTENANCE' | 'OPTIONAL';
|
||||
explanation: string;
|
||||
}
|
||||
|
||||
@@ -27,6 +27,10 @@ export interface ExplanationParams {
|
||||
vehicleInfo?: string;
|
||||
mileage?: number;
|
||||
maintenanceInterval?: number;
|
||||
tone?: 'professional' | 'friendly' | 'concise' | 'detailed';
|
||||
length?: 'short' | 'standard' | 'detailed';
|
||||
useVehicleContext?: boolean;
|
||||
safetyWording?: 'conservative' | 'balanced' | 'direct';
|
||||
}
|
||||
|
||||
export interface CatalogDescriptionParams {
|
||||
@@ -113,6 +117,42 @@ function stripCodeBlocks(text: string): string {
|
||||
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 ─────────────────────────────────
|
||||
|
||||
/**
|
||||
@@ -167,64 +207,112 @@ ${JSON.stringify(servicesToAnalyze)}`;
|
||||
export async function aiWriteExplanation(
|
||||
params: ExplanationParams,
|
||||
): 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;
|
||||
|
||||
let additionalContext = '';
|
||||
const additionalContext: string[] = [];
|
||||
const mileageNum = mileage ?? 0;
|
||||
|
||||
if (technicianNotes) {
|
||||
additionalContext += `\nTechnician's internal notes: "${technicianNotes}"`;
|
||||
additionalContext.push(`Technician's internal notes: "${technicianNotes}"`);
|
||||
}
|
||||
if (vehicleInfo) {
|
||||
additionalContext += `\nVehicle: ${vehicleInfo}`;
|
||||
if (useVehicleContext && vehicleInfo) {
|
||||
additionalContext.push(`Vehicle: ${vehicleInfo}`);
|
||||
}
|
||||
if (maintenanceInterval && mileageNum > 0) {
|
||||
if (useVehicleContext && maintenanceInterval && mileageNum > 0) {
|
||||
const lastInterval = Math.floor(mileageNum / maintenanceInterval) * maintenanceInterval;
|
||||
const nextInterval = lastInterval + maintenanceInterval;
|
||||
const milesUntilNext = nextInterval - mileageNum;
|
||||
const occurrences = Math.floor(mileageNum / maintenanceInterval);
|
||||
additionalContext += `\nCurrent mileage: ${mileageNum.toLocaleString()} miles`;
|
||||
additionalContext += `\nService interval: every ${maintenanceInterval.toLocaleString()} miles.`;
|
||||
additionalContext.push(`Current mileage: ${mileageNum.toLocaleString()} miles`);
|
||||
additionalContext.push(`Service interval: every ${maintenanceInterval.toLocaleString()} miles.`);
|
||||
if (occurrences >= 1) {
|
||||
const s = ['th', 'st', 'nd', 'rd'];
|
||||
const v = occurrences % 100;
|
||||
const ordinal = occurrences + (s[(v - 20) % 10] || s[v] || s[0]);
|
||||
additionalContext += ` This would be the ${ordinal} time this service is performed.`;
|
||||
additionalContext.push(`This is the ${ordinal} interval for this service, assuming it has followed this repeating mileage schedule.`);
|
||||
}
|
||||
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) {
|
||||
additionalContext += ` Next interval at ${nextInterval.toLocaleString()} miles (${milesUntilNext.toLocaleString()} from now).`;
|
||||
additionalContext.push(`Next interval at ${nextInterval.toLocaleString()} miles (${milesUntilNext.toLocaleString()} from now).`);
|
||||
} 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) {
|
||||
additionalContext += `\nVehicle mileage (context only — do NOT mention unless the service is mileage-based): ${mileageNum.toLocaleString()} miles`;
|
||||
} else if (useVehicleContext && mileageNum > 0) {
|
||||
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:
|
||||
1. Determine the appropriate recommendation level based on the reason and any additional context provided (CRITICAL, RECOMMENDED, OPTIONAL, PREVENTIVE, or MAINTENANCE)
|
||||
2. Write a professional explanation as if a master technician is explaining to a customer
|
||||
3. If technician notes are provided, incorporate that specific information into the explanation
|
||||
4. If mileage and interval data are provided, mention whether the service is due now, approaching, or past due. If mileage is provided WITHOUT interval data, do NOT mention the mileage unless the service itself is mileage-based.
|
||||
Rules:
|
||||
- Use only the provided source facts. Do not invent diagnosis, measurements, causes, symptoms, part conditions, or inspection findings.
|
||||
- Translate technician/internal notes into customer-friendly language. Do not mention "internal notes", quote private shorthand, or say "the technician wrote".
|
||||
- Use vehicle year/make/model and mileage only when directly relevant to the service explanation. Do not mention mileage just because it was provided.
|
||||
- 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:
|
||||
LEVEL: [Choose one: CRITICAL, RECOMMENDED, OPTIONAL, PREVENTIVE, MAINTENANCE]
|
||||
EXPLANATION: [Write 2-4 sentences explaining the service, safety implications, and consequences of neglect using qualifying words like "potentially", "may", "could". Frame risks helpfully, not alarmingly.]`;
|
||||
Priority definitions:
|
||||
- CRITICAL_SAFETY: Immediate safety risk or vehicle should not be driven without repair, supported by explicit facts.
|
||||
- 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;
|
||||
|
||||
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);
|
||||
|
||||
if (levelMatch && explanationMatch) {
|
||||
return {
|
||||
level: levelMatch[1].toUpperCase(),
|
||||
level: normalizeExplanationPriority(levelMatch[1]),
|
||||
explanation: explanationMatch[1].trim(),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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
@@ -592,6 +592,7 @@ export async function generateQuotePDF(
|
||||
let displayDiscount = 0;
|
||||
let displayTax = 0;
|
||||
let displayTotal = 0;
|
||||
const showTax = appearance.showTaxLine && settings.taxEnabled !== false && settings.taxRate > 0;
|
||||
|
||||
if (hasMixed) {
|
||||
const split = computeSplitQuoteTotals(services, quote.discount || { value: 0, type: 'dollar' }, settings);
|
||||
@@ -616,7 +617,7 @@ export async function generateQuotePDF(
|
||||
if (discountValue > 0) {
|
||||
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);
|
||||
displayTotal = servicesTotal - displayDiscount + displayShopCharge + displayTax;
|
||||
}
|
||||
@@ -636,13 +637,13 @@ export async function generateQuotePDF(
|
||||
sumRows += 1; // combined subtotal
|
||||
if (appearance.showDiscountLine && displayDiscount > 0) sumRows += 1; // combined discount
|
||||
if (appearance.showShopChargeLine && displayShopCharge > 0) sumRows += 1; // combined capped shop charge
|
||||
if (appearance.showTaxLine && settings.taxRate > 0) sumRows += 1; // combined recalculated tax
|
||||
if (showTax) sumRows += 1; // combined recalculated tax
|
||||
sumRows += 2; // separator + grand total
|
||||
} else {
|
||||
sumRows = 1; // subtotal
|
||||
if (appearance.showDiscountLine && displayDiscount > 0) sumRows++;
|
||||
if (appearance.showShopChargeLine && displayShopCharge > 0) sumRows++;
|
||||
if (appearance.showTaxLine && settings.taxRate > 0) sumRows++;
|
||||
if (showTax) sumRows++;
|
||||
sumRows += 2; // separator + total
|
||||
}
|
||||
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' });
|
||||
y += getLineHeight(9);
|
||||
}
|
||||
if (appearance.showTaxLine && settings.taxRate > 0) {
|
||||
if (showTax) {
|
||||
applyTextStyle(doc, appearance.totals, DG);
|
||||
doc.text(settings.taxLabel || 'Tax', sumX, y);
|
||||
doc.setTextColor(...BL);
|
||||
@@ -702,7 +703,7 @@ export async function generateQuotePDF(
|
||||
doc.text(`$${pendingTotals.shopCharge.toFixed(2)}`, sumRight, y, { align: 'right' });
|
||||
y += getLineHeight(9);
|
||||
}
|
||||
if (appearance.showTaxLine && settings.taxRate > 0) {
|
||||
if (showTax) {
|
||||
applyTextStyle(doc, appearance.totals, DG);
|
||||
doc.text(settings.taxLabel || 'Tax', sumX, y);
|
||||
doc.setTextColor(...BL);
|
||||
@@ -739,7 +740,7 @@ export async function generateQuotePDF(
|
||||
y += getLineHeight(9);
|
||||
}
|
||||
|
||||
if (appearance.showTaxLine && settings.taxRate > 0) {
|
||||
if (showTax) {
|
||||
applyTextStyle(doc, appearance.totals, DG);
|
||||
doc.text(`${settings.taxLabel || 'Tax'} @ ${settings.taxRate}%`, sumX, y);
|
||||
doc.setTextColor(...GR);
|
||||
@@ -779,7 +780,7 @@ export async function generateQuotePDF(
|
||||
}
|
||||
|
||||
// Tax
|
||||
if (appearance.showTaxLine && settings.taxRate > 0) {
|
||||
if (showTax) {
|
||||
applyTextStyle(doc, appearance.totals, DG);
|
||||
doc.text(settings.taxLabel || 'Tax', sumX, y);
|
||||
doc.setTextColor(...BL);
|
||||
|
||||
@@ -96,6 +96,7 @@ export const DEFAULT_SETTINGS: ShopSettings = {
|
||||
businessAddress: '',
|
||||
businessPhone: '',
|
||||
serviceAdvisor: '',
|
||||
taxEnabled: true,
|
||||
taxRate: 0,
|
||||
taxLabel: 'Tax',
|
||||
shopChargeRate: 3,
|
||||
@@ -120,6 +121,69 @@ export const DEFAULT_SETTINGS: ShopSettings = {
|
||||
showAdvisorOnQuote: true,
|
||||
showTechnicianOnQuote: false,
|
||||
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,
|
||||
};
|
||||
|
||||
|
||||
@@ -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
@@ -19,10 +19,12 @@ export interface TotalsBreakdown {
|
||||
total: number;
|
||||
}
|
||||
|
||||
type TaxAndFeeSettings = Pick<ShopSettings, 'taxRate' | 'shopChargeRate' | 'shopChargeCap'> & Partial<Pick<ShopSettings, 'taxEnabled'>>;
|
||||
|
||||
export interface QuoteTotalsInput {
|
||||
services: QuoteService[];
|
||||
discount: { value: number; type: 'dollar' | 'percent' };
|
||||
settings: Pick<ShopSettings, 'taxRate' | 'shopChargeRate' | 'shopChargeCap'>;
|
||||
settings: TaxAndFeeSettings;
|
||||
}
|
||||
|
||||
// Determine which services count toward totals.
|
||||
@@ -69,7 +71,8 @@ export function computeQuoteTotals(input: QuoteTotalsInput): TotalsBreakdown {
|
||||
);
|
||||
|
||||
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;
|
||||
|
||||
return {
|
||||
@@ -92,7 +95,7 @@ export function computeQuoteTotals(input: QuoteTotalsInput): TotalsBreakdown {
|
||||
export function computeSplitQuoteTotals(
|
||||
services: QuoteService[],
|
||||
discount: { value: number; type: 'dollar' | 'percent' },
|
||||
settings: Pick<ShopSettings, 'taxRate' | 'shopChargeRate' | 'shopChargeCap'>,
|
||||
settings: TaxAndFeeSettings,
|
||||
): {
|
||||
approved: TotalsBreakdown;
|
||||
pending: TotalsBreakdown;
|
||||
@@ -124,7 +127,8 @@ export function computeSplitQuoteTotals(
|
||||
const cap = settings.shopChargeCap ?? 69.95;
|
||||
const shopCharge = Math.min(chargeable * ((settings.shopChargeRate || 0) / 100), cap);
|
||||
const taxableBase = afterDiscount + shopCharge;
|
||||
const tax = taxableBase * ((settings.taxRate || 0) / 100);
|
||||
const taxRate = settings.taxEnabled === false ? 0 : settings.taxRate || 0;
|
||||
const tax = taxableBase * (taxRate / 100);
|
||||
const total = taxableBase + tax;
|
||||
|
||||
const combined: TotalsBreakdown = {
|
||||
@@ -144,7 +148,7 @@ export function computeSplitQuoteTotals(
|
||||
export interface ROTotalsInput {
|
||||
services: ROService[];
|
||||
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
|
||||
@@ -192,7 +196,8 @@ export function computeROTotals(input: ROTotalsInput): TotalsBreakdown {
|
||||
);
|
||||
|
||||
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;
|
||||
|
||||
return {
|
||||
|
||||
+23
-16
@@ -5,6 +5,8 @@ import { pb } from '../lib/pocketbase';
|
||||
import { useToast } from '../components/ui/Toast';
|
||||
import { getSetupRequiredMessage, isMissingCollectionError, logError } from '../lib/userMessages';
|
||||
import { customerWriteSchema } from '../schemas/customer';
|
||||
import { useSettings } from '../store/settings';
|
||||
import { formatCustomerFormData, formatVehicleFormData } from '../lib/dataFormatting';
|
||||
import {
|
||||
formatVehicleLabel,
|
||||
vehicleKey,
|
||||
@@ -41,6 +43,7 @@ import type {
|
||||
|
||||
export default function Customers() {
|
||||
const { showToast } = useToast();
|
||||
const settings = useSettings();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const initialSearch = (() => {
|
||||
const q = searchParams.get('search');
|
||||
@@ -303,14 +306,15 @@ export default function Customers() {
|
||||
|
||||
const handleAddCustomer = useCallback(
|
||||
async (data: CustomerFormData) => {
|
||||
const parsed = customerWriteSchema.safeParse({ ...data, userId });
|
||||
const formatted = formatCustomerFormData(data, settings);
|
||||
const parsed = customerWriteSchema.safeParse({ ...formatted, userId });
|
||||
if (!parsed.success) {
|
||||
showToast(parsed.error.issues[0].message, 'error');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await pb.collection('customers').create({
|
||||
...data,
|
||||
...formatted,
|
||||
userId,
|
||||
});
|
||||
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');
|
||||
}
|
||||
},
|
||||
[userId, showToast, fetchAll]
|
||||
[settings, userId, showToast, fetchAll]
|
||||
);
|
||||
|
||||
const handleEditCustomer = useCallback(
|
||||
async (data: CustomerFormData) => {
|
||||
if (!editingCustomer) return;
|
||||
const parsed = customerWriteSchema.safeParse(data);
|
||||
const formatted = formatCustomerFormData(data, settings);
|
||||
const parsed = customerWriteSchema.safeParse(formatted);
|
||||
if (!parsed.success) {
|
||||
showToast(parsed.error.issues[0].message, 'error');
|
||||
return;
|
||||
}
|
||||
try {
|
||||
await pb.collection('customers').update(editingCustomer.id, data);
|
||||
if (userId) {
|
||||
await pb.collection('customers').update(editingCustomer.id, formatted);
|
||||
if (userId && settings.syncCustomerEditsToActiveROs) {
|
||||
const linkedActiveROs = await pb.collection('repairOrders').getFullList({
|
||||
filter: activeROFilterForCustomer(userId, editingCustomer.id, editingCustomer.name),
|
||||
fields: 'id',
|
||||
@@ -343,9 +348,9 @@ export default function Customers() {
|
||||
await Promise.all(linkedActiveROs.map((ro) =>
|
||||
pb.collection('repairOrders').update(ro.id, {
|
||||
customerId: editingCustomer.id,
|
||||
customerName: data.name.trim(),
|
||||
customerPhone: data.phone.trim(),
|
||||
customerEmail: data.email.trim(),
|
||||
customerName: formatted.name.trim(),
|
||||
customerPhone: formatted.phone.trim(),
|
||||
customerEmail: formatted.email.trim(),
|
||||
})
|
||||
));
|
||||
}
|
||||
@@ -354,7 +359,7 @@ export default function Customers() {
|
||||
fetchAll();
|
||||
if (selectedCustomer?.id === editingCustomer.id) {
|
||||
setSelectedCustomer((prev) =>
|
||||
prev ? { ...prev, ...data } : null
|
||||
prev ? { ...prev, ...formatted } : null
|
||||
);
|
||||
}
|
||||
} catch (err) {
|
||||
@@ -362,7 +367,7 @@ export default function Customers() {
|
||||
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 () => {
|
||||
@@ -395,9 +400,10 @@ export default function Customers() {
|
||||
const handleAddVehicle = useCallback(
|
||||
async (data: VehicleFormData) => {
|
||||
if (!selectedCustomer || !userId) return;
|
||||
const formatted = formatVehicleFormData(data, settings);
|
||||
try {
|
||||
await pb.collection('vehicles').create({
|
||||
...data,
|
||||
...formatted,
|
||||
customerId: selectedCustomer.id,
|
||||
userId,
|
||||
});
|
||||
@@ -414,18 +420,19 @@ export default function Customers() {
|
||||
return prev;
|
||||
});
|
||||
},
|
||||
[selectedCustomer, userId, showToast, fetchAll, handleViewCustomer]
|
||||
[selectedCustomer, settings, userId, showToast, fetchAll, handleViewCustomer]
|
||||
);
|
||||
|
||||
const handleEditVehicle = useCallback(
|
||||
async (data: VehicleFormData) => {
|
||||
if (!editingVehicle || !selectedCustomer || !userId) return;
|
||||
const formatted = formatVehicleFormData(data, settings);
|
||||
try {
|
||||
if (editingVehicle.id) {
|
||||
await pb.collection('vehicles').update(editingVehicle.id, data);
|
||||
await pb.collection('vehicles').update(editingVehicle.id, formatted);
|
||||
} else {
|
||||
await pb.collection('vehicles').create({
|
||||
...data,
|
||||
...formatted,
|
||||
customerId: selectedCustomer.id,
|
||||
userId,
|
||||
});
|
||||
@@ -441,7 +448,7 @@ export default function Customers() {
|
||||
fetchAll();
|
||||
if (selectedCustomer) handleViewCustomer(selectedCustomer.id);
|
||||
},
|
||||
[editingVehicle, selectedCustomer, userId, showToast, fetchAll, handleViewCustomer]
|
||||
[editingVehicle, selectedCustomer, settings, userId, showToast, fetchAll, handleViewCustomer]
|
||||
);
|
||||
|
||||
const handleDeleteVehicle = useCallback(async () => {
|
||||
|
||||
@@ -13,6 +13,7 @@ import { useToast } from '../components/ui/Toast';
|
||||
import type { QuoteService, CustomerInfo } from '../types';
|
||||
import { logError } from '../lib/userMessages';
|
||||
import { normalizeCustomerInfo, formatQuoteCurrency, formatQuoteDate } from '../lib/quoteHelpers';
|
||||
import { formatCustomerName, formatMileage, formatServiceTextFields, formatVehicleInfo, formatVin } from '../lib/dataFormatting';
|
||||
|
||||
/* ── Helper: convert legacy flat customer fields to CustomerInfo ── */
|
||||
function toCustomerInfo(opts: {
|
||||
@@ -420,6 +421,7 @@ export default function QuoteGenerator() {
|
||||
|
||||
const settings = useSettingsStore.getState().settings;
|
||||
const laborRate = settings.defaultLaborRate || 95;
|
||||
const formattedCustomerName = formatCustomerName(ci.name, settings);
|
||||
let svcs: any[] = [];
|
||||
const rawSvcs = q.services;
|
||||
if (typeof rawSvcs === 'string') { try { svcs = JSON.parse(rawSvcs); } catch (err) { logError('Failed to parse quote-to-RO svcs JSON', err); svcs = []; } }
|
||||
@@ -430,7 +432,7 @@ export default function QuoteGenerator() {
|
||||
.map((s: any, i: number) => {
|
||||
const price = parseFloat(String(s.price ?? 0)) || 0;
|
||||
const laborHours = price / laborRate;
|
||||
return {
|
||||
const service = {
|
||||
id: s.id || `rosvc-${i}`,
|
||||
name: s.name || '',
|
||||
description: s.explanation || '',
|
||||
@@ -444,6 +446,7 @@ export default function QuoteGenerator() {
|
||||
applyShopCharge: s.applyShopCharge !== false,
|
||||
technicianNotes: s.technicianNotes || '',
|
||||
};
|
||||
return settings.applyServiceFormattingToROs ? formatServiceTextFields(service, settings) : service;
|
||||
});
|
||||
|
||||
const roNumber = uniqueRONumber(existing);
|
||||
@@ -452,11 +455,11 @@ export default function QuoteGenerator() {
|
||||
userId,
|
||||
quoteId: q.id,
|
||||
customerId: q.customerId || '',
|
||||
customerName: ci.name,
|
||||
customerName: formattedCustomerName,
|
||||
customerPhone: ci.phone,
|
||||
vehicleInfo: ci.vehicleInfo,
|
||||
vin: ci.vin,
|
||||
mileage: ci.mileage,
|
||||
vehicleInfo: formatVehicleInfo(ci.vehicleInfo, settings),
|
||||
vin: formatVin(ci.vin, settings),
|
||||
mileage: formatMileage(ci.mileage, settings),
|
||||
advisorName: ci.serviceAdvisor,
|
||||
roNumber,
|
||||
notes: `Converted from quote #${q.id.slice(0, 8)}`,
|
||||
|
||||
@@ -23,6 +23,8 @@ import { downloadROPDF, printROPDF } from '../lib/roPdf';
|
||||
import { useSettings } from '../store/settings';
|
||||
import { logError } from '../lib/userMessages';
|
||||
import { parseRepairOrderServices, syncAssignmentsForROServices } from '../lib/technicianData';
|
||||
import { formatServiceTextFields } from '../lib/dataFormatting';
|
||||
import { spellCheckProps } from '../lib/spellcheck';
|
||||
|
||||
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>;
|
||||
}
|
||||
|
||||
function EditableServiceRow({ service, index, onChange, onRemove, defaultLaborRate = 145 }: {
|
||||
function EditableServiceRow({ service, index, onChange, onRemove, defaultLaborRate = 145, spellCheck, lang }: {
|
||||
service: Partial<ROService>;
|
||||
index: number;
|
||||
onChange: (index: number, field: string, value: string | number | boolean) => void;
|
||||
onRemove: (index: number) => void;
|
||||
defaultLaborRate?: number;
|
||||
spellCheck?: boolean;
|
||||
lang?: string;
|
||||
}) {
|
||||
const pricingMode = service.pricingMode || 'full';
|
||||
const isSplit = pricingMode === 'split';
|
||||
@@ -246,7 +250,7 @@ function EditableServiceRow({ service, index, onChange, onRemove, defaultLaborRa
|
||||
</div>
|
||||
<div className="sm:col-span-2 lg:col-span-1">
|
||||
<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>
|
||||
{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 (
|
||||
<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"
|
||||
/>
|
||||
);
|
||||
@@ -321,6 +327,8 @@ export default function RODetailModal() {
|
||||
const navigate = useNavigate();
|
||||
const { showToast } = useToast();
|
||||
const settings = useSettings();
|
||||
const serviceTextSpellCheck = spellCheckProps(settings, settings.spellCheckServiceText);
|
||||
const internalNotesSpellCheck = spellCheckProps(settings, settings.spellCheckInternalNotes);
|
||||
const statusConfig = useMemo(() => getStatusConfig(settings), [settings]);
|
||||
|
||||
const modalBackground = (location.state as { backgroundLocation?: unknown })?.backgroundLocation;
|
||||
@@ -864,7 +872,8 @@ export default function RODetailModal() {
|
||||
if (!ro) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
const cleaned = localServices.map((s) => ({
|
||||
const cleaned = localServices.map((s) => {
|
||||
const service = {
|
||||
id: s.id,
|
||||
name: s.name,
|
||||
description: s.description || '',
|
||||
@@ -876,7 +885,9 @@ export default function RODetailModal() {
|
||||
technician: s.technician || '',
|
||||
pricingMode: s.pricingMode || 'full',
|
||||
applyShopCharge: s.applyShopCharge !== false,
|
||||
}));
|
||||
};
|
||||
return settings.applyServiceFormattingToROs ? formatServiceTextFields(service, settings) : service;
|
||||
});
|
||||
const payload = { services: JSON.stringify(cleaned) };
|
||||
await pb.collection('repairOrders').update(ro.id, payload);
|
||||
|
||||
@@ -916,7 +927,7 @@ export default function RODetailModal() {
|
||||
const partsPrice = Number(newServiceForm.partsPrice) || 0;
|
||||
const isSplit = newServiceForm.pricingMode === 'split';
|
||||
const description = newServiceForm.description.trim();
|
||||
const data = {
|
||||
const data = formatServiceTextFields({
|
||||
name: newServiceForm.name.trim(),
|
||||
category: newServiceForm.category.trim(),
|
||||
description,
|
||||
@@ -927,7 +938,7 @@ export default function RODetailModal() {
|
||||
price: isSplit ? laborPrice + partsPrice : fullPrice,
|
||||
active: true,
|
||||
userId,
|
||||
};
|
||||
}, settings.applyServiceFormattingToCatalog ? settings : { ...settings, serviceNameFormat: 'preserve', serviceDescriptionFormat: 'preserve', serviceRecommendationFormat: 'preserve', serviceCategoryFormat: 'preserve', trimServiceText: true, addPeriodToServiceDescriptions: false });
|
||||
try {
|
||||
const created = await pb.collection('services').create(data) as Record<string, unknown>;
|
||||
const newSvc: ServiceItem = {
|
||||
@@ -1054,6 +1065,7 @@ export default function RODetailModal() {
|
||||
onChange={(e) => setReminderNotes(e.target.value)}
|
||||
placeholder="e.g. Next oil change reminder"
|
||||
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"
|
||||
/>
|
||||
</div>
|
||||
@@ -1315,7 +1327,7 @@ export default function RODetailModal() {
|
||||
<div className="mt-3">
|
||||
<FormField label="Description">
|
||||
<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>
|
||||
</FormField>
|
||||
</div>
|
||||
@@ -1373,7 +1385,7 @@ export default function RODetailModal() {
|
||||
</div>
|
||||
<div className="space-y-3">
|
||||
{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 && (
|
||||
<p className="text-center text-sm text-gray-400">No services on this RO yet.</p>
|
||||
|
||||
@@ -7,6 +7,9 @@ import { aiWriteCatalogDescription } from '../lib/ai';
|
||||
import { logError } from '../lib/userMessages';
|
||||
import { ListSkeleton } from '../components/ui/ListSkeleton';
|
||||
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';
|
||||
|
||||
@@ -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 (
|
||||
<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"
|
||||
/>
|
||||
);
|
||||
@@ -114,6 +119,8 @@ function ServiceRow({ service, onEdit, onDelete, onToggleActive }: { service: Se
|
||||
|
||||
export default function ServiceCatalog() {
|
||||
const { showToast } = useToast();
|
||||
const settings = useSettings();
|
||||
const serviceTextSpellCheck = spellCheckProps(settings, settings.spellCheckServiceText);
|
||||
const userId = pb.authStore.model?.id;
|
||||
const [services, setServices] = useState<ServiceItem[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
@@ -206,7 +213,7 @@ export default function ServiceCatalog() {
|
||||
const partsPrice = Number(serviceForm.partsPrice) || 0;
|
||||
const isSplitPricing = serviceForm.pricingMode === 'split';
|
||||
const description = serviceForm.description.trim();
|
||||
const data = {
|
||||
const data = formatServiceTextFields({
|
||||
name: serviceForm.name.trim(),
|
||||
category: serviceForm.category.trim(),
|
||||
description,
|
||||
@@ -217,7 +224,7 @@ export default function ServiceCatalog() {
|
||||
price: isSplitPricing ? laborPrice + partsPrice : fullPrice,
|
||||
active: editingService ? editingService.active : true,
|
||||
userId,
|
||||
};
|
||||
}, settings.applyServiceFormattingToCatalog ? settings : { ...settings, serviceNameFormat: 'preserve', serviceDescriptionFormat: 'preserve', serviceRecommendationFormat: 'preserve', serviceCategoryFormat: 'preserve', trimServiceText: true, addPeriodToServiceDescriptions: false });
|
||||
try {
|
||||
if (editingService?.id) {
|
||||
await pb.collection('services').update(editingService.id, data);
|
||||
@@ -332,7 +339,7 @@ export default function ServiceCatalog() {
|
||||
<div className="mt-3">
|
||||
<FormField label="Description">
|
||||
<div className="space-y-2">
|
||||
<Textarea value={serviceForm.description} onChange={(value) => setServiceForm((prev) => ({ ...prev, description: value }))} placeholder="Brief description of the service..." rows={2} />
|
||||
<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">
|
||||
<button
|
||||
type="button"
|
||||
|
||||
+662
-37
@@ -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 { useSettingsStore } from '../store/settings';
|
||||
import { parseRichText, hasRichText } from '../lib/richtext';
|
||||
import { spellCheckProps } from '../lib/spellcheck';
|
||||
import { isMissingCollectionError, logError } from '../lib/userMessages';
|
||||
import { settingsWriteSchema, accountSettingsWriteSchema } from '../schemas/settings';
|
||||
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: '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: '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: '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 ────────────────────────────────────────── */
|
||||
@@ -76,6 +82,18 @@ const TABS: SettingsTab[] = [
|
||||
// one place across the app.
|
||||
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 ─────────────────────────────────── */
|
||||
|
||||
function SectionCard({
|
||||
@@ -173,12 +191,16 @@ function Textarea({
|
||||
placeholder,
|
||||
rows = 3,
|
||||
hint,
|
||||
spellCheck,
|
||||
lang,
|
||||
}: {
|
||||
value: string;
|
||||
onChange: (val: string) => void;
|
||||
placeholder?: string;
|
||||
rows?: number;
|
||||
hint?: string;
|
||||
spellCheck?: boolean;
|
||||
lang?: string;
|
||||
}) {
|
||||
return (
|
||||
<div>
|
||||
@@ -187,6 +209,8 @@ function Textarea({
|
||||
onChange={(e) => onChange(e.target.value)}
|
||||
placeholder={placeholder}
|
||||
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"
|
||||
/>
|
||||
{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 ────────────────────────── */
|
||||
|
||||
export default function Settings() {
|
||||
@@ -953,6 +1210,7 @@ export default function Settings() {
|
||||
const merged = await loadSettingsForUser(userId);
|
||||
setSettings(merged);
|
||||
useSettingsStore.getState().setSettings(merged);
|
||||
setDarkMode(applyThemeMode(merged.appTheme));
|
||||
|
||||
// Load advisors
|
||||
await loadAdvisors();
|
||||
@@ -1166,12 +1424,10 @@ export default function Settings() {
|
||||
|
||||
/* ── Dark mode handler ────────────────────────── */
|
||||
|
||||
const handleDarkModeToggle = useCallback(() => {
|
||||
const next = !darkMode;
|
||||
setDarkMode(next);
|
||||
document.documentElement.classList.toggle('dark', next);
|
||||
localStorage.setItem('spq-dark-mode', String(next));
|
||||
}, [darkMode]);
|
||||
const handleThemeChange = useCallback((theme: ShopSettings['appTheme']) => {
|
||||
updateSetting('appTheme', theme);
|
||||
setDarkMode(applyThemeMode(theme));
|
||||
}, [updateSetting]);
|
||||
|
||||
/* ── Notifications save ───────────────────────── */
|
||||
|
||||
@@ -1243,6 +1499,7 @@ export default function Settings() {
|
||||
const showsGlobalSave = activeTab !== 'notifications';
|
||||
const handlePrimarySave = activeTab === 'account' ? saveAccountSettings : saveAllSettings;
|
||||
const primarySaveLabel = activeTab === 'account' ? 'Save Account' : 'Save';
|
||||
const quoteMessageSpellCheck = spellCheckProps(settings, settings.spellCheckQuoteMessages);
|
||||
|
||||
const tabNavigation = (
|
||||
<aside className="flex flex-col h-full">
|
||||
@@ -1580,6 +1837,14 @@ export default function Settings() {
|
||||
{activeTab === 'tax' && (
|
||||
<SectionCard title="Tax & Fees" icon={DollarSign}>
|
||||
<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">
|
||||
<FormField label="Tax Rate (%)">
|
||||
<Input
|
||||
@@ -1624,6 +1889,7 @@ export default function Settings() {
|
||||
onChange={(v) => updateSetting('shopChargeExplanation', v)}
|
||||
placeholder="e.g. A 3% shop charge is added to cover shop supplies..."
|
||||
rows={3}
|
||||
{...quoteMessageSpellCheck}
|
||||
/>
|
||||
</FormField>
|
||||
{!isModal && (
|
||||
@@ -1655,6 +1921,7 @@ export default function Settings() {
|
||||
onChange={(v) => updateSetting('headerMessage', v)}
|
||||
placeholder="Hi {customerName}, here is the explanation..."
|
||||
rows={3}
|
||||
{...quoteMessageSpellCheck}
|
||||
/>
|
||||
</FormField>
|
||||
<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)}
|
||||
placeholder="Thank you for choosing our shop!"
|
||||
rows={2}
|
||||
{...quoteMessageSpellCheck}
|
||||
/>
|
||||
<RichTextPreview text={settings.footerMessage} accentHex={settings.pdfAccentColor} />
|
||||
</FormField>
|
||||
@@ -1672,9 +1940,68 @@ export default function Settings() {
|
||||
onChange={(v) => updateSetting('quoteFooterNote', v)}
|
||||
placeholder="All prices include parts and labor unless otherwise noted."
|
||||
rows={2}
|
||||
{...quoteMessageSpellCheck}
|
||||
/>
|
||||
<RichTextPreview text={settings.quoteFooterNote} accentHex={settings.pdfAccentColor} />
|
||||
</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 && (
|
||||
<div className="flex justify-end pt-2">
|
||||
<button
|
||||
@@ -1709,6 +2036,277 @@ export default function Settings() {
|
||||
</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 ───────────────────────── */}
|
||||
{activeTab === 'business-ops' && (
|
||||
<div className="space-y-6">
|
||||
@@ -1719,6 +2317,10 @@ export default function Settings() {
|
||||
saving={saving}
|
||||
isModal={isModal}
|
||||
/>
|
||||
<DataEntryDefaultsSection
|
||||
settings={settings}
|
||||
updateSetting={updateSetting}
|
||||
/>
|
||||
<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">
|
||||
<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>
|
||||
)}
|
||||
|
||||
{/* ─── 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 ──────────────────────── */}
|
||||
{activeTab === 'notifications' && (
|
||||
<div className="space-y-6">
|
||||
@@ -1766,36 +2421,6 @@ export default function Settings() {
|
||||
</div>
|
||||
</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>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -6,6 +6,7 @@ export const settingsWriteSchema = z.object({
|
||||
businessAddress: z.string().optional().default(''),
|
||||
businessPhone: z.string().min(1, 'Business phone is required'),
|
||||
serviceAdvisor: z.string().min(1, 'Service advisor name is required'),
|
||||
taxEnabled: z.boolean().optional().default(true),
|
||||
taxRate: z.number().nonnegative().default(0),
|
||||
taxLabel: z.string().optional().default('Tax'),
|
||||
shopChargeRate: z.number().nonnegative().default(3),
|
||||
@@ -26,6 +27,69 @@ export const settingsWriteSchema = z.object({
|
||||
serviceLocationFieldsEnabled: z.boolean().optional().default(false),
|
||||
defaultTravelFee: z.number().nonnegative().default(0),
|
||||
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>;
|
||||
|
||||
+14
-4
@@ -3,6 +3,7 @@ import { persist } from 'zustand/middleware';
|
||||
import type { QuoteService, CustomerInfo, QuoteAuthorizationMethod } from '../types';
|
||||
import { loadSettings } from '../lib/settings';
|
||||
import { billableServices } from '../lib/totals';
|
||||
import { formatServiceTextFields } from '../lib/dataFormatting';
|
||||
|
||||
interface QuoteState {
|
||||
customerInfo: CustomerInfo;
|
||||
@@ -61,7 +62,9 @@ export const useQuoteStore = create<QuoteState>()(
|
||||
|
||||
addService: (service) => set((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) })),
|
||||
@@ -102,9 +105,16 @@ export const useQuoteStore = create<QuoteState>()(
|
||||
}),
|
||||
})),
|
||||
|
||||
updateService: (id, updates) => set((s) => ({
|
||||
services: s.services.map((x) => (x.id === id ? { ...x, ...updates } : x)),
|
||||
})),
|
||||
updateService: (id, updates) => set((s) => {
|
||||
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: [] }),
|
||||
setDiscount: (value, type) => set({ discount: { value, type } }),
|
||||
|
||||
@@ -171,6 +171,7 @@ export interface ShopSettings {
|
||||
businessAddress: string;
|
||||
businessPhone: string;
|
||||
serviceAdvisor: string;
|
||||
taxEnabled: boolean;
|
||||
taxRate: number;
|
||||
shopChargeRate: number;
|
||||
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
|
||||
showTechnicianOnQuote: boolean; // show technician name on PDF 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;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user