13 KiB
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:
- Searching
repairOrders,quotes, andappointmentscollections for records wherecustomerNamematches the current customer's name - Extracting
vehicleInfoandvinfrom each matching record - Deduplicating by
vehicleInfo, preferring records that have a VIN
// 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:
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:
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:
// Build: customerName -> VehicleRecord[]
const customerVehicleMap = new Map<string, Map<string, VehicleRecord>>();
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):
// ✅ 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:
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:
{/* In a <tbody>, each quote row is followed by a detail row */}
<>
<tr key={q.id} className="cursor-pointer" onClick={() => toggleQuote(q.id)}>
{/* summary columns: date, vehicle, total, status, advisor */}
</tr>
<tr key={q.id + '-detail'}>
<td colSpan={5} className="p-0">
{isExpanded && (
<div className="bg-gray-50 px-6 py-4 dark:bg-gray-800/50">
{/* Service line items with name + price + Approved/Declined badge */}
{/* Notes if present */}
{/* VIN if present */}
{/* Collapse button */}
</div>
)}
</td>
</tr>
</>
Adjacent <tr> elements in a <tbody> must be wrapped in a fragment (<>...</>) to satisfy JSX parsing. Each <tr> retains its key prop.
Quote status color mapping for UI
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:
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(notcreated/updated) on most collectionsuserIdis a plaintextfield (not arelationtype) on all user-owned collectionsfinancialis aJSONfield onrepairOrdersservicesis atextfield onrepairOrders(JSON-stringified array)- One superuser admin exists (not
_superuserscollection — legacy/api/admins/auth returns 404) - User auth uses
_superuserscollection viaPOST /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):
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):
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
phoneparam 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 checkingeditId. 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). TheeditIdguard in the effect prevents the pre-population from overwriting data loaded from the existing quote record.