initial commit

This commit is contained in:
ray
2026-07-12 10:17:17 -04:00
commit dab5a4ebc6
1424 changed files with 330463 additions and 0 deletions
@@ -0,0 +1,76 @@
# Cross-Origin / Same-Code Debugging (SPQ-specific)
Diagnose why the **same PocketBase + React app code** behaves differently on two domains.
## When to Use
User reports: "Feature X works on shopproquote.graj-media.com but returns 'Invalid input' / 'Error' on grajmedia.duckdns.org." Both serve the same build.
## Protocol
### 1. Verify code is identical
Check nginx config at `/etc/nginx/sites-enabled/`:
```bash
cat /etc/nginx/sites-enabled/shopproquote
```
Look for:
- `root` directive — same path for both `server_name` entries?
- `server_name` — both domains listed in the same `server` block?
In SPQ's deployment, nginx serves all three domains (`grajmedia.duckdns.org`, `graj-media.com`, `shopproquote.graj-media.com`) from a single `root` at `/mnt/seagate8tb/Websites/ShopProQuote.backup-20260626-2058/spq-v2/dist`.
### 2. Check backend proxy
In the same nginx config, check all `location /pb/` blocks:
```
proxy_pass http://127.0.0.1:8091/;
```
Both domains proxy to the same PocketBase instance. No difference possible.
### 3. Conclusion
If code AND backend are identical, the difference is **client-side state**:
- **localStorage** — per-origin; the Zustand `spq-quote` store is persisted here
- Service worker cache (less common)
- Cookies
### 4. Find the failing data path
In SPQ's Quote Generator, both **Save** (`handleSave` in `QuoteSummary.tsx`) and **Share** (`handleShare``ensureShareToken`) do:
```typescript
const parsed = quoteWriteSchema.safeParse(data);
if (!parsed.success) {
console.warn('Quote schema validation failed:', JSON.stringify(parsed.error.issues, null, 2));
showToast(parsed.error.issues[0].message, 'error');
return;
}
```
The toast message like "Invalid input" comes from Zod validation. Common fields that fail:
| Field | Schema constraint | Stale localStorage value |
|---|---|---|
| `discountType` | `z.enum(['dollar', 'percent'])` | `null`, `undefined`, or unexpected string |
| `customerDecision` (per service) | `z.enum(['approved', 'declined', 'pending'])` | `undefined` (missing from old store shape) |
| `customerName` | `z.string().min(1)` | `""` |
| `vehicleInfo` | `z.string().min(1)` | `""` |
### 5. Confirm stale localStorage
Open DevTools → Application → Local Storage → `<origin>` → key `spq-quote`.
Compare the stored `discount` and `services` arrays against the current Zod schema (`src/schemas/quote.ts`).
### 6. Fix
```js
localStorage.removeItem('spq-quote');
```
Reload and retry.
@@ -0,0 +1,266 @@
# 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<T>(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<string | undefined> {
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 = '<id>' → year/make/model → vehicleInfo, vin, mileage
2. quotes collection (by customerId)
└─ customerId = '<id>' && userId = '<uid>' → vehicleInfo, vin, mileage, repairOrderNumber
3. quotes collection (by customerName — fallback for legacy records)
└─ customerName = '<name>' && userId = '<uid>' → same fields
4. repairOrders collection (by customerId)
└─ customerId = '<id>' && userId = '<uid>' → vehicleInfo, vin, mileage, roNumber
5. repairOrders collection (by customerName — fallback)
└─ customerName = '<name>' && userId = '<uid>' → 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: "" }`
@@ -0,0 +1,79 @@
# GlitchTip / Sentry DSN Path-Prefix Quirk
## The Problem
When GlitchTip (Sentry-compatible) is hosted behind an nginx path prefix like `/glitchtip/`, the Sentry SDK's DSN parser **rejects** the multi-segment path.
**DSN format Sentry expects:**
```
https://<public_key>@<host>/<project_id>
```
Single path segment only. `/1` in the path is the project ID (a number).
**What doesn't work:**
```
https://<key>@host/glitchtip/1
```
The Sentry SDK v10+ DSN parser throws `Invalid Sentry Dsn` because `glitchtip/1` is two path segments, not one.
## The Fix
### 1. Change the DSN in env file
```
VITE_GLITCHTIP_DSN=https://<key>@host/1
```
Remove `/glitchtip` from the DSN path. Keep only the project ID.
### 2. Add nginx location for bare project-ID paths
The original `/glitchtip/` location remains for backward compatibility. Add a new regex location that matches `/<digits>/api/` and `/digits/store/` and proxies to GlitchTip:
```nginx
# In the server block:
location /glitchtip/ {
rewrite ^/glitchtip/[0-9]+/(api/.*)$ /$1 break;
rewrite ^/glitchtip/[0-9]+/(store/.*)$ /api/1/$1 break;
rewrite ^/glitchtip/(.*)$ /$1 break;
proxy_pass http://127.0.0.1:8001;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_buffering off;
}
# Bare project-ID path (for Sentry DSN compat)
location ~ ^/([0-9]+)/(api|store)/(.*)$ {
rewrite ^/([0-9]+)/api/(.*)$ /api/$1/$2 break;
rewrite ^/([0-9]+)/store/(.*)$ /api/$1/$2 break;
proxy_pass http://127.0.0.1:8001;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_buffering off;
}
```
### 3. Rebuild frontend
```bash
npm run build
sudo nginx -t && sudo systemctl reload nginx
```
### 4. Test
```bash
curl -s -o /dev/null -w "%{http_code}" -I "https://your-domain.com/1/api/1/envelope/"
```
Should return 405 (Method Not Allowed — HEAD on POST-only endpoint) or 400 (valid request reached GlitchTip), not 404.
## Verification
- Browser console: no `Invalid Sentry Dsn` error
- Sentry SDK initializes: `window.__SENTRY__` is defined
- Test events reach GlitchTip: check GlitchTip dashboard for new issues
@@ -0,0 +1,202 @@
# Quote-to-RO Approved Services Sync
## Overview
When a quote is generated from a repair order (`?fromRO=RO_ID` URL parameter) and the user approves or declines services, the approved services should be written back to the originating RO's services array. This keeps the RO's service list up-to-date with the customer's decisions.
## Implementation
The sync happens in `src/components/quoteGenerator/QuoteSummary.tsx` at two points:
1. **`handleSave`** (Save Quote button) — after saving the quote record, if `repairOriginId` is set
2. **`ensureShareToken`** (Share with Customer / Send Email / Send Text) — after creating the quote record, if `repairOriginId` is set
### Sync Logic
```typescript
if (repairOriginId) {
const approvedQuoteSvcs = services.filter((s) => s.approved);
if (approvedQuoteSvcs.length > 0) {
// 1. Load current RO
const ro = await pb.collection('repairOrders').getOne(repairOriginId);
let roServices: any[] = [];
const raw = (ro as any).services;
if (typeof raw === 'string' && raw.trim()) {
try { roServices = JSON.parse(raw); } catch { roServices = []; }
} else if (Array.isArray(raw)) {
roServices = raw;
}
// 2. Merge approved quote services into RO services
const rate = settings.defaultLaborRate || 95;
for (const qs of approvedQuoteSvcs) {
const price = parseFloat(String(qs.price ?? 0)) || 0;
const roSvc = {
id: `qs-${qs.id}`,
name: qs.name || '',
description: qs.explanation || '',
laborHours: Math.round((price / rate) * 100) / 100,
laborRate: rate,
partsCost: 0,
total: price,
status: 'pending' as const,
technician: '',
};
const existingIdx = roServices.findIndex((s) => s.name === qs.name);
if (existingIdx >= 0) {
// Update existing — preserve status and technician
roServices[existingIdx] = { ...roServices[existingIdx], ...roSvc,
status: roServices[existingIdx].status || 'pending' };
} else {
roServices.push(roSvc);
}
}
// 3. Save updated RO
await pb.collection('repairOrders').update(repairOriginId, {
services: JSON.stringify(roServices),
});
}
}
```
### Merge Rules
- **Same name = same service**: matched by `s.name === qs.name`
- **Existing service updated**: price/total/hours/rate overwritten, but `status` and `technician` are preserved
- **New service appended**: added as a new entry with status `'pending'`
- **Declined/pending services ignored**: only `s.approved === true` services get written back
### `repairOriginId` Flow
1. User opens RO → clicks "Generate Quote" → URL gets `?fromRO=RO_ID`
2. `QuoteGenerator.tsx` reads the param, sets `repairOriginId` state (line 74)
3. Passes `repairOriginId` to `QuoteSummary` component (line 601)
4. `QuoteSummary` includes `repairOrderId: repairOriginId` in the saved quote record (line 123/244)
5. After save, the sync block writes approved services back to that RO
### EnsureShareToken Sync Gap
The `ensureShareToken` function has a logic gap critical for debugging:
```typescript
let quoteId = editId;
if (!quoteId) {
// ... create new quote record ...
// ... SYNC approved services to RO ...
}
// ... generate share token ...
```
When `editId` is set (editing an existing quote), the `if (!quoteId)` block is **entirely skipped** — including the sync code. This means:
- **Save path**: `handleSave` sync runs for BOTH new and edit saves. Save-then-Share works.
- **Share-only path**: If the user Shares an existing quote WITHOUT first clicking Save, the sync is skipped. Approved services won't transfer.
- **Mitigation**: Extract the copy-pasted sync logic into a shared function called from both paths.
### Root Cause Patterns
Three root cause categories emerged from investigation:
### 1. Auth Ownership Mismatch (404, not 403)
PocketBase's `userId = @request.auth.id` rule returns **404** (not 403) when the authenticated user doesn't own the record. If `demo@shop.com` logs in but the RO belongs to `mani8994@gmail.com`, the sync silently fails — the inner catch block shows a toast that may go unnoticed.
**Diagnosis:** Check userId on both quote and RO:
```bash
curl -s -H "Authorization: $APP_TOKEN" \
"http://host:8091/api/collections/quotes/records/QUOTE_ID?fields=id,userId,repairOrderId"
curl -s -H "Authorization: $APP_TOKEN" \
"http://host:8091/api/collections/repairOrders/records/RO_ID?fields=id,userId"
```
Both must match the authenticated user's ID.
### 2. Missing `repairOrderId` on Legacy Quotes
Quotes created before the `repairOrderId` field was added (migration `1739999000006`) have an empty field. The edit-load effect won't set `repairOriginId` for these quotes, so the sync code exits early.
**Diagnosis:** Count quotes with non-empty repairOrderId:
```bash
curl -s -H "Authorization: $SUPERTOKEN" \
"http://host:8091/api/collections/quotes/records?filter=repairOrderId!=%27%27&perPage=1"
# %27 is URL-encoded single quote. In PocketBase filter syntax: repairOrderId!='' means "not empty"
```
### 3. Zustand Persist vs Edit-Load (Not a Real Race)
The Zustand `persist` middleware rehydrates synchronously from localStorage on first render, BEFORE the edit-load `useEffect` fires. The effect's `useQuoteStore.setState()` correctly overwrites with PocketBase data. No race condition exists in practice.
## Debugging: Direct API Verification
When the sync looks correct in code but the RO doesn't update, verify at the API level:
**1. Check repairOrderId on quotes:**
```bash
APP_TOKEN=$(curl -s -X POST http://host:8091/api/collections/users/auth-with-password \
-H 'Content-Type: application/json' \
-d '{"identity":"user@example.com","password":"pwd"}' | python3 -c "import json,sys;print(json.load(sys.stdin).get('token',''))")
curl -s -H "Authorization: $APP_TOKEN" \
"http://host:8091/api/collections/quotes/records?filter=repairOrderId!=%27%27&perPage=10&fields=id,customerName,repairOrderId,services" | python3 -c "
import json,sys; d=json.load(sys.stdin)
for q in d.get('items',[]):
svcs = json.loads(q.get('services','[]')) if isinstance(q.get('services'),str) else (q.get('services') or [])
approved = len([s for s in svcs if s.get('approved')])
print(f'{q[\"id\"][:12]} {q.get(\"customerName\",\"?\")[:20]} roId={q.get(\"repairOrderId\",\"\")[:12]} svcs={len(svcs)} approved={approved}')
"
```
**2. Check services on the linked RO:**
```bash
curl -s -H "Authorization: $APP_TOKEN" \
"http://host:8091/api/collections/repairOrders/records/RO_ID?skipTotal=1" | python3 -c "
import json,sys; d=json.load(sys.stdin)
svcs = json.loads(d.get('services','[]')) if isinstance(d.get('services'),str) else (d.get('services') or [])
print(f'Services in RO: {len(svcs)}')
"
```
**3. Manually test the update the sync code performs:**
```bash
curl -s -X PATCH "http://host:8091/api/collections/repairOrders/records/RO_ID" \
-H "Authorization: Bearer $APP_TOKEN" \
-H 'Content-Type: application/json' \
-d '{"services": "[{\"name\":\"Test\",\"total\":100,\"status\":\"pending\"}]"}'
```
If this succeeds, the code logic is correct — the issue is in the UI execution path.
**4. Auth ownership check:**
```bash
curl -s -H "Authorization: $APP_TOKEN" \
"http://host:8091/api/collections/quotes/records/QUOTE_ID?skipTotal=1&fields=id,userId,repairOrderId"
curl -s -H "Authorization: $APP_TOKEN" \
"http://host:8091/api/collections/repairOrders/records/RO_ID?skipTotal=1&fields=id,userId"
```
The `userId` fields must match. If they differ, the `userId = @request.auth.id` rule blocks the update with 404 (not 403).
### Debugging: Creating Test Data with Known Ownership
To isolate ownership variables, create test data owned by the same user:
```bash
# Authenticate and get user ID
RESP=$(curl -s -X POST http://host:8091/api/collections/users/auth-with-password \
-H 'Content-Type: application/json' \
-d '{"identity":"user@example.com","password":"pwd"}')
TOKEN=$(echo "$RESP" | python3 -c "import json,sys;print(json.load(sys.stdin).get('token',''))")
AUTH_ID=$(echo "$RESP" | python3 -c "import json,sys;print(json.load(sys.stdin).get('record',{}).get('id',''))")
# Create a test RO
curl -s -X POST "http://host:8091/api/collections/repairOrders/records" \
-H "Authorization: Bearer $TOKEN" \
-H 'Content-Type: application/json' \
-d "{\"userId\":\"$AUTH_ID\",\"customerName\":\"Test\",\"roNumber\":\"TEST-001\",\"status\":\"active\",\"services\":\"[]\"}"
# Create a linked quote with approved services
curl -s -X POST "http://host:8091/api/collections/quotes/records" \
-H "Authorization: Bearer $TOKEN" \
-H 'Content-Type: application/json' \
-d "{\"userId\":\"$AUTH_ID\",\"customerName\":\"Test\",\"repairOrderId\":\"RO_ID\",\"services\":\"[{\\\"id\\\":\\\"qs-1\\\",\\\"name\\\":\\\"Service\\\",\\\"price\\\":100,\\\"approved\\\":true}]\"}"
```
This ensures the test reproduces the same-owner scenario. If the sync still fails with same-owner data, the bug is in the code logic, not the auth layer.
@@ -0,0 +1,56 @@
# Settings Persistence Pattern (ShopProQuote)
## Architecture
Settings live in two places:
1. **localStorage** (`spq-settings` key) — always available, even without login
2. **PocketBase** `settings` collection — per-user JSON field (`data` field), only when logged in
## Loading order (Settings.tsx init)
1. `loadLocalSettings()` → reads localStorage, spreads over DEFAULT_SETTINGS
2. If logged in, fetch PB record → spread `{ ...DEFAULT_SETTINGS, ...data }` over the stored `data` JSON field
3. `saveLocalSettings(merged)` — overwrites localStorage with PB data so next load uses it
## Saving
- `updateSetting(key, value)` — updates local state + immediately writes to localStorage
- `saveAllSettings()` — saves to PocketBase (if logged in) + localStorage
## Type: ShopSettings (src/types.ts)
```typescript
export interface ShopSettings {
businessName: string;
businessAddress: string;
businessPhone: string;
serviceAdvisor: string;
taxRate: number;
taxLabel: string;
shopChargeRate: number;
shopChargeExplanation: string;
headerMessage: string;
footerMessage: string;
quoteFooterNote: string;
// PDF & Branding
logoUrl?: string;
showLogoOnPdf: boolean;
quoteExpiryDays: number;
quoteNumberPrefix: string;
pdfAccentColor: string;
// Business Ops
businessHours?: string;
defaultLaborRate: number;
paymentTerms: string;
}
```
## DEFAULT_SETTINGS lives in two places
Both MUST stay in sync:
- `src/pages/Settings.tsx` (the settings UI uses this)
- `src/pages/QuoteGenerator.tsx` (the PDF generator's `loadSettings()` fallback)
When adding a new setting: add to types.ts → add to both DEFAULT_SETTINGS → add UI field → add to pdf.ts usage.
@@ -0,0 +1,73 @@
#!/usr/bin/env python3
"""Serve a Vite SPA + proxy /pb to PocketBase. Generic, reusable."""
from http.server import HTTPServer, SimpleHTTPRequestHandler
import urllib.request
import os
DIST = os.environ.get('SPQ_DIST', './dist')
PB = os.environ.get('PB_URL', 'http://127.0.0.1:8091')
class Handler(SimpleHTTPRequestHandler):
def __init__(self, *args, **kwargs):
super().__init__(*args, directory=DIST, **kwargs)
def end_headers(self):
self.send_header('Access-Control-Allow-Origin', '*')
self.send_header('Access-Control-Allow-Methods', 'GET, POST, PUT, PATCH, DELETE, OPTIONS')
self.send_header('Access-Control-Allow-Headers', 'Authorization, Content-Type')
super().end_headers()
def do_OPTIONS(self):
self.send_response(204)
self.end_headers()
def do_GET(self):
if self.path.startswith('/pb') or self.path.startswith('/api'):
self.proxy()
else:
full = os.path.join(DIST, self.path.lstrip('/'))
if not os.path.exists(full) or (os.path.isdir(full) and self.path != '/'):
self.path = '/index.html'
super().do_GET()
def do_POST(self):
if self.path.startswith('/pb') or self.path.startswith('/api'):
self.proxy()
else:
self.send_error(405)
def do_PUT(self): self.proxy()
def do_PATCH(self): self.proxy()
def do_DELETE(self): self.proxy()
def proxy(self):
# PB SDK calls /pb/api/collections/... — path already includes /api/
url = PB + self.path
if self.path.startswith('/pb'):
url = PB + self.path[3:] # strip /pb prefix
if not url.startswith(PB + '/api'):
url = PB + '/api' + self.path[3:]
body = None
if self.headers.get('Content-Length'):
body = self.rfile.read(int(self.headers['Content-Length']))
req = urllib.request.Request(url, data=body, method=self.command)
for k, v in self.headers.items():
if k.lower() not in ('host', 'connection', 'origin', 'referer'):
req.add_header(k, v)
try:
resp = urllib.request.urlopen(req)
self.send_response(resp.status)
for k, v in resp.headers.items():
if k.lower() not in ('transfer-encoding', 'connection'):
self.send_header(k, v)
self.end_headers()
self.wfile.write(resp.read())
except urllib.error.HTTPError as e:
self.send_response(e.code)
self.end_headers()
self.wfile.write(e.read())
if __name__ == '__main__':
server = HTTPServer(('0.0.0.0', 4173), Handler)
print(f'Serving {DIST} on 0.0.0.0:4173 (PB → {PB})')
server.serve_forever()
@@ -0,0 +1,321 @@
# 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.