diff --git a/src/components/quoteGenerator/ServiceRow.tsx b/src/components/quoteGenerator/ServiceRow.tsx
index 128c21c..cd2e241 100644
--- a/src/components/quoteGenerator/ServiceRow.tsx
+++ b/src/components/quoteGenerator/ServiceRow.tsx
@@ -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 (
@@ -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"
/>
@@ -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"
/>
diff --git a/src/components/quoteGenerator/ServicesTable.tsx b/src/components/quoteGenerator/ServicesTable.tsx
index de8379a..6331ad2 100644
--- a/src/components/quoteGenerator/ServicesTable.tsx
+++ b/src/components/quoteGenerator/ServicesTable.tsx
@@ -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 (
diff --git a/src/lib/ai.ts b/src/lib/ai.ts
index 9f32e7d..3212ed5 100644
--- a/src/lib/ai.ts
+++ b/src/lib/ai.ts
@@ -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
{
- 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(),
};
}
diff --git a/src/lib/dataFormatting.ts b/src/lib/dataFormatting.ts
new file mode 100644
index 0000000..4aec7a0
--- /dev/null
+++ b/src/lib/dataFormatting.ts
@@ -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(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): 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): string {
+ return formatCustomerNamePart(value, settings);
+}
+
+export function formatVehicleInfo(value: string, settings: Pick): string {
+ const cleaned = collapseWhitespace(value);
+ if (settings.vehicleInfoFormat === 'smart_title') return smartTitleCase(cleaned);
+ return cleaned;
+}
+
+export function formatVin(value: string, settings: Pick): string {
+ const cleaned = value.trim().replace(/\s+/g, '');
+ return settings.vinUppercase ? cleaned.toUpperCase() : cleaned;
+}
+
+export function formatLicensePlate(value: string, settings: Pick): string {
+ const cleaned = collapseWhitespace(value);
+ return settings.licensePlateFormat === 'uppercase' ? cleaned.toUpperCase() : cleaned;
+}
+
+export function formatMileage(value: string, settings: Pick): 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),
+ };
+}
diff --git a/src/lib/pdf.ts b/src/lib/pdf.ts
index af65f45..b663143 100644
--- a/src/lib/pdf.ts
+++ b/src/lib/pdf.ts
@@ -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);
diff --git a/src/lib/settings.ts b/src/lib/settings.ts
index 7cee76c..053527f 100644
--- a/src/lib/settings.ts
+++ b/src/lib/settings.ts
@@ -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,
};
diff --git a/src/lib/spellcheck.ts b/src/lib/spellcheck.ts
new file mode 100644
index 0000000..12fad9b
--- /dev/null
+++ b/src/lib/spellcheck.ts
@@ -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,
+ };
+}
diff --git a/src/lib/totals.ts b/src/lib/totals.ts
index cd0aede..ad92b3e 100644
--- a/src/lib/totals.ts
+++ b/src/lib/totals.ts
@@ -19,10 +19,12 @@ export interface TotalsBreakdown {
total: number;
}
+type TaxAndFeeSettings = Pick & Partial>;
+
export interface QuoteTotalsInput {
services: QuoteService[];
discount: { value: number; type: 'dollar' | 'percent' };
- settings: Pick;
+ 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,
+ 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;
+ 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 {
diff --git a/src/pages/Customers.tsx b/src/pages/Customers.tsx
index a2491b8..9c6fc21 100644
--- a/src/pages/Customers.tsx
+++ b/src/pages/Customers.tsx
@@ -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 () => {
diff --git a/src/pages/QuoteGenerator.tsx b/src/pages/QuoteGenerator.tsx
index 6b24560..bafe1c6 100644
--- a/src/pages/QuoteGenerator.tsx
+++ b/src/pages/QuoteGenerator.tsx
@@ -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)}`,
diff --git a/src/pages/RODetailModal.tsx b/src/pages/RODetailModal.tsx
index d6f794f..b43d7cb 100644
--- a/src/pages/RODetailModal.tsx
+++ b/src/pages/RODetailModal.tsx
@@ -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 Split;
}
-function EditableServiceRow({ service, index, onChange, onRemove, defaultLaborRate = 145 }: {
+function EditableServiceRow({ service, index, onChange, onRemove, defaultLaborRate = 145, spellCheck, lang }: {
service: Partial;
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
{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 (