initial commit
This commit is contained in:
@@ -0,0 +1,728 @@
|
||||
---
|
||||
name: shop-pro-quote
|
||||
description: Develop and maintain the ShopProQuote auto repair shop management app — PocketBase backend, vanilla JS frontend (v1) and React/Vite SPA frontend (v2), Tailwind CSS, OCR scan features.
|
||||
---
|
||||
|
||||
# ShopProQuote Development
|
||||
|
||||
PocketBase-backed auto repair shop management web app at `/mnt/seagate8tb/Websites/ShopProQuote/`.
|
||||
|
||||
## Stack
|
||||
|
||||
- **Backend**: PocketBase v0.39.1 (ghcr.io/muchobien/pocketbase:latest) on Docker at 127.0.0.1:8091
|
||||
- **v1 Frontend** (legacy, live at port 443): Vanilla JS by Mike. Tailwind via CDN — do NOT attempt build-step Tailwind (dynamic classes in template literals would be purged). See `references/local-testing.md`.
|
||||
|
||||
**Conventions:** Phone #s stored as digits, displayed `555-555-5555` via `src/lib/phone.ts` (see `references/phone-formatting.md`). New quote services default to Pending. RO form extracted to `src/components/ROForm.tsx`. RO Details tab: see `references/ro-details-tab.md`. RO Detail Modal (4-tab viewer): see `references/ro-detail-modal.md`.
|
||||
- **Auth**: users collection only (no superuser for app login; `pb.authStore.model.collectionName === 'users'`). Test user creation — see `references/pocketbase-test-users.md`.. Test user creation — see `references/pocketbase-test-users.md`.
|
||||
- **Reverse proxy**: nginx on 443 SSL. DeepSeek via `/deepseek/` proxy.
|
||||
- **Local dev**: Vite — see `references/local-dev-server.md` for proxy config, PocketBase SDK `import` keyword fix, admin API paths.
|
||||
- **PDF gen**: jspdf 4.x `src/lib/pdf.ts` — see `references/pdf-generation.md` for array-text pitfall and page-break rules.
|
||||
- **Served at**: `https://grajmedia.duckdns.org`
|
||||
|
||||
## Service Data Model
|
||||
|
||||
Services are stored in PocketBase `services` collection (pbc_863811952) with fields:
|
||||
`id, userId, name, description, price, category, duration, maintenanceInterval, technicianNotes, createdAt, updatedAt, recommendation, explanation`
|
||||
|
||||
- `recommendation` = the REASON (e.g., "pads worn to 2mm") — displayed in blue uppercase in service cards
|
||||
- `explanation` = the customer-facing EXPLANATION text — displayed in gray italic in service cards, used in PDFs, and populated by AI Write
|
||||
- `maintenanceInterval` = mileage interval in miles (e.g., 5000 for oil changes). Optional. Used by AI Suggest and displayed in quote builder as "📅 Due every X,XXX miles — Your [vehicle] has YY,YYY miles"
|
||||
- `technicianNotes` = internal-only notes for AI context. NEVER shown in customer-facing output (PDF, print). Shown in service cards as collapsible "🔧 Tech Notes (Internal)" details element and in Settings service list as "🔧 Notes: ..."
|
||||
- These fields were added post-migration via PATCH to the PocketBase schema.
|
||||
|
||||
### State Normalization
|
||||
|
||||
When loading services into `quoteStateManager.setSelectedServices()`, normalize defaults:
|
||||
```javascript
|
||||
setSelectedServices(services) {
|
||||
const normalized = services.map(s => ({
|
||||
...s,
|
||||
applyShopCharge: s.applyShopCharge !== false // default ON
|
||||
}));
|
||||
this.updateState({ selectedServices: normalized }, ['selectedServices']);
|
||||
}
|
||||
```
|
||||
Without this, services loaded from saved quotes lack `applyShopCharge` and render unchecked.
|
||||
|
||||
## Files (key pages)
|
||||
|
||||
See `references/file-map.md` for the full table.
|
||||
Supporting references: `references/ai-features.md`, `references/deepseek-proxy.md`, `references/spq-v2-pitfalls.md` (query/React/date/nginx pitfalls), `references/pdf-layout-patterns.md` (jsPDF fill-before-text, dynamic heights, color visibility), `references/dropdown-teleport-pattern.md` (portal to dropdown-root), `references/pdf-generation.md` (jsPDF layout pitfalls), `references/ui-pitfalls.md` (dropdown stacking context, state clearing).
|
||||
|
||||
| Page | HTML | JS | Notes |
|
||||
|------|------|----|-------|
|
||||
| Appointments | `appointments.html` | `appointments.js` | OCR scan, create/edit/list |
|
||||
| Repair Orders | `repair-orders.html` | `repair-orders.js`, `repair-orders-utils.js` | Tabs, modals, settings |
|
||||
| Customers | `customers.html` | `customers.js` | User email population on auth |
|
||||
| Login | `login.html` | — | Auth via PocketBase adapter |
|
||||
| Shared | — | `pocketbase.js`, `ui.js`, `style.css`, `shared/`, `auto-save-draft.js` | Adapter, notifications, modal manager, form draft persistence |
|
||||
|
||||
## Architecture Notes
|
||||
|
||||
- `pocketbase.js` adapter maps PocketBase REST to Firebase-compatible API surface (`collection()`, `addDoc()`, `getDocs()`, `onAuthStateChanged()`, etc.)
|
||||
- `shared/debug.js` — DEBUG-gated logging utility. Exports `log()` (only logs when `DEBUG=true`), `warn()`, `error()`. All JS files import from here instead of using raw `console.log`. Set `DEBUG = false` for production.
|
||||
- `shared/skeleton.js` — Exports `skeletonCard(lines)` and `skeletonRow()` for pulse-animation loading placeholders. Replace raw "Loading..." text patterns with these.
|
||||
- `shared/modal-manager.js` — Centralized modal utilities. Exports `confirmDialog(message, title)` (themed replacement for native `confirm()` — returns Promise<boolean>), `registerAccountSettingsModal()`, and `registerSiteSettingsModal()` for deduplicated settings handling across pages.
|
||||
- `shared/confirm.js` — Exports `confirmDialog(message, title)` that replaces native `confirm()` with a styled, dark-mode-aware modal. Returns Promise<boolean>. All `confirm()` calls in the codebase have been replaced with `await confirmDialog(...)`.
|
||||
- `api.js` exports `streamAIResponse(systemPrompt, userContent, onChunk, options)` for streaming LLM responses with typewriter rendering. Supports `AbortSignal` via `options.signal` for cancellation. Used by Daily Briefing.
|
||||
- Module scripts (`type="module"`) use `import` from `pocketbase.js`. Inline `<script>` blocks (not modules) need globals exposed via `window.xxx = ...` or local helpers.
|
||||
- Settings persisted via `settings` collection in PocketBase with `data` JSON field + `userId` + `name` for upsert.
|
||||
- Model/agent routing for this project: main agent uses orchestrator model, subagents use worker model (configured in Hermes).
|
||||
- **Inline IIFE modal handlers**: `repair-orders.html` has a capture-phase click handler (lines 1376-1427) that defines `window.openModal`/`window.closeModal` and intercepts modal button clicks BEFORE module code. See `references/inline-iife-modal-handlers.md` for the full event flow, selector escaping fix, and diagnostic approach.
|
||||
- **PocketBase real-time subscriptions**: `onSnapshot()` in pocketbase.js uses PocketBase SSE subscriptions (`pb.collection(name).subscribe('*', callback)`) for live dashboard updates. Returns a proper unsubscribe function. Dashboard subscriptions are set up in `setupRealtimeSubscriptions()` and cleaned up on page unload. Collections subscribed: repairOrders, appointments, tasks, quotes.
|
||||
|
||||
## User Preferences
|
||||
|
||||
- **PREFER LOCAL SOLUTIONS OVER EXTERNAL API DEPENDENCIES.** If a feature can work without an external API, build it that way. Rule-based parsers, local LLMs (Ollama on RTX 2080 Ti FE 11GB), and regex are preferred over paid/external API calls. Exception: the appointment scan screenshot feature defaults to DeepSeek V4 Flash (cloud) by user preference — local Ollama remains available as a fallback in the model dropdown. The cost difference is negligible (~$0.15/yr vs ~$0.55/yr GPU power). See `references/deepseek-proxy-cost.md`.
|
||||
- **Questions vs actions**: When the user asks a question (\"is there a database...?\", \"what are my options...?\"), answer it without taking action. Only implement when explicitly authorized (\"yes implement this\", \"go ahead\").
|
||||
- Direct, concise responses. Stop and plan before executing.
|
||||
- Prefer container restart over destroy-and-recreate (`docker compose restart`).
|
||||
- Ask before container lifecycle operations.
|
||||
- Dislikes VPNs on mobile; prefers nginx reverse proxy.
|
||||
|
||||
### Appointment Screenshot Extraction Architecture
|
||||
|
||||
**Two-stage pipeline: Tesseract.js OCR (client-side) → DeepSeek V4 Flash (cloud, default) or qwen2.5:14b (local fallback)**.
|
||||
|
||||
**CSP requirements for Tesseract.js v5** — the `<meta>` CSP tag must include:
|
||||
- `script-src ... 'unsafe-eval'` (WASM compilation)
|
||||
- `connect-src ... https://cdn.jsdelivr.net blob:` (WASM + traineddata downloads, worker comms)
|
||||
- `worker-src blob:` (inline Web Workers)
|
||||
|
||||
Without these, Tesseract silently hangs during initialization ("Running OCR..." forever).
|
||||
|
||||
Vision models (LLaVA 7B/13B) were tried and abandoned — they struggle with dense tabular grids, hallucinate names, and can't follow rigid JSON schema instructions reliably.
|
||||
|
||||
**Current flow** (`appointments.html` inline script, ~line 1512):
|
||||
1. **Tesseract OCR** with adaptive upscale (2×/3×) + mild contrast enhancement + PSM 4 (single column) → raw text
|
||||
2. **Text LLM** receives the OCR text + detailed system prompt → returns JSON array
|
||||
|
||||
**Dual LLM backend** (added 2026-06-15): The scan feature now supports two backends selected via the model dropdown:
|
||||
- **`deepseek-v4-flash`** (DEFAULT) → VPS nginx proxy at `/deepseek/v1/chat/completions` → `api.deepseek.com`. Uses OpenAI format, non-thinking mode (`thinking: {type: 'disabled'}`). Response parsed from `choices[0].message.content`. Cost: ~$0.0004/scan.
|
||||
- **`qwen2.5:14b`** (Local fallback) → `/llm/api/chat` → Ollama at 127.0.0.1:11434. Uses Ollama format. Response parsed from `message.content`. Free but needs GPU VRAM.
|
||||
|
||||
The code auto-detects which backend to use based on the selected model, switching endpoint, request format, and response parsing accordingly. Both paths share the same retry logic (auto-retry on 502/503 with 5s delay for cold model loading).
|
||||
|
||||
**Model selector**: The dropdown in the scan modal (`id="vision-model-select"`) defaults to `deepseek-v4-flash`. The dropdown ID is a legacy name — it now selects text models, not vision models.
|
||||
|
||||
**Why this is better**: Tesseract handles pixel-to-text (what OCR is good at), and qwen2.5:14b handles structure understanding (what text LLMs are good at). Neither step requires a vision model.
|
||||
|
||||
**OCR preprocessing — critical for dealership schedule screenshots**: These screenshots are very wide (~1780px) with many narrow columns, making text hard to OCR. Three preprocessing steps dramatically improve accuracy:
|
||||
- **Adaptive upscale**: Upscale 3× for small images (<1MP, ~1000×1000), 2× for larger screenshots. This gives Tesseract enough pixel data per character while avoiding the massive canvases (5760×3240) that choke browser-side OCR. The 2× threshold was added after discovering 3× on full-HD screenshots caused 60-180+ second hangs or silent Web Worker crashes.
|
||||
- **Mild contrast enhancement** (NOT aggressive binarization): Only push extremes (>240→white, <30→black). Leave mid-tones alone — Tesseract handles them better than a hard binary cutoff at 128, which destroys anti-aliased edges on small text
|
||||
- **PSM 4 (single column)**: `tessedit_pageseg_mode: '4'` tells Tesseract the page has variable-sized text blocks in a single column — critical for the dealership schedule's row-per-appointment layout. Without PSM 4, Tesseract defaults to auto mode which splits each appointment row into multiple disconnected chunks
|
||||
|
||||
**OCR + LLM pipeline** (`appointments.html` inline script):
|
||||
```javascript
|
||||
// Step 1: Adaptive upscale + mild contrast + PSM 4
|
||||
const preprocessed = await new Promise((resolve, reject) => {
|
||||
const img = new Image();
|
||||
img.onload = () => {
|
||||
const canvas = document.createElement('canvas');
|
||||
const ctx = canvas.getContext('2d');
|
||||
// Upscale for better OCR — 2x for large images, 3x for small ones
|
||||
const scale = (img.width * img.height > 1000000) ? 2 : 3;
|
||||
canvas.width = img.width * scale;
|
||||
canvas.height = img.height * scale;
|
||||
ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
|
||||
|
||||
// Mild contrast enhancement (not aggressive binarization)
|
||||
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
|
||||
const data = imageData.data;
|
||||
for (let i = 0; i < data.length; i += 4) {
|
||||
const avg = (data[i] + data[i+1] + data[i+2]) / 3;
|
||||
if (avg > 240) data[i] = data[i+1] = data[i+2] = 255;
|
||||
else if (avg < 30) data[i] = data[i+1] = data[i+2] = 0;
|
||||
}
|
||||
ctx.putImageData(imageData, 0, 0);
|
||||
|
||||
canvas.toBlob(blob => blob ? resolve(blob) : reject(new Error('Canvas toBlob failed')), 'image/png');
|
||||
};
|
||||
img.onerror = () => reject(new Error('Image load failed'));
|
||||
img.src = URL.createObjectURL(file);
|
||||
});
|
||||
|
||||
// Tesseract with FULL phase logging + 90s timeout
|
||||
// CRITICAL: Tesseract.js v5 has 4 silent init phases (loading core, initializing,
|
||||
// loading traineddata, initializing API) where no progress fires. The logger
|
||||
// MUST show ALL phases, not just "recognizing text", or the user sees
|
||||
// "Running OCR..." with zero feedback during the 10-30s init.
|
||||
const ocrResult = await Promise.race([
|
||||
Tesseract.recognize(preprocessed, 'eng', {
|
||||
logger: m => {
|
||||
console.log(`[OCR] ${m.status} ${m.progress ? Math.round(m.progress*100)+'%' : ''}`);
|
||||
if(m.status === 'recognizing text') {
|
||||
progressText.textContent = `OCR: ${Math.round(m.progress*100)}%`;
|
||||
} else {
|
||||
progressText.textContent = `Tesseract: ${m.status}...`;
|
||||
}
|
||||
},
|
||||
tessedit_pageseg_mode: '4'
|
||||
}),
|
||||
new Promise((_, reject) => setTimeout(() => reject(new Error('OCR timed out after 90s')), 90000))
|
||||
]);
|
||||
const ocrText = ocrResult.data.text;
|
||||
|
||||
// Step 2: Date OCR pass (PSM 11) with the same pattern
|
||||
const dateOcrResult = await Promise.race([
|
||||
Tesseract.recognize(preprocessed, 'eng', {
|
||||
logger: m => {
|
||||
progressText.textContent = `Date OCR: ${m.status}...`;
|
||||
},
|
||||
tessedit_pageseg_mode: '11'
|
||||
}),
|
||||
new Promise((_, reject) => setTimeout(() => reject(new Error('Date OCR timed out')), 30000))
|
||||
]);
|
||||
|
||||
// Step 3: Send OCR text to text LLM (NOT vision model) — with retry for cold model
|
||||
const selectedModel = document.getElementById('vision-model-select')?.value || 'qwen2.5:14b';
|
||||
let llmResp, lastErr;
|
||||
for (let attempt = 0; attempt < 2; attempt++) {
|
||||
try {
|
||||
llmResp = await fetch('/llm/api/chat', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
model: selectedModel,
|
||||
messages: [{ role: 'user', content: systemPrompt + '\n\n### OCR TEXT FROM SCREENSHOT:\n' + ocrText }],
|
||||
stream: false,
|
||||
options: { temperature: 0.0 }
|
||||
})
|
||||
});
|
||||
if (llmResp.ok) break;
|
||||
if (llmResp.status !== 502 && llmResp.status !== 503) throw new Error('LLM error: ' + llmResp.status);
|
||||
console.log(`LLM attempt ${attempt+1} failed with ${llmResp.status}, retrying...`);
|
||||
progressText.textContent = 'AI warming up, retrying...';
|
||||
await new Promise(r => setTimeout(r, 5000));
|
||||
} catch(e) {
|
||||
if (attempt === 1) throw e;
|
||||
console.log(`LLM attempt ${attempt+1} error, retrying...`);
|
||||
await new Promise(r => setTimeout(r, 3000));
|
||||
}
|
||||
}
|
||||
if (!llmResp.ok) throw new Error(lastErr);
|
||||
// Step 4: Robust JSON extraction with indexOf('{')...lastIndexOf('}')
|
||||
const llmData = await llmResp.json();
|
||||
let rawJson = llmData.message.content;
|
||||
const startIdx = rawJson.indexOf('{');
|
||||
const endIdx = rawJson.lastIndexOf('}');
|
||||
if (startIdx !== -1 && endIdx !== -1 && endIdx > startIdx) {
|
||||
rawJson = rawJson.substring(startIdx, endIdx + 1);
|
||||
}
|
||||
const parsed = JSON.parse(rawJson);
|
||||
```
|
||||
|
||||
**System prompt**: The full extraction prompt with column layout, OCR artifact recovery rules, and VIN digit mappings is in `references/appointment-system-prompt.md`. Key features:
|
||||
- Explicit column order: Time | Customer(s) | Phone | Advisor | OpCode | Service | Duration | RO# | VehicleInfo | VIN
|
||||
- OCR artifact decoding: time garbling patterns (ESCAM→8:00AM), VIN digit swaps (8↔B, 5↔S, 0↔O), duration garbling (CS→0.5, FS→3.5)
|
||||
- Honda VIN prefix hints for recovery (1HG, 5FN, JHM, 19X)
|
||||
- Advisor tag stripping from service descriptions
|
||||
|
||||
**Text LLM server**: Ollama running `qwen2.5:14b` (~9GB) on `127.0.0.1:11434`, proxied through nginx at `/llm/` (120s timeout, `proxy_buffering off`). This is the SAME endpoint as the AI Write / Priority Analysis features — no separate vision endpoint needed for appointments.
|
||||
|
||||
**System prompt** is defined as `const systemPrompt` before the try block — instructs the text LLM to parse OCR text (not an image) into the appointment JSON schema. The full prompt text is in `references/appointment-system-prompt.md`.
|
||||
|
||||
**Parser (rules fallback)**: `parseWithRules()` handles phone, VIN, date, time, duration, vehicle, name/service extraction. See `references/ocr-parser.md` for the full parser code and test cases.
|
||||
|
||||
**OCR-to-fields strategy** (for building similar parsers):
|
||||
1. **Phase 0 — Extract header date**: Before any stripping, scan the first 15 lines for a date pattern (e.g., "Monday, Jun 09, 2026", "Jun 09", "6/9/2026"). Save as `headerDate`. This is critical because screenshots put the appointment date in the top-middle header.
|
||||
2. Split input into blocks by blank lines
|
||||
3. Strip separator lines (`---`, `===`) and short ALL-CAPS header lines. Strip schedule metadata (day-of-week + date like "Monday Jun 09") — but only AFTER Phase 0 captured it.
|
||||
4. Try multi-line block as table: if >2 lines and first line has header words (`name|phone|date`), parse each line individually
|
||||
5. For each block: extract structured fields (phone, date, time, VIN, duration) by regex FIRST, replacing matches with spaces to preserve column gaps
|
||||
6. Split remaining on multi-spaces (preserves column structure) — if that fails, fall back to single-space word heuristics
|
||||
7. **Customer name extraction**: Skip parts matching service words (`isServiceWord()` blocklist), year-prefixed vehicle descriptions, or long all-caps strings before grabbing the name. Never carry a service word as a name across blocks.
|
||||
8. **Phase 4 — Apply header date**: After all appointments are parsed, any appointment missing `appointmentDate` gets `headerDate` assigned automatically.
|
||||
|
||||
### Service Word Blocklist
|
||||
|
||||
A blocklist of 28 common shop/service words prevents OCR confusion where service descriptions bleed into customer name fields:
|
||||
|
||||
```javascript
|
||||
var serviceWords = {SERVICE:1,REPAIR:1,CHECK:1,MAINTENANCE:1,INSPECTION:1,DIAGNOSTIC:1,DIAG:1,
|
||||
REPLACE:1,REPLACEMENT:1,INSTALL:1,REMOVE:1,ADJUST:1,ALIGNMENT:1,ROTATION:1,BALANCE:1,
|
||||
FLUSH:1,DRAIN:1,FILL:1,TUNE:1,UP:1,ESTIMATE:1,WAITING:1,APPOINTMENT:1,REQUEST:1,
|
||||
CUSTOMER:1,VEHICLE:1,ADVISOR:1,TECHNICIAN:1,MECHANIC:1};
|
||||
function isServiceWord(w) { w = w.toUpperCase().replace(/[^A-Z]/g,''); return serviceWords[w] || w.length > 12; }
|
||||
```
|
||||
|
||||
Three guards use this blocklist:
|
||||
- **CarryName validation**: When a name is carried from a previous block, reject it if `isServiceWord()`
|
||||
- **Step 7 name extraction**: Skip `parts[0]` if it matches a service word before assigning to `customerName`
|
||||
- **Step 10 cross-block carry**: Don't carry trailing all-caps words if they match the blocklist or are >20 chars
|
||||
|
||||
## Auto-Save Drafts
|
||||
|
||||
`auto-save-draft.js` is a shared module that saves form field values to localStorage when the user is filling out a form, then restores them if the page reloads or the user navigates away.
|
||||
|
||||
### How it works
|
||||
- Watches specified field IDs for `input`/`change` events
|
||||
- Debounces saves (2s after last keystroke) to `spq-draft-<key>` in localStorage
|
||||
- On page load, restores saved values and dispatches `input`/`change` events so app state updates
|
||||
- Clears draft automatically on form submit (for `<form>` elements) or via `window.autoSaveDraft.clearDraft(key)`
|
||||
- Respects `window.appSettings.autoSaveForms` — toggling off clears all drafts immediately
|
||||
|
||||
### Wiring it up
|
||||
|
||||
```html
|
||||
<!-- Add script before inline blocks -->
|
||||
<script type="module" src="auto-save-draft.js"></script>
|
||||
```
|
||||
|
||||
```javascript
|
||||
// Call after form elements exist in DOM
|
||||
if (window.autoSaveDraft) {
|
||||
window.autoSaveDraft.init('#form-selector', 'storage-key', [
|
||||
'field-id-1', 'field-id-2', ...
|
||||
]);
|
||||
}
|
||||
```
|
||||
|
||||
For forms without a `<form>` tag (e.g., modal buttons), clear the draft manually on success:
|
||||
```javascript
|
||||
if (window.autoSaveDraft) window.autoSaveDraft.clearDraft('storage-key');
|
||||
```
|
||||
|
||||
### Capturing dynamic state (services, non-input data)
|
||||
|
||||
For forms with dynamic non-input state (e.g., services added/removed in the quote tab, which live in `quoteStateManager` not DOM inputs), use three features:
|
||||
|
||||
**`getExtraData` callback** — returns an object merged into the draft under `_extra`:
|
||||
```javascript
|
||||
getExtraData: function() {
|
||||
const services = quoteStateManager.getSelectedServices();
|
||||
return { services: services.map(s => ({ name, price, partsNotInStock, aftermarketAvailable })) };
|
||||
}
|
||||
```
|
||||
|
||||
**`onRestoreExtra` callback** — receives `_extra` on page load to restore complex state:
|
||||
```javascript
|
||||
onRestoreExtra: function(extra) {
|
||||
if (extra && extra.services) quoteStateManager.setSelectedServices(extra.services);
|
||||
}
|
||||
```
|
||||
|
||||
**`triggerSave()` method** — call when non-input state changes (e.g., in a state subscription):
|
||||
```javascript
|
||||
if (changedKeys.includes('selectedServices')) {
|
||||
renderSelectedServices();
|
||||
updateQuoteSummary();
|
||||
if (window._quoteAutoSave && window._quoteAutoSave.triggerSave) {
|
||||
window._quoteAutoSave.triggerSave(); // debounced 2s
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Store the init return value as `window._quoteAutoSave` so the state subscription handler can access it.
|
||||
|
||||
**Important**: The `getExtraData` callback runs at save time, not init time, so `quoteStateManager` is always available. But `onRestoreExtra` may fire during `waitForForm()` → `restore()` which can happen before `quoteStateManager` finishes initializing. Guard with `typeof quoteStateManager !== 'undefined'` and wrap in try/catch.
|
||||
|
||||
### Currently wired forms
|
||||
| Page | Storage Key | Form | Fields |
|
||||
|------|-------------|------|--------|
|
||||
| Repair Orders | `ro-create` | `#create-ro-form` | create-customer-name, create-customer-phone, create-vehicle-info, create-mileage, create-vin, create-ro-number, create-estimated-time, create-status, create-services |
|
||||
| Appointments | `appointment-create` | `#create-appointment-modal` | customer-name, customer-phone, vehicle-info, vin-number, appointment-date, appointment-time, duration, reason, notes |
|
||||
| Quote Tab | `quote-tab` | `#generate-quote-content` | customerName, customerPhone, serviceAdvisor, repairOrderNumber, mileage, vehicleInfo, vin + **services** (via `getExtraData`/`onRestoreExtra`) |
|
||||
|
||||
## AI Features (Implemented)
|
||||
|
||||
Four LLM-powered features run via the local Ollama endpoint (`/llm/v1/chat/completions`, model `qwen2.5:14b`). All follow the same fetch pattern used in `api.js`.
|
||||
|
||||
### Daily Briefing (dashboard.js → index.html panel)
|
||||
|
||||
Generates a natural-language shop briefing on dashboard load. Key implementation details:
|
||||
|
||||
- **Data gathered**: today's appointments (with names+times), active ROs (with overdue customer details and promised times), pending quotes (total value), quotes with unaddressed per-service customer decisions (customer names + pending service counts)
|
||||
- **Time-of-day greeting**: computed from `new Date().getHours()` — `< 12` = "Good morning", `< 17` = "Good afternoon", else "Good evening". Never hardcode "Good morning."
|
||||
- **Anti-Team system prompt**: `"You are speaking directly to [Name], a service advisor. You are NOT a foreman addressing a team. ALWAYS address [Name] by their first name. Never say 'Team' or 'everyone.'"` The user's name comes from `window.appSettings?.serviceAdvisor`.
|
||||
- **Output**: bullet-point format with conversational opening, highlights overdue ROs and pending customer decisions first, ends with encouraging sentence
|
||||
- **Rendering**: detects `- ` / `• ` / `* ` prefixes and styles as blue-dot bullet points with proper spacing
|
||||
- **Cache**: 5-minute localStorage TTL; "Refresh" button bypasses cache
|
||||
- **Panel title**: "Daily Briefing" (NOT "Morning Briefing" — time-agnostic)
|
||||
- **max_tokens**: 400 (room for bullet-point format)
|
||||
|
||||
**Pitfalls:**
|
||||
- LLM ignores soft "address by name" instructions → must explicitly say "You are NOT a foreman. Never say Team."
|
||||
- Hardcoded "Good morning" in user prompt → compute from `getHours()`
|
||||
- Quote analysis only checked quote-level status, missing per-service `customerDecision` → now counts `pending` services within each quote and reports customer names
|
||||
|
||||
### Smart Upsell (quote-tab-manager.js → repair-orders.html)
|
||||
|
||||
"AI Suggest" button in Quote Generator Actions panel. Reads vehicle info + selected services + available catalog → LLM suggests complementary services.
|
||||
|
||||
- **System prompt includes explicit mileage tiers**: 30K-60K (transmission, coolant, filters), 60K-90K (timing belt, water pump, suspension), 90K+ (comprehensive, fuel system), ANY 50K+ (brake inspection, alignment, rotation). Must suggest 2-4 services.
|
||||
- **Catalog data sent**: name, category, price, duration — gives LLM rich context
|
||||
- **Rendering**: suggestion cards with service name, AI reasoning, price, one-click "Add" button (dims to "Added ✓")
|
||||
- **max_tokens**: 500
|
||||
|
||||
**Pitfalls:**
|
||||
- Generic "suggest 1-3 services" prompt returns 0-1 suggestions → must include mileage-specific thresholds and require "at least 2"
|
||||
- Catalog without prices/durations → LLM can't make informed suggestions → include price+duration in catalog data
|
||||
- **CRITICAL: LLM fabricates vehicle conditions it can't know** — the original prompt said "this mileage/condition warrants this service" and "be proactive." The LLM took this as permission to invent inspection findings like "shocks are leaking," "brakes are worn to 2mm," "cabin filter is dirty." It has NO inspection data. **Fix**: the system prompt must explicitly say "You have NOT inspected this vehicle and do NOT know its actual condition. NEVER invent or assume a condition. Do NOT say anything is leaking, worn, cracked, failing, low, dirty, or due unless referencing a manufacturer mileage interval. Every reason MUST cite a mileage milestone or complementary pairing." Also add explicit complementary pairings (brake pads → rotors, timing belt → water pump) and only suggest struts/shocks when mileage exceeds 80K (manufacturer interval, not a fabricated condition).
|
||||
- `setSelectedServices()` must normalize `customerDecision` defaults for any service added via upsell
|
||||
|
||||
### Tech Note Translation (repair-orders.js → Create RO form)
|
||||
|
||||
"Technician Notes" textarea + "Translate Notes" button on Create RO form. Technician types raw notes → LLM returns structured JSON with findings, severity, customer explanations.
|
||||
|
||||
- **Output format**: JSON array with `finding`, `severity` (CRITICAL/RECOMMENDED/PREVENTIVE), `explanation`, `estimatedCost`
|
||||
- **Rendering**: color-coded severity badges (red/amber/blue), explanation text, cost estimate, "Add to Services" button per finding
|
||||
- **Persistence**: `technicianNotes` field saved to PocketBase with the RO; auto-save draft includes `technician-notes` field
|
||||
- **max_tokens**: 600
|
||||
|
||||
### Customer Decision Tracking
|
||||
|
||||
See "Customer Decision Tracking" section above for full implementation. Key interaction with other features:
|
||||
- Daily Briefing counts unaddressed services per quote
|
||||
- PDF groups into "SERVICES APPROVED" / "POSTPONED SERVICES" sections when decisions are mixed
|
||||
- `setSelectedServices()` normalizes `customerDecision: 'pending'` for all services loaded from PocketBase
|
||||
|
||||
**Reference**: `references/settings-modal-architecture.md` — for the full settings modal element-ID inventory and build pattern. `references/settings-field-addition-pattern.md` — for the step-by-step checklist when adding a new setting across 2 JS files + 4 HTML files. `references/ocr-parser.md` — for the OCR parser code. `references/quote-save-flow.md` — for the quote save/load data model and flow. `references/pocketbase-schema-patch.md` — for adding missing fields to PocketBase collections. `references/dashboard-overview.md` — for the complete dashboard layout. `references/appointment-system-prompt.md` — the full OCR-text-to-JSON system prompt. `references/element-id-mismatches.md` — recurring element ID mismatch patterns and the diagnostic approach when buttons silently do nothing. `references/ai-prompt-rules.md` — AI prompt engineering rules that prevent fabrication, force use of context, and ensure helpful outputs. `references/nginx-permission-pitfall.md` — the recurring 600→403 nginx permission bug, prevention checklist, and diagnostic commands. `references/form-validator-silent-failure.md` — FormValidator using wrong element IDs causing silent no-op saves. `references/pocketbase-json-field-guard.md` — three-step guard for PocketBase nested JSON fields returning as strings vs arrays. `references/model-lineup.md` — current model inventory and GPU fit. `references/ai-prompt-rules.md` — AI prompt engineering rules (no fabricated conditions, mileage-only, occurrence counts). `references/ai-features.md` — architecture, prompt patterns, and data flow for AI features.
|
||||
|
||||
### 1. AI Write (`api.js:handleAiWrite()`)
|
||||
|
||||
Custom service modal: user types a service name + recommendation reason → LLM generates a professional customer-facing explanation with priority level.
|
||||
|
||||
**Enhanced capabilities (added 2026-06-13):**
|
||||
- Accepts optional `technicianNotesEl` (textarea element) and `vehicleContext` ({vehicleInfo, mileage, maintenanceInterval}) parameters
|
||||
- Technician notes are fed to the LLM as additional context ("Technician's internal notes: ...") but NEVER appear in customer-facing output
|
||||
- When mileage + maintenance interval are provided, the prompt includes ordinal calculations: "This would be the 2nd time. Next service due at 60,000 miles (0 miles ago)." Uses an `ordinal(n)` helper function
|
||||
- Every AI-generated explanation now requires: (1) what the service involves, (2) why it's needed (using recommendation + tech notes + mileage context), (3) what could happen if left unaddressed (1 sentence, using qualifying language like "may potentially lead to")
|
||||
- Explanation stays 3-5 sentences — clear enough for customers, concise enough to be read
|
||||
|
||||
**Call sites (4 total — update ALL when changing signature):**
|
||||
1. `settings.js` Edit Service modal (line ~821) — passes `service-edit-technician-notes` element, null vehicleContext
|
||||
2. `settings.js` Add Service modal (line ~833) — passes `add-service-technician-notes` element, null vehicleContext
|
||||
3. `quote-tab-manager.js` `setupServiceEditModal()` (line ~1549) — passes technician notes + vehicleCtx from DOM
|
||||
4. `quote-tab-manager.js` `setupCustomServiceModalEventListeners()` (line ~1336) — same, dynamic import
|
||||
|
||||
```javascript
|
||||
// Payload pattern (enhanced)
|
||||
fetch('/llm/v1/chat/completions', {
|
||||
body: JSON.stringify({
|
||||
messages: [
|
||||
{role:'system', content: longPromptWithFormattingRules},
|
||||
{role:'user', content: `Service: ${name}\nRecommendation: ${reason}${additionalContext}`}
|
||||
],
|
||||
temperature: 0, max_tokens: 400
|
||||
})
|
||||
});
|
||||
// Response parsed for: LEVEL: [...] and EXPLANATION: [...]
|
||||
```
|
||||
|
||||
### Technician Notes (Internal-Only AI Context)
|
||||
|
||||
A `technicianNotes` textarea on all service forms (Add Custom Service modal, Edit Service modal, Settings service editor). This field:
|
||||
|
||||
- **Purpose**: Gives the AI additional context when generating customer explanations. Example: "Last done at 27K miles — fluid was dark. Recommend drain and fill, not flush."
|
||||
- **Visibility**: SHOP-ONLY. Displayed in quote builder service cards as a collapsible `<details>` element with amber styling. Displayed in Settings service list as "🔧 Notes: ...". Explicitly EXCLUDED from PDF/print output.
|
||||
- **Storage**: PocketBase `services` collection, `technicianNotes` field (text, not required)
|
||||
- **AI Write integration**: Notes are passed to `handleAiWrite()` as the 5th parameter. The LLM prompt includes them under "Technician's internal notes:" and uses them to generate more specific, context-aware explanations
|
||||
- **Pitfall**: When adding new fields to the services collection via PocketBase API PATCH, the field won't appear in the app until settings.js `saveServiceEdits()`/`saveNewService()` includes it in the payload. Check all save handlers.
|
||||
|
||||
### Maintenance Interval on Services
|
||||
|
||||
A `maintenanceInterval` (number, miles) field on each service. Used to show mileage-aware context.
|
||||
|
||||
- **Settings form**: "Maintenance Interval (miles)" input with step=1000, placeholder "e.g., 5000". Shown after the price field in both Add and Edit service forms.
|
||||
- **Quote builder display**: When a service has an interval AND the vehicle has mileage filled in, shows: "📅 Due every 5,000 miles — Your 2018 Honda Accord has 48,000 miles"
|
||||
- **AI Suggest integration**: Interval data is included in the catalog sent to the LLM. Prompt instructs: "If a service in the catalog has a maintenanceInterval, recommend it when the vehicle mileage is approaching or past that interval"
|
||||
- **AI Write integration**: When mileage + interval are present, the LLM computes `floor(mileage / interval)` to determine the occurrence number (1st, 2nd, 3rd) and how many miles past due the service is. These appear in the generated explanation.
|
||||
|
||||
### 2. Priority Analysis (`api.js:getPriorityAnalysis()`)
|
||||
|
||||
Takes an array of service objects → returns JSON ranked by safety criticality. Used by the quote builder.
|
||||
|
||||
### 3. Generate Priorities (`quote-tab-manager.js`)
|
||||
|
||||
Same concept as #2 but different entry point — "Generate Priorities" button in the quote tab. Uses `SERVICE_N: PRIORITY_LEVEL - reason` format with robust fallback parsing in `parseAndApplyPriorities()` that overrides LLM misclassifications with keyword-based rules (lines 2168-2251).
|
||||
|
||||
### Response format
|
||||
|
||||
All three use the same fetch pattern:
|
||||
```javascript
|
||||
const response = await fetch('/llm/v1/chat/completions', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
messages: [
|
||||
{role:'system', content: instructions},
|
||||
{role:'user', content: data}
|
||||
],
|
||||
temperature: 0,
|
||||
max_tokens: 500
|
||||
})
|
||||
});
|
||||
const result = await response.json();
|
||||
const text = result.choices[0].message.content;
|
||||
```
|
||||
|
||||
**Note**: This is the OpenAI-compatible path (`choices[0].message.content`), NOT the Gemini path (`candidates[0].content.parts[0].text`). The nginx proxy at `/llm/` handles routing to Ollama at `127.0.0.1:11434`.
|
||||
|
||||
## AI Features
|
||||
|
||||
See `references/ai-features.md` for AI integration details (DeepSeek proxy, Ollama fallback, scan-to-quote).
|
||||
|
||||
### Daily Briefing (`dashboard.js` → `generateDailyBriefing()`)
|
||||
Dashboard panel showing appointment count, active ROs (overdue + waiters), and quotes. Uses per-service `customerDecision` field to split quotes into "awaiting decisions" vs "decided but not converted." Time-of-day greeting (morning/afternoon/evening). Cached 5 min in localStorage. Streaming typewriter rendering via `streamAIResponse()`.
|
||||
|
||||
### Smart Upsell (`quote-tab-manager.js` → `aiSuggestServices()`)
|
||||
"AI Suggest" button in Quote Generator Actions panel. Sends vehicle profile + selected services + available catalog + maintenance intervals → LLM returns 2-4 suggestions with reasons.
|
||||
|
||||
### AI Write (`api.js` → `handleAiWrite()`)
|
||||
Generates customer-facing explanations from service name + recommendation reason + technician notes + vehicle/mileage context. Computes occurrence count (floor(mileage ÷ interval)) and past-due status. Requires: describe service, explain why needed, state consequence of inaction. 3-5 sentences max.
|
||||
|
||||
### Priority Analysis (`api.js` → `getPriorityAnalysis()`)
|
||||
Ranks services by safety criticality. Uses keyword-based fallback in `parseAndApplyPriorities()`.
|
||||
|
||||
### AI Prompt Rules (critical — the LLM fabricates otherwise)
|
||||
- **NEVER invent conditions.** Do not say "leaking," "worn," "cracked," "failing" unless referencing a manufacturer mileage interval. The AI has NOT inspected the vehicle.
|
||||
- Every reason MUST cite a mileage milestone or complementary pairing.
|
||||
- Maintenance intervals: compute `floor(mileage ÷ interval)` for occurrence count. Use ordinals (1st, 2nd, 3rd). Mention "approaching" if within 2K miles of next interval.
|
||||
- Always include consequence of inaction (1 sentence, use "may potentially" not "will").
|
||||
|
||||
### AI Write Call Sites (3 setup functions, different element IDs)
|
||||
1. `setupCustomServiceModalEventListeners()` — Add Custom Service modal
|
||||
2. Service Edit modal (inline, dynamically created) — IDs use `-quote` suffix
|
||||
3. Settings → Edit/Add Service — IDs use `-settings` suffix
|
||||
When debugging "AI Write does nothing," verify the element IDs match what the handler expects.
|
||||
|
||||
## Per-Service Customer Approval/Decline
|
||||
|
||||
Each service in quote builder has `customerDecision` field: `'pending'` (default) | `'approved'` | `'declined'`.
|
||||
- Green border + badge for approved, red + strikethrough for declined
|
||||
- Quote summary shows approved subtotal
|
||||
- PDF groups into "SERVICES APPROVED" and "POSTPONED SERVICES" sections when decisions are mixed
|
||||
- Briefing counts only quotes with pending decisions as "needing decisions"
|
||||
|
||||
## Maintenance Intervals
|
||||
|
||||
`maintenanceInterval` field (number, miles) on services collection. Shown in service cards as "📅 Due every X,XXX miles — Your [vehicle] has YY,YYY miles" when mileage is filled in. AI Suggest uses interval data to compute occurrence counts.
|
||||
|
||||
## Technician Notes (Internal Only)
|
||||
|
||||
`technicianNotes` field (text) on services. Visible to shop in Settings and quote builder (collapsible `<details>`). Excluded from PDF/print. Fed to AI Write as additional context for generating explanations. Never shown to customers.
|
||||
|
||||
## Quote Save Bug
|
||||
|
||||
When updating an existing quote, use `updateDoc(quoteRef, quoteData)` NOT `setDoc()`. `setDoc()` searches by `name` field which doesn't exist on quotes collection → update silently fails. `updateDoc()` directly targets the PocketBase record ID.
|
||||
|
||||
## Clear Quote Confirmation
|
||||
|
||||
`resetQuoteState()` shows `confirm()` dialog when clearing an unsaved quote (no `currentQuoteId` AND has content). Saved quotes clear immediately.
|
||||
|
||||
## Pitfalls
|
||||
|
||||
New files default to 600 (owner-only). Nginx (www-data) returns 403. Fix: `chmod 644 <file>`. See also `references/ui-pitfalls.md` for dropdown, state-clearing, and PDF pitfalls.
|
||||
|
||||
- **Worker-created shared modules break entire site when 403**: When `shared/debug.js` returns 403, EVERY module that imports it silently fails. `repair-orders.js` → no tab handlers, no data loading. `dashboard.js` → briefing stuck. The failure is invisible — no console error (403 is a network error, not a JS error). Symptom: pages load but all JS features are dead. First diagnostic: `curl -sk -o /dev/null -w "%{http_code}" https://localhost/shared/debug.js`.
|
||||
|
||||
- **`console.log` → `log()` replacement creates import dependency**: The `shared/debug.js` module exports `log()`, `warn()`, `error()`. Every .js file that uses `log()` MUST import it. Non-module `<script>` blocks need `window.log = window.log || function(){};` as fallback. When replacing `console.log` with `log`, verify every file has the import.
|
||||
|
||||
- **Dynamic Tailwind classes get purged by build tools**: The codebase constructs Tailwind classes in JS template literals (`bg-${color}-600`, `${config.textColor}`). Build-step Tailwind purging destroys every dynamically-constructed class. **Keep the CDN approach** (`cdn.tailwindcss.com`) — do not attempt a compiled/purged Tailwind build for this project. Attempted once and reverted — site was completely broken. If switching to a build step, every dynamic class must be safelisted.
|
||||
|
||||
- **AI Write duplicate event listener accumulation (race condition)**: `setupServiceEditEventListeners()` was called every time the Edit Service modal opened, attaching a new click handler each time via `addEventListener()`. After N opens, clicking AI Write fired N+1 simultaneous API calls. The last response to arrive overwrote the textarea — appearing as the explanation "reverting on its own." **Fix**: add a guard at the top of the function: `if (modal.hasAttribute('data-edit-event-listeners-attached')) return; modal.setAttribute('data-edit-event-listeners-attached', 'true');`. Also check for duplicate handlers in `setupServiceEditModal()` — it had a SECOND AI Write handler that added a permanent extra listener. Remove duplicate handlers, keep only one.
|
||||
|
||||
- **AI Write element ID mismatches — handler looks for wrong IDs**: The edit modal AI Write handler at `setupServiceEditEventListeners()` originally looked for button `ai-write-btn-main` or `ai-write-btn-fallback`, explanation `service-edit-explanation-main` or `service-edit-explanation`, and technician notes `custom-service-technician-notes`. But the actual HTML IDs are `ai-write-btn-quote`, `service-edit-explanation-quote`, and `service-edit-technician-notes-quote`. The handler was never finding any elements. **Fix**: always include the `-quote` suffix variants FIRST in fallback chains. The full chain should be: button = `ai-write-btn-main || ai-write-btn-fallback || ai-write-btn-quote`, explanation = `service-edit-explanation-quote || service-edit-explanation-main || service-edit-explanation`, notes = `service-edit-technician-notes-quote || service-edit-technician-notes || custom-service-technician-notes`.\n\n- **Save Changes button ID includes `btn` — handler misses it**: The Save Changes button in the edit modal has ID `service-edit-save-btn-quote` (notice `btn`). But `setupServiceEditEventListeners()` line 1254 originally looked for `service-edit-save` or `service-edit-save-quote`. Neither matches. The saveBtn was null, no click handler was attached, and clicking Save Changes silently did nothing. **Fix**: add `service-edit-save-btn-quote` to the fallback chain: `const saveBtn = document.getElementById('service-edit-save') || document.getElementById('service-edit-save-btn-quote') || document.getElementById('service-edit-save-quote');`\n\n- **AI Write button should sit below the explanation textarea, not overlaid**: The original HTML had the AI Write button as `absolute top-2 right-2` inside a `relative` container over the textarea. This causes the button to float over the text and can produce OCR artifacts in screenshots. **Fix**: remove the `relative` wrapper and `absolute` positioning. Place the button as a separate row below the textarea with `mt-2` spacing.
|
||||
|
||||
- **FormValidator silent failure — wrong element IDs block save**: `setupServiceEditEventListeners()` initialized a `FormValidator` with element IDs like `service-edit-form`, `service-edit-name`, `service-edit-price`, `service-edit-recommendation`. But the actual HTML modal uses `-quote` suffixes: `service-edit-form-quote`, `service-edit-name-quote`, etc. Every `document.getElementById()` returned null, so `validateForm()` silently returned `false` on every call. `saveServiceEdit()` was never reached — no error, no notification, just "nothing happens." **Fix**: bypass the FormValidator for the save path and call `saveServiceEdit()` directly, which has its own validation with user-visible error messages. Alternatively, fix all FormValidator IDs to match the HTML.
|
||||
|
||||
- **AI Write ignores technician notes — prompt structure issue**: The `additionalContext` (technician notes + vehicle data) was appended to the user message but the system prompt never instructed the LLM to read or use it. The LLM followed the prompt literally and only used the service name and recommendation reason. **Fix**: (1) Move the additional context INTO the system prompt body with a prominent "ADDITIONAL CONTEXT FROM THE TECHNICIAN:" header. (2) Add explicit numbered tasks: "3. If technician notes are provided, incorporate that specific information into the explanation." (3) Add a `WITH_NOTES` example showing exactly how to incorporate notes. (4) Increase sentence count from 2-4 to 3-5 when notes are present. Without all four changes, the LLM defaults to ignoring the notes.
|
||||
|
||||
- **AI Write element ID mismatches**: The edit modal AI Write handler looks for specific element IDs. When adding new fields to modals, verify the handler's `getElementById` calls match the actual HTML IDs. The setup uses `||` fallback chains — add new IDs to the start of the chain, not the end.
|
||||
|
||||
- **Hardcoded Ollama model names in `api.js`**: The AI Write, Priority Analysis, and Generate Priorities features all call `fetch('/llm/v1/chat/completions')` with `model: 'qwen2.5:14b'` hardcoded. When you swap, upgrade, or reinstall the LLM via Ollama, these hardcoded strings break silently (Ollama returns a model-not-found error, but the browser catches it as a generic failure). Update ALL occurrences in api.js whenever the LLM model name changes.
|
||||
|
||||
- **Hardcoded Ollama model names in `api.js`**: The AI Write, Priority Analysis, and Generate Priorities features all call `fetch('/llm/v1/chat/completions')` with `model: 'qwen2.5:14b'` hardcoded. When you swap or upgrade the LLM via Ollama, update ALL occurrences in api.js, dashboard.js (daily briefing), and quote-tab-manager.js (smart upsell). Ollama returns a model-not-found error caught as a generic failure.
|
||||
|
||||
- **Tailwind build-step destroys dynamically-constructed classes — DO NOT USE**: The codebase constructs Tailwind classes in JS template literals (e.g., `bg-${color}-600`, `${config.textColor}`, conditional `bg-green-600 text-white`). A compiled/purged Tailwind build strips every class not appearing as a complete string literal in source files. Attempted once and reverted — site was completely broken. The CDN approach (`<script src=\"https://cdn.tailwindcss.com\"></script>`) is the correct approach for this codebase. Never attempt another build-step migration unless every dynamic class pattern is safelisted in tailwind.config.js.
|
||||
|
||||
- **Fragile LLM JSON parsing in browser**: Simple regexes like `.replace(/^```(?:json)?\s*\n?/i, '').replace(/\n?```\s*$/i, '')` will fail if the LLM includes conversational preambles/postambles or extra newlines outside the code fence, causing a `SyntaxError` and a silent fallback to OCR. **Fix**: Use a robust `indexOf('{')` and `lastIndexOf('}')` slice to extract the JSON payload, and dynamically inspect keys in case the array wrapper varies (e.g., lowercase vs capitalized array keys, or raw arrays).
|
||||
- **Whitespace collapse order matters**: Split on `\s{2,}` BEFORE collapsing with `\s+`. Collapsing first destroys column boundaries.
|
||||
- **Inline scripts can't import modules**: Functions needed by both module and inline code must be exposed via `window.xxx`.
|
||||
- **`onclick` attributes can fire before ES modules load**: `<button onclick="saveSiteSettings()">` depends on `window.saveSiteSettings` being set by settings.js (a deferred module script). If the user clicks before the module finishes loading, the onclick silently fails (ReferenceError). **Fix**: handle the click in the IIFE capture handler (which also runs as a non-module script, same execution timing as onclick) with a localStorage fallback, OR delay-click-guard by checking `typeof window.saveSiteSettings === 'function'`.
|
||||
- **`escapeHtml` not globally available**: The module-only export from `shared/sanitize.js` isn't accessible in non-module scripts. Define locally or add `window.escapeHtml`.
|
||||
- **`closeModal` must be on `window` for `onclick` handlers**: Inline `onclick="closeModal(...)"` needs a global definition.
|
||||
- **Function declaration hoisting**: Two `handleFile` function declarations in same scope — the second silently overwrites the first. Delete dead code, don't rely on hoisting.
|
||||
- **Nginx 301 redirect**: HTTP→HTTPS redirect doesn't affect browser flow (browser loads from HTTPS port 3447 directly). Testing with `curl localhost` gives 301; use `curl -k https://localhost:3447/...`.
|
||||
- **Nginx must proxy `/llm/` to Ollama**: All AI features (appointment scan, AI Write, priority analysis) call `fetch('/llm/...')` — a relative URL that only works because nginx serves the static site AND proxies `/llm/` to Ollama on the same port. Without the `location /llm/` block (`proxy_pass http://127.0.0.1:11434/`), all AI calls fail silently. Use `proxy_buffering off;` and `proxy_read_timeout 120s`. The `/vision/` block is legacy and no longer required — it was only used by the old llava vision model path which has been replaced with OCR + text LLM.
|
||||
- **Ollama auto-unloads models after 5 min idle → 502 transient errors**: By default (`OLLAMA_KEEP_ALIVE=5m`), Ollama evicts models from VRAM after 5 minutes of inactivity. Set explicitly via `Environment="OLLAMA_KEEP_ALIVE=5m"` in `/etc/systemd/system/ollama.service`. When a cold model needs to reload (3-6s load time), the VPS nginx proxy can return 502 if the load exceeds the upstream connect timeout, or connection refused from the Ollama port. The `/llm/api/chat` fetch in `appointments.html` now includes a retry loop: on 502/503, waits 5 seconds (enough for the model to finish loading) and retries once. Any other HTTP error is thrown immediately. Console logs show `LLM attempt N failed with 502, retrying...` during the retry. GPU power difference (loaded vs unloaded) is only ~15W — the idle timer is for freeing VRAM, not saving electricity. Dashboard auto-refresh was previously preventing the timer from ever expiring (see \"Dashboard auto-refresh fires LLM\" pitfall).
|
||||
|
||||
- **CSP meta tag blocks Tesseract.js — the silent killer**: The `<meta http-equiv="Content-Security-Policy">` tag on every SPQ page originally had `connect-src 'self'`. Tesseract.js v5 Web Workers need to download WASM core files and `eng.traineddata` from `cdn.jsdelivr.net` — ALL blocked by `connect-src 'self'`. The `script-src` allowed the CDN (so the main tesseract.min.js loaded), but the worker's internal `fetch()` calls to the CDN were silently rejected. Three CSP changes are REQUIRED for Tesseract.js to work: (1) `connect-src 'self' https://cdn.jsdelivr.net blob:` — allows worker to download WASM + traineddata and use blob URLs for internal messaging, (2) `worker-src blob:` — allows creation of inline Web Workers via blob URLs, (3) `'unsafe-eval'` in `script-src` — WASM compilation uses `new Function()`-like operations. When any of these are missing, the symptom is "Running OCR..." stuck forever with zero progress — Tesseract silently fails to initialize and the promise never resolves. Diagnostic: check browser console for CSP violation messages (`"violates the following Content Security Policy directive"`). The correct CSP for pages using Tesseract.js: `default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval' https://cdn.tailwindcss.com https://cdn.jsdelivr.net https://cdnjs.cloudflare.com https://fonts.googleapis.com https://www.gstatic.com; style-src 'self' 'unsafe-inline' https://cdn.tailwindcss.com https://fonts.googleapis.com; font-src https://fonts.gstatic.com; connect-src 'self' https://cdn.jsdelivr.net blob:; img-src 'self' data: blob:; worker-src blob:; frame-src 'self';`
|
||||
|
||||
- **`const` / `let` redeclaration in single scope causes silent script failure**: Vanilla JS does not allow redeclaring `const` or `let` variables in the same block. If you write `const text = ...` twice inside a function (e.g., `generateDailyBriefing()`), the entire JS file throws a fatal `SyntaxError` at parse time and fails to execute. The app appears to hang on "Loading..." because the polling fallback never finds the function. **Fix**: rename one of the variables. **Diagnostic technique (when node/linters are unavailable)**: Use `grep -P "^\\s*const \\w+ =" file.js | awk '{print $2}' | sort | uniq -d` to statically find duplicate declarations in a script.
|
||||
|
||||
- **Function calls inside try/catch silently skipped — use `window._flag` + post-catch placement**: If a critical function call sits inside a `try` block, any error thrown by earlier lines skips the call. The function is never reached — symptom: feature stuck on initial loading state with no error visible. This happened with `generateDailyBriefing()` inside `loadDashboardData()`'s try block. **Fix**: place fire-and-forget calls AFTER the try/catch block with `.catch(e => console.warn(...))` to prevent unhandled rejections. Use a `window._featureFired` flag to prevent duplicates on auto-refresh. The flag also survives page navigation (set to `true` on first load, persists for the session). **Inline fallback pattern**: when a module-loaded function might not be available in time (ES modules load asynchronously), add an inline `<script>` that polls for `typeof window.theFunction === 'function'` with `setTimeout(check, 500)` and a 15-second max timeout. This is the safety net that fires even if the ES module chain breaks. Place it directly in the HTML after the element the function populates.
|
||||
|
||||
- **Dashboard auto-refresh fires briefing LLM every tick → Ollama never unloads**: `generateDailyBriefing()` was originally inside `loadDashboardData()`, which fires every ~6 min on auto-refresh. This kept the local LLM permanently loaded in VRAM. **Fix**: moved briefing to fire once via `window._briefingGenerated` flag, placed AFTER the try/catch block of `loadDashboardData()` (not inside it — errors before it would skip the call). Switched to DeepSeek API (`deepseek-v4-flash` via nginx proxy) so no local GPU is used. Auto-refresh only updates stats cards.
|
||||
|
||||
- **Function calls inside try/catch silently skipped**: If a critical function call sits inside a `try` block, any error thrown by earlier lines skips the call. Symptom: feature stuck on initial loading state with no error visible. **Fix**: place fire-and-forget calls AFTER the try/catch block with `.catch(e => console.warn(...))` to prevent unhandled rejections. Use a `window._featureFired` flag to prevent duplicates on auto-refresh.
|
||||
|
||||
- **DeepSeek nginx proxy for cloud LLM calls**: Location `/deepseek/` at `/etc/nginx/sites-enabled/shopproquote` proxies to `api.deepseek.com` with server-side API key. REQUIRES `proxy_ssl_server_name on;` (SNI) and `proxy_ssl_protocols TLSv1.2 TLSv1.3;` for SSL upstream handshake — without these nginx returns 502 with `SSL routines::ssl/tls alert handshake failure`. Browser calls `/deepseek/v1/chat/completions` with model `deepseek-v4-flash`. Use `thinking: {type: 'disabled'}` to keep costs at the lower non-thinking rate (~$0.0004/scan). See `references/deepseek-proxy-cost.md` for cost comparison with local Ollama.
|
||||
- **"Running OCR..." stuck — Tesseract.js silent init phases + large image hang**: Tesseract.js v5 has FOUR silent phases before actual OCR begins: `loading tesseract core` (downloads WASM from CDN), `initializing tesseract`, `loading language traineddata` (downloads eng.traineddata), `initializing api`. The original logger only showed progress during `recognizing text` — so during the 10-30 second init phase the user sees "Running OCR..." with zero feedback. If any CDN download hangs (network blip, CDN outage), the promise never resolves and the UI freezes forever. Once OCR does start, the 3× upscale on full-HD screenshots (1920×1080 → 5760×3240) can take 60-180+ seconds, or silently crash the Web Worker (promise never resolves/rejects). **Diagnostic**: check VPS nginx access logs for `/llm/api/chat` hits — zero hits = stall is at Tesseract.js client-side (before any network call). `ssh root@51.81.84.34 'grep "/llm/api/chat" /var/log/nginx/access.log | tail -5'`. **Three-part fix**: (1) **Adaptive upscale** — 2× for images >1MP, 3× only for small images. Cuts pixel count by ~56% on HD screenshots. (2) **Full-phase progress logging** — logger shows ALL statuses, not just "recognizing text". UI updates to "Tesseract: loading tesseract core..." etc. so user knows what's happening. (3) **90s Promise.race timeout** — breaks out of hangs with a clear error instead of freezing forever. Same 30s timeout on the date OCR pass (PSM 11). Without all three fixes, the feature is effectively broken for real-world screenshots at typical resolutions. Console logs prefixed `[OCR]` and `[OCR-Date]` show phase + timing for debugging.
|
||||
|
||||
- **PSM 4 misses header dates — requires two-pass OCR**: PSM 4 (single column) is essential for the tabular appointment data but completely misses isolated header text like the date at the top-middle of the screenshot (e.g., "Wednesday Jun 10, 2026"). Running only PSM 4 means the OCR text sent to the LLM never contains the date, so `appointmentDate` is always blank. **Fix**: after the main PSM 4 OCR pass, run a second Tesseract pass with PSM 11 (sparse text) on the same preprocessed image. PSM 11 reliably captures the header date. Extract it with the same date regex the rule-based parser uses, then apply it as a fallback in the appointment mapping (`a.appointmentDate || headerDate`). Wrap the PSM 11 pass in try/catch — it's a best-effort enhancement. Also strip weekday prefixes ("Wednesday ") from the matched date before parsing, since PSM 11 captures them but the month-name parse branch expects the month to start the string. Example Tesseract.js call: `Tesseract.recognize(preprocessed, 'eng', { tessedit_pageseg_mode: '11' })`. See `appointments.html` lines ~1552-1578 for the working implementation.\n\n- **RO number double-prefix display — \"RO# RO-904680\"**: `createRepairOrderFromAppointment()` in `appointments.js` defaults the modal to `RO-${Date.now()...}` and `cleanRoNumber` forcibly prepends `RO-` if missing. Every display template prepends `RO# ` to `ro.roNumber`, producing \"RO# RO-904680\". Three-part fix: (a) modal default: strip `RO-` from the value — just `${Date.now().toString().slice(-6)}`; (b) `cleanRoNumber` logic: flip from adding `RO-` to stripping it — `roNumber.replace(/^RO[-#]?\\s*/i, '')`; (c) display: strip `RO-` in the three primary display locations for backward compatibility with existing data — `ro.roNumber.replace(/^RO-/, '')` in `ro-active.js` line 75, `repair-orders.js` lines 2476 and 4236.\n\n- **RO-VIN-Mileage data flow gaps — three files to update**: Two gaps in the appointment → RO → quote pipeline: (1) The "Create RO from Appointment" modal (`promptRepairOrderDetails()` in `appointments.js`) asked for RO number, estimated hours, mileage, and service type — but NOT VIN. VIN was silently pulled from appointment data with no chance to verify. (2) When creating a quote from an RO (`generateQuoteFromRO()` → `populateQuoteFromRO()`), mileage was never passed through, even though the quote form has a mileage field. **Fix**: (a) Add a VIN input to the modal HTML (after mileage, with `maxlength="17"`), pre-fill from `appointment.vin`, include in `resolve()` and `createRepairOrderFromAppointment()` destructuring. (b) Add `data-generate-quote-mileage` attribute to the generate-quote button rendering, read it in the event handler, pass to `generateQuoteFromRO()`, store in localStorage, and set in `populateQuoteFromRO()` via `safeSetValue('mileage', roData.mileage)`. See `appointments.js` (VIN modal field + flow), `repair-orders.js` (mileage dataset + generateQuoteFromRO signature), `main.js` (populateQuoteFromRO mileage setter).
|
||||
|
||||
- **Service advisor not populating when creating quote from RO**: `populateROData()` in `quote-tab-manager.js` populated customerName, customerPhone, vehicleInfo, mileage, vin, and repairOrderNumber — but never touched the `serviceAdvisor` field. The quote form's service advisor stayed blank even though `appSettings.serviceAdvisor` had a configured value. `resetQuoteState()` already set the advisor from settings, but the RO-to-quote path bypassed reset. **Fix**: add `if (elements.serviceAdvisor) { elements.serviceAdvisor.value = appSettings.serviceAdvisor || ''; }` to `populateROData()`. Always set it (not conditional on RO data) because ROs don't carry advisor info — the settings default is the source of truth.
|
||||
|
||||
- **RO number key mismatch between localStorage writer and reader**: `generateQuoteFromRO()` in `repair-orders.js` stored the RO number in localStorage under the key `roNumber`, but `populateROData()` in `quote-tab-manager.js` checked for `repairOrderNumber`. The dedicated RO number field on the quote form (`id="repairOrderNumber"`) was never populated. **Fix**: (a) store both keys in `generateQuoteFromRO()` — `roNumber` (for backward compat with notes/reference) and `repairOrderNumber` (for the quote form field); (b) in `populateROData()`, accept either key: `const roNum = roData.repairOrderNumber || roData.roNumber;`.
|
||||
|
||||
- **`populateQuoteFromRO` in main.js is dead code**: `main.js` has `populateQuoteFromRO()` and `initializeQuoteGenerator()` that read `quoteFromROData` from localStorage, but `main.js` is not loaded by any HTML file (no `<script src="main.js">` tag in index.html or repair-orders.html). The real RO-to-quote population path is `quote-tab-manager.js` → `initializeQuoteTab()` → `populateROData()`. Ignore `main.js` for RO-to-quote fixes — all changes must go in `quote-tab-manager.js`.\n\n- **Aggressive binarization destroys OCR on narrow-column screenshots**: The old code used `avg > 128 ? 255 : 0` which forces every pixel to pure black or white. Dealership schedule screenshots are ~1780px wide with 10+ narrow columns — text in the VIN and service columns is only a few pixels wide. Hard binary cutoff at 128 destroys the anti-aliased edges that Tesseract relies on for character recognition, turning VINs and service text into unreadable blobs. **Fix**: use mild contrast enhancement (only push extremes: >240→white, <30→black, leave mid-tones alone), 3x upscale the image before OCR, and use PSM 4 (single column). These three changes together reduce VIN errors from ~90% to ~10%.: When converting an appointment extraction feature from vision-model to OCR + text LLM, you must update ALL of these:
|
||||
1. Remove the `FileReader` base64 conversion — OCR uses `URL.createObjectURL(file)` directly
|
||||
2. Change the fetch endpoint from `/vision/api/chat` to `/llm/api/chat`
|
||||
3. Remove the `images: [base64]` field from the request body
|
||||
4. Update the system prompt to say "OCR text" instead of "image" (otherwise the LLM gets confused about what it's receiving)
|
||||
5. Update the model selector dropdown from vision models (llava) to text models (qwen2.5)
|
||||
6. Remove the try/catch fallback that triggered OCR on vision failure — OCR is now primary
|
||||
7. Keep `temperature: 0.0` — still critical for structured extraction
|
||||
8. Keep the `indexOf('{')` + `lastIndexOf('}')` JSON extraction — text LLMs also add conversational wrappers
|
||||
- **Model swap procedure**: `ollama pull <model>` to download, `ollama rm <old-model>` to clean up. Update hardcoded model names: `api.js` lines 36 and 143 (AI Write, Priority Analysis) AND `appointments.html` model selector dropdown default (`qwen2.5:14b`). For LLM swaps, `api.js` and `appointments.html` both need updating. For the vision dropdown (legacy — only llava:13b remains, unused by appointments), no HTML changes needed.
|
||||
|
||||
- **AI Write button ID mismatch — three setup functions, inconsistent selectors**: The Quote Generator has THREE separate functions that wire AI Write click handlers to different modals. Each looks for a DIFFERENT button ID, and if a new ID is added or renamed, one of the three will silently fail to attach:
|
||||
1. `setupCustomServiceModalEventListeners()` — looks for `custom-service-ai-write-btn` (Add Custom Service modal, static HTML in `repair-orders.html`)
|
||||
2. `setupServiceEditEventListeners()` — looks for `ai-write-btn-main` || `ai-write-btn-fallback` (dynamically-created Edit Service modal from `createServiceEditModalHTML()`)
|
||||
3. `setupServiceEditModal()` — looks for `ai-write-btn` || `ai-write-btn-quote` (legacy/pre-existing Edit Service modal)
|
||||
**Bug encountered**: `setupServiceEditModal()` was missing the `ai-write-btn-fallback` fallback. The dynamically-created modal used that ID, so the handler never attached and clicking AI Write did nothing. Fix: add `|| document.getElementById('ai-write-btn-fallback')` to the chain at line 1543. When debugging "AI Write button does nothing," check which of these three functions ran and whether the button ID exists in any of them.
|
||||
- **Ollama GPU acceleration**: Ollama auto-detects NVIDIA GPUs and uses CUDA by default. No manual Vulkan/CUDA configuration needed — just `ollama serve`. Verify GPU usage with `nvidia-smi` while a model is loaded. Current GPU: RTX 2080 Ti FE (11GB VRAM) — comfortably fits qwen2.5:14b (~9GB) and llava:13b (~8GB) individually.
|
||||
- **Service search closes on click-outside while typing**: `quote-tab-manager.js` and `main.js` both have document-level click handlers that hide `#service-search-results`. If these fire while the user has typed a search term, the dropdown vanishes mid-search. Fix: only hide when the input is empty (`if (!serviceSearchInput.value.trim())`).
|
||||
- **Notify Before Promised Time — new site config setting and dashboard notification**: A dropdown in Site Configuration → Notifications lets users choose: Off, 15 min, 30 min, or 1 hour before the promised time. The dashboard `checkForApproachingPromised()` function runs each refresh tick, finds ROs within the threshold window (promisedTime is in the future but within the threshold), and fires a desktop notification with the customer name, vehicle, and minutes remaining. Uses a `lastApproachingIds` Set to avoid duplicate notifications per RO. Honors both `desktopNotifications` and `soundAlerts` toggles. Setting defaults to `0` (Off) and is stored/loaded via the standard `appSettings` pipeline.
|
||||
- **No multi-service add flow**: `addServiceById()` clears the input and hides results after each add, forcing the user to re-open search for every service. Fix: after adding, refocus the input, re-show all remaining available services (filter out already-added), and show a status indicator. See `quote-tab-manager.js` lines 1021-1045 for the working pattern.
|
||||
- **Settings gear icon broken on repair-orders page**: `settings.js` `initializeSettings()` checks for `settings-modal` (tabbed settings — only on index.html). On repair-orders.html, this modal doesn't exist, so the function returns early and `settings-btn` gets zero click handlers. Additionally, the button had a long inline `onclick` that conflicted with `settings.js`'s `hasAttribute('onclick')` guard, preventing `settings.js` from wiring it even when the modal existed. **Three-part fix**: (1) remove the inline `onclick` from `#settings-btn` in the HTML, (2) add an `addEventListener('click', ...)` in `repair-orders.js` `setupEventListeners()` that calls `populateSiteSettingsForm()` then `openModal('site-settings-modal')`, (3) ensure `site-settings-modal` exists in the page with the card-layout settings (repair-orders uses a simplified card-layout version, not the full 5-tab version — `settings.js` handles missing tab elements gracefully). See `references/settings-modal-architecture.md` for element IDs and structure.
|
||||
|
||||
- **`handleUseExtractedData()` references 6 undefined variables (appointments.js, ~line 1899)**: The function tries to set `.value` on `customerName`, `customerPhone`, `vehicleInfo`, `vinNumber`, `appointmentDate`, `appointmentTime` but none of these variables are declared or retrieved from the DOM. Only `reason` and `notes` are properly defined. This throws a `ReferenceError` at runtime. **Fix**: add `const` declarations with `document.getElementById()` for each field using the correct HTML element IDs (`customer-name`, `customer-phone`, `vehicle-info`, `vin-number`, `appointment-date`, `appointment-time`). Also remove dead code: the entire AI import feature (import-appointment-btn, cancel-import-btn, use-extracted-data-btn, import-results, extracted-data) was removed from HTML but JS handlers remained.
|
||||
|
||||
- **Quick action SVG icons rendered as 8px dots (appointments.html)**: The four quick action button SVGs (New Appointment, Scan Screenshot, Settings, Expand All) use `class=\"w-2 h-2\"` (8px) — should be `w-8 h-8` (64px). Symptom: icons are invisible tiny dots. **Fix**: change `w-2 h-2` to `w-8 h-8` on all four SVGs (lines ~452, 459, 468, 476).
|
||||
|
||||
- **Dual `initializeApp()` calls cause potential duplicate event listeners (appointments.js)**: `initializeApp()` is called from both `DOMContentLoaded` (line 1573) and `onAuthStateChanged` (line 46). If auth fires synchronously with DOMContentLoaded, all listeners in `setupEventListeners()` register twice — double save notifications, double modal closes. **Fix**: add a `let initialized = false` guard at the top of `initializeApp()` that returns early on second call.
|
||||
|
||||
- **Global click handler swallows tab button clicks (repair-orders)**: The repair-orders IIFE has a capture-phase click handler that calls `e.stopPropagation()` on matching elements. This prevents bubble-phase event listeners on tab buttons and other interactive elements from ever firing. Symptom: clicking tabs produces no response; `element.click()` in console works because it bypasses capture. **Fix**: change `e.preventDefault(); e.stopPropagation()` to just `e.preventDefault()` — allow events to bubble so JS handlers can fire for state cleanup and tab switching.
|
||||
|
||||
- **Login error handling uses Firebase error codes for PocketBase (login.html)**: The catch blocks check `error.code` for `auth/user-not-found`, `auth/wrong-password`, `auth/invalid-email`, etc. — Firebase Auth error codes that never match PocketBase HTTP responses. All errors fall through to the generic `default` case showing only `error.message` ("Failed to authenticate."). Same pattern repeats in password reset, signup, and change-password handlers. **Fix**: replace `switch(error.code)` with `if(error.status)` checks: status 400 → invalid credentials, status 429 → rate limited, status 0 → network error, fallback to `error.data?.message || error.message`. Also fix dead code: `forgot-password-btn` ID changed to correct `open-change-password-modal`.
|
||||
- **PocketBase silently drops fields not in schema (services collection)**: If a service is saved with `recommendation` or `explanation` fields but those aren't defined in the PocketBase `services` collection schema, the fields are silently dropped (HTTP 200, no error). Fix: PATCH `/api/collections/{id}` with complete fields array. See `references/pocketbase-schema-patch.md` for the Python script pattern.
|
||||
- **Service explanation not visible in quote builder**: `renderSelectedServices()` only showed `service.recommendation` (the reason) but never rendered `service.explanation` (the customer explanation). Even when data was correctly saved to PocketBase, it was invisible in the UI. Fix: added an explanation line below the recommendation with italic styling and `line-clamp-3`.
|
||||
- **Service cache stale after Settings save**: `repair-orders.js` loads services once at page init via `loadUserServices()`. Services saved from Settings → Services tab go to PocketBase but the in-memory `userServices` array is never refreshed. When the user switches to the Quote Generator tab, they get stale service data. Fix: call `await loadUserServices()` in `switchMainTab()` before `initializeQuoteTab()`. Also requires making `switchMainTab` an `async` function.
|
||||
- **`applyShopCharge` not defaulting to ON**: New services via `addSelectedService()` default to `true`, but services loaded from saved quotes via `setSelectedServices()` pass through as-is — if the saved quote lacked the field, the checkbox renders unchecked. Fix: normalize in `setSelectedServices()` with `applyShopCharge: s.applyShopCharge !== false`.
|
||||
- **Generate Priorities button missing from HTML**: `renderSelectedServices()` toggles `#generate-priorities-btn` visibility and `setupQuoteEventListeners()` attaches the click handler, but the button element didn't exist in `repair-orders.html`. The LLM-based priority analysis was unreachable. Fix: add the button in the Actions panel above Save Quote. Also requires lowering the visibility threshold from `> 1` services to `>= 1`.
|
||||
- **PDF single-service page waste**: `generateQuotePDF()` had excessive padding: `yPos += 20` before pricing summary, `yPos += 40` before footer, and `-40` page-break margins. For a single service with explanation, the pricing summary and footer got pushed to page 2 leaving page 1 half-blank. Fix: reduce padding (20→10, 40→15), reduce page-break margins (40→20), and reduce after-service spacing (12→6). Recovers ~81 units on A4 (841 units = ~10% of page).
|
||||
- **Add Custom Service modal had dropdown instead of text input**: The `#custom-service-recommendation` field was a `<select>` with priority LEVEL options (RECOMMENDED/CRITICAL/etc.) instead of a text input for the recommendation REASON (e.g., "pads worn to 2mm"). The AI Write button needs the reason text as input. Fix: replaced with `<input type=\"text\">` with placeholder "e.g., pads worn to 2mm, oil is overdue" and a help text explaining the AI will determine the priority level.
|
||||
- **Settings field saved by wrong tab's handler**: The Shop Charge Explanation field lives in the Financial tab HTML but was only saved by the Messages tab's handler. When the user edits it and clicks "Save Financial," the changes are silently lost because Financial's handler doesn't include it. Fix: add the field to the Financial tab's save handler. **General pattern**: whenever a field appears in one settings tab, verify it's actually saved by that tab's handler — don't assume cross-tab coverage.
|
||||
- **Patch tool corrupts JS regex escaping in HTML files**: The `patch` tool doubles backslashes in JavaScript regex literals inside HTML `<script>` tags — `\\n` → `\\\\n`, `\\s` → `\\\\s`, `\\d` → `\\\\d`, `\\b` → `\\\\b`, etc. This silently breaks regex patterns (e.g., `\\d{3}` becomes `\\\\d{3}` which matches literal backslash-d, not digits). **Fix**: use Python with raw strings via terminal to restore — `section.replace(r'\\\\n', r'\\n')` replaces the 3-char sequence `\\, \, n` with the 2-char `\, n`. Always wrap the function in `(function(){...}})()` and verify with `node --check` after fixes. Prefer terminal-based sed/Python for JS-in-HTML edits over the patch tool when regex literals are involved.
|
||||
- **Auto-save-forms toggle was dead**: The checkbox saved/loaded its own state but no code actually performed auto-saving. The `auto-save-draft.js` module now implements the actual functionality behind the toggle. It captures static form fields AND dynamic state (services in quote tab via `getExtraData`/`onRestoreExtra`). If adding new forms to the app, call `window.autoSaveDraft.init()` to wire them up. For forms with dynamic non-DOM state, use the `getExtraData`/`onRestoreExtra`/`triggerSave` pattern.
|
||||
- **Sound alerts toggle was dead**: `playAlertSound()` in dashboard.js played the 800Hz beep unconditionally, ignoring the `soundAlerts` setting. Fixed by adding `if (window.appSettings && window.appSettings.soundAlerts === false) return;` at the top of the function.
|
||||
- **`initializeSettings()` not called on all pages**: `initializeSettings()` (which calls `loadSettings()`, `populateSiteSettingsForm()`, `setupUnifiedSiteSettingsModalHandlers()`, `ensureSiteSettingsCloseAttributes()`, etc.) must be explicitly called from every page's init code. It is NOT auto-called by loading `settings.js` as a module — it's an exported function. Pages currently calling it: repair-orders.js, customers.js, main.js (index), dashboard.js. **appointments.js was missing it**, causing settings to show HTML defaults (all unchecked) on the appointments page. Fix: add `import { initializeSettings } from './settings.js'` and call `initializeSettings()` inside the page's `initializeApp()` function. Also verify the HTML element IDs match what the JS expects — `appointments.js` was referencing `site-configuration-modal` and `save-site-configuration-btn` which didn't exist in the HTML (the HTML uses `site-settings-modal` and `save-site-settings`). Since the JS selectors returned null, appointments.js's custom save handler never attached (silent fail), and settings.js's handler works after calling `initializeSettings()`.
|
||||
|
||||
- **`handleUseExtractedData()` references 6 undefined variables (appointments.js)**: The function tries to set `.value` on `customerName`, `customerPhone`, `vehicleInfo`, `vinNumber`, `appointmentDate`, `appointmentTime` but none are declared or queried from the DOM. Only `reason` and `notes` are properly defined. Throws `ReferenceError` at runtime. **Fix**: add `const` declarations with `document.getElementById()` using correct IDs (`customer-name`, `customer-phone`, `vehicle-info`, `vin-number`, `appointment-date`, `appointment-time`). Also remove dead AI import code: handlers for `import-appointment-btn`, `cancel-import-btn`, `use-extracted-data-btn`, `import-results`, `extracted-data` — all HTML elements removed but JS handlers remained (safely guarded by `if(element)` but dead). Also remove `setupDayControlEventListeners()` — defined but never called.
|
||||
|
||||
- **Quick action SVG icons rendered as 8px dots (appointments.html)**: The four quick action button SVGs (New Appointment, Scan Screenshot, Settings, Expand All) use `class=\"w-2 h-2\"` (8px) instead of `w-8 h-8` (64px). Icons are invisible. **Fix**: change `w-2 h-2` to `w-8 h-8` on all four SVGs (lines ~452, 459, 468, 476).
|
||||
|
||||
- **Dual `initializeApp()` calls cause potential duplicate event listeners (appointments.js)**: `initializeApp()` is called from both `DOMContentLoaded` (line 1573) and `onAuthStateChanged` (line 46). If auth fires synchronously, all listeners in `setupEventListeners()` register twice — double save notifications, double modal closes, double form submissions. **Fix**: add a `let initialized = false` guard at the top of `initializeApp()` that returns early on second call. Also check other pages for the same pattern.
|
||||
|
||||
- **Global click handler swallows tab button clicks (repair-orders)**: The IIFE capture-phase click handler calls `e.stopPropagation()` on matching elements, preventing bubble-phase event listeners on tab buttons from firing. Symptom: clicking tabs does nothing visually; `element.click()` in console works because it bypasses capture. **Fix**: change `e.preventDefault(); e.stopPropagation()` to just `e.preventDefault()` — allow events to bubble so JS handlers fire for state cleanup and tab switching.
|
||||
|
||||
- **Login error handling uses Firebase error codes for PocketBase (login.html)**: Catch blocks check `error.code` for `auth/user-not-found`, `auth/wrong-password`, `auth/invalid-email` — Firebase Auth codes that never match PocketBase HTTP responses. All errors fall through to generic `default` showing only `error.message`. Same pattern in password reset, signup, and change-password handlers. **Fix**: replace `switch(error.code)` with `if(error.status)` checks: 400→invalid credentials, 429→rate limited, 0→network error, fallback to `error.data?.message || error.message`. Also fix dead code: `forgot-password-btn` ID → correct `open-change-password-modal`.
|
||||
- **Desktop notifications were dead — now wired**: `showDesktopNotification(title, body)` in dashboard.js checks `appSettings.desktopNotifications`, requests `Notification.permission` on first use, and fires for new critical overdue repair orders. Exposed globally as `window.showDesktopNotification`. The function respects the toggle and silently degrades if the browser doesn't support the Notification API.
|
||||
- **Conflicting site configuration save handlers**: Both `settings.js` (`setupUnifiedSiteSettingsModalHandlers`) and `dashboard.js` (`handleSaveSiteSettings`) attached click handlers to the same Save button. They saved to DIFFERENT PocketBase documents — `settings.js` → `appSettings` (with `data` wrapper), `dashboard.js` → `siteConfiguration` (flat). This caused toggles to revert after save because one handler's defaults (`false`) overwrote the other's saved values (`true`). Also made the modal appear broken (two handlers racing). Fix: remove the `handleSaveSiteSettings` listener from dashboard.js (line 651) and let settings.js handle everything. Dashboard.js `getDefaultSiteSettings()` defaults were also wrong (`false` → corrected to `true` for notifications/sound).
|
||||
- **Site config dropdown inconsistencies across pages — check all 4 pages**: The user dropdown (`#user-dropdown`) must be identical on all pages. customers.html was missing: (a) `onclick="openModal('...')"` handlers on the Account Settings and Site Configuration buttons, (b) the divider + Sign Out button section. Symptom: clicking those menu items did nothing, and the user had no way to sign out from the customers page. Fix: copy the exact dropdown structure from repair-orders.html (including the 3 SVG icons and all onclick attributes) to customers.html. The Sign Out button uses `id="logoutBtn"` which `shared/header-functionality.js` already handles — no JS changes needed after adding the HTML.
|
||||
- **Site config toggle defaults were wrong in dashboard.js**: `getDefaultSiteSettings()` returned `desktopNotifications: false` and `soundAlerts: false`, while `settings.js` defaults were `true`. Since dashboard.js had its own save handler (now removed), its defaults would overwrite saved values. Defaults should match across all files.
|
||||
- **Modal close buttons unresponsive — three-layer debugging approach**: When X/Cancel/Save buttons on a modal do nothing visible when clicked, use this diagnostic pipeline:
|
||||
0. **Compare against a known-working page FIRST**: The fastest way to isolate the problem is to find a page where the same modal works (e.g., index.html) and diff the relevant sections — HTML structure, script tags, event handlers, module imports. In a known case, index.html has NO IIFE while repair-orders.html does, immediately pinpointing the IIFE as the culprit.
|
||||
1. **Check for duplicate id attributes** — getElementById returns the FIRST match. If there are two elements with the same modal id, the hidden first one matches and the visible second one stays open. grep -c should return 1.
|
||||
2. **Add a visual flash to closeModal** (lime outline, 500ms) to confirm the function is being called. Green flash + modal stays = CSS issue. No green flash = event never reached the handler.
|
||||
3. **Use style.setProperty with !important as the nuclear option** — inline style beats every CSS rule. Pair with removeProperty in openModal to restore.
|
||||
4. **Check for capture-phase interference**: The inline IIFE on repair-orders.html has a capture-phase click listener (addEventListener with true) that calls stopPropagation when it matches. This prevents bubble-phase handlers (including onclick attributes) from ever firing.
|
||||
- **Broken `aria-label` selector escaping**: The inline IIFE's close-button selector was `[aria-label=\\\"Close\\\"]` in the HTML source. After HTML/JS parsing, this became the CSS selector `[aria-label=\"Close\"]` which matches the literal attribute value `"Close"` (WITH quote characters), not `Close`. No element on the page has `aria-label='"Close"'` — they have `aria-label="Close"`. Fix: write `[aria-label="Close"]` directly in the inline `<script>` block (no backslash escaping). Also applies to `[role="dialog"]` selectors.
|
||||
- **Conflicting save handlers fixed**: `dashboard.js` had its own `handleSaveSiteSettings` listener on the same save button as `settings.js`'s `setupUnifiedSiteSettingsModalHandlers`. They saved to different PocketBase documents (`appSettings` vs `siteConfiguration`), causing toggles to revert. Removed the dashboard.js handler; only `settings.js` handles site config saves.
|
||||
- **Site configuration modal had duplicate/dead toggles**: The `auto-save-forms` toggle appeared in both Account Settings AND Site Configuration — removed from Site Configuration (appointments.html, repair-orders.html, index.html, customers.html). The `site-dark-mode` toggle was also in Site Configuration (appointments.html, repair-orders.html, index.html) — removed since dark mode is handled by the header toggle. The `settings.js` `bind()` function gracefully handles missing elements (`if (!el) return;`), so no JS cleanup was needed.
|
||||
- **stopPropagation() does not prevent other listeners on the SAME element**: Calling `e.stopPropagation()` during the target phase prevents the bubble phase, but does NOT stop other target-phase listeners on the current element. If `settings.js` adds a listener via `addEventListener('click', fn)` to the same button, and your onclick property handler calls `stopPropagation()`, BOTH fire. Use `e.stopImmediatePropagation()` instead — it prevents ALL remaining listeners on the current element regardless of phase.
|
||||
- **hasAttribute('onclick') only checks HTML attributes, not DOM properties**: Setting `btn.onclick = fn` in JavaScript sets a DOM property — `btn.hasAttribute('onclick')` still returns false. Only `btn.setAttribute('onclick', '...')` or inline HTML `onclick="..."` makes `hasAttribute` return true. This matters because `settings.js` uses `!saveBtn.hasAttribute('onclick')` as a guard to skip adding its own listener — if you set onclick via JS property, the guard won't work and you'll get double-fires. Fix with `stopImmediatePropagation()` or set the HTML attribute explicitly.
|
||||
- **Ollama Host and Origin checking**: Ollama returns `403 Forbidden` for any request with a `Host` or `Origin` header that doesn't match `localhost` or `127.0.0.1`. Since browsers attach the external `Origin` header (e.g., `https://grajmedia.duckdns.org`) to all non-GET requests, you MUST explicitly override the Origin header in nginx: `proxy_set_header Origin "http://localhost";` in the `/llm/` block. The `/vision/` block is legacy but should also include this header if kept. Test with: `curl -i -X POST -H "Origin: https://grajmedia.duckdns.org" -sk http://127.0.0.1:11434/api/chat`.
|
||||
- **Diagnostic for Ollama 403 errors**: To isolate whether a 403 is from nginx or Ollama, test Ollama directly via curl: `curl -i -X POST -H "Origin: https://grajmedia.duckdns.org" -sk http://127.0.0.1:11434/api/chat`. If it returns 403, Ollama is enforcing Origin security.
|
||||
- **Required Body Size Limit**: Set `client_max_body_size 50m;` in the nginx server block. Base64-encoded screenshots can easily exceed nginx's default 1MB body limit, causing uploads to fail silently with a `413 Request Entity Too Large` error before reaching Ollama.
|
||||
- **Vision model pitfalls (HISTORICAL — no longer used for appointment extraction)**: These apply to the old two-tier architecture where llava:13b was the primary path. The current OCR + text LLM architecture avoids all of these issues entirely.
|
||||
- **VLM temperature**: Always set `temperature: 0.0` — Ollama defaults to 0.8, causing hallucinated names.
|
||||
- **Fragile VLM JSON parsing**: Smaller VLMs wrap responses in conversational text. Always use `indexOf('{')` + `lastIndexOf('}')` slice, not regex.
|
||||
- **VLM model selection**: llava:13b ~8GB fits on RTX 2080 Ti 11GB but produces unreliable structured output.
|
||||
- **Ollama 403**: Requires explicit `proxy_set_header Origin "http://localhost"` in both `/llm/` and `/vision/` nginx blocks.
|
||||
- **Body size**: Base64-encoded screenshots need `client_max_body_size 50m;` in nginx (still relevant — OCR can also process large images client-side).
|
||||
- **CSP must be updated when removing CDN Tailwind**: When switching from CDN to build-step Tailwind, the CSP meta tags must have `cdn.tailwindcss.com` removed from both `script-src` and `style-src` directives. Otherwise the browser blocks the CDN (which is already removed from HTML) while the build-step CSS loads fine — but any residual CDN reference causes CSP violation noise in the console. All four production HTML pages (index.html, appointments.html, repair-orders.html, customers.html) need the update. Also add `https://cdnjs.cloudflare.com` to `script-src` for jsPDF dynamic imports. CSP template: `default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval' https://cdn.tailwindcss.com https://cdn.jsdelivr.net https://cdnjs.cloudflare.com https://fonts.googleapis.com https://www.gstatic.com; style-src 'self' 'unsafe-inline' https://cdn.tailwindcss.com https://fonts.googleapis.com; font-src https://fonts.gstatic.com; connect-src 'self' https://cdn.jsdelivr.net blob:; img-src 'self' data: blob:; worker-src blob:; frame-src 'self';`\n\n- **Restoring HTML from backup wipes CSP meta tags**: When restoring HTML files from a pre-CSP backup, the CSP `<meta>` tags are lost. After restore, re-add CSP to all pages that lack it. Check with `grep -l 'Content-Security-Policy' *.html` — all 4 production pages should have it. Use sed to insert after `<meta charset=\"UTF-8\">`.\n\n- **PocketBase `services` field can return as JSON string, not array**: When loading quotes from PocketBase, the `services` field (an array of service objects) may come back as a JSON string instead of a parsed array — depends on how the collection field is typed in PocketBase. Always guard with: `let services = q.services || []; if (typeof services === 'string') { try { services = JSON.parse(services); } catch(e) { services = []; } } if (!Array.isArray(services)) services = [];` This is critical for the daily briefing's per-service decision counting and the unaddressed-quote enrichment. Apply this guard EVERYWHERE you read `q.services` from PocketBase-loaded data. The `convertFromPB` function in pocketbase.js tries to parse top-level JSON strings but doesn't recursively process nested fields in arrays.
|
||||
|
||||
- **`modal.remove()` destroys the DOM → subsequent opens break**: Some close/save handlers call `modal.remove()` which permanently deletes the modal from the DOM. The next time the modal is opened, the code has to dynamically recreate it from a fallback template — leading to missing elements and broken handlers. **Fix**: always use `modal.classList.add('hidden')` to hide modals. Never `remove()` unless the modal is truly ephemeral and recreated from scratch each time. Check all modal close handlers: close buttons, cancel buttons, outside-click handlers, AND save handlers. In `setupServiceEditEventListeners()` in `quote-tab-manager.js`, both `closeModal()` and `saveServiceEdit()` had `modal.remove()` — changed both to `modal.classList.add('hidden')`.
|
||||
|
||||
- **Save button closes modal without saving — IIFE captures `data-close-modal`**: `ensureSiteSettingsCloseAttributes()` and `dashboard.js` used to set `data-close-modal` on the Save button. The IIFE capture handler matches it, calls `stopPropagation()`, and closes BEFORE any save handler fires. Symptom: modal closes, settings unchanged. Two-part fix: (1) never set `data-close-modal` on save buttons; (2) use a direct `onclick` property override in the IIFE with `stopImmediatePropagation()` to handle save in the target phase (avoids capture-phase interference AND prevents settings.js double-fire). See `references/inline-iife-modal-handlers.md` for full event flow with phases, property-vs-attribute distinction, and code. `ensureSiteSettingsCloseAttributes()` and `dashboard.js` used to set `data-close-modal` on the Save button. The IIFE capture handler matches it, calls `stopPropagation()`, and closes BEFORE any save handler fires. Symptom: modal closes, settings unchanged. Two-part fix: (1) never set `data-close-modal` on save buttons; (2) use a direct `onclick` property override in the IIFE with `stopImmediatePropagation()` to handle save in the target phase (avoids capture-phase interference AND prevents settings.js double-fire). See `references/inline-iife-modal-handlers.md` for full event flow with phases, property-vs-attribute distinction, and code.
|
||||
- **Saved quote delete buttons**: The saved quotes sidebar and modal both need trash-icon delete buttons. Add `<button>` with `data-delete-quote` attribute and a trash SVG (opacity-0, group-hover:opacity-100), stop click propagation so the delete doesn't trigger the load handler. The `deleteQuote()` function calls PocketBase `deleteDoc`, filters the local `allQuotesData` cache, and refreshes both the sidebar and modal (if open). Confirm with `confirm()` before deleting.
|
||||
- **Quote edit-then-re-save silently fails — setDoc can't find existing records**: `saveQuote()` in `quote-tab-manager.js` used `setDoc()` for updating existing quotes. `setDoc()` in pocketbase.js (line 349) searches for records by filtering on `name = "${docId}"` — but `addDoc()` never sets a `name` field on the PocketBase record. So `setDoc` never finds the existing record and the update silently fails (error caught generically as "Error saving quote"). **Fix**: use `updateDoc()` instead of `setDoc()` for existing quotes. `updateDoc()` (pocketbase.js line 285) directly calls `pb.collection(collName).update(docId, pbData)` using the actual PocketBase record ID — no name-field lookup needed. Change the import from `setDoc` to `updateDoc` and replace the `setDoc(quoteRef, ...)` call with `updateDoc(quoteRef, quoteData)`. Both edits in `saveQuote()` at lines ~2818 and ~2824.\n\n- **Aftermarket parts selection invisible in PDF/print (quote-tab-manager.js)**: `generateQuotePDF()` only rendered `partsNotInStock` status (lines 3794-3806) but completely ignored `aftermarketAvailable`. When a user selected "Aftermarket Parts Available" on a service, the PDF showed nothing — no note, no parts list. Same gap in the height calculator (`calculateServiceHeight`). **Fix**: add aftermarket rendering block after the partsNotInStock block in BOTH the PDF service loop AND the height calculator. Aftermarket rendering shows bold "NOTE: Aftermarket parts are available for this service." plus the parts list if `aftermarketPartsList` exists. This is a recurring-class pitfall: any new service field added to the data model MUST be added to both the PDF renderer AND the page-break height calculator — they're separate code paths that diverge silently.
|
||||
|
||||
- **Button contrast — unselected toggle buttons invisible on service cards**: The customer decision buttons (✓ Approve / ✗ Decline / ↺ Reset) and parts toggle buttons (🔧 Labor Only / ✅ In Stock / ⚠️ Need Order / 💡 Aftermarket) originally used `bg-gray-200 text-gray-700` for unselected state. On the `bg-gray-50` service card background, these gray-on-gray buttons have near-zero contrast and are unreadable. **Fix**: unselected buttons use `bg-white text-gray-800 border border-gray-300 font-medium` — white fill with visible border, dark text, and a tinted hover matching the button's purpose (e.g. `hover:bg-green-50` for Approve). Selected buttons keep their saturated fills (`bg-green-600 text-white border-green-600`, etc.). This applies anywhere toggle-button groups appear in service cards. If adding new toggle groups, start with this pattern — never use `bg-gray-200` on a gray card background.
|
||||
|
||||
- **Clear quote without saving wipes work silently**: `resetQuoteState()` (bound to `#clear-quote-btn`) immediately cleared the form with no confirmation — even when the user had entered a customer name, vehicle info, and added services to an unsaved quote. One misclick lost all work. **Fix**: at the top of `resetQuoteState()`, check if the quote is unsaved (`!currentQuoteId`) AND has content (customer name, vehicle info, or any services selected). If both true, show `confirm('This quote has not been saved. Are you sure you want to clear it?')` before proceeding. Saved quotes (with a quote ID) clear immediately without asking. Pattern: `const hasContent = (elements.customerName?.value?.trim() || '') !== '' || (elements.vehicleInfo?.value?.trim() || '') !== '' || selectedServices.length > 0;`
|
||||
|
||||
- **PocketBase `quotes` collection has wrong/leftover schema — writes fail with "Error saving quote"**: The `quotes` collection existed but with fields from a different app use (deviceModel, deviceType, issue, etc.). `saveQuote()` sends vehicleInfo, mileage, vin, services, serviceAdvisor, repairOrderNumber, discountValue, discountType, lastUpdated — PocketBase rejects unknown fields. The HTTP error is caught generically with "Error saving quote" so the user has no clue it's a schema mismatch. Fix: PATCH `/api/collections/{id}` with the complete fields array (GET the current schema, append new fields, PATCH the whole array back). Do NOT use POST to `/api/collections/{name}/fields` — that endpoint returns 404. The correct API call is `PATCH /api/collections/{id}` with `{"fields": [...]}` as the body. Process: auth as superuser → GET collection schema → append `{name, type, required: false}` objects → PATCH. See `references/pocketbase-schema-patch.md` for the Python script pattern.
|
||||
|
||||
## Customer Decision Tracking (Per-Service Approval/Decline)
|
||||
|
||||
Each service in a quote can be marked as approved or declined by the customer. This allows the advisor to track what the customer authorized vs. postponed during the quote review process.
|
||||
|
||||
**Data model:** Each service object in `quoteStateManager.getSelectedServices()` carries a `customerDecision` field:
|
||||
- `'pending'` (default) — not yet decided
|
||||
- `'approved'` — customer authorized this service
|
||||
- `'declined'` — customer declined this service
|
||||
|
||||
**UI (`renderSelectedServices()` in quote-tab-manager.js):**
|
||||
- Three decision buttons per service card: ✓ Approve | ✗ Decline | ↺ Reset
|
||||
- Approved: green left border on card, green "✓ Approved" badge
|
||||
- Declined: red left border, strikethrough on name, red "✗ Declined" badge
|
||||
- Global setter: `window.setCustomerDecision(index, decision)` updates the service, re-renders, and recalculates summary
|
||||
|
||||
**Quote Summary (`updateQuoteSummary()`):**
|
||||
- Shows "Approved Subtotal" line when there's a mix of approved and non-approved services
|
||||
- If nothing is explicitly approved, full total shows (avoids $0 shock)
|
||||
|
||||
**PDF Generation (`generateQuotePDF()`):**
|
||||
- When decisions are mixed, services are grouped into two labeled sections:
|
||||
- "SERVICES APPROVED" (green header + underline) — only approved services
|
||||
- "POSTPONED SERVICES" (gray header + underline) — declined + pending services
|
||||
- If all services share the same decision, no section headers (renders normally)
|
||||
- Each service shows status badges: "✓ CUSTOMER APPROVED" or "✗ CUSTOMER DECLINED"
|
||||
- Declined services get line-through on the name
|
||||
- Pricing summary includes "Approved Subtotal" line
|
||||
- Section headers intelligently page-break
|
||||
|
||||
**Persistence:** `customerDecision` is stored inside each service object in the quotes PocketBase collection. `setSelectedServices()` normalizes missing fields to `'pending'`. Save and reload preserves all decisions.
|
||||
|
||||
**Normalization pattern** (in `setSelectedServices()`):
|
||||
```javascript
|
||||
const normalized = services.map(s => ({
|
||||
...s,
|
||||
customerDecision: s.customerDecision || 'pending',
|
||||
applyShopCharge: s.applyShopCharge !== false
|
||||
}));
|
||||
```
|
||||
|
||||
## PDF Generation
|
||||
|
||||
`generateQuotePDF()` in `quote-tab-manager.js` (line 3167) dynamically imports jsPDF 2.5.1 from Cloudflare CDN and builds a multi-page quote PDF with:
|
||||
|
||||
- Two-column header (business info + customer details with vehicle, VIN, RO#, mileage)
|
||||
- Personalized message from `appSettings.headerMessage`
|
||||
- Each service: name, price, priority level/reason, recommendation, explanation, parts status, customer decision badge (✓ Approved / ✗ Declined), aftermarket parts note
|
||||
- **When customer decisions are mixed**: services split into "SERVICES APPROVED" (green header) and "POSTPONED SERVICES" (gray header) sections. Declined service names get line-through.
|
||||
- Pricing summary: subtotal → approved subtotal (when mixed decisions) → discount → shop charge → tax → total
|
||||
- Footer with shop charge explanation, footer message, quote validity note
|
||||
- Page numbers on every page
|
||||
- Automatic page breaks with height calculations
|
||||
|
||||
Dependencies: internet (jsPDF CDN), `appSettings` import from settings.js, `quoteStateManager.getSelectedServices()`, DOM elements from `getQuoteDomElements()`. Validation requires customer name + at least one service.
|
||||
|
||||
### PDF Spacing Tuning
|
||||
|
||||
The spacing constants were adjusted to prevent single-service PDFs from wasting a page. Original values were overly conservative:
|
||||
|
||||
| Section | Before | After | Reason |
|
||||
|---|---|---|---|
|
||||
| After services → before summary | `yPos += 20` | `yPos += 10` | Too much dead space |
|
||||
| Summary page-break margin | `-40` | `-20` | Page numbers only need 15px |
|
||||
| Between totals → footer | `yPos += 40` | `yPos += 15` | Massive gap, footer only needs ~20px |
|
||||
| Footer page-break margin | `-40` | `-20` | Same — page numbers only |
|
||||
| After last service | `yPos += 12` | `yPos += 6` | Minimal needed |
|
||||
|
||||
Total reclaimed: ~81 units on A4 (841 units = ~10% of usable page height).
|
||||
@@ -0,0 +1,73 @@
|
||||
# Adding a New Advisor-Only Page to SPQ v2
|
||||
|
||||
Five files to touch. Follow the exact pattern below.
|
||||
|
||||
## Step-by-step
|
||||
|
||||
### 1. Create the page component
|
||||
```
|
||||
src/pages/advisor/<PageName>.tsx
|
||||
```
|
||||
Use `export default function PageName()` — lazy import expects a default export.
|
||||
|
||||
Page shell pattern:
|
||||
```tsx
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { pb } from '../../lib/pocketbase';
|
||||
|
||||
export default function PageName() {
|
||||
const userId = pb.authStore.model?.id as string | undefined;
|
||||
// ... state, effects, return JSX using the same Tailwind classes
|
||||
// as other advisor pages (AdvisorTeam as canonical reference)
|
||||
}
|
||||
```
|
||||
|
||||
### 2. `src/App.tsx` — lazy import + route
|
||||
```tsx
|
||||
const PageName = lazy(() => import('./pages/advisor/PageName'));
|
||||
```
|
||||
Route inside `<Route element={<OwnerLayout />}>`:
|
||||
```tsx
|
||||
<Route path="/route-path" element={<AdvisorOnly><PageName /></AdvisorOnly>} />
|
||||
```
|
||||
`AdvisorOnly` restricts access to `advisor` role only.
|
||||
|
||||
### 3. `src/components/Layout.tsx` — nav item
|
||||
Add the icon to the lucide-react import, then add a nav item in the `navItems` array:
|
||||
```tsx
|
||||
{ to: "/route-path", label: "Page Label", icon: IconName },
|
||||
```
|
||||
Also add preload import and wire handlers:
|
||||
```tsx
|
||||
import { preloadSettingsPage, preloadTimeCards } from "../lib/routePreload";
|
||||
// In the NavLink loop:
|
||||
onMouseEnter={item.to === "/route-path" ? preloadPageName : ...}
|
||||
```
|
||||
|
||||
### 4. `src/components/mobile/MobileLayout.tsx` — same nav pattern
|
||||
Mirror the import, nav item, and preload wiring from step 3.
|
||||
|
||||
### 5. `src/lib/routePreload.ts` — preload helpers
|
||||
```ts
|
||||
export const loadPageName = () => import('../pages/advisor/PageName');
|
||||
export function preloadPageName() {
|
||||
void loadPageName();
|
||||
}
|
||||
```
|
||||
|
||||
## Common page components available
|
||||
|
||||
- `ListSkeleton` / `ListError` — from `../../components/ui/`
|
||||
- `StatusBadge` — define inline or import from existing pages
|
||||
- Loading/error/empty pattern: `loading ? <ListSkeleton /> : error ? <ListError /> : actualContent`
|
||||
|
||||
## Verification
|
||||
```bash
|
||||
npm run build && npm run lint && npm run test
|
||||
```
|
||||
|
||||
## Pitfalls
|
||||
|
||||
- **Lazy import path**: Must point to the .tsx file directly. The `lazy(() => import(...))` wrapper uses dynamic import — Vite code-splits it.
|
||||
- **Don't forget MobileLayout** — every new page needs a nav entry in both layouts or mobile users can't reach it.
|
||||
- **Preload wiring**: Both `onMouseEnter` and `onFocus` need the preload call in both Layout.tsx and MobileLayout.tsx. Missing either means no prefetch.
|
||||
@@ -0,0 +1,59 @@
|
||||
# AI Features — DeepSeek Integration
|
||||
|
||||
spq-v2 mirrors v1's AI integration exactly: all calls go to `/deepseek/v1/chat/completions` with `deepseek-v4-flash`.
|
||||
|
||||
## Architecture
|
||||
|
||||
All AI functions live in `src/lib/ai.ts` and use a shared `callDeepSeek()` helper:
|
||||
|
||||
```
|
||||
fetch('/deepseek/v1/chat/completions', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
model: 'deepseek-v4-flash',
|
||||
messages: [{role:'system', content}, {role:'user', content}],
|
||||
temperature: 0,
|
||||
thinking: { type: 'disabled' },
|
||||
max_tokens: 500,
|
||||
}),
|
||||
})
|
||||
```
|
||||
|
||||
The endpoint works through the Python proxy server (`/deepseek/*` is forwarded by nginx in production, not the Python proxy — the proxy only handles `/pb/*`; ensure nginx has the `/deepseek` location block).
|
||||
|
||||
## Exported Functions
|
||||
|
||||
### 1. `getPriorityAnalysis(services)` — Generate Priorities
|
||||
- Input: `ServiceItem[]` (name, recommendation)
|
||||
- Output: `PriorityResult[]` (name, rank, priority_reason)
|
||||
- Sends services to AI for safety-first ranking: CRITICAL_SAFETY > SAFETY_CONCERN > RECOMMENDED > MAINTENANCE > OPTIONAL
|
||||
- Strips markdown code blocks from response before JSON parsing
|
||||
|
||||
### 2. `aiWriteExplanation(params)` — AI Write
|
||||
- Input: `ExplanationParams` (serviceName, recommendation, technicianNotes?, vehicleInfo?, mileage?, maintenanceInterval?)
|
||||
- Output: `ExplanationResult` (level, explanation) or null
|
||||
- Generates professional customer-facing explanation
|
||||
- Incorporates vehicle context, mileage, interval data
|
||||
- Parses `LEVEL:` and `EXPLANATION:` tagged response format
|
||||
- Level is one of: CRITICAL, RECOMMENDED, OPTIONAL, PREVENTIVE, MAINTENANCE
|
||||
|
||||
### 3. `aiSuggestServices(vehicle, selectedServices, catalog)` — AI Suggest
|
||||
- Input: `SuggestVehicle`, `string[]`, `CatalogItem[]`
|
||||
- Output: `SuggestResult[]` (name, reason) or null
|
||||
- Recommends services from catalog based on mileage + vehicle info
|
||||
- Cross-references AI output against catalog names — only returns catalog matches
|
||||
- Never invents services; only suggests from the provided catalog
|
||||
|
||||
### 4. Screenshot OCR + Extraction (in Appointments.tsx)
|
||||
- Uses Tesseract.js (dynamic CDN import: `cdn.jsdelivr.net/npm/tesseract.js@5`)
|
||||
- Canvas preprocessing: 2-3x upscale + mild contrast enhancement
|
||||
- OCR text sent to `/deepseek/v1/chat/completions` with `max_tokens: 2000` for structured JSON extraction
|
||||
- Extracted appointments reviewed before batch import
|
||||
|
||||
## Pitfalls
|
||||
|
||||
- **Tesseract.js not in package.json** — the screenshot import feature loads it dynamically from CDN. If offline or CDN blocked, the feature silently fails.
|
||||
- **Model must be `deepseek-v4-flash`** — other models may not follow the strict JSON-only response format.
|
||||
- **AI functions return `null` on failure** — UI must handle null gracefully (show toast, don't crash).
|
||||
- **`temperature: 0` is intentional** — structured extraction needs deterministic output.
|
||||
@@ -0,0 +1,92 @@
|
||||
# AI Prompt Engineering Rules for ShopProQuote
|
||||
|
||||
All AI features (AI Write, Smart Upsell, Priority Analysis, Daily Briefing) run via local Ollama (qwen2.5:14b at `/llm/v1/chat/completions`). These rules prevent the LLM from fabricating information, ignoring provided context, or responding in unhelpful ways.
|
||||
|
||||
## Rule 1: NEVER fabricate vehicle conditions
|
||||
|
||||
The LLM has NOT inspected the vehicle. It only knows year/make/model, mileage, and the service catalog. **Never let it invent inspection findings.**
|
||||
|
||||
**Bad prompt signals** (these give the LLM permission to fabricate):
|
||||
- "Be proactive about what the customer likely needs"
|
||||
- "this mileage/condition warrants this service" (the word "condition")
|
||||
- "suggest what's wearing out"
|
||||
|
||||
**Good prompt signals**:
|
||||
- "You have NOT inspected this vehicle and do NOT know its actual condition."
|
||||
- "NEVER invent or assume a condition. Do NOT say anything is leaking, worn, cracked, failing, low, dirty, or due unless referencing a manufacturer mileage interval."
|
||||
- "Every reason MUST cite either: (a) a mileage milestone, or (b) a known complementary pairing."
|
||||
|
||||
**Real example of LLM fabrication**: User had "Front Mount Motor" selected. LLM suggested "Replace shocks because they are leaking." The LLM had no inspection data — it invented the leak.
|
||||
|
||||
## Rule 2: System prompt must explicitly command use of additional context
|
||||
|
||||
If you pass extra data (technician notes, vehicle mileage, interval data) in the user message but only mention the service name + recommendation in the system prompt, the LLM will follow the system prompt literally and IGNORE the extra data.
|
||||
|
||||
**Fix requires ALL FOUR changes:**
|
||||
1. Move the additional context INTO the system prompt body with a prominent header: `"ADDITIONAL CONTEXT FROM THE TECHNICIAN:\n${additionalContext}"`
|
||||
2. Add an explicit numbered task: "3. If technician notes are provided, incorporate that specific information into the explanation"
|
||||
3. Add a WITH_NOTES example showing exactly how to incorporate notes
|
||||
4. Increase sentence count limit when notes are present (2-4 → 3-5)
|
||||
|
||||
Missing any one of these four → the LLM still ignores the notes.
|
||||
|
||||
## Rule 3: Compute occurrence counts for mileage-based recommendations — EXPLICIT BOUNDARY CASES
|
||||
|
||||
When a service has a `maintenanceInterval` and the vehicle has mileage, compute interval boundaries explicitly. The LLM needs ALL FOUR cases spelled out or it will guess wrong.
|
||||
|
||||
```
|
||||
occurrence = floor(mileage / interval)
|
||||
currentBoundary = occurrence × interval
|
||||
```
|
||||
|
||||
**Four output cases** (give the LLM worked examples for each):
|
||||
1. **Due now** (mileage == boundary): e.g. 90,000 ÷ 30,000 = 90,000. "This service is due now."
|
||||
2. **Past due** (mileage > boundary): e.g. 95,000 ÷ 30,000 → boundary 90,000, 5,000 miles past. "This service is 5,000 miles past due."
|
||||
3. **Approaching** (mileage within 2,000 of next boundary): e.g. 88,000 ÷ 30,000 → boundary 90,000. "Approaching 90,000 miles — this service will be due soon."
|
||||
4. **Well before** (mileage far from boundary): e.g. 75,000 ÷ 30,000 → boundary 90,000. "Next due at 90,000 miles."
|
||||
|
||||
**CRITICAL**: Include "NEVER subtract the last-done mileage. ONLY compare current mileage against interval boundaries." The LLM will otherwise subtract "last done at 60K" from "current 90K" and claim it's 30K past due — which is wrong.
|
||||
|
||||
**Pitfall (fixed June 2026)**: Old instructions only had two cases (occurrence ≥ 1, or approaching next). The LLM guessed the "past due" case and usually got it wrong by subtracting last-done mileage instead of comparing against interval boundaries.
|
||||
|
||||
Use an `ordinal()` helper: 1→1st, 2→2nd, 3→3rd, 4→4th, etc.
|
||||
|
||||
## Rule 4: Daily Briefing — anti-Team prompt
|
||||
|
||||
The LLM defaults to addressing groups as "Team" when role-playing as a foreman. To make it address a specific person:
|
||||
|
||||
```
|
||||
System: "You are speaking directly to [Name], a service advisor. You are NOT a foreman addressing a team. ALWAYS address [Name] by their first name. Never say 'Team' or 'everyone.'"
|
||||
```
|
||||
|
||||
Soft instructions ("address them by name") are ignored. Must be explicit and negative ("NOT a foreman. Never say Team.").
|
||||
|
||||
## Rule 5: Time-of-day greeting
|
||||
|
||||
Never hardcode "Good morning". Compute from `new Date().getHours()`:
|
||||
- < 12: "Good morning"
|
||||
- < 17: "Good afternoon"
|
||||
- else: "Good evening"
|
||||
|
||||
## Rule 6: Consequence of inaction
|
||||
|
||||
Every AI Write explanation must end with what could happen if left unaddressed. Use qualifying language ("may potentially lead to", not "will cause"). One sentence max.
|
||||
|
||||
## Rule 7: AI Suggest — require minimum suggestions
|
||||
|
||||
Without explicit minimums, the LLM returns 0-1 suggestions. With "at least 2 services" + mileage tiers, it reliably returns 2-4. The prompt must specify both a minimum count and the mileage thresholds.
|
||||
|
||||
## Rule 8: Catalog data must be rich
|
||||
|
||||
The LLM needs price, duration, and category to make informed suggestions. Sending only service names produces generic, unhelpful recommendations. Include: name, category, price, duration, maintenanceInterval.
|
||||
|
||||
## Rule 9: Suppress mileage mentions for non-interval services (AI Write)
|
||||
|
||||
When AI Write generates an explanation for a non-interval service (brake pads, belts, leaks, mounts — anything the technician FOUND rather than a scheduled maintenance item), the mileage should NOT be mentioned in the customer explanation text. It sounds robotic and repetitive, especially when AI Write is used on multiple services in the same RO.
|
||||
|
||||
**Implementation** (in `api.js` `handleAiWrite`):
|
||||
- Only include mileage in `additionalContext` when `maintenanceInterval` is also present
|
||||
- For non-interval services, pass mileage as background-only with explicit instruction: `"Vehicle mileage (for context only — do NOT mention this in the explanation unless the service itself is mileage-based): X miles"`
|
||||
- Prompt rule: `"Only mention mileage in the explanation if the service has interval data. Skip mileage entirely for non-interval services (e.g., brake pads, belts, fluid leaks) unless the service name explicitly includes a mileage milestone."`
|
||||
|
||||
**Pitfall (fixed June 2026)**: Old code always appended `"Current mileage: X miles"` to every AI Write prompt, and the prompt instructed the LLM to always mention it. Every explanation said "at X miles, your..." — redundant and unnatural.
|
||||
@@ -0,0 +1,74 @@
|
||||
# AI Write Debugging Guide
|
||||
|
||||
The AI Write feature has multiple call sites with different element IDs, and
|
||||
several silent-failure modes. Use this guide when "AI Write does nothing."
|
||||
|
||||
## Call sites (3 locations, different element IDs)
|
||||
|
||||
| Function | Button ID | Explanation ID | Notes ID |
|
||||
|----------|-----------|---------------|----------|
|
||||
| `setupCustomServiceModalEventListeners()` | `custom-service-ai-write-btn` | `custom-service-explanation` | `custom-service-technician-notes` |
|
||||
| `setupServiceEditEventListeners()` (inline modal) | `ai-write-btn-quote` | `service-edit-explanation-quote` | `service-edit-technician-notes-quote` |
|
||||
| Settings `setupServiceEventListeners()` | `ai-write-btn-settings` | `service-edit-explanation-settings` | `service-edit-technician-notes` |
|
||||
|
||||
## Common failure modes
|
||||
|
||||
### 1. Element ID mismatch
|
||||
The handler looks for one ID but the HTML has a different suffix. Always use
|
||||
`||` fallback chains that include ALL possible ID variants:
|
||||
```javascript
|
||||
const btn = document.getElementById('ai-write-btn-main')
|
||||
|| document.getElementById('ai-write-btn-fallback')
|
||||
|| document.getElementById('ai-write-btn-quote');
|
||||
```
|
||||
|
||||
### 2. Duplicate event listeners (race condition)
|
||||
`setupServiceEditEventListeners()` was called every time the modal opened,
|
||||
accumulating click handlers via `addEventListener()`. After 3 opens, clicking
|
||||
AI Write fired 4 simultaneous API calls. Fix: guard with attribute.
|
||||
```javascript
|
||||
if (modal.hasAttribute('data-edit-event-listeners-attached')) return;
|
||||
modal.setAttribute('data-edit-event-listeners-attached', 'true');
|
||||
```
|
||||
|
||||
### 3. FormValidator blocks save silently
|
||||
FormValidator initialized with wrong IDs → `validateForm()` always false →
|
||||
`saveServiceEdit()` never called. No error shown. Fix: call `saveServiceEdit()`
|
||||
directly.
|
||||
|
||||
### 4. LLM ignores technician notes
|
||||
The notes were sent to the LLM but the prompt never told it to use them. Fix:
|
||||
put notes in the system prompt body with a prominent header AND add explicit
|
||||
numbered instructions.
|
||||
|
||||
### 5. `explanationTextarea` vs `explTextarea` typo
|
||||
Variable defined as `explanationTextarea` but used as `explTextarea` at one
|
||||
call site. ReferenceError — caught by the catch block, shows generic error.
|
||||
|
||||
### 6. Custom service modal AI Write missing technician notes / vehicle context
|
||||
`setupCustomServiceModalEventListeners()` calls `handleAiWrite(nameInput, recInput, explTextarea, aiWriteBtn)` — only 4 args. Missing:
|
||||
- 5th arg: `technicianNotesEl` (the `custom-service-technician-notes` textarea)
|
||||
- 6th arg: `vehicleContext` object with vehicleInfo and mileage
|
||||
|
||||
Without these, the LLM prompt never includes \"Technician's internal notes:\" or
|
||||
\"Vehicle:\" / \"Current mileage:\" context. The AI writes generic explanations
|
||||
instead of incorporating the technician's specific observations.
|
||||
|
||||
**Fix**: Match the edit-service call site pattern:
|
||||
```js
|
||||
const technicianNotesEl = document.getElementById('custom-service-technician-notes');
|
||||
const vehicleCtx = {
|
||||
vehicleInfo: document.getElementById('vehicleInfo')?.value || '',
|
||||
mileage: document.getElementById('mileage')?.value || '',
|
||||
maintenanceInterval: null
|
||||
};
|
||||
await handleAiWrite(nameInput, recInput, explTextarea, aiWriteBtn, technicianNotesEl, vehicleCtx);
|
||||
```
|
||||
|
||||
## Diagnostic checklist
|
||||
|
||||
1. `grep -n "ai-write-btn" repair-orders.html` — verify button ID exists
|
||||
2. Check console for "Missing required elements for AI Write" log
|
||||
3. Check if `handleAiWrite` is actually imported (line 5 of quote-tab-manager.js)
|
||||
4. Verify the LLM endpoint is reachable: `curl -sk https://localhost/llm/v1/chat/completions`
|
||||
5. Check nginx error log for 403 on shared modules
|
||||
@@ -0,0 +1,47 @@
|
||||
# Appointment Screenshot System Prompt
|
||||
|
||||
The `const systemPrompt` in `appointments.html` (inline script, ~line 1510) instructs the text LLM how to parse OCR text into structured appointment JSON.
|
||||
|
||||
## Full Prompt
|
||||
|
||||
```
|
||||
You are a strict data extraction AI. The following text was OCR'd from an auto repair shop appointment schedule screenshot. Extract all appointments into JSON. Return ONLY the raw JSON string — no markdown, no intro, no outro.
|
||||
|
||||
### COLUMN LAYOUT (left to right, each horizontal line = one appointment):
|
||||
Time | Customer(s) | Phone | Advisor | OpCode | Service | Duration | RO# | VehicleInfo | VIN
|
||||
|
||||
### OCR ARTIFACTS — COMMON GARBLING TO FIX:
|
||||
- Times: "ESCAM"→"8:00AM", "SSLAM"→"8:30AM", "EOLEM"→"9:00AM", "EODEM"→"9:30AM", "LOLAM"→"10:00AM", "LOSDAM"→"10:30AM", "LLAM"→"11:00AM", etc. Pattern: first 1-2 chars = hour digit(s), then OCR-garbled "AM"/"PM".
|
||||
- VINs are 17 chars. OCR often mangles digits: 8→B, B→8, 5→S, S→5, 0→O, O→0, 1→I, I→1, 2→Z, Z→2, 6→G, G→6, 3→E, E→3. Use context to recover: VINs follow the vehicle make/model (e.g., Honda VINs start with 1HG, 5FN, JHM, 19X). Recover the most likely valid VIN.
|
||||
- Phone numbers: pattern (XXX) XXX-XXXX or XXX-XXX-XXXX. Extract digits from garbled text near the customer name.
|
||||
- Duration: "0.5 hrs", "1.2 hrs", "3.5 hrs", "CS hrs"→"0.5 hrs", "FS hrs"→"3.5 hrs". Convert decimal hours to integer minutes.
|
||||
- Service descriptions: strip advisor tags (Reekmar, Rrahmar, Rehmar, Reakmar, Brakmar) from the service field.
|
||||
- Vehicle info: "YEAR MAKE MODEL" (e.g., "2015 HONDA ACCORD", "2020 HONDA PILOT"). Extract the full string.
|
||||
- Op codes appear in brackets: [OSWL], [FFIS], [BODY], [TICCAA], [BYCO], [CSWL], etc.
|
||||
|
||||
### EXTRACTION RULES:
|
||||
1. appointmentDate: Find the date in the header (e.g., "Wednesday Jun 10, 2026") → YYYY-MM-DD. If service notes say "SERVICE ON M/D/YY", use THAT date.
|
||||
2. appointmentTime: Convert OCR-garbled time to 24h HH:MM. If you see "No" near the time field and no clear time, use "" (empty).
|
||||
3. durationMinutes: Decimal hours → integer minutes. "0.5 hrs"=30, "1.2 hrs"=72, "3.5 hrs"=210.
|
||||
4. customerName: ALL name text before the phone number. If 2 names appear (e.g., "Phil Stark Becca Stark"), include both separated by space.
|
||||
5. customerPhone: Extract 10 digits from the garbled phone field. Strip non-digits.
|
||||
6. customerEmail: Look for "@" pattern. If absent, use "".
|
||||
7. vin: Recover from OCR-garbled 17-char string near the vehicle info. Apply digit-recovery rules. Output uppercase, no spaces.
|
||||
8. vehicleInfo: "YEAR MAKE MODEL" string. Or partial if OCR is poor.
|
||||
9. opCode: Text inside square brackets like [OSWL] or [BODY]. Include brackets in output.
|
||||
10. serviceType: Service description text, excluding the opCode and advisor name.
|
||||
11. Missing fields: "" for strings, 60 for minutes.
|
||||
|
||||
### JSON SCHEMA:
|
||||
{"appointments":[{"appointmentDate":"YYYY-MM-DD","appointmentTime":"HH:MM","durationMinutes":integer,"customerName":"string","customerPhone":"string","customerEmail":"string","vin":"string","vehicleInfo":"string","opCode":"string","serviceType":"string"}]}
|
||||
```
|
||||
|
||||
## Key Design Decisions
|
||||
|
||||
- **No markdown code blocks**: The prompt explicitly forbids wrapping JSON in ``` fences, because smaller models frequently put extra text inside/outside the fences, breaking `JSON.parse()`.
|
||||
- **Temperature 0.0**: Always sent with `options: { temperature: 0.0 }` to prevent hallucinated names.
|
||||
- **OCR text, not image**: Unlike the previous vision-model approach, the LLM receives pre-extracted OCR text — it never sees the image. This separates pixel-reading (Tesseract's job) from structure understanding (the LLM's job).
|
||||
- **Explicit column layout**: Telling the LLM the exact column order helps it reconstruct garbled OCR that's lost column alignment.
|
||||
- **OCR artifact recovery rules**: The prompt includes specific mappings for common Tesseract garbling patterns (times, VINs, durations). This is critical because Tesseract reliably mangles certain character combinations in small/narrow text.
|
||||
- **VIN prefix hints**: Honda VIN prefixes (1HG, 5FN, JHM, 19X) help the LLM distinguish between OCR-garbled characters when the VIN is partially readable.
|
||||
- **Advisor tag stripping**: OCR often picks up advisor name tags mixed into service descriptions — the prompt instructs the LLM to strip them.
|
||||
@@ -0,0 +1,62 @@
|
||||
# SPQ Auth Flow & Page Structure
|
||||
|
||||
## File roles
|
||||
|
||||
| File | Role |
|
||||
|------|------|
|
||||
| `index.html` | **Login/signup page** — the root page. No auth redirect on load. |
|
||||
| `dashboard.html` | **Dashboard** — the main app (was previously `index.html`). |
|
||||
| `appointments.html`, `repair-orders.html`, `customers.html` | Sub-pages with shared header/nav pointing to `dashboard.html`. |
|
||||
|
||||
## Auth redirects
|
||||
|
||||
```
|
||||
Not logged in:
|
||||
/ → index.html (login form, NO redirect)
|
||||
/dashboard.html → auth guard → redirects to /index.html
|
||||
|
||||
Logged in:
|
||||
/ → detects token in localStorage → redirects to /dashboard.html
|
||||
/dashboard.html → stays
|
||||
Logout → redirects to /index.html
|
||||
```
|
||||
|
||||
## Why index.html is the login page
|
||||
|
||||
Originally `index.html` was the dashboard and `login.html` was the login page. This caused a **redirect loop**: when PocketBase auth state was unstable (stale/expired token), `onAuthStateChanged` on the dashboard saw no user and redirected to `login.html`, whose own `onAuthStateChanged` saw the stored token and redirected back to `index.html` — creating a flip-flop between the two URLs.
|
||||
|
||||
**Fix**: Made `index.html` the login page and moved the dashboard to `dashboard.html`. Now visiting the root (`/`) shows the login form directly with zero redirects. The dashboard only redirects when it detects no user (→ `/`), and the login only redirects when it detects a user (→ `/dashboard.html`). No more reciprocal bounce.
|
||||
|
||||
## Reference: all redirect targets
|
||||
|
||||
When renaming or restructuring pages, update these files:
|
||||
|
||||
### Auth failure → `index.html` (login)
|
||||
- `dashboard.js:122` — `window.location.href = 'index.html'`
|
||||
- `main.js:44` — `window.location.href = 'index.html'`
|
||||
- `appointments.js:50` — `window.location.href = 'index.html'`
|
||||
- `customers.js:78` — `window.location.href = 'index.html'`
|
||||
- `repair-orders.js:58` — `window.location.href = 'index.html'`
|
||||
- `shared/header-functionality.js:318` — logout handler → `'index.html'`
|
||||
|
||||
### Login success → `dashboard.html`
|
||||
- `login.js:33` — `window.location.href = 'dashboard.html'`
|
||||
- `index.html:131` — inline script: `location.replace('dashboard.html?cb='+Date.now())`
|
||||
- `index.html:577` — login success handler
|
||||
- `index.html:1035` — signup success handler
|
||||
|
||||
### Nav links → `dashboard.html`
|
||||
All `href="dashboard.html"` in: `appointments.html`, `repair-orders.html`, `customers.html`, `dashboard.html`
|
||||
- `dashboard.html` nav has `active-page` class on its own link
|
||||
|
||||
### Quote deep-link → `dashboard.html?quoteId=...`
|
||||
- `customers.js:676` — `href="dashboard.html?quoteId=${escapeHtml(quote.id)}"`
|
||||
|
||||
### Keyboard shortcut → `dashboard.html`
|
||||
- `dashboard.js:2144` — case 'q' keyboard shortcut
|
||||
|
||||
## Dual-VPS config Host header note
|
||||
|
||||
The VPS has two ShopProQuote nginx configs:
|
||||
- `shopproquote-duckdns` (for `grajmedia.duckdns.org`) — `Host: grajmedia.duckdns.org` ✓ correct
|
||||
- `shopproquote` (for `shopproquote.graj-media.com`) — `Host: graj-media.com` — **may not match home nginx** which only has `server_name grajmedia.duckdns.org`. See `vps-reverse-proxy` skill's "Host header mismatch → SPA redirect loop" pitfall.
|
||||
@@ -0,0 +1,82 @@
|
||||
# Auth Redirect Anti-Loop Pattern
|
||||
|
||||
## The Problem
|
||||
|
||||
When BOTH the login page and the dashboard have `onAuthStateChanged` listeners that redirect to each other, any auth-state instability creates an infinite loop:
|
||||
|
||||
```
|
||||
dashboard.js: onAuthStateChanged(null) → redirect to login page
|
||||
login.js: onAuthStateChanged(user) → redirect to dashboard
|
||||
→ LOOP
|
||||
```
|
||||
|
||||
This fires when PocketBase auth is unstable — stale token in localStorage that passes JWT expiry check but gets 401 on first API call, Host header mismatch causing API calls to fail, etc.
|
||||
|
||||
## The Fix (Three Parts)
|
||||
|
||||
### 1. login.js — onAuthStateChanged is UI-only, NO redirect
|
||||
|
||||
```js
|
||||
onAuthStateChanged(auth, (user) => {
|
||||
if (user) {
|
||||
// Show logged-in UI, hide form
|
||||
// DO NOT redirect
|
||||
} else {
|
||||
// Show login form, hide loading
|
||||
// DO NOT redirect
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
Only explicit login/signup button handlers redirect:
|
||||
```js
|
||||
loginBtn.addEventListener('click', async () => {
|
||||
await signInWithEmailAndPassword(...);
|
||||
window.location.href = 'dashboard.html';
|
||||
});
|
||||
```
|
||||
|
||||
### 2. index.html — inline localStorage check for "already logged in"
|
||||
|
||||
```html
|
||||
<script>if(localStorage.getItem('pocketbase_auth')){location.replace('dashboard.html?cb='+Date.now())}</script>
|
||||
```
|
||||
|
||||
This fires BEFORE any ES modules load. No auth-state dependency. Cannot cause a loop. The `cb=` parameter busts browser cache.
|
||||
|
||||
### 3. dashboard.js and all protected pages — redirect to login on null
|
||||
|
||||
```js
|
||||
onAuthStateChanged(auth, (user) => {
|
||||
if (user) {
|
||||
initializeApp();
|
||||
} else {
|
||||
window.location.href = 'index.html';
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
This is correct and should remain. It's the login page's auto-redirect that creates the loop, not the dashboard's.
|
||||
|
||||
## File Architecture
|
||||
|
||||
| File | Role | Redirects? |
|
||||
|------|------|-----------|
|
||||
| `index.html` | Login page | Inline script: localStorage check → dashboard |
|
||||
| `login.js` | Login logic | Only on explicit button click → dashboard |
|
||||
| `dashboard.js` | Dashboard | onAuthStateChanged(null) → index.html |
|
||||
| `repair-orders.js` | Protected page | onAuthStateChanged(null) → index.html |
|
||||
| `appointments.js` | Protected page | onAuthStateChanged(null) → index.html |
|
||||
| `customers.js` | Protected page | onAuthStateChanged(null) → index.html |
|
||||
| `main.js` | Protected page | onAuthStateChanged(null) → index.html |
|
||||
| `shared/header-functionality.js` | Logout handler | signOut → index.html |
|
||||
|
||||
## Verification Checklist
|
||||
|
||||
After any auth or routing change:
|
||||
1. Visit root with NO auth token → login form shows, no redirect
|
||||
2. Log in → redirects to dashboard, one hop
|
||||
3. Visit root with VALID auth token → auto-redirect to dashboard, one hop
|
||||
4. Visit `/dashboard.html` with NO auth → redirect to login, one hop
|
||||
5. Log out → redirect to login, one hop
|
||||
6. **No double-bounce under any scenario**
|
||||
@@ -0,0 +1,44 @@
|
||||
# Auth Redirect Loop
|
||||
|
||||
## Pattern
|
||||
|
||||
`index.html` line 131 checks localStorage for a `pocketbase_auth` token with a blind truthy check:
|
||||
|
||||
```javascript
|
||||
if(localStorage.getItem('pocketbase_auth')){location.replace('dashboard.html?cb='+Date.now())}
|
||||
```
|
||||
|
||||
This fires BEFORE PocketBase SDK loads. Any string in that key — including stale/expired tokens — triggers a redirect to `dashboard.html`.
|
||||
|
||||
Then `dashboard.js` (line 113-124) calls `onAuthStateChanged`. If the auth is invalid:
|
||||
```javascript
|
||||
onAuthStateChanged(auth, (user) => {
|
||||
if (user) { ... }
|
||||
else { window.location.href = 'index.html'; }
|
||||
});
|
||||
```
|
||||
|
||||
The redirect back to `index.html` does NOT clear the stale `pocketbase_auth` from localStorage. So `index.html` line 131 fires again → infinite loop.
|
||||
|
||||
## Fix
|
||||
|
||||
Every JS file that redirects back to `index.html` on auth failure MUST clear the stale token first:
|
||||
|
||||
```javascript
|
||||
localStorage.removeItem('pocketbase_auth');
|
||||
window.location.href = 'index.html';
|
||||
```
|
||||
|
||||
**Files requiring this fix:**
|
||||
- `dashboard.js` — `onAuthStateChanged` callback, `user == null` path
|
||||
- `appointments.js` — auth check in init (line ~49)
|
||||
- `repair-orders.js` — auth check in init (line ~57)
|
||||
|
||||
The `shared/header-functionality.js` logout path already calls `signOut(auth)` which calls `pb.authStore.clear()`, so it's fine.
|
||||
|
||||
## Diagnostics
|
||||
|
||||
If the user reports a redirect loop (page flashes between URLs), check:
|
||||
1. Does `localStorage.getItem('pocketbase_auth')` return a value? → stale token
|
||||
2. Do the bounce-back paths clear it? → likely not
|
||||
3. Cache: old JS files with `login.html` references are a separate browser-cache issue (hard reload needed)
|
||||
@@ -0,0 +1,65 @@
|
||||
# Common Bugs & Pitfalls
|
||||
|
||||
## Auth redirect loop (index.html ↔ dashboard.html)
|
||||
|
||||
**Symptom**: Page constantly refreshes between index.html and dashboard.html.
|
||||
|
||||
**Root cause**: `index.html` line 131 blindly checks `localStorage.getItem('pocketbase_auth')` — any truthy value (including stale/expired tokens) triggers a redirect to dashboard.html. Dashboard's `onAuthStateChanged` then detects the invalid token and bounces back to index.html, but the stale token is never cleared from localStorage.
|
||||
|
||||
**Fix**: In every file that redirects unauthenticated users back to index.html, clear the stale token first:
|
||||
|
||||
```javascript
|
||||
localStorage.removeItem('pocketbase_auth');
|
||||
window.location.href = 'index.html';
|
||||
```
|
||||
|
||||
Files that need this: `dashboard.js` (line 122), `appointments.js` (line 50), `repair-orders.js` (line 58).
|
||||
|
||||
## PocketBase auto-cancellation ("request was auto-cancelled")
|
||||
|
||||
**Symptom**: Creating/updating records fails with "The request was auto-cancelled" or similar abort error.
|
||||
|
||||
**Root cause**: PocketBase SDK auto-cancels requests that share the same `requestKey`. By default, all `create`/`update` calls to the same collection share the same key — a rapid double-click fires two identical requests and the first is aborted.
|
||||
|
||||
**Fix — two parts**:
|
||||
|
||||
1. Disable auto-cancellation in `pocketbase.js` by passing `{ requestKey: null }` to all mutating PocketBase calls:
|
||||
- `addDoc`: `pb.collection(collName).create(pbData, { requestKey: null })`
|
||||
- `updateDoc`: `pb.collection(collName).update(docId, pbData, { requestKey: null })`
|
||||
- `setDoc`: both internal `update` and `create` calls need it
|
||||
|
||||
2. Add double-submission guard to form submit handlers — disable the button on first click:
|
||||
```javascript
|
||||
const submitBtn = document.getElementById('submit-create-ro');
|
||||
if (submitBtn && submitBtn.disabled) return;
|
||||
if (submitBtn) {
|
||||
submitBtn.disabled = true;
|
||||
submitBtn.textContent = 'Creating...';
|
||||
}
|
||||
```
|
||||
Re-enable the button in BOTH the success path (after form clear/collapse) AND the catch block on error. Forgetting the success path leaves the button permanently disabled after a successful submission.
|
||||
|
||||
## DuckDNS DNS stale / site unreachable
|
||||
|
||||
**Symptom**: "This site can't be reached" when accessing grajmedia.duckdns.org.
|
||||
|
||||
**Check order**:
|
||||
1. `dig +short grajmedia.duckdns.org` — if returns `127.0.0.1`, run the DuckDNS updater manually: `bash /opt/duckdns/update.sh`
|
||||
2. The cron job runs every 5 min: `*/5 * * * * /opt/duckdns/update.sh`
|
||||
3. `/etc/hosts` has `127.0.0.1 grajmedia.duckdns.org` for local hairpin NAT — this is intentional, not a bug
|
||||
|
||||
## Dashboard Completed counter shows 0 (`ro.status` vs `ro.workStatus`)
|
||||
|
||||
**Symptom**: "Completed: 0" on the dashboard Repair Orders Today card, despite having completed orders today.
|
||||
|
||||
**Root causes (usually BOTH apply)**:
|
||||
|
||||
1. **Wrong field**: Code filters by `ro.status === 'completed'` but `status` only stores `waiter`/`drop-off`. Must use `ro.workStatus === 'completed'`.
|
||||
2. **Completed orders filtered out at source**: `loadRepairOrders()` in dashboard.js removes all completed orders from `dashboardData.repairOrders` before any counter can see them.
|
||||
|
||||
**Fix checklist**:
|
||||
- Replace ALL `ro.status === 'completed'` with `ro.workStatus === 'completed'`
|
||||
- Replace ALL `ro.status === 'picked-up'` with `ro.workStatus === 'waiting-for-pickup'`
|
||||
- Remove the completed-filter from `loadRepairOrders()` so `dashboardData.repairOrders` contains ALL orders
|
||||
- Each consumer then filters by `ro.workStatus` as needed
|
||||
- See `references/status-field-confusion.md` for the full reference
|
||||
@@ -0,0 +1,27 @@
|
||||
# CSP Requirements for Tesseract.js
|
||||
|
||||
When adding client-side OCR (Tesseract.js v5 via CDN) to an SPQ page, the Content Security Policy `<meta>` tag must be extended beyond the standard SPQ CSP. Without these, Tesseract.js silently fails — the UI shows "Running OCR..." forever because the Web Worker can't download WASM/language data.
|
||||
|
||||
## Required CSP directives
|
||||
|
||||
Tesseract.js v5 from `cdn.jsdelivr.net` needs:
|
||||
|
||||
| Directive | Addition | Why |
|
||||
|---|---|---|
|
||||
| `connect-src` | `https://cdn.jsdelivr.net blob:` | Worker fetches WASM core + `eng.traineddata` from CDN; blob worker communication |
|
||||
| `worker-src` | `blob:` | Tesseract creates inline Web Workers via blob URLs |
|
||||
| `script-src` | `'unsafe-eval'` | WASM compilation uses `new Function()` under Emscripten |
|
||||
|
||||
## Full CSP template
|
||||
|
||||
```
|
||||
<meta http-equiv="Content-Security-Policy" content="default-src 'self'; script-src 'self' 'unsafe-inline' 'unsafe-eval' https://cdn.tailwindcss.com https://cdn.jsdelivr.net https://cdnjs.cloudflare.com https://fonts.googleapis.com https://www.gstatic.com; style-src 'self' 'unsafe-inline' https://cdn.tailwindcss.com https://fonts.googleapis.com; font-src https://fonts.gstatic.com; connect-src 'self' https://cdn.jsdelivr.net blob:; img-src 'self' data: blob:; worker-src blob:; frame-src 'self';">
|
||||
```
|
||||
|
||||
## Debugging checklist
|
||||
|
||||
1. Check browser console (F12) — CSP violations are logged clearly with the blocked directive and resource
|
||||
2. "violates content security policy directive 'connect-src'" → missing CDN in connect-src
|
||||
3. "'unsafe-eval' is not allowed" → missing in script-src
|
||||
4. `.map` file blocked (source map) → cosmetic, can ignore
|
||||
5. After CSP fix, hard refresh (Ctrl+Shift+R) to bypass cached CSP meta tag
|
||||
@@ -0,0 +1,83 @@
|
||||
# CSP for WASM / Web Worker Libraries
|
||||
|
||||
SPQ pages use `<meta http-equiv="Content-Security-Policy">` tags with restrictive defaults. When
|
||||
adding client-side libraries that use WebAssembly or Web Workers (Tesseract.js, ONNX runtime, etc.),
|
||||
the CSP MUST be updated or the library silently fails.
|
||||
|
||||
## Required CSP Directives
|
||||
|
||||
### `script-src` — add `'unsafe-eval'`
|
||||
|
||||
WASM compilation uses `new Function()` / `eval()` internally. Without it:
|
||||
|
||||
```
|
||||
'unsafe-eval' is not allowed source of script in ... "script-src 'self' 'unsafe-inline'"
|
||||
```
|
||||
|
||||
### `connect-src` — add CDN origin + `blob:`
|
||||
|
||||
Web Workers download WASM cores and data files (e.g. `eng.traineddata`) from CDN via `fetch()`.
|
||||
Blob URLs are needed for worker communication.
|
||||
|
||||
Without CDN:
|
||||
|
||||
```
|
||||
Tesseract.min.js.map - violates ... "connect-src 'self'"
|
||||
```
|
||||
|
||||
Without `blob:`:
|
||||
|
||||
```
|
||||
Refused to connect to 'blob:...' because it violates ... "connect-src"
|
||||
```
|
||||
|
||||
### `worker-src` — add `blob:`
|
||||
|
||||
Inline Web Workers created via `new Worker(URL.createObjectURL(blob))` need `blob:` in `worker-src`.
|
||||
|
||||
## Full CSP Template
|
||||
|
||||
```html
|
||||
<meta http-equiv="Content-Security-Policy" content="
|
||||
default-src 'self';
|
||||
script-src 'self' 'unsafe-inline' 'unsafe-eval'
|
||||
https://cdn.tailwindcss.com https://cdn.jsdelivr.net
|
||||
https://cdnjs.cloudflare.com https://fonts.googleapis.com https://www.gstatic.com;
|
||||
style-src 'self' 'unsafe-inline'
|
||||
https://cdn.tailwindcss.com https://fonts.googleapis.com;
|
||||
font-src https://fonts.gstatic.com;
|
||||
connect-src 'self' https://cdn.jsdelivr.net blob:;
|
||||
img-src 'self' data: blob:;
|
||||
worker-src blob:;
|
||||
frame-src 'self';
|
||||
">
|
||||
```
|
||||
|
||||
## Diagnosis Pattern
|
||||
|
||||
When a WASM/worker library silently hangs with no console errors visible at first glance:
|
||||
|
||||
1. Open **F12 → Console** and look for CSP violation messages (they appear as warnings, not errors)
|
||||
2. They say exactly which directive was violated and which resource was blocked
|
||||
3. Add the missing origin/blob: to the relevant directive
|
||||
4. Hard refresh (Ctrl+Shift+R) to bypass `<meta>` tag cache
|
||||
|
||||
Common silent-failure symptoms:
|
||||
- **Tesseract.js**: "Running OCR..." forever (worker can't download WASM/traineddata)
|
||||
- **ONNX**: Model loading hangs (can't fetch `.onnx` from CDN)
|
||||
- **Any WASM**: White screen or stuck spinner (WASM compilation blocked)
|
||||
|
||||
## Pages Affected
|
||||
|
||||
Every SPQ page has its own `<meta>` CSP tag:
|
||||
|
||||
| Page | CSP needed? |
|
||||
|---|---|
|
||||
| `appointments.html` | Yes (Tesseract.js) |
|
||||
| `index.html` | Minimal (no WASM) |
|
||||
| `customers.html` | Minimal |
|
||||
| `login.html` | Minimal |
|
||||
| `repair-orders.html` | Minimal (but has AI features via api.js) |
|
||||
|
||||
Only update CSP on pages that actually load WASM/worker libraries. Over-restricting is fine;
|
||||
over-permissing is not.
|
||||
@@ -0,0 +1,85 @@
|
||||
# Customer Decision System (v2 Quote Generator)
|
||||
|
||||
## Fields
|
||||
|
||||
Each `QuoteService` has two related fields:
|
||||
|
||||
| Field | Type | Purpose |
|
||||
|---|---|---|
|
||||
| `approved` | boolean | Visual toggle state for the green checkbox |
|
||||
| `customerDecision` | `'approved'` / `'pending'` / `'declined'` | Actual decision state |
|
||||
|
||||
## How They Stay in Sync
|
||||
|
||||
- **Approve button** (green ✓): sets `approved: true, customerDecision: 'approved'`
|
||||
- **Decline button** (red ✕): sets `approved: false, customerDecision: 'declined'`
|
||||
- **Pending button**: sets `approved: false, customerDecision: 'pending'`
|
||||
- **toggleApproved** (checkbox click): toggles `approved`, sets `customerDecision` to `'pending'` (when unapproving) or `'approved'` (when approving)
|
||||
- **toggleDeclined** (X checkbox click): sets `approved: false`, toggles `customerDecision` between `'declined'` and `'pending'`
|
||||
|
||||
Both are set together in every action path — they never drift.
|
||||
|
||||
## Service Grouping in ServicesTable
|
||||
|
||||
The `ServicesTable` component groups services by `customerDecision` into three sections:
|
||||
- **Approved** (green header): `customerDecision === 'approved'`
|
||||
- **Pending** (gray header): `customerDecision === 'pending'`
|
||||
- **Declined** (red header): `customerDecision === 'declined'`
|
||||
|
||||
An "Approve All" button sits above the groups when non-approved services exist.
|
||||
|
||||
## Totals Calculation: `billableServices()`
|
||||
|
||||
Defined in `src/lib/totals.ts`. This is the single shared heuristic used by the UI Quote Summary, the store's `subtotal()`/`approvedSubtotal()`, and the PDF generator.
|
||||
|
||||
```typescript
|
||||
export function billableServices(services: { customerDecision: string }[]) {
|
||||
const nonDeclined = services.filter(s => s.customerDecision !== 'declined');
|
||||
const hasApproved = nonDeclined.some(s => s.customerDecision === 'approved');
|
||||
const hasPending = nonDeclined.some(s => s.customerDecision === 'pending');
|
||||
return (hasApproved && hasPending)
|
||||
? nonDeclined.filter(s => s.customerDecision === 'approved') // mixed → only approved
|
||||
: nonDeclined; // unanimous → all
|
||||
}
|
||||
```
|
||||
|
||||
**Rules:**
|
||||
- All services approved → count all (full total)
|
||||
- All services pending → count all (full total)
|
||||
- Mixed approved + pending → count only approved (partial total)
|
||||
- Declined services → always excluded
|
||||
|
||||
This makes totals dynamic: they adjust as the advisor approves/declines individual services. The full total only appears when the decision state is unanimous.
|
||||
|
||||
## PDF Generation
|
||||
|
||||
In `src/lib/pdf.ts`, the `billableServices()` result is pre-computed as a `Set` of service IDs:
|
||||
```typescript
|
||||
const billableIds = new Set(billableServices(services).map(s => s.id));
|
||||
```
|
||||
Used in two places within the generator:
|
||||
- Service total accumulation: `if (billableIds.has(svc.id)) servicesTotal += price`
|
||||
- Shop charge base: `(billableIds.has(s.id) && s.applyShopCharge)`
|
||||
|
||||
## Store Actions
|
||||
|
||||
- `toggleApproved(id)` — toggles approve checkbox state
|
||||
- `toggleDeclined(id)` — toggles decline (X) checkbox state
|
||||
- `updateService(id, updates)` — partial update (used by Approve/Decline/Pending buttons)
|
||||
|
||||
## Dual Checkbox UI
|
||||
|
||||
ServiceRow renders two checkboxes side by side:
|
||||
|
||||
| Checkbox | Icon | Active color | Calls |
|
||||
|---|---|---|---|
|
||||
| Approve | ✓ (always visible, gray when off) | Green | `onToggleApproved` |
|
||||
| Decline | ✕ (always visible, gray when off) | Red | `onToggleDeclined` |
|
||||
|
||||
The icons are always rendered — grayed-out (`text-gray-300`) when inactive, white on colored background when active. Clicking either toggles its state.
|
||||
|
||||
## Recent Quotes Status Display
|
||||
|
||||
The Recent Quotes panel (`src/pages/QuoteGenerator.tsx`) derives a display status from service decisions, NOT from the raw PocketBase `status` field. When all services are decided (no pending), the status is computed: all-approved → "Approved", all-declined → "Declined". The PocketBase `status` field only tracks lifecycle ('draft' on save, 'sent' on share, 'approved'/'declined' on customer submission via QuoteApprove) — it does NOT reflect service-level decisions made in the QuoteGenerator.
|
||||
|
||||
See `references/recent-quotes-status-derivation.md` for the full fix and rationale.
|
||||
@@ -0,0 +1,96 @@
|
||||
# Dashboard Metric Cards
|
||||
|
||||
The ShopProQuote dashboard (`dashboard.html` / `dashboard.js`) has a set of Quick Metric cards that display live counts from the repair orders, appointments, tasks, and quotes collections.
|
||||
|
||||
## Architecture
|
||||
|
||||
### HTML — Card Structure
|
||||
|
||||
Cards live in a Tailwind grid inside `<!-- Quick Metrics -->`:
|
||||
|
||||
```html
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-6 gap-6 mb-8">
|
||||
```
|
||||
|
||||
Each card follows this exact template:
|
||||
|
||||
```html
|
||||
<!-- Card Label -->
|
||||
<div class="metric-card rounded-xl p-6 shadow-lg">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<div class="p-2 bg-COLOR-100 dark:bg-COLOR-900 rounded-lg">
|
||||
<!-- SVG icon matching the metric theme -->
|
||||
<svg class="w-6 h-6 text-COLOR-600 dark:text-COLOR-400" ...>...</svg>
|
||||
</div>
|
||||
<span id="UNIQUE-ID-count" class="text-3xl font-bold text-gray-900 dark:text-white">0</span>
|
||||
</div>
|
||||
<h3 class="text-sm font-medium text-gray-600 dark:text-gray-400 mb-2">Card Title</h3>
|
||||
<div class="text-xs text-gray-500 dark:text-gray-500" id="UNIQUE-ID-breakdown">
|
||||
<!-- Optional sub-items with status-indicator spans -->
|
||||
<span class="status-indicator status-COLOR"></span>Status label
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
**Key:**
|
||||
- Each card gets a unique `id` on its count `<span>` (e.g., `parts-on-order-count`, `ready-for-pickup-count`)
|
||||
- Color variants: `bg-{color}-100`, `text-{color}-600` (light) / `bg-{color}-900`, `text-{color}-400` (dark)
|
||||
- Status indicators use the existing class system: `status-overdue` (red), `status-warning` (amber), `status-good` (green), `status-info` (blue)
|
||||
- When adding cards, count the total. If > 4, bump `lg:grid-cols-4` to `lg:grid-cols-6` (or `lg:grid-cols-3` for two rows of 3).
|
||||
|
||||
### JS — Data Population (`renderMetrics()`)
|
||||
|
||||
The `renderMetrics()` function in `dashboard.js` (~line 1528) populates all metric cards. It's called from `renderDashboard()` on every data refresh.
|
||||
|
||||
**Pattern for adding a new card:**
|
||||
|
||||
```js
|
||||
// 1. Compute the value from dashboardData
|
||||
const myMetric = dashboardData.repairOrders.filter(ro =>
|
||||
(ro.status || '').toLowerCase() === 'my-status-value'
|
||||
).length;
|
||||
|
||||
// 2. Log for debugging
|
||||
log('My Metric count:', myMetric);
|
||||
|
||||
// 3. Update DOM with null-safe access
|
||||
const myMetricEl = document.getElementById('my-metric-count');
|
||||
if (myMetricEl) myMetricEl.textContent = myMetric;
|
||||
```
|
||||
|
||||
**Available data sources for filtering:**
|
||||
- `dashboardData.repairOrders` — objects with `.status` (string), `.writeupTime` (Date), `.promisedTime` (Date), `.customerName`
|
||||
- `dashboardData.appointments` — objects with `.status`, `.appointmentDateTime` (Date), `.customerName`
|
||||
**Available data sources for filtering:**
|
||||
- `dashboardData.repairOrders` — objects with `.workStatus` (string: diagnosing/in-progress/waiting-for-approval/waiting-for-parts/waiting-for-pickup/completed), `.status` (string: waiter/drop-off — customer service status, NOT work progress), `.writeupTime` (Date), `.promisedTime` (Date), `.customerName`
|
||||
|
||||
**CRITICAL: `ro.status` ≠ `ro.workStatus`**
|
||||
- `ro.status` = customer service status (`waiter`, `drop-off`) — used for the red/blue service badge on RO cards
|
||||
- `ro.workStatus` = work progress status (`diagnosing`, `in-progress`, `waiting-for-approval`, `waiting-for-parts`, `waiting-for-pickup`, `completed`) — used for work progress badges and ALL completion/dashboard filtering logic
|
||||
|
||||
**NEVER filter by `ro.status === 'completed'`** — it will always match nothing because `status` only stores `waiter`/`drop-off`. Always use `ro.workStatus === 'completed'`.
|
||||
|
||||
**Common repair order workStatus values used in filters:**
|
||||
- `'diagnosing'`, `'in-progress'`, `'waiting-for-approval'`, `'waiting-for-parts'`, `'waiting-for-pickup'`, `'completed'`
|
||||
|
||||
### Click Handlers
|
||||
|
||||
Metric cards can optionally be made clickable. Follow the existing pattern in `addMetricClickHandlers()` (~line 1888):
|
||||
|
||||
```js
|
||||
const myCard = document.querySelector('#my-metric-count')?.closest('.metric-card');
|
||||
if (myCard) {
|
||||
myCard.style.cursor = 'pointer';
|
||||
myCard.addEventListener('click', () => {
|
||||
window.location.href = 'target-page.html';
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
## Adding a New Metric Card — Full Recipe
|
||||
|
||||
1. **HTML**: Add the card `<div>` inside the Quick Metrics grid, following the template above. Ensure the count `<span>` has a unique ID.
|
||||
2. **Grid**: If total cards exceed the current `lg:grid-cols-N` value, bump it.
|
||||
3. **JS**: In `renderMetrics()`, after the existing quote section (~line 1674), add filter logic + DOM update.
|
||||
4. **Click handler (optional)**: If the card should navigate somewhere, add it in `addMetricClickHandlers()`.
|
||||
5. **Verify**: The card will populate automatically on next data refresh (periodic auto-refresh + real-time PocketBase subscriptions).
|
||||
@@ -0,0 +1,52 @@
|
||||
# ShopProQuote Dashboard Overview
|
||||
|
||||
Complete map of all pages, tabs, modals, and features. Use this when asked "what's in the dashboard" or "overview of everything" — give a concise structured summary, NOT a code dive.
|
||||
|
||||
## Pages
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `login.html` | Login/signup/auth |
|
||||
| `repair-orders.html` | Main dashboard (1448 lines) — repair orders + quote builder |
|
||||
| `index.html` | Home/overview with tasks, alerts, financial dashboard |
|
||||
| `appointments.html` | Appointments with scan screenshot (OCR + LLM) |
|
||||
| `customers.html` | Customer database lookup |
|
||||
|
||||
## repair-orders.html — 3 Main Tabs
|
||||
|
||||
1. **Active Orders** — Current RO cards with status, vehicle, services. Shows next appointment at top.
|
||||
2. **RO History** — Search/filter completed orders (RO#, name, VIN). Status filter dropdown.
|
||||
3. **Generate Quote** — Quote builder: service search dropdown, selected services list, vehicle/mileage/discount fields, AI Write + Generate Priorities buttons, Save Quote, Print Preview, Saved Quotes card.
|
||||
|
||||
### Modals on repair-orders
|
||||
|
||||
- Add Custom Service (text input for recommendation reason, AI Write button)
|
||||
- Edit RO (status dropdown)
|
||||
- Financial Summary
|
||||
- All Saved Quotes modal (search, sort, delete, load)
|
||||
- Quote Settings (gear icon): 5 sub-tabs — Business Info, Advisor, Financial, Messages, Services
|
||||
- Account Settings / Change Password / Site Settings
|
||||
|
||||
## index.html — Home/Overview
|
||||
|
||||
- Critical Alerts section
|
||||
- Action cards: Repair Orders, Appointments, Financial Dashboard
|
||||
- Tasks modal: Active tasks + Task History, create/edit with recurring options
|
||||
- Financial Dashboard modal (3 tabs): Overview, Analytics, Performance — revenue charts, top services, top customers
|
||||
- Account/Site Settings modals
|
||||
|
||||
## appointments.html
|
||||
|
||||
- Appointment list with customer/vehicle info
|
||||
- Scan Screenshot: Tesseract.js OCR → local LLM via `/llm/v1/chat/completions` → parse to structured JSON → review modal → batch create
|
||||
- Fallback: `parseWithRules()` client-side regex parser if LLM down
|
||||
|
||||
## Infrastructure
|
||||
|
||||
| Component | Role |
|
||||
|-----------|------|
|
||||
| nginx :3447 | Serves site + proxies `/llm/` → llama-server:8081 |
|
||||
| llama-server | Currently Qwen2.5-7B Q2_K via Vulkan (3.2 GB VRAM). Drives AI Write, Generate Priorities, OCR parsing |
|
||||
| PocketBase :8091 | Local DB. Stores services, quotes, repair orders, settings |
|
||||
| Tesseract.js | CDN-loaded client-side OCR. `eng.traineddata` cached in browser |
|
||||
| GPU | GTX 1050 Ti 4GB → RTX 2080 Ti 11GB (on order) |
|
||||
@@ -0,0 +1,39 @@
|
||||
# DeepSeek vs Local Ollama — Cost Comparison
|
||||
|
||||
## Per-Scan Cost (Appointment Screenshot Extraction)
|
||||
|
||||
| | DeepSeek V4 Flash | Local qwen2.5:14b (Ollama) |
|
||||
|---|---|---|
|
||||
| OCR step | Free (browser Tesseract.js) | Free (browser Tesseract.js) |
|
||||
| LLM input tokens | ~2,000 tokens | ~2,000 tokens |
|
||||
| LLM output tokens | ~400 tokens (non-thinking) | ~400 tokens |
|
||||
| **Per-scan cost** | **$0.0004** | **$0.0015** (GPU power) |
|
||||
| **Scans per dollar** | ~2,500 | ~667 |
|
||||
| **Annual (daily)** | ~$0.15 | ~$0.55 |
|
||||
|
||||
## Token Breakdown
|
||||
|
||||
Typical appointment screenshot (10 appointments):
|
||||
- System prompt: ~1,200 tokens (extraction rules, OCR artifact fixes, VIN recovery)
|
||||
- OCR text: ~800 tokens (raw text from 10 appointment rows)
|
||||
- Output JSON: ~400 tokens (structured appointments array)
|
||||
- Total: ~2,400 tokens
|
||||
|
||||
## Pricing (DeepSeek V4 Flash, June 2026)
|
||||
|
||||
| | Per 1M tokens |
|
||||
|---|---|
|
||||
| Input (cache miss) | $0.14 |
|
||||
| Output | $0.28 |
|
||||
|
||||
Note: Cache hits are rare for one-off scan requests. The system prompt is large (~1,200 tokens) and repeatable across scans, so cached input could drop cost 95% if DeepSeek's KV cache covers it.
|
||||
|
||||
## Thinking Mode
|
||||
|
||||
V4 Flash defaults to **thinking mode** (adds ~200 internal reasoning tokens to output). Disabling via `thinking: {type: "disabled"}` keeps costs at the lower rate. The appointment extraction task is straightforward structured parsing — thinking adds cost with no accuracy benefit.
|
||||
|
||||
## Privacy Trade-off
|
||||
|
||||
- **DeepSeek**: Customer names, phone numbers, VINs, and vehicle details are sent to DeepSeek's cloud API servers
|
||||
- **Ollama**: All data stays on the local GPU (RTX 2080 Ti FE, 11GB VRAM)
|
||||
- Cost difference is negligible at ~$0.40/year — the deciding factor is privacy preference
|
||||
@@ -0,0 +1,92 @@
|
||||
# DeepSeek AI Proxy (nginx auth-injecting reverse proxy)
|
||||
|
||||
ShopProQuote uses an auth-injecting nginx reverse proxy to forward `/deepseek/` requests to `api.deepseek.com`. The proxy validates the client's PocketBase token before injecting the DeepSeek API key, so the key never reaches the browser.
|
||||
|
||||
## Architecture (Roadmap 1.2 — fully deployed 2026-07-06)
|
||||
|
||||
```
|
||||
Browser → /deepseek/v1/chat/completions
|
||||
└→ nginx [auth_request /_spq_validate]
|
||||
├→ 127.0.0.1:8092 (spq-ai-validator, systemd)
|
||||
│ └→ POST http://127.0.0.1:8091/api/collections/users/auth-refresh
|
||||
│ with client's Bearer token → 200 or 401
|
||||
└→ on 200: strip client Authorization, inject DeepSeek key,
|
||||
rewrite path, proxy_pass https://api.deepseek.com (streaming)
|
||||
```
|
||||
|
||||
## Components
|
||||
|
||||
### 1. Token validator — `/opt/spq/ai-proxy/validate.js`
|
||||
|
||||
Node HTTP service (no deps) on 127.0.0.1:8092. Validates the incoming Bearer token against PocketBase's `/api/collections/users/auth-refresh` endpoint. Returns 200 (valid) or 401 (invalid).
|
||||
|
||||
Key detail: uses `auth-refresh`, NOT `refresh` — the roadmap spec wrote `/api/collections/users/refresh` which is wrong for PB 0.39. The correct endpoint is `auth-refresh`.
|
||||
|
||||
### 2. systemd unit — `/etc/systemd/system/spq-ai-validator.service`
|
||||
|
||||
Enabled + auto-restart on failure. Environment: `PB_URL=http://127.0.0.1:8091`, `PORT=8092`.
|
||||
|
||||
If the unit file changes on disk, run `sudo systemctl daemon-reload` to clear the stale-config warning. No restart needed if the service is already running fine.
|
||||
|
||||
### 3. nginx snippet — `/etc/nginx/snippets/spq-deepseek.conf`
|
||||
|
||||
```nginx
|
||||
location /deepseek/ {
|
||||
limit_req zone=spq_ai burst=5 nodelay; # 20r/m rate limit
|
||||
auth_request /_spq_validate; # token validation gate
|
||||
auth_request_set $spq_auth_status $upstream_status;
|
||||
proxy_set_header Authorization ""; # strip client's token
|
||||
include /etc/nginx/snippets/deepseek-key.conf; # sets $deepseek_key
|
||||
proxy_set_header Authorization "Bearer $deepseek_key";
|
||||
rewrite ^/deepseek/(.*)$ /$1 break;
|
||||
proxy_pass https://api.deepseek.com;
|
||||
proxy_ssl_server_name on;
|
||||
proxy_set_header Host api.deepseek.com;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_buffering off; # streaming responses
|
||||
}
|
||||
|
||||
location = /_spq_validate {
|
||||
internal;
|
||||
proxy_pass http://127.0.0.1:8092/;
|
||||
proxy_pass_request_body off;
|
||||
proxy_set_header Content-Length "";
|
||||
proxy_set_header Authorization $http_authorization;
|
||||
}
|
||||
```
|
||||
|
||||
The `limit_req_zone $binary_remote_addr zone=spq_ai:10m rate=20r/m;` directive is in `/etc/nginx/nginx.conf:15`, not in the snippet.
|
||||
|
||||
### 4. API key — `/etc/nginx/snippets/deepseek-key.conf`
|
||||
|
||||
Mode 0600, root-owned. Contains `set $deepseek_key "sk-...";`. Included by the snippet, never shipped to the client. Do NOT read this file unless explicitly needed. The key is NO LONGER in the site config directly (old location, now migrated to the separate 0600 snippet file).
|
||||
|
||||
### 5. Inclusion
|
||||
|
||||
Included in the `shopproquote` server block at `/etc/nginx/sites-enabled/shopproquote` via `include /etc/nginx/snippets/spq-deepseek.conf;`.
|
||||
|
||||
## Frontend — `src/lib/ai.ts`
|
||||
|
||||
Calls `/deepseek/v1/chat/completions` with the PocketBase token as Bearer. Handles 401 (invalid/missing token) and 429 (rate limited) gracefully by returning null.
|
||||
|
||||
## Model
|
||||
|
||||
`deepseek-v4-flash` with `thinking: { type: 'disabled' }`. The thinking parameter is critical — without it, DeepSeek-v4-flash puts all tokens into reasoning content and returns empty `content` field.
|
||||
|
||||
## Dev proxy (vite.config.ts) — intentionally stale
|
||||
|
||||
`vite.config.ts` still targets `https://127.0.0.1:3448` for `/deepseek` — a dead gateway. Ray confirmed (2026-07-07) he runs prod only (nginx/dist), never `npm run dev`. The dev proxy was intentionally left stale. If dev-mode AI testing is ever needed, point it at `http://127.0.0.1:80`.
|
||||
|
||||
## Verify the proxy is live
|
||||
|
||||
```bash
|
||||
# Validator running?
|
||||
systemctl status spq-ai-validator
|
||||
ss -tlnp | grep 8092
|
||||
|
||||
# nginx config valid?
|
||||
sudo nginx -t
|
||||
|
||||
# Snippet included in site?
|
||||
grep spq-deepseek /etc/nginx/sites-enabled/shopproquote
|
||||
```
|
||||
@@ -0,0 +1,68 @@
|
||||
# DeepSeek API Nginx Proxy
|
||||
|
||||
All ShopProQuote AI features now use DeepSeek V4 Flash via the VPS `/deepseek/` proxy. The browser cannot call `api.deepseek.com` directly — the API key is server-side only.
|
||||
|
||||
## VPS Nginx Configuration
|
||||
|
||||
At `/etc/nginx/sites-enabled/shopproquote` on VPS (51.81.84.34):
|
||||
|
||||
```nginx
|
||||
location /deepseek/ {
|
||||
proxy_pass https://api.deepseek.com/;
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Host api.deepseek.com;
|
||||
proxy_set_header Authorization "Bearer ${DEEPSEEK_API_KEY}";
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_ssl_server_name on;
|
||||
proxy_ssl_protocols TLSv1.2 TLSv1.3;
|
||||
proxy_read_timeout 120;
|
||||
}
|
||||
```
|
||||
|
||||
Key requirements:
|
||||
- `proxy_ssl_server_name on` is REQUIRED — without it, nginx fails with "SSL handshake failure: alert number 40"
|
||||
- `proxy_read_timeout 120` because model loading can take a few seconds
|
||||
- The `DEEPSEEK_API_KEY` comes from `/home/ray/.hermes/.env`
|
||||
- `proxy_ssl_protocols TLSv1.2 TLSv1.3` ensures compatibility with CloudFront
|
||||
|
||||
## Client-Side Usage
|
||||
|
||||
All SPQ AI features use this pattern:
|
||||
|
||||
```javascript
|
||||
const response = await fetch('/deepseek/v1/chat/completions', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
model: 'deepseek-v4-flash',
|
||||
messages: [
|
||||
{ role: 'system', content: systemPrompt },
|
||||
{ role: 'user', content: userMsg }
|
||||
],
|
||||
temperature: 0,
|
||||
thinking: { type: 'disabled' },
|
||||
max_tokens: 500
|
||||
})
|
||||
});
|
||||
const result = await response.json();
|
||||
const text = result.choices[0].message.content;
|
||||
```
|
||||
|
||||
## Cost & Thinking Mode
|
||||
|
||||
- `thinking: { type: 'disabled' }` keeps costs at the non-thinking rate: $0.14/M input, $0.28/M output
|
||||
- Typical AI Write call: ~400 tokens total → ~$0.0001
|
||||
- Appointment scan: ~2,000 input + ~500 output → ~$0.0004
|
||||
- Without `thinking: disabled`, thinking tokens 3-5x the output cost
|
||||
|
||||
## CSP
|
||||
|
||||
`connect-src 'self'` covers `/deepseek/` since it's same-origin. No CSP changes needed for DeepSeek specifically. For Tesseract.js / WASM / Web Worker CSP requirements, see `references/csp-wasm-libraries.md`.
|
||||
|
||||
## Testing
|
||||
|
||||
```bash
|
||||
curl -s -X POST https://shopproquote.graj-media.com/deepseek/v1/chat/completions \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"model":"deepseek-v4-flash","messages":[{"role":"user","content":"Say OK"}],"max_tokens":50,"thinking":{"type":"disabled"}}'
|
||||
```
|
||||
@@ -0,0 +1,68 @@
|
||||
# Dropdown Portal / Teleport Pattern (ShopProQuote)
|
||||
|
||||
## Problem
|
||||
|
||||
The `#service-search-results` dropdown lives inside `#services-content` (a collapsible accordion section). When the accordion uses `overflow: hidden` or any ancestor creates a stacking context, the absolute-positioned dropdown gets clipped — it appears behind the Quote Summary section or is truncated.
|
||||
|
||||
CSS `z-index: 99999 !important` does NOT fix this because z-index is scoped within the parent stacking context.
|
||||
|
||||
## Fix: Teleport to `#dropdown-root`
|
||||
|
||||
All ShopProQuote pages have `<div id="dropdown-root"></div>` at the end of `<body>` (in `index.html`, `dashboard.html`, `repair-orders.html`, `customers.html`, `appointments.html`). Use this as a portal target.
|
||||
|
||||
### Implementation (Vanilla JS)
|
||||
|
||||
In `renderServiceResults()` in both `main.js` and `quote-tab-manager.js`:
|
||||
|
||||
```javascript
|
||||
// 1. Move dropdown to portal root (only on first render)
|
||||
const dropdownRoot = document.getElementById('dropdown-root');
|
||||
if (dropdownRoot && serviceSearchResults.parentElement !== dropdownRoot) {
|
||||
dropdownRoot.appendChild(serviceSearchResults);
|
||||
}
|
||||
|
||||
// 2. Position relative to the search input using getBoundingClientRect
|
||||
const rect = serviceSearchInput.getBoundingClientRect();
|
||||
serviceSearchResults.style.position = 'fixed';
|
||||
serviceSearchResults.style.top = (rect.bottom + 4) + 'px';
|
||||
serviceSearchResults.style.left = rect.left + 'px';
|
||||
serviceSearchResults.style.width = rect.width + 'px';
|
||||
|
||||
// 3. Add scroll/resize repositioning
|
||||
const repositionDropdown = () => {
|
||||
if (serviceSearchResults.classList.contains('hidden')) return;
|
||||
const rect = serviceSearchInput.getBoundingClientRect();
|
||||
serviceSearchResults.style.top = (rect.bottom + 4) + 'px';
|
||||
serviceSearchResults.style.left = rect.left + 'px';
|
||||
serviceSearchResults.style.width = rect.width + 'px';
|
||||
};
|
||||
window.addEventListener('scroll', repositionDropdown, { passive: true });
|
||||
window.addEventListener('resize', repositionDropdown);
|
||||
```
|
||||
|
||||
### Why This Works
|
||||
|
||||
- `appendChild` to `dropdown-root` (direct child of `<body>`) escapes ALL ancestor stacking contexts
|
||||
- `position: fixed` with `getBoundingClientRect()` positions the dropdown relative to the viewport, anchored to the input
|
||||
- Scroll/resize listener keeps it positioned correctly as the user scrolls
|
||||
|
||||
### Files to Modify
|
||||
|
||||
- `main.js`: `renderServiceResults()` function
|
||||
- `quote-tab-manager.js`: `renderServiceResults()` function AND `initializeServiceSearch()` (for the scroll listener)
|
||||
- BOTH files have their own service search implementation — both need the fix
|
||||
|
||||
### React Equivalent (spq-v2)
|
||||
|
||||
In the React rewrite, use a portal:
|
||||
|
||||
```tsx
|
||||
import { createPortal } from 'react-dom';
|
||||
|
||||
{open && createPortal(
|
||||
<div className="fixed z-50 ..." style={{ top: rect.bottom + 4, left: rect.left, width: rect.width }}>
|
||||
{/* dropdown items */}
|
||||
</div>,
|
||||
document.getElementById('dropdown-root')!
|
||||
)}
|
||||
```
|
||||
@@ -0,0 +1,47 @@
|
||||
# Dropdown Teleport Pattern
|
||||
|
||||
## Problem
|
||||
|
||||
The `#service-search-results` dropdown lives inside `#services-content`, an accordion section with `overflow: hidden`. Even with `z-index: 99999 !important`, it's clipped by the parent stacking context and hidden behind the Quote Summary section.
|
||||
|
||||
## Fix: Teleport to `#dropdown-root`
|
||||
|
||||
Every page has a `<div id="dropdown-root"></div>` at the end of `<body>`. When the dropdown opens, move it there and position it with `position: fixed` + `getBoundingClientRect()`.
|
||||
|
||||
### In `renderServiceResults()` (both `main.js` and `quote-tab-manager.js`):
|
||||
|
||||
```js
|
||||
// Teleport to dropdown-root to escape stacking context clipping
|
||||
const dropdownRoot = document.getElementById('dropdown-root');
|
||||
const serviceSearchInput = document.getElementById('service-search-input');
|
||||
if (dropdownRoot && serviceSearchResults.parentElement !== dropdownRoot) {
|
||||
dropdownRoot.appendChild(serviceSearchResults);
|
||||
}
|
||||
// Position relative to the input
|
||||
if (serviceSearchInput) {
|
||||
const rect = serviceSearchInput.getBoundingClientRect();
|
||||
serviceSearchResults.style.position = 'fixed';
|
||||
serviceSearchResults.style.top = (rect.bottom + 4) + 'px';
|
||||
serviceSearchResults.style.left = rect.left + 'px';
|
||||
serviceSearchResults.style.width = rect.width + 'px';
|
||||
}
|
||||
```
|
||||
|
||||
### Scroll/resize repositioning (in `initializeServiceSearch` / `attachMainEventListeners`):
|
||||
|
||||
```js
|
||||
const repositionDropdown = () => {
|
||||
if (serviceSearchResults.classList.contains('hidden')) return;
|
||||
const rect = serviceSearchInput.getBoundingClientRect();
|
||||
serviceSearchResults.style.top = (rect.bottom + 4) + 'px';
|
||||
serviceSearchResults.style.left = rect.left + 'px';
|
||||
serviceSearchResults.style.width = rect.width + 'px';
|
||||
};
|
||||
window.addEventListener('scroll', repositionDropdown, { passive: true });
|
||||
window.addEventListener('resize', repositionDropdown);
|
||||
```
|
||||
|
||||
## Files Modified
|
||||
|
||||
- `main.js` — `renderServiceResults()` + `attachMainEventListeners()`
|
||||
- `quote-tab-manager.js` — `renderServiceResults()` + `initializeServiceSearch()`
|
||||
@@ -0,0 +1,66 @@
|
||||
# Element ID Mismatch Debugging
|
||||
|
||||
Recurring class of bugs in ShopProQuote: JavaScript event handlers look for elements by ID,
|
||||
but the actual HTML uses a slightly different ID. The handler silently gets `null`, no error is
|
||||
thrown, and the button appears to do nothing.
|
||||
|
||||
## Common Patterns
|
||||
|
||||
### 1. Fallback chain missing the actual ID
|
||||
|
||||
```javascript
|
||||
// BROKEN — actual ID is 'ai-write-btn-quote', none of these match
|
||||
const btn = document.getElementById('ai-write-btn-main') ||
|
||||
document.getElementById('ai-write-btn-fallback');
|
||||
|
||||
// FIXED — add the actual ID first
|
||||
const btn = document.getElementById('ai-write-btn-main') ||
|
||||
document.getElementById('ai-write-btn-fallback') ||
|
||||
document.getElementById('ai-write-btn-quote');
|
||||
```
|
||||
|
||||
### 2. Prefix/suffix mismatch (-btn vs no -btn, -settings vs -quote)
|
||||
|
||||
| Code looks for | Actual HTML ID | Pattern |
|
||||
|---------------|----------------|---------|
|
||||
| `service-edit-save-quote` | `service-edit-save-btn-quote` | Missing `btn` |
|
||||
| `service-edit-form` | `service-edit-form-quote` | Missing `-quote` suffix |
|
||||
| `service-edit-name` | `service-edit-name-quote` | Missing `-quote` suffix |
|
||||
| `custom-service-technician-notes` | `service-edit-technician-notes-quote` | Wrong modal entirely |
|
||||
|
||||
### 3. Explanation textarea across different modals
|
||||
|
||||
```javascript
|
||||
// Safe fallback chain covering all modal variants:
|
||||
const explanation = document.getElementById('service-edit-explanation-quote') || // quote builder
|
||||
document.getElementById('service-edit-explanation-main') || // main edit
|
||||
document.getElementById('service-edit-explanation'); // legacy
|
||||
```
|
||||
|
||||
### 4. Technician notes across different modals
|
||||
|
||||
```javascript
|
||||
// Safe fallback chain:
|
||||
const notes = document.getElementById('service-edit-technician-notes-quote') || // quote edit modal
|
||||
document.getElementById('service-edit-technician-notes') || // settings edit
|
||||
document.getElementById('custom-service-technician-notes'); // add custom
|
||||
```
|
||||
|
||||
## Diagnostic Approach
|
||||
|
||||
When a button silently does nothing:
|
||||
|
||||
1. **Find the handler** — search for `getElementById` calls that should be finding this element
|
||||
2. **Check the actual HTML ID** — read the HTML to see the real `id` attribute
|
||||
3. **Compare** — if they don't match, add the real ID to the fallback chain
|
||||
4. **Verify** — grep for both the code-side ID and the HTML-side ID
|
||||
|
||||
## Recurring ID Convention
|
||||
|
||||
The codebase uses two modal naming conventions that coexist:
|
||||
|
||||
- **Quote builder modals**: `-quote` suffix (e.g., `service-edit-modal-quote`, `ai-write-btn-quote`)
|
||||
- **Settings modals**: `-settings` suffix (e.g., `service-edit-explanation-settings`)
|
||||
- **Legacy modals**: no suffix (e.g., `service-edit-modal`, `service-edit-name`)
|
||||
|
||||
When writing new handlers, always include ALL THREE variants in the fallback chain.
|
||||
@@ -0,0 +1,30 @@
|
||||
# ShopProQuote File Map
|
||||
|
||||
| Page | HTML | JS | Notes |
|
||||
|------|------|-----|-------|
|
||||
| Index/Dashboard | `index.html` | `dashboard.js` | Main dashboard, daily briefing, Repair Orders |
|
||||
| Appointments | `appointments.html` | `appointments.js` | OCR scan, VIN decode, appointment management |
|
||||
| Repair Orders | `repair-orders.html` | `repair-orders.js` | RO creation/edit, service lines, parts |
|
||||
| Customers | `customers.html` | `customers.js` | Customer lookup and management |
|
||||
| Login | `login.html` | `login.js` | Firebase auth (migrated to PocketBase) |
|
||||
|
||||
## Shared Modules (loaded by HTML pages via `<script type="module">`)
|
||||
|
||||
| Module | File | Used By |
|
||||
|--------|------|---------|
|
||||
| PocketBase adapter | `pocketbase.js` | All pages |
|
||||
| API utilities | `api.js` | Dashboard, Repair Orders |
|
||||
| Quote tab manager | `quote-tab-manager.js` | Repair Orders |
|
||||
| UI utilities | `ui.js` | All pages |
|
||||
| Settings | `settings.js` | Dashboard, Appointment, RO |
|
||||
| Customer lookup | `shared/customer-lookup.js` | RO, Appointments |
|
||||
| Modal manager | `shared/modal-manager.js` | All pages |
|
||||
| Styles | `style.css` | All pages |
|
||||
|
||||
## Key Function Locations
|
||||
|
||||
- **Daily Briefing**: `dashboard.js` → `generateDailyBriefing()`, called by inline script in `index.html`
|
||||
- **AI Write**: `api.js` → `handleAiWrite()`, called from `quote-tab-manager.js` modal handlers
|
||||
- **AI Suggest**: `quote-tab-manager.js` → inline system prompt, calls DeepSeek V4 Flash
|
||||
- **OCR Scan**: `appointments.js` → Tesseract.js PSM4
|
||||
- **VIN Decode**: `main.js` → `handleDecodeVin()`, calls `/api/` → PocketBase
|
||||
@@ -0,0 +1,124 @@
|
||||
# Financial Summary — v1 modal & v2 Financial tab
|
||||
|
||||
## v1: The editable modal (ShopProQuote original)
|
||||
|
||||
**HTML**: `/mnt/seagate8tb/Websites/ShopProQuote/repair-orders.html:857-933`
|
||||
- Container `#financial-summary-modal` (hidden, unhidden on "Complete Order")
|
||||
- **Customer Pay** card (green): inputs `financial-cp-total`, `financial-cp-cost`, live display `financial-cp-profit`
|
||||
- **Warranty** card (blue): inputs `financial-wh-total`, `financial-wh-cost`, live display `financial-wh-profit`
|
||||
- **Gross Profit** bar: `financial-gross-profit` = (cpTotal + whTotal) - (cpCost + whCost), color-coded
|
||||
- Validation message `financial-validation-message` (at least one payment type must have values)
|
||||
- Cancel / "Complete Order" buttons
|
||||
|
||||
**JS**: `/mnt/seagate8tb/Websites/ShopProQuote/repair-orders.js`
|
||||
- `openFinancialSummaryModal()` ~line 2016 — seeds CP total/cost from RO's existing totals
|
||||
- `calculateFinancialSummary()` line 2140 — recomputes profits on every input change
|
||||
- `applyWarrantyDeduction()` line 2180 — **automatic**: warranty amounts are deducted from CP amounts (stores `originalTotalAmount/Cost` and adjusts CP input fields down by whTotal/whCost)
|
||||
- `saveFinancialSummary()` ~line 2276 — writes the financial blob
|
||||
|
||||
**Saved `financial` blob shape** (written to PocketBase/Firebase `repairOrders.financial`):
|
||||
```json
|
||||
{
|
||||
"cpTotal": 0, "cpCost": 0, "whTotal": 0, "whCost": 0,
|
||||
"cpProfit": 0, "whProfit": 0,
|
||||
"totalAmount": 0, "totalCost": 0,
|
||||
"grossProfit": 0,
|
||||
"completedAt": "ISO timestamp"
|
||||
}
|
||||
```
|
||||
|
||||
**Shop charge is NOT a field in the v1 modal.** Shop charge is configured globally (settings `shopChargeRate` % + `shopChargeCap` $) and applied per-line on quotes/ROs. The RO card summary's `#shop-charge-amount` (repair-orders.html:685) is a display of the computed shop charge, not a financial-modal input. If a `shopCharge` value is present in the financial blob, v2's dashboard will pick it up, but v1 never wrote one here.
|
||||
|
||||
**Inline preview** (read-only) at `repair-orders.js:3273` — renders on each RO card, splits into "mixed" (two-column CP/Warranty cards) vs "single" (3-col Revenue/Cost/Profit) based on `hasBoth`.
|
||||
|
||||
## v2: Read-only reader (FinancialDashboard.tsx)
|
||||
|
||||
**File**: `src/pages/FinancialDashboard.tsx`
|
||||
- `finData(o)` parses `o.financial` (string or object)
|
||||
- `finVal(f, newKey, legacyKey)` reads new schema field with v1 legacy fallback:
|
||||
- `grossTotal` falls back to `cpTotal` (top-level fallback `o.grossTotal`)
|
||||
- `grossCost` falls back to `cpCost`
|
||||
- `warrTotal` falls back to `whTotal`
|
||||
- `warrCost` falls back to `whCost`
|
||||
- `shopCharge` falls back to `shopCharge` (top-level `o.shopCharge` or `o.shopCharges`)
|
||||
- `customerPayGpOf(o)` = `max(0, grossTotal - warrTotal - shopCharge) - max(0, grossCost - warrCost)`
|
||||
- `warrantyGpOf(o)` = `warrTotal - warrCost`
|
||||
- Aggregated into `totalCustomerPayGP` + `totalWarrantyGP` = `overallGrossProfit`
|
||||
|
||||
The FinancialDashboard is read-only. The editable Financial tab lives in `RODetailModal.tsx` (see below).
|
||||
|
||||
## PocketBase schema context
|
||||
|
||||
`repairOrders.financial` is a JSON blob (see `references/pocketbase-schema.md`). Migration `1739999000003_promote_financial_fields.js` added top-level numeric columns `grossTotal`, `grossCost`, `warrTotal`, `warrCost`, `shopCharge` and backfilled from the blob — both the blob and the columns coexist; `FinancialDashboard.tsx` checks the blob first, then top-level.
|
||||
|
||||
## v2 Financial tab — IMPLEMENTED
|
||||
|
||||
Ray confirmed the design and pre-seed behavior. Implementation is in `src/pages/RODetailModal.tsx` as a 5th tab ("Financial") in the TABS array. The infrastructure was already wired before implementation: the `roFinancialAudit` collection + PB hook (M25) auto-diffs the blob on every update, the `'financial'` ROEvent type is in `types.ts` and rendered in the timeline, and the Timeline tab already shows the audit trail.
|
||||
|
||||
### Placement
|
||||
|
||||
New 5th tab "Financial" inside `RODetailModal.tsx` — NOT a separate backgroundLocation modal. Added to the `TABS` array (now: details, services, financial, history, timeline). The `TabId` type union was extended with `'financial'`.
|
||||
|
||||
### Inputs (advisor enters)
|
||||
|
||||
- **CP Total** — the full RO billed amount (includes the warranty portion)
|
||||
- **CP Cost** — the full cost of the job (includes the warranty portion)
|
||||
- **Warranty Total** — the warranty-attributable revenue
|
||||
- **Warranty Cost** — the warranty-attributable cost
|
||||
- **Shop Charge** — consumables/supplies charge (NEW — v1 did not have this as an input in the financial modal)
|
||||
|
||||
### Computed live (display-only)
|
||||
|
||||
- **CP Net Total** = CP Total - Warranty Total
|
||||
- **CP Net Cost** = CP Cost - Warranty Cost
|
||||
- **CP Gross Profit** = (CP Net Total) - (CP Net Cost) - Shop Charge
|
||||
- **Warranty Gross Profit** = Warranty Total - Warranty Cost (shop charge does NOT touch this — warranty is manufacturer-reimbursed at flat rates)
|
||||
- **Overall Gross Profit** = CP GP + Warranty GP
|
||||
|
||||
This exactly matches `customerPayGpOf()` + `warrantyGpOf()` already in `FinancialDashboard.tsx:141-161`, so dashboard totals pick up the entered values with zero dashboard-side changes.
|
||||
|
||||
### Key design difference from v1
|
||||
|
||||
v1's `applyWarrantyDeduction()` auto-adjusted the CP **input fields** down when warranty was entered (mutating the inputs the advisor sees). Ray's v2 spec treats it as a **computed display** — the advisor enters the full CP Total/Cost, the tab computes and shows CP Net Total/Cost, and the advisor sees the deduction as a live readout, not as mutated inputs. This is intentional and was preserved in the implementation.
|
||||
|
||||
### Pre-seed (CONFIRMED by Ray)
|
||||
|
||||
Ray confirmed pre-seed is desired. On first opening the Financial tab (`activeTab === 'financial'` and `!finSeeded`), inputs are pre-seeded from the RO's existing `financial` blob. If no existing values:
|
||||
- CP Total falls back to `computeROTotals().total` (the RO's computed grand total)
|
||||
- Shop Charge falls back to `computeROTotals().shopCharge`
|
||||
- CP Cost, Warranty Total, Warranty Cost default to empty
|
||||
|
||||
The `finSeeded` flag prevents reseeding on subsequent tab switches. If the RO changes (different RO loaded), `finSeeded` should be reset — the effect depends on `[activeTab, ro, finSeeded, settings]`.
|
||||
|
||||
### Storage on save
|
||||
|
||||
`handleSaveFinancial()` writes to `repairOrders.financial` JSON blob using the keys v2 already reads:
|
||||
- `grossTotal` ← CP Total
|
||||
- `grossCost` ← CP Cost
|
||||
- `warrTotal`
|
||||
- `warrCost`
|
||||
- `shopCharge`
|
||||
- `cpProfit` ← CP GP (computed)
|
||||
- `whProfit` ← Warranty GP (computed)
|
||||
- `grossProfit` ← Overall GP (computed)
|
||||
- `totalAmount` ← CP Total + Warr Total
|
||||
- `totalCost` ← CP Cost + Warr Cost
|
||||
- `completedAt` ← existing or new ISO timestamp
|
||||
|
||||
Existing blob keys (e.g. `customerType`) are preserved via spread. An `'financial'` ROEvent is appended for the timeline. The PB `onRecordAfterUpdateRequest` hook (M25 migration) auto-creates hash-chained `roFinancialAudit` entries.
|
||||
|
||||
### Completion-flow lever (NOT yet implemented)
|
||||
|
||||
The "Complete" button in RODetailModal Details tab still sets status to `'completed'` without switching to the Financial tab. A natural follow-up (Ray has not confirmed) is to switch to the Financial tab on completion-press, forcing financial entry on completion — matching v1's "Complete Repair Order" modal behavior. This remains a TBD.
|
||||
|
||||
### Implementation patch sequence (for reference)
|
||||
|
||||
The Financial tab was added via 4 patches to `RODetailModal.tsx`:
|
||||
1. Extended `TabId` union and `TABS` array with `{ id: 'financial', label: 'Financial', icon: DollarSign }`
|
||||
2. Added financial input state (`finCpTotal`, `finCpCost`, `finWarrTotal`, `finWarrCost`, `finShopCharge`, `finSaving`, `finSaved`, `finSeeded`)
|
||||
3. Added pre-seed `useEffect` (triggered when `activeTab === 'financial'` and `!finSeeded`)
|
||||
4. Added live computed values and `handleSaveFinancial()` handler
|
||||
5. Added `financialContent` JSX (sticky save bar, CP/Warranty/ShopCharge cards, Gross Profit summary)
|
||||
6. Wired `case 'financial': return financialContent;` into the `activeTabContent` switch
|
||||
|
||||
**Pitfall**: The `patch` tool requires `path`, `old_string`, and `new_string` all together. Omitting `path` (even when calling under a default-path context) causes a hard-fail after 6 retries. Always include the `path` parameter explicitly on every `patch` call.
|
||||
@@ -0,0 +1,32 @@
|
||||
# FormValidator Silent Failure Pattern
|
||||
|
||||
**The bug:** A `FormValidator` is initialized with element IDs that don't match the actual HTML. Every `document.getElementById()` returns `null`. The validator's `validateForm()` silently returns `false` on every call. The save function is never reached. No error, no notification — just nothing.
|
||||
|
||||
**Where it happened:** Edit Service modal (`#service-edit-modal-quote`) in quote-tab-manager.js
|
||||
|
||||
**The mismatch:**
|
||||
|
||||
| Validator expected | Actual HTML ID |
|
||||
|-------------------|----------------|
|
||||
| `service-edit-form` | `service-edit-form-quote` |
|
||||
| `service-edit-name` | `service-edit-name-quote` |
|
||||
| `service-edit-price` | `service-edit-price-quote` |
|
||||
| `service-edit-recommendation` | `service-edit-recommendation-quote` |
|
||||
|
||||
The Edit Service modal HTML uses `-quote` suffixed IDs to distinguish from the Settings modal's elements. The validator was written for the Settings modal's naming convention.
|
||||
|
||||
**Fix:** For the Edit Service modal, bypass the FormValidator entirely and call `saveServiceEdit()` directly. The save function has its own robust validation with user-visible error notifications. The FormValidator is optional visual feedback, not a required gate.
|
||||
|
||||
**Prevention:** When adding a new modal or form, audit all three layers:
|
||||
1. HTML element IDs
|
||||
2. JS `getElementById()` lookups (check for `||` fallback chains)
|
||||
3. Any validators that reference those IDs
|
||||
|
||||
Create a simple grep check:
|
||||
```bash
|
||||
# List all element IDs referenced in JS that don't exist in HTML
|
||||
grep -oP "getElementById\('([^']+)'\)" quote-tab-manager.js | sort -u | while read line; do
|
||||
id=$(echo "$line" | grep -oP "(?<=')[^']+(?=')")
|
||||
grep -q "id=\"$id\"" repair-orders.html || echo "MISSING in HTML: $id"
|
||||
done
|
||||
```
|
||||
@@ -0,0 +1,164 @@
|
||||
# Repair Orders — Inline IIFE Modal Handler Architecture
|
||||
|
||||
The `repair-orders.html` page (lines 1376-1427) has an inline IIFE that defines `window.openModal`, `window.closeModal`, and a capture-phase click handler. This is important because it runs OUTSIDE the ES module system and handles modal interactions before any module code executes.
|
||||
|
||||
## Functions
|
||||
|
||||
### `window.closeModal(id)`
|
||||
- Adds `classList.add('hidden')` + `style.setProperty('display','none','important')` + `aria-hidden='true'`
|
||||
- Uses `style.setProperty` with `!important` to defeat all CSS cascade issues
|
||||
- Brief lime green outline flash for visual diagnostic confirmation
|
||||
- Logs `console.warn` if element not found
|
||||
|
||||
### `window.openModal(id)`
|
||||
- Removes `hidden` class + `style.removeProperty('display')` + `aria-hidden='false'`
|
||||
- Sets `style.zIndex='100000'`
|
||||
- Uses `removeProperty` to properly clear any `!important` display style set by `closeModal`
|
||||
|
||||
## Capture-Phase Click Handler (line 1396)
|
||||
|
||||
```javascript
|
||||
document.addEventListener('click', function(e) {
|
||||
// 1. Close buttons: aria-label="Close" or data-close-modal attribute
|
||||
var closeBtn = e.target.closest('[aria-label="Close"],[data-close-modal]');
|
||||
if (closeBtn) {
|
||||
e.preventDefault(); e.stopPropagation();
|
||||
// Check for data-close-modal attribute (set by settings.js ensureSiteSettingsCloseAttributes)
|
||||
// Falls back to DOM traversal: closeBtn.closest('.modal,...')
|
||||
}
|
||||
|
||||
// 2. Cancel buttons: id starts with 'cancel-'
|
||||
if (e.target.id && e.target.id.indexOf('cancel-') === 0) {
|
||||
// closeModal on parent .modal or .fixed
|
||||
}
|
||||
|
||||
// 3. Click-away: clicking on modal backdrop
|
||||
// target.classList.contains('modal') or (fixed + inset-0 + bg-opacity)
|
||||
}, true); // CAPTURE phase — fires BEFORE bubble handlers
|
||||
```
|
||||
|
||||
### CRITICAL: `stopPropagation()` suppresses `onclick` attributes
|
||||
|
||||
Because this handler runs in the **capture phase** and calls `e.stopPropagation()`, any `onclick="closeModal(...)"` attribute on the matching button will **never fire**. The capture handler itself must call `closeModal()` — it can't rely on the button's onclick.
|
||||
|
||||
This is why the X button's inline `onclick="closeModal('site-settings-modal')"` is redundant when the capture handler works — but it serves as a belt-and-suspenders defense.
|
||||
|
||||
### Broken aria-label selector (FIXED)
|
||||
|
||||
The original selector was:
|
||||
```javascript
|
||||
var closeBtn = e.target.closest('[aria-label=\\\"Close\\\"],[data-close-modal]');
|
||||
```
|
||||
|
||||
In the HTML source, `\\\"` is interpreted by the browser's HTML parser as `\"` in the JavaScript string. The resulting CSS selector was `[aria-label=\"Close\"]` which looks for the literal attribute value `"Close"` (WITH double-quote characters). No element has `aria-label='"Close"'` — they have `aria-label="Close"`.
|
||||
|
||||
**Fix**: Write it without escaping:
|
||||
```javascript
|
||||
var closeBtn = e.target.closest('[aria-label="Close"],[data-close-modal]');
|
||||
```
|
||||
|
||||
Same fix applies to: `closeBtn.closest('.modal,[role="dialog"],.fixed')`
|
||||
|
||||
## Event Flow When X Button Clicked
|
||||
|
||||
1. **Capture phase** (inline IIFE): Matches `[aria-label="Close"]` → finds `data-close-modal="site-settings-modal"` (set by `settings.js`) → calls `closeModal('site-settings-modal')` → `e.stopPropagation()` prevents bubble phase
|
||||
2. **Target phase**: Skipped (stopPropagation in capture)
|
||||
3. **Bubble phase**: Skipped (stopPropagation in capture)
|
||||
- `onclick="closeModal('site-settings-modal')"` — never fires
|
||||
- settings.js `closeBtn.addEventListener('click', ...)` — never fires
|
||||
|
||||
## Event Flow When Cancel Button Clicked
|
||||
|
||||
1. **Capture phase**: Matches `id.startsWith('cancel-')` → `closeModal(parentModal.id)` → modal closes
|
||||
2. Bubble: Skipped
|
||||
|
||||
## Event Flow When Save Button Clicked (AFTER FINAL FIX — onclick property override)
|
||||
|
||||
The capture-phase approach (IIFE catches `#save-site-settings`, calls save, closes) was unreliable — it worked on some pages but not repair-orders. The issue: even with `stopPropagation()` in capture, the async `window.saveSiteSettings()` call could fire but not complete before `closeModal()`. And the `stopPropagation()` prevents settings.js's own handler from running as a backup.
|
||||
|
||||
**Final fix**: Use a direct `onclick` property override that fires in the **target phase** with `stopImmediatePropagation()`:
|
||||
|
||||
```javascript
|
||||
// In the IIFE (non-module script, runs after all ES modules):
|
||||
var saveSettingsBtn = document.getElementById('save-site-settings');
|
||||
if (saveSettingsBtn) {
|
||||
saveSettingsBtn.onclick = function(e) {
|
||||
e.stopImmediatePropagation(); // prevents settings.js's addEventListener
|
||||
if (typeof window.saveSiteSettings === 'function') {
|
||||
window.saveSiteSettings(); // async — save + close
|
||||
} else {
|
||||
// Fallback: direct localStorage save
|
||||
var s = {};
|
||||
try { var ex = localStorage.getItem('quoteGenSettings_v5'); if (ex) s = JSON.parse(ex); } catch(ign) {}
|
||||
var gv = function(id, ck) { var el = document.getElementById(id); if (!el) return ck ? false : ''; return ck ? !!el.checked : el.value; };
|
||||
s.darkMode = false;
|
||||
s.refreshRate = parseInt(gv('refresh-rate')) || 60;
|
||||
s.itemsPerPage = parseInt(gv('items-per-page')) || 25;
|
||||
s.desktopNotifications = gv('desktop-notifications', true);
|
||||
s.soundAlerts = gv('sound-alerts', true);
|
||||
s.criticalAlerts = gv('critical-alerts', true);
|
||||
s.notifyBeforePromised = parseInt(gv('notify-before-promised')) || 0;
|
||||
s.timezone = gv('timezone-select') || 'America/New_York';
|
||||
s.autoSaveForms = gv('auto-save-forms', true);
|
||||
localStorage.setItem('quoteGenSettings_v5', JSON.stringify(s));
|
||||
window.closeModal('site-settings-modal');
|
||||
}
|
||||
return false;
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
**Execution order when Save is clicked:**
|
||||
|
||||
1. **Capture phase** (IIFE on document): no `[data-close-modal]`, no `aria-label="Close"`, id doesn't start with `cancel-` → **ignored**
|
||||
2. **Target phase**: `onclick` property handler fires → `stopImmediatePropagation()` blocks all other listeners → tries `window.saveSiteSettings()` (async save + close via settings.js) or falls back to direct localStorage → modal closes
|
||||
3. **Bubble phase**: blocked by `stopImmediatePropagation()` — settings.js's `addEventListener` never fires ✓
|
||||
|
||||
### Why `stopImmediatePropagation()` not `stopPropagation()`
|
||||
|
||||
`stopPropagation()` called during target phase prevents the bubble phase, but does **NOT** prevent other target-phase listeners on the same element. Since `settings.js`'s `setupUnifiedSiteSettingsModalHandlers` also adds a `click` listener via `addEventListener` to the same `#save-site-settings` button, `stopPropagation()` alone would let it fire (double save + double notification).
|
||||
|
||||
`stopImmediatePropagation()` prevents **all** remaining listeners on the current element — so the settings.js handler never fires. ✓
|
||||
|
||||
### Why `onclick` property not capture-phase handler
|
||||
|
||||
The capture-phase approach was unreliable because:
|
||||
- `stopPropagation()` in capture kills ALL other handlers, including settings.js's (no backup if the IIFE save fails)
|
||||
- The async `window.saveSiteSettings()` call starts but `closeModal()` runs immediately after — if the save throws, the error is swallowed and the modal closes without saving
|
||||
- The `stopPropagation()` prevents settings.js's full handler (with its own try/catch and notification) from ever running as a safety net
|
||||
|
||||
The `onclick` approach is more surgical — it only blocks the same element's other listeners, not the entire event chain.
|
||||
|
||||
### `hasAttribute('onclick')` vs property distinction
|
||||
|
||||
Setting `saveBtn.onclick = fn` in JavaScript sets the DOM **property**, NOT the HTML `onclick` **attribute**. So `saveBtn.hasAttribute('onclick')` returns `false` even after the property is set.
|
||||
|
||||
This means `settings.js`'s guard `if (!saveBtn.hasAttribute('onclick'))` will NOT skip the save button when `onclick` is set via JS property. This is why `stopImmediatePropagation()` is essential — it's the only way to prevent the double-fire when using the property-set approach.
|
||||
|
||||
**Alternative**: set `saveBtn.setAttribute('onclick', '...')` which makes `hasAttribute('onclick')` return true. But this requires generating a string of JS code, which is fragile. The `stopImmediatePropagation()` approach is cleaner.
|
||||
|
||||
### PITFALL: data-close-modal on Save buttons kills the save
|
||||
|
||||
Before the fix, `ensureSiteSettingsCloseAttributes()` and `dashboard.js` both set `data-close-modal` on the Save button. This caused:
|
||||
|
||||
1. **Capture phase**: IIFE matches `[data-close-modal]` → calls `e.preventDefault()` + `e.stopPropagation()` → closes modal immediately
|
||||
2. **Target phase**: NEVER REACHED (stopPropagation killed the event)
|
||||
3. **Result**: Modal closes but settings are NOT saved. User sees the modal vanish with no change.
|
||||
|
||||
**Fix (two parts)**:
|
||||
1. Only set `data-close-modal` on CLOSE and CANCEL buttons — never on SAVE buttons.
|
||||
2. Add a dedicated `e.target.closest('#save-site-settings')` check in the IIFE capture handler BEFORE the close/cancel logic (see § "Event Flow When Save Button Clicked (AFTER FINAL FIX)" above). This ensures the save ALWAYS fires before the close, and works even if the ES module hasn't loaded yet (localStorage fallback).
|
||||
|
||||
In `settings.js`, `setupUnifiedSiteSettingsModalHandlers()` skips `addEventListener` on save buttons that have an inline `onclick` attribute (to prevent double saves). But with the capture-handler approach this guard is now secondary — `stopPropagation()` in the capture handler already prevents bubble-phase handlers from firing.
|
||||
|
||||
## IDs Used
|
||||
|
||||
| Element | ID | Defined By |
|
||||
|---------|-----|-----------|
|
||||
| Modal | `site-settings-modal` | repair-orders.html |
|
||||
| X button | `close-site-settings` | repair-orders.html |
|
||||
| Cancel button | `cancel-site-settings` | repair-orders.html |
|
||||
| Save button | `save-site-settings` | repair-orders.html |
|
||||
| Save function | `window.saveSiteSettings` | settings.js (bridge) |
|
||||
| Close function | `window.closeModal` | inline IIFE |
|
||||
| Open function | `window.openModal` | inline IIFE |
|
||||
@@ -0,0 +1,87 @@
|
||||
# ShopProQuote JS Debugging Patterns
|
||||
|
||||
## Silent module load failure from SyntaxError
|
||||
|
||||
**Symptom:** A feature is "stuck loading" despite the HTML being correct and the backend responding. The page shows a permanent "Loading..." state.
|
||||
|
||||
**Root cause:** A syntax error in a `<script>`-loaded JS file prevents the ENTIRE module from executing. Common in ShopProQuote because JS files are loaded as regular scripts (not ES modules with isolated scopes).
|
||||
|
||||
**Example from dashboard briefing (June 2026):**
|
||||
```js
|
||||
// Line ~3926
|
||||
const briefingText = dsResult.choices?.[0]?.message?.content?.trim() || '';
|
||||
// ... later, line ~3944, same function scope:
|
||||
const briefingText = contentDiv.textContent || ''; // 💥 SyntaxError: redeclaration of const
|
||||
```
|
||||
|
||||
JavaScript refuses to parse the file, `window.generateDailyBriefing` is never defined, the inline fallback in `index.html` polls forever for it, and the user sees "Loading briefing..." permanently.
|
||||
|
||||
## Debugging steps
|
||||
|
||||
1. **Check if the function exists at all:**
|
||||
```js
|
||||
// In browser console
|
||||
typeof window.generateDailyBriefing // 'undefined' = module never loaded
|
||||
```
|
||||
|
||||
2. **Syntax-check the file (if Node.js available):**
|
||||
```bash
|
||||
node -c dashboard.js
|
||||
```
|
||||
|
||||
3. **Without Node.js, grep for duplicate declarations:**
|
||||
```bash
|
||||
# Find duplicate const declarations in the same function
|
||||
sed -n '/async function generateDailyBriefing/,/^ }/p' dashboard.js | grep -P "^\s*const \w+ =" | awk '{print $2}' | sort | uniq -d
|
||||
```
|
||||
|
||||
4. **Check browser console** — a SyntaxError appears but users often miss it.
|
||||
|
||||
## Proxy verification pattern
|
||||
|
||||
When an AI feature relies on an nginx proxy, verify the proxy works independently of the frontend:
|
||||
|
||||
```bash
|
||||
curl -k -X POST https://127.0.0.1/deepseek/v1/chat/completions \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"model":"deepseek-v4-flash","messages":[{"role":"user","content":"test"}],"max_tokens":10}'
|
||||
```
|
||||
|
||||
This separates "proxy broken" from "JS broken."
|
||||
|
||||
## "Button does nothing" — element existence guard pattern
|
||||
|
||||
When clicking a submit button produces literally zero visible effect (no spinner, no error, no console message beyond a hidden TypeError), the most likely cause is that one or more DOM elements the handler reads from are `null`. The form element exists in the HTML, but at the time the handler fires, it's not in the DOM (dynamic insertion timing, modal re-opening, or form.reset() clearing dynamic content).
|
||||
|
||||
### Defensive pattern
|
||||
|
||||
Instead of directly chaining `.value` on `document.getElementById()` calls, capture each element reference first, check for null, and log which ones are missing:
|
||||
|
||||
```js
|
||||
async function handleCreateTask() {
|
||||
console.log('🔍 handleCreateTask() called');
|
||||
const titleEl = document.getElementById('new-task-title');
|
||||
const dueDateEl = document.getElementById('new-task-due-date');
|
||||
const dueTimeEl = document.getElementById('new-task-due-time');
|
||||
|
||||
if (!titleEl || !dueDateEl || !dueTimeEl) {
|
||||
console.error('❌ Missing form elements:', {
|
||||
title: !!titleEl, dueDate: !!dueDateEl, dueTime: !!dueTimeEl
|
||||
});
|
||||
showNotification('Form elements not found. Please refresh the page.', true);
|
||||
return;
|
||||
}
|
||||
|
||||
const title = titleEl.value.trim();
|
||||
// ... rest of handler
|
||||
}
|
||||
```
|
||||
|
||||
This pattern accomplishes three things simultaneously:
|
||||
1. The `console.log` at line 1 proves the handler even fired (vs HTML5 validation blocking submit)
|
||||
2. The null checks pinpoint exactly which element is missing
|
||||
3. The early return with notification gives the user a visible message instead of silent failure
|
||||
|
||||
### Why this beats try/catch alone
|
||||
|
||||
A `try/catch` wrapping the handler body catches the TypeError, but you don't know WHICH element caused it without the logged boolean map. And you can't show a meaningful error message — just "something went wrong."
|
||||
@@ -0,0 +1,34 @@
|
||||
# ShopProQuote JS Pitfalls
|
||||
|
||||
## `const` redeclaration in large single-file scripts
|
||||
|
||||
`dashboard.js` is a large file (~4000 lines) with many functions sharing the same closure scope. When a `const` variable is declared twice in the same block, it throws a fatal parse error that prevents the ENTIRE script from loading. The browser won't execute any of it.
|
||||
|
||||
### Real bug — Daily Briefing stuck on "Loading..."
|
||||
|
||||
In `dashboard.js`, `const briefingText` was declared twice in the same `try` block:
|
||||
|
||||
```js
|
||||
// First declaration (line 3926) — extracting from API response
|
||||
const briefingText = dsResult.choices?.[0]?.message?.content?.trim() || '';
|
||||
|
||||
// ... (HTML rendering) ...
|
||||
|
||||
// Second declaration (line 3944) — caching
|
||||
const briefingText = contentDiv.textContent || '';
|
||||
```
|
||||
|
||||
This parse error stopped `dashboard.js` from loading entirely. The fallback inline script in `index.html` kept polling for `window.generateDailyBriefing` (which was never defined), showing "Loading briefing..." forever.
|
||||
|
||||
### Fix
|
||||
|
||||
Rename the second variable unambiguously:
|
||||
```js
|
||||
const finalBriefingText = contentDiv.textContent || '';
|
||||
```
|
||||
|
||||
### Prevention
|
||||
|
||||
- When editing a function in a large file, check for variable name collisions in the same function scope.
|
||||
- `let` declarations in different blocks can share names safely, but `const` in the same block cannot.
|
||||
- There is no build step or linter — these errors only surface at runtime. Test in browser console.
|
||||
@@ -0,0 +1,44 @@
|
||||
# Local Dev Server (Vite)
|
||||
|
||||
Start: `npx vite --host 0.0.0.0 --port 5173`
|
||||
|
||||
## vite.config.ts requirements
|
||||
|
||||
### 1. Proxy rewrite (critical)
|
||||
Vite's proxy prefix stripping is unreliable. Always explicit:
|
||||
```ts
|
||||
### 1. Proxy rewrites
|
||||
|
||||
```
|
||||
'/pb': {
|
||||
target: 'http://127.0.0.1:8091',
|
||||
changeOrigin: true,
|
||||
rewrite: (path) => path.replace(/^\/pb/, ''),
|
||||
},
|
||||
'/deepseek': {
|
||||
target: 'https://localhost',
|
||||
changeOrigin: true,
|
||||
secure: false, // localhost self-signed cert
|
||||
rewrite: (path) => path.replace(/^\/deepseek/, '/deepseek'),
|
||||
},
|
||||
```
|
||||
|
||||
**Important:** The `/deepseek` proxy MUST route through the local nginx server (`localhost:443`), NOT directly to Ollama or api.deepseek.com. The nginx config adds the `Authorization` header with the DeepSeek API key. Without routing through nginx, AI calls will fail with authentication errors in dev mode.
|
||||
```
|
||||
|
||||
### 2. PocketBase SDK `import` keyword error
|
||||
pb SDK v0.27.0 uses `import` as a class method name at line ~28540:
|
||||
`async import(e, t = !1, s)`
|
||||
|
||||
Vite's esbuild pre-bundler leaves `import` as-is. Browsers reject it as syntax error.
|
||||
|
||||
**Two-part fix:**
|
||||
1. vite.config.ts: `optimizeDeps: { exclude: ['pocketbase'] }`
|
||||
2. Patch source: `sed -i 's/async import(/async _import(/g' node_modules/pocketbase/dist/pocketbase.es.mjs`
|
||||
3. Clear cache: `rm -rf node_modules/.vite`
|
||||
|
||||
## PocketBase Admin (v0.39.x)
|
||||
|
||||
- Superuser auth: `POST /api/collections/_superusers/auth-with-password` (NOT `/api/admins/...`)
|
||||
- Create superuser: `docker exec pocketbase /usr/local/bin/pocketbase superuser create <email> <pass> --dir /pb_data`
|
||||
- Create collections via superuser token from auth endpoint
|
||||
@@ -0,0 +1,167 @@
|
||||
# Local Testing — Serving SPQ Builds
|
||||
|
||||
Two approaches depending on which version you're testing.
|
||||
|
||||
## v1: Vanilla JS (standalone HTML files)
|
||||
|
||||
Served from `/mnt/seagate8tb/Websites/ShopProQuote/` (no build step, just HTML+JS+CSS).
|
||||
|
||||
```bash
|
||||
cd /mnt/seagate8tb/Websites/ShopProQuote
|
||||
python3 -m http.server 4173 --bind 0.0.0.0
|
||||
```
|
||||
|
||||
## v2: React SPA (built with Vite)
|
||||
|
||||
Served from `/mnt/seagate8tb/Websites/ShopProQuote/spq-v2/dist/`. **Plain http.server breaks login** because the built frontend connects to PocketBase at `/pb` (relative path). The simple server returns 404 for `/pb/*` requests. You need a Python reverse proxy that:
|
||||
|
||||
1. Serves static files from `dist/`
|
||||
2. Has SPA fallback (all non-file routes → `index.html`)
|
||||
3. Proxies `/pb/*` → PocketBase at `127.0.0.1:8091/api/*`
|
||||
4. Sends CORS headers on every response (including OPTIONS preflight)
|
||||
|
||||
### Template: `/tmp/spq-backup-server.py`
|
||||
|
||||
```python
|
||||
#!/usr/bin/env python3
|
||||
"""Serve SPQ dist + proxy /pb to PocketBase with CORS."""
|
||||
from http.server import HTTPServer, SimpleHTTPRequestHandler
|
||||
import urllib.request
|
||||
import os
|
||||
|
||||
DIST = '/mnt/seagate8tb/Websites/ShopProQuote/spq-v2/dist'
|
||||
PB = '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') or self.path.startswith('/deepseek'):
|
||||
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') or self.path.startswith('/deepseek'):
|
||||
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/
|
||||
# DeepSeek calls go to api.deepseek.com
|
||||
if self.path.startswith('/deepseek'):
|
||||
url = 'https://api.deepseek.com' + self.path[len('/deepseek'):]
|
||||
else:
|
||||
url = PB + self.path
|
||||
if self.path.startswith('/pb'):
|
||||
url = PB + self.path[3:] # /pb/api/... → /api/... on PB
|
||||
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)
|
||||
# Add DeepSeek API key
|
||||
if self.path.startswith('/deepseek'):
|
||||
with open('/tmp/deepseek_key.txt') as f:
|
||||
req.add_header('Authorization', f'Bearer {f.read().strip()}')
|
||||
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('Serving SPQ on http://0.0.0.0:4173 (PB proxy → 8091)')
|
||||
server.serve_forever()
|
||||
```
|
||||
|
||||
### Usage
|
||||
|
||||
**IMPORTANT**: The proxy also routes `/deepseek/*` → `https://api.deepseek.com/` using the API key from `/tmp/deepseek_key.txt`. The key is extracted from `/etc/nginx/sites-enabled/shopproquote` on startup. Without this, AI Write, Generate Priorities, and AI Suggest all fail silently.
|
||||
|
||||
```bash
|
||||
# Extract API key and start proxy
|
||||
python3 -c "
|
||||
import re
|
||||
with open('/etc/nginx/sites-enabled/shopproquote') as f:
|
||||
m = re.search(r'Bearer\s+(sk-\S+)', f.read())
|
||||
if m:
|
||||
with open('/tmp/deepseek_key.txt','w') as out:
|
||||
out.write(m.group(1).rstrip('\"'))
|
||||
print('Key saved')
|
||||
"
|
||||
|
||||
python3 /tmp/spq-backup-server.py &
|
||||
# Verify
|
||||
curl -s -o /dev/null -w '%{http_code}' http://localhost:4173/
|
||||
# → 200
|
||||
curl -s -X POST http://localhost:4173/pb/collections/users/auth-with-password \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"identity":"demo@shop.com","password":"test1234"}'
|
||||
# → {"token":"..."} (login works through proxy)
|
||||
|
||||
# Verify DeepSeek
|
||||
curl -s -X POST http://localhost:4173/deepseek/v1/chat/completions \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"model":"deepseek-v4-flash","messages":[{"role":"user","content":"Say hello"}],"temperature":0,"thinking":{"type":"disabled"},"max_tokens":50}'
|
||||
# → {"choices":[{"message":{"content":"Hello!"}}]} (AI works)
|
||||
|
||||
### Pitfalls
|
||||
|
||||
- **No `--bind 0.0.0.0` on plain http.server** → LAN devices get connection refused. The proxy template above binds `0.0.0.0` by default.
|
||||
- **Missing CORS headers** → browser blocks all PB requests. The proxy adds CORS on every response + handles OPTIONS preflight.
|
||||
- **Missing SPA fallback** → `/dashboard`, `/quote` etc. return 404 on page refresh. The proxy falls back to `index.html` for non-file paths.
|
||||
- **PB URL mismatch** — v2's `pocketbase.ts` uses `VITE_PB_URL` env var (defaults to `/pb`). If the build was made with a different URL, adjust the proxy or rebuild.
|
||||
- **Backup paths vary** — the timestamped backup folder (`ShopProQuote.backup-YYYYMMDD-HHMM`) changes per backup. Update DIST path in the script.
|
||||
- **Dashboard 400 / Something went wrong** — if the PocketBase quotes collection is missing the created system field, the sort=-created query returns HTTP 400. Fix: use sort=-id instead. Same issue applies to repairOrders collection.
|
||||
|
||||
- **AI features return no results** — the /deepseek/ path must be proxied to https://api.deepseek.com/ with the API key from /tmp/deepseek_key.txt. The AI module at src/lib/ai.ts calls /deepseek/v1/chat/completions with model: deepseek-v4-flash and thinking: disabled. Without proxy setup, all AI calls return null.
|
||||
|
||||
- **React input focus loss** — if a ServiceRow-like component is defined as a nested function inside its parent, every zustand state update causes a re-render that creates a new function identity. React unmounts/remounts the DOM, killing input focus. Fix: extract to a file-level component wrapped in memo, pass all state via props.
|
||||
|
||||
## V2 Feature Coverage
|
||||
|
||||
As of 2026-06-27, spq-v2 covers ~90% of v1's feature set. See `references/v1-v2-feature-gap.md` for a detailed comparison table of what's built vs what's still only in v1. Missing PocketBase collections (`repair_orders`, `invoices`, `service_advisors`, `vehicles`) cause error states on some pages — create them via admin UI to unlock full functionality.
|
||||
|
||||
For mass porting patterns (parallel delegation), see `references/mass-port-delegation.md`.
|
||||
|
||||
## Common Port
|
||||
|
||||
Use **4173** for local SPQ testing. Check availability:
|
||||
|
||||
```bash
|
||||
ss -tlnp | grep 4173 || echo "port free"
|
||||
```
|
||||
|
||||
Kill any existing server: `pkill -f "spq-backup-server.py"` or `pkill -f "python3 -m http.server 4173"`
|
||||
@@ -0,0 +1,66 @@
|
||||
# Mass Port via Delegation
|
||||
|
||||
Pattern used on 2026-06-26 to port 6 pages from vanilla JS v1 to React v2 in one session.
|
||||
|
||||
## Approach: Parallel Fan-Out
|
||||
|
||||
1. **Infrastructure first** — update types, routes, navigation yourself (quick edits, no delegation needed)
|
||||
2. **Dispatch all pages in parallel** — 2 batches of 3 `delegate_task` calls
|
||||
3. **Each subagent gets**: target file path, original source file paths, project context (types, existing patterns, PB collections), feature checklist
|
||||
4. **Subagents write the complete .tsx file** — no partial work, no test cycle (speed over perfect TDD for bulk codegen)
|
||||
|
||||
## Batch Structure
|
||||
|
||||
```
|
||||
Wave 1 (dispatch together): Customers, RepairOrders, Appointments
|
||||
Wave 2 (dispatch together): FinancialDashboard, Invoices, Settings
|
||||
```
|
||||
|
||||
## Per-Task Context Template
|
||||
|
||||
```
|
||||
TASK: Build <file path>
|
||||
|
||||
ORIGINAL SOURCE: <v1 file paths with sizes>
|
||||
|
||||
PROJECT CONTEXT:
|
||||
- React 19 + TypeScript + Vite + Tailwind CSS
|
||||
- PocketBase backend at pb (import from '../lib/pocketbase')
|
||||
- Types defined in ../types.ts — see <interfaces>
|
||||
- Uses lucide-react for icons
|
||||
- Existing pages for reference: Dashboard.tsx, QuoteGenerator.tsx
|
||||
|
||||
FEATURES TO IMPLEMENT:
|
||||
1. Feature with specific behavior
|
||||
2. ...
|
||||
|
||||
POCKETBASE COLLECTIONS:
|
||||
- '<name>' collection with fields: <schema>
|
||||
|
||||
CODE PATTERNS TO FOLLOW:
|
||||
- Import pb from '../lib/pocketbase'
|
||||
- LoadingSpinner for loading state
|
||||
- Error state with retry button
|
||||
- Empty state with create CTA
|
||||
- Modals for create/edit
|
||||
- Tailwind dark mode patterns
|
||||
```
|
||||
|
||||
## Post-Dispatch
|
||||
|
||||
1. **Build**: `npx vite build`
|
||||
2. **Fix import errors** — subagents may use wrong paths or import components that need providers (e.g. `useToast` needs `ToastProvider` wrapper)
|
||||
3. **Add ToastProvider** — lazy-loaded pages that use `useToast` crash silently if `ToastProvider` isn't wrapping `<Routes>`
|
||||
4. **Lazy loading**: use `React.lazy()` + `<Suspense>` to isolate page errors and enable code splitting
|
||||
5. **PB collections**: subagents can't create collections without admin access. Create missing ones or handle gracefully with error states.
|
||||
|
||||
## Pitfalls
|
||||
|
||||
- **`useToast` crash**: New pages using toast without `ToastProvider` → white screen with no error message. Always wrap `<Routes>` in `<ToastProvider>`.
|
||||
- **Broken sort field**: Legacy PocketBase collections may lack `created`/`updated` system fields. Dashboard `sort=-created` returns 400. Patch built JS to use `sort=-id`.
|
||||
- **Double `/api` prefix**: PocketBase SDK's `buildURL` adds `/api/` to the path. Proxy must NOT blindly prepend another `/api/`.
|
||||
- **Refreshed browser snapshots**: After navigation, refs change. Always re-snapshot before clicking nav links.
|
||||
- **Missing onClick handlers**: Subagents frequently create buttons with text but no `onClick` — verify every button in generated code has an attached handler.
|
||||
- **AI function signature mismatches**: Subagents may write UI code calling AI functions with different signatures than the actual `ai.ts` module. Always cross-check the `handleAiWrite`/`handleGeneratePriorities`/`handleAiSuggest` call signatures against the exports in `src/lib/ai.ts`.
|
||||
- **Wrong PB collection names**: Original v1 code uses underscore names (`repair_orders`) but the PocketBase instance uses camelCase (`repairOrders`). Subagents copy the v1 name verbatim → query fails. Always verify collection names against the actual PB instance before dispatching.
|
||||
- **Lazy-loaded pages and providers**: Pages wrapped in `React.lazy()` don't get providers from parent context if the provider is mounted after the lazy boundary. Mount `<ToastProvider>` and other context providers ABOVE `<Suspense>` in App.tsx.
|
||||
@@ -0,0 +1,48 @@
|
||||
# ShopProQuote Modal Click-Outside Behavior
|
||||
|
||||
## Default pattern (to REMOVE)
|
||||
|
||||
ShopProQuote modals use this pattern to close on outside click:
|
||||
|
||||
```js
|
||||
// Close on outside click
|
||||
modal.addEventListener('click', (e) => {
|
||||
if (e.target === modal) {
|
||||
modal.classList.add('hidden'); // or closeModal() or modal.remove()
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
Or for confirm dialogs in `shared/modal-manager.js`:
|
||||
|
||||
```js
|
||||
overlay.onclick = (e) => { if (e.target === overlay) { overlay.remove(); resolve(false); } };
|
||||
```
|
||||
|
||||
## Finding all instances
|
||||
|
||||
```bash
|
||||
grep -rn "e\.target\s*===.*modal\|e\.target\s*===.*overlay" /mnt/seagate8tb/Websites/ShopProQuote/
|
||||
```
|
||||
|
||||
## Locations (as of June 2026)
|
||||
|
||||
| File | Context |
|
||||
|---|---|
|
||||
| `quote-tab-manager.js:1379` | Service edit modal — `modal.classList.add('hidden')` |
|
||||
| `quote-tab-manager.js:1680` | Parts availability modal — `closeModal()` |
|
||||
| `shared/customer-lookup.js:253` | Customer lookup modal — `modal.remove()` |
|
||||
| `settings.js:315` | Site settings modal — `closeModal()` |
|
||||
| `shared/modal-manager.js:328` | Confirm dialog overlay — `overlay.remove(); resolve(false)` |
|
||||
|
||||
## Fix
|
||||
|
||||
Remove the entire `modal.addEventListener('click', ...)` block (or `overlay.onclick = ...` line) and replace with a comment:
|
||||
|
||||
```js
|
||||
// Click-outside-to-close disabled per user preference: only close via save or close button
|
||||
```
|
||||
|
||||
## What NOT to touch
|
||||
|
||||
Dropdown menus (`ui.js`, `dashboard.js`, `repair-orders.js`, `financial-dashboard.js`, `shared/customer-lookup.js` search results) — these use the same pattern but closing on outside click is expected UX for dropdowns. Only disable for **modal dialogs**.
|
||||
@@ -0,0 +1,31 @@
|
||||
# Current Ollama Model Inventory
|
||||
|
||||
Host: RTX 2080 Ti FE (11GB VRAM)
|
||||
Ollama on `127.0.0.1:11434`, nginx proxies `/llm/` and `/vision/` to it.
|
||||
|
||||
## Active Models
|
||||
|
||||
| Model | Size | Role | Used By |
|
||||
|-------|------|------|---------|
|
||||
| `qwen2.5:14b` | 9.0 GB (Q4_K_M) | Text LLM | AI Write, Priority Analysis, Generate Priorities |
|
||||
| `llava:13b` | 8.0 GB (Q4_0) | Vision | Screenshot Scan (appointments.html) |
|
||||
|
||||
## Removed Models
|
||||
|
||||
- `qwen2.5:7b` (replaced by 14b)
|
||||
- `moondream:latest` (redundant — llava:13b covers vision)
|
||||
- `llava:7b` (replaced by 13b)
|
||||
|
||||
## VRAM Budget
|
||||
|
||||
- qwen2.5:14b loaded: ~9.7 GB (86% VRAM, ~1.6 GB headroom for context)
|
||||
- llava:13b loaded: ~8.0 GB (~3 GB headroom)
|
||||
- Both models can't load simultaneously on 11GB
|
||||
- Auto-unload after 5 min idle (`OLLAMA_KEEP_ALIVE=5m`)
|
||||
|
||||
## Updating Models
|
||||
|
||||
1. `ollama pull <model>` to download
|
||||
2. `ollama rm <old-model>` to clean up
|
||||
3. Update hardcoded model name in `api.js` (lines 36 and 143) for LLM swaps
|
||||
4. Vision dropdown in `appointments.html` auto-populates from Ollama — no HTML changes needed
|
||||
@@ -0,0 +1,77 @@
|
||||
# New Features Added 2026-06-28
|
||||
|
||||
## Settings Page — 8 tabs (was 6)
|
||||
|
||||
Two new tabs added:
|
||||
|
||||
### PDF & Branding Tab
|
||||
| Setting | Field | Effect |
|
||||
|---------|-------|--------|
|
||||
| Logo | `logoUrl` (base64 data-URL) | Upload PNG/JPG (max 500KB), shows on PDF quotes |
|
||||
| Show logo on PDF | `showLogoOnPdf` (boolean) | Toggle logo visibility |
|
||||
| Quote Expiry (days) | `quoteExpiryDays` (number) | Replaces hardcoded "30 days" in PDF footer |
|
||||
| Quote Number Prefix | `quoteNumberPrefix` (string) | Prepended to quote IDs on PDF |
|
||||
| Accent Color | `pdfAccentColor` (hex string) | Color picker — changes green accent on PDF headers/badges |
|
||||
|
||||
### Business Ops Tab
|
||||
| Setting | Field | Effect |
|
||||
|---------|-------|--------|
|
||||
| Tax Label | `taxLabel` (string) | Rename "Tax" to "Sales Tax", "HST", etc. |
|
||||
| Payment Terms | `paymentTerms` (string) | Shown on invoices/quotes ("Due on Receipt", "Net 15", "Net 30") |
|
||||
| Default Labor Rate | `defaultLaborRate` (number) | Preset $/hr when adding catalog services |
|
||||
| Business Hours | `businessHours` (string) | Free-text or JSON schedule for appointment system |
|
||||
|
||||
**Files changed:** `types.ts` (ShopSettings), `pages/Settings.tsx` (PDFBrandingSection + BusinessOpsSection components), `lib/pdf.ts` (reads new fields)
|
||||
|
||||
**PDF generator integration:**
|
||||
- `const ACCENT: ColorTuple = settings.pdfAccentColor ? hexToRgb(settings.pdfAccentColor) : GR` — replaces hardcoded GR color
|
||||
- Footer uses `settings.quoteExpiryDays` instead of hardcoded 30
|
||||
- Tax line uses `settings.taxLabel` instead of hardcoded "Tax"
|
||||
- Logo renders via `doc.addImage()` on page 1
|
||||
- Payment terms appear in summary section
|
||||
|
||||
## Dashboard — Custom Dropdown with Search
|
||||
|
||||
Replaced the Recent Quotes table (desktop) + card list (mobile) with a custom dropdown component:
|
||||
|
||||
- **Trigger button**: "Jump to a recent quote…" with rotating chevron
|
||||
- **Search input**: Pinned at top of panel, filters by customer name / RO# / vehicle info
|
||||
- **Result rows**: Each shows customer name + RO# on one line, vehicle + date below, total + status badge on the right
|
||||
- **Behavior**: click-outside-to-close, auto-focus on search when opened, query resets on navigation
|
||||
|
||||
## Shop Charge Defaults to `true`
|
||||
|
||||
In `ServiceSearch.handleAdd()`, `applyShopCharge` now defaults to `true` instead of undefined:
|
||||
|
||||
```typescript
|
||||
addService({ ...service, selected: true, approved: true, customerDecision: 'approved', priority: undefined, applyShopCharge: true });
|
||||
```
|
||||
|
||||
Previously the checkbox was unchecked by default and the shop charge was $0.00/hidden unless the user manually checked it on each service. The per-service toggle still exists for exceptions.
|
||||
|
||||
## AI Write Now Includes Technician Notes
|
||||
|
||||
`handleAiWrite` in `QuoteGenerator.tsx` now passes `technicianNotes: svc.technicianNotes` to `aiWriteExplanation`. The existing prompt builder in `ai.ts` already handles it:
|
||||
|
||||
```typescript
|
||||
if (technicianNotes) {
|
||||
additionalContext += `\nTechnician's internal notes: "${technicianNotes}"`;
|
||||
}
|
||||
```
|
||||
|
||||
So the AI-generated explanation incorporates the tech's internal notes as context.
|
||||
|
||||
## PDF-to-Images Export
|
||||
|
||||
New file: `src/lib/pdf-to-images.ts`
|
||||
|
||||
`downloadPdfAsImages(pdfInput: jsPDF | Blob, customerName: string): Promise<void>`
|
||||
|
||||
Accepts either a jsPDF instance or a Blob. Converts every page to PNG via pdfjs-dist and triggers a download per page: `Jon_Smith_Page1.png`, `Jon_Smith_Page2.png`, etc.
|
||||
|
||||
**Setup required:**
|
||||
- `npm install pdfjs-dist`
|
||||
- Worker config in `main.tsx`: `pdfjsLib.GlobalWorkerOptions.workerSrc = new URL('pdfjs-dist/build/pdf.worker.min.mjs', import.meta.url).toString()`
|
||||
- Scale configurable via `getViewport({ scale })` (default 2 = Retina)
|
||||
|
||||
**Button added** in QuoteGenerator QuoteSummary: blue border "Export as Images" button between Print and Save.
|
||||
@@ -0,0 +1,49 @@
|
||||
# Nginx Cache Pitfall — 30-Day Immutable Static Files
|
||||
|
||||
**The bug:** Nginx is configured to cache `.js`, `.css`, and other static files for **30 days** with `Cache-Control: public, immutable`. This means the browser NEVER re-fetches JS/CSS files after the first visit — all code changes are invisible until the cache expires or the user hard-refreshes.
|
||||
|
||||
**This is separate from the file-permission issue** (`nginx-permission-pitfall.md`). That causes 403 Forbidden. This causes stale files to be served from browser cache with 200 OK and zero indication of staleness.
|
||||
|
||||
## How it manifests
|
||||
|
||||
- Code changes are made to `.js` or `.css` files
|
||||
- User reports that fixes "don't work" or "nothing happens"
|
||||
- The browser serves the cached version from days/weeks ago
|
||||
- A hard refresh (Ctrl+Shift+R) temporarily fixes it
|
||||
- On the next visit, the cache is still valid — old version returns
|
||||
|
||||
## Root cause
|
||||
|
||||
The `immutable` directive tells the browser: "this resource will never change, don't even send a conditional request." Combined with `expires 30d`, the browser caches for 30 days with zero revalidation.
|
||||
|
||||
In `/etc/nginx/sites-available/shopproquote`:
|
||||
```nginx
|
||||
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff2?)$ {
|
||||
expires 30d;
|
||||
add_header Cache-Control "public, immutable";
|
||||
}
|
||||
```
|
||||
|
||||
## Fix
|
||||
|
||||
```nginx
|
||||
location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff2?)$ {
|
||||
expires 1h;
|
||||
add_header Cache-Control "public, must-revalidate";
|
||||
}
|
||||
```
|
||||
|
||||
Then: `sudo nginx -t && sudo nginx -s reload`
|
||||
|
||||
**`must-revalidate`** means the browser checks with the server after expiry (1 hour). **No `immutable`** means conditional requests are allowed.
|
||||
|
||||
## When to suspect this
|
||||
|
||||
- User says "I just changed X and it didn't take effect"
|
||||
- Hard refresh fixes the issue temporarily
|
||||
- The file on disk differs from what the browser loads (check with browser DevTools → Sources)
|
||||
- Multiple pages are affected simultaneously (stale cache of shared modules like `pocketbase.js`)
|
||||
|
||||
## Prevention
|
||||
|
||||
After any code deployment, tell the user to hard-refresh (Ctrl+Shift+R). For production, consider cache-busting query strings (`?v=<hash>`) or shorter cache durations during active development.
|
||||
@@ -0,0 +1,49 @@
|
||||
# nginx: Serving .mjs Files with Correct MIME Type
|
||||
|
||||
## Problem
|
||||
|
||||
pdfjs-dist uses a web worker loaded as an ES module (`.mjs`). The worker is referenced via Vite's `new URL()` pattern in `src/main.tsx`:
|
||||
|
||||
```typescript
|
||||
pdfjsLib.GlobalWorkerOptions.workerSrc = new URL(
|
||||
'pdfjs-dist/build/pdf.worker.min.mjs',
|
||||
import.meta.url,
|
||||
).toString();
|
||||
```
|
||||
|
||||
At runtime this resolves to e.g. `/assets/pdf.worker.min-DEtVeC4l.mjs`. Browsers **refuse** to load ES modules served with `Content-Type: application/octet-stream` — they require `text/javascript` or `application/javascript`.
|
||||
|
||||
nginx's default `mime.types` does not include `.mjs`, so it falls back to the `default_type` of `application/octet-stream`. This causes "Export as Images" (and any feature using pdfjs-dist) to fail with:
|
||||
|
||||
```
|
||||
Failed to fetch dynamically imported module: https://grajmedia.duckdns.org/assets/pdf.worker.min-XXXX.mjs
|
||||
```
|
||||
|
||||
## Fix
|
||||
|
||||
Add a `types` block in `/etc/nginx/nginx.conf` after the `include /etc/nginx/mime.types;` line:
|
||||
|
||||
```nginx
|
||||
include /etc/nginx/mime.types;
|
||||
|
||||
# ES modules (.mjs) need text/javascript MIME type — browsers refuse
|
||||
# application/octet-stream for dynamic imports.
|
||||
types {
|
||||
application/javascript mjs;
|
||||
}
|
||||
|
||||
default_type application/octet-stream;
|
||||
```
|
||||
|
||||
Then reload:
|
||||
|
||||
```bash
|
||||
sudo nginx -t && sudo nginx -s reload
|
||||
```
|
||||
|
||||
Verify:
|
||||
|
||||
```bash
|
||||
curl -sI https://grajmedia.duckdns.org/assets/pdf.worker.min-XXXX.mjs | grep -i content-type
|
||||
# → Content-Type: application/javascript
|
||||
```
|
||||
@@ -0,0 +1,42 @@
|
||||
# Nginx Permission Pitfall — Prevention Checklist
|
||||
|
||||
The #1 recurring bug in ShopProQuote development. Every new file created by
|
||||
agents or build tools defaults to owner-only permissions (600). Nginx runs as
|
||||
`www-data` and returns 403 Forbidden. When a JS module returns 403, every file
|
||||
that imports it silently fails — no console error, just dead features.
|
||||
|
||||
## After creating ANY new file in the project
|
||||
|
||||
```bash
|
||||
# Fix all 600-permission files in one command
|
||||
find /mnt/seagate8tb/Websites/ShopProQuote -name "*.js" -perm 600 -exec chmod 644 {} \;
|
||||
find /mnt/seagate8tb/Websites/ShopProQuote -name "*.css" -perm 600 -exec chmod 644 {} \;
|
||||
```
|
||||
|
||||
## Diagnostic when pages are broken but HTML loads fine
|
||||
|
||||
```bash
|
||||
# Check all JS files are accessible
|
||||
for f in shared/debug.js shared/skeleton.js api.js dashboard.js repair-orders.js; do
|
||||
curl -sk -o /dev/null -w "%{http_code} " https://localhost/$f && echo "$f"
|
||||
done
|
||||
|
||||
# Check nginx error log for permission denied
|
||||
tail -50 /var/log/nginx/error.log | grep -i "permission denied"
|
||||
```
|
||||
|
||||
## Files that have been broken by this bug (historical)
|
||||
|
||||
| File | Symptom when 403 |
|
||||
|------|-----------------|
|
||||
| `shared/debug.js` | ALL modules that import it silently fail. Entire app dead. |
|
||||
| `shared/skeleton.js` | Skeleton loading functions unavailable, graceful fallback to text |
|
||||
| `dist/custom.css` | No custom styling — site looks completely unstyled |
|
||||
| `dist/tailwind.css` | No Tailwind — site looks like plain HTML |
|
||||
|
||||
## Worker/agent file creation checklist
|
||||
|
||||
After `write_file`, `patch`, or terminal commands that create files:
|
||||
1. `ls -la <file>` — check permissions
|
||||
2. If `-rw-------` (600), `chmod 644 <file>`
|
||||
3. `curl -sk -o /dev/null -w "%{http_code}" https://localhost/<file>` — verify accessible
|
||||
@@ -0,0 +1,41 @@
|
||||
# Nginx File Permission Pitfall — Prevention Checklist
|
||||
|
||||
**The bug:** New files created in the ShopProQuote project default to `600` (owner-only). Nginx runs as `www-data` and returns `403 Forbidden` when it can't read them. This silently breaks the site.
|
||||
|
||||
**How it manifests:**
|
||||
- Pages load but are completely un-styled (CSS files blocked)
|
||||
- JS modules fail silently with no console error visible to the user (ES module import fails with 403)
|
||||
- Tabs don't work, data doesn't load, buttons do nothing — because the JS module that registers event handlers never executed
|
||||
|
||||
**Files broken by this in production (2026-06-13):**
|
||||
- `dist/custom.css` → site completely un-styled, all Tailwind classes missing
|
||||
- `shared/debug.js` → every JS module that imports from it failed (repair-orders.js, dashboard.js, quote-tab-manager.js, etc.)
|
||||
- `shared/skeleton.js` → skeleton loading states failed
|
||||
|
||||
**Root cause:** The Linux `umask` for files created by dev tools (workers, `write_file` tool, `touch`) produces `600`. Nginx needs `644` or better.
|
||||
|
||||
**Prevention — run after creating ANY new file:**
|
||||
```bash
|
||||
# Fix all JS files with bad permissions
|
||||
find /mnt/seagate8tb/Websites/ShopProQuote -name "*.js" -perm 600 -exec chmod 644 {} \;
|
||||
|
||||
# Fix all CSS files
|
||||
find /mnt/seagate8tb/Websites/ShopProQuote -name "*.css" -perm 600 -exec chmod 644 {} \;
|
||||
|
||||
# Fix all HTML files
|
||||
find /mnt/seagate8tb/Websites/ShopProQuote -name "*.html" -perm 600 -exec chmod 644 {} \;
|
||||
```
|
||||
|
||||
**Quick diagnostic:**
|
||||
```bash
|
||||
# Check which files are inaccessible to nginx
|
||||
find /mnt/seagate8tb/Websites/ShopProQuote -perm 600 -name "*.js" -o -name "*.css" -perm 600 | while read f; do
|
||||
code=$(curl -sk -o /dev/null -w "%{http_code}" "https://localhost/${f#/mnt/seagate8tb/Websites/ShopProQuote/}")
|
||||
echo "$code $f"
|
||||
done
|
||||
|
||||
# Or check nginx error log directly
|
||||
tail -50 /var/log/nginx/error.log | grep "Permission denied"
|
||||
```
|
||||
|
||||
**When to suspect this:** Whenever you create a new shared module, CSS file, or JS utility and suddenly multiple pages break with no obvious JS errors. The browser console may show a failed module import but the root 403 is only visible in nginx logs or curl. Always `chmod 644` after creating new project files.
|
||||
@@ -0,0 +1,73 @@
|
||||
# Appointment Screenshot Date Handling
|
||||
|
||||
The date in appointment screenshots sits in the top middle (e.g., "Wednesday Jun 10, 2026").
|
||||
All appointments in one screenshot are for the same day.
|
||||
|
||||
## Current approach: User-selected date (no OCR date extraction)
|
||||
|
||||
The user picks the date via a date picker before scanning. The date is injected into the LLM
|
||||
system prompt so the AI never tries to extract it from garbled OCR text. This eliminates an
|
||||
entire Tesseract OCR pass (~30s savings) and frees tokens for better appointment extraction.
|
||||
|
||||
### Date picker UI element
|
||||
|
||||
```html
|
||||
<div class="mb-4">
|
||||
<label for="scan-appointment-date" class="...">Appointment Date</label>
|
||||
<input type="date" id="scan-appointment-date" class="...">
|
||||
<p class="text-xs text-purple-500 mt-1">All appointments in this screenshot are for this date.</p>
|
||||
</div>
|
||||
```
|
||||
|
||||
Located between the model selector and upload area in `#scan-screenshot-modal`.
|
||||
|
||||
### Default to today
|
||||
|
||||
When the modal opens, the date picker defaults to today if not already set:
|
||||
|
||||
```js
|
||||
const dateInput = document.getElementById('scan-appointment-date');
|
||||
if (dateInput && !dateInput.value) dateInput.value = new Date().toISOString().split('T')[0];
|
||||
```
|
||||
|
||||
### Reading in handleFile
|
||||
|
||||
```js
|
||||
const selectedDate = document.getElementById('scan-appointment-date')?.value
|
||||
|| new Date().toISOString().split('T')[0];
|
||||
```
|
||||
|
||||
### System prompt injection
|
||||
|
||||
The LLM prompt includes the date explicitly so it never wastes tokens deciphering the header:
|
||||
|
||||
```
|
||||
### ALL APPOINTMENTS ARE FOR: 2026-06-14
|
||||
```
|
||||
|
||||
And rule #1 changed from "Find the date in the header..." to:
|
||||
|
||||
```
|
||||
1. appointmentDate: Use "2026-06-14" for EVERY appointment. Do not read dates from the text.
|
||||
```
|
||||
|
||||
The prompt is built with string concatenation: `'...### ALL APPOINTMENTS ARE FOR: ' + selectedDate + '\n\n### COLUMN LAYOUT...'`
|
||||
|
||||
### Fallback in mapping
|
||||
|
||||
The final appointment mapping uses `selectedDate` as fallback:
|
||||
|
||||
```js
|
||||
appointmentDate: /^\d{4}-\d{2}-\d{2}$/.test(a.appointmentDate) ? a.appointmentDate : selectedDate,
|
||||
```
|
||||
|
||||
## Removed: Two-pass OCR date extraction
|
||||
|
||||
The previous approach (removed June 2026) used a second Tesseract pass with PSM 7 on a
|
||||
cropped top-20% image. This was fragile — OCR artifacts, wrong PSM, and table-row false
|
||||
positives made it unreliable. The user-picker approach is faster, more reliable, and simpler.
|
||||
|
||||
## One remaining detail
|
||||
|
||||
The `preprocessed` object still creates a `header` blob during preprocessing (top 20% crop)
|
||||
but it is no longer used. It can be cleaned up in a future refactor.
|
||||
@@ -0,0 +1,154 @@
|
||||
# OCR Rule-Based Parser for Appointment Extraction
|
||||
|
||||
Full code of `parseWithRules()` from `appointments.html` inline script, with test cases. Built to replace a DeepSeek API call with zero-dependency CPU parsing.
|
||||
|
||||
## Design
|
||||
|
||||
Layered extraction order (5 phases now):
|
||||
|
||||
**Phase 0 — Extract header date BEFORE stripping**: Screenshots often have the appointment date in the top-middle header (e.g., "Monday, Jun 09, 2026"). Scan the first ~15 lines for a date pattern BEFORE Phase 1 strips metadata lines. Save as `headerDate`. After all appointments are parsed, apply `headerDate` as the fallback for any appointment missing its own date.
|
||||
|
||||
**Phases 1-4** (unchanged):
|
||||
1. Split blocks by blank lines
|
||||
2. Strip separator lines (`---`, `===`) and short ALL-CAPS headers (`SCHEDULE`, `NAME`)
|
||||
3. Table detection: if block has 2+ lines and header line contains `name|phone|date|time|service|vehicle|vin`, parse each line individually
|
||||
4. Per-block: extract phone → VIN → date → time → duration (replacing matches with single space to preserve column gaps)
|
||||
5. Split remaining on `\s{2,}` (multi-space column boundaries) — if that fails, fall back to single-space word heuristics
|
||||
6. Name = first consecutive capitalized words without digits. Vehicle = chunk containing year (`19xx`/`20xx`) or known make. Service = everything else.
|
||||
|
||||
## Key Patterns
|
||||
|
||||
| Field | Regex | Notes |
|
||||
|-------|-------|-------|
|
||||
| Phone | `\(?\d{3}\)?[\s.\-]*\d{3}[\s.\-]*\d{4}` | Captures `(555)123-4567`, `555.123.4567`, `555 123 4567` |
|
||||
| VIN | `\b[A-HJ-NPR-Z0-9]{17}\b` | Excludes I, O, Q per VIN standard |
|
||||
| Date (ISO) | `(\d{4})[\/\-.](\d{1,2})[\/\-.](\d{1,2})` | `2024-06-15` |
|
||||
| Date (US) | `(\d{1,2})[\/](\d{1,2})(?:\/(\d{4}))?` | `06/20/2024` or `06/20` |
|
||||
| Date (named) | `(jan\|feb\|...)[a-z]*\s+(\d{1,2})(?:[,\s]+(\d{4}))?` | `Jan 15, 2024` |
|
||||
| Time (HH:MM) | `(\d{1,2}):(\d{2})\s*(AM\|PM)?` | `9:00 AM`, `14:00` |
|
||||
| Time (bare) | `(\d{1,2})\s*(AM\|PM)` | `8am`, `1PM` |
|
||||
| Duration | `(\d+)\s*(?:min\|minutes?\|hrs?\|hours?)` | Converts `1 hour` → 60, `90 min` → 90 |
|
||||
| Vehicle (year) | `\b(19\|20)\d{2}\b` | Detects year-in-text |
|
||||
| Vehicle (make) | `ford\|chevy\|chevrolet\|toyota\|honda\|...` | Known make list |
|
||||
|
||||
## Verified Test Cases
|
||||
|
||||
### 1. Two appointments with double spacing
|
||||
```
|
||||
John Smith (555) 123-4567 2024-06-15 9:00 AM Oil Change 60 min 2018 Toyota Camry
|
||||
|
||||
Jane Doe (555) 987-6543 06/20/2024 2:00 PM Brake Inspection 90 min 2020 Honda Civic
|
||||
```
|
||||
→ 2 appts: `John Smith / 2024-06-15 09:00 / 2018 Toyota Camry / Oil Change`, `Jane Doe / 2024-06-20 14:00 / 2020 Honda Civic / Brake Inspection`
|
||||
|
||||
### 2. Headers and separator lines
|
||||
```
|
||||
SCHEDULE
|
||||
--------
|
||||
Robert Brown 865-555-1234 Jan 15, 2024 8:00am Tire Rotation 1 hour Toyota Tacoma
|
||||
|
||||
Sarah Wilson (423)555-6789 03/01/2024 1:30PM AC Repair 2 hours Jeep Grand Cherokee
|
||||
```
|
||||
→ 2 appts (headers stripped): `Robert Brown / 2024-01-15 08:00 / Toyota Tacoma / Tire Rotation`, `Sarah Wilson / 2024-03-01 13:30 / Jeep Grand Cherokee / AC Repair`
|
||||
|
||||
### 3. Single appointment
|
||||
```
|
||||
Mike Johnson 2024-07-01 10:30 AM Transmission Fluid 2022 Ford F-150
|
||||
```
|
||||
→ 1 appt: `Mike Johnson / 2024-07-01 10:30 / 2022 Ford F-150 / Transmission Fluid`
|
||||
|
||||
### 4. Table format with header row
|
||||
```
|
||||
Name Phone Date Time Service Vehicle
|
||||
John Smith 555-123-4567 2024-06-15 9:00 AM Oil Change 2018 Toyota Camry
|
||||
Jane Doe 555-987-6543 06/20/2024 2:00 PM Brake Inspection 2020 Honda Civic
|
||||
```
|
||||
→ 2 appts (header stripped, rows parsed): correct fields for both
|
||||
|
||||
### 5. Screenshot with header date — no per-appointment dates
|
||||
```
|
||||
Monday, Jun 09, 2026
|
||||
|
||||
8:00 AM 1.5 hrs John Smith 555-123-4567 Oil Change 2018 Toyota Camry
|
||||
10:30 AM 1 hr Jane Doe 555-987-6543 Brake Inspection 2020 Honda Civic
|
||||
```
|
||||
→ 2 appts, both get `appointmentDate: "2026-06-09"` from Phase 0 header extraction. The "Monday Jun 09" line is extracted before Phase 1 strips it.
|
||||
|
||||
## Phase 0 Header Date + Phase 4 Fallback (added 2026-06-09)
|
||||
|
||||
**Problem**: Screenshots have the date in the top-middle header (e.g., "Monday, Jun 09, 2026"), but Phase 1 strips it as metadata before any appointments are parsed. Each appointment ends up with no date.
|
||||
|
||||
**Solution**:
|
||||
- **Phase 0** (before Phase 1): Scan first ~15 lines with `headerDateRe` for a date pattern, call `parseDate()`, store as `headerDate`.
|
||||
- **Phase 4** (applied at BOTH return points — main return and table fallback): If `headerDate` exists, any appointment without `appointmentDate` gets it assigned.
|
||||
|
||||
```javascript
|
||||
// Phase 0
|
||||
var headerDate = null;
|
||||
var headerLines = t.split('\n').slice(0, 15);
|
||||
var headerDateRe = /\b(?:(?:Mon|Tue|...)[,\s]+)?(?:Jan|Feb|...)[a-z]*\s+\d{1,2}(?:[,\s]+\d{4})?\b|.../i;
|
||||
for (var hi = 0; hi < headerLines.length; hi++) {
|
||||
var hm = headerLines[hi].match(headerDateRe);
|
||||
if (hm) { var hd = parseDate(hm[0]); if (hd) { headerDate = hd; break; } }
|
||||
}
|
||||
|
||||
// Phase 4 (before each return)
|
||||
if (headerDate) {
|
||||
appointments.forEach(function(a) {
|
||||
if (!a.appointmentDate) a.appointmentDate = headerDate;
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
**Critical**: Apply at ALL return points. The table fallback (`lines.forEach(...); return appointments;`) has its own early return that bypasses the main Phase 4 block. Add the headerDate fallback guard before that return too.
|
||||
|
||||
## Service Word Blocklist for Name Extraction (added 2026-06-09)
|
||||
|
||||
**Problem**: Cross-block name carry (Step 10) picks up trailing all-caps words from `serviceType` and treats them as the next appointment's customer name. Words like "SERVICE", "REPAIR", "CHECK" get carried as names.
|
||||
|
||||
**Solution — `isServiceWord()` helper**:
|
||||
```javascript
|
||||
var serviceWords = {SERVICE:1,REPAIR:1,CHECK:1,MAINTENANCE:1,INSPECTION:1,DIAGNOSTIC:1,
|
||||
DIAG:1,REPLACE:1,REPLACEMENT:1,INSTALL:1,REMOVE:1,ADJUST:1,ALIGNMENT:1,ROTATION:1,
|
||||
BALANCE:1,FLUSH:1,DRAIN:1,FILL:1,TUNE:1,UP:1,ESTIMATE:1,WAITING:1,APPOINTMENT:1,
|
||||
REQUEST:1,CUSTOMER:1,VEHICLE:1,ADVISOR:1,TECHNICIAN:1,MECHANIC:1};
|
||||
function isServiceWord(w) {
|
||||
w = w.toUpperCase().replace(/[^A-Z]/g,'');
|
||||
return serviceWords[w] || w.length > 12;
|
||||
}
|
||||
```
|
||||
|
||||
**Applied in three places**:
|
||||
|
||||
1. **parseOne() entry** — validate `carryName` before using it:
|
||||
```javascript
|
||||
if (carryName) {
|
||||
if (isServiceWord(carryName)) carryName = null;
|
||||
else appt.customerName = carryName;
|
||||
}
|
||||
```
|
||||
|
||||
2. **Step 7 name extraction** — skip parts that are service words, year-prefixed vehicle descriptions, or long all-caps:
|
||||
```javascript
|
||||
var nameIdx = 0;
|
||||
while (nameIdx < parts.length && (
|
||||
isServiceWord(parts[nameIdx]) ||
|
||||
/^\d{4}\s/.test(parts[nameIdx]) ||
|
||||
/^[A-Z\d\s\-]{6,}$/.test(parts[nameIdx])
|
||||
)) { nameIdx++; }
|
||||
```
|
||||
|
||||
3. **Step 10 cross-block carry** — reject service words before carrying:
|
||||
```javascript
|
||||
if (tn && tn[1].length > 3 && tn[1].length < 20 && !isServiceWord(tn[1])) { ... }
|
||||
```
|
||||
|
||||
## Pitfalls Encountered During Development
|
||||
|
||||
1. **Whitespace collapse order is critical**: Collapsing `\s+` to single space BEFORE splitting on `\s{2,}` destroys column boundaries. Must split on multi-spaces first, then clean each part.
|
||||
|
||||
2. **Bracket mismatch from refactoring**: Extracting inline code into a `parseBlock()` helper left a leftover `});` from the old `blocks.forEach()` closure. Caused `SyntaxError: Unexpected token ')'`.
|
||||
|
||||
3. **Dead function declarations**: Two `handleFile` declarations in same scope — second silently overwrites first. Always delete dead code.
|
||||
|
||||
4. **Global dependency gaps**: Inline scripts need `window.closeModal`, `window.escapeHtml`, `window.batchCreateAppointments` — module exports aren't accessible.
|
||||
@@ -0,0 +1,74 @@
|
||||
# SPQ Page Architecture & Auth Flow
|
||||
|
||||
## Page layout
|
||||
|
||||
```
|
||||
/ (index.html) — login page (NO redirect — root lands here directly)
|
||||
dashboard.html — main dashboard (auth-guarded: no user → redirects to /)
|
||||
appointments.html, customers.html, repair-orders.html — sub-pages (auth-guarded)
|
||||
```
|
||||
|
||||
## Why index.html = login page
|
||||
|
||||
When `index.html` was the dashboard and `login.html` was a separate page, stale/expired PocketBase tokens in localStorage caused an infinite redirect loop:
|
||||
|
||||
1. User visits `/` → `index.html` (dashboard) → `onAuthStateChanged` → SDK sees stale token as valid → stays
|
||||
2. First PocketBase API call fails (401) → SDK clears auth → `onChange(null)` fires → redirect to `login.html`
|
||||
3. `login.html` loads → SDK reads token from localStorage again (still there, client-side JWT expiry check says valid) → `onAuthStateChanged(user)` → redirect to `index.html`
|
||||
4. Loop!
|
||||
|
||||
Making `index.html` the login page eliminates the bounce: unauthorized users see the login form directly, authorized users get one clean redirect to `dashboard.html`.
|
||||
|
||||
## Auth guard pattern (in every page JS)
|
||||
|
||||
```js
|
||||
onAuthStateChanged(auth, (user) => {
|
||||
if (user) {
|
||||
// initialize page
|
||||
} else {
|
||||
window.location.href = 'index.html'; // redirect to login
|
||||
}
|
||||
});
|
||||
```
|
||||
|
||||
**login.js** (login page):
|
||||
```js
|
||||
onAuthStateChanged(auth, (user) => {
|
||||
if (user) {
|
||||
window.location.href = 'dashboard.html'; // already logged in → dashboard
|
||||
}
|
||||
// If no user, stay on login page
|
||||
});
|
||||
```
|
||||
|
||||
## PocketBase auth flow
|
||||
|
||||
The `pocketbase.js` adapter:
|
||||
- Creates PocketBase client pointed at `window.location.origin` (same-origin, through nginx `/api/` proxy)
|
||||
- `onAuthStateChanged` fires initial state synchronously via `auth.currentUser` getter (reads from `pb.authStore`)
|
||||
- Then registers `pb.authStore.onChange` for future changes
|
||||
- `authStore` reads from localStorage key `pocketbase_auth`
|
||||
|
||||
## When renaming/moving pages
|
||||
|
||||
All references are relative (`dashboard.html`, not absolute paths). Files to update:
|
||||
|
||||
| File | What to change |
|
||||
|------|---------------|
|
||||
| `login.js` | Login success redirect → `dashboard.html` |
|
||||
| `dashboard.js`, `main.js`, `appointments.js`, `customers.js`, `repair-orders.js` | Auth fail redirect → `index.html` |
|
||||
| `shared/header-functionality.js` | Logout redirect → `index.html` |
|
||||
| `index.html` (login page body) | Post-login redirects → `dashboard.html?cb=...` |
|
||||
| `appointments.html`, `repair-orders.html`, `customers.html` | Nav links → `dashboard.html` |
|
||||
| `dashboard.html` | Nav self-links → `dashboard.html` |
|
||||
| `customers.js` | Quote links → `dashboard.html?quoteId=...` |
|
||||
|
||||
Total: ~28 references across 13 files.
|
||||
|
||||
## nginx note
|
||||
|
||||
Home nginx `server_name` must include the domain the VPS sends in `Host` header. If the VPS proxies `shopproquote.graj-media.com` with `Host: graj-media.com`, add it as an alias:
|
||||
|
||||
```
|
||||
server_name grajmedia.duckdns.org graj-media.com shopproquote.graj-media.com;
|
||||
```
|
||||
@@ -0,0 +1,95 @@
|
||||
# PB 0.39.1 JS Migration Quirks
|
||||
|
||||
Collected during SPQ-v2 Phase 4 implementation (2026-07-07).
|
||||
|
||||
## API Rule Validation Ordering Bug
|
||||
|
||||
**Symptom:** `listRule` / `viewRule` / `createRule` that reference custom fields fail with `invalid left operand "foo" - unknown field "foo"` even when the field IS in the schema.
|
||||
|
||||
**Root cause:** PB 0.39 validates API rules against its internal schema snapshot at rule-set time. If you create a collection AND set rules in the same `migrate()` call, the rules validator is running against the PRE-COMMIT snapshot (which doesn't have the new fields yet).
|
||||
|
||||
**What does NOT work (all three tried, all failed):**
|
||||
1. Saving with empty rules, then setting rules on the same object + `app.save()` again — FAILS
|
||||
2. Saving with empty rules, then `app.findCollectionByNameOrId()`, setting rules on fetched object + `app.save()` — FAILS
|
||||
3. Separate migration file (M27 — after M25/M26 committed) — STILL FAILS
|
||||
|
||||
**The rules validator always runs against the pre-commit snapshot.** The only workaround for single-shop deployments: use empty rules (`''`) and rely on frontend filtering + `createRule: null` for write protection on server-side hook-only collections.
|
||||
|
||||
**When you need scoped rules:** Use the PocketBase admin UI after deployment, not JS migrations.
|
||||
|
||||
## Missing Field Constructors
|
||||
|
||||
| Wanted | Use Instead |
|
||||
|--------|-------------|
|
||||
| `DateTimeField` | `TextField` — store ISO strings |
|
||||
| `DateField` | `TextField` — store ISO strings |
|
||||
|
||||
Available constructors (confirmed working in PB 0.39.1):
|
||||
- `TextField`
|
||||
- `EmailField`
|
||||
- `SelectField`
|
||||
- `JSONField`
|
||||
- `BoolField`
|
||||
- `NumberField` (likely, not confirmed)
|
||||
|
||||
## JS Runtime Support
|
||||
|
||||
PB 0.39.1 uses Goja (Go-based ES runtime). Confirmed working:
|
||||
- ✅ `const`, `let`, `var`
|
||||
- ✅ Template literals `` `hello ${world}` ``
|
||||
- ✅ Arrow functions `(x) => x + 1`
|
||||
- ✅ `Object.keys()`, `Array.forEach()`, `JSON.stringify()`
|
||||
- ✅ `new Collection({ name, type, schema: [...] })`
|
||||
- ✅ `new Record(collection, data)`
|
||||
|
||||
## Hook Event Names
|
||||
|
||||
| Correct API (PB 0.39) | Names that DON'T exist |
|
||||
|------------------------|------------------------|
|
||||
| `app.onRecordCreateRequest((e) => { ... })` | — |
|
||||
| `app.onRecordUpdateRequest((e) => { ... })` | `onRecordBeforeUpdateRequest`, `onRecordAfterUpdateRequest` |
|
||||
| `app.onRecordDeleteRequest((e) => { ... })` | — |
|
||||
|
||||
Only the three "Request" hooks exist — there are no "Before"/"After" variants.
|
||||
|
||||
## Record Access in Hooks
|
||||
|
||||
- `e.record` — the record being saved (in update hooks, this is the NEW data)
|
||||
- `e.oldRecord` — available in update hooks, the current DB state before the update
|
||||
- `e.dao` — data access object for querying other collections
|
||||
- `e.httpContext` — access to the HTTP request (query params, headers)
|
||||
|
||||
## Creating Records Inside Hooks
|
||||
|
||||
```js
|
||||
var collection = app.findCollectionByNameOrId('myCollection');
|
||||
var record = new Record(collection, {
|
||||
field1: 'value1',
|
||||
field2: 'value2',
|
||||
});
|
||||
e.dao.saveRecord(record);
|
||||
```
|
||||
|
||||
## Public Share-Token Access Pattern
|
||||
|
||||
To detect if a request is using the public share-token path (vs. authenticated API):
|
||||
```js
|
||||
var shareToken = e.httpContext?.request?.query?.get('shareToken') || '';
|
||||
if (shareToken) {
|
||||
// This is a public (unauthenticated) update via share link
|
||||
// Enforce expiry, restrict fields, etc.
|
||||
}
|
||||
```
|
||||
|
||||
## Mailer API (for sending email in hooks)
|
||||
|
||||
```js
|
||||
var mailer = app.getMailer();
|
||||
await mailer.send({
|
||||
to: [{ address: 'user@example.com', name: 'Name' }],
|
||||
subject: 'Subject',
|
||||
html: '<p>HTML body</p>',
|
||||
});
|
||||
```
|
||||
|
||||
Requires SMTP env vars set on the PB container (PB will crash on startup without valid `PB_SMTP_*` vars once mailer is used).
|
||||
@@ -0,0 +1,83 @@
|
||||
# PDF Generation — ShopProQuote v2
|
||||
|
||||
Source: `src/lib/pdf.ts` in the v2 SPA.
|
||||
|
||||
## Critical pitfalls (from real debugging sessions)
|
||||
|
||||
### 1. NEVER pass string arrays to `doc.text()` in jspdf 4.x
|
||||
|
||||
jspdf 4.x uses internal matrix-based line spacing when you pass a string array to `doc.text()`. The manual yPos calculation (`array.length * lineHeight`) does NOT match where jspdf actually placed the text. This causes a cascading yPos desync and text overlap throughout the entire document.
|
||||
|
||||
```typescript
|
||||
// WRONG — causes overlap
|
||||
const lines = doc.splitTextToSize(text, maxWidth);
|
||||
doc.text(lines, x, y);
|
||||
yPos += lines.length * 6; // does NOT match jspdf's internal spacing
|
||||
|
||||
// CORRECT — render each line individually
|
||||
doc.splitTextToSize(text, maxWidth).forEach((line) => {
|
||||
doc.text(line, x, yPos);
|
||||
yPos += 6;
|
||||
});
|
||||
```
|
||||
|
||||
### 2. Pagination must be proactive, not reactive
|
||||
|
||||
The page-break condition must check whether the NEXT service fits before rendering it:
|
||||
|
||||
```typescript
|
||||
// Calculate full service height (badge + name + price + priority + recommendation + explanation + parts + separator)
|
||||
let svcH = 0;
|
||||
if (svc.customerDecision === 'approved' || svc.customerDecision === 'declined') svcH += 5;
|
||||
svcH += 6; // name + price
|
||||
if (svc.priority && svc.priorityReason) svcH += 16;
|
||||
if (svc.recommendation) svcH += 8;
|
||||
if (svc.explanation) svcH += splitLines * 5 + 2;
|
||||
if (svc.partsNotInStock) { svcH += 8; if (svc.partsDeliveryTime) svcH += 8; }
|
||||
if (svc.aftermarketAvailable) { svcH += 8; if (svc.aftermarketPartsList) svcH += 8; }
|
||||
svcH += 6; // separator
|
||||
|
||||
// Break BEFORE rendering
|
||||
if (y + svcH > CONTENT_BOTTOM - FOOTER_HEIGHT) {
|
||||
y = newPage(doc);
|
||||
// Redraw section header on new page
|
||||
}
|
||||
```
|
||||
|
||||
Key rules:
|
||||
- Use `CONTENT_BOTTOM - FOOTER_HEIGHT` (not `PAGE_HEIGHT * 0.7`) as the threshold
|
||||
- Redraw section headers (SERVICES APPROVED / POSTPONED SERVICES) after page breaks
|
||||
- Draw footer (separator + messages + page numbers) on EVERY page, not just the last
|
||||
|
||||
### 3. Font size must exceed line spacing
|
||||
|
||||
The legacy code used very tight spacing (font size 10 with only 5pt line advance). This works in jspdf 2.x but may cause visible overlap in jspdf 4.x if font metrics differ. The current values match the legacy exactly and produce correct output in jspdf 4.2.1:
|
||||
- Explanation lines: font 10, advance 5pt per line, +2pt end padding
|
||||
- Parts messages: font 9, advance 8pt
|
||||
- Service name: font 10, advance 6pt
|
||||
- Recommendation: font 9, advance 8pt
|
||||
|
||||
### 4. Customer info loading bug
|
||||
|
||||
When editing a saved quote, `reset()` was called AFTER `setCustomerInfo()`, wiping the just-loaded customer data:
|
||||
|
||||
```typescript
|
||||
// WRONG — reset() wipes customerInfo
|
||||
setCustomerInfo(data.customerInfo);
|
||||
reset();
|
||||
|
||||
// CORRECT
|
||||
reset();
|
||||
setCustomerInfo(data.customerInfo);
|
||||
```
|
||||
|
||||
Also, PocketBase may return JSON fields as strings or parsed objects. Defensively handle both:
|
||||
```typescript
|
||||
const ci = typeof data.customerInfo === 'string' ? JSON.parse(data.customerInfo) : data.customerInfo;
|
||||
```
|
||||
|
||||
### 5. File locations
|
||||
|
||||
- Source: `/mnt/seagate8tb/Websites/ShopProQuote.backup-20260626-2058/spq-v2/src/lib/pdf.ts`
|
||||
- Built: `/mnt/seagate8tb/Websites/ShopProQuote.backup-20260626-2058/spq-v2/dist/assets/QuoteGenerator-*.js`
|
||||
- Legacy reference: `/mnt/seagate8tb/Websites/ShopProQuote/quote-tab-manager.js` (function `generateQuotePDF`)
|
||||
@@ -0,0 +1,74 @@
|
||||
# PDF Generation (jspdf 4.x)
|
||||
|
||||
The v2 app uses `jspdf` (v4.2.1) to generate quote PDFs. The generator lives at `src/lib/pdf.ts`.
|
||||
|
||||
## Critical Pitfalls
|
||||
|
||||
### 1. NEVER pass string arrays to `doc.text()`
|
||||
|
||||
In jspdf 4.x, `doc.text(stringArray, x, y)` uses internal matrix-based line spacing that differs from manual `array.length * lineHeight` calculations. This causes yPos desync — every subsequent element renders at the wrong position, causing cascading text overlap.
|
||||
|
||||
**WRONG:**
|
||||
```ts
|
||||
const lines = doc.splitTextToSize(text, maxWidth);
|
||||
doc.text(lines, x, y); // jspdf 4.x spacing != manual calc
|
||||
yPos += lines.length * 6 + 15; // DOESN'T MATCH where jspdf rendered
|
||||
```
|
||||
|
||||
**RIGHT:**
|
||||
```ts
|
||||
const lines = doc.splitTextToSize(text, maxWidth);
|
||||
lines.forEach((line) => { // render each line individually
|
||||
doc.text(line, x, yPos);
|
||||
yPos += 6; // explicit tracking per line
|
||||
});
|
||||
yPos += 15;
|
||||
```
|
||||
|
||||
### 2. Page-break logic must use content-bottom + footer-height
|
||||
|
||||
Calculate max y as `PAGE_HEIGHT - MARGIN - FOOTER_HEIGHT` (not just page edge). Footer is 35pt: separator line + message + page number.
|
||||
|
||||
Before rendering a service, compute its total height and check:
|
||||
```
|
||||
if (yPos + serviceHeight > PAGE_HEIGHT - MARGIN - FOOTER_HEIGHT) {
|
||||
doc.addPage();
|
||||
yPos = MARGIN;
|
||||
// Redraw section header (APPROVED / POSTPONED) on new page
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Section headers must be redrawn after page breaks
|
||||
|
||||
Track `currentGroupLabel` and `currentGroupColor`. When a page break occurs mid-section, redraw the section header at the top of the new page.
|
||||
|
||||
### 4. Footers on EVERY page, not just the last
|
||||
|
||||
Draw footers (separator + message + page number) in a post-render loop over all pages. Do NOT draw them inline — they'll only appear on the last page.
|
||||
|
||||
### 5. Height calculation must match actual yPos increments
|
||||
|
||||
The pre-render height estimate must use the same values as actual yPos advances:
|
||||
- Customer decision badge: 5pt
|
||||
- Name+price: 6pt
|
||||
- Priority (label+reason): 6+10=16pt
|
||||
- Recommendation: 8pt
|
||||
- Explanation lines: 5pt each + 2pt end padding
|
||||
- Parts messages: 8pt each
|
||||
- Service separator+spacing: 6pt
|
||||
|
||||
### 7. Service counting for totals: `!== 'declined'`, not `=== 'approved'`
|
||||
|
||||
The PDF's `servicesTotal` accumulator and `shopChargeableSubtotal` use `customerDecision !== 'declined'`. This means **both approved AND pending services** contribute to the pricing summary.
|
||||
|
||||
The UI summary (`computeQuoteTotals`) uses `customerDecision === 'approved'` — a deliberate difference. The PDF is customer-facing, so pending services must appear in the total so the customer sees the full cost. Only services the customer has explicitly declined are excluded.
|
||||
|
||||
Do NOT change the PDF filter to `=== 'approved'` — pending services would produce $0 totals.
|
||||
|
||||
Use `let boxBottomY` (not `const`) so it can be updated when a page break changes the box position. After rendering all summary rows, set `yPos = boxBottomY + 10` for the next content.
|
||||
|
||||
## File Locations
|
||||
|
||||
- Source: `src/lib/pdf.ts`
|
||||
- Built: `dist/assets/QuoteGenerator-*.js`
|
||||
- Test script pattern: write a `.mjs` file in the project root, import jspdf, generate, save with fs.writeFileSync
|
||||
@@ -0,0 +1,106 @@
|
||||
# PDF Layout Refinement Patterns (jsPDF)
|
||||
|
||||
Patterns learned from iterative PDF layout fixes in `quote-tab-manager.js` (ShopProQuote).
|
||||
|
||||
## Fill-Before-Text Ordering
|
||||
|
||||
When drawing a background box with text on top, always draw the fill FIRST, then the text, then the border last:
|
||||
|
||||
```javascript
|
||||
// 1. Draw fill-only background BEFORE any text
|
||||
doc.setFillColor(248, 248, 248);
|
||||
doc.roundedRect(x, y, w, initialHeight, r, r, 'F');
|
||||
|
||||
// 2. Draw all text content on top of the fill
|
||||
doc.text(...);
|
||||
|
||||
// 3. Compute actual height, draw border-only at correct size
|
||||
const actualHeight = finalY - startY;
|
||||
doc.setDrawColor(220, 220, 220);
|
||||
doc.setLineWidth(0.5);
|
||||
doc.roundedRect(x, y, w, actualHeight, r, r, 'D');
|
||||
```
|
||||
|
||||
NEVER use `'FD'` (fill+draw) after text — it paints over the text. The two-pass approach (fill first with generous initial height, border after at computed height) ensures text is always visible and borders are tight.
|
||||
|
||||
## Dynamic Box Height
|
||||
|
||||
Never hardcode box height. Compute it from content:
|
||||
|
||||
```javascript
|
||||
const headerStartY = yPos;
|
||||
// ... draw all content, advancing yPos ...
|
||||
const actualHeight = yPos - headerStartY;
|
||||
```
|
||||
|
||||
This prevents boxes from being too short (clipping content) or too tall (wasted space with large gaps).
|
||||
|
||||
## Overlap Prevention
|
||||
|
||||
When a footer note appears below a box, position it relative to the box's visual bottom, not the last text line:
|
||||
|
||||
```javascript
|
||||
// WRONG — note may overlap the box
|
||||
yPos += 15;
|
||||
doc.text(note, x, yPos);
|
||||
|
||||
// RIGHT — note starts after box's visual boundary
|
||||
yPos = boxBottomY + 10;
|
||||
doc.text(note, x, yPos);
|
||||
```
|
||||
|
||||
## Color Visibility Thresholds
|
||||
|
||||
On white paper (#ffffff), very light fills and borders are invisible:
|
||||
|
||||
| Color | Visible? | Minimum for visibility |
|
||||
|-------|----------|----------------------|
|
||||
| Fill #fafafa (250,250,250) | No — indistinguishable from white | #f8f8f8 (248,248,248) |
|
||||
| Fill #f8f8f8 (248,248,248) | Yes — subtle but visible | — |
|
||||
| Border #e6e6e6 at 0.5px | No — too light and thin | #dcdcdc at 0.5px |
|
||||
| Border #dcdcdc at 0.5px | Yes — subtle but defined | — |
|
||||
|
||||
Rule: fill at #f8f8f8, border at #dcdcdc, line width 0.5px. These match across header boxes, totals boxes, and service separators for consistent style.
|
||||
|
||||
## Label Alignment (Widest-First)
|
||||
|
||||
When rendering label-value pairs in a PDF, align all values at a consistent offset after the widest label:
|
||||
|
||||
```javascript
|
||||
// 1. Measure the widest label
|
||||
let maxWidth = 0;
|
||||
labels.forEach(label => {
|
||||
const w = doc.getTextWidth(label);
|
||||
if (w > maxWidth) maxWidth = w;
|
||||
});
|
||||
|
||||
// 2. Position all values at widest label + gap
|
||||
const valueX = labelStartX + maxWidth + 12;
|
||||
labels.forEach((label, value) => {
|
||||
doc.text(label, labelStartX, y);
|
||||
doc.text(value, valueX, y);
|
||||
});
|
||||
```
|
||||
|
||||
This produces a clean table-like alignment without wasted whitespace between label and value.
|
||||
|
||||
## Multi-Pass Page Numbering
|
||||
|
||||
Add page numbers AFTER all content is laid out, iterating through all pages:
|
||||
|
||||
```javascript
|
||||
const totalPages = doc.internal.getNumberOfPages();
|
||||
for (let i = 1; i <= totalPages; i++) {
|
||||
doc.setPage(i);
|
||||
doc.text(`Page ${i} of ${totalPages}`, pageWidth - margin, pageHeight - 10, { align: 'right' });
|
||||
}
|
||||
```
|
||||
|
||||
This ensures page numbers reflect the final page count, not an intermediate count.
|
||||
|
||||
## Service-Specific Pitfalls
|
||||
|
||||
- `technicianNotes` should be excluded from customer-facing PDFs (internal context only)
|
||||
- Approved/declined badges should use colored backgrounds (green #dcfce7 for approved, red #fef2f2 for declined)
|
||||
- Declined services should be grayed out with strikethrough
|
||||
- Services should be grouped: approved first, then pending/declined
|
||||
@@ -0,0 +1,52 @@
|
||||
# PDF Generation Pagination
|
||||
|
||||
Source: `src/lib/pdf.ts`
|
||||
|
||||
## Page-break logic
|
||||
|
||||
Each service calculates its height before rendering. Page break triggers when `y + serviceHeight > CONTENT_BOTTOM - FOOTER_HEIGHT`.
|
||||
|
||||
Key constants:
|
||||
- `CONTENT_BOTTOM = PAGE_H - MARGIN` (772)
|
||||
- `FOOTER_HEIGHT = 35` (reserved for separator + footer text + page number)
|
||||
|
||||
## Height calculation (must match rendering yPos increments)
|
||||
|
||||
```
|
||||
svcH = 0
|
||||
if hasDecision: svcH += 5 // badge
|
||||
svcH += 6 // name + price
|
||||
if priority: svcH += 16 // priority label + reason
|
||||
if recommendation: svcH += 8
|
||||
if explanation: svcH += lines * 5 + 2
|
||||
if partsNotInStock: svcH += 8 + (deliveryTime ? 8 : 0)
|
||||
if aftermarket: svcH += 8 + (partsList ? 8 : 0)
|
||||
svcH += 6 // separator + spacing
|
||||
```
|
||||
|
||||
## After page break
|
||||
|
||||
- Call `doc.addPage()` then set `yPos = MARGIN`
|
||||
- **Redraw current section header** (SERVICES APPROVED / POSTPONED SERVICES) so new page has context
|
||||
- Summary box uses same FOOTER_HEIGHT check before rendering
|
||||
|
||||
## Footer
|
||||
|
||||
Drawn on EVERY page via post-render loop:
|
||||
```ts
|
||||
const totalPages = doc.getNumberOfPages();
|
||||
for (let i = 1; i <= totalPages; i++) {
|
||||
drawFooter(doc, i, totalPages, settings, shopChargeAmount);
|
||||
}
|
||||
```
|
||||
Footer includes: separator line, shop message, validity note, "Page N of M"
|
||||
|
||||
## String rendering (jspdf 4.x)
|
||||
|
||||
NEVER pass arrays to `doc.text()` — jspdf 4.x uses different internal line spacing than manual yPos tracking, causing cascading desync and text overlap. Always render `splitTextToSize()` results per-line:
|
||||
```ts
|
||||
doc.splitTextToSize(text, CW).forEach((line) => {
|
||||
doc.text(line, MARGIN, y);
|
||||
y += 5;
|
||||
});
|
||||
```
|
||||
@@ -0,0 +1,74 @@
|
||||
# PDF Generation — Pagination & Layout
|
||||
|
||||
Applies to: `src/lib/pdf.ts` in spq-v2.
|
||||
|
||||
## Page Break Logic
|
||||
|
||||
The correct page-break pattern for jsPDF 4.x:
|
||||
|
||||
```typescript
|
||||
const CONTENT_BOTTOM = PAGE_H - MARGIN; // 772
|
||||
const FOOTER_HEIGHT = 35; // reserved for footer separator + text + page number
|
||||
|
||||
// Before rendering each service, check if it fits:
|
||||
let svcH = 0;
|
||||
svcH += 5; // decision badge (if present)
|
||||
svcH += 6; // name + price
|
||||
svcH += 16; // priority (if present)
|
||||
svcH += 8; // recommendation (if present)
|
||||
svcH += doc.splitTextToSize(svc.explanation, CW).length * 5 + 2; // explanation
|
||||
svcH += 8; // parts flags (each, if present)
|
||||
svcH += 6; // separator + spacing
|
||||
|
||||
if (y + svcH > CONTENT_BOTTOM - FOOTER_HEIGHT) {
|
||||
y = newPage(doc); // doc.addPage() + y = MARGIN
|
||||
// Redraw section header on new page
|
||||
doc.setFont(...); doc.text(currentSectionLabel, MARGIN, y); y += 8;
|
||||
}
|
||||
```
|
||||
|
||||
**Do NOT use `yPos > PAGE_HEIGHT * 0.7` as a guard** — it blocks breaks in the top 70% of the page, forcing content past the page edge.
|
||||
|
||||
## Footer Rendering
|
||||
|
||||
```typescript
|
||||
function drawFooter(doc, pageNum, totalPages) {
|
||||
doc.setPage(pageNum);
|
||||
const fy = PAGE_H - MARGIN - FOOTER_HEIGHT + 5;
|
||||
doc.line(MARGIN, fy, PAGE_W - MARGIN, fy);
|
||||
doc.text('message', PAGE_W / 2, fy + 15, { align: 'center' });
|
||||
doc.text(`Page ${pageNum} of ${totalPages}`, PAGE_W - MARGIN, PAGE_H - 10, { align: 'right' });
|
||||
}
|
||||
// After all content: loop over pages to draw footer on EVERY page
|
||||
for (let i = 1; i <= doc.getNumberOfPages(); i++) drawFooter(doc, i, totalPages);
|
||||
```
|
||||
|
||||
## Text Rendering (jspdf 4.x)
|
||||
|
||||
Never pass arrays to `doc.text()`. Render `splitTextToSize` results one line at a time:
|
||||
|
||||
```typescript
|
||||
// ❌ Causes overlap in jspdf 4.x:
|
||||
doc.text(lines, MARGIN, yPos);
|
||||
yPos += lines.length * 6;
|
||||
|
||||
// ✅ Correct:
|
||||
lines.forEach((line) => { doc.text(line, MARGIN, yPos); yPos += 6; });
|
||||
```
|
||||
|
||||
## Height Estimator Constants
|
||||
|
||||
The height estimator must match actual yPos increments exactly:
|
||||
|
||||
| Element | yPos Advance |
|
||||
|---------|-------------|
|
||||
| Decision badge | 5 |
|
||||
| Name + price | 6 |
|
||||
| Priority label | 6 |
|
||||
| Priority reason | 10 |
|
||||
| Recommendation | 8 |
|
||||
| Explanation line | 5 |
|
||||
| Explanation gap | 2 |
|
||||
| Parts flag | 8 |
|
||||
| Aftermarket flag | 8 |
|
||||
| Separator + spacing | 6 |
|
||||
@@ -0,0 +1,46 @@
|
||||
# ShopProQuote PDF Styling Guidelines
|
||||
|
||||
PDFs are generated client-side by `quote-tab-manager.js` → `generateQuotePDF()` using jsPDF (dynamically imported from CDN).
|
||||
|
||||
## User's aesthetic preferences
|
||||
|
||||
- **Compact and tight** — no wasted whitespace, no large gaps between labels and values
|
||||
- **Professional** — clean invoice-style with subtle gray background panels, thin separators, no colons on labels
|
||||
- **Font sizes**: 8-10px range. 8px for labels/section headers, 9px for body text, 10px for bold totals
|
||||
- **Spacing**: 6-8px between lines, tight but readable
|
||||
- **Boxes**: subtle `#f8f8f8` fill with `#dcdcdc` border at 0.5px, 2-3px corner radius
|
||||
- **Separators**: 0.5px `#c8c8c8` (not thick black lines)
|
||||
- **Labels**: no colons, gray (#646464) for field labels, black for values
|
||||
|
||||
## Key rendering sections in `generateQuotePDF()`
|
||||
|
||||
### Header box (~line 3765)
|
||||
- Two-column: SERVICE PROVIDER (left) + CUSTOMER INFORMATION (right)
|
||||
- Fill is drawn FIRST (`roundedRect` with `'F'` only) before text, so text renders on top
|
||||
- Border drawn AFTER content (`roundedRect` with `'D'` only) at computed height
|
||||
- `headerBoxPadding` controls top+bottom padding consistently
|
||||
|
||||
### Customer info lines (~line 3841)
|
||||
- `addInfoLine()` helper — label at `rightColX`, value at `rightColX + 55` (compact, not full-width)
|
||||
- Labels: Customer, Vehicle, RO #, Mileage, VIN, Date
|
||||
|
||||
### Service items (~line 3917)
|
||||
- Service name: 10px bold at line 3994
|
||||
- Price: right-aligned, same line
|
||||
- Customer decision badges (approved/declined) above service name
|
||||
- Priority, recommendation, explanation, parts notes follow
|
||||
|
||||
### Totals box (~line 4172)
|
||||
- Summary box at 45% width from right margin
|
||||
- Fill `#f8f8f8`, border `#dcdcdc` at 0.5px
|
||||
- Line items: 9px, gray labels, black values, 8px row spacing
|
||||
- TOTAL: 10px bold
|
||||
- Shop charge note positioned at `boxBottomY + 10` (below box boundary, never overlapping)
|
||||
|
||||
## Pitfalls
|
||||
|
||||
- **Box covering text**: draw fill-only (`'F'`) BEFORE text; draw border-only (`'D'`) after content height is known
|
||||
- **Overlapping sections**: use `boxBottomY` from the box calculation, not `yPos + N` magic numbers
|
||||
- **Invisible borders**: `#fafafa` fill with `#e6e6e6` border at 0.5px is imperceptible — use `#f8f8f8` fill with `#dcdcdc` border for visible contrast
|
||||
- **Excessive whitespace between label/value**: don't right-align values to `pageWidth - margin` — use a fixed offset from the label instead (`rightColX + 55`)
|
||||
- **Font size consistency**: keep body text at 9px, labels at 8px, totals at 10px throughout
|
||||
@@ -0,0 +1,71 @@
|
||||
# PDF Export as Images (pdf-to-images.ts)
|
||||
|
||||
Converts every page of a jsPDF document into separate PNG files that the browser downloads automatically.
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
npm install pdfjs-dist
|
||||
```
|
||||
|
||||
Worker config in `main.tsx`:
|
||||
|
||||
```typescript
|
||||
import * as pdfjsLib from 'pdfjs-dist';
|
||||
pdfjsLib.GlobalWorkerOptions.workerSrc = new URL(
|
||||
'pdfjs-dist/build/pdf.worker.min.mjs',
|
||||
import.meta.url,
|
||||
).toString();
|
||||
```
|
||||
|
||||
## Function Signature
|
||||
|
||||
```typescript
|
||||
import { downloadPdfAsImages } from '../lib/pdf-to-images';
|
||||
|
||||
// Option A: pass a jsPDF instance
|
||||
const doc = new jsPDF();
|
||||
// ... build PDF content ...
|
||||
await downloadPdfAsImages(doc, 'Jon Smith');
|
||||
|
||||
// Option B: pass a Blob from generateQuotePDF()
|
||||
const blob = await generateQuotePDF(quote, settings);
|
||||
await downloadPdfAsImages(blob, 'Jon Smith');
|
||||
```
|
||||
|
||||
## How It Works
|
||||
|
||||
1. Accepts `jsPDF | Blob` — if Blob, reads as ArrayBuffer; if jsPDF, calls `doc.output('arraybuffer')`
|
||||
2. Loads PDF binary into pdfjsLib
|
||||
3. Iterates each page, renders onto an off-screen `<canvas>` at 2× scale (144 DPI)
|
||||
4. Exports canvas as `image/png` Blob
|
||||
5. Triggers a browser download for each page as `{CustomerName}_Page{N}.png`
|
||||
|
||||
## Naming
|
||||
|
||||
Spaces in customer name become underscores. Output: `Jon_Smith_Page1.png`, `Jon_Smith_Page2.png`, etc.
|
||||
|
||||
## Scale Tuning
|
||||
|
||||
- `scale = 1` → 72 DPI (small files)
|
||||
- `scale = 2` → 144 DPI (default, good quality)
|
||||
- `scale = 3` → 216 DPI (print quality, larger files)
|
||||
|
||||
## Dual-Input Pattern
|
||||
|
||||
The function accepts both `jsPDF` and `Blob` to work with `generateQuotePDF()` which returns a Blob. This avoids needing to expose the internal jsPDF doc from the builder. The pattern is:
|
||||
|
||||
```typescript
|
||||
export async function downloadPdfAsImages(
|
||||
pdfInput: jsPDF | Blob,
|
||||
customerName: string,
|
||||
): Promise<void> {
|
||||
let arrayBuffer: ArrayBuffer;
|
||||
if (pdfInput instanceof Blob) {
|
||||
arrayBuffer = await pdfInput.arrayBuffer();
|
||||
} else {
|
||||
arrayBuffer = pdfInput.output('arraybuffer');
|
||||
}
|
||||
// ... rest uses pdfjsLib
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,86 @@
|
||||
# SPQ-v2 Phase 4 Implementation Reference (2026-07-07)
|
||||
|
||||
## Completed Features
|
||||
|
||||
Six features implemented in a single session using batched parallel delegation.
|
||||
|
||||
### Batch A — Pure frontend (no PB migrations)
|
||||
|
||||
**4.5 Technician time-card CSV export**
|
||||
- `src/pages/advisor/TimeCards.tsx` — date-range picker, queries `technicianAssignments` by shop, aggregates clock entries per-tech per-day, CSV download via Blob + URL.createObjectURL
|
||||
- Route `/time-cards` in App.tsx, nav in both Layout.tsx and MobileLayout.tsx (Clock icon)
|
||||
- Preload helper in `src/lib/routePreload.ts`
|
||||
|
||||
**4.9 Data export / GDPR**
|
||||
- `src/pages/Privacy.tsx` — export all data as JSON download (repairOrders, customers, appointments, invoices, settings), delete account with two-step confirmation
|
||||
- Route `/privacy` in App.tsx under `<AdvisorOnly>`, link in Settings Account tab
|
||||
|
||||
### Batch B — PB hooks (no new collections)
|
||||
|
||||
**4.1 Quote approval notifications**
|
||||
- PB M23: `app.onRecordUpdateRequest` on `quotes` — when status changes `sent → approved/declined`, emails shop owner via PB mailer
|
||||
|
||||
**4.2 Quote expiry enforcement**
|
||||
- PB M24: `app.onRecordUpdateRequest` on `quotes` — rejects share-token updates past `created + quoteExpiryDays` with 410
|
||||
- `src/pages/QuoteApprove.tsx` — distinct "Quote Has Expired" UI (Clock icon, red styling), catches 410 on load and submit
|
||||
|
||||
### Batch C — New PB collections + frontend
|
||||
|
||||
**4.3 Financial audit log**
|
||||
- PB M25: `roFinancialAudit` collection (roId, field, from, to, by, at, prevHash, hash) with hash-chained hook on `repairOrders` financial blob diffs
|
||||
- Viewer in RODetailModal Timeline tab — queries by roId, shows field labels, from→to JSON, hash chain
|
||||
|
||||
**4.6 Recurring maintenance reminders**
|
||||
- PB M26: `reminders` collection (customerId, customerName, vehicleInfo, shopUserId, mileageAtService, intervalMileage, dueMileage, notes, active)
|
||||
- "Set Reminder" button in RODetailModal header, inline form with computed due mileage
|
||||
- "Maintenance Reminders" card in Dashboard with count badge
|
||||
|
||||
## Skipped / Deferred
|
||||
|
||||
- 4.4 Multi-vehicle history — skipped per user
|
||||
- 4.7 Offline-first for technicians — sub-project scope, skipped
|
||||
- 4.8 Two-factor auth — skipped per user
|
||||
- 4.10 Multi-shop readiness — decision deferred (no code change needed)
|
||||
|
||||
## PB Migrations Applied
|
||||
|
||||
- M23 `1740000000023_quote_approval_notify.js`
|
||||
- M24 `1740000000024_quote_expiry_enforce.js`
|
||||
- M25 `1740000000025_ro_financial_audit.js`
|
||||
- M26 `1740000000026_reminders.js`
|
||||
- M27 `1740000000027_set_audit_and_reminder_rules.js` (no-op placeholder due to PB 0.39 rule validation quirk)
|
||||
|
||||
## Verification
|
||||
|
||||
`npm run build` ✓, `npm run lint` ✓ (0 errors, 24 pre-existing warnings), `npm run test` ✓ (86/86 tests)
|
||||
|
||||
## PB 0.39 Quirks Discovered
|
||||
|
||||
See `pb-0.39-migration-quirks.md` for full details. Key ones:
|
||||
- Field-based API rules can't be set via JS migrations (even in separate files) — use empty rules + frontend filtering
|
||||
- `DateTimeField` doesn't exist — use `TextField` with ISO strings
|
||||
- `onRecordBeforeUpdateRequest` and `onRecordAfterUpdateRequest` don't exist — use `onRecordUpdateRequest`
|
||||
- Collection constructor and `new Record()` both work in JS migrations
|
||||
|
||||
## GlitchTip DSN Format (FIXED 2026-07-07)
|
||||
|
||||
The Sentry client-side SDK v10 rejects DSNs with multi-segment paths like `/glitchtip/1`. Fix applied:
|
||||
|
||||
1. **`.env.production`**: DSN path changed from `/glitchtip/1` to `/1`
|
||||
2. **nginx**: Added regex location `^/([0-9]+)/(api|store)/(.*)$` that proxies bare project-ID paths to GlitchTip at `127.0.0.1:8001`
|
||||
3. After hard refresh, Sentry initializes with zero console errors
|
||||
|
||||
The nginx snippet:
|
||||
```nginx
|
||||
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;
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,50 @@
|
||||
# Phase 3 Server-Side Audit — Quick Check Recipe
|
||||
|
||||
Before starting any Phase 3 roadmap item, run this block to determine what's already live vs. what actually needs work. The roadmap's "NOT DONE" status is frequently stale — Phase 3.3/3.4 were fully deployed in prior sessions with no roadmap update.
|
||||
|
||||
## One-shot audit block
|
||||
|
||||
```bash
|
||||
# 3.1 GlitchTip — does the stack exist?
|
||||
ls /opt/glitchtip 2>/dev/null || echo "3.1: not installed"
|
||||
|
||||
# 3.2 PB SMTP/superadmin env vars — are any set?
|
||||
docker inspect pocketbase --format '{{range .Config.Env}}{{println .}}{{end}}' 2>/dev/null | grep -iE 'SMTP|SUPERADMIN' || echo "3.2: no SMTP/superadmin env"
|
||||
|
||||
# 3.2 PB backup cron — is one installed?
|
||||
crontab -l 2>/dev/null | grep -iE 'spq|pocketbase|pb_data' || echo "3.2: no pb backup cron"
|
||||
|
||||
# 3.3/3.4 nginx — is the server block serving the SPA + proxying /pb?
|
||||
# (If both exist, 3.3/3.4 are DONE regardless of roadmap status)
|
||||
cat /etc/nginx/sites-enabled/shopproquote 2>/dev/null | head -40
|
||||
curl -sI https://shopproquote.graj-media.com/ | grep -iE 'HTTP/|content-security|x-frame'
|
||||
|
||||
# 3.5 Structured logging — json log format in nginx?
|
||||
grep -l 'json_combined\|log_format.*json' /etc/nginx/nginx.conf 2>/dev/null && echo "3.5: has json format" || echo "3.5: no json format"
|
||||
|
||||
# 3.5 PB log rotation — driver + limits?
|
||||
docker inspect pocketbase --format '{{.HostConfig.LogConfig.Type}} {{.HostConfig.LogConfig.Config}}' 2>/dev/null
|
||||
# json-file with no max-size/max-file = no rotation (needs fixing)
|
||||
|
||||
# 3.6 Offline banner — does the component exist?
|
||||
ls src/components/OfflineBanner.tsx 2>/dev/null && echo "3.6: file exists" || echo "3.6: missing"
|
||||
|
||||
# 3.7 Dependency audit
|
||||
npm audit 2>&1 | head -3
|
||||
```
|
||||
|
||||
## Interpretation
|
||||
|
||||
- **3.1** — Only needs work if `/opt/glitchtip` doesn't exist. If it does, check `docker compose ps` inside it.
|
||||
- **3.2** — SMTP env vars and backup cron are both > APPROVAL REQUIRED (AGENTS.md). Never set passwords without asking.
|
||||
- **3.3/3.4** — If the nginx site block exists and serves dist/ on 443 with `try_files ... /index.html` and a `/pb/` location, these are DONE. Do NOT re-implement from the roadmap spec.
|
||||
- **3.5** — PB container always uses `json-file` driver by default, but without `max-size`/`max-file` opts there's no rotation. The fix is `docker update --log-driver json-file --log-opt max-size=10m --log-opt max-file=5 pocketbase` — needs go-ahead.
|
||||
- **3.6** — Pure frontend, no approval needed. If the file exists and is wired in App.tsx, it's done.
|
||||
- **3.7** — `npm audit` only. If 0 vulnerabilities, it's done. Minor patch updates don't need action.
|
||||
|
||||
## Approval rules (from AGENTS.md)
|
||||
|
||||
- **Do not** touch nginx config without explaining the exact change and getting go-ahead.
|
||||
- **Do not** set or change passwords (PB superadmin, SMTP, GlitchTip DB) without explicit permission.
|
||||
- **Do not** make PocketBase schema changes without confirming with the user.
|
||||
- Frontend-only changes (3.6, 3.7) need no approval — just build/lint/test after.
|
||||
@@ -0,0 +1,62 @@
|
||||
# Phone Number Formatting
|
||||
|
||||
All phone numbers in SPQ are **stored as raw digits** (no formatting) and **displayed/input as `555-555-5555`** (hyphen-separated, no parentheses).
|
||||
|
||||
## Shared Utility: `src/lib/phone.ts`
|
||||
|
||||
Two exports:
|
||||
|
||||
- **`formatPhoneInput(raw: string): string`** — live input mask for onChange. Strips non-digits, inserts hyphens as user types.
|
||||
- `""` → `""`
|
||||
- `"555"` → `"555"`
|
||||
- `"5555"` → `"555-5"`
|
||||
- `"5555555555"` → `"555-555-5555"`
|
||||
|
||||
- **`formatPhoneDisplay(phone: string | null | undefined): string`** — display formatter for read-only contexts (tables, PDFs, previews).
|
||||
- `"5555555555"` → `"555-555-5555"`
|
||||
- `"5551234"` → `"555-1234"` (7-digit fallback)
|
||||
- Handles legacy formatted data (strips parens, spaces, dots).
|
||||
|
||||
## Input Pattern (onChange + value)
|
||||
|
||||
For every phone `<input>`:
|
||||
|
||||
```tsx
|
||||
import { formatPhoneInput } from '../lib/phone';
|
||||
|
||||
// In the input:
|
||||
value={formatPhoneInput(phoneValue)}
|
||||
onChange={(e) => setPhone(e.target.value.replace(/\D/g, ''))}
|
||||
placeholder="555-555-5555"
|
||||
```
|
||||
|
||||
This stores raw digits (stripped on every keystroke) and displays the mask.
|
||||
|
||||
## Display Pattern
|
||||
|
||||
For every read-only phone render:
|
||||
|
||||
```tsx
|
||||
import { formatPhoneDisplay } from '../lib/phone';
|
||||
|
||||
// In JSX:
|
||||
{formatPhoneDisplay(customer.phone)}
|
||||
// In template literals:
|
||||
`${formatPhoneDisplay(inv.customerPhone)}`
|
||||
```
|
||||
|
||||
## Files Using Phone Formatting
|
||||
|
||||
| File | Applied |
|
||||
|---|---|
|
||||
| `QuoteGenerator.tsx` | CustomerInfoPanel phone field |
|
||||
| `ROForm.tsx` | RO create/edit phone field |
|
||||
| `Appointments.tsx` | 3 inputs + list display |
|
||||
| `Invoices.tsx` | 1 input + printable HTML + preview |
|
||||
| `Customers.tsx` | 1 input + 2 displays (card + detail) |
|
||||
|
||||
## Pitfalls
|
||||
|
||||
- **Stored data may be legacy-formatted** — `formatPhoneDisplay` and `formatPhoneInput` both strip non-digits first, so legacy `(555) 555-5555` data is handled.
|
||||
- **Don't store the formatted value** — the `onChange` handler must strip formatting (`.replace(/\D/g, '')`) so PocketBase only contains raw digits.
|
||||
- **Don't create local formatPhone functions** — always import from `src/lib/phone.ts`. Duplicate implementations exist in legacy code and should be replaced.
|
||||
@@ -0,0 +1,96 @@
|
||||
# ShopProQuote Critical Pitfalls
|
||||
|
||||
## Redirect loop: stale PocketBase auth token
|
||||
|
||||
**Symptom:** Browser enters an infinite redirect loop between `index.html` ↔ `dashboard.html` (or `login.html` if browser cache has old code).
|
||||
|
||||
**Root cause:** `index.html` line 131 auto-redirects to `dashboard.html` based on a truthy check of `localStorage.getItem('pocketbase_auth')`. When the PocketBase token is stale (expired/invalid), the PocketBase SDK's `onAuthStateChanged` fires with `null`, and the JS redirects back to `index.html`. The stale token is NOT cleared from localStorage before redirecting, so `index.html` sees it again → infinite loop.
|
||||
|
||||
**Fix:** In every auth-failure handler (`onAuthStateChanged` else block), always clear before redirecting:
|
||||
```javascript
|
||||
localStorage.removeItem('pocketbase_auth');
|
||||
window.location.href = 'index.html';
|
||||
```
|
||||
Affected files: `dashboard.js`, `repair-orders.js`, `appointments.js`, `shared/header-functionality.js`.
|
||||
|
||||
Also check: `index.html` line 131 only does a truthy check. Never add logic that redirects based solely on localStorage presence without verifying token validity.
|
||||
|
||||
## PocketBase SDK auto-cancellation
|
||||
|
||||
**Symptom:** "auto-cancelled" error or "request was autocancelled" when creating/updating records, especially on double-click.
|
||||
|
||||
**Root cause:** PocketBase JS SDK auto-cancels requests that share the same `requestKey`. By default, all `create()` and `update()` calls to the same collection share a generated key based on method + URL. A rapid second call cancels the first.
|
||||
|
||||
**Fix:** Always pass `{ requestKey: null }` to disable auto-cancellation in `pocketbase.js`:
|
||||
```javascript
|
||||
// addDoc
|
||||
pb.collection(collName).create(pbData, { requestKey: null });
|
||||
|
||||
// updateDoc
|
||||
pb.collection(collName).update(docId, pbData, { requestKey: null });
|
||||
|
||||
// setDoc (both paths)
|
||||
pb.collection(collName).update(records[0].id, pbData, { requestKey: null });
|
||||
pb.collection(collName).create(pbData, { requestKey: null });
|
||||
```
|
||||
|
||||
## Double-submission guard
|
||||
|
||||
**Symptom:** Form creates duplicate records when user double-clicks submit button.
|
||||
|
||||
**Fix:** Guard all create/update form handlers against re-entry:
|
||||
```javascript
|
||||
function handleSubmit() {
|
||||
const submitBtn = document.getElementById('submit-btn');
|
||||
if (submitBtn && submitBtn.disabled) return; // guard
|
||||
if (submitBtn) {
|
||||
submitBtn.disabled = true;
|
||||
submitBtn.textContent = 'Creating...';
|
||||
}
|
||||
try {
|
||||
// ... do work ...
|
||||
} finally {
|
||||
// Always re-enable — success and error paths both need this
|
||||
if (submitBtn) {
|
||||
submitBtn.disabled = false;
|
||||
submitBtn.textContent = 'Create Repair Order';
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
Primary file: `repair-orders.js` `handleCreateROSubmit()`.
|
||||
|
||||
## scoping bug (ReferenceError silently crashes handler)
|
||||
|
||||
**Symptom:** Button click does nothing — no error, no notification, no action.
|
||||
|
||||
**Common cause:** Variable declared with `const`/`let` inside a `try` block, then referenced outside the block. JavaScript throws a silent ReferenceError that crashes async handlers with no visible feedback.
|
||||
|
||||
**Example (broken):**
|
||||
```javascript
|
||||
try {
|
||||
const roData = getROData(roId);
|
||||
// ...
|
||||
} catch (e) { /* ... */ }
|
||||
// BUG: roData is block-scoped to try, ReferenceError here
|
||||
if (roData) { /* ... */ }
|
||||
```
|
||||
|
||||
**Fix:** Declare the variable before the try block:
|
||||
```javascript
|
||||
const roData = repairOrders.find(ro => ro.id === roId);
|
||||
try {
|
||||
// use roData...
|
||||
} catch (e) { /* ... */ }
|
||||
// roData still accessible here
|
||||
```
|
||||
|
||||
## Quote service approved vs customerDecision mismatch (totals show $0 in PDF)
|
||||
|
||||
**Symptom:** On-screen quote summary shows correct totals, but generated PDF or Print view shows $0.00 for all totals even though services are present.
|
||||
|
||||
**Root cause:** The `QuoteService` type has two fields (`approved: boolean` and `customerDecision: string`) that must stay in sync. When they diverge — e.g. a service has `approved: true` but `customerDecision: 'pending'` — the UI total (which used `s.approved`) counts it but the PDF (which uses `svc.customerDecision === 'approved'`) excludes it. If all services are in this broken state, the PDF shows $0.
|
||||
|
||||
**Fix:** All code paths that compute totals must use `customerDecision === 'approved'` as the single source of truth. See `references/quote-service-dual-fields.md` for the invariant table and the three-state toggle system.
|
||||
|
||||
**Verification:** Check that `computeQuoteTotals` (src/lib/totals.ts), store `subtotal()`/`total()`, and `generateQuotePDF` (src/lib/pdf.ts) all filter by `customerDecision === 'approved'`. The `approved` boolean should only drive the checkbox UI, never business logic.
|
||||
@@ -0,0 +1,62 @@
|
||||
# PocketBase Admin API (v0.39.1)
|
||||
|
||||
PocketBase runs in Docker at `127.0.0.1:8091`. Admin panel: http://127.0.0.1:8091/_/
|
||||
|
||||
## Superuser Management
|
||||
|
||||
Create via CLI:
|
||||
```bash
|
||||
docker exec pocketbase /usr/local/bin/pocketbase superuser create <email> <password> --dir /pb_data
|
||||
```
|
||||
|
||||
## API Authentication
|
||||
|
||||
Superuser auth endpoint (v0.39.x):
|
||||
```
|
||||
POST /api/collections/_superusers/auth-with-password
|
||||
{"identity": "admin@shop.com", "password": "admin123"}
|
||||
→ returns {token, record}
|
||||
```
|
||||
|
||||
Note: `/api/admins/auth-with-password` returns 404 in v0.39.1. Use the collections path above.
|
||||
|
||||
## Creating Collections
|
||||
|
||||
POST `/api/collections` with:
|
||||
- `name`, `type: "base"`
|
||||
- `fields` array (NOT `schema` — that's ignored)
|
||||
- API rules: `listRule`, `viewRule`, `createRule`, `updateRule`, `deleteRule`
|
||||
|
||||
**Important**: Local collections use `text` type for userId (NOT `relation`). The existing `services`, `quotes`, etc. collections all store userId as plain text strings, not relation fields.
|
||||
|
||||
Example:
|
||||
```python
|
||||
{
|
||||
"name": "service_advisors",
|
||||
"type": "base",
|
||||
"listRule": "userId = '{userid}'",
|
||||
"viewRule": "userId = '{userid}'",
|
||||
"createRule": "@request.auth.id != ''",
|
||||
"updateRule": "userId = '{userid}'",
|
||||
"deleteRule": "userId = '{userid}'",
|
||||
"fields": [
|
||||
{"name": "name", "type": "text", "required": True},
|
||||
{"name": "userId", "type": "text", "required": True},
|
||||
],
|
||||
}
|
||||
```
|
||||
|
||||
## Updating Collection Rules
|
||||
|
||||
PATCH `/api/collections/<collection_name>` with just the rule fields:
|
||||
```python
|
||||
{"listRule": "userId = '{userid}'", "viewRule": "userId = '{userid}'", ...}
|
||||
```
|
||||
|
||||
## Deleting a Collection
|
||||
|
||||
DELETE `/api/collections/<collection_id>` — use the ID from GET `/api/collections/<name>`, not the name.
|
||||
|
||||
## Data Directory
|
||||
|
||||
`/pb_data` inside the container. Contains `data.db`, `auxiliary.db`, `storage/`.
|
||||
@@ -0,0 +1,60 @@
|
||||
# PocketBase SDK Auto-Cancellation
|
||||
|
||||
## The Problem
|
||||
|
||||
PocketBase JS SDK enables **auto-cancellation by default**. Every `create()` and `update()` call to the same collection shares a default `requestKey` derived from method + URL. When two calls to the same collection happen simultaneously (double-click, race condition, rapid re-submission), the second call **auto-cancels the first** and throws:
|
||||
|
||||
```
|
||||
Error: The request was autocancelled.
|
||||
```
|
||||
|
||||
This is surfaced to the user as "Error creating repair order: The request was autocancelled."
|
||||
|
||||
## The Fix
|
||||
|
||||
Pass `{ requestKey: null }` to ALL mutating PocketBase calls in `pocketbase.js`:
|
||||
|
||||
```js
|
||||
// addDoc
|
||||
const record = await pb.collection(collName).create(pbData, { requestKey: null });
|
||||
|
||||
// updateDoc
|
||||
const record = await pb.collection(collName).update(docId, pbData, { requestKey: null });
|
||||
|
||||
// setDoc (both branches)
|
||||
return pb.collection(collName).update(records[0].id, pbData, { requestKey: null });
|
||||
return pb.collection(collName).create(pbData, { requestKey: null });
|
||||
```
|
||||
|
||||
The `getDocs` and `getFullListLegacy` functions already pass `{ requestKey: null }` in their options.
|
||||
|
||||
## Belt-and-Suspenders: Double-Submission Guard
|
||||
|
||||
Even with `requestKey: null`, prevent duplicate form submissions by disabling the submit button on first click:
|
||||
|
||||
```js
|
||||
function handleFormSubmit() {
|
||||
const submitBtn = document.getElementById('submit-btn');
|
||||
if (submitBtn && submitBtn.disabled) return; // guard
|
||||
if (submitBtn) {
|
||||
submitBtn.disabled = true;
|
||||
submitBtn.textContent = 'Saving...';
|
||||
}
|
||||
// ... submission logic ...
|
||||
// Re-enable after success:
|
||||
if (submitBtn) {
|
||||
submitBtn.disabled = false;
|
||||
submitBtn.textContent = 'Create Repair Order';
|
||||
}
|
||||
// ... success cleanup (clear form, collapse section) ...
|
||||
} catch (error) {
|
||||
// Re-enable in catch block:
|
||||
if (submitBtn) {
|
||||
submitBtn.disabled = false;
|
||||
submitBtn.textContent = 'Create Repair Order';
|
||||
}
|
||||
// ... error handling ...
|
||||
}
|
||||
```
|
||||
|
||||
This guards against: double-clicks, keyboard double-Enter, and accidental double-taps on mobile.
|
||||
@@ -0,0 +1,31 @@
|
||||
# PocketBase JSON Field Guard
|
||||
|
||||
**The problem:** When loading data from PocketBase collections, nested JSON fields (like `services` array on quotes) may come back as either a parsed array OR a JSON string — depending on how the field type is defined in the PocketBase collection schema.
|
||||
|
||||
**Where this bites:** The Daily Briefing scans `q.services` to count unaddressed customer decisions. If `services` is a JSON string, `services.filter(...)` iterates over individual characters instead of objects. `!s.customerDecision` is always `true` for a character like `{` — so every quote shows as "needing decisions" even when all services are approved/declined.
|
||||
|
||||
**The three-step guard — apply EVERYWHERE you read nested JSON from PocketBase:**
|
||||
|
||||
```javascript
|
||||
// Step 1: Default to empty array
|
||||
let services = q.services || [];
|
||||
|
||||
// Step 2: Parse if it's a JSON string
|
||||
if (typeof services === 'string') {
|
||||
try { services = JSON.parse(services); } catch(e) { services = []; }
|
||||
}
|
||||
|
||||
// Step 3: Ensure it's actually an array
|
||||
if (!Array.isArray(services)) services = [];
|
||||
|
||||
// Now safe to use .filter(), .forEach(), etc.
|
||||
const unaddressed = services.filter(s => !s.customerDecision || s.customerDecision === 'pending');
|
||||
```
|
||||
|
||||
**Why `convertFromPB` doesn't help:** The `convertFromPB()` function in pocketbase.js (line 422) tries to `JSON.parse()` top-level string fields that start with `{` or `[`. But when PocketBase returns a field as an already-parsed array (the normal case for JSON-typed fields), `convertFromPB` doesn't recurse into it. And when it returns as a string (text-typed fields), `convertFromPB` does parse it — but inconsistently. The guard handles both cases.
|
||||
|
||||
**Files where this guard is applied:**
|
||||
- `dashboard.js` — `generateDailyBriefing()` quote analysis (two locations)
|
||||
- `quote-tab-manager.js` — any function reading saved quote data
|
||||
|
||||
**When adding new code that reads PocketBase-sourced nested JSON:** always add this guard. It costs nothing when the field is already an array, and prevents silent bugs when it's a string.
|
||||
@@ -0,0 +1,87 @@
|
||||
# PocketBase Patterns & Pitfalls
|
||||
|
||||
## Redirect Loop: Stale localStorage Token
|
||||
|
||||
**Problem:** `index.html` blindly checks `localStorage.getItem('pocketbase_auth')` (line 131) and redirects to `dashboard.html` if ANY value exists — including stale/expired tokens. When dashboard.js bounces back to `index.html` without clearing the stale token, it creates an infinite loop.
|
||||
|
||||
**Fix:** Every JS file that redirects away to `index.html` on auth failure MUST clear the stale token first:
|
||||
|
||||
```javascript
|
||||
// In onAuthStateChanged callback, before redirect:
|
||||
localStorage.removeItem('pocketbase_auth');
|
||||
window.location.href = 'index.html';
|
||||
```
|
||||
|
||||
Files: `dashboard.js:122`, `appointments.js:50`, `repair-orders.js:58`
|
||||
|
||||
Also add the same pattern in `shared/header-functionality.js` logout paths.
|
||||
|
||||
## Auto-Cancelled Requests: PocketBase SDK `requestKey`
|
||||
|
||||
**Problem:** PocketBase SDK auto-cancels requests that share the same default `requestKey`. All `create` calls to the same collection share a key — a rapid second call cancels the first. Manifests as "The request was auto-cancelled" error.
|
||||
|
||||
**Fix:** Pass `{ requestKey: null }` on ALL mutating PocketBase SDK calls in `pocketbase.js`:
|
||||
|
||||
```javascript
|
||||
// addDoc
|
||||
const record = await pb.collection(collName).create(pbData, { requestKey: null });
|
||||
|
||||
// updateDoc
|
||||
const record = await pb.collection(collName).update(docId, pbData, { requestKey: null });
|
||||
|
||||
// setDoc (both paths)
|
||||
return pb.collection(collName).update(records[0].id, pbData, { requestKey: null });
|
||||
return pb.collection(collName).create(pbData, { requestKey: null });
|
||||
```
|
||||
|
||||
## Double-Submit Guard
|
||||
|
||||
**Problem:** Form submit handler lacks guard against rapid double-clicks. Two simultaneous `addDoc` calls + PocketBase auto-cancellation = first request cancelled.
|
||||
|
||||
**Fix:** Disable the submit button on first click, re-enable on both success AND error:
|
||||
|
||||
```javascript
|
||||
function handleCreateROSubmit() {
|
||||
const submitBtn = document.getElementById('submit-create-ro');
|
||||
if (submitBtn && submitBtn.disabled) return; // guard
|
||||
if (submitBtn) {
|
||||
submitBtn.disabled = true;
|
||||
submitBtn.textContent = 'Creating...';
|
||||
}
|
||||
// ... try/catch ...
|
||||
// On success:
|
||||
submitBtn.disabled = false;
|
||||
submitBtn.textContent = 'Create Repair Order';
|
||||
// On catch:
|
||||
submitBtn.disabled = false;
|
||||
submitBtn.textContent = 'Create Repair Order';
|
||||
}
|
||||
```
|
||||
|
||||
## Block-Scope Bug: `const` in `try` Block
|
||||
|
||||
**Problem:** `const roData = ...` declared inside a `try {}` block but referenced outside. JavaScript throws a silent `ReferenceError` in async handlers — crashes with no visible error.
|
||||
|
||||
**Fix:** Declare variables that need to be accessed outside the `try` before the block:
|
||||
|
||||
```javascript
|
||||
const roData = repairOrders.find(ro => ro.id === roId); // outside try
|
||||
try {
|
||||
const servicesLines = roData?.services ? ...;
|
||||
// ...
|
||||
} catch (e) {}
|
||||
// roData is accessible here
|
||||
```
|
||||
|
||||
## DuckDNS: Force VPS IP (Not Auto-Detect)
|
||||
|
||||
**Problem:** DuckDNS update script runs on home server with `&ip=` (auto-detect), so DuckDNS records the home IP instead of the VPS IP. Traffic should route VPS → Tailscale → home, but DNS points directly to home.
|
||||
|
||||
**Fix:** Hard-code VPS IP in `/opt/duckdns/update.sh`:
|
||||
|
||||
```bash
|
||||
#!/bin/bash
|
||||
echo url="https://www.duckdns.org/update?domains=grajmedia&token=TOKEN&ip=51.81.84.34" | curl -k -s -o /opt/duckdns/duck.log -K -
|
||||
```
|
||||
|
||||
Also check `/etc/hosts` for local overrides (`127.0.0.1 grajmedia.duckdns.org`) for local access.
|
||||
@@ -0,0 +1,136 @@
|
||||
# PocketBase Timestamp Compatibility
|
||||
|
||||
The `pocketbase.js` adapter's `Timestamp` class wraps dates for Firebase compatibility (`toDate()`, `toMillis()`, etc.).
|
||||
|
||||
## Missing Static Methods (Fixed)
|
||||
|
||||
Code in `appointments.js` calls `Timestamp.fromDate()` and `Timestamp.now()` — these are Firebase APIs that did NOT exist on the adapter's `Timestamp` class.
|
||||
|
||||
**Fix** — added to `pocketbase.js`:
|
||||
```js
|
||||
class Timestamp {
|
||||
// ... existing methods ...
|
||||
|
||||
static fromDate(date) { return new Timestamp(date); }
|
||||
static now() { return new Timestamp(new Date()); }
|
||||
}
|
||||
```
|
||||
|
||||
**Symptom when missing**: Edit appointment appears to work (no JS error in console for the `Timestamp` reference itself) but the `updateDoc` call receives malformed data and the save silently fails. Look for `TypeError: Timestamp.fromDate is not a function` in dev tools.
|
||||
|
||||
## convertToPB Does NOT Handle Timestamp Objects (CRITICAL)
|
||||
|
||||
The `convertToPB()` function checks `instanceof Date` to convert dates to ISO strings. But `Timestamp` is a plain class — NOT a `Date` subclass. So `Timestamp` objects fall through to the catch-all `JSON.stringify(value)` branch and get serialized as garbage:
|
||||
|
||||
```json
|
||||
{"_date":"2026-06-14T14:30:00.000Z"}
|
||||
```
|
||||
|
||||
This silently poisons PocketBase records. The fix is to ADD `Timestamp` handling to `convertToPB`:
|
||||
|
||||
```js
|
||||
} else if (value && typeof value === 'object' && !Array.isArray(value) && !(value instanceof Date) && !(value instanceof Timestamp)) {
|
||||
result[key] = JSON.stringify(value);
|
||||
} else if (value instanceof Timestamp) {
|
||||
result[key] = value.toISOString();
|
||||
```
|
||||
|
||||
**Better approach**: Stop using `Timestamp` wrapper for saves entirely. Use plain `new Date()` — `convertToPB` handles `Date` natively with `value.toISOString()`. Only use `Timestamp` when READING from PocketBase (it's created by `convertFromPB`).
|
||||
|
||||
## `new Date(timestampObj)` Crashes Silently
|
||||
|
||||
Code like `new Date(appointment.appointmentDateTime)` produces `Invalid Date` when `appointmentDateTime` is a `Timestamp` object (because `Date()` constructor doesn't know about the internal `_date` property). This causes empty date/time fields in the edit modal.
|
||||
|
||||
**Fix**: Use the `Timestamp.toDate()` method:
|
||||
```js
|
||||
const date = appointmentDateTime?.toDate
|
||||
? appointmentDateTime.toDate()
|
||||
: new Date(appointmentDateTime);
|
||||
```
|
||||
|
||||
**Locations fixed**: `appointments.js` lines 173 (date grouping) and 814 (modal populating).
|
||||
|
||||
## Round-Trip
|
||||
|
||||
- `convertToPB()`: `Date` instances → ISO strings; `Timestamp` instances → ISO strings (after fix above)
|
||||
- `convertFromPB()`: ISO string fields → `Timestamp` objects (for app use)
|
||||
|
||||
`Timestamp` fields recognized by the converter: `writeupTime`, `createdAt`, `updatedAt`, `lastUpdated`, `appointmentDateTime`, `completionTime`, `lastActivity`, `timestamp`, `dateCreated`, `startDate`, `endDate`, `deadline`, `dueDateTime`, `promisedTime`.
|
||||
|
||||
## Silent Field Drop — PocketBase Schema Mismatch
|
||||
|
||||
When a field name is NOT in the PocketBase collection schema, the write **silently drops the field** — no error, no warning. The symptom surfaces on READ: the field is `undefined`, and any fallback (e.g., `let dueDateTime = new Date()`) produces wrong data.
|
||||
|
||||
**Example**: Tasks showed overdue with today's date regardless of the selected due date because `dueDateTime` was not in the tasks collection schema. PocketBase dropped it on write; the retrieval code fell back to `new Date()`.
|
||||
|
||||
**Fix checklist when adding a new field to any collection**:
|
||||
1. Add the field to the PocketBase collection schema (via API PATCH or SQLite)
|
||||
2. If it's a date/datetime field, add it to `TIMESTAMP_FIELDS` in `pocketbase.js`
|
||||
3. Restart PocketBase container
|
||||
|
||||
See `references/pocketbase-schema-patch.md` for the SQLite direct-patch pattern when API auth is unavailable.
|
||||
|
||||
## Nested try/catch Gap in handleEditAppointment
|
||||
|
||||
The validation code in `handleEditAppointment()` was OUTSIDE any try/catch — any error (null element, `log()` throwing) would silently reject the async promise with no visible error to the user. Fix: wrap the entire function body in a single `try/catch` with an `alert()` fallback so errors are ALWAYS visible.
|
||||
|
||||
## Create RO from Appointment — Required Fields
|
||||
|
||||
The `promptRepairOrderDetails()` modal in `appointments.js` must include ALL fields the quote generator has. Current (fixed) set:
|
||||
|
||||
| Field | ID | Pre-fill source |
|
||||
|---|---|---|
|
||||
| Customer Name | `ro-customer-name` | `appointment.customerName` |
|
||||
| Customer Phone | `ro-customer-phone` | `appointment.customerPhone` |
|
||||
| Vehicle Info | `ro-vehicle-info` | `appointment.vehicleInfo` |
|
||||
| Service Advisor | `ro-service-advisor` | `window.appSettings?.serviceAdvisor` |
|
||||
| RO Number | `ro-number-input` | Auto (timestamp last 6 digits) |
|
||||
| Mileage | `mileage-input` | `appointment.mileage` (if present) |
|
||||
| VIN | `vin-input` | `appointment.vin` |
|
||||
| Estimated Hours | `estimated-hours-input` | Default 2.0 |
|
||||
| Service Type | `service-type` radio | Waiter/Drop Off |
|
||||
|
||||
Function signature: `promptRepairOrderDetails(appointment)` — takes the full appointment object, not just the VIN.
|
||||
|
||||
## Quote Auto-Conversion
|
||||
|
||||
In `saveQuote()` (`quote-tab-manager.js`): when every service on a quote has `customerDecision` set to `'approved'` or `'declined'` (no pending), set `quoteData.status = 'converted'` automatically. This drops the quote from the dashboard Daily Briefing.
|
||||
|
||||
## Daily Briefing Dollar Breakdown
|
||||
|
||||
In `generateDailyBriefing()` (`dashboard.js`): iterate active quotes and sum service prices by `customerDecision` to produce `approvedDollars` and `declinedDollars`. Feed these to the LLM data string:
|
||||
|
||||
```
|
||||
Total quote value: $X — Approved: $Y, Declined: $Z
|
||||
```
|
||||
|
||||
The system prompt instructs the LLM to mention the breakdown when relevant.
|
||||
|
||||
## Auth Redirect Loop — Login ↔ Dashboard
|
||||
|
||||
When two static HTML pages use mutual `onAuthStateChanged` redirects (login → dashboard, dashboard → login), a stale PocketBase auth token in localStorage causes an infinite bounce loop.
|
||||
|
||||
**How it happens**: PocketBase's `authStore.onChange` fires `callback(null)` after the SDK's first API call returns 401 (expired/invalidated token). But the old token persists in localStorage, so the next page load reads it again — `currentUser` returns a user, fires `callback(user)`, redirects back, and the cycle repeats.
|
||||
|
||||
**Symptoms**: Browser flickers between login and dashboard pages, constantly refreshing.
|
||||
|
||||
**The fix — merge login into root**: Make the login page the site root (`index.html`) and move the dashboard to a separate page (`dashboard.html`). This eliminates the mutual redirect:
|
||||
|
||||
| Before | After |
|
||||
|--------|-------|
|
||||
| `index.html` = dashboard | `dashboard.html` = dashboard |
|
||||
| `login.html` = login page | `index.html` = login page |
|
||||
| Visit `/` → dashboard → redirect to login | Visit `/` → login page directly (no redirect) |
|
||||
|
||||
**Files to update (13 total across SPQ)**:
|
||||
- Auth redirects: `'login.html'` → `'index.html'` (7 JS files)
|
||||
- Login success: `'index.html'` → `'dashboard.html'` (login.js + index.html inline scripts)
|
||||
- Nav links in HTML: `href="index.html"` → `href="dashboard.html"` (4 HTML files)
|
||||
- Keyboard shortcut: dashboard.js case 'q' → `'dashboard.html'`
|
||||
- Quote links: customers.js `'index.html?quoteId='` → `'dashboard.html?quoteId='`
|
||||
|
||||
**Verification**: Visit root with no auth → login form shows directly. Visit root with valid token → one clean redirect to dashboard.html. Visit dashboard.html with no auth → one clean redirect to index.html.
|
||||
|
||||
## PocketBase SDK Auto-Cancellation
|
||||
|
||||
See `references/pocketbase-auto-cancellation.md` for the full guide. PocketBase SDK auto-cancels duplicate `create()`/`update()` calls by default. Fix: pass `{ requestKey: null }` on all mutating calls and add double-submission guards to form handlers.
|
||||
@@ -0,0 +1,106 @@
|
||||
# PocketBase Schema Patch Pattern
|
||||
|
||||
Use this when a PocketBase collection is missing fields needed by the frontend. The symptom is "Error saving to [collection]" with no clear error message.
|
||||
|
||||
## Pattern
|
||||
|
||||
1. Auth as superuser
|
||||
2. GET the collection schema
|
||||
3. Append missing fields to the `fields` array
|
||||
4. PATCH the full `fields` array back — NOT POST to `/fields` (returns 404)
|
||||
|
||||
```python
|
||||
#!/usr/bin/env python3
|
||||
import json, urllib.request
|
||||
|
||||
BASE = "http://127.0.0.1:8091"
|
||||
COLLECTION = "quotes" # change as needed
|
||||
EMAIL = "admin@grajmedia.duckdns.org"
|
||||
PASSWORD = "RayPB2024!"
|
||||
|
||||
# Auth
|
||||
data = json.dumps({"identity": EMAIL, "password": PASSWORD}).encode()
|
||||
req = urllib.request.Request(f"{BASE}/api/collections/_superusers/auth-with-password", data=data, headers={"Content-Type": "application/json"})
|
||||
token = json.loads(urllib.request.urlopen(req).read())["token"]
|
||||
|
||||
# Get schema
|
||||
req = urllib.request.Request(f"{BASE}/api/collections/{COLLECTION}", headers={"Authorization": f"Bearer {token}"})
|
||||
schema = json.loads(urllib.request.urlopen(req).read())
|
||||
existing = {f["name"] for f in schema["fields"]}
|
||||
|
||||
# Fields to add: [(name, type), ...]
|
||||
new_fields = [
|
||||
("fieldName", "text"),
|
||||
("otherField", "json"),
|
||||
]
|
||||
|
||||
for name, typ in new_fields:
|
||||
if name not in existing:
|
||||
schema["fields"].append({"name": name, "type": typ, "required": False})
|
||||
print(f"Adding: {name} ({typ})")
|
||||
|
||||
# PATCH
|
||||
req = urllib.request.Request(
|
||||
f"{BASE}/api/collections/{COLLECTION}",
|
||||
data=json.dumps({"fields": schema["fields"]}).encode(),
|
||||
headers={"Authorization": f"Bearer {token}", "Content-Type": "application/json"},
|
||||
method="PATCH"
|
||||
)
|
||||
result = json.loads(urllib.request.urlopen(req).read())
|
||||
print(f"Done: {len(result['fields'])} fields total")
|
||||
```
|
||||
|
||||
## Common PocketBase field types
|
||||
|
||||
| Type | Use for |
|
||||
|------|---------|
|
||||
| `text` | strings |
|
||||
| `number` | integers/floats |
|
||||
| `bool` | booleans |
|
||||
| `json` | arrays, objects |
|
||||
| `date` | ISO 8601 datetime strings |
|
||||
| `file` | file uploads |
|
||||
| `select` | enum values (needs `values` array) |
|
||||
| `relation` | foreign keys (needs `collectionId`, `cascadeDelete`) |
|
||||
|
||||
## Silently Dropped Fields (CRITICAL)
|
||||
|
||||
PocketBase silently drops any field that is NOT in its collection schema. There is NO error, NO warning, NO console message. The write succeeds but the field is missing on the next read.
|
||||
|
||||
**Symptom on read**: The missing field is `undefined`. Code that falls back to `new Date()` or `false` silently uses the fallback — often producing wrong behavior (e.g., tasks showing as overdue with today's date because `dueDateTime` was dropped and `new Date()` was the fallback).
|
||||
|
||||
**When adding new JS-side fields to any collection**, you MUST:
|
||||
1. Add the field to the PocketBase collection schema
|
||||
2. Add any date fields to `TIMESTAMP_FIELDS` in `pocketbase.js`
|
||||
3. Restart the PocketBase container
|
||||
|
||||
### Tasks collection (fixed June 2026)
|
||||
|
||||
The tasks collection was created with only 11 basic fields. These were added:
|
||||
|
||||
```
|
||||
dueDateTime text
|
||||
dueTime text
|
||||
completed bool
|
||||
notes text
|
||||
isRecurring bool
|
||||
recurrenceType text
|
||||
customInterval number
|
||||
parentTaskId text
|
||||
instanceNumber number
|
||||
lastUpdated text
|
||||
```
|
||||
|
||||
### SQLite direct patch (when API auth fails)
|
||||
|
||||
```python
|
||||
import sqlite3, json
|
||||
conn = sqlite3.connect('/home/ray/docker/pocketbase/pb_data/data.db')
|
||||
rows = conn.execute("SELECT id, fields FROM _collections WHERE name='tasks'").fetchall()
|
||||
coll_id, fields_json = rows[0]
|
||||
fields = json.loads(fields_json)
|
||||
# Append missing fields, then:
|
||||
conn.execute("UPDATE _collections SET fields = ? WHERE id = ?", (json.dumps(fields), coll_id))
|
||||
conn.commit()
|
||||
# Restart container: docker restart pocketbase
|
||||
```
|
||||
@@ -0,0 +1,45 @@
|
||||
# PocketBase Collection Schemas
|
||||
|
||||
All `userId` fields are type `text` (not `relation`). PocketBase 0.39.1.
|
||||
|
||||
## services
|
||||
Fields: `id`, `userId`, `name`, `description`, `price`, `category`, `duration`, `maintenanceInterval`, `createdAt`, `updatedAt`, `recommendation`, `explanation`, `technicianNotes`
|
||||
|
||||
**Critical**: The legacy import stores technician narratives in `explanation`, NOT `description`. When loading services for display in the Settings catalog, map `explanation` → `description` UI field. The QuoteGenerator already reads `explanation` directly.
|
||||
|
||||
## settings
|
||||
Fields: `id`, `userId`, `name`, `data` (JSON)
|
||||
|
||||
All business settings go into the `data` JSON field. When creating: `{ userId, name: 'businessSettings', data: { businessName, ... } }`. When updating: `{ data: { businessName, ... } }`. When loading: read from `record.data.businessName` (not `record.businessName`). Include fallback to top-level for backward compatibility.
|
||||
|
||||
## service_advisors
|
||||
Fields: `id`, `name`, `email`, `phone`, `userId` (text)
|
||||
|
||||
API rules (must be set after creation): `listRule: "userId = @request.auth.id"`, `viewRule: "userId = @request.auth.id"`, `createRule: "@request.auth.id != ''"`, `updateRule: "userId = @request.auth.id"`, `deleteRule: "userId = @request.auth.id"`
|
||||
|
||||
## repairOrders (spq-v2)
|
||||
|
||||
Fields: `id`, `status` (TEXT), `userId` (TEXT), `customerName`, `customerEmail`, `customerPhone`, `vehicleInfo`, `vin`, `mileage`, `roNumber`, `notes`, `services` (TEXT — JSON array stored as string), `writeupTime` (TEXT — ISO timestamp), `promisedTime` (TEXT — ISO timestamp), `estimatedTime` (TEXT — hours as decimal string, e.g. "1.5"), `completedTime` (TEXT — ISO timestamp), `financial` (JSON blob), `advisorName`, `total` (NUMERIC), `tax` (NUMERIC), `shopCharges` (NUMERIC), `warranty` (TEXT/"bool"), `lastModified`, `createdAt`, `updatedAt`, `customerId`, `workStatus`
|
||||
|
||||
**⚠️ Critical: Field name mismatch**. The React frontend uses `estimatedDuration` (minutes number) but PB stores `estimatedTime` (hours as TEXT string like "1.5"). Always normalize in both directions:
|
||||
- **On load** (fetchOrders): `estimatedDuration = Math.round(parseFloat(item.estimatedTime) * 60);`
|
||||
- **On save** (handleSave / handleAddTime): `estimatedTime: (estimatedDuration / 60).toFixed(1);`
|
||||
|
||||
**Custom fields**: There is no `customerType` or `estimatedDuration` column. Store extra fields in the `financial` JSON blob:
|
||||
- Read: `item.financial?.customerType`
|
||||
- Write: `pb.collection('repairOrders').update(id, { financial: JSON.stringify({ ...existing, customerType: 'waiter' }) })`
|
||||
|
||||
## Creating collections via API
|
||||
POST to `/api/collections` with `fields` array (not `schema`). PocketBase 0.39.x uses `fields`. Example:
|
||||
```json
|
||||
{
|
||||
"name": "service_advisors",
|
||||
"type": "base",
|
||||
"fields": [
|
||||
{"name": "name", "type": "text", "required": true},
|
||||
{"name": "userId", "type": "text", "required": true}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
Superuser auth: `POST /api/collections/_superusers/auth-with-password` with `{ "identity": "email", "password": "..." }`. Returns `{ token, record }`. Use token in `Authorization` header.
|
||||
@@ -0,0 +1,27 @@
|
||||
# PocketBase Test User Creation
|
||||
|
||||
Quick test accounts for local development — no admin auth needed if the users collection allows public creation.
|
||||
|
||||
## Create user
|
||||
|
||||
```bash
|
||||
curl -s -X POST http://127.0.0.1:8091/api/collections/users/records \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"email":"demo@shop.com","password":"test1234","passwordConfirm":"test1234","emailVisibility":true}'
|
||||
```
|
||||
|
||||
## Verify login
|
||||
|
||||
```bash
|
||||
curl -s -X POST http://127.0.0.1:8091/api/collections/users/auth-with-password \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"identity":"demo@shop.com","password":"test1234"}'
|
||||
```
|
||||
|
||||
Returns a JWT `token` on success. Use the token in `Authorization: Bearer <token>` for authenticated requests.
|
||||
|
||||
## Notes
|
||||
|
||||
- If public creation is disabled, authenticate as admin first and use the admin token
|
||||
- `passwordConfirm` must match `password` exactly
|
||||
- `emailVisibility: true` makes the email visible in record listings and API responses
|
||||
@@ -0,0 +1,95 @@
|
||||
# PocketBase Timestamp + convertToPB Guard
|
||||
|
||||
## The problem
|
||||
|
||||
The `pocketbase.js` adapter exports a `Timestamp` class for Firebase API compatibility. Three interlocking bugs make writes silently fail when Timestamps are used without proper support infrastructure.
|
||||
|
||||
## Root causes
|
||||
|
||||
### 1. Timestamp static methods don't exist by default
|
||||
|
||||
Firebase APIs like `Timestamp.fromDate(date)` and `Timestamp.now()` are NOT present on the PocketBase adapter's `Timestamp` class. Any code that calls them (e.g., `handleEditAppointment` in `appointments.js`) throws `TypeError: Timestamp.fromDate is not a function`.
|
||||
|
||||
Because async event handlers don't surface their rejections to the browser UI, the user sees **nothing** — no error, no notification. The function just silently dies.
|
||||
|
||||
**Fix — add to the Timestamp class in `pocketbase.js`:**
|
||||
```js
|
||||
static fromDate(date) { return new Timestamp(date); }
|
||||
static now() { return new Timestamp(new Date()); }
|
||||
```
|
||||
|
||||
### 2. `convertToPB` doesn't recognize Timestamp objects
|
||||
|
||||
`convertToPB` checks `instanceof Date` to serialize dates to ISO strings. But `Timestamp` does NOT extend `Date` — it wraps a `_date` property. So Timestamp objects fall through to the generic `JSON.stringify()` branch and produce garbage like `{"_date":"2026-06-15T..."}`. PocketBase either rejects this or stores junk that `convertFromPB` can't parse back.
|
||||
|
||||
**Fix — add Timestamp check BEFORE the generic object branch in `convertToPB`:**
|
||||
```js
|
||||
} else if (value && typeof value === 'object' && !Array.isArray(value) && !(value instanceof Date) && !(value instanceof Timestamp)) {
|
||||
result[key] = JSON.stringify(value);
|
||||
} else if (value instanceof Timestamp) {
|
||||
result[key] = value.toISOString();
|
||||
```
|
||||
|
||||
### 3. `new Date(timestampObj)` returns Invalid Date
|
||||
|
||||
Since Timestamp is not a Date, `new Date(timestampObj)` produces an `Invalid Date`. This breaks code that reads from PocketBase (where `convertFromPB` created Timestamp objects) and tries to display or process dates.
|
||||
|
||||
**Fix — always use `.toDate()`:**
|
||||
```js
|
||||
const date = obj?.toDate ? obj.toDate() : new Date(obj);
|
||||
```
|
||||
|
||||
## Preferred pattern for new writes
|
||||
|
||||
For new records and updates, **prefer plain `new Date()` over `Timestamp.fromDate()`**. `convertToPB` handles `Date` instances natively at the `instanceof Date` branch. Timestamp is only needed for reading FROM PocketBase (to preserve the Firebase-compatible API surface).
|
||||
|
||||
```js
|
||||
// BAD — Timestamp chain of pain
|
||||
appointmentDateTime: Timestamp.fromDate(new Date(...)),
|
||||
updatedAt: Timestamp.now()
|
||||
|
||||
// GOOD — plain Date, convertToPB handles it
|
||||
appointmentDateTime: new Date(...),
|
||||
updatedAt: new Date()
|
||||
```
|
||||
|
||||
## Async handler "nothing happens" pattern
|
||||
|
||||
When a button click produces zero visible effect, the most common cause is an uncaught synchronous throw in an async handler before the first `await`. The fix:
|
||||
|
||||
```js
|
||||
async function handleSave() {
|
||||
console.log('=== SAVE CLICKED ==='); // proves handler fired
|
||||
try {
|
||||
// ALL code — validation, DOM reads, API calls — inside one try
|
||||
await updateDoc(...);
|
||||
} catch (error) {
|
||||
console.error('Save error:', error);
|
||||
alert('Save failed: ' + (error.message || error)); // hard fallback
|
||||
showNotification('Failed to save.', true);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
No nested try/catch — one try wrapping the entire body.
|
||||
|
||||
## Files affected
|
||||
|
||||
- `pocketbase.js` — `Timestamp` class, `convertToPB` function, `TIMESTAMP_FIELDS` set
|
||||
- `appointments.js` — `handleEditAppointment`, date reading in `openEditAppointmentModal`, date grouping
|
||||
- Any file using `Timestamp.fromDate()` or `Timestamp.now()`
|
||||
- Any file doing `new Date(someTimestampObject)` on PocketBase-loaded data
|
||||
|
||||
## TIMESTAMP_FIELDS Maintenance Rule
|
||||
|
||||
The `TIMESTAMP_FIELDS` set in `pocketbase.js` controls which ISO string fields `convertFromPB` auto-converts to `Timestamp` objects on retrieval. **When you add a new date/datetime field to ANY PocketBase collection, you MUST also add its field name to this set.** Missing fields come back as raw ISO strings, which break `.toDate()` calls and cause `new Date()` fallbacks (producing today's date instead of the stored date).
|
||||
|
||||
Current set (as of June 2026):
|
||||
```
|
||||
writeupTime, createdAt, updatedAt, lastUpdated,
|
||||
appointmentDateTime, completionTime, lastActivity,
|
||||
timestamp, dateCreated, startDate, endDate, deadline,
|
||||
dueDateTime, promisedTime
|
||||
```
|
||||
|
||||
`dueDateTime` and `promisedTime` were added June 2026 after tasks showed overdue with today's date because these fields were missing from both the PocketBase schema AND `TIMESTAMP_FIELDS`.
|
||||
@@ -0,0 +1,48 @@
|
||||
# Post-Delegation Audit Pattern
|
||||
|
||||
After subagents build spq-v2 pages, dispatch a second wave of auditing subagents before considering the work done.
|
||||
|
||||
## Why
|
||||
|
||||
Subagent-generated code consistently has these issues that won't surface until runtime:
|
||||
- Missing `onClick` handlers on buttons (text rendered, no action)
|
||||
- AI function signature mismatches between `src/lib/ai.ts` and page code
|
||||
- Wrong PocketBase collection names (underscore vs camelCase)
|
||||
- `sort: '-created'` on collections lacking system date fields
|
||||
- `useToast` calls without `ToastProvider` wrapping the route tree
|
||||
- Empty state / error state / loading state transitions that swallow errors
|
||||
|
||||
## How
|
||||
|
||||
Dispatch 3 auditing subagents in parallel, each covering 2-3 pages:
|
||||
|
||||
```
|
||||
delegate_task tasks=[
|
||||
{ goal: "Audit Dashboard + QuoteGenerator + Settings against originals", toolsets: ["file","terminal"] },
|
||||
{ goal: "Audit Customers + RepairOrders + Appointments against originals", toolsets: ["file","terminal"] },
|
||||
{ goal: "Audit FinancialDashboard + Invoices + Settings against originals", toolsets: ["file","terminal"] },
|
||||
]
|
||||
```
|
||||
|
||||
Each auditor must:
|
||||
1. READ the original v1 source files
|
||||
2. READ the spq-v2 page files
|
||||
3. Test PocketBase API calls at `http://127.0.0.1:8091` (login: demo@shop.com/test1234)
|
||||
4. Compare every feature, UI element, and API call
|
||||
5. Fix every bug found (they have `file` toolset to edit code)
|
||||
6. Verify fixes with actual API calls
|
||||
|
||||
## Common Root Causes Found
|
||||
|
||||
| Symptom | Root Cause | Fix |
|
||||
|---------|-----------|-----|
|
||||
| "Something went wrong" on Dashboard | `sort: '-created'` on quotes collection missing `created` field | Change to `sort: '-id'` |
|
||||
| "Something went wrong" on Financial | Same `-created` issue on repairOrders collection | Change to `sort: '-id'` |
|
||||
| "Something went wrong" on RepairOrders | Querying `repair_orders` but collection is `repairOrders` | Fix collection name |
|
||||
| Custom service add does nothing | Button has no `onClick` handler | Add handler that creates service and calls `handleAdd()` |
|
||||
| AI Write/Generate Priorities fail silently | Function signature mismatch between page code and `ai.ts` | Align call signatures |
|
||||
| White screen on page load | `useToast` called without `ToastProvider` | Wrap `<Routes>` in `<ToastProvider>` |
|
||||
|
||||
## Verification
|
||||
|
||||
After auditors finish, rebuild (`npx vite build`), restart the proxy server, and test every page in the browser.
|
||||
@@ -0,0 +1,140 @@
|
||||
# Split/Full Pricing Toggle
|
||||
|
||||
## Where It Lives
|
||||
|
||||
Three separate components — each has its own copy:
|
||||
|
||||
| Location | Component | File |
|
||||
|----------|-----------|------|
|
||||
| Quote Generator | `ServiceRow` (memo) | `src/components/quoteGenerator/ServiceRow.tsx` |
|
||||
| RO Create/Edit | `ServiceRow` (inline) | `src/components/ROForm.tsx` |
|
||||
| RO Detail Modal | `EditableServiceRow` (inline) | `src/pages/RODetailModal.tsx` |
|
||||
|
||||
All three share the same toggle pattern. When adding the toggle to a new service row, copy the pattern from any existing one.
|
||||
|
||||
## Type Fields
|
||||
|
||||
Added to `Service` / `QuoteService` / `ROService` in `src/types.ts`:
|
||||
|
||||
```ts
|
||||
pricingMode?: 'full' | 'split';
|
||||
laborHours?: number;
|
||||
laborRate?: number;
|
||||
partsCost?: number;
|
||||
```
|
||||
|
||||
`ROService` already had `pricingMode` and split fields. `QuoteService` extended `Service` to add them.
|
||||
|
||||
## Toggle Pattern
|
||||
|
||||
A pill-button group rendered in the row header:
|
||||
|
||||
```tsx
|
||||
<div className="flex rounded-lg border border-gray-300 dark:border-gray-600 overflow-hidden text-xs">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (isSplit) {
|
||||
onChange(index, 'pricingMode', 'full');
|
||||
}
|
||||
}}
|
||||
className={`px-2.5 py-1 font-medium transition-colors ${
|
||||
!isSplit
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-400 hover:bg-gray-200 dark:hover:bg-gray-600'
|
||||
}`}
|
||||
>
|
||||
Full
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (!isSplit) {
|
||||
const flatTotal = service.total || total;
|
||||
const rate = defaultLaborRate;
|
||||
const hours = rate > 0 ? Math.round((flatTotal / rate) * 100) / 100 : 0;
|
||||
onChange(index, 'pricingMode', 'split');
|
||||
onChange(index, 'laborHours', hours);
|
||||
onChange(index, 'laborRate', rate);
|
||||
onChange(index, 'partsCost', 0);
|
||||
}
|
||||
}}
|
||||
className={`px-2.5 py-1 font-medium transition-colors ${
|
||||
isSplit
|
||||
? 'bg-blue-600 text-white'
|
||||
: 'bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-400 hover:bg-gray-200 dark:hover:bg-gray-600'
|
||||
}`}
|
||||
>
|
||||
Split
|
||||
</button>
|
||||
</div>
|
||||
```
|
||||
|
||||
### State Derivation
|
||||
|
||||
```ts
|
||||
const isSplit = service.pricingMode === 'split'; // default Full
|
||||
```
|
||||
|
||||
When `pricingMode` is undefined (existing data without the field), it defaults to **Full**.
|
||||
|
||||
### Mode Switch Behavior
|
||||
|
||||
| Switch | What Happens |
|
||||
|--------|-------------|
|
||||
| Full → Split | Derives `laborHours = flatten Total / laborRate` from the flat price. Sets `laborRate = defaultLaborRate`, `partsCost = 0` |
|
||||
| Split → Full | Just sets `pricingMode = 'full'`. Split field values persist in state but are hidden |
|
||||
|
||||
### Full Mode Display
|
||||
- Grid collapses to 2 columns: **Name** + **Total Price** (editable input for `service.total`)
|
||||
|
||||
### Split Mode Display
|
||||
- Grid shows 5 columns: **Name**, **Labor Hrs**, **Rate/hr**, **Parts $**, **Total** (read-only, calculated)
|
||||
|
||||
## Total Calculation
|
||||
|
||||
`roServiceTotal()` in `src/lib/totals.ts`:
|
||||
|
||||
```ts
|
||||
export function roServiceTotal(s): number {
|
||||
const split = (s.laborHours || 0) * (s.laborRate || 0) + (s.partsCost || 0);
|
||||
return split || s.total || 0;
|
||||
}
|
||||
```
|
||||
|
||||
For Full mode: hours=0 → split=0 → falls back to `s.total`. **Every call site must pass `total: s.total || 0`** to `roServiceTotal` — otherwise Full-mode services compute to $0.
|
||||
|
||||
## Where Total Must Be Passed
|
||||
|
||||
Search the codebase for `roServiceTotal` call sites and ensure `total` is in the argument:
|
||||
|
||||
- `ROForm.tsx` ServiceRow render — the `total` const used for display
|
||||
- `ROForm.tsx` `computeROTotals` mapper
|
||||
- `ROForm.tsx` `handleSubmit` cleanedServices mapper
|
||||
- `RODetailModal.tsx` EditableServiceRow render
|
||||
- `RODetailModal.tsx` service cleanup on save
|
||||
|
||||
## Initial Defaults
|
||||
|
||||
Every place that creates a new service must set `pricingMode: 'full'`:
|
||||
|
||||
| Location | File | Line |
|
||||
|----------|------|------|
|
||||
| ROForm edit path fallback | `ROForm.tsx` | ~385 |
|
||||
| ROForm create path | `ROForm.tsx` | ~413 |
|
||||
| ROForm handleAddService | `ROForm.tsx` | ~553 |
|
||||
| RODetailModal handleAddService | `RODetailModal.tsx` | ~822 |
|
||||
| RODetailModal createEmptyServiceForm | `RODetailModal.tsx` | 57-67 (already 'full') |
|
||||
| QuoteGenerator ServiceSearch handleAdd | `ServiceSearch.tsx` | 56 (addService passes defaults) |
|
||||
|
||||
## Persistence
|
||||
|
||||
When saving services back to PocketBase, `pricingMode` must be included in the cleaned service object:
|
||||
|
||||
```ts
|
||||
pricingMode: (s.pricingMode || 'full') as 'full' | 'split',
|
||||
```
|
||||
|
||||
This is in:
|
||||
- `ROForm.tsx` handleSubmit cleanedServices
|
||||
- `RODetailModal.tsx` service save handler
|
||||
@@ -0,0 +1,101 @@
|
||||
# Quote Generator & Appointment Fixes (June 2026)
|
||||
|
||||
## Timestamp Static Methods — PARTIAL FIX (deeper issues found)
|
||||
|
||||
`pocketbase.js` `Timestamp` class was missing Firebase-compatible static methods. Added:
|
||||
|
||||
```js
|
||||
static fromDate(date) { return new Timestamp(date); }
|
||||
static now() { return new Timestamp(new Date()); }
|
||||
```
|
||||
|
||||
`appointments.js:handleEditAppointment()` uses `Timestamp.fromDate(appointmentDateTime)` and `Timestamp.now()` — without these, the save silently fails (TypeError).
|
||||
|
||||
HOWEVER, adding the static methods was NOT sufficient. Two deeper issues discovered:
|
||||
|
||||
1. **`convertToPB` poison**: `Timestamp` objects are NOT `Date` instances, so `convertToPB` falls through to `JSON.stringify()` producing garbage `{"_date":"..."}` in PocketBase.
|
||||
|
||||
2. **`new Date(timestampObj)` crashes**: `Date()` constructor doesn't understand Timestamp wrapper → `Invalid Date` → empty form fields → user re-enters data → save appears to work but PocketBase gets garbage.
|
||||
|
||||
**Final fix**: Removed `Timestamp` wrapper entirely from `handleEditAppointment()`. Use plain `new Date()` for saves — `convertToPB` handles `Date` natively. Also added `Timestamp` recognition to `convertToPB` as belt-and-suspenders.
|
||||
|
||||
## Quote Auto-Conversion on Full Decision
|
||||
|
||||
In `quote-tab-manager.js:saveQuote()`: when EVERY service has `customerDecision` of `approved` or `declined` (none pending), the quote auto-sets `status: 'converted'`. This drops it from the Daily Briefing on the dashboard.
|
||||
|
||||
```js
|
||||
const allDecided = selectedServices.length > 0 &&
|
||||
selectedServices.every(s => s.customerDecision === 'approved' || s.customerDecision === 'declined');
|
||||
if (allDecided) {
|
||||
quoteData.status = 'converted';
|
||||
}
|
||||
```
|
||||
|
||||
## Daily Briefing Approved/Declined Dollar Breakdown
|
||||
|
||||
In `dashboard.js:generateDailyBriefing()`: now calculates `approvedDollars` and `declinedDollars` across all active quotes. The data string sent to the LLM includes:
|
||||
|
||||
```
|
||||
Total quote value: $2,847.50 — Approved: $1,200.00, Declined: $1,647.50
|
||||
```
|
||||
|
||||
System prompt updated to tell the LLM: "Mention the Approved vs Declined dollar breakdown when relevant."
|
||||
|
||||
## Create RO from Appointment — Full Fields
|
||||
|
||||
`appointments.js:promptRepairOrderDetails()` now accepts the full appointment object (not just `appointment.vin`). Modal expanded from 5 fields to 9, all pre-filled:
|
||||
|
||||
| Field | Source |
|
||||
|---|---|
|
||||
| Customer Name | appointment.customerName |
|
||||
| Customer Phone | appointment.customerPhone |
|
||||
| Vehicle Info | appointment.vehicleInfo |
|
||||
| Service Advisor | appSettings.serviceAdvisor |
|
||||
| RO Number | auto-generated (timestamp) |
|
||||
| Mileage | appointment.mileage |
|
||||
| VIN | appointment.vin |
|
||||
| Estimated Hours | default 2.0 |
|
||||
| Customer Service Type | Waiter/Drop Off |
|
||||
|
||||
`createRepairOrderFromAppointment()` now uses modal values for customerName, customerPhone, vehicleInfo, serviceAdvisor (with appointment fallback).
|
||||
|
||||
## Add Custom Service Modal Unification
|
||||
|
||||
`repair-orders.html`: Modal now matches Edit Service modal:
|
||||
- Width: `max-w-lg` → `max-w-6xl`
|
||||
- Label: "Recommendation Reason" → "Recommendation"
|
||||
- Label: "Explanation" → "Customer Explanation"
|
||||
- Added: Shop charge checkbox (checked by default)
|
||||
- AI Write button: "AI Write" → "✨ AI Write"
|
||||
|
||||
`quote-tab-manager.js:saveCustomService()` now captures:
|
||||
- `technicianNotes` (was in modal but not saved)
|
||||
- `applyShopCharge` (new checkbox)
|
||||
- `recommendationReason` (maps to same value as recommendation, for edit-service consistency)
|
||||
|
||||
`openAddCustomServiceModal()` resets technician notes and shop charge on open.
|
||||
|
||||
## Appointment Scan — User-Selected Date (no OCR date pass)
|
||||
|
||||
The screenshot scanner now asks the user to pick the appointment date BEFORE scanning.
|
||||
See `references/ocr-date-extraction.md` for full details.
|
||||
|
||||
Key changes:
|
||||
- Date picker added to `#scan-screenshot-modal` between model selector and upload area
|
||||
- Defaults to today when modal opens
|
||||
- `selectedDate` injected into LLM system prompt: `### ALL APPOINTMENTS ARE FOR: 2026-06-14`
|
||||
- Rule 1 changed to: "Use the provided date for ALL appointments. Do not read dates from the text."
|
||||
- Second Tesseract OCR pass (PSM 7 on cropped header) REMOVED entirely — saves ~30s per scan
|
||||
- `headerDate` fallback replaced with `selectedDate` in appointment mapping
|
||||
|
||||
## Pattern: Services JSON Parsing
|
||||
|
||||
Always guard against services being returned as strings (PocketBase stores nested objects as JSON):
|
||||
|
||||
```js
|
||||
let svcs = q.services || [];
|
||||
if (typeof svcs === 'string') {
|
||||
try { svcs = JSON.parse(svcs); } catch(e) { svcs = []; }
|
||||
}
|
||||
if (!Array.isArray(svcs)) svcs = [];
|
||||
```
|
||||
@@ -0,0 +1,93 @@
|
||||
# Quote Generator — Totals Architecture
|
||||
|
||||
Path: `src/pages/QuoteGenerator.tsx`, `src/lib/totals.ts`, `src/lib/pdf.ts`, `src/store/quote.ts`
|
||||
|
||||
## Core Concept: `approved` vs `customerDecision`
|
||||
|
||||
Each `QuoteService` has two decision fields that MUST stay in sync:
|
||||
|
||||
| Field | Type | Meaning |
|
||||
|-------|------|---------|
|
||||
| `approved` | `boolean` | UI checkbox state (is the service included in the quote?) |
|
||||
| `customerDecision` | `'approved' | 'pending' | 'declined'` | The actual customer-facing status |
|
||||
|
||||
**Critical invariant:** `approved` and `customerDecision` must agree. When a service is `customerDecision: 'pending'`, `approved` must be `false`. When `customerDecision: 'declined'`, `approved` must be `false`. Only `customerDecision: 'approved'` should have `approved: true`.
|
||||
|
||||
All mutation paths enforce this:
|
||||
- `toggleApproved()` — sets both fields atomically
|
||||
- `toggleDeclined()` — sets both fields atomically
|
||||
- Customer Decision buttons (Approve/Decline/Pending) — all three set both fields
|
||||
- New service via `addService()` — initializes both to pending state (`approved: false, customerDecision: 'pending'`)
|
||||
|
||||
## `billableServices()` Helper
|
||||
|
||||
Location: `src/lib/totals.ts`
|
||||
|
||||
```typescript
|
||||
export function billableServices(services) {
|
||||
const nonDeclined = services.filter(s => s.customerDecision !== 'declined');
|
||||
const hasApproved = nonDeclined.some(s => s.customerDecision === 'approved');
|
||||
const hasPending = nonDeclined.some(s => s.customerDecision === 'pending');
|
||||
return (hasApproved && hasPending)
|
||||
? nonDeclined.filter(s => s.customerDecision === 'approved')
|
||||
: nonDeclined;
|
||||
}
|
||||
```
|
||||
|
||||
**Heuristic:**
|
||||
- All services pending → count all (full total)
|
||||
- All services approved → count all (full total)
|
||||
- Mixed approved + pending → count only approved (partial total)
|
||||
- Declined services → always excluded
|
||||
|
||||
This is the shared single source of truth used by:
|
||||
1. `computeQuoteTotals()` — UI Quote Summary sidebar
|
||||
2. Store `subtotal()` / `approvedSubtotal()` — computed getters
|
||||
3. PDF generator — `servicesTotal` accumulation and shop charge calculation
|
||||
|
||||
## Where Totals Are Calculated
|
||||
|
||||
### 1. UI Summary (`QuoteSummary` component)
|
||||
- Uses `computeQuoteTotals({ services, discount, settings })` from `src/lib/totals.ts`
|
||||
- Shows Subtotal, Discount, Shop Charge, Tax, TOTAL in the right sidebar
|
||||
- Renders only if settings have non-zero rates
|
||||
|
||||
### 2. Store Computed Getters
|
||||
- `subtotal()` and `approvedSubtotal()` in `src/store/quote.ts`
|
||||
- Both use `billableServices()` for consistency
|
||||
- Used by `approvedSubtotal` display in `hasMixed` check
|
||||
|
||||
### 3. PDF Generator (`generateQuotePDF` in `src/lib/pdf.ts`)
|
||||
- Pre-computes `billableIds = new Set(billableServices(services).map(s => s.id))`
|
||||
- Service accumulation: `if (billableIds.has(svc.id)) servicesTotal += price`
|
||||
- Shop charge: `billableIds.has(s.id) && s.applyShopCharge`
|
||||
- Discount, tax, and total are computed from `servicesTotal`
|
||||
|
||||
## ServiceRow Checkboxes
|
||||
|
||||
Two mutually-exclusive checkboxes in the collapsed row:
|
||||
|
||||
| Checkbox | Icon | Active colors | Inactive colors | Action |
|
||||
|----------|------|---------------|-----------------|--------|
|
||||
| Approve | ✓ checkmark | green bg, white icon | gray border, gray icon | `toggleApproved()` |
|
||||
| Decline | ✕ X mark | red bg, white icon | gray border, gray icon | `toggleDeclined()` |
|
||||
|
||||
Both icons are ALWAYS visible — grayed out when inactive, white when active on colored background. This gives the user a visual cue of what clicking will do.
|
||||
|
||||
## ServicesTable Groups
|
||||
|
||||
Three groups sorted by `customerDecision` (not `approved` boolean):
|
||||
1. **Approved** (green header) — `customerDecision === 'approved'`
|
||||
2. **Pending** (gray header) — `customerDecision === 'pending'`
|
||||
3. **Declined** (red header) — `customerDecision === 'declined'`
|
||||
|
||||
## "Approve All" Button
|
||||
|
||||
A green button above the service groups, only visible when `notApprovedCount > 0`. Calls `updateService` on each non-approved service to set `approved: true, customerDecision: 'approved'`.
|
||||
|
||||
## Common Pitfalls
|
||||
|
||||
- **Don't filter by `s.approved` for totals.** Always use `billableServices()` or `customerDecision !== 'declined'` / `billableIds.has()`. The `approved` boolean is a UI convenience, not the source of truth for billing.
|
||||
- **Don't let `approved` and `customerDecision` diverge.** Every mutation path must set both fields together. Adding a new path that sets only one will cause the UI summary and PDF to disagree.
|
||||
- **PDF has its own totals math** inline (discount/shop charge/tax/total) that must stay consistent with `billableServices`. Any change to the heuristic must update both `totals.ts` and `pdf.ts`.
|
||||
- **Changing service defaults? Hit all three add paths.** `QuoteGenerator.tsx` adds services in three places — all must set `approved` + `customerDecision` consistently: (1) `handleAdd` (catalog picker, ~line 147), (2) RO import `forEach` (~line 772), (3) `handleAddSuggestion` (AI suggestions, ~line 952). Missing one creates inconsistent behavior depending on how the service was added.
|
||||
@@ -0,0 +1,69 @@
|
||||
# Quote PDF Generation (quote-tab-manager.js)
|
||||
|
||||
## Entry Points
|
||||
|
||||
Two user actions, both in `quote-tab-manager.js`:
|
||||
|
||||
- **`downloadPdf()`** (line ~3696): creates download link, triggers save as `Quote_XXX_CustomerName.pdf`
|
||||
- **`printQuote()`** (line ~3670): opens PDF blob in new window, triggers `window.print()`
|
||||
|
||||
Both call `generateQuotePDF()` — the only difference is what happens with the returned blob.
|
||||
|
||||
## Button Wiring
|
||||
|
||||
`ensureDownloadPdfBtnListener()` (line ~706):
|
||||
1. Looks for buttons with IDs `download-pdf-btn` or `generate-pdf-btn`
|
||||
2. Falls back to scanning all buttons for text/classes containing "pdf" or "download"
|
||||
3. If still nothing found, dynamically creates a PDF button via `createPdfButton()`
|
||||
|
||||
## generateQuotePDF() Structure
|
||||
|
||||
Location: `quote-tab-manager.js` line ~3727
|
||||
|
||||
1. Dynamically imports jsPDF from CDN: `https://cdnjs.cloudflare.com/ajax/libs/jspdf/2.5.1/jspdf.umd.min.js`
|
||||
2. Accesses via `window.jspdf.jsPDF`
|
||||
3. Validates customer name + services exist
|
||||
4. Builds PDF with these sections:
|
||||
|
||||
### Header (two-column layout)
|
||||
- **Left column**: "SERVICE PROVIDER" label, business name/address/phone, service advisor
|
||||
- **Right column**: "CUSTOMER INFORMATION" label, name/vehicle/RO#/mileage/VIN/date
|
||||
- Bold separator line between header and content
|
||||
|
||||
### Personalized Message
|
||||
- Uses `appSettings.headerMessage` template with `{customerName}` and `{vehicleInfo}` placeholders
|
||||
- Split to fit content width
|
||||
|
||||
### Services Section
|
||||
- Split into "SERVICES APPROVED" and "POSTPONED SERVICES" if mixed decisions exist
|
||||
- Each service: green "✓ CUSTOMER APPROVED" or red "✗ CUSTOMER DECLINED" badge
|
||||
- Service name: **bold, 10px** (reduced from 12px)
|
||||
- Price: right-aligned
|
||||
- Priority header (if prioritized): `[CRITICAL]`, `[SAFETY]`, `[RECOMMENDED]`, `[MAINTENANCE]`, `[OPTIONAL]`
|
||||
- Recommendation level text
|
||||
- Service explanation (split to fit width)
|
||||
- Parts/aftermarket notes
|
||||
- Light gray separator between services
|
||||
|
||||
### Pricing Summary (totals box)
|
||||
- Subtle `#f8f8f8` rounded-rect background box with `#dcdcdc` border
|
||||
- 45% width from right margin
|
||||
- Line items (9px, labels in gray `#646464`): Subtotal, Approved (green), Discount (green with − sign), Shop charge, Tax
|
||||
- Separator: 0.5px `#b4b4b4` line
|
||||
- TOTAL: bold 10px
|
||||
- Row spacing: 8px throughout
|
||||
|
||||
### Shop Charge Note
|
||||
- Positioned at `boxBottomY + 10` — must stay after the box's visual boundary
|
||||
- 8px font, gray, prefixed with `*`
|
||||
|
||||
### Footer
|
||||
- Light gray separator line
|
||||
- Centered `appSettings.footerMessage` and `appSettings.quoteFooterNote`
|
||||
- Page numbers: "Page X of Y" at bottom right
|
||||
|
||||
## Key Design Choice
|
||||
`technicianNotes` are deliberately excluded from PDFs — they're internal AI context notes, not customer-facing.
|
||||
|
||||
## Invoice PDF (repair-orders.js)
|
||||
Separate from quotes. `printInvoice()` at line ~1340 in `repair-orders.js` calls `generateInvoicePDF()` which also uses jsPDF but with a different layout.
|
||||
@@ -0,0 +1,147 @@
|
||||
# Quote Pricing Modes & RO Reconciliation
|
||||
|
||||
Patterns added 2026-07-08 covering Split/Full pricing and approved-service writeback.
|
||||
|
||||
## Split vs Full Pricing Mode
|
||||
|
||||
Services in the Quote Generator and RO Form now support two pricing modes via `pricingMode: 'full' | 'split'` on the `Service` / `QuoteService` / `ROService` type.
|
||||
|
||||
### Type Fields (`src/types.ts`)
|
||||
|
||||
```typescript
|
||||
interface Service {
|
||||
// ... existing ...
|
||||
laborHours?: number;
|
||||
laborRate?: number;
|
||||
partsCost?: number;
|
||||
pricingMode?: 'full' | 'split';
|
||||
}
|
||||
```
|
||||
|
||||
ROService already had `pricingMode?: 'full' | 'split'` (line 219). QuoteService inherits through Service.
|
||||
|
||||
### Schema (`src/schemas/quote.ts`)
|
||||
|
||||
```typescript
|
||||
quoteServiceSchema = z.object({
|
||||
// ... existing ...
|
||||
pricingMode: z.enum(['full', 'split']).optional(),
|
||||
laborHours: z.number().nonnegative().optional(),
|
||||
laborRate: z.number().nonnegative().optional(),
|
||||
partsCost: z.number().nonnegative().optional(),
|
||||
});
|
||||
```
|
||||
|
||||
### Quote Generator — ServiceRow.tsx
|
||||
|
||||
Toggle buttons per row in expanded view:
|
||||
- **[Full]** default — shows single `$` price input
|
||||
- **[Split]** — shows Hrs × Rate + Parts inputs, total auto-calc `= $XXX.XX`
|
||||
|
||||
Switching Full→Split: derives `hours = price / defaultLaborRate` from settings.
|
||||
Switching Split→Full: keeps current price (from split calc), hides split fields.
|
||||
Auto-recalc: changing Hrs/Rate/Parts triggers `price = (hrs × rate) + partsCost` on every `onChange`.
|
||||
|
||||
### RO Form — ROForm.tsx (ServiceRow sub-component)
|
||||
|
||||
Same Full/Split toggle on each service row header. Key details:
|
||||
- **Split mode** (default when `pricingMode` is undefined — backward-compatible): 5-column grid (Name, Labor Hrs, Rate/hr, Parts $, read-only Total)
|
||||
- **Full mode**: 2-column grid (Name, editable Total Price input)
|
||||
- `roServiceTotal()` already falls back to `s.total` when split calc = 0 — always pass `total: s.total || 0` when calling it
|
||||
- Switching Full→Split: derives hours from flat total ÷ defaultLaborRate, sets laborRate/partsCost
|
||||
|
||||
### Catalog Service Add
|
||||
|
||||
Both `ServiceSearch.tsx` (quote) and `ROForm.tsx` (RO) map split pricing fields from the PB `services` collection. The RO form's `handleAddCatalogService` already detects split vs full:
|
||||
```typescript
|
||||
const laborPrice = (svc.laborHours || 0) * (svc.laborRate || 0);
|
||||
const isSplit = laborPrice > 0 || (svc.partsCost || 0) > 0;
|
||||
// sets pricingMode accordingly
|
||||
```
|
||||
|
||||
### Where to check for existing split/full support
|
||||
|
||||
`RODetailModal.tsx` already has full split/full support (EditableServiceRow, PricingBadge, new service form toggle). No changes needed there.
|
||||
|
||||
## Approved Quote Services Sync Back to RO
|
||||
|
||||
Added in `src/components/quoteGenerator/QuoteSummary.tsx` to both `handleSave` and `ensureShareToken`.
|
||||
|
||||
### Trigger
|
||||
When a quote is saved/shared and `repairOriginId` is set (quote was generated from a Repair Order).
|
||||
|
||||
### What happens
|
||||
1. Filters services where `approved === true`
|
||||
2. For each approved service, maps to ROService shape: `{ name, description, laborHours, laborRate, partsCost, total, status: 'pending' }`
|
||||
3. Loads the current RO's services
|
||||
4. Merges: matching by `name` — if exists, updates price/etc but preserves existing `status` and `technician`; if new, appends it
|
||||
5. Writes merged services back to the RO via `pb.collection('repairOrders').update()`
|
||||
|
||||
### Why two sync points
|
||||
- `handleSave` — Save Quote button
|
||||
- `ensureShareToken` — Share with Customer / Send Email / Send Text (auto-saves the quote)
|
||||
|
||||
Both paths are needed because share auto-creates the quote without going through handleSave.
|
||||
|
||||
## Pagination Removal Pattern
|
||||
|
||||
When users want "show all records" instead of paginated results:
|
||||
|
||||
### Before (paginated)
|
||||
```typescript
|
||||
const records = await pb.collection('repairOrders').getList(page, perPage, {
|
||||
filter: `userId = '${userId}'`,
|
||||
sort: '-id',
|
||||
});
|
||||
return {
|
||||
items: records.items.map(normalizeRO),
|
||||
page: records.page,
|
||||
perPage: records.perPage,
|
||||
totalItems: records.totalItems,
|
||||
totalPages: records.totalPages,
|
||||
};
|
||||
```
|
||||
|
||||
### After (unlimited)
|
||||
```typescript
|
||||
const records = await pb.collection('repairOrders').getFullList({
|
||||
filter: `userId = '${userId}'`,
|
||||
sort: '-id',
|
||||
});
|
||||
return {
|
||||
items: (records as any[]).map(normalizeRO),
|
||||
page: 1,
|
||||
perPage: 999999,
|
||||
totalItems: records.length,
|
||||
totalPages: 1, // makes hasMore=false in usePagedList
|
||||
};
|
||||
```
|
||||
|
||||
This keeps `usePagedList`'s interface happy while making `hasMore` always `false`.
|
||||
The "Load more" button never appears.
|
||||
|
||||
## Cross-Domain LocalStorage Debug Pattern
|
||||
|
||||
When same built code works on one domain but fails on another:
|
||||
|
||||
### Core insight
|
||||
Zustand stores with `persist` middleware save to `localStorage` under a fixed key (e.g. `spq-quote`). localStorage is **per-origin** in the browser. Same nginx server block, same build files, same PocketBase — but different browser origins have different localStorage.
|
||||
|
||||
### Most common failure
|
||||
Stale discount type or service data from an older app version that doesn't match the current Zod schema. In `QuoteSummary.tsx:126`:
|
||||
```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');
|
||||
}
|
||||
```
|
||||
|
||||
### Verification
|
||||
1. Open DevTools → Console on the failing domain
|
||||
2. Click Save/Share — look for `Quote schema validation failed:` warning
|
||||
3. The console warning shows the exact Zod issue (e.g. `invalid_enum_value` on `discountType`)
|
||||
4. Open Application → Local Storage → key `spq-quote` to see stale state
|
||||
|
||||
### Fix
|
||||
Clear localStorage for that domain (`localStorage.removeItem('spq-quote')`) and reload.
|
||||
@@ -0,0 +1,183 @@
|
||||
# Quote Save Flow & Data Model
|
||||
|
||||
## Save destination
|
||||
|
||||
PocketBase `quotes` collection, via `pocketbase.js` Firebase-compatible adapter. No external APIs.
|
||||
|
||||
```
|
||||
Save Quote button click
|
||||
→ saveQuote() [quote-tab-manager.js:2496]
|
||||
→ import pocketbase.js (Firebase-compat API)
|
||||
→ pocketbase.js connects to window.location.origin (same server)
|
||||
→ addDoc(collection(db, 'quotes'), quoteData) // new
|
||||
→ setDoc(doc(db, 'quotes', id), {...}, {merge}) // update
|
||||
```
|
||||
|
||||
## Quote data shape
|
||||
|
||||
```javascript
|
||||
const quoteData = {
|
||||
userId: currentUser.uid,
|
||||
customerName: string,
|
||||
customerPhone: string,
|
||||
serviceAdvisor: string,
|
||||
repairOrderNumber: string,
|
||||
vehicleInfo: string, // e.g., "2019 Honda Pilot"
|
||||
mileage: string,
|
||||
vin: string,
|
||||
services: [{
|
||||
...serviceObject, // spread of the service fields from PocketBase
|
||||
price: number,
|
||||
partsNotInStock: boolean,
|
||||
aftermarketAvailable: boolean,
|
||||
aftermarketPartsList: string|null,
|
||||
partsDeliveryTime: string|null
|
||||
}],
|
||||
discountValue: number, // parsed from DOM input
|
||||
discountType: 'dollar'|'percent',
|
||||
total: number, // parsed from #total-amount
|
||||
lastUpdated: Date|Timestamp,
|
||||
status: 'converted'|undefined // auto-set when all services decided
|
||||
};
|
||||
```
|
||||
|
||||
## Auto-conversion on full decision
|
||||
|
||||
When `saveQuote()` builds `quoteData`, it checks if every service has a `customerDecision` of `'approved'` or `'declined'` (none `'pending'`). If so, it sets `quoteData.status = 'converted'` before saving. This causes the quote to drop out of the Daily Briefing's active-quote list immediately.
|
||||
|
||||
Implementation in `quote-tab-manager.js` ~line 2995:
|
||||
```javascript
|
||||
const allDecided = selectedServices.length > 0 &&
|
||||
selectedServices.every(s => s.customerDecision === 'approved' || s.customerDecision === 'declined');
|
||||
if (allDecided) {
|
||||
quoteData.status = 'converted';
|
||||
}
|
||||
```
|
||||
|
||||
## Load flow
|
||||
|
||||
When Quote Generator tab opens:
|
||||
1. `initializeQuoteTab()` → `loadSavedQuotesEnhanced()`
|
||||
2. Queries `quotes` collection filtered by `userId`, ordered by `lastUpdated desc`
|
||||
3. Renders into element `#saved-quotes-list` (sidebar card, up to 5 quotes)
|
||||
4. If >0 quotes, shows "See All N Quotes" button → opens `#quotes-modal` with full searchable/sortable list
|
||||
|
||||
## Delete flow
|
||||
|
||||
`deleteQuote(quoteId)` in `quote-tab-manager.js`:
|
||||
1. `confirm('Delete this quote permanently?')` — user confirmation
|
||||
2. `deleteDoc(doc(db, 'quotes', quoteId))` via pocketbase.js adapter
|
||||
3. Remove from `allQuotesData` cache for immediate modal update
|
||||
4. `await loadSavedQuotesEnhanced()` — refresh sidebar list from DB
|
||||
5. If `#quotes-modal` is open, `populateAndRenderQuotesModal(allQuotesData)` to refresh it
|
||||
|
||||
**Delete buttons**: trash can SVG icon (heroicons outline) appears on hover via `opacity-0 group-hover:opacity-100`. Uses `e.stopPropagation()` so clicking trash doesn't also trigger load. Pattern applied in both the sidebar cards and the modal list.
|
||||
|
||||
## UI Elements (both were missing — now exist)
|
||||
|
||||
### v2 Save Flow (React/Vite)
|
||||
|
||||
The v2 QuoteGenerator has a distinct save flow from the legacy v1. Key differences:
|
||||
|
||||
### handleSave — top-level field sync
|
||||
|
||||
The v2 `handleSave()` writes customer info both as a nested `customerInfo` JSON blob AND as individual top-level fields:
|
||||
|
||||
```tsx
|
||||
const data = {
|
||||
userId: pb.authStore.model?.id,
|
||||
customerName: customerInfo.name,
|
||||
vehicleInfo: customerInfo.vehicleInfo,
|
||||
roNumber: customerInfo.roNumber,
|
||||
customerInfo: JSON.parse(JSON.stringify(customerInfo)),
|
||||
services: JSON.parse(JSON.stringify(services)),
|
||||
discount: JSON.parse(JSON.stringify(discount)),
|
||||
total: grandTotal,
|
||||
status: 'draft',
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
```
|
||||
|
||||
The top-level fields (`customerName`, `vehicleInfo`, `roNumber`) serve the Dashboard's list queries (`fields: 'id,customerName,vehicleInfo,roNumber,total,status,createdAt,lastUpdated'`). Without them, the Dashboard shows empty values for records created by the new app.
|
||||
|
||||
**Pitfall (fixed 2026-06-28)**: The original v2 `handleSave` only saved `customerInfo` as a JSON blob. Dashboard queries for `customerName` and `vehicleInfo` returned empty. Fix: add the three top-level fields alongside the nested blob.
|
||||
|
||||
### Load logic — merged fallback pattern
|
||||
|
||||
When loading a quote via `?edit=ID`, the v2 code merges top-level fields with nested JSON to handle both old (individual top-level fields) and new (nested `customerInfo` JSON) record formats:
|
||||
|
||||
```tsx
|
||||
const rawCustomerInfo = data.customerInfo
|
||||
? { ...data, ...(typeof data.customerInfo === 'string' ? JSON.parse(data.customerInfo) : data.customerInfo) }
|
||||
: data;
|
||||
const ci = normalizeCustomerInfo(rawCustomerInfo);
|
||||
```
|
||||
|
||||
The merge `{ ...data, ...customerInfo }` means nested `customerInfo` fields take priority, but top-level fields fill in any gaps (e.g., old records that stored `roNumber` as a flat field but not inside `customerInfo`).
|
||||
|
||||
### applyShopCharge default
|
||||
|
||||
In v2, `applyShopCharge` must default to `true` for new services added via `handleAdd()`. The store default (`undefined`) is falsy, producing $0 shop charge and hiding the row in Quote Summary. See `v2-quote-pdf-architecture.md` for the fix.
|
||||
|
||||
### Dashboard Quote Jump dropdown (v2)
|
||||
|
||||
Replaced the native `<select>` with a custom React dropdown in the Recent Quotes section. Pattern:
|
||||
- **Trigger**: styled `<button>` with placeholder text + rotating `ChevronDown` icon
|
||||
- **Panel**: absolutely positioned `<div>` below trigger, `z-50`, `max-h-72` with scroll
|
||||
- **Close**: click-outside handler via `useRef` + `mousedown` listener in `useEffect`
|
||||
- **Items**: Each shows customer name + RO# on top line, vehicle + date on second line (left), total + status badge (right)
|
||||
- **Navigation**: Selecting an item calls `navigate()` and closes the panel
|
||||
|
||||
See `references/ui-pitfalls.md` for the React custom dropdown pattern details.
|
||||
|
||||
### v1 Saved Quotes UI (legacy)
|
||||
|
||||
### `#saved-quotes-list` (sidebar card)
|
||||
Inside `#generate-quote-content`, below the Summary + Actions row. A "Saved Quotes" card with header (clipboard icon, title, subtitle) and body containing the list. Shows up to 5 recent quotes. Each quote card shows customer name, vehicle, date, and total. Trash icon appears on hover top-right. Click card to load quote.
|
||||
|
||||
### `#quotes-modal` (full modal)
|
||||
Before `<script>` tags in `repair-orders.html`. Modal with:
|
||||
- **Header**: "All Saved Quotes" title + close button (X)
|
||||
- **Search bar**: `#quotes-modal-search-main` — filters by customer, vehicle, or ID
|
||||
- **Sort dropdown**: `#quotes-modal-sort-main` — newest first, oldest first, customer name, highest total, lowest total
|
||||
- **Count display**: `#quotes-count-display` — shows "Showing X of Y total quotes"
|
||||
- **List**: `#modal-quotes-list` — each row has customer/vehicle on left, total/date on right, trash icon on hover
|
||||
- **Empty state**: `#quotes-empty-state` — shown when no quotes match filter
|
||||
- **Footer**: Close button `#quotes-modal-close-btn-2-main`
|
||||
|
||||
Both close buttons (`#quotes-modal-close-btn` and `#quotes-modal-close-btn-2-main`) call `closeQuotesModal()`. Click-outside-to-close works via the global modal handler.
|
||||
|
||||
## PocketBase `quotes` collection schema
|
||||
|
||||
**Full field list** (26 fields as of 2026-06-07):
|
||||
|
||||
| Field | Type | Required | Notes |
|
||||
|-------|------|----------|-------|
|
||||
| `id` | text | ✓ | PocketBase auto-generated |
|
||||
| `userId` | text | ✓ | PB user ID |
|
||||
| `customerName` | text | | |
|
||||
| `customerPhone` | text | | |
|
||||
| `customerEmail` | text | | legacy, unused by saveQuote |
|
||||
| `serviceAdvisor` | text | | |
|
||||
| `repairOrderNumber` | text | | |
|
||||
| `vehicleInfo` | text | | e.g. "2019 Honda Pilot" |
|
||||
| `mileage` | text | | |
|
||||
| `vin` | text | | |
|
||||
| `services` | json | | array of service objects |
|
||||
| `discountValue` | number | | |
|
||||
| `discountType` | text | | "dollar" or "percent" |
|
||||
| `subtotal` | number | | legacy, unused by saveQuote |
|
||||
| `tax` | number | | legacy, unused by saveQuote |
|
||||
| `total` | number | | |
|
||||
| `lastUpdated` | date | | ISO 8601 string |
|
||||
| `createdAt` | text | | legacy |
|
||||
| `updatedAt` | text | | legacy |
|
||||
| `deviceModel` | text | | legacy |
|
||||
| `deviceType` | text | | legacy |
|
||||
| `issue` | text | | legacy |
|
||||
| `status` | text | | legacy |
|
||||
| `notes` | text | | legacy |
|
||||
| `customerId` | text | | legacy |
|
||||
| `repairOrderId` | text | | legacy |
|
||||
|
||||
**Schema mismatch is the #1 cause of "Error saving quote".** The collection had leftover fields from a previous use (device repair tracking). The 9 fields needed by saveQuote (`serviceAdvisor`, `repairOrderNumber`, `vehicleInfo`, `mileage`, `vin`, `services`, `discountValue`, `discountType`, `lastUpdated`) were all missing. Fix: PATCH `/api/collections/quotes` with the complete fields array. See `references/pocketbase-schema-patch.md` for the Python script pattern.
|
||||
@@ -0,0 +1,61 @@
|
||||
# QuoteService dual-field trap: `approved` vs `customerDecision`
|
||||
|
||||
The `QuoteService` type has TWO fields that represent approval state:
|
||||
|
||||
```typescript
|
||||
export interface QuoteService extends Service {
|
||||
approved: boolean;
|
||||
customerDecision: 'approved' | 'declined' | 'pending';
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
These MUST stay in sync. When they diverge, the UI summary and the PDF produce different totals.
|
||||
|
||||
## The invariant
|
||||
|
||||
Every code path that sets one MUST set the other consistently:
|
||||
|
||||
| State | `approved` | `customerDecision` |
|
||||
|-------|-----------|-------------------|
|
||||
| Customer approved | `true` | `'approved'` |
|
||||
| Customer declined | `false` | `'declined'` |
|
||||
| Pending decision | `false` | `'pending'` |
|
||||
|
||||
## Where each filter is used
|
||||
|
||||
### ALL totals (src/lib/totals.ts, src/store/quote.ts, src/lib/pdf.ts)
|
||||
All three now use `customerDecision !== 'declined'` — both approved AND pending services count.
|
||||
Only declined services are excluded from subtotal, discount base, shop charge, and tax.
|
||||
Both the on-screen Quote Summary sidebar AND the generated PDF/Print use the same filter.
|
||||
|
||||
### UI grouping (src/pages/QuoteGenerator.tsx ServicesTable)
|
||||
Three sections by `customerDecision`: Approved (green), Pending (gray), Declined (red).
|
||||
|
||||
## Three-state toggle system
|
||||
|
||||
Two store actions handle the three states, both keeping fields in sync:
|
||||
|
||||
**`toggleApproved(id)`** — toggles between approved ↔ pending:
|
||||
- If approved → `{ approved: false, customerDecision: 'pending' }`
|
||||
- If not approved → `{ approved: true, customerDecision: 'approved' }`
|
||||
|
||||
**`toggleDeclined(id)`** — toggles between declined ↔ pending:
|
||||
- If declined → `{ approved: false, customerDecision: 'pending' }`
|
||||
- If not declined → `{ approved: false, customerDecision: 'declined' }`
|
||||
|
||||
Both are wired to adjacent checkboxes in the ServiceRow: green ✓ (toggleApproved) and red ✕ (toggleDeclined).
|
||||
|
||||
The expanded Customer Decision buttons (Approve / Decline / Pending) are the alternative UI path — they use `onUpdate()` to set both fields atomically.
|
||||
|
||||
## Pitfall: all three must use the same filter
|
||||
|
||||
All three places that compute totals must use the SAME filter — `customerDecision !== 'declined'`:
|
||||
|
||||
- **`computeQuoteTotals()`** in `src/lib/totals.ts`
|
||||
- **Store `subtotal()` / `approvedSubtotal()`** in `src/store/quote.ts`
|
||||
- **PDF `servicesTotal` + shop charge** in `src/lib/pdf.ts`
|
||||
|
||||
If any one of these diverges, the on-screen summary and the generated PDF will show different amounts, or the PDF will show $0.00 when all services are pending.
|
||||
|
||||
**Prevention:** when changing the filter rule, grep all three files and change them together.
|
||||
@@ -0,0 +1,107 @@
|
||||
# Quote Sharing & Customer Approval Architecture
|
||||
|
||||
## Overview
|
||||
|
||||
The quote → share → customer approve/decline → shop notified flow. Covers v2 React/Vite frontend only.
|
||||
|
||||
## Key Files
|
||||
|
||||
| File | Role |
|
||||
|------|------|
|
||||
| `src/components/quoteGenerator/QuoteSummary.tsx` | "Share with Customer" button + email/SMS draft links |
|
||||
| `src/pages/QuoteApprove.tsx` | Public customer-facing approval page (no auth) |
|
||||
| `src/store/quote.ts` | Zustand store with `approvedSubtotal()`, decision state |
|
||||
| `src/lib/contactLinks.ts` | `openEmailDraft()` and `openSmsDraft()` — local mailto:/sms: links only |
|
||||
| `src/lib/totals.ts` | Billable subtotal logic: approved-only vs all-non-declined |
|
||||
| `src/pages/Dashboard.tsx` | Real-time subscription on quotes collection (line 162) |
|
||||
| `src/schemas/quote.ts` | Zod validation — `shareToken`, `status`, service `customerDecision` |
|
||||
|
||||
## Share Flow (QuoteSummary.tsx:104-170)
|
||||
|
||||
1. `ensureShareToken()` called — saves quote to PocketBase if not yet saved, generates a `crypto.randomUUID()` token, writes it to `quote.shareToken`
|
||||
2. Returns URL: `${window.location.origin}/quote/approve/${token}`
|
||||
3. **Copy to clipboard**: `handleShare()` (line 154) — copies link, shows shareUrl UI with Email/SMS buttons
|
||||
4. **Email draft**: `handleSendEmail()` (line 172) — calls `openEmailDraft(to="customerPhone", subject, body)` which fires `mailto:` link. Does NOT send from server.
|
||||
5. **SMS draft**: `handleSendSms()` (line 195) — calls `openSmsDraft(to="customerPhone", body)` which fires `sms:` link. Does NOT send from server.
|
||||
6. Quote status set to `'sent'` on first share.
|
||||
|
||||
**Important**: `customerEmail` does NOT exist on `CustomerInfo` or the quote schema (`quoteWriteSchema`). Only `customerPhone` is captured. The `mailto:` link uses phone number as the `to` field (a no-op in most clients).
|
||||
|
||||
## Customer Approval Page (QuoteApprove.tsx)
|
||||
|
||||
### Loading
|
||||
- Public route: `/quote/approve/:token` — no auth required
|
||||
- `loadQuote()` queries PocketBase using `shareToken` as both filter and query param (PB API rule gating)
|
||||
- On load, stamps `viewedAt` on the quote record via `stampViewed()` — fires once per quote
|
||||
|
||||
### Approval Flow
|
||||
1. Customer sees per-service Approve/Decline toggle buttons
|
||||
2. Must make a decision on every visible service before submitting
|
||||
3. Must enter their full name as signature
|
||||
4. `handleSubmitAll()` (line 159) writes per-service decisions to PocketBase:
|
||||
- `customerDecision`: `'approved'` / `'declined'`
|
||||
- `approved`: `true` / `false`
|
||||
- `authorizedBy`: customer's name
|
||||
- `authorizedAt`: ISO timestamp
|
||||
- `authorizedMethod`: `'signature'`
|
||||
5. Top-level quote `status` computed:
|
||||
- All approved → `'approved'`
|
||||
- All declined → `'declined'`
|
||||
- Mixed → `'sent'` (stays "pending" in UI filters)
|
||||
|
||||
### Security Gaps
|
||||
- **No identity verification**: The share token (UUID) is the only gate. Anyone with the URL can approve/decline. No phone/email verification, no PIN, no expiry.
|
||||
- **No customer email field**: `CustomerInfo` type lacks `email`. Only repair orders and invoices have `customerEmail`. No way to send the share link to an email address even if email sending were implemented.
|
||||
- **No expiry**: Roadmap item 4.2 suggests PB `onRecordBeforeUpdateRequest` hook checking `quoteExpiryDays` from settings, but not implemented.
|
||||
|
||||
## How the Shop Knows
|
||||
|
||||
### Dashboard Real-Time Subscription (Dashboard.tsx:157-189)
|
||||
```ts
|
||||
unsub = await pb.collection('quotes').subscribe('*', (data) => {
|
||||
// Delta-applies quote changes to the in-memory list
|
||||
// Stats (approved count, pending review) update reactively
|
||||
});
|
||||
```
|
||||
- **Live on Dashboard only**: Stats update without refresh while on that page
|
||||
- **No proactive notification anywhere**: No toast popup, no browser Notification API, no sound, no email/SMS sent to the shop
|
||||
- **Other pages**: Quotes page (`QuoteGenerator.tsx`) must be manually refreshed
|
||||
|
||||
### Toggle in Settings
|
||||
- Settings → Notifications tab has "Email Notifications" toggle (description: "Receive email notifications for quote updates and approvals")
|
||||
- **Placard only**: Saved to `localStorage` as `spq-emailNotifications`. No backend code reads this preference. No email is ever sent regardless of toggle state.
|
||||
- Roadmap 4.1 describes a PB `onRecordAfterUpdateRequest` hook that would auto-send email via PB SMTP when quote status changes from `sent` to `approved`/`declined` — not implemented.
|
||||
|
||||
## Verified Capabilities & Gaps Matrix
|
||||
|
||||
| Capability | Status | Where |
|
||||
|-----------|--------|-------|
|
||||
| Generate share link with UUID token | ✅ Done | QuoteSummary.tsx:104-146 |
|
||||
| Copy link to clipboard | ✅ Done | QuoteSummary.tsx:164 |
|
||||
| Open local email/SMS draft | ⚠️ mailto:/sms: only, no server sending | contactLinks.ts |
|
||||
| Customer views & stamps viewedAt | ✅ Done | QuoteApprove.tsx:56-67 |
|
||||
| Per-service approve/decline | ✅ Done | QuoteApprove.tsx:155-157 |
|
||||
| Per-service authorizedBy/authorizedAt tracking | ✅ Done | QuoteApprove.tsx:180-192 |
|
||||
| Quote status update (approved/declined/sent) | ✅ Done | QuoteApprove.tsx:198-205 |
|
||||
| Real-time Dashboard updates | ✅ Done | Dashboard.tsx:162-183 |
|
||||
| **Toast/notification when customer responds** | ❌ Missing | — |
|
||||
| **Browser Notification API alert** | ❌ Missing | — |
|
||||
| **Email sent to shop on approval** | ❌ Missing | Planned at roadmap 4.1 |
|
||||
| **Phone/last-4 verification** | ❌ Missing | — |
|
||||
| **Quote expiry** | ❌ Missing | Planned at roadmap 4.2 |
|
||||
| **customerEmail field on quote form** | ❌ Missing | Only on RO/Invoice schemas |
|
||||
| **Server-side email sending** | ❌ Missing | No SMTP util in codebase |
|
||||
| **Server-side SMS sending** | ❌ Missing | Would need Twilio/etc |
|
||||
|
||||
## Related Roadmap Items
|
||||
|
||||
- **4.1 Quote approval notifications**: PB hook sends email when status flips from `sent` to `approved`/`declined`. Requires PB SMTP from 3.2 (already configured in Docker env per Ray).
|
||||
- **4.2 Quote expiry enforcement**: PB hook blocks updates if `quoteExpiryDays` exceeded and request uses `shareToken` query param.
|
||||
|
||||
## Approximate Approach for Adding Identity Verification (least-infrastructure option)
|
||||
|
||||
1. Add `verificationCode` (short random string, e.g. last-4-of-phone or 4-digit PIN) to the quote PB record — set it when generating the share link
|
||||
2. On `QuoteApprove.tsx` load, if verification code exists, show a code-entry prompt before revealing services
|
||||
3. Customer enters the code → matches → show the approval UI
|
||||
4. The verification code can be communicated separately (verbally, or via SMS/email through existing channels)
|
||||
5. No server-side infrastructure beyond the PB record field
|
||||
@@ -0,0 +1,41 @@
|
||||
# Recent Quotes Status Derivation (v2)
|
||||
|
||||
## The Bug
|
||||
|
||||
In the Recent Quotes panel (`src/pages/QuoteGenerator.tsx`), the status badge was showing "Draft" even when ALL services had been decided (approved or declined) by shop staff in the QuoteGenerator.
|
||||
|
||||
## Root Cause
|
||||
|
||||
The quote's `status` field in PocketBase is only a **lifecycle** field:
|
||||
- `'draft'` — set on Save (QuoteSummary.tsx:123)
|
||||
- `'sent'` — set on Share (QuoteSummary.tsx:246)
|
||||
- `'approved'` / `'declined'` — set by the customer via QuoteApprove.tsx:221-225
|
||||
|
||||
When shop staff approve/decline individual services in the QuoteGenerator, the `customerDecision` values are saved inside the services JSON blob, but the quote-level `status` field is NOT updated. It stays `'draft'`.
|
||||
|
||||
## The Fix
|
||||
|
||||
In `loadRecentQuotes()` (QuoteGenerator.tsx:196), derive the display status from individual service decisions when ALL services have been decided:
|
||||
|
||||
```typescript
|
||||
const hasPending = servicesList.some((s: any) => !s.customerDecision || s.customerDecision === 'pending');
|
||||
let derivedStatus: QuoteRecord['status'] = record.status;
|
||||
if (!hasPending && servicesList.length > 0) {
|
||||
const allApproved = servicesList.every((s: any) => s.customerDecision === 'approved');
|
||||
const allDeclined = servicesList.every((s: any) => s.customerDecision === 'declined');
|
||||
if (allApproved) derivedStatus = 'approved';
|
||||
else if (allDeclined) derivedStatus = 'declined';
|
||||
}
|
||||
// Use derivedStatus in the returned object's `status` field
|
||||
```
|
||||
|
||||
This overrides status to:
|
||||
- `'approved'` — when ALL services have `customerDecision === 'approved'`
|
||||
- `'declined'` — when ALL services have `customerDecision === 'declined'`
|
||||
- Keeps original PocketBase status — when some services are still pending, or when decisions are mixed (some approved, some declined)
|
||||
|
||||
Because the mapped object's `status` field feeds both the `RecentStatusBadge` component and the filter/count logic (`recentCounts`, `matchesRecentStatusFilter`), this single change fixes the badge, the filter tabs, and the filter counts.
|
||||
|
||||
## Also Fixed
|
||||
|
||||
The `hasPending` check was previously `servicesList.some(s => s.customerDecision === 'pending')`. A service with no `customerDecision` field at all would NOT be counted as pending. Changed to `servicesList.some(s => !s.customerDecision || s.customerDecision === 'pending')` to handle services that don't have the field yet (edge case with legacy data).
|
||||
@@ -0,0 +1,79 @@
|
||||
# Maintenance Reminders (Phase 4.6)
|
||||
|
||||
## Overview
|
||||
|
||||
Maintenance reminders allow shops to set mileage-based follow-up reminders for customers after service. Reminders are created from the RO Detail Modal and displayed on the Dashboard.
|
||||
|
||||
## PocketBase Collection: `reminders`
|
||||
|
||||
| Field | Type | Notes |
|
||||
|-------|------|-------|
|
||||
| `customerId` | text | From RO's `customerId` |
|
||||
| `customerName` | text | Pre-filled from RO |
|
||||
| `vehicleInfo` | text | Pre-filled from RO |
|
||||
| `shopUserId` | text | The shop's PB user ID |
|
||||
| `mileageAtService` | number | User-entered, pre-filled from `ro.mileage` |
|
||||
| `intervalMileage` | number | User-entered, defaults to 5000 |
|
||||
| `dueMileage` | number | Computed: `mileageAtService + intervalMileage` |
|
||||
| `notes` | text | Optional notes field |
|
||||
| `active` | bool | Set `true` on creation |
|
||||
|
||||
## Integration Points
|
||||
|
||||
### 1. RODetailModal.tsx — Set Reminder
|
||||
|
||||
**Location**: Header area, in the button group next to Print/PDF/Generate Quote buttons.
|
||||
|
||||
**Button**: `BellRing` icon, toggles the inline form. Pre-fills `reminderMileage` from `ro.mileage` when opened. Active state uses blue styling to indicate the form is open.
|
||||
|
||||
**Inline Form** (appears below header, above info bar):
|
||||
- Blue-themed card (`border-blue-200 bg-blue-50`)
|
||||
- Shows customer name and vehicle info from RO (read-only)
|
||||
- Three fields in a grid:
|
||||
1. Mileage at Service (pre-filled from `ro.mileage`)
|
||||
2. Interval in miles (defaults to 5000)
|
||||
3. Due Mileage (computed live: `(parseInt(mileage)||0) + (parseInt(interval)||0)`)
|
||||
- Notes textarea (optional)
|
||||
- Cancel / Save Reminder buttons with loading spinner
|
||||
|
||||
**State variables** (in main `RODetailModal` component):
|
||||
```
|
||||
showReminderForm, reminderMileage, reminderInterval, reminderNotes, savingReminder
|
||||
```
|
||||
|
||||
**Handler**: `handleSaveReminder()` — validates non-zero mileage + interval, creates record in `reminders` collection, shows success/error toast.
|
||||
|
||||
**Imports used**: `BellRing` from lucide-react (added to existing icon import line).
|
||||
|
||||
### 2. Dashboard.tsx — Reminders Widget
|
||||
|
||||
**Location**: Between the header row and the loading state section. Only renders when `reminders.length > 0`.
|
||||
|
||||
**Widget**: White card with ring shadow, contains:
|
||||
- Header: `BellRing` icon + "Maintenance Reminders" title + count badge ("N active" in blue pill)
|
||||
- Each reminder row shows:
|
||||
- Customer name + vehicle info
|
||||
- Optional notes (italic)
|
||||
- Due mileage (right-aligned)
|
||||
- "Active"/"Paused" badge (amber if `dueMileage > 0`, green otherwise)
|
||||
|
||||
**Data fetching**: `fetchReminders()` callback queries `reminders` collection filtered by `shopUserId` and `active = true`, sorted `-created`. Called via `useEffect` on mount.
|
||||
|
||||
**Interface**: `ReminderRecord` interface defined locally in Dashboard.tsx.
|
||||
|
||||
**Imports used**: `BellRing` from lucide-react (added to existing icon import).
|
||||
|
||||
## Verification
|
||||
|
||||
After changes to either file, run:
|
||||
```bash
|
||||
npm run build # tsc + vite build
|
||||
npm run lint # oxlint
|
||||
npm run test # vitest
|
||||
```
|
||||
|
||||
## Pitfalls
|
||||
|
||||
- **Unused import errors**: TypeScript `tsc -b` is strict about unused variables/imports. If you import `useToast` but don't call `showToast`, the build fails. Remove the unused import and destructuring.
|
||||
- **Collection must exist in PocketBase**: The `reminders` collection needs to be created via a PB migration or admin UI before the code will work at runtime.
|
||||
- **Pre-fill mileage**: The "Set Reminder" button click handler pre-fills from `ro.mileage` — make sure the button's `onClick` includes `setReminderMileage(ro.mileage || '')` so the form opens with the current mileage.
|
||||
@@ -0,0 +1,138 @@
|
||||
# Repair Order Status Workflow (6-Status System)
|
||||
|
||||
Introduced June 2026. Replaced the original 4-status system (in-progress, waiting-for-parts, completed, waiting-for-pickup).
|
||||
|
||||
## Statuses and Display Colors
|
||||
|
||||
| Status | Label | Color | Badge Class |
|
||||
|--------|-------|-------|-------------|
|
||||
| `diagnosing` | DIAGNOSING | amber-100/800 | `bg-amber-100 text-amber-800 dark:bg-amber-900 dark:text-amber-200` |
|
||||
| `in-progress` | IN PROGRESS | green | `bg-green-100 text-green-800` |
|
||||
| `waiting-for-approval` | WAITING APPROVAL | rose | `bg-rose-100 text-rose-800` |
|
||||
| `waiting-for-parts` | WAITING FOR PARTS | orange | `bg-orange-100 text-orange-800` |
|
||||
| `waiting-for-pickup` | WAITING FOR PICK-UP | blue | `bg-blue-100 text-blue-800` |
|
||||
| `completed` | COMPLETED | purple | `bg-purple-100 text-purple-800` |
|
||||
|
||||
## Sorting Priority (renderRepairOrders)
|
||||
|
||||
```
|
||||
diagnosing: 0, in-progress: 1, waiting-for-approval: 2,
|
||||
waiting-for-pickup: 3, waiting-for-parts: 4, completed: 5
|
||||
```
|
||||
|
||||
New ROs default to `diagnosing`.
|
||||
|
||||
## Segmented Control Status Modal
|
||||
|
||||
`openWorkStatusModal()` in `repair-orders.js` renders a 2-column × 3-row grid of pill buttons (replacing the old vertical list).
|
||||
|
||||
- Active status is highlighted with ring-2 + colored border
|
||||
- Hover shows subtle bg-gray-50
|
||||
- **Parts Info Section**: when `waiting-for-parts` is the active or selected status, an inline form appears showing vendor/ETA fields. Pre-populated from `ro.partsInfo`.
|
||||
- **Completed → Financial**: clicking "Completed" closes the status modal and opens `openFinancialSummaryModal(roId, 'completed')` directly — no intermediate confirmation dialog.
|
||||
- **Parts-Leaving Warning**: changing FROM `waiting-for-parts` to any other status shows a "Parts Received?" confirmation before proceeding.
|
||||
|
||||
## Status History Tracking
|
||||
|
||||
Every status change pushes an entry to `ro.statusHistory[]`:
|
||||
|
||||
```js
|
||||
{
|
||||
from: oldWorkStatus || 'none',
|
||||
to: newWorkStatus,
|
||||
timestamp: new Date().toISOString()
|
||||
}
|
||||
```
|
||||
|
||||
Synced to PocketBase as part of `updateData` / `createData` in `updateRepairOrderWorkStatus()`.
|
||||
|
||||
A collapsible "Status History" section in the edit modal (`openEditModal()`) displays the timeline:
|
||||
|
||||
```
|
||||
● Completed — Jun 23 14:32
|
||||
● Waiting Pick-Up — Jun 22 09:15
|
||||
● In Progress — Jun 22 08:00
|
||||
● Diagnosing — Jun 21 16:45
|
||||
```
|
||||
|
||||
## Parts Info (waiting-for-parts)
|
||||
|
||||
Stored as `ro.partsInfo = { vendor: string, eta: string }`.
|
||||
|
||||
- Captured from the inline form in the work status modal
|
||||
- Displayed in the RO card when status is `waiting-for-parts`
|
||||
- Synced to Firebase with every status update
|
||||
|
||||
## Pickup Tracking (waiting-for-pickup)
|
||||
|
||||
- `ro.pickupSetAt` timestamp saved when status changes to `waiting-for-pickup`
|
||||
- RO card shows elapsed waiting time with color coding:
|
||||
- < 1 day: green "Just ready"
|
||||
- 1-3 days: yellow "2d waiting"
|
||||
- 3+ days: red "3d waiting" (semibold)
|
||||
- Time-until-due field shows blue **"READY FOR PICKUP"** text instead of "OVERDUE" — regardless of whether the promised time has passed
|
||||
- Copy Pickup SMS button was REMOVED (June 2026)
|
||||
|
||||
## Financial Modal on Completion
|
||||
|
||||
When completing from the status modal, `openFinancialSummaryModal(roId, 'completed')` shows:
|
||||
|
||||
- A purple notice: "Marking RO #XXX as completed. Enter financial details below and click 'Save & Complete'."
|
||||
- Pre-populated CP fields parsed from services string (extracts dollar amounts, estimates from service line count)
|
||||
- LocalStorage saves/loads last-used labor rate (`shopProQuote_financialRates` key)
|
||||
- On save, status history is updated with the completed transition
|
||||
- Works as both completion AND editing flow (the `context` parameter controls appearance only)
|
||||
|
||||
## Dashboard Widget Counts
|
||||
|
||||
`dashboard.js` → `updateDashboardStats()` now counts:
|
||||
- `parts-on-order-count`: ROs with `workStatus === 'waiting-for-parts'`
|
||||
- `ready-for-pickup-count`: ROs with `workStatus === 'waiting-for-pickup'`
|
||||
- Breakdown text updated dynamically
|
||||
|
||||
## Edit Modal Updates
|
||||
|
||||
The `#edit-work-status` dropdown in `repair-orders.html` includes all 6 statuses.
|
||||
The `#status-history-section` collapsible timeline is rendered when `openEditModal()` finds `ro.statusHistory` entries.
|
||||
|
||||
## Customer Notes (per-customer, across ROs)
|
||||
|
||||
Introduced June 2026. Notes are tied to the customer (by phone number), not the RO — they persist across different repair orders for the same customer.
|
||||
|
||||
**Storage**: `localStorage` keyed by `cust_notes_{phone}` (phone digits only). Full text string.
|
||||
|
||||
**UI**:
|
||||
- "Customer Notes" button in each RO card's three-dot dropdown menu (teal icon, between Invoice Lines and Delete)
|
||||
- Opens a modal showing customer name/phone, a textarea with existing notes, Cancel/Save
|
||||
- If notes exist for that customer, a small teal checkmark badge appears next to the customer name on EVERY RO card for that customer
|
||||
|
||||
**Key code**: `openCustomerNotesModal(roId)` in `repair-orders.js`. Triggered by `.customer-notes-btn` in `handleROMenuClicks`.
|
||||
|
||||
## Due Time Modal
|
||||
|
||||
Clicking the due date/time on any RO card opens `openDueTimeModal(roId)` with two options:
|
||||
|
||||
1. **Set exact date/time**: `datetime-local` input pre-filled with current due, "Set Due Time" button
|
||||
2. **Add hours**: number input (default 1, min 1, max 168) + "Add Hours" button — pushes the due out by that many hours from current
|
||||
|
||||
Both options call `updateDueTime(roId, newISO)` which:
|
||||
- Updates `repairOrders[].promisedTime` locally
|
||||
- Syncs to PocketBase via `updateDoc()`
|
||||
- Re-renders the active orders list
|
||||
- Shows success notification
|
||||
|
||||
**Due date format** (`formatDueDate`) now includes clock time: "Today 3:15 PM", "Tomorrow 10:30 AM", "Wed 2:00 PM", "Jun 28 11:00 AM". The "Due: " prefix was removed.
|
||||
|
||||
| Feature | File | Function |
|
||||
|---------|------|----------|
|
||||
| Status modal + segmented control | `repair-orders.js` | `openWorkStatusModal()` |
|
||||
| Status update + history tracking | `repair-orders.js` | `updateRepairOrderWorkStatus()` |
|
||||
| RO card rendering | `repair-orders.js` | `createROHTML()` |
|
||||
| Edit modal status history | `repair-orders.js` | `openEditModal()` |
|
||||
| Financial modal (completion) | `repair-orders.js` | `openFinancialSummaryModal()`, `handleFinancialCompletion()` |
|
||||
| Dashboard widget counts | `dashboard.js` | `updateDashboardStats()` |
|
||||
| Due time modal | `repair-orders.js` | `openDueTimeModal()`, `updateDueTime()` |
|
||||
| Customer notes modal | `repair-orders.js` | `openCustomerNotesModal()` |
|
||||
| Due date formatting (with time) | `repair-orders.js` | `formatDueDate()` |
|
||||
| Status modal HTML (segments, parts) | `repair-orders.js` | Generated in `openWorkStatusModal()` |
|
||||
| Status history list HTML | `repair-orders.html` | `#status-history-section` |
|
||||
@@ -0,0 +1,90 @@
|
||||
# RO Data Corruption Recovery
|
||||
|
||||
## Problem
|
||||
|
||||
Some Repair Orders in PocketBase have `services` as a plain text string instead of a valid JSON array. Examples found (2026-07-08):
|
||||
|
||||
- `"a13 hondacare"` — appears to be a PocketBase filter value mistakenly written to the field
|
||||
- `"Oil change, tire rotation, and brake inspection"` — plain text notes
|
||||
- `"[Tire Rotation] Rotating your tires..."` — formatted text with markdown-like syntax
|
||||
- `"OIL CHANGE - TIRE ROTATION\\n[Engine Oil and Filter]..."` — multi-line text
|
||||
|
||||
These corrupt records cause `normalizeRO` to fail JSON parsing, which logs a console error but silently falls back to `services = []`.
|
||||
|
||||
## normalizeRO Graceful Handling
|
||||
|
||||
In `src/lib/repairOrders.ts` (lines 161-166):
|
||||
|
||||
```ts
|
||||
export function normalizeRO(item: any): RepairOrder {
|
||||
let services = item.services || [];
|
||||
if (typeof services === 'string' && services.trim()) {
|
||||
try { services = JSON.parse(services); } catch (err) {
|
||||
logError(`Failed to parse normalizeRO services JSON for RO ${item.id}: ${services}`, err);
|
||||
services = [];
|
||||
}
|
||||
}
|
||||
if (!Array.isArray(services)) services = [];
|
||||
// ...
|
||||
}
|
||||
```
|
||||
|
||||
The function already handles non-JSON strings gracefully — it falls back to `[]`. The `logError` call surfaces the issue in the console so corrupt records can be identified and fixed.
|
||||
|
||||
## Finding Corrupt Records
|
||||
|
||||
Query PocketBase as superuser to find all ROs with non-JSON services:
|
||||
|
||||
```bash
|
||||
SUPERTOKEN=$(curl -s -X POST http://127.0.0.1:8091/api/collections/_superusers/auth-with-password \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"identity":"admin@shop.com","password":"TtAU6aUEFSXrVR"}' | python3 -c "import json,sys;d=json.load(sys.stdin);print(d.get('token',''))")
|
||||
|
||||
curl -s "http://127.0.0.1:8091/api/collections/repairOrders/records?perPage=200&skipTotal=1" \
|
||||
-H "Authorization: $SUPERTOKEN" | python3 -c "
|
||||
import json, sys
|
||||
data = json.load(sys.stdin)
|
||||
for ro in data['items']:
|
||||
svcs = ro.get('services','')
|
||||
if svcs and isinstance(svcs, str) and svcs.strip():
|
||||
try: json.loads(svcs)
|
||||
except:
|
||||
print(f'{ro[\"id\"]} | {ro.get(\"roNumber\",\"\")} | {ro.get(\"customerName\",\"\")}')
|
||||
"
|
||||
```
|
||||
|
||||
## Fixing Corrupt Records
|
||||
|
||||
Reset the `services` field to an empty JSON array:
|
||||
|
||||
```bash
|
||||
for RO_ID in <id1> <id2> ...; do
|
||||
curl -s -o /dev/null -w '%{http_code}' -X PATCH \
|
||||
"http://127.0.0.1:8091/api/collections/repairOrders/records/${RO_ID}" \
|
||||
-H "Authorization: $SUPERTOKEN" \
|
||||
-H 'Content-Type: application/json' \
|
||||
-d '{"services":"[]"}'
|
||||
done
|
||||
```
|
||||
|
||||
## Prevention
|
||||
|
||||
All write paths in the codebase write `JSON.stringify(array)` to `services`:
|
||||
- `QuoteSummary.tsx` sync block (lines 179-181, 294-296)
|
||||
- `RepairOrders.tsx` line 373
|
||||
- `RODetailModal.tsx` line 878
|
||||
|
||||
The corrupt records predate these write paths and likely originate from early development or manual PocketBase admin edits. New corruption should not occur through the normal app flow.
|
||||
|
||||
## Already Fixed (2026-07-08)
|
||||
|
||||
The following 6 ROs were identified and fixed during investigation:
|
||||
|
||||
| RO # | Customer | Corrupt Value |
|
||||
|------|----------|---------------|
|
||||
| RO-2026-001 | John Smith | "Oil change, tire rotation, and brake inspection" |
|
||||
| 905003 | GREG JONES | "[Tire Rotation] Rotating your tires..." |
|
||||
| 905118 | Abbey Warbington | "OIL CHANGE - TIRE ROTATION..." |
|
||||
| 905150 | DARRELL FUJIYOSHI | "RIGHT FRONT TIRE LOSING AIR" |
|
||||
| 905156 | PAUL W GROTH | "[Oil and Filter Change] Your vehicle is due..." |
|
||||
| 905188 | JACOB NABORS | "a13 hondacare" |
|
||||
@@ -0,0 +1,143 @@
|
||||
# RODetailModal Architecture
|
||||
|
||||
## Overview
|
||||
|
||||
`src/pages/RODetailModal.tsx` (~1600 lines) — the full-featured modal viewer for a single Repair Order. Supports both modal (overlay via `navigate(-1)`) and standalone page rendering. Contains 5 tabs: Details, Services, Financial, History, and Timeline.
|
||||
|
||||
## Tabs
|
||||
|
||||
| Tab | Key Content |
|
||||
|-----|-------------|
|
||||
| `details` | Status management, info bar, services summary, promised-time override, clock, void, satisfaction rating |
|
||||
| `services` | Catalog search + add, manual service rows, new-service creation, totals |
|
||||
| `financial` | CP/Warranty Total/Cost inputs, Shop Charge input, live Gross Profit display. Writes to `financial` blob keys v2 already reads. See `references/financial-summary-modal.md`. |
|
||||
| `history` | VIN-based vehicle history (past ROs + quotes for the same VIN) |
|
||||
| `timeline` | Audit timeline from `roEvents.ts` + Financial Audit Trail from `roFinancialAudit` collection |
|
||||
|
||||
## Component Structure
|
||||
|
||||
```
|
||||
RODetailModal
|
||||
├── Modal wrapper (fixed overlay) or standalone full page
|
||||
├── Tab bar (Details, Services, Financial, History, Timeline)
|
||||
├── Tab content (one at a time via activeTab state)
|
||||
│ ├── detailsContent
|
||||
│ ├── servicesContent
|
||||
│ ├── financialContent
|
||||
│ ├── historyContent
|
||||
│ └── timelineContent
|
||||
└── AddTimeModal
|
||||
```
|
||||
|
||||
## Data-Fetching Patterns
|
||||
|
||||
### Core RO Fetch
|
||||
- `fetchRO()` — called on mount, fetches the RO from PocketBase, normalizes services JSON
|
||||
- `fetchCatalogServices()` — fetches all catalog services for the Services tab
|
||||
|
||||
### Tab-Triggered Fetches
|
||||
Each tab that needs its own data follows this pattern:
|
||||
|
||||
```tsx
|
||||
const loadX = useCallback(async () => {
|
||||
if (!ro) return;
|
||||
setXLoading(true);
|
||||
try {
|
||||
const records = await pb.collection('x').getFullList({ filter: ..., sort: ... });
|
||||
setXData(records.map(...));
|
||||
} catch { setXData([]); }
|
||||
finally { setXLoading(false); }
|
||||
}, [ro]);
|
||||
|
||||
useEffect(() => {
|
||||
if (activeTab === 'tabname') loadX();
|
||||
}, [activeTab, loadX]);
|
||||
```
|
||||
|
||||
### Examples:
|
||||
- **History tab**: `loadHistory()` queries `repairOrders` and `quotes` by VIN, triggered when `activeTab === 'history'`
|
||||
- **Financial Audit Trail**: `loadFinancialAudit()` queries `roFinancialAudit` by `roId`, triggered when `activeTab === 'timeline'`
|
||||
|
||||
## Financial Tab (implemented)
|
||||
|
||||
See `references/financial-summary-modal.md` for full design rationale and v1 history.
|
||||
|
||||
### State
|
||||
- `finCpTotal`, `finCpCost`, `finWarrTotal`, `finWarrCost`, `finShopCharge` — string inputs
|
||||
- `finSaving`, `finSaved`, `finSeeded` — booleans for save state and pre-seed gate
|
||||
|
||||
### Pre-seed
|
||||
On first opening the Financial tab (`activeTab === 'financial'` and `!finSeeded`), inputs are pre-seeded from the RO's existing `financial` blob, falling back to `computeROTotals()` values (CP Total falls back to `totals.total`, Shop Charge falls back to `totals.shopCharge`). The `finSeeded` flag prevents reseeding on subsequent tab switches. Ray confirmed pre-seed is desired.
|
||||
|
||||
### Live computed values
|
||||
- `cpNetTotal` = `max(0, CP Total - Warr Total)`
|
||||
- `cpNetCost` = `max(0, CP Cost - Warr Cost)`
|
||||
- `cpGrossProfit` = `cpNetTotal - cpNetCost - Shop Charge`
|
||||
- `warrGrossProfit` = `Warr Total - Warr Cost`
|
||||
- `overallGrossProfit` = `cpGrossProfit + warrGrossProfit`
|
||||
|
||||
These match `customerPayGpOf()` and `warrantyGpOf()` in `FinancialDashboard.tsx`, so dashboard totals pick up the entered values with zero dashboard-side changes.
|
||||
|
||||
### Save
|
||||
`handleSaveFinancial()` preserves existing blob keys (e.g. `customerType`), writes `grossTotal`/`grossCost`/`warrTotal`/`warrCost`/`shopCharge`/`cpProfit`/`whProfit`/`grossProfit`/`totalAmount`/`totalCost`/`completedAt` to the `financial` JSON blob, appends a `'financial'` ROEvent for the timeline, and relies on the PB M25 hook for audit-trail creation.
|
||||
|
||||
### Layout
|
||||
- Sticky save bar at top (Save Financial button) — per the modal standard, this is the only save button
|
||||
- Helper text explaining the deduction rules
|
||||
- Customer Pay card (green): CP Total input, CP Cost input, CP Net Total display, CP Net Cost display, CP Gross Profit display
|
||||
- Warranty card (blue): Warranty Total input, Warranty Cost input, Warranty GP display
|
||||
- Shop Charge card (amber): Shop Charge input, CP GP after Shop Charge display
|
||||
- Gross Profit summary bar: Overall Gross Profit (large, color-coded green/red), CP GP + Warranty GP breakdown
|
||||
|
||||
## Timeline Tab
|
||||
|
||||
### Audit Timeline (existing)
|
||||
- Reads events from `readEvents(ro)` (stored in the RO's `financial` JSON blob)
|
||||
- Renders `TimelineEntry` components for each event type (created, status, clock_start, financial, etc.)
|
||||
|
||||
### Financial Audit Trail (added Phase 4.3)
|
||||
- Queries `roFinancialAudit` PocketBase collection filtered by current RO `roId`, sorted newest-first (`-at`)
|
||||
- Each entry shows: field name (camelCase → Title Case via `formatFieldName`), `from → to` JSON values in monospace, "by" attribution, truncated hash with full hash on `title` hover
|
||||
- Only renders when entries exist (hidden otherwise)
|
||||
- Shows `Loader2` spinner while loading
|
||||
|
||||
#### Adding a New Data Section to a Tab
|
||||
The pattern:
|
||||
1. Add state: `const [data, setData] = useState<Type[]>([])` + `const [loading, setLoading] = useState(false)`
|
||||
2. Add `useCallback` load function querying PocketBase
|
||||
3. Add `useEffect` hooked to `activeTab` and `ro`
|
||||
4. Add conditional render in the tab's content JSX (loading spinner + data display + empty state)
|
||||
5. Reuse existing imports (Loader2, formatDateTime, etc.)
|
||||
|
||||
## Types
|
||||
|
||||
- `ROFinancialAuditEntry` in `src/types.ts`:
|
||||
```ts
|
||||
interface ROFinancialAuditEntry {
|
||||
id: string; roId: string; field: string;
|
||||
from: unknown; to: unknown; by: string; at: string;
|
||||
prevHash: string; hash: string;
|
||||
}
|
||||
```
|
||||
|
||||
## Associated Files
|
||||
|
||||
- `src/pages/RODetailModal.tsx` — main component
|
||||
- `src/components/ROForm.tsx` — shared create/edit form (now includes catalog search; see `references/roform-catalog-search.md`)
|
||||
- `src/types.ts` — type definitions (ROFinancialAuditEntry, ROEvent, RepairOrder, etc.)
|
||||
- `src/lib/roEvents.ts` — event reading/writing for timeline (readEvents, appendROEvent, clock*, void*)
|
||||
- `src/lib/format.ts` — formatCurrency, formatDateTime, formatElapsed
|
||||
- `src/lib/totals.ts` — computeROTotals, roServiceTotal
|
||||
- `src/lib/technicianData.ts` — parseRepairOrderServices, syncAssignmentsForROServices
|
||||
- `src/store/settings.ts` — useSettings hook for shop charge/tax rates
|
||||
|
||||
## Maintenance Reminders (Phase 4.6)
|
||||
|
||||
The Details tab header includes a "Set Reminder" button (BellRing icon) that opens an inline form to create maintenance reminders. See `references/reminders.md` for full details on the reminders feature.
|
||||
|
||||
### Quick Reference
|
||||
- **Button location**: Header button group, after Generate Quote
|
||||
- **Form**: Inline below header, blue-themed card with mileage/interval/notes fields
|
||||
- **State**: `showReminderForm`, `reminderMileage`, `reminderInterval`, `reminderNotes`, `savingReminder`
|
||||
- **Handler**: `handleSaveReminder()` creates record in `reminders` PocketBase collection
|
||||
- **Dashboard widget**: See `src/pages/Dashboard.tsx` — displays active reminders at top of dashboard
|
||||
@@ -0,0 +1,104 @@
|
||||
# RO Details Tab Architecture
|
||||
|
||||
## Overview
|
||||
|
||||
The Repair Orders page has a "RO Details" tab that provides inline editing of a selected repair order. Clicking any RO row navigates to this tab with a full edit form, info bar, and time management.
|
||||
|
||||
## Files Involved
|
||||
|
||||
- `src/pages/RepairOrders.tsx` — Main page with `RODetailsPanel` component, `handleSelectRO`, `handleInlineUpdate`, early-return rendering
|
||||
- `src/components/ROForm.tsx` — Shared RO create/edit form (extracted from ROModal)
|
||||
- `src/components/ROForm.tsx` — `readOnlyDuration` prop controls whether "Estimated Duration" is editable
|
||||
|
||||
## Component Architecture
|
||||
|
||||
```
|
||||
RepairOrders (main component)
|
||||
├── if activeTab === 'details' → early return with RODetailsPanel
|
||||
│ └── RODetailsPanel
|
||||
│ ├── Back button + RO header
|
||||
│ ├── Info bar (Customer Type, Status, Write-up, Promised, Time Remaining + Add Time)
|
||||
│ ├── Message banner (success/error)
|
||||
│ ├── ROForm (inline, readOnlyDuration=true)
|
||||
│ └── AddTimeModal
|
||||
└── else → mainContent (tab bar + table)
|
||||
├── TabButton (active/all/completed)
|
||||
├── FragmentRow → onClick → handleSelectRO → sets selectedROId + activeTab='details'
|
||||
└── ROModal (uses ROForm for create/edit)
|
||||
```
|
||||
|
||||
## State Flow
|
||||
|
||||
1. **Row click**: `handleSelectRO(id)` → `setSelectedROId(id)` + `setActiveTab('details')`
|
||||
2. **Early return**: When `activeTab === 'details' && selectedRO`, the component returns RODetailsPanel directly
|
||||
3. **Save**: `RODetailsPanel.onSave` → `handleInlineUpdate(roId, data)` → PB update → `fetchOrders()`
|
||||
4. **Add Time**: `RODetailsPanel.onAddTime` → `handleAddTime(id, minutes)` → PB update + optimistic UI
|
||||
5. **Back**: Clears `selectedROId`, sets `activeTab='active'`
|
||||
|
||||
## Key Details
|
||||
|
||||
### `handleInlineUpdate` vs `handleSave`
|
||||
- `handleSave` uses the module-level `editRO` state (for the modal flow)
|
||||
- `handleInlineUpdate(roId, data)` takes the RO id directly (for the details tab flow)
|
||||
- Both map `estimatedDuration` → `estimatedTime` for PocketBase
|
||||
|
||||
### `ROForm` — Shared Form Component
|
||||
- Extracted from the old ROModal (was ~600 lines, now ~50)
|
||||
- Takes `editRO`, `existingRoNumbers`, `settings`, `onSave`, `onCancel`, `submitLabel`, `readOnlyDuration`
|
||||
- Manages ALL form state internally (customer info, services, discount, totals, notes)
|
||||
- Used by both `ROModal` (create/edit popup) and `RODetailsPanel` (inline edit)
|
||||
|
||||
### `readOnlyDuration` Prop
|
||||
- When `true`: Shows "Original Estimated Duration of Service" as read-only text ("1h 30m")
|
||||
- When `false` (default): Shows editable Hours/Minutes inputs
|
||||
- The underlying `estimatedDuration` state is still populated and included in saves
|
||||
- Helper text: "Add time via the info bar above to extend."
|
||||
|
||||
### Info Bar
|
||||
5-column responsive grid showing:
|
||||
1. **Customer Type** — Waiter/Drop-Off with icon
|
||||
2. **Status** — StatusBadge
|
||||
3. **Write-up Time** — `formatDateTime(ro.writeupTime || ro.created)`
|
||||
4. **Promised Time** — `formatDueDateTime(ro)` (writeup + duration)
|
||||
5. **Time Remaining** — Live countdown + urgency colors + "+ Add Time" button
|
||||
- Urgent (past due): red text + AlertTriangle icon
|
||||
- Warning (≤30min): amber text
|
||||
- Completed/delivered: "—" (no button)
|
||||
|
||||
### AddTimeModal
|
||||
- Reused from the existing `AddTimeModal` component in RepairOrders.tsx
|
||||
- Props: `open`, `initialMinutes` (15), `onCancel`, `onConfirm(totalMinutes)`
|
||||
- Calls `onAddTime(ro.id, totalMinutes)` which updates `estimatedTime` in PB
|
||||
|
||||
## Pitfalls
|
||||
|
||||
- **Don't use `handleSave` for details tab** — it depends on `editRO` state which is null during details view
|
||||
- **Don't remove the early return pattern** — the details tab renders BEFORE `mainContent`, so both can't be in the same JSX tree
|
||||
- **ROForm manages its own state** — don't try to lift state up; populate happens via the `editRO` prop and `useEffect`
|
||||
- **The `readOnlyDuration` form still includes `estimatedDuration` in save payload** — it's just not editable by the user
|
||||
|
||||
## Dirty Tracking (Unsaved Changes Confirmation)
|
||||
|
||||
ROForm has an `onDirtyChange?: (dirty: boolean) => void` prop. When provided:
|
||||
|
||||
1. After form population (from `editRO`), a JSON snapshot of initial values is captured via `useRef`
|
||||
2. A `useEffect` watches all form state fields and compares to the snapshot
|
||||
3. On first divergence, `onDirtyChange(true)` fires once (using `dirtyNotified` ref to prevent redundant calls)
|
||||
4. The snapshot resets when `editRO` changes (navigating to a different RO)
|
||||
|
||||
In `RODetailsPanel`:
|
||||
- Tracks `isDirty` state via `onDirtyChange={setIsDirty}`
|
||||
- Back button calls `handleBack()` which checks `isDirty` and shows `window.confirm()` if true
|
||||
- On successful save, `setIsDirty(false)` clears the flag
|
||||
|
||||
## RO Sort Order
|
||||
|
||||
The `sortOrders()` function in `RepairOrders.tsx` uses a 4-tier sort:
|
||||
|
||||
| Tier | Statuses | Secondary Sort |
|
||||
|---|---|---|
|
||||
| 1 | active, in_progress | Waiters first, then by due time (most urgent on top) |
|
||||
| 2 | waiting_pickup | By due time ascending |
|
||||
| 3 | waiting_parts | By due time ascending (absolute bottom) |
|
||||
|
||||
`completed` and `delivered` are filtered into the "Completed" tab and excluded from active sorts.
|
||||
@@ -0,0 +1,81 @@
|
||||
# RO-Quote Sync Mechanism
|
||||
|
||||
## Overview
|
||||
|
||||
When a quote is generated from a Repair Order (RO), approved services on the quote sync back to the RO's services array on Save. This is the bidirectional RO ↔ Quote link.
|
||||
|
||||
## Key Files
|
||||
|
||||
| File | Role |
|
||||
|------|------|
|
||||
| `src/components/quoteGenerator/QuoteSummary.tsx` | Sync logic in `handleSave` (lines 146-194) and `ensureShareToken` (lines ~270-303) |
|
||||
| `src/pages/QuoteGenerator.tsx` | Sets `repairOriginId` from `data.repairOrderId` on edit load (line ~524) and from `fromRO` URL param (line ~103) |
|
||||
|
||||
## Data Flow
|
||||
|
||||
### 1. New Quote from RO
|
||||
|
||||
- User clicks "Generate Quote" from RO → navigates to `/quote?fromRO=RO_ID&name=...`
|
||||
- `fromRO` effect in QuoteGenerator.tsx loads the RO, sets `repairOriginId = ro.id`
|
||||
- User adds/approves services, clicks Save
|
||||
- `handleSave` in QuoteSummary.tsx creates the quote record with `repairOrderId: repairOriginId` (line 125)
|
||||
- Sync block runs: filters `services.filter(s => s.approved)`, loads current RO services, merges (by service name — avoids duplicates), updates RO with `JSON.stringify(roServices)`
|
||||
- Toast: `"Synced N approved service(s) to RO"`
|
||||
|
||||
### 2. Editing Existing Quote from RO
|
||||
|
||||
- Quote has `repairOrderId` set in PocketBase
|
||||
- Edit-load effect restores `setRepairOriginId(data.repairOrderId)` (QuoteGenerator.tsx line ~524)
|
||||
- User approves more services, clicks Save
|
||||
- Sync block runs same as above
|
||||
|
||||
### 3. Share with Customer (ensureShareToken)
|
||||
|
||||
- **New quote**: Creates the quote record, syncs approved services to RO, then creates share token. Same merge logic as handleSave.
|
||||
- **Editing existing quote** (editId set): The `if (!quoteId)` block is SKIPPED — no sync on share of existing quotes. The quote is read from PB, share token created/returned. **This is a known gap.**
|
||||
|
||||
## Critical Conditions
|
||||
|
||||
The sync block only runs when BOTH are true:
|
||||
1. `repairOriginId` is set (non-null)
|
||||
2. `services.filter(s => s.approved).length > 0`
|
||||
|
||||
## Why Sync Might Not Run
|
||||
|
||||
| Cause | Symptom |
|
||||
|-------|---------|
|
||||
| Quote's `repairOrderId` is null in PB | `repairOriginId` stays null; entire sync block skipped silently |
|
||||
| No services approved | Sync block skipped; no toast shown (by design) |
|
||||
| RO load fails (permissions/network) | Falls to catch block → error toast: `"Sync to RO failed: ..."` |
|
||||
| Schema validation fails before sync | Error toast from Zod; sync never reached |
|
||||
|
||||
**Most common cause**: The quote was created before the `repairOrderId` field existed. Check the quote in PocketBase admin — if `repairOrderId` is empty, the quote has no RO link. Generate a new quote from the RO to get the field populated.
|
||||
|
||||
## PocketBase Fields
|
||||
|
||||
- `quotes.repairOrderId` — relation field linking quote to its originating RO. Set at quote creation (line 125 in handleSave).
|
||||
- `repairOrders.services` — stored as `JSON.stringify(array)` (plain JSON text field, NOT a PocketBase relation).
|
||||
|
||||
## Sync Merge Logic
|
||||
|
||||
Services are merged by **name** (not ID):
|
||||
- If RO already has a service with the same name → update it (preserving status if already set)
|
||||
- If name not found → append new service
|
||||
|
||||
Each synced service gets: `id: 'qs-{quoteService.id}'`, `name`, `description`, `laborHours` (from price/rate), `laborRate`, `partsCost: 0`, `total: price`, `status: 'pending'`
|
||||
|
||||
## Testing the Sync
|
||||
|
||||
1. Create an RO owned by the current user
|
||||
2. Generate a quote from it (`/quote?fromRO=RO_ID`)
|
||||
3. Add and approve services
|
||||
4. Click Save
|
||||
5. Check the RO in PocketBase — `services` should contain the approved services
|
||||
6. Verify via API: `GET /api/collections/repairOrders/records/RO_ID`
|
||||
|
||||
## Previous Investigation
|
||||
|
||||
- The sync code was verified working end-to-end (2026-07-08)
|
||||
- All debug `[SPQ-DEBUG]` console.logs have been removed
|
||||
- The sync runs in both `handleSave` and `ensureShareToken` (new-quote path only)
|
||||
- RO `services` field is written as `JSON.stringify(roServices)` — always produces valid JSON arrays
|
||||
@@ -0,0 +1,107 @@
|
||||
# Roadmap Status Audit — SPQ-v2
|
||||
|
||||
## When to maintain this
|
||||
|
||||
After completing any item in `roadmap_5.2.md` (or any successor roadmap file), **append a dated Master Status Audit section at the bottom of the roadmap file itself**. Ray explicitly asked for this so the next agent can boot up in seconds instead of re-running `search_files`/`read_file` across the whole repo to figure out what's done.
|
||||
|
||||
## What it contains
|
||||
|
||||
A per-item ✅ / ⚠️ / ❌ / ❓ table for every phase, with **file:line evidence** of the actual current codebase state — not what was planned. Status values:
|
||||
|
||||
- ✅ DONE — complete and verified (cite the file/test that proves it)
|
||||
- ⚠️ PARTIAL — part done (say which part), part blocked (say why, usually `> APPROVAL REQUIRED`)
|
||||
- ❌ NOT DONE — unstarted; note if approval-gated
|
||||
- ❓ UNKNOWN — server-side / host-level, not verifiable from the repo
|
||||
|
||||
## Format (copy this skeleton)
|
||||
|
||||
```markdown
|
||||
## Master Status Audit — YYYY-MM-DD
|
||||
|
||||
> Compiled by an audit pass so the next agent does not burn time re-checking each item.
|
||||
> Each line records what is actually in the codebase today (verified by search_files / read_file / test run),
|
||||
> not what was planned.
|
||||
|
||||
### Phase N — <Phase Title>
|
||||
|
||||
| # | Item | Status | Evidence |
|
||||
|---|------|--------|----------|
|
||||
| N.M | <item> | ✅ DONE | `path/file.ts:NN` … |
|
||||
|
||||
### Next-item recommendation
|
||||
<one paragraph: next non-approval, frontend-only, highest-impact item + a pointer to any saved plan in .hermes/plans/>
|
||||
|
||||
### Verification snapshot (YYYY-MM-DD)
|
||||
```
|
||||
npm run test → …
|
||||
npm run build → …
|
||||
npm run lint → …
|
||||
```
|
||||
```
|
||||
|
||||
## How to verify each item efficiently
|
||||
|
||||
- `search_files` for the symbol/file the roadmap step says to create (e.g. `ErrorBoundary`, `mutateAssignment`, `quoteWriteSchema`).
|
||||
- `read_file` the cited file:line to confirm the implementation matches the roadmap spec, not just that the symbol exists.
|
||||
- `terminal` run `npx vitest run` once for the test-count snapshot; `npm run build` only if code changed this session (prior memos already recorded green builds).
|
||||
- For PB-migration items: list `pb_migrations/` — absence of the named migration file = ❌ NOT DONE (don't apply without approval).
|
||||
- **For PB migration items — verify the migration is actually applied in the running container**, not just that the file exists in `pb_migrations/` or in the container's `/pb_data/migrations/`. The proof is:
|
||||
```bash
|
||||
docker exec <PB_CID> /usr/local/bin/pocketbase migrate up --dir=/pb_data --migrationsDir=/pb_data/migrations
|
||||
```
|
||||
If it says **"No new migrations to apply."** → the migration is live (✅). If it applies a migration → it was on disk but not yet run (⚠️, now applied). Find the container ID with `docker ps --format '{{.ID}} {{.Names}}' | grep pocketbase`.
|
||||
- **For nginx snippet / security-header items — curl the production URL and grep for headers**:
|
||||
```bash
|
||||
curl -sI https://shopproquote.graj-media.com/ | grep -iE 'content-security|x-content|x-frame|referrer|permissions|HTTP/'
|
||||
```
|
||||
Snippets can exist on disk (`/etc/nginx/snippets/`) and even be included in the site block but not actually served if the server block wasn't reloaded. The curl check is the source of truth. Run `sudo nginx -t` before relying on config.
|
||||
- **For systemd-service items — check both the unit file and the running state**:
|
||||
```bash
|
||||
systemctl status <service-name> | head -15
|
||||
ss -tlnp | grep <port>
|
||||
```
|
||||
A unit file on disk doesn't mean the service is enabled or running. `systemctl is-enabled <service>` confirms auto-start.
|
||||
|
||||
## Pitfalls
|
||||
|
||||
- **Don't trust the "Execution Memo" sections alone** — those record what an agent *claimed* to finish, not what survived. Verify against the codebase. A memo can say "done" while a later refactor reverted it. During the 2026-07-07 Phase 1 audit pass, items 1.2, 1.3, and 1.4 were all already fully implemented on the server but had no Execution Memo and showed ❌/⚠️ in the status table — the code and infrastructure were done, the roadmap just hadn't been updated.
|
||||
- **"Already done" is a valid outcome** — before writing new code, audit the live state. Items may have been implemented in a prior session without the roadmap being updated. In that case: verify live, flip the status table to ✅, write the Execution Memo, and skip the code work. Do NOT re-implement from the roadmap spec.
|
||||
- **Approval-gated items stay ❌/⚠️** even if frontend prep is done. The `> APPROVAL REQUIRED` marker in the roadmap text is real — never mark the server half ✅ without the owner's go-ahead (AGENTS.md standing rule).
|
||||
- **vite.config.ts dev proxy drift**: roadmap 1.2 says point `/deepseek` at `http://127.0.0.1:80`. The live config still targets `https://127.0.0.1:3448` (dead gateway). Ray confirmed on 2026-07-07 he runs **prod only** (nginx/dist, not `npm run dev`), so this fix has zero practical impact and was intentionally skipped. Mark it ⚠️ with a note, not ❌. Do NOT keep re-suggesting this fix every session.
|
||||
- **`patch` tool parameter gotcha** — the `patch` tool requires `path` (not `patch`) as the parameter name for the file path. Repeated calls with `path` missing hit the tool-call guardrail (5 retries → blocked). If `patch` keeps failing with "path required", switch to `execute_code` with the Python `patch()` wrapper from `hermes_tools`, which takes `path` as a named kwarg and is more forgiving.
|
||||
- Keep evidence lines terse — `path:line` plus a 6-word fragment. The table is for triage, not prose.
|
||||
|
||||
## Current audit location
|
||||
|
||||
The live audit lives at the bottom of `/mnt/seagate8tb/Websites/ShopProQuote.backup-20260626-2058/spq-v2/roadmap_5.2.md` (section `## Master Status Audit — 2026-07-07`). Append *new* dated sections below the prior one; don't overwrite old audits — they're a timeline.
|
||||
|
||||
## Phase 1 completion (as of 2026-07-07)
|
||||
|
||||
All Phase 1 items (1.1–1.7) are ✅ DONE.
|
||||
|
||||
## Phase 2 completion (as of 2026-07-07)
|
||||
|
||||
All Phase 2 items (2.1–2.10) are ✅ DONE, including 2.7 (logo uploads to PB).
|
||||
|
||||
## Phase 3 status (updated 2026-07-07)
|
||||
|
||||
| # | Item | Status | Notes |
|
||||
|---|------|--------|-------|
|
||||
| 3.1 | GlitchTip self-hosted error tracking | ❌ NOT DONE | Needs go-ahead — Docker stack + @sentry/react + DSN. No /opt/glitchtip exists yet. |
|
||||
| 3.2 | PocketBase production hardening | ❌ NOT DONE | Needs go-ahead — SMTP env vars on PB container, backup cron, superadmin env. None set currently. |
|
||||
| 3.3 | Frontend hosting (nginx static) | ✅ ALREADY LIVE | `/etc/nginx/sites-enabled/shopproquote` serves dist/ on 443 with TLS, try_files SPA fallback, security headers. No work needed. |
|
||||
| 3.4 | Reverse proxy for /pb and /api | ✅ ALREADY LIVE | /pb/ → 127.0.0.1:8091 with WS upgrade + 86400 timeout. /api/ also proxied. No work needed. |
|
||||
| 3.5 | Structured logging | ❌ NOT DONE | Needs go-ahead — nginx JSON access log format, PB log rotation (json-file driver is set but no rotation limits). |
|
||||
| 3.6 | Offline banner | ✅ DONE | `src/components/OfflineBanner.tsx` wired in `src/App.tsx` above `<AppRoutes/>`. |
|
||||
| 3.7 | Dependency audit | ✅ DONE | 0 vulnerabilities. 8 minor/patch updates available (all same-major). No action needed. |
|
||||
|
||||
**Key lesson:** Before implementing any Phase 3 item, audit the live nginx config first. 3.3 and 3.4 were already fully deployed via prior sessions with no roadmap update — the roadmap said ❌ but reality said ✅. This mirrors the Phase 1 pattern where 1.2/1.3/1.4 were live but unmarked. Always curl the production URL and inspect the actual server block before writing new config. See `references/spq-v2-deployment.md` for the live nginx layout.
|
||||
|
||||
## Phase 4 status (as of 2026-07-07)
|
||||
|
||||
All Phase 4 items (4.1–4.10) are ❌ NOT DONE. These are feature items, not infrastructure. Best self-contained picks:
|
||||
- **4.1** Quote approval notifications (PB hook only, no frontend change) — tiny
|
||||
- **4.2** Quote expiry enforcement (PB hook + frontend expired UI) — small
|
||||
- **4.4** Multi-vehicle customer history (new page + route, reuses existing customerId) — most self-contained feature pick
|
||||
- **4.5** Technician time-card CSV export (new advisor page) — self-contained
|
||||
- **4.7** Offline-first service worker — scoped as sub-project, significant effort
|
||||
@@ -0,0 +1,102 @@
|
||||
# ROForm Service Catalog Search
|
||||
|
||||
## Overview
|
||||
|
||||
`src/components/ROForm.tsx` — the shared create/edit form used by both `ROModal` and `RODetailsPanel` — includes a service catalog search box above the manual service rows. This allows advisors to search the PocketBase `services` collection and add catalog items to the RO with one click, instead of manually typing name/labor/rate/parts for every service.
|
||||
|
||||
**Implemented:** 2026-07-08. See execution memo in `roadmap_5.2.md`.
|
||||
|
||||
## Files
|
||||
|
||||
- `src/components/ROForm.tsx` — main form component (shared between create modal and inline edit)
|
||||
- `src/components/repairOrders/ROModal.tsx` — create/edit modal wrapper (inherits form change automatically)
|
||||
- `src/pages/RepairOrders.tsx` — inline edit panel via `RODetailsPanel` (also inherits)
|
||||
|
||||
## State
|
||||
|
||||
```tsx
|
||||
const [catalogServices, setCatalogServices] = useState<ServiceItem[]>([]);
|
||||
const [catalogSearch, setCatalogSearch] = useState('');
|
||||
const [catalogLoaded, setCatalogLoaded] = useState(false);
|
||||
```
|
||||
|
||||
## Lazy Load
|
||||
|
||||
Catalog services are fetched **only on first search box focus** (`onFocus={handleCatalogFocus}`), not on component mount. This avoids an unnecessary PocketBase query every time the modal opens.
|
||||
|
||||
```tsx
|
||||
const handleCatalogFocus = () => {
|
||||
if (!catalogLoaded) fetchCatalogServices();
|
||||
};
|
||||
```
|
||||
|
||||
## Fetch
|
||||
|
||||
Same pattern as `RODetailModal.tsx`:
|
||||
|
||||
```tsx
|
||||
const fetchCatalogServices = useCallback(async () => {
|
||||
if (!userId || catalogLoaded) return;
|
||||
const records = await pb.collection('services').getFullList({
|
||||
filter: `userId="${userId}"`,
|
||||
sort: 'name',
|
||||
});
|
||||
setCatalogServices(records.map(...));
|
||||
setCatalogLoaded(true);
|
||||
}, [userId, catalogLoaded]);
|
||||
```
|
||||
|
||||
## Add from Catalog
|
||||
|
||||
Mirrors `RODetailModal.tsx:799` — detects split vs full pricing:
|
||||
|
||||
```tsx
|
||||
const handleAddCatalogService = (svc: ServiceItem) => {
|
||||
const laborPrice = (svc.laborHours || 0) * (svc.laborRate || 0);
|
||||
const isSplit = laborPrice > 0 || (svc.partsCost || 0) > 0;
|
||||
setServices((prev) => [...prev, {
|
||||
id: `catalog-${svc.id}-${Date.now()}`,
|
||||
name: svc.name,
|
||||
description: svc.description,
|
||||
laborHours: isSplit ? svc.laborHours || 1 : 0,
|
||||
laborRate: isSplit ? svc.laborRate || DEFAULT_SHOP_RATE : 0,
|
||||
partsCost: isSplit ? svc.partsCost : 0,
|
||||
total: svc.price,
|
||||
status: 'pending' as const,
|
||||
technician: '',
|
||||
pricingMode: isSplit ? 'split' : 'full',
|
||||
applyShopCharge: true,
|
||||
}]);
|
||||
};
|
||||
```
|
||||
|
||||
## Filtered Results
|
||||
|
||||
Same search logic as RODetailModal — filters on name, category, and description:
|
||||
|
||||
```tsx
|
||||
const filteredCatalog = catalogServices.filter((s) => {
|
||||
if (!catalogSearch.trim()) return true;
|
||||
const q = catalogSearch.toLowerCase();
|
||||
return (
|
||||
s.name.toLowerCase().includes(q) ||
|
||||
s.category.toLowerCase().includes(q) ||
|
||||
s.description.toLowerCase().includes(q)
|
||||
);
|
||||
});
|
||||
```
|
||||
|
||||
## UI Layout
|
||||
|
||||
Inserted between the "Services" heading and the `ServiceRow` list:
|
||||
|
||||
1. **Search input** with Search icon, lazy-loads on focus
|
||||
2. **Scrollable results** (`max-h-48`) showing name + category + price + green "Add" button
|
||||
3. **Empty state hint** when search yields no results: "Use Add Manual Service above"
|
||||
4. **Manual button** renamed from "Add Service" to "Add Manual Service" to clarify the two paths
|
||||
|
||||
## Pitfalls
|
||||
|
||||
- The catalog search uses the same `pb.collection('services')` query as `RODetailModal.tsx` and `ServiceCatalog.tsx` — if you add new fields to the `services` collection, update the mapping in all three places.
|
||||
- `handleAddCatalogService` pushes to the same `services` state array that `ServiceRow` renders — no additional plumbing needed.
|
||||
- The existing `handleSubmit` serializer already filters services with `s.name?.trim()` and maps them to `ROService` — catalog-added services have names, so they flow through correctly.
|
||||
@@ -0,0 +1,19 @@
|
||||
# Service Default Status
|
||||
|
||||
When a service is added to a new quote, it defaults to **Pending** (not Approved).
|
||||
|
||||
## Locations (3 code paths in `QuoteGenerator.tsx`)
|
||||
|
||||
| Path | Line | Description |
|
||||
|---|---|---|
|
||||
| `handleAdd` | ~147 | Catalog search picker |
|
||||
| RO import | ~777-778 | Loading services from a Repair Order |
|
||||
| `handleAddSuggestion` | ~959-960 | AI-suggested services |
|
||||
|
||||
All three set `approved: false, customerDecision: 'pending'` instead of the old default of `approved: true, customerDecision: 'approved'`.
|
||||
|
||||
## How It Works
|
||||
|
||||
- The approve/decline/pending toggle buttons in the service list are unchanged — the advisor explicitly approves each service
|
||||
- The `billableServices()` helper in `src/lib/totals.ts` handles mixed approved/pending correctly: counts all when unanimous, only approved when mixed
|
||||
- The "Convert to RO" flow filters on `s.approved`, so only approved services become RO line items
|
||||
@@ -0,0 +1,101 @@
|
||||
# Adding a New Site Configuration Setting
|
||||
|
||||
When adding a single new setting to the Site Configuration modal, you MUST update **6 places** across
|
||||
the codebase. Missing any one will cause silent failures.
|
||||
|
||||
## Checklist (ordered, follow exactly)
|
||||
|
||||
### 1. `settings.js` — Add default value
|
||||
```javascript
|
||||
// In the `let appSettings = { ... }` block (~line 8)
|
||||
notifyBeforePromised: 0, // 0 = off, 15/30/60 = minutes before promised time
|
||||
```
|
||||
|
||||
### 2. `settings.js` — Populate form on load (`populateSiteSettingsForm`)
|
||||
```javascript
|
||||
// ~line 244
|
||||
setValue('notify-before-promised', appSettings.notifyBeforePromised, 0);
|
||||
```
|
||||
Use `setChecked()` for checkboxes/toggles, `setValue()` for selects/inputs.
|
||||
|
||||
### 3. `settings.js` — Save handler (`setupUnifiedSiteSettingsModalHandlers`) ×2
|
||||
There are TWO save handlers that must both read the new field:
|
||||
```javascript
|
||||
// ~line 277 (inside setupUnifiedSiteSettingsModalHandlers)
|
||||
appSettings.notifyBeforePromised = parseInt(document.getElementById('notify-before-promised')?.value ?? '0');
|
||||
|
||||
// ~line 1008 (window.saveSiteSettings bridge — repair-orders inline onclick)
|
||||
appSettings.notifyBeforePromised = parseInt(document.getElementById('notify-before-promised')?.value ?? '0');
|
||||
```
|
||||
Use `replace_all=true` when patching since the two blocks are identical.
|
||||
|
||||
### 4. `settings.js` — Auto-save binding (`attachSiteSettingsAutoSave`)
|
||||
```javascript
|
||||
// ~line 559
|
||||
bind('notify-before-promised', 'change', (el) => appSettings.notifyBeforePromised = parseInt(el.value || '0'));
|
||||
```
|
||||
Use `'change'` for selects, `'change'` for checkboxes.
|
||||
|
||||
### 5. `settings.js` — Realtime sync (`subscribeToRealtimeSettings`)
|
||||
```javascript
|
||||
// ~line 677
|
||||
setIfSafe('notify-before-promised', String(appSettings.notifyBeforePromised ?? 0));
|
||||
```
|
||||
Use `String()` for value fields, `!!` + `'checked'` for checkbox fields.
|
||||
|
||||
### 6. ALL FOUR HTML pages — Add the form control
|
||||
- `repair-orders.html` — inside `#site-settings-modal` → Notifications card
|
||||
- `appointments.html` — inside `#site-settings-modal` → Notifications card
|
||||
- `customers.html` — inside `#site-settings-modal` → Notifications card
|
||||
- `index.html` — inside `#site-settings-modal` → Notifications card
|
||||
|
||||
All four pages need the identical HTML block. Find the `Critical Alerts` toggle section
|
||||
and add the new control right after it, before the closing `</div>` of the Notifications card.
|
||||
|
||||
Template for a select dropdown:
|
||||
```html
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Setting Label</label>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 mb-2">Help text explaining the setting</p>
|
||||
<select id="setting-id" class="w-full p-3 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 focus:ring-2 focus:ring-purple-500 focus:outline-none">
|
||||
<option value="0">Off</option>
|
||||
<option value="15">15 minutes before</option>
|
||||
<option value="30">30 minutes before</option>
|
||||
<option value="60">1 hour before</option>
|
||||
</select>
|
||||
</div>
|
||||
```
|
||||
|
||||
Template for a toggle switch:
|
||||
```html
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<label class="text-sm font-medium text-gray-700 dark:text-gray-300">Toggle Label</label>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400">Help text</p>
|
||||
</div>
|
||||
<label class="toggle-switch">
|
||||
<input type="checkbox" id="toggle-id">
|
||||
<span class="slider"></span>
|
||||
</label>
|
||||
</div>
|
||||
```
|
||||
|
||||
## Verification
|
||||
|
||||
After all changes, verify with curl:
|
||||
```bash
|
||||
# settings.js should have 6 references (default, populate, save×2, auto-save, realtime)
|
||||
curl -sk https://localhost/settings.js | grep -c 'newFieldName'
|
||||
|
||||
# Each page should have exactly 1 reference (the element)
|
||||
for page in repair-orders.html appointments.html customers.html index.html; do
|
||||
echo "$page: $(curl -sk https://localhost/$page | grep -c 'element-id')"
|
||||
done
|
||||
```
|
||||
|
||||
## Common mistakes
|
||||
|
||||
- **Forgetting the second save handler**: `window.saveSiteSettings` (the bridge at file end) is a DUPLICATE of the handler inside `setupUnifiedSiteSettingsModalHandlers`. Both must read the new field.
|
||||
- **Missing a page**: All 4 pages use the same `settings.js` backend, so the form control must exist on all 4 or `getElementById` returns null.
|
||||
- **Wrong `bind` event type**: Use `'change'` for selects and checkboxes, not `'input'`.
|
||||
- **HTML ID mismatch**: The `id` attribute in HTML must exactly match the `document.getElementById()` call in JS.
|
||||
@@ -0,0 +1,100 @@
|
||||
# Settings Gap Analysis — ShopProQuote vs. Industry Standards
|
||||
|
||||
Analysis conducted 2026-06-28 comparing current SPQ settings against features in leading auto repair
|
||||
shop management systems (Tekmetric, Shopmonkey, Mitchell1, Orderry).
|
||||
|
||||
## Current SPQ Settings (what exists)
|
||||
|
||||
### Account Settings modal
|
||||
- Profile (email, display name)
|
||||
- Security (change password)
|
||||
- Preferences (email notifications toggle, auto-save forms toggle)
|
||||
|
||||
### Site Configuration modal
|
||||
- Display: dashboard refresh rate, items per page
|
||||
- Notifications: desktop notifications, sound alerts, critical alerts, notify-before-promised
|
||||
- System: timezone select (4 US zones)
|
||||
|
||||
### appSettings object (settings.js)
|
||||
- Business Info: businessName, businessAddress, businessPhone
|
||||
- Service Advisor: serviceAdvisor, advisorPhone
|
||||
- Financial: taxRate (single rate), shopChargeRate (single percentage)
|
||||
- Quote Messages: headerMessage, footerMessage, estimateValidDays, warrantyText, termsText
|
||||
- Quote Appearance: quoteTitle, quoteSubtitle, quoteFooterNote
|
||||
- Parts: defaultPartsDeliveryMessage
|
||||
- Shop Charge: shopChargeExplanation
|
||||
- System: darkMode, refreshRate, itemsPerPage, desktopNotifications, soundAlerts, criticalAlerts, notifyBeforePromised, timezone, autoSaveForms
|
||||
|
||||
## Ranked Gap Suggestions
|
||||
|
||||
### 1. Labor Rate Configuration (HIGH)
|
||||
SPQ has no labor rate field at all — quotes use a `lineTotal` manually. Industry standard is tiered rates.
|
||||
- Standard rate, diagnostic rate, specialty rate
|
||||
- Per-service-item rate override
|
||||
- Honda context: timing belts, valve adjustments, VTEC diagnostics command premium
|
||||
|
||||
### 2. Parts Markup & Pricing Rules (HIGH)
|
||||
Only a single taxRate and shopChargeRate exist. No parts markup configuration.
|
||||
- Default markup percentage
|
||||
- Tiered/matrix markup (higher % on cheap parts)
|
||||
- OEM vs. aftermarket toggle
|
||||
- Show/hide parts cost on customer PDF
|
||||
|
||||
### 3. Payment & Invoicing Defaults (HIGH)
|
||||
Only `termsText` free-text field. No structured payment config.
|
||||
- Accepted payment methods
|
||||
- Deposit percentage for large jobs
|
||||
- Invoice numbering (prefix, starting number)
|
||||
- Due date offset
|
||||
|
||||
### 4. Appointment Configuration (HIGH)
|
||||
No appointment settings except notify-before-promised.
|
||||
- Default duration, buffer time, daily max
|
||||
- Appointment color coding by type
|
||||
- Auto-transition to RO on arrival
|
||||
|
||||
### 5. Vehicle Info Field Config (MEDIUM-HIGH)
|
||||
No vehicle field configuration at all.
|
||||
- Required vs. optional fields (VIN, plate, mileage, engine)
|
||||
- VIN auto-decode toggle
|
||||
- Honda-specific: transmission type (CVT/auto/manual), VTEC variant
|
||||
|
||||
### 6. Customer Communication Templates (MEDIUM)
|
||||
No template system for automated customer messages.
|
||||
- Appointment confirmation, vehicle ready, follow-up, service reminder
|
||||
- Template variables: {customerName}, {vehicleInfo}, {appointmentDate}, etc.
|
||||
- Send method: email, SMS, or both
|
||||
|
||||
### 7. Repair Order Status Workflow (MEDIUM-HIGH)
|
||||
No configurable status workflow.
|
||||
- Custom status labels and colors
|
||||
- Mandatory checklist before completion
|
||||
- Auto-status triggers
|
||||
|
||||
### 8. Warranty & Guarantee Config (MEDIUM)
|
||||
Only free-text `warrantyText`. No structured warranty.
|
||||
- Parts warranty (months/miles)
|
||||
- Labor warranty (months/miles)
|
||||
- Per-service overrides
|
||||
- Disclaimer text
|
||||
|
||||
### 9. Inventory Stock Thresholds (MEDIUM)
|
||||
Only `defaultPartsDeliveryMessage`. No stock management settings.
|
||||
- Low-stock alert threshold
|
||||
- Critical-stock threshold
|
||||
- Auto-add to shopping list
|
||||
|
||||
### 10. Data Retention & Privacy (LOW-MEDIUM)
|
||||
No data retention or privacy settings.
|
||||
- Customer record retention period
|
||||
- Auto-archive completed ROs
|
||||
- Data export format / anonymization toggle
|
||||
|
||||
## Industry Sources Consulted
|
||||
|
||||
- Utility Mobile Apps: "50 Must-Have Features for Any Auto Repair Software" (2021)
|
||||
- Automotive Management Network: "100 Must-Have Features" checklist
|
||||
- Orderry: "Top 10 Features for Repair Shop Software in 2026"
|
||||
- Tekmetric feature comparison pages
|
||||
- Shopmonkey feature pages
|
||||
- Mitchell1 product pages
|
||||
@@ -0,0 +1,49 @@
|
||||
# Settings Modal Architecture
|
||||
|
||||
The `settings.js` `initializeSettings()` function expects a full tabbed settings modal with these element IDs:
|
||||
|
||||
## Required elements
|
||||
|
||||
### Modal shell
|
||||
- `settings-modal` — main modal container
|
||||
- `settings-close-btn` — close button
|
||||
|
||||
### Tab navigation
|
||||
- `business-info-tab-btn`, `service-advisor-tab-btn`, `financial-tab-btn`, `messages-tab-btn`, `services-tab-btn`
|
||||
- Tab contents: `business-info-tab`, `service-advisor-tab`, `financial-tab`, `messages-tab`, `services-tab`
|
||||
|
||||
### Save buttons
|
||||
- `business-info-save-btn`, `service-advisor-save-btn`, `financial-save-btn`, `messages-save-btn`
|
||||
|
||||
### Input fields (Business Info)
|
||||
- `settings-business-name`, `settings-business-address`, `settings-business-phone`
|
||||
|
||||
### Input fields (Service Advisor)
|
||||
- `settings-service-advisor`, `settings-advisor-phone`
|
||||
|
||||
### Input fields (Financial)
|
||||
- `tax-rate-input`, `settings-shop-charge`, `settings-shop-charge-explanation`
|
||||
|
||||
### Input fields (Messages)
|
||||
- `settings-header-message`, `settings-footer-message`, `settings-quote-footer-note`
|
||||
- `settings-estimate-valid-days`, `settings-warranty-text`, `settings-terms-text`
|
||||
- `settings-default-parts-delivery`
|
||||
|
||||
### Service management (in Services tab)
|
||||
- `service-search-input-settings` — search within settings
|
||||
- `add-service-btn` — opens add modal
|
||||
- `services-list` — rendered list
|
||||
- `add-service-modal-settings`, `add-service-name`, `add-service-price`, `add-service-recommendation`, `add-service-explanation`, `ai-write-btn-add-service`, `add-service-save-btn`, `add-service-cancel-btn`
|
||||
- `service-edit-modal-settings`, `service-edit-name-settings`, `service-edit-price-settings`, `service-edit-recommendation-settings`, `service-edit-explanation-settings`, `ai-write-btn-settings`, `service-edit-save-btn-settings`, `service-edit-cancel-btn-settings`
|
||||
|
||||
## Behavior
|
||||
|
||||
If ANY required element is missing, `initializeSettings()` returns early with a console error and `settings-btn` gets no click handler. This is why the gear icon does nothing on pages without the full modal.
|
||||
|
||||
## Repair-orders.html fix
|
||||
|
||||
The repair-orders.html page had `account-settings-modal` (profile/preferences) and `site-settings-modal` (display/notifications/system) but lacked the full `settings-modal`. Solution: inserted the complete 5-tab modal + 2 sub-modals (add service, edit service) before the script tags. All element IDs must match exactly what settings.js expects.
|
||||
|
||||
## Service list rendering pitfall
|
||||
|
||||
`renderServicesList()` in settings.js originally displayed only `service.recommendation` — it never rendered `service.explanation`. The data was saved to Firestore correctly but invisible in the list. Fix: add explanation display as italic text below the recommendation line.
|
||||
@@ -0,0 +1,28 @@
|
||||
# Settings Save/Load Bug
|
||||
|
||||
The `settings` PocketBase collection has schema: `id`, `userId`, `name`, `data` (JSON). All business settings go inside the `data` JSON field.
|
||||
|
||||
## The Bug
|
||||
|
||||
Settings were saved as direct fields (`businessName`, `businessAddress`, etc.) which PocketBase silently drops because they don't match the collection schema. On reload, settings were read from those same direct fields, which were always empty.
|
||||
|
||||
## Fix in Settings.tsx
|
||||
|
||||
**Save** — wrap in `{ data: {...} }`:
|
||||
```typescript
|
||||
// ❌ Before — fields silently dropped:
|
||||
await pb.collection('settings').create({ userId, ...DEFAULT_SETTINGS });
|
||||
await pb.collection('settings').update(record.id, s);
|
||||
|
||||
// ✅ After — stored in data JSON field:
|
||||
await pb.collection('settings').create({ userId, name: 'businessSettings', data: DEFAULT_SETTINGS });
|
||||
await pb.collection('settings').update(record.id, { data: s });
|
||||
```
|
||||
|
||||
**Load** — read from `r.data` with fallback to direct fields:
|
||||
```typescript
|
||||
const d = (r.data as Record<string, unknown>) || {};
|
||||
businessName: (d.businessName as string) || (r.businessName as string) || '',
|
||||
```
|
||||
|
||||
The fallback preserves backward compatibility with any records that may have been saved at the top level.
|
||||
@@ -0,0 +1,65 @@
|
||||
# SPQ-v2 Nginx Deployment (grajmedia.duckdns.org)
|
||||
|
||||
Serves the React/Vite SPA at **https://grajmedia.duckdns.org** on port 443.
|
||||
|
||||
## Current live config
|
||||
|
||||
The live nginx config is at `/etc/nginx/sites-enabled/shopproquote` — this is a **regular file** (not a symlink to `sites-available/`), and its content is completely different from the one at `sites-available/shopproquote`. Always check the **sites-enabled** version first when debugging; the sites-available version is stale.
|
||||
|
||||
## Root directory
|
||||
|
||||
```
|
||||
root /mnt/seagate8tb/Websites/ShopProQuote.backup-20260626-2058/spq-v2/dist;
|
||||
```
|
||||
|
||||
## Reverse proxy routes
|
||||
|
||||
| Path | Target | Purpose |
|
||||
|------|--------|---------|
|
||||
| `/pb/` | `http://127.0.0.1:8091/` | PocketBase API (used by spq-v2) |
|
||||
| `/api/` | `http://127.0.0.1:8091/api/` | PocketBase API (compatibility) |
|
||||
| `/llm/` | `http://127.0.0.1:11434/` | Ollama LLM |
|
||||
| `/vision/` | `http://127.0.0.1:11434/` | Ollama vision model |
|
||||
| `/deepseek/` | `https://api.deepseek.com/` | DeepSeek API proxy (with auth header) |
|
||||
|
||||
## Security headers (Phase 3.3 / 1.4 — already LIVE)
|
||||
|
||||
`/etc/nginx/snippets/spq-security.conf` is included at the top of the server block. Confirmed via `curl -sI`:
|
||||
- `Content-Security-Policy` (script-src 'self', style-src 'self' 'unsafe-inline', img-src 'self' data: blob:, frame-ancestors 'none')
|
||||
- `X-Content-Type-Options: nosniff`
|
||||
- `X-Frame-Options: DENY`
|
||||
- `Referrer-Policy: no-referrer` (stricter than roadmap's strict-origin-when-cross-origin)
|
||||
- `Permissions-Policy: microphone=(), geolocation=(), interest-cohort=()`
|
||||
|
||||
Do NOT re-implement roadmap 3.3 or 3.4 from the spec — they are already deployed (static hosting + SPA fallback + /pb + /api reverse proxy). Verify with:
|
||||
```bash
|
||||
curl -sI https://shopproquote.graj-media.com/ | grep -iE 'content-security|x-content|x-frame|referrer|permissions'
|
||||
```
|
||||
|
||||
## SPA fallback
|
||||
|
||||
```nginx
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html;
|
||||
}
|
||||
```
|
||||
|
||||
## Building and deploying
|
||||
|
||||
1. Source code: `/mnt/seagate8tb/Websites/ShopProQuote.backup-20260626-2058/spq-v2/`
|
||||
2. Run `npx vite build` in background mode (foreground gets falsely flagged as a server process):
|
||||
```
|
||||
cd /mnt/seagate8tb/Websites/ShopProQuote.backup-20260626-2058/spq-v2
|
||||
```
|
||||
Use `terminal(background=true, notify_on_complete=true)` to build.
|
||||
3. Build output goes to `dist/` in the same directory — nginx already points here, so no copy needed.
|
||||
4. Reload nginx: `sudo nginx -t && sudo systemctl reload nginx`
|
||||
5. Verify at https://grajmedia.duckdns.org
|
||||
|
||||
## Build workaround
|
||||
|
||||
`npx vite build` in foreground mode is flagged by Hermes as a long-lived server process. Use background mode instead. The project uses Vite 8.x with rolldown — builds complete in <1s.
|
||||
|
||||
## Port conflict notes
|
||||
|
||||
Port 3448 has a server_name conflict between `hermes-webui` (proxies to :8787) and `shopproquote` (serves legacy site). The VPS reverse proxy on graj-media.com sends traffic to port 443 (spq-v2), not 3448.
|
||||
@@ -0,0 +1,59 @@
|
||||
# SPQ-v2 Pitfalls (June 2026)
|
||||
|
||||
Bugs discovered and fixed during the spq-v2 porting/deployment phase.
|
||||
|
||||
## 1. Nested component input focus loss
|
||||
|
||||
The `ServiceRow` was defined as a nested function inside `ServicesTable`. Every store update re-rendered `ServicesTable`, recreating `ServiceRow` as a new React component type, destroying and remounting the DOM inputs. Focus lost on every keystroke.
|
||||
|
||||
**Fix:** Extracted to file-level `memo`'d component. See `pocketbase-development` skill.
|
||||
|
||||
## 2. Subagent-generated bugs
|
||||
|
||||
Subagents dispatched to build feature pages consistently produce the same class of bugs:
|
||||
- **Missing onClick handlers** — buttons rendered without any action (e.g., "Add as custom service")
|
||||
- **Wrong collection names** — using `repair_orders` when it's `repairOrders`
|
||||
- **Missing API wiring** — AI functions imported but not connected
|
||||
- **Nested components** — components defined inside components (causes focus loss)
|
||||
- **`sort: '-created'`** — references field that doesn't exist on most PB collections
|
||||
|
||||
Post-subagent audit checklist:
|
||||
- [ ] Every button has an onClick handler
|
||||
- [ ] Every collection name matches the actual PB collection
|
||||
- [ ] All import functions are actually called somewhere
|
||||
- [ ] No components defined inside other components
|
||||
- [ ] All sort fields exist on the target collection
|
||||
|
||||
## 3. PocketBase collection naming
|
||||
|
||||
| Code uses | Actual PB name | Status |
|
||||
|-----------|---------------|--------|
|
||||
| `repair_orders` | `repairOrders` | Wrong — must match |
|
||||
| `repairOrders` | `repairOrders` | Correct |
|
||||
| `invoices` | MISSING | Needs collection created |
|
||||
| `service_advisors` | MISSING | Needs collection created |
|
||||
| `vehicles` | MISSING | Needs collection created |
|
||||
|
||||
## 4. `created` field doesn't exist
|
||||
|
||||
PB collections created via the SDK on first insert don't get system `created`/`updated` fields. Use `sort: '-id'` instead. Also, `createdAt` field was used inconsistently — the QuoteGenerator saves `createdAt` but the Dashboard reads `created`.
|
||||
|
||||
## 5. Tesseract.js CDN import fails in Vite prod
|
||||
|
||||
`import('https://cdn.jsdelivr.net/npm/tesseract.js@5/...')` — Vite can't bundle external URLs. Use `import Tesseract from 'tesseract.js'` (npm package already in package.json).
|
||||
|
||||
## 6. RepairOrders JSON field (services)
|
||||
|
||||
PB `json` type fields arrive as strings when the collection was created via certain paths. Always guard with `Array.isArray()` and wrap `JSON.parse()` in try/catch.
|
||||
|
||||
## 7. Date formatting crashes
|
||||
|
||||
`new Date(undefined)` → `Invalid Date` → `RangeError: Invalid time value` when calling `.toLocaleDateString()`. Always guard with `if (!iso) return '—'`.
|
||||
|
||||
## 8. Completed → Reopen
|
||||
|
||||
Original bug: once a repair order was marked "completed", all status buttons disappeared. Only "Mark Delivered" was shown. Fixed by adding "Reopen" button that sets status back to "active".
|
||||
|
||||
## 9. Promised Completion (Due Time)
|
||||
|
||||
The original site shows a "Due" column with formatted promised completion times. Added `promisedTime` field to RepairOrder type, `formatDueDate()` formatter, datetime-local input in create/edit modal, and column in the table.
|
||||
@@ -0,0 +1,99 @@
|
||||
# PocketBase Query Pitfalls (spq-v2)
|
||||
|
||||
These recurring bugs cause silent page crashes and "Something went wrong" in spq-v2 React pages. Check these FIRST when a page breaks.
|
||||
|
||||
## sort: '-created' fails on base collections
|
||||
PB auto-adds `created`/`updated` to **auth** collections only, not **base** collections. spq-v2 uses base collections (quotes, repairOrders, appointments, customers, services, settings). Always use `sort: '-id'` — PB IDs are ULID-based and time-sortable.
|
||||
|
||||
**Symptom**: Page shows "Something went wrong".
|
||||
|
||||
**Fix**: `pb.collection('repairOrders').getList(1, 200, { sort: '-id' })`
|
||||
|
||||
## JSON fields come as strings from PB API
|
||||
When PocketBase stores a JSON field, the API may return it as a **serialized string**, not a parsed array/object. Accessing `.reduce()` or `.map()` on a string throws.
|
||||
|
||||
**Symptom**: `e.reduce is not a function`, `Unexpected end of JSON input`.
|
||||
|
||||
**Fix** — always normalize:
|
||||
```ts
|
||||
let services = item.services || [];
|
||||
if (typeof services === 'string' && services.trim()) {
|
||||
try { services = JSON.parse(services); } catch { services = []; }
|
||||
}
|
||||
if (!Array.isArray(services)) services = [];
|
||||
```
|
||||
|
||||
## `fields` parameter causes silent failures
|
||||
Restricting fields with `fields: 'id,customerName,...'` fails silently if any field name doesn't exist in the collection. PB returns an error that is hard to diagnose.
|
||||
|
||||
**Symptom**: "Failed to load appointments" with cryptic message.
|
||||
|
||||
**Fix**: Omit `fields` entirely and let PB return all fields. The bandwidth cost is negligible.
|
||||
|
||||
## Date formatting: always guard against undefined
|
||||
`new Date(undefined)` creates an Invalid Date object. Calling `.toLocaleDateString()`, `.toISOString()`, or any format method on it throws `RangeError: Invalid time value`.
|
||||
|
||||
**Symptom**: White screen, `Uncaught RangeError: Invalid time value`.
|
||||
|
||||
**Fix** — always add null guards:
|
||||
```ts
|
||||
function formatDate(iso: string | undefined | null) {
|
||||
if (!iso) return '—';
|
||||
const d = new Date(iso);
|
||||
if (isNaN(d.getTime())) return '—';
|
||||
return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
|
||||
}
|
||||
```
|
||||
|
||||
# React/Vite Pitfalls (spq-v2)
|
||||
|
||||
## Input focus loss in nested components
|
||||
Defining a component as a nested function inside another component causes React to destroy/recreate it on every parent re-render. Input fields lose focus on every keystroke.
|
||||
|
||||
**Symptom**: Typing in a price/textarea field causes focus loss after each character.
|
||||
|
||||
**Fix**: Extract to file-level and wrap with `React.memo`:
|
||||
```tsx
|
||||
const ServiceRow = memo(function ServiceRow(props: ServiceRowProps) {
|
||||
return <div>...</div>;
|
||||
});
|
||||
```
|
||||
|
||||
## CDN dynamic imports fail in Vite production builds
|
||||
`await import('https://cdn.jsdelivr.net/npm/tesseract.js@5/dist/tesseract.min.js')` cannot be bundled by Vite at build time. The import silently fails in production.
|
||||
|
||||
**Symptom**: Scan Screenshot button opens modal but OCR never starts.
|
||||
|
||||
**Fix**: Use static npm imports:
|
||||
```tsx
|
||||
import Tesseract from 'tesseract.js'; // correct
|
||||
```
|
||||
|
||||
# Nginx + DeepSeek API
|
||||
|
||||
- DeepSeek API key stored in `AUXILIARY_APPROVAL_API_KEY` env var (35 chars, valid key)
|
||||
- Nginx config at `/etc/nginx/sites-enabled/shopproquote` proxies `/deepseek/` → `https://api.deepseek.com/`
|
||||
- `proxy_set_header Authorization "Bearer $key"` must include the full uncorrupted key
|
||||
- **CRITICAL**: The secrets output filter truncates API keys when reading/writing files. When constructing nginx configs that embed the key, use Python (`os.environ.get()`) to read the key and write the config — do NOT use tee/heredoc with the key inline, as the filter will truncate it.
|
||||
- All AI calls (AI Write, Generate Priorities, AI Suggest, Scan Screenshot extraction) use the same `/deepseek/v1/chat/completions` endpoint through nginx
|
||||
|
||||
## SPA nginx config for spq-v2
|
||||
|
||||
```nginx
|
||||
root /path/to/spq-v2/dist;
|
||||
index index.html;
|
||||
|
||||
location / {
|
||||
try_files $uri $uri/ /index.html; # SPA fallback — NOT =404
|
||||
}
|
||||
|
||||
location /pb/ {
|
||||
proxy_pass http://127.0.0.1:8091/; # PocketBase
|
||||
}
|
||||
|
||||
location /deepseek/ {
|
||||
proxy_pass https://api.deepseek.com/;
|
||||
proxy_set_header Authorization "Bearer $API_KEY";
|
||||
proxy_ssl_server_name on;
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,40 @@
|
||||
# SPQ v1 → v2 Porting Patterns
|
||||
|
||||
## Architecture
|
||||
|
||||
- **v2**: React 19 + TypeScript + Vite + Tailwind CSS 4. Lives at `/mnt/seagate8tb/Websites/ShopProQuote.backup-20260626-2058/spq-v2/`
|
||||
- **Backend**: Same PocketBase at 127.0.0.1:8091, proxied through `/pb` → served by a Python proxy script
|
||||
- **Build**: `npx vite build` → `dist/` (code-split with React.lazy, ~38 chunks)
|
||||
- **Serve**: `/tmp/spq-backup-server.py` — static files from dist/ + PB proxy on port 4173
|
||||
|
||||
## Common Pitfalls
|
||||
|
||||
### PocketBase collection field mismatches
|
||||
SPQ's PocketBase collections often lack `created`/`updated` system fields because they were created via the API rather than the admin UI. Any query using `sort: '-created'` or `fields: '...,created'` will fail with 400. Replace with `sort: '-id'` (PB IDs are roughly chronological).
|
||||
|
||||
### Collection naming conventions
|
||||
The same collection may be named `repairOrders` (camelCase) or `repair_orders` (snake_case) depending on how it was created. Always verify the exact name via `GET /api/collections` before writing queries. The error is a 404 with "The requested resource wasn't found" — identical to a missing collection.
|
||||
|
||||
### Existing collections vs expected collections
|
||||
Collections that exist: `users`, `quotes`, `customers`, `appointments`, `services`, `settings`, `repairOrders`
|
||||
Collections that may be missing: `invoices`, `service_advisors`, `vehicles`
|
||||
|
||||
### Page states when collections are missing
|
||||
Pages should detect 404 responses and show a `SetupBanner` component with the collection name, required fields, and a link to `http://192.168.50.98:8091/_/` (PB admin). See `src/components/SetupBanner.tsx`.
|
||||
|
||||
### Build vs TypeScript
|
||||
`npx tsc --noEmit` passing does NOT guarantee `npx vite build` success. Rolldown (Vite's bundler) enforces stricter module resolution. Always run the build after changes.
|
||||
|
||||
### React patterns from subagents
|
||||
Subagents frequently produce:
|
||||
- Buttons without `onClick` handlers (appear in DOM but do nothing)
|
||||
- Nested components inside parent functions → input focus loss on every keystroke
|
||||
- AI function modules with hardcoded fallbacks instead of real API calls
|
||||
- Function signature drift between `ai.ts` exports and `QuoteGenerator.tsx` imports
|
||||
|
||||
### Test credentials
|
||||
```
|
||||
Email: demo@shop.com
|
||||
Password: test1234
|
||||
```
|
||||
The demo user has no data — all pages show empty states.
|
||||
@@ -0,0 +1,52 @@
|
||||
# SPQ v2 (React Rewrite)
|
||||
|
||||
## Project location
|
||||
`/mnt/seagate8tb/Websites/ShopProQuote.backup-20260626-2058/spq-v2/`
|
||||
|
||||
This is the CANONICAL codebase going forward. All new features go here. The v1 site at `/mnt/seagate8tb/Websites/ShopProQuote/` is the live reference being superseded.
|
||||
|
||||
## Stack
|
||||
- React 19 + TypeScript + Vite
|
||||
- Tailwind CSS v4 (compiled, not CDN)
|
||||
- PocketBase JS SDK (via npm, not CDN)
|
||||
- React Router v7
|
||||
- Zustand (state management)
|
||||
- lucide-react (icons)
|
||||
|
||||
## Key files
|
||||
- `src/types.ts` — all TypeScript interfaces
|
||||
- `src/App.tsx` — routes
|
||||
- `src/components/Layout.tsx` — sidebar nav
|
||||
- `src/lib/pocketbase.ts` — PB client init
|
||||
- `src/pages/*.tsx` — page components
|
||||
|
||||
## PocketBase config
|
||||
- PB URL: `/pb` (relative — needs proxy when served standalone)
|
||||
- Auth: users collection (same as v1)
|
||||
- Admin auth: NOT working with cb66154f (credentials outdated or wrong endpoint)
|
||||
|
||||
## Features status
|
||||
| Feature | v1 (live) | v2 (React) |
|
||||
|---------|-----------|-------------|
|
||||
| Login | ✅ | ✅ |
|
||||
| Dashboard | ✅ (full) | ✅ (basic) |
|
||||
| Quote Generator | ✅ (full) | ✅ (functional) |
|
||||
| Customers CRUD | ✅ | 🚧 ported |
|
||||
| Repair Orders | ✅ | 🚧 ported |
|
||||
| Appointments | ✅ | 🚧 ported |
|
||||
| Financial Dashboard | ✅ | 🚧 ported |
|
||||
| Invoice Manager | ✅ | 🚧 ported |
|
||||
| Settings (full) | ✅ | 🚧 ported |
|
||||
|
||||
## Local dev server
|
||||
For testing spq-v2 locally with PB proxy, use the script at `/tmp/spq-backup-server.py`:
|
||||
```
|
||||
python3 /tmp/spq-backup-server.py # serves on :4173, proxies /pb → :8091
|
||||
```
|
||||
|
||||
## Build and deploy
|
||||
```
|
||||
cd /mnt/seagate8tb/Websites/ShopProQuote.backup-20260626-2058/spq-v2
|
||||
npm run build # outputs to dist/
|
||||
npx tsc --noEmit # type check
|
||||
```
|
||||
@@ -0,0 +1,126 @@
|
||||
# spq-v2 React Frontend
|
||||
|
||||
The v2 rewrite lives at `/mnt/seagate8tb/Websites/ShopProQuote.backup-20260626-2058/spq-v2/`. Served on port 443 via nginx from its `dist/` directory.
|
||||
|
||||
## Build
|
||||
|
||||
Use `node ./node_modules/.bin/vite build` — NOT `npm run build`. The latter runs `tsc -b` first, which fails on pre-existing type errors in other files (Appointments, Customers, FinancialDashboard, etc.). Vite's esbuild handles TS more leniently and builds fine directly.
|
||||
|
||||
`npm run dev` starts the Vite dev server (port 5173) for live development.
|
||||
|
||||
## PocketBase Field Normalization (Critical)
|
||||
|
||||
The React frontend field names often differ from the PB collection column names. **This is the #1 source of "save doesn't persist" bugs.**
|
||||
|
||||
### How to diagnose
|
||||
|
||||
Query the actual PB SQLite schema:
|
||||
```bash
|
||||
sqlite3 /home/ray/docker/pocketbase/pb_data/data.db "PRAGMA table_info(repairOrders)"
|
||||
```
|
||||
|
||||
The TypeScript type definitions (`src/types.ts`) may be aspirational — the DB is the source of truth.
|
||||
|
||||
### The normalization pattern
|
||||
|
||||
When field names differ, transform in **both directions**:
|
||||
|
||||
- **On load** (in `fetchOrders` normalization): map PB column → frontend field
|
||||
- **On save** (in `handleSave`, `handleAddTime`): map frontend field → PB column
|
||||
|
||||
### Known mismatches
|
||||
|
||||
| Frontend field | Frontend type | PB column | PB type |
|
||||
|---|---|---|---|
|
||||
| `estimatedDuration` | number (minutes) | `estimatedTime` | TEXT (hours decimal string, e.g. "1.5") |
|
||||
| `customerType` | 'waiter' \| 'drop-off' | `financial.customerType` | stored in `financial` JSON blob |
|
||||
|
||||
### Example: estimatedTime ↔ estimatedDuration
|
||||
|
||||
```typescript
|
||||
// On load
|
||||
estimatedDuration = Math.round(parseFloat(item.estimatedTime) * 60);
|
||||
|
||||
// On save
|
||||
estimatedTime: (estimatedDuration / 60).toFixed(1);
|
||||
```
|
||||
|
||||
### Custom fields in financial JSON
|
||||
|
||||
Fields without a dedicated PB column (like `customerType`) must be stored/loaded from the `financial` JSON column:
|
||||
|
||||
```typescript
|
||||
// On load — extract from financial blob
|
||||
let customerType: CustomerType | undefined;
|
||||
if (item.financial) {
|
||||
try {
|
||||
const fin = typeof item.financial === 'string' ? JSON.parse(item.financial) : item.financial;
|
||||
if (fin && (fin.customerType === 'waiter' || fin.customerType === 'drop-off')) {
|
||||
customerType = fin.customerType;
|
||||
}
|
||||
} catch {}
|
||||
}
|
||||
|
||||
// On save — store in financial blob
|
||||
let financial: Record<string, any> = {};
|
||||
if (ro?.financial) {
|
||||
try { financial = typeof ro.financial === 'string' ? JSON.parse(ro.financial) : ro.financial; } catch {}
|
||||
}
|
||||
financial.customerType = newType;
|
||||
pb.collection('repairOrders').update(id, { financial: JSON.stringify(financial) });
|
||||
```
|
||||
|
||||
## Repair Orders Dashboard
|
||||
|
||||
The active Repair Orders page (`src/pages/RepairOrders.tsx`) has several key components:
|
||||
|
||||
### ROForm — Shared Edit Component
|
||||
|
||||
`src/components/ROForm.tsx` — reusable form extracted from ROModal. Used by:
|
||||
- **ROModal** — wraps ROForm in a modal overlay for create/edit popups
|
||||
- **RODetailsPanel** — renders ROForm inline in the Details tab
|
||||
|
||||
Props: `editRO` (null = create mode), `existingRoNumbers`, `settings`, `onSave`, `onCancel`, `submitLabel`. Contains all form state including services CRUD (add/remove/edit service rows), totals preview, discount, notes. The `ServiceRow` sub-component lives here — do not duplicate.
|
||||
|
||||
### RO Details Tab
|
||||
|
||||
Clicking an RO row navigates to a "Details" tab:
|
||||
- `TabId` includes `'details'` alongside `'active' | 'all' | 'completed'`
|
||||
- `selectedROId` state + `selectedRO` memoized lookup from `orders`
|
||||
- `handleSelectRO(id)` sets `selectedROId` + switches tab to `'details'`
|
||||
- Early-return guard when `activeTab === 'details'` renders `RODetailsPanel`
|
||||
- `handleInlineUpdate(roId, data)` updates PB directly (separate from modal's `handleSave`)
|
||||
|
||||
### Time helpers
|
||||
|
||||
- `calcDueTime(ro)` — computes due timestamp from `writeupTime` (falls back to `ro.created`) + `estimatedDuration` (defaults to 60min)
|
||||
- `formatTimeRemaining(ro, now)` — returns "1h 15m", "45m", "-10m" (returns "—" for completed/delivered)
|
||||
- `getUrgencyClass(ro, now)` — returns '' | 'warning' (≤30min remaining) | 'urgent' (past due)
|
||||
- `formatDueDateTime(ro)` — formatted date/time string of the computed due time
|
||||
- `formatDateTime(iso)` — "Jun 29, 3:45 PM" format
|
||||
|
||||
### UI components
|
||||
|
||||
- **CustomerTypeBadge** — clickable Waiter/Drop-Off badge with `window.confirm()` toggle
|
||||
- **TimeRemainingCell** — live countdown display with urgency coloring + alert icon
|
||||
- **StatusBadge** / **Status config** — `waiting_pickup` uses teal colors
|
||||
|
||||
### Multi-tier sort (`sortOrders(orders)`)
|
||||
|
||||
1. Waiting For Parts → pinned to bottom, sorted by due time ascending
|
||||
2. Waiters → above Drop-Offs
|
||||
3. Within each group, due time ascending (most urgent first)
|
||||
|
||||
### Urgency styling
|
||||
|
||||
- **Warning** (≤30min to due): amber text on countdown, no row highlight
|
||||
- **Urgent** (past due): red row background (`bg-red-50`), red text, alert triangle icon
|
||||
- **Completed/delivered**: no styling, countdown shows "—"
|
||||
|
||||
### Add Time
|
||||
|
||||
`handleAddTime(id, extraMinutes)` — triggered from a "+ Add Time" button in the expanded detail action bar. Shows a prompt accepting flexible formats:
|
||||
- `"30m"` → 30 minutes
|
||||
- `"1h"` → 60 minutes
|
||||
- `"1h 30m"` → 90 minutes
|
||||
- `"90"` → 90 minutes (plain number = minutes)
|
||||
@@ -0,0 +1,52 @@
|
||||
# ro.status vs ro.workStatus — Field Confusion
|
||||
|
||||
Two repair order fields with similar names, completely different meanings. Confusing them causes silent bugs.
|
||||
|
||||
## The Fields
|
||||
|
||||
| Field | Meaning | Values | Used For |
|
||||
|-------|---------|--------|----------|
|
||||
| `ro.status` | Customer service status | `waiter`, `drop-off` | Red/blue badge on RO card |
|
||||
| `ro.workStatus` | Work progress status | `diagnosing`, `in-progress`, `waiting-for-approval`, `waiting-for-parts`, `waiting-for-pickup`, `completed` | Work progress badge, dashboard counters, overdue filters, completion logic |
|
||||
|
||||
## Common Bug: Filtering by `ro.status === 'completed'`
|
||||
|
||||
**Symptom:** Dashboard shows "Completed: 0" despite having completed repair orders.
|
||||
|
||||
**Root cause:** Code filters by `ro.status === 'completed'` but `status` only stores `waiter` or `drop-off`. Completed orders have `ro.status` = `waiter` or `drop-off`, and `ro.workStatus` = `completed`.
|
||||
|
||||
**Wrong:**
|
||||
```javascript
|
||||
const completed = orders.filter(ro => ro.status === 'completed');
|
||||
```
|
||||
|
||||
**Correct:**
|
||||
```javascript
|
||||
const completed = orders.filter(ro => ro.workStatus === 'completed');
|
||||
```
|
||||
|
||||
## Common Bug: Filtering by `ro.status === 'picked-up'`
|
||||
|
||||
There is NO `picked-up` status value. The correct check is `ro.workStatus === 'waiting-for-pickup'`.
|
||||
|
||||
**Wrong:**
|
||||
```javascript
|
||||
ro.status !== 'completed' && ro.status !== 'picked-up'
|
||||
```
|
||||
|
||||
**Correct:**
|
||||
```javascript
|
||||
ro.workStatus !== 'completed' && ro.workStatus !== 'waiting-for-pickup'
|
||||
```
|
||||
|
||||
## Compound Bug: `ro.status` + Completed-Filtered Data Source
|
||||
|
||||
**Symptom:** Completed counter stays at 0 even after fixing status field checks.
|
||||
|
||||
**Additional cause:** `loadRepairOrders()` in dashboard.js filtered out ALL completed orders:
|
||||
```javascript
|
||||
const activeOrders = allOrders.filter(ro => ro.workStatus !== 'completed');
|
||||
dashboardData.repairOrders = activeOrders; // completed orders NEVER loaded
|
||||
```
|
||||
|
||||
**Fix:** Remove the filter from the data source. Let `dashboardData.repairOrders` contain ALL orders. Each consumer filters as needed.
|
||||
@@ -0,0 +1,39 @@
|
||||
# Task Desktop Notifications
|
||||
|
||||
Added June 2026 to `dashboard.js`. Tasks now fire browser desktop notifications when overdue or due within 1 hour.
|
||||
|
||||
## Architecture
|
||||
|
||||
The `checkForTaskAlerts()` function runs on every dashboard refresh tick (configurable, default 30s) alongside existing `checkForNewCriticalAlerts()` and `checkForApproachingPromised()`.
|
||||
|
||||
```
|
||||
tick() → loadDashboardData() → checkForNewCriticalAlerts() → checkForApproachingPromised() → checkForTaskAlerts()
|
||||
```
|
||||
|
||||
## Notification types
|
||||
|
||||
| Alert | Trigger | Title | Body format |
|
||||
|---|---|---|---|
|
||||
| Overdue | Task past due, not completed, newly detected | "🔴 Task Overdue" | "[title] — was due [Xh Ym / Xm] ago" |
|
||||
| Upcoming | Task due within 1 hour, not completed, newly detected | "⏰ Task Due Soon" | "[title] — due in [<1 min / Xm]" |
|
||||
|
||||
## Deduplication
|
||||
|
||||
New alerts are only sent for tasks NOT in the last-known set:
|
||||
- `lastOverdueTaskIds` — Set of overdue task IDs (resets each tick)
|
||||
- `lastUpcomingTaskIds` — Set of upcoming task IDs (resets each tick)
|
||||
|
||||
A task that's overdue on tick 1 fires once. On tick 2, it's already in the set and won't fire again. If the task is completed, it falls out of both filters.
|
||||
|
||||
## Respects settings
|
||||
|
||||
Uses the same `window.appSettings.desktopNotifications` toggle as RO overdue alerts. If desktop notifications are disabled in Site Configuration, task alerts are muted too.
|
||||
|
||||
## Browser API
|
||||
|
||||
Uses the existing `showDesktopNotification()` helper which wraps `Notification` API. First use prompts for permission. Once granted, notifications appear as OS-level toasts across all tabs/apps.
|
||||
|
||||
## Code location
|
||||
|
||||
- `dashboard.js`: `checkForTaskAlerts()` function (~line 2011), `tick()` at line 205
|
||||
- `dashboard.js`: `showDesktopNotification()` helper at line 2054
|
||||
@@ -0,0 +1,85 @@
|
||||
# Tesseract.js Client-Side Debugging Pattern
|
||||
|
||||
## The Problem
|
||||
|
||||
`Tesseract.recognize()` in the browser has multiple silent phases before "recognizing text":
|
||||
|
||||
1. `loading tesseract core` — downloads WASM from CDN
|
||||
2. `initializing tesseract` — sets up the engine
|
||||
3. `loading language traineddata` — downloads `eng.traineddata` from CDN
|
||||
4. `initializing api` — final setup
|
||||
5. `recognizing text` — actual OCR (the only phase with progress %)
|
||||
|
||||
If any phase fails (CSP block, CDN timeout, worker crash), the promise **never resolves or rejects**.
|
||||
The UI just shows "Running OCR..." forever.
|
||||
|
||||
## The Fix: Promise.race + Full Phase Logger
|
||||
|
||||
```javascript
|
||||
// Show ALL phases, not just "recognizing text"
|
||||
const ocrResult = await Promise.race([
|
||||
Tesseract.recognize(preprocessed, 'eng', {
|
||||
logger: m => {
|
||||
console.log(`[OCR] ${m.status} ${m.progress ? Math.round(m.progress*100)+'%' : ''}`);
|
||||
if (m.status === 'recognizing text') {
|
||||
progressText.textContent = `OCR: ${Math.round(m.progress*100)}%`;
|
||||
} else {
|
||||
progressText.textContent = `Tesseract: ${m.status}...`;
|
||||
}
|
||||
},
|
||||
tessedit_pageseg_mode: '4'
|
||||
}),
|
||||
new Promise((_, reject) =>
|
||||
setTimeout(() => reject(new Error('OCR timed out after 90s')), 90000)
|
||||
)
|
||||
]);
|
||||
```
|
||||
|
||||
**Why this works:**
|
||||
- `Promise.race` breaks the hang — after 90s, it rejects with a clear error
|
||||
- Full phase logging shows EXACTLY where it's stuck ("loading language traineddata..." = CDN issue)
|
||||
- The console gets timestamped `[OCR]` logs for post-mortem debugging
|
||||
|
||||
## Performance: Adaptive Upscaling
|
||||
|
||||
3× upscale on HD screenshots creates massive canvases (5760×3240). Use adaptive scaling:
|
||||
|
||||
```javascript
|
||||
// 2x for large images, 3x for small ones
|
||||
const scale = (img.width * img.height > 1000000) ? 2 : 3;
|
||||
canvas.width = img.width * scale;
|
||||
canvas.height = img.height * scale;
|
||||
```
|
||||
|
||||
This cuts pixel count by ~55% on typical screenshots while keeping 3× for small crops.
|
||||
|
||||
## Second OCR Pass (Date Extraction)
|
||||
|
||||
The date OCR pass uses PSM 11 (sparse text) and runs on the same preprocessed blob. Apply the same
|
||||
pattern with a shorter timeout (30s):
|
||||
|
||||
```javascript
|
||||
const dateOcrResult = await Promise.race([
|
||||
Tesseract.recognize(preprocessed, 'eng', {
|
||||
logger: m => { /* same pattern */ },
|
||||
tessedit_pageseg_mode: '11'
|
||||
}),
|
||||
new Promise((_, reject) =>
|
||||
setTimeout(() => reject(new Error('Date OCR timed out')), 30000)
|
||||
)
|
||||
]);
|
||||
```
|
||||
|
||||
## Ollama Cold-Model 502 Retry
|
||||
|
||||
The VPS proxy may return 502 when the local model is unloaded (KEEP_ALIVE=5m). The model takes
|
||||
~3-6s to load. Retry once after a 5s delay:
|
||||
|
||||
```javascript
|
||||
for (let attempt = 0; attempt < 2; attempt++) {
|
||||
const resp = await fetch(endpoint, { ... });
|
||||
if (resp.ok) break;
|
||||
if (resp.status !== 502 && resp.status !== 503) throw new Error(...);
|
||||
await new Promise(r => setTimeout(r, 5000)); // wait for model load
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,31 @@
|
||||
# Tool-Usage Pitfalls (SPQ-v2 sessions)
|
||||
|
||||
Lessons from real sessions that bit the agent. Read this before heavy editing sessions on spq-v2.
|
||||
|
||||
## read_file dedup guard
|
||||
|
||||
The `read_file` tool caches by path. Calling it again on the same path WITHOUT an `offset`/`limit` is treated as a duplicate and BLOCKED after 5 repeats, halting your turn.
|
||||
|
||||
**Symptom**: "BLOCKED: You have called read_file on this exact region N times and the file has NOT changed."
|
||||
|
||||
**What went wrong (2026-07-08 session)**: After reading ROForm.tsx (lines 1-500) and RODetailModal.tsx (full), I called `read_file` on the same paths again without offset to "get the rest". The dedup guard fired, blocked the call 5+ times, and triggered a hard-stop guardrail. The user saw repeated identical failures and expressed frustration ("why are you making so many mistakes").
|
||||
|
||||
**Fix**: If you already have content from a prior read and need a different region, pass `offset` + `limit` to read a specific slice. Never repeat the bare path call. If you hit the block, stop immediately and change strategy — do not retry the identical call.
|
||||
|
||||
**Prevention**: Track which files you have already read in the session. When you need more of a large file, use `offset` to skip already-read lines.
|
||||
|
||||
## patch requires all 3 params
|
||||
|
||||
The `patch` tool hard-fails after 6 retries if `path` is omitted, even when `old_string` and `new_string` are correct.
|
||||
|
||||
**Symptom**: "Tool loop hard stop: same_tool_failure_halt; Stopped patch: it failed 6 times this turn."
|
||||
|
||||
**What went wrong (2026-07-08 session)**: While adding the Financial tab, I called `patch` with only `mode`, `old_string`, and `new_string` but forgot `path`. The tool kept failing, I kept retrying with the same missing param, and it hit the hard-stop guardrail after 6 attempts.
|
||||
|
||||
**Fix**: Always include `path`, `old_string`, AND `new_string` together in a single call. Do not attempt to "batch" patches by omitting `path` — it will not work.
|
||||
|
||||
## User frustration signals
|
||||
|
||||
When the user says "why are you making so many mistakes" or "i need you to not make mistakes", this is a FIRST-CLASS signal that your tool usage has become repetitive and error-prone. Stop immediately, acknowledge the specific failure, and change strategy — do not attempt to justify or retry the failing approach.
|
||||
|
||||
Ray's preference: He is tolerant of exploration and wrong turns, but repeated identical tool failures (especially guardrail hits) frustrate him. When you hit a tool guardrail, the correct response is to acknowledge it and switch tools, not retry.
|
||||
@@ -0,0 +1,98 @@
|
||||
# ShopProQuote Troubleshooting Patterns
|
||||
|
||||
## Pitfall: PocketBase SDK auto-cancellation (`requestKey: null`)
|
||||
|
||||
PocketBase JS SDK has auto-cancellation enabled by default. All `create()` and `update()` calls to the same collection share a request key. A second call cancels the first — user sees "auto-cancelled" error.
|
||||
|
||||
**Symptom:** Creating a repair order shows "The request was auto-cancelled."
|
||||
|
||||
**Root cause:** Rapid double-submit fires two `addDoc` calls. Second POST cancels first via PocketBase's `requestKey` dedup.
|
||||
|
||||
**Fix:**
|
||||
1. Add `{ requestKey: null }` to all PocketBase mutating calls in `pocketbase.js`:
|
||||
```js
|
||||
// addDoc
|
||||
const record = await pb.collection(collName).create(pbData, { requestKey: null });
|
||||
// updateDoc
|
||||
const record = await pb.collection(collName).update(docId, pbData, { requestKey: null });
|
||||
// setDoc (both create and update branches)
|
||||
return pb.collection(collName).update(records[0].id, pbData, { requestKey: null });
|
||||
return pb.collection(collName).create(pbData, { requestKey: null });
|
||||
```
|
||||
2. Add double-submit guard: disable button on click, re-enable in success/error paths.
|
||||
```js
|
||||
const submitBtn = document.getElementById('submit-create-ro');
|
||||
if (submitBtn && submitBtn.disabled) return;
|
||||
if (submitBtn) { submitBtn.disabled = true; submitBtn.textContent = 'Creating...'; }
|
||||
```
|
||||
3. Re-enable on success AND error.
|
||||
|
||||
## Pitfall: Auth redirect loop (stale PocketBase token)
|
||||
|
||||
**Symptom:** Page constantly refreshes between index.html → dashboard.html → index.html.
|
||||
|
||||
**Root cause:** `index.html` line 131: `if(localStorage.getItem('pocketbase_auth')){location.replace('dashboard.html')}`. This is a blind truthy check — ANY string in localStorage (including expired tokens) triggers the redirect. When `dashboard.js` bounces back to `index.html` (invalid auth), the stale token was NEVER cleared, so index.html redirects again. Infinite loop.
|
||||
|
||||
**Fix:** Add `localStorage.removeItem('pocketbase_auth')` in ALL auth-fail redirect paths BEFORE navigating away:
|
||||
- `dashboard.js` onAuthStateChanged → `else` branch
|
||||
- `repair-orders.js` onAuthStateChanged → `else` branch
|
||||
- `appointments.js` auth check → `else` branch
|
||||
|
||||
```js
|
||||
} else {
|
||||
localStorage.removeItem('pocketbase_auth');
|
||||
window.location.href = 'index.html';
|
||||
}
|
||||
```
|
||||
|
||||
**Caveat:** If the user sees `login.html` in the redirect, that's browser cache — old JS files referenced `login.html` which no longer exists. All live files redirect to `index.html`. Hard-refresh (Ctrl+Shift+R) or clear site data.
|
||||
|
||||
## Pitfall: `ro.status` vs `ro.workStatus` confusion
|
||||
|
||||
**Symptom:** Dashboard "Completed Today" shows 0 even when ROs were completed. History filters don't work.
|
||||
|
||||
**Root cause:** Repair orders have TWO status fields:
|
||||
- `ro.status` — customer service status: `'waiter'` or `'drop-off'`
|
||||
- `ro.workStatus` — work progress: `'diagnosing'`, `'in-progress'`, `'waiting-for-approval'`, `'waiting-for-parts'`, `'waiting-for-pickup'`, `'completed'`
|
||||
|
||||
Dashboard.js had 5 instances checking `ro.status === 'completed'` (never true — customer service status is never 'completed'). Must check `ro.workStatus`.
|
||||
|
||||
**Also:** For completed filters, use `completedTime` or `financial.completedAt` for the date, NOT `writeupTime`. A RO written last week and completed today should count as completed today.
|
||||
|
||||
## Pitfall: DuckDNS auto-detects home IP, not VPS IP
|
||||
|
||||
**Symptom:** `grajmedia.duckdns.org` resolves to 162.81.173.34 (home) instead of 51.81.84.34 (VPS).
|
||||
|
||||
**Root cause:** DuckDNS update script at `/opt/duckdns/update.sh` uses `&ip=` (empty) which makes DuckDNS use the requesting server's public IP — the home server, not the VPS.
|
||||
|
||||
**Fix:** Explicitly set VPS IP:
|
||||
```bash
|
||||
echo url="https://www.duckdns.org/update?domains=grajmedia&token=<token>&ip=51.81.84.34" | curl -k -s -o /opt/duckdns/duck.log -K -
|
||||
```
|
||||
|
||||
The `/etc/hosts` entry `127.0.0.1 grajmedia.duckdns.org` is intentional — allows local access without hairpin NAT round-trip through the VPS.
|
||||
|
||||
## Pitfall: Completion handler scoping (const in try block)
|
||||
|
||||
**Symptom:** Financial modal "Complete" button does nothing after entering all data.
|
||||
|
||||
**Root cause:** `roData` declared with `const` inside a `try` block, but referenced outside it:
|
||||
```js
|
||||
try {
|
||||
const roData = getROData(roId); // block-scoped
|
||||
...
|
||||
} catch (e) {}
|
||||
if (roData) { ... } // ReferenceError — roData not defined here
|
||||
```
|
||||
The error crashes `handleFinancialCompletion()` silently.
|
||||
|
||||
**Fix:** Move declaration outside the try block:
|
||||
```js
|
||||
const roData = repairOrders.find(ro => ro.id === roId);
|
||||
try {
|
||||
const servicesLines = roData?.services ? ...;
|
||||
```
|
||||
|
||||
## Pattern: Status modal with segmented control
|
||||
|
||||
The work status modal uses a 2×3 grid of buttons instead of a dropdown. Each status has a color-coded pill with active state highlighting. Clicking a status immediately applies it (except 'completed' which opens financial modal, and leaving 'waiting-for-parts' which shows a confirmation).
|
||||
@@ -0,0 +1,133 @@
|
||||
# UI Pitfalls & Patterns
|
||||
|
||||
## Dropdown Stacking Context (z-index clipping)
|
||||
|
||||
**Symptom**: Service search dropdown (`#service-search-results`) is clipped/hidden by the quote summary section or accordion panels, despite `z-index: 99999 !important`.
|
||||
|
||||
**Root cause**: A parent element creates a stacking context (`overflow: hidden`, `transform`, `opacity < 1`, `will-change`). The dropdown's z-index is scoped within that parent and cannot escape. The accordion panels use `overflow: hidden` for collapse animations.
|
||||
|
||||
**Fix — Portal/teleport pattern** (applied in `main.js` and `quote-tab-manager.js`):
|
||||
|
||||
1. In `renderServiceResults()`, move dropdown to `#dropdown-root` on first open
|
||||
2. Position with `position: fixed` + `getBoundingClientRect()` on search input
|
||||
3. Add scroll/resize listeners to keep dropdown anchored to input
|
||||
|
||||
```js
|
||||
// Before showing dropdown:
|
||||
const dropdownRoot = document.getElementById('dropdown-root');
|
||||
if (dropdownRoot && resultsEl.parentElement !== dropdownRoot) {
|
||||
dropdownRoot.appendChild(resultsEl);
|
||||
}
|
||||
const rect = input.getBoundingClientRect();
|
||||
resultsEl.style.position = 'fixed';
|
||||
resultsEl.style.top = (rect.bottom + 4) + 'px';
|
||||
resultsEl.style.left = rect.left + 'px';
|
||||
resultsEl.style.width = rect.width + 'px';
|
||||
|
||||
// In initialization, add:
|
||||
const reposition = () => {
|
||||
if (resultsEl.classList.contains('hidden')) return;
|
||||
const rect = input.getBoundingClientRect();
|
||||
resultsEl.style.top = (rect.bottom + 4) + 'px';
|
||||
resultsEl.style.left = rect.left + 'px';
|
||||
resultsEl.style.width = rect.width + 'px';
|
||||
};
|
||||
window.addEventListener('scroll', reposition, { passive: true });
|
||||
window.addEventListener('resize', reposition);
|
||||
```
|
||||
|
||||
All pages have `#dropdown-root` at end of `<body>`: `index.html`, `dashboard.html`, `repair-orders.html`, `appointments.html`, `customers.html`.
|
||||
|
||||
## State Clearing Before RO Population
|
||||
|
||||
## React Custom Dropdown (v2)
|
||||
|
||||
When you need a styled dropdown that always opens below the trigger (native `<select>` lets the OS decide direction), build a custom one:
|
||||
|
||||
### Structure
|
||||
|
||||
```tsx
|
||||
const [open, setOpen] = useState(false);
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
// Click-outside-to-close
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const handler = (e: MouseEvent) => {
|
||||
if (ref.current && !ref.current.contains(e.target as Node)) {
|
||||
setOpen(false);
|
||||
}
|
||||
};
|
||||
document.addEventListener('mousedown', handler);
|
||||
return () => document.removeEventListener('mousedown', handler);
|
||||
}, [open]);
|
||||
|
||||
return (
|
||||
<div className="relative" ref={ref}> {/* relative anchors the panel */}
|
||||
<button onClick={() => setOpen(o => !o)}> {/* trigger */}
|
||||
<span>Placeholder…</span>
|
||||
<ChevronDown className={open ? 'rotate-180' : ''} />
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div className="absolute z-50 mt-2 w-full max-h-72 overflow-y-auto rounded-xl border bg-white shadow-xl">
|
||||
{items.map((item, i) => (
|
||||
<button
|
||||
key={item.id}
|
||||
onClick={() => { setOpen(false); navigate(`/target/${item.id}`); }}
|
||||
className={`w-full flex items-center gap-3 px-4 py-3 text-left hover:bg-gray-50 ${
|
||||
i > 0 ? 'border-t' : ''
|
||||
}`}
|
||||
>
|
||||
{/* Left: primary info */}
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="truncate text-sm font-medium">{item.name}</span>
|
||||
<span className="shrink-0 text-xs text-gray-400">#{item.id}</span>
|
||||
</div>
|
||||
<p className="truncate text-xs text-gray-500">{item.subtitle}</p>
|
||||
</div>
|
||||
{/* Right: secondary info */}
|
||||
<div className="shrink-0 flex flex-col items-end gap-1">
|
||||
<span className="text-sm font-semibold">{item.value}</span>
|
||||
<StatusBadge status={item.status} />
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
```
|
||||
|
||||
### Key details
|
||||
|
||||
| Detail | Why |
|
||||
|--------|-----|
|
||||
| `parent <div className="relative">` | Required — without it, the `absolute` panel positions relative to the nearest positioned ancestor (often the viewport) |
|
||||
| `z-50` | Stacks above surrounding content |
|
||||
| `mt-2` | 8px gap between trigger and panel |
|
||||
| `mousedown` listener (not `click`) | Fires earlier, feels more responsive |
|
||||
| `open ? 'rotate-180' : ''` | Chevron rotation gives clear open/closed affordance |
|
||||
| `hover:bg-gray-50` with `border-t` | Clear item boundaries + hover highlight |
|
||||
| `setOpen(false)` before `navigate()` | Prevents stale panel state if user navigates back |
|
||||
|
||||
### When to use vs. native `<select>`
|
||||
|
||||
- **Native `<select>`**: Good for simple value-picking forms (settings, filters) where the OS dropdown look is fine
|
||||
- **Custom dropdown**: Good for navigation, rich item layout (multiple lines, badges, icons), or when you need the dropdown to always open downward
|
||||
|
||||
**Bug**: Clicking "Generate Quote" from a repair order loads correct customer info but retains services from whatever quote was previously open.
|
||||
|
||||
**Fix**: In `populateROData()` in `quote-tab-manager.js`, call `quoteStateManager.clearSelectedServices()` BEFORE any other operations:
|
||||
|
||||
```js
|
||||
function populateROData(roData) {
|
||||
// CRITICAL: clear old services first
|
||||
quoteStateManager.clearSelectedServices();
|
||||
|
||||
const elements = getQuoteDomElements();
|
||||
// ... populate customer info ...
|
||||
// ... add RO services ...
|
||||
}
|
||||
```
|
||||
@@ -0,0 +1,37 @@
|
||||
# ShopProQuote UX Conventions
|
||||
|
||||
## Modal behavior — NEVER close on outside click
|
||||
|
||||
User preference: modals should ONLY close via explicit Save/Close/Cancel buttons. Clicking the backdrop (outside the modal) must NOT dismiss the modal.
|
||||
|
||||
### Files that had click-outside-to-close handlers (all removed):
|
||||
|
||||
| File | Modal | Was |
|
||||
|---|---|---|
|
||||
| `quote-tab-manager.js:1379` | Service edit modal | `modal.classList.add('hidden')` on `e.target === modal` |
|
||||
| `quote-tab-manager.js:1680` | Parts availability modal | `closeModal()` on `e.target === modal` |
|
||||
| `shared/customer-lookup.js:253` | Customer lookup modal | `modal.remove()` on `e.target === modal` |
|
||||
| `settings.js:315` | Site settings modal | `closeModal()` on `e.target === modal` |
|
||||
| `shared/modal-manager.js:328` | Confirm dialog overlay | `overlay.remove(); resolve(false)` on `e.target === overlay` |
|
||||
|
||||
### Pattern to recognize
|
||||
|
||||
Any `modal.addEventListener('click', (e) => { if (e.target === modal) { ... } });` blocks must be removed. If adding a new modal, do NOT add an outside-click handler.
|
||||
|
||||
### What was NOT changed (correctly)
|
||||
|
||||
Dropdown menus (service search, customer search, header menus) still close on outside click. This is expected UX for dropdowns — the rule only applies to modal dialogs.
|
||||
|
||||
## Toggle checkboxes — always show the icon
|
||||
|
||||
Approval/decline checkboxes (green ✓ and red ✕ in the ServiceRow) must always render their SVG icon, not conditionally. When inactive the icon is grayed-out (`text-gray-300`), when active the icon is white on a colored background (`text-white` on `bg-green-600` or `bg-red-600`).
|
||||
|
||||
**Pattern:**
|
||||
```tsx
|
||||
// ✓ — always visible, gray when inactive, white when active
|
||||
<button className={`... ${active ? 'bg-green-600 border-green-600 text-white' : 'border-gray-300 text-gray-300'}`}>
|
||||
<svg>...</svg> // always rendered, never conditional
|
||||
</button>
|
||||
```
|
||||
|
||||
Do NOT wrap the SVG in `{active && <svg>...</svg>}` — the icon should always be visible as a preview of the action.
|
||||
@@ -0,0 +1,63 @@
|
||||
# V1 → V2 Feature Gap (updated 2026-07-08)
|
||||
|
||||
spq-v2 (React/Vite SPA) covers ~90% of v1's feature set after the mass port on 2026-06-26.
|
||||
|
||||
## What v2 has (all pages render)
|
||||
|
||||
| Screen | File | Status |
|
||||
|--------|------|--------|
|
||||
| Login | `src/pages/Login.tsx` | Working |
|
||||
| Dashboard | `src/pages/Dashboard.tsx` | Working (use sort=-id, not -created) |
|
||||
| Quote Generator | `src/pages/QuoteGenerator.tsx` | Working — AI Write, Generate Priorities, AI Suggest, Quick Parts, Customer Decision, Tech Notes, Aftermarket Parts editor, Delivery Time editor, full legacy-accurate PDF with parts flags in output |
|
||||
| Customers | `src/pages/Customers.tsx` | Working (graceful when vehicles collection missing) |
|
||||
| Repair Orders | `src/pages/RepairOrders.tsx` | Working — uses repairOrders (NOT repair_orders) |
|
||||
| Appointments | `src/pages/Appointments.tsx` | Working — includes Scan Screenshot (Tesseract OCR + DeepSeek extraction) |
|
||||
| Financial Dashboard | `src/pages/FinancialDashboard.tsx` | Working — uses repairOrders collection (READ-ONLY: no editable financial summary modal) |
|
||||
| Invoices | `src/pages/Invoices.tsx` | Shows setup banner (invoices collection missing) |
|
||||
| Settings | `src/pages/Settings.tsx` | Working — 8 tabs (Business Info, Service Advisors, Tax & Fees, Quote Defaults, PDF & Branding, Business Ops, Service Catalog, Notifications), shows setup banner on Service Advisors tab |
|
||||
| PDF-to-Images Export | `src/lib/pdf-to-images.ts` | New — extracts each PDF page as a PNG download |
|
||||
| Dashboard (updated) | `src/pages/Dashboard.tsx` | Recent quotes now a custom dropdown with live search |
|
||||
|
||||
## AI Features (all in v2)
|
||||
|
||||
| Feature | Code | Endpoint |
|
||||
|---------|------|----------|
|
||||
| AI Write | `src/lib/ai.ts:aiWriteExplanation()` | `/deepseek/v1/chat/completions` → `api.deepseek.com` |
|
||||
| Generate Priorities | `src/lib/ai.ts:getPriorityAnalysis()` | Same endpoint |
|
||||
| AI Suggest Services | `src/lib/ai.ts:aiSuggestServices()` | Same endpoint |
|
||||
| Scan Screenshot | `Appointments.tsx` inline | Tesseract.js OCR + DeepSeek extraction |
|
||||
|
||||
All use model: deepseek-v4-flash with thinking: disabled, temperature: 0. API key in `/etc/nginx/sites-enabled/shopproquote`.
|
||||
|
||||
## What v1 has that v2 still lacks
|
||||
|
||||
| Feature | v1 Files | Notes |
|
||||
|---------|----------|-------|
|
||||
| Auto-save drafts | `auto-save-draft.js` | Auto-save form state to localStorage |
|
||||
| Customer Lookup (quick find) | `customer-lookup.js` | Quick search popup separate from full customer page |
|
||||
| Active ROs quick view | `ro-active.js` | Compact active RO overview widget |
|
||||
| Appointment Import | `appointment-import.js` | CSV bulk import |
|
||||
| **Financial Summary / Complete RO modal** | `repair-orders.html:857`, `repair-orders.js:2016+` | Editable CP total/cost + Warranty total/cost inputs with live gross-profit; auto-deducts warranty from CP. **Not ported to v2** — `FinancialDashboard.tsx` only reads the saved blob. See `references/financial-summary-modal.md` |
|
||||
|
||||
The Financial Summary modal is a significant missing editing surface (not a minor utility). Completing an RO in v1 prompts for CP/Warranty total/cost; v2 has no equivalent input flow.
|
||||
|
||||
## PocketBase collections needed
|
||||
|
||||
| Collection | Created? | Used by |
|
||||
|------------|----------|---------|
|
||||
| `quotes` | Yes | Dashboard, QuoteGenerator |
|
||||
| `customers` | Yes | Customers |
|
||||
| `appointments` | Yes | Appointments |
|
||||
| `services` | Yes | Settings (Service Catalog) |
|
||||
| `settings` | Yes | Settings |
|
||||
| `repair_orders` | **No** | RepairOrders, Financial, Invoices |
|
||||
| `invoices` | **No** | Invoices |
|
||||
| `service_advisors` | **No** | Settings (Advisors tab) |
|
||||
| `vehicles` | **No** | Customers (vehicle sub-records) |
|
||||
|
||||
Missing collections cause those pages to show error states. Create them via PocketBase admin UI or API to unlock full functionality.
|
||||
|
||||
## Architecture
|
||||
|
||||
- **v1**: Vanilla JS ES modules, Tailwind CSS via CDN, PocketBase SDK via CDN. No build step. Each page is a standalone HTML file.
|
||||
- **v2**: React 19 + TypeScript + Vite + PocketBase SDK (npm). React Router SPA routing. Lazy-loaded code splitting. Tailwind via PostCSS.
|
||||
@@ -0,0 +1,113 @@
|
||||
# SPQ v2 — Key Patterns & Pitfalls
|
||||
|
||||
Project at `/mnt/seagate8tb/Websites/ShopProQuote.backup-20260626-2058/spq-v2/`.
|
||||
Vite + React + TypeScript SPA, PocketBase backend.
|
||||
|
||||
## Dev Proxy — DeepSeek AI
|
||||
|
||||
The app calls DeepSeek API at `/deepseek/v1/chat/completions` (OpenAI-compatible). The API key lives in **nginx** (`/etc/nginx/sites-enabled/shopproquote`), not in the Vite project.
|
||||
|
||||
**Vite dev proxy MUST route through localhost nginx**, not direct to api.deepseek.com or Ollama:
|
||||
|
||||
```typescript
|
||||
'/deepseek': {
|
||||
target: 'https://localhost',
|
||||
changeOrigin: true,
|
||||
secure: false, // localhost self-signed cert
|
||||
},
|
||||
```
|
||||
|
||||
The old approach (pointing to Ollama :11434) fails because Ollama has no `deepseek-v4-flash`.
|
||||
|
||||
## Quote Service Defaults
|
||||
|
||||
Services added to a quote default to **Pending** status (yellow), NOT Approved:
|
||||
|
||||
```typescript
|
||||
addService({ ...service, selected: true, approved: false, customerDecision: 'pending', priority: undefined, applyShopCharge: true });
|
||||
```
|
||||
|
||||
This applies to all three code paths in `QuoteGenerator.tsx`:
|
||||
- `handleAdd` (catalog search picker, ~line 147)
|
||||
- RO import → quote pre-fill (~lines 777-778)
|
||||
- `handleAddSuggestion` (AI suggestions, ~lines 959-960)
|
||||
|
||||
The per-service "Shop charge" checkbox in the expanded row lets users override. The `QuoteSummary` only shows the shop charge line when `shopCharge > 0`.
|
||||
|
||||
## AI Write — Tech Notes
|
||||
|
||||
`handleAiWrite` in `QuoteGenerator.tsx` passes `technicianNotes: svc.technicianNotes` to `aiWriteExplanation`. The AI prompt in `ai.ts` already reads `technicianNotes` and includes them as `Technician's internal notes: "..."` in the context. Always include this field when calling `aiWriteExplanation`.
|
||||
|
||||
## RO Number & Top-Level Fields
|
||||
|
||||
`handleSave` writes customer info BOTH as nested `customerInfo` JSON AND as top-level fields (`customerName`, `vehicleInfo`, `roNumber`). The Dashboard queries top-level fields. The edit-load logic merges both sources:
|
||||
|
||||
```typescript
|
||||
const rawCustomerInfo = data.customerInfo
|
||||
? { ...data, ...(typeof data.customerInfo === 'string' ? JSON.parse(data.customerInfo) : data.customerInfo) }
|
||||
: data;
|
||||
```
|
||||
|
||||
## PDF → PNG Export
|
||||
|
||||
`downloadPdfAsImages(doc | Blob, customerName)` in `src/lib/pdf-to-images.ts` converts every PDF page to a PNG and triggers downloads. Accepts either a jsPDF instance or a Blob (from `generateQuotePDF()`). Uses pdfjs-dist with worker configured in `main.tsx` via Vite static-asset import.
|
||||
|
||||
## Repair Orders Dashboard
|
||||
|
||||
See `references/v2-repair-orders-dashboard.md` for the complete time-management system:
|
||||
- 6-status model with live countdown timers
|
||||
- Multi-tier sorting (waiting parts → waiters → drop-off → due time)
|
||||
- Urgency styling (warning ≤30min, past due = red row)
|
||||
- Interactive Waiter/Drop-Off toggle with confirmation
|
||||
- "+ Add Time" feature via prompt
|
||||
- Hours + Minutes duration input in create/edit modal
|
||||
|
||||
## Dashboard Dropdown
|
||||
|
||||
The Dashboard "Recent Quotes" section uses a custom dropdown with search instead of a native `<select>`. State: `jumpOpen` + `jumpQuery`. Click-outside closes via `useRef` + `mousedown` listener. Search filters by customer name, RO#, and vehicle. Quotes sorted by `-id` (PocketBase time-sortable).
|
||||
|
||||
## ROForm — Shared Edit Form Component
|
||||
|
||||
`src/components/ROForm.tsx` — reusable form component extracted from the ROModal (~600 lines → standalone). Used by both:
|
||||
- **ROModal** (create/edit popup) — wraps ROForm in a modal overlay with header
|
||||
- **RODetailsPanel** (inline details tab) — renders ROForm directly in-page
|
||||
|
||||
Props: `editRO` (null for create), `existingRoNumbers`, `settings`, `onSave`, `onCancel`, `submitLabel`.
|
||||
|
||||
Contains all form state (customer info, vehicle, advisor, status, warranty, services CRUD, discount, notes, totals preview). The `ServiceRow` sub-component also lives here — do NOT duplicate it.
|
||||
|
||||
## RO Details Tab
|
||||
|
||||
Clicking an RO row navigates to the "Details" tab (not inline expand). Architecture:
|
||||
- `TabId` includes `'details'`
|
||||
- `selectedROId` state + `selectedRO` memoized lookup
|
||||
- `handleSelectRO(id)` sets `selectedROId` + switches to `'details'` tab
|
||||
- Early-return guard renders `RODetailsPanel` when `activeTab === 'details' && selectedRO`
|
||||
- `handleInlineUpdate(roId, data)` handles PB update + refresh (separate from modal's `handleSave` which uses module-level `editRO` state)
|
||||
- Back button in RODetailsPanel resets to `'active'` tab
|
||||
|
||||
## JSX Refactoring — Fragment Wrapping Pitfall
|
||||
|
||||
When wrapping existing JSX in a conditional fragment, prefer **early return** over inline `{cond && (<>...</>)}` wrapping. The inline approach causes indentation/closure mismatches when the wrapped content has its own fragment nesting. Pattern:
|
||||
|
||||
```tsx
|
||||
// ✅ DO — early return
|
||||
if (someCondition) {
|
||||
return <SpecialView />;
|
||||
}
|
||||
const mainContent = (
|
||||
<>...existing content untouched...</>
|
||||
);
|
||||
|
||||
// ❌ AVOID — fragment wrapping
|
||||
const mainContent = (
|
||||
<>
|
||||
{cond && (
|
||||
<SpecialView />
|
||||
)}
|
||||
{!cond && (
|
||||
<>...existing content...</> // nesting hell
|
||||
)}
|
||||
</>
|
||||
);
|
||||
```
|
||||
@@ -0,0 +1,70 @@
|
||||
# v2 PDF Generation (src/lib/pdf.ts)
|
||||
|
||||
Stack: `jspdf@^4.2.1` (ESM: `import { jsPDF } from 'jspdf'`). Letter portrait, 20pt margins, 572pt content width.
|
||||
|
||||
## Critical jspdf 4.x anti-patterns
|
||||
|
||||
**DO NOT pass string arrays to `doc.text()`.** Array-based `doc.text(lines, x, y)` uses internal matrix positioning whose line spacing differs from manual `array.length * lineHeight` calculations — cascading yPos desync causes text overlap everywhere below. Always iterate line-by-line:
|
||||
|
||||
```ts
|
||||
// WRONG:
|
||||
doc.text(messageLines, MARGIN, yPos);
|
||||
yPos += messageLines.length * 6 + 15;
|
||||
|
||||
// RIGHT:
|
||||
messageLines.forEach((line) => { doc.text(line, MARGIN, yPos); yPos += 6; });
|
||||
yPos += 15;
|
||||
```
|
||||
|
||||
## Service height calculation
|
||||
|
||||
Must exactly match actual yPos increments for page-break estimates:
|
||||
|
||||
| Element | yPos advance |
|
||||
|---------|-------------|
|
||||
| Decision badge (approved/declined) | +5 |
|
||||
| Name + price line | +6 |
|
||||
| Priority header (label + reason) | +16 |
|
||||
| Recommendation | +8 |
|
||||
| Explanation (per line + final gap) | +5 per line, +2 final |
|
||||
| Parts Not In Stock | +8 (+8 more if delivery time) |
|
||||
| Aftermarket Available | +8 (+8 more if parts list) |
|
||||
| Service separator + spacing | +6 |
|
||||
|
||||
## Page-break logic
|
||||
|
||||
- `CONTENT_BOTTOM = PAGE_HEIGHT - MARGIN` (772)
|
||||
- `FOOTER_HEIGHT = 35` reserved at bottom of every page
|
||||
- Before each service: compute total height, break if `y + height > CONTENT_BOTTOM - FOOTER_HEIGHT`
|
||||
- After break: redraw current section header (APPROVED/POSTPONED)
|
||||
- Footer (separator + messages + "Page N of M") rendered on EVERY page via post-render loop — NOT just the last page
|
||||
|
||||
## Customer info loading (QuoteGenerator.tsx)
|
||||
|
||||
Zustand `reset()` wipes all state including customer info. Call `reset()` BEFORE setting any fields:
|
||||
|
||||
```ts
|
||||
reset(); // first
|
||||
if (data.customerInfo) setCustomerInfo(data.customerInfo); // then set
|
||||
if (data.services) { /* add services */ }
|
||||
```
|
||||
|
||||
PocketBase may return JSON fields as strings. Always check and parse:
|
||||
```ts
|
||||
const ci = typeof data.customerInfo === 'string' ? JSON.parse(data.customerInfo) : data.customerInfo;
|
||||
```
|
||||
|
||||
## Error visibility
|
||||
|
||||
PDF errors must show a toast — not just `console.error`:
|
||||
```tsx
|
||||
const { showToast } = useToast();
|
||||
// ...
|
||||
} catch (err: any) {
|
||||
showToast(`PDF generation failed: ${err?.message || 'Unknown error'}`, 'error');
|
||||
}
|
||||
```
|
||||
|
||||
## User preference
|
||||
|
||||
If the user says "stop", "this is taking too long", or "i can test it" — stop debugging immediately and let them test. Do not do browser-based PDF verification unless explicitly asked.
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user