Files
2026-07-12 10:17:17 -04:00

322 lines
13 KiB
Markdown

# 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<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`):**
```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 <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
```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.