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