322 lines
13 KiB
Markdown
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.
|