# SPQ Legacy Data Model — Migration Patterns ## Vehicle Data: Not a Separate Collection The SPQ legacy database has **no `vehicles` collection**. Vehicle data lives on `repairOrders` and `quotes` records as flat text fields: - `vehicleInfo` (text) — e.g. `"2021 Honda CR-V"`, `"2019 Honda Pilot"` - `vin` (text) — e.g. `"2HKRW2H99MH670367"` The legacy JS code (`customers.js`) dynamically assembles each customer's vehicles by: 1. Searching `repairOrders`, `quotes`, and `appointments` collections for records where `customerName` matches the current customer's name 2. Extracting `vehicleInfo` and `vin` from each matching record 3. Deduplicating by `vehicleInfo`, preferring records that have a VIN ```typescript // Pattern to follow when migrating this functionality const nameFilter = customerNames .map((n) => `customerName ~ '${n.replace(/'/g, "\\'")}'`) .join(' || '); // Query repairOrders and quotes const roResult = await pb.collection('repairOrders').getList(1, 500, { filter: nameFilter, fields: 'vehicleInfo,vin,customerName', }); ``` ### Key schema facts | Collection | Fields with vehicles | Notes | |---|---|---| | `repairOrders` | `vehicleInfo`, `vin`, `mileage`, `customerName` | Primary source. `customerId` is empty on all 55 records. | | `quotes` | `vehicleInfo`, `vin`, `mileage`, `customerName` | Secondary source. `customerId` is empty on all 47 records. | | `appointments` | `vehicleInfo` only (no `vin` column, no `customerId` column) | Has `customerName` field | | `customers` | `vehicleInfo` (text), `vin` (text) | Only one vehicle per customer; often empty | There is NO `vehicles` collection anywhere in the legacy database.* **Update (post-M16 migration):** A `vehicles` collection was added by migration M16 (`pb_migrations/1740000000007_create_vehicles.js`). It is user-scoped (`userId` + `customerId`) and stores vehicle records (make, model, year, vin, mileage, licensePlate, color, engine). New records created via the Customer page's vehicle editor go here. However, the legacy repair orders/quotes still have empty `customerId` and their vehicle data lives on the flat text fields. The `CustomerInfoPanel.tsx` code has a multi-tier fallback: (1) try `vehicles` collection by `customerId`, (2) fallback to `quotes` by `customerName`, (3) fallback to `repairOrders` by `customerName`. \* Coverage note: the `vehicles` table exists in SQLite (created by M16) but has no records in the backup dataset. **Critical: `customerId` is empty on ALL existing repair orders (55/55) and ALL quotes (47/47).** Any query using `customerId` as a filter will return zero records. Always use `customerName` for record linkage. If `customerId` is ever populated in the future, the `customerId` pattern will work, but the current data has zero records matching. ### Parsing vehicleInfo text `vehicleInfo` values follow the pattern `"YYYY Make Model"` (e.g. `"2021 Honda CR-V"`). A regex extraction handles this: ```typescript function parseVehicleInfo(info: string) { const match = info.trim().match(/^(\d{4})\s+(.+)$/); if (match) { const rest = match[2].trim(); const spaceIdx = rest.indexOf(' '); if (spaceIdx > 0) { return { year: match[1], make: rest.slice(0, spaceIdx), model: rest.slice(spaceIdx + 1), }; } return { year: match[1], make: rest, model: '' }; } return { year: '', make: info, model: '' }; } ``` ### Display in CustomerCard The card shows `vehicleCount` and the first vehicle's label via `formatVehicleLabel`: ```typescript function formatVehicleLabel(v: VehicleRecord): string { const parts = [v.year, v.make, v.model].filter(Boolean); return parts.length > 0 ? parts.join(' ') : 'Unknown vehicle'; } ``` ### Combine-and-deduplicate pattern across multiple collections When building a per-customer vehicle list from repairOrders + quotes, deduplicate by `vehicleInfo` within each customer: ```typescript // Build: customerName -> VehicleRecord[] const customerVehicleMap = new Map>(); for (const vs of vehicleSources) { if (!vs.vehicleInfo || !vs.customerName) continue; if (!customerVehicleMap.has(vs.customerName)) { customerVehicleMap.set(vs.customerName, new Map()); } const customerVehicles = customerVehicleMap.get(vs.customerName)!; const existing = customerVehicles.get(vs.vehicleInfo); // Prefer the entry that has a VIN if (!existing || (!existing.vin && vs.vin)) { const parsed = parseVehicleInfo(vs.vehicleInfo); customerVehicles.set(vs.vehicleInfo, { id: '', customerId: '', make: parsed.make, model: parsed.model, year: parsed.year, vin: vs.vin || '', licensePlate: '', mileage: '', color: '', engine: '', }); } } const enriched = customerList.map((c) => { const cv = customerVehicleMap.get(c.name); const vehicles = cv ? Array.from(cv.values()) : []; return { ...c, vehicles, vehicleCount: vehicles.length }; }); ``` ## Quotes Collection Schema The `quotes` collection stores quote data with the same customer-vehicle relationship pattern: | Field | Type | Notes | |-------|------|-------| | `customerId` | text | ⚠ EMPTY ON ALL 47 EXISTING RECORDS — use `customerName` for queries | | `customerName` | text | Denormalized for display | | `vehicleInfo` | text | e.g. "2023 Honda CR-V" | | `vin` | text | | | `mileage` | text | | | `status` | text | Values: `converted`, `sent`, `declined`, `draft` | | `createdAt` | text | ISO datetime | | `total` | number | Quote total | | `serviceAdvisor` | text | Advisor name | | `services` | json | Array of service objects | | `discountValue` | number | | | `discountType` | text | "dollar" or "percent" | **Fetch quotes by `customerName` (not `customerId`):** ```typescript // ✅ Works — customerName is the only reliable link const nameFilter = `customerName = '${customer.name.replace(/'/g, "\\'")}'`; const result = await pb.collection('quotes').getList(1, 100, { filter: nameFilter, sort: '-createdAt', fields: 'id,customerName,vehicleInfo,vin,mileage,status,createdAt,total,serviceAdvisor,services,notes', }); // ❌ Will return zero records — customerId is empty on all existing data const result = await pb.collection('quotes').getList(1, 100, { filter: `customerId = '${customerId}'`, ... }); ``` ### Quote services JSON — display pattern The `services` field stores a JSON array of service items. **PocketBase may return this field as either a pre-parsed JavaScript array OR a raw JSON string.** Always normalize: ```typescript function parseQuoteServices(raw: any): QuoteServiceItem[] { if (!raw) return []; let items = raw; if (typeof raw === 'string') { try { items = JSON.parse(raw); } catch { return []; } } if (!Array.isArray(items)) return []; return items.map((s: any) => ({ name: s.name || 'Service', price: typeof s.price === 'number' ? s.price : 0, customerDecision: s.customerDecision || 'pending', })); } ``` The expandable row shows each service name with its price and a colored decision badge: ```tsx {/* In a , each quote row is followed by a detail row */} <> toggleQuote(q.id)}> {/* summary columns: date, vehicle, total, status, advisor */} {isExpanded && (
{/* Service line items with name + price + Approved/Declined badge */} {/* Notes if present */} {/* VIN if present */} {/* Collapse button */}
)} ``` **Adjacent `` elements in a `` must be wrapped in a fragment (`<>...`) to satisfy JSX parsing.** Each `` retains its `key` prop. ### Quote status color mapping for UI ```typescript const qStatus = q.status === 'converted' ? 'bg-green-50 text-green-700 dark:bg-green-900/30 dark:text-green-400' : q.status === 'sent' ? 'bg-blue-50 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400' : q.status === 'declined' ? 'bg-red-50 text-red-700 dark:bg-red-900/30 dark:text-red-400' : 'bg-gray-50 text-gray-700 dark:bg-gray-900/30 dark:text-gray-400'; ``` ## Repair Orders Collection Schema | Field | Type | Notes | |-------|------|-------| | `customerId` | text | ⚠ EMPTY ON ALL 55 EXISTING RECORDS — use `customerName` | | `customerName` | text | | | `customerPhone` | text | Exists in DB but missing from TS `RepairOrder` type — add if used | | `customerEmail` | text | Exists in DB but missing from TS `RepairOrder` type — add if used | | `roNumber` | text | | | `vehicleInfo` | text | e.g. "2019 Honda Pilot" | | `vin` | text | | | `mileage` | text | | | `status` | text | Values: `active`, `in_progress`, `waiting_parts`, `waiting_pickup`, `completed`, `delivered` | | `workStatus` | text | Legacy values: `active`, `in-progress`, `waiter`, `completed`, `delivered` | | `writeupTime` | text | ISO datetime — timestamp of RO creation | | `estimatedTime` | text | Hours as string (e.g. `"1.5"`) — **NOT** `estimatedDuration` (minutes number) | | `promisedTime` | text | ISO datetime of promised completion time | | `completedTime` | text | | | `technician` | text | | | `notes` | text | | | `services` | json | JSON-stringified array of service objects | | `financial` | json | Financial breakdown + customerType storage | | `createdAt` | text | ISO datetime | | `updatedAt` | text | ISO datetime | Fetch repair orders by customer name: ```typescript const result = await pb.collection('repairOrders').getList(1, 100, { filter: `customerName = '${customerName}'`, sort: '-createdAt', fields: 'id,roNumber,customerId,customerName,vehicleInfo,vin,mileage,status,workStatus,createdAt,completedTime,writeupTime,technician,notes,services', }); ``` ## Other Schema Notes - `createdAt` / `updatedAt` (not `created` / `updated`) on most collections - `userId` is a plain `text` field (not a `relation` type) on all user-owned collections - `financial` is a `JSON` field on `repairOrders` - `services` is a `text` field on `repairOrders` (JSON-stringified array) - One superuser admin exists (not `_superusers` collection — legacy `/api/admins/` auth returns 404) - User auth uses `_superusers` collection via `POST /api/collections/_superusers/auth-with-password` (PB v0.39+) ## Cross-Page Data Passing: URL Query Parameters ### When to use Passing record context between pages when the source page (e.g., Repair Orders) needs to pre-populate a form on the target page (e.g., Quote Generator). The alternative is global state (Zustand store) or React Router location state, but URL params are preferred when: - The data should be shareable/bookmarkable - The target page needs to work standalone (refresh-safe) - Only flat key-value pairs need to pass (no complex nested objects) ### Implementation pattern **Sender** (source page): ```typescript import { useNavigate } from 'react-router-dom'; const navigate = useNavigate(); const handleGenerateQuote = () => { const params = new URLSearchParams({ name: ro.customerName || '', phone: ro.customerPhone || '', vehicleInfo: ro.vehicleInfo || '', vin: ro.vin || '', mileage: ro.mileage || '', roNumber: ro.roNumber || '', serviceAdvisor: ro.advisorName || '', }); navigate(`/quote?${params.toString()}`); }; ``` **Receiver** (target page): ```typescript import { useSearchParams } from 'react-router-dom'; const [searchParams] = useSearchParams(); const editId = searchParams.get('edit'); // Pre-populate from RO data passed via URL params (runs once on mount) useEffect(() => { if (editId) return; // don't pre-populate when editing an existing quote const name = searchParams.get('name'); if (!name) return; // no RO data — nothing to pre-fill setCustomerInfo({ name, phone: searchParams.get('phone') || '', vehicleInfo: searchParams.get('vehicleInfo') || '', vin: searchParams.get('vin') || '', mileage: searchParams.get('mileage') || '', roNumber: searchParams.get('roNumber') || '', serviceAdvisor: searchParams.get('serviceAdvisor') || '', }); }, [editId]); // depends on editId so it re-runs if editId changes ``` ### Pitfalls - **URL length limits.** Browsers cap URLs at ~2000 characters. For large objects (e.g., full service lists with 20+ items), use router state or a store instead. - **URI encoding.** `URLSearchParams.toString()` handles encoding automatically. Manually building query strings with template literals will break on special characters like `&` or `=` in values. - **Empty string values.** An empty `phone` param still appears in the URL (`&phone=`). This is harmless — the receiver's fallback-to-empty-string handles it. - **The effect dependency array matters.** Using `[]` (empty) means the pre-population runs on every mount, including when loading an existing quote for editing. Guard against this by checking `editId`. Using `[editId]` ensures the effect re-runs if the user switches between "new from RO" and "edit existing" flows. - **Competition with edit mode.** The URL may contain both `?edit=...` (load existing quote for editing) and `?name=...&phone=...` (pre-populate from RO). The `editId` guard in the effect prevents the pre-population from overwriting data loaded from the existing quote record.