# SPQ Customer DB Integration Pattern ## Shared Helper Library (all forms) **`src/lib/customerName.ts`** provides three reusable functions used by AppointmentModal, ROForm, CustomerInfoPanel, and CustomerFormModal: - **`composeName(first, middle, last)`** — joins parts → single `name` string - **`parseName(fullName)`** — splits → `{ firstName, middleName, lastName }` (first-word/last-word heuristic) - **`findMatchingCustomer(customers, query)`** — generic matcher: phone (exact 3+ digits) → exact full name → partial name The appointment and RO forms use client-side matching on the loaded customers array (400ms debounce). The quote form (CustomerInfoPanel) uses PB queries with its own 600ms debounce. ## Name Split (firstName / middleName / lastName) `CustomerInfo` in SPQ stores both a single `name` field (for backward compat with existing code reading `customerInfo.name`) AND separate `firstName`, `middleName`, `lastName` fields for granular display/editing. ### Auto-compose rule (in `src/store/quote.ts`) When `setCustomerInfo()` is called, if `firstName`, `middleName`, or `lastName` changed, the store auto-composes `name` from the three parts joined by spaces. Empty parts are filtered out. When `name` is passed directly (legacy callers), it auto-splits into first/last using `.split(/\s+/)` — first word → `firstName`, rest → `lastName`, `middleName` left empty. ```typescript setCustomerInfo: (info) => set((s) => { const merged = { ...s.customerInfo, ...info }; // Auto-compose name from firstName + lastName if (info.firstName !== undefined || info.lastName !== undefined || info.middleName !== undefined) { const parts = [merged.firstName, merged.middleName, merged.lastName].filter(Boolean); merged.name = parts.join(' ') || merged.name; } // Backward compat: if caller passes `name` directly, split it if (info.name !== undefined && info.name !== merged.name && info.firstName === undefined) { const parts = info.name.trim().split(/\s+/); merged.firstName = parts[0] || ''; merged.lastName = parts.slice(1).join(' ') || ''; } return { customerInfo: merged }; }), ``` ### Helper to convert legacy flat fields Used in `QuoteGenerator.tsx` for URL params, RO data, and quote editing: ```typescript function toCustomerInfo(opts: { name?: string; phone?: string; vehicleInfo?: string; vin?: string; mileage?: string; roNumber?: string; serviceAdvisor?: string; }): CustomerInfo { const n = opts.name || ''; const parts = n.trim().split(/\s+/); return { firstName: parts[0] || '', middleName: '', lastName: parts.slice(1).join(' ') || '', name: n, phone: opts.phone || '', vehicleInfo: opts.vehicleInfo || '', vin: opts.vin || '', mileage: opts.mileage || '', roNumber: opts.roNumber || '', serviceAdvisor: opts.serviceAdvisor || '', email: '', }; } ``` ## Customer Record Auto-Creation ### `ensureCustomerRecord` helper (in `QuoteSummary.tsx`) When saving or sharing a quote, this function ensures a `customers` collection record exists: 1. If `customerInfo.customerId` is already set → use it (skip) 2. Search for existing customer by phone (digits, exact match via PB filter `phone ~`) 3. If not found by phone, search by exact name match 4. If still not found, create a new record in `customers` collection with `userId`, `name`, `firstName`, `lastName`, `phone`, `email` ```typescript async function ensureCustomerRecord(customerInfo: { name: string; firstName: string; lastName: string; phone: string; email?: string; }): Promise { if (!customerInfo.name.trim()) return undefined; const userId = pb.authStore.model?.id; if (!userId) return undefined; // Check by phone first const phoneDigits = customerInfo.phone.replace(/\D/g, ''); if (phoneDigits.length >= 4) { try { const existing = await pb.collection('customers').getFirstListItem( `phone ~ "${phoneDigits}" && userId = "${userId}"`, { fields: 'id', batch: 1 } ); if (existing) return existing.id; } catch { /* proceed */ } } // Check by name try { const existing = await pb.collection('customers').getFirstListItem( `name = "${customerInfo.name.replace(/"/g, '')}" && userId = "${userId}"`, { fields: 'id', batch: 1 } ); if (existing) return existing.id; } catch { /* proceed */ } // Create new try { const created = await pb.collection('customers').create({ name: customerInfo.name, firstName: customerInfo.firstName || customerInfo.name.split(' ')[0] || '', lastName: customerInfo.lastName || customerInfo.name.split(' ').slice(1).join(' ') || '', phone: customerInfo.phone, email: customerInfo.email || '', address: '', notes: '', userId, }); return created.id; } catch (err) { return undefined; } } ``` ### Integration points (all forms) - **`handleSave()`** in QuoteSummary.tsx — calls `ensureCustomerRecord` before saving the quote, passes `customerId` in the PB quote record - **`ensureShareToken()`** in QuoteSummary.tsx — same pattern for shared quotes - **RO conversion** in QuoteGenerator.tsx — reads `customerId` from the source quote and passes it to the RO creation payload ## Duplicate Detection (All Forms) The `CustomerInfoPanel` fires a debounced duplicate check (600ms after last keystroke on firstName/lastName/phone). It fetches all user's customers and filters client-side by name contains or phone contains. If a match is found, an amber banner offers "Use Existing" or "Create New" buttons. **Client-side filtering (safe fallback):** ```typescript const result = await pb.collection('customers').getList(1, 500, { fields: 'id,name,phone', batch: 500, }); const matched = (result.items as any[]).filter((c: any) => { const cName = (c.name || '').toLowerCase(); const cPhone = (c.phone || '').replace(/\D/g, ''); if (nameQuery && cName.includes(nameLc)) return true; if (phoneDigits.length >= 4 && cPhone.includes(phoneDigits)) return true; return false; }); ``` This avoids PB filter operator issues (the `~` operator can return 400 on some collections — see "Filter `~` operator can return 400" pitfall in the main SKILL.md). ## Customer Select → Multi-Source Data Population When an advisor selects a customer from the search dropdown, the form pre-fills vehicle info, VIN, mileage, email, and the last RO number. These fields are scattered across multiple PocketBase collections — no single query can populate them all. ### Fallback chain The `selectCustomer()` function in `CustomerInfoPanel.tsx` tries each source in order, stopping as soon as it finds data: ``` 1. vehicles collection └─ customerId = '' → year/make/model → vehicleInfo, vin, mileage 2. quotes collection (by customerId) └─ customerId = '' && userId = '' → vehicleInfo, vin, mileage, repairOrderNumber 3. quotes collection (by customerName — fallback for legacy records) └─ customerName = '' && userId = '' → same fields 4. repairOrders collection (by customerId) └─ customerId = '' && userId = '' → vehicleInfo, vin, mileage, roNumber 5. repairOrders collection (by customerName — fallback) └─ customerName = '' && userId = '' → same fields ``` Each lookup uses `getList(1, 1, { filter, sort: '-created' })` to get the most recent record. The `=` filter operator works reliably on text fields (unlike `~`). Results are merged — each field keeps the first non-empty value found. ### Why vehicles alone isn't enough Customers created via quote auto-save (`ensureCustomerRecord`) have NO vehicle records in the `vehicles` collection — the vehicle info only exists on their quote/RO records. So the fallback to quotes and ROs is essential for those customers. ### Full function pattern ```typescript const selectCustomer = useCallback(async (c: CustomerSearchResult) => { const userId = pb.authStore.model?.id; try { const full = await pb.collection('customers').getOne(c.id); let vehicleInfo = '', vin = '', mileage = '', roNumber = ''; // 1. Try vehicles collection try { const vResult = await pb.collection('vehicles').getList(1, 1, { filter: `customerId = '${c.id.replace(/'/g, "\\'")}'`, sort: '-created', fields: 'year,make,model,vin,mileage', }); if (vResult.items.length > 0) { const v = vResult.items[0] as any; vehicleInfo = [v.year, v.make, v.model].filter(Boolean).join(' '); vin = v.vin || ''; mileage = v.mileage || ''; } } catch { /* vehicles collection may not exist */ } // 2-5. Fallback to quotes/ROs const escapedId = c.id.replace(/'/g, "\\'"); const escapedName = (full.name || c.name || '').replace(/'/g, "\\'"); const idFilter = `customerId = '${escapedId}' && userId = '${userId}'`; const nameFilter = `customerName = '${escapedName}' && userId = '${userId}'`; for (const [collection, filterToTry] of [ ['quotes', idFilter], ['quotes', nameFilter], ['repairOrders', idFilter], ['repairOrders', nameFilter], ] as const) { if (vehicleInfo) break; // stop once we have data try { const result = await pb.collection(collection).getList(1, 1, { filter: filterToTry, sort: '-created', fields: 'vehicleInfo,vin,mileage,repairOrderNumber,roNumber', }); if (result.items.length > 0) { const r = result.items[0] as any; if (!vehicleInfo) vehicleInfo = r.vehicleInfo || ''; if (!vin) vin = r.vin || ''; if (!mileage) mileage = r.mileage || ''; if (!roNumber) roNumber = r.repairOrderNumber || r.roNumber || ''; } } catch { /* try next source */ } } const parts = (full.name || c.name || '').trim().split(/\s+/); setCustomerInfo({ customerId: c.id, firstName: parts[0] || '', middleName: parts.length > 2 ? parts.slice(1, -1).join(' ') : '', lastName: parts.length > 1 ? parts[parts.length - 1] : '', name: full.name || c.name || '', phone: full.phone || c.phone || '', email: full.email || '', vehicleInfo, vin, mileage, roNumber, }); } catch (e) { logError('Failed to load full customer record', e); // Fallback: set what we have from search result const parts = c.name.trim().split(/\s+/); setCustomerInfo({ customerId: c.id, firstName: parts[0] || '', lastName: parts.slice(1).join(' ') || '', name: c.name, phone: c.phone }); } }, [setCustomerInfo]); ``` ### Name splitting logic Given a customer name string, split into first/middle/last: ```typescript const parts = name.trim().split(/\s+/); const firstName = parts[0] || ''; const middleName = parts.length > 2 ? parts.slice(1, -1).join(' ') : ''; const lastName = parts.length > 1 ? parts[parts.length - 1] : ''; ``` - `"John Doe"` → `{ firstName: "John", middleName: "", lastName: "Doe" }` - `"John Michael Doe"` → `{ firstName: "John", middleName: "Michael", lastName: "Doe" }` - `"Alice"` → `{ firstName: "Alice", middleName: "", lastName: "" }`