60 KiB
SPQ-v2 Production Hardening Roadmap (v5.2) — Remaining Work
Generated: 2026-07-05 (cleaned 2026-07-07 — removed completed implementation steps) Source audit: Production-readiness audit of SPQ-v2 (React 19 / Vite 8 / Tailwind v4 / Zustand / PocketBase 0.39) Scope: Remaining items only — completed items (1.1, 1.5, 1.6, 1.7, 2.1, 2.2, 2.3, 2.4, 2.5, 2.6, 2.8, 2.9, 2.10) are DONE and their implementation steps removed. See Execution Memos below for what was completed.
Table of Contents
- Conventions & Pre-flight
- Phase 1 — Critical Fixes — Remaining Work
- Phase 2 — Optimization & UX — Remaining Work
- Phase 3 — Deployment & Monitoring
- Phase 4 — Recommended Features
- 4.1 Quote approval notifications
- 4.2 Quote expiry enforcement
- 4.3 Financial audit log (append-only, hash-chained)
- 4.4 Multi-vehicle customer history
- 4.5 Technician time-card CSV export
- 4.6 Recurring maintenance reminders
- 4.7 Offline-first for technicians (service worker)
- 4.8 Two-factor auth for advisors (TOTP)
- 4.9 Data export / GDPR
- 4.10 Multi-shop readiness (decision)
- Appendix A — Verification command cheat sheet
- Appendix B — PocketBase backup & migration procedure
- Appendix C — Environment variable reference
- Execution Memos (Historical Record)
Conventions & Pre-flight
Scope decisions (locked)
- AI proxy: self-hosted auth-injecting reverse proxy (nginx + tiny validator).
- PB migrations: included.
- No git / no CI — local home-server workflow.
- Hosting: nginx on the same box as PocketBase.
- Error tracking: self-hosted GlitchTip (Sentry-protocol compatible).
- Tests: zod schemas + vitest alongside each fix.
Standing rules (from AGENTS.md)
- Before any PB migration, back up the DB (Appendix B).
- Do not open
/tmp/opencode/pb-admin.txtwithout permission. Migration authoring does not require it. - Modal changes must follow the background-location overlay pattern documented in AGENTS.md.
- Verify after every step with the commands in the step's
Verify:block, not just at the end.
Pre-flight: confirm the PocketBase container
Before any PB step, confirm the container ID (it may have changed since 2026-07-05):
docker ps --format '{{.ID}} {{.Image}} {{.Names}} {{.Ports}}' | grep pocketbase
Export it for the session:
export PB_CID=<id-from-above>
All docker exec commands in this roadmap use $PB_CID.
Phase 1 — Critical Fixes — Remaining Work
1.2 DeepSeek AI auth-injecting reverse proxy (server-side)
Status: Frontend done (ai.ts sends PB token, handles 401/429, VITE_AI_ENABLED gate). Server-side pending.
What remains — server-side installation:
Step 1 — Write the token validator (Node, no deps):
// /opt/spq/ai-proxy/validate.js
// Tiny HTTP service: returns 200 if the incoming Authorization Bearer token
// is a valid PocketBase user token, 401 otherwise. Called by nginx auth_request.
const http = require('http');
const PB_URL = process.env.PB_URL || 'http://127.0.0.1:8091';
const PORT = parseInt(process.env.PORT || '8092', 10);
const VALIDATE_PATH = '/api/collections/users/refresh';
const server = http.createServer(async (req, res) => {
const auth = req.headers.authorization;
if (!auth || !auth.startsWith('Bearer ')) {
res.writeHead(401); return res.end('no token');
}
try {
const r = await fetch(`${PB_URL}${VALIDATE_PATH}`, {
method: 'POST',
headers: { Authorization: auth }
});
res.writeHead(r.status === 200 ? 200 : 401);
res.end(r.status === 200 ? 'ok' : 'invalid');
} catch {
res.writeHead(502); res.end('pb unreachable');
}
});
server.listen(PORT, '127.0.0.1', () => {
console.log(`[spq-ai-validator] listening on 127.0.0.1:${PORT}`);
});
Step 2 — systemd unit:
# /opt/spq/ai-proxy/spq-ai-validator.service
# Copy to /etc/systemd/system/ and: sudo systemctl enable --now spq-ai-validator
[Unit]
Description=SPQ DeepSeek token validator
After=network.target docker.service
[Service]
Type=simple
WorkingDirectory=/opt/spq/ai-proxy
ExecStart=/usr/bin/node validate.js
Environment=PB_URL=http://127.0.0.1:8091
Environment=PORT=8092
Restart=on-failure
RestartSec=5
[Install]
WantedBy=multi-user.target
Step 3 — nginx location (include in the server block from 3.3):
# /etc/nginx/snippets/spq-deepseek.conf
limit_req_zone $binary_remote_addr zone=spq_ai:10m rate=20r/m;
location /deepseek/ {
limit_req zone=spq_ai burst=5 nodelay;
auth_request /_spq_validate;
auth_request_set $spq_auth_status $upstream_status;
proxy_set_header Authorization "";
proxy_set_header Authorization "Bearer $http_deepseek_api_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;
}
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;
}
Write the API key to /etc/nginx/snippets/deepseek-key.conf (mode 0600, owner root) containing set $deepseek_key "sk-..."; and include it inside the location.
Step 4 — Fix the stale dev proxy in vite.config.ts:
The /deepseek proxy currently targets https://127.0.0.1:3448 with secure: false — that gateway no longer exists. Point at local nginx:
'/deepseek': {
target: 'http://127.0.0.1:80',
changeOrigin: true,
rewrite: (path) => path,
},
Or use a .env.local value: target: process.env.VITE_DEEPSEEK_DEV_PROXY || 'http://127.0.0.1:80'.
Verify:
npm run build
npm run lint
# Manual: with nginx + validator running, log in, open Quote Generator,
# trigger AI Suggest — should succeed. Without login — should 401.
1.3 Block self-signup role escalation (PB hook)
Status: NOT DONE — needs PB migration.
Problem: src/pages/Login.tsx:96-104 sends role: 'advisor' from the client. PocketBase auth collections allow client-set fields on self-create, so a malicious signup can send role: 'technician' and shopUserId: '<someone>' to masquerade as a technician under any shop.
Fix: PB onRecordBeforeCreate hook on users that forces role = 'advisor' and clears shopUserId — unless there is a matching pending, unexpired technicianInvites record for the email being registered.
File: pb_migrations/1740000000020_users_create_role_guard.js
/// <reference path="../pb_data/types.d.ts" />
migrate(
(app) => {
app.onRecordBeforeCreateRequest((e) => {
if (e.collection.name !== 'users') return;
const record = e.record;
const email = (record.get('email') || '').toString().trim().toLowerCase();
if (!email) return;
let invite = null;
try {
invite = e.dao.findFirstRecordByFilter(
'technicianInvites',
`email = "${email.replace(/"/g, '')}" && status = "pending" && expiresAt >= @now`
);
} catch {
invite = null;
}
if (invite) {
record.set('role', 'technician');
record.set('shopUserId', invite.get('shopUserId') || '');
} else {
record.set('role', 'advisor');
record.set('shopUserId', '');
}
});
},
(app) => {
console.log('[M20] rollback requested — remove this migration file and restart PocketBase');
}
);
Also update Login.tsx to stop sending role/shopUserId from the client (they're now server-set by the hook):
await pb.collection('users').create({
email: email.trim(),
password,
passwordConfirm: confirmPassword,
name: name.trim() || '',
displayName: name.trim() || '',
emailVisibility: true,
// role and shopUserId are now server-set by the M20 hook.
});
Verify:
npm run build
npm run lint
# Manual: sign up new account → check users.role == 'advisor', shopUserId == ''
# Try curl POST with role:'technician' → confirm role still 'advisor'
# Create a technician invite, accept via link → confirm role == 'technician'
1.4 Security headers + CSP (nginx snippet)
Status: Frontend cleanup done (Toast <style> injection already removed, keyframes in index.css). Nginx snippet pending.
What remains — nginx snippet:
# /etc/nginx/snippets/spq-security.conf
# Include inside the server { } block for the SPQ frontend.
add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self'; connect-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'self';" always;
add_header X-Content-Type-Options "nosniff" always;
add_header X-Frame-Options "DENY" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
add_header Permissions-Policy "camera=(), microphone=(), geolocation=(), interest-cohort=()" always;
Include in the server block (wired in step 3.3):
server {
listen 443 ssl http2;
server_name spq.yourdomain.tld;
include /etc/nginx/snippets/spq-security.conf;
# ... (rest from 3.3)
}
Verify:
# After nginx reload:
curl -sI https://spq.yourdomain.tld/ | grep -iE 'content-security|x-content|referrer|permissions'
# Confirm Toast still animates (no console CSP violations).
Phase 2 — Optimization & UX — Remaining Work
2.7 Logo uploads to PocketBase file records
Status: NOT DONE — needs PB migration.
Problem: ShopSettings.logoUrl stores a base64 data-URL. Bloats localStorage + every settings round-trip.
Files:
- New PB migration:
pb_migrations/1740000000022_settings_logo_file.js - Edit:
src/pages/Settings.tsx(upload flow) - Edit:
src/lib/settings.ts(load file URL)
Step 1 — PB migration adds a logo file field to settings:
// pb_migrations/1740000000022_settings_logo_file.js
migrate((app) => {
const col = app.findCollectionByNameOrId('settings');
if (!col.fields.find(f => f.name === 'logo')) {
col.fields.add(new FileField({
name: 'logo',
required: false,
maxSize: 5242880,
allowedMimeTypes: ['image/png', 'image/jpeg', 'image/webp', 'image/svg+xml'],
}));
app.save(col);
}
}, () => {});
Step 2 — Frontend upload in Settings.tsx:
const file = input.files?.[0];
if (file) {
const formData = new FormData();
formData.append('logo', file);
await pb.collection('settings').update(recordId, formData);
const url = pb.files.getURL({ id: recordId, collectionId: 'settings' }, 'logo');
setSettings(s => ({ ...s, logoUrl: url + '?thumb=200x200' }));
}
Step 3 — Stop storing base64 in logoUrl. Update QuoteApprove.tsx and PDF generators to use the file URL (they already handle URLs).
Verify:
npm run build && npm run lint && npm run test
# Manual: upload a logo in Settings, confirm it appears on quotes & PDFs.
Phase 3 — Deployment & Monitoring (Hosting, Logging, Error Tracking)
3.1 GlitchTip self-hosted (Docker)
Problem: No client-side error tracking. console.error is the only signal.
Files:
- New:
/opt/glitchtip/docker-compose.yml - New:
.env.glitchtip(server-side secrets) - Edit:
src/lib/userMessages.ts(wiresetErrorReporter) - Edit:
src/main.tsx(init Sentry SDK pointed at GlitchTip)
Step 1 — GlitchTip docker-compose:
# /opt/glitchtip/docker-compose.yml
version: "3.8"
services:
db:
image: postgres:15
environment:
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
volumes: [pgdata:/var/lib/postgresql/data]
redis:
image: redis:7
web:
image: glitchtip/glitchtip:latest
depends_on: [db, redis]
environment:
DATABASE_URL: postgresql://postgres:${POSTGRES_PASSWORD}@db:5432/postgres
REDIS_URL: redis://redis:6379/1
SECRET_KEY: ${SECRET_KEY}
EMAIL_URL: "consolemail://"
PORT: 8000
ports: ["127.0.0.1:8000:8000"]
volumes: [uploads:/app/uploads]
volumes: { pgdata: {}, uploads: {} }
Step 2 — Create a DSN in the GlitchTip UI (one-shot, not scriptable). Set VITE_GLITCHTIP_DSN in .env.production.
Step 3 — Frontend Sentry SDK pointed at GlitchTip:
npm install @sentry/react
// src/main.tsx — add BEFORE createRoot
import * as Sentry from '@sentry/react';
import { setErrorReporter } from './lib/userMessages';
const DSN = import.meta.env.VITE_GLITCHTIP_DSN;
if (DSN) {
Sentry.init({
dsn: DSN,
environment: import.meta.env.VITE_ENV || 'development',
tracesSampleRate: 0.1,
});
setErrorReporter((err, ctx) => {
Sentry.captureException(err, { extra: ctx });
});
}
Verify:
npm run build
# Manual: trigger an error (throw in a render), confirm it appears in GlitchTip UI.
3.2 PocketBase production hardening
Step 1 — HTTPS via nginx or Caddy. nginx already in scope (step 3.3). Ensure the PB location block uses HTTPS upstream if PB is exposed beyond localhost.
Step 2 — SMTP for verification + invite emails. Set PB env vars:
PB_SMTP_ENABLED=true
PB_SMTP_HOST=smtp.your-provider.com
PB_SMTP_PORT=587
PB_SMTP_USERNAME=...
PB_SMTP_PASSWORD=...
PB_SMTP_TLS=true
Restart the container. Test: sign up a new account, confirm the verification email arrives.
Step 3 — Daily DB backup cron. Add to crontab:
0 3 * * * docker exec $PB_CID cp /pb_data/data.db /pb_data/data.db.backup-$(date +\%Y\%m\%d) && docker cp $PB_CID:/pb_data/data.db.backup-$(date +\%Y\%m\%d) /backups/spq/ && find /backups/spq/ -mtime +14 -delete
Step 4 — Superadmin from env:
PB_SUPERADMIN_EMAIL=...
PB_SUPERADMIN_PASSWORD=...
Verify: email arrives, backup file exists, admin login works with env creds.
3.3 Frontend hosting (nginx static)
Files: /etc/nginx/sites-available/spq.conf
server {
listen 80;
server_name spq.yourdomain.tld;
return 301 https://$host$request_uri;
}
server {
listen 443 ssl http2;
server_name spq.yourdomain.tld;
ssl_certificate /etc/letsencrypt/live/spq.yourdomain.tld/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/spq.yourdomain.tld/privkey.pem;
root /var/www/spq/dist;
index index.html;
include /etc/nginx/snippets/spq-security.conf; # from 1.4
location / {
try_files $uri $uri/ /index.html;
add_header Cache-Control "no-cache, must-revalidate";
}
location /assets/ {
expires 1y;
add_header Cache-Control "public, immutable";
}
include /etc/nginx/snippets/spq-pb.conf;
include /etc/nginx/snippets/spq-deepseek.conf;
location ~* \.(woff2|png|jpg|jpeg|gif|svg|webp|ico)$ {
expires 30d;
add_header Cache-Control "public";
}
gzip on;
gzip_types text/css application/javascript application/json image/svg+xml;
gzip_min_length 1024;
}
Step 1 — Build and deploy:
npm run build
sudo rsync -av --delete dist/ /var/www/spq/dist/
sudo nginx -t && sudo systemctl reload nginx
Step 2 — TLS via certbot:
sudo certbot --nginx -d spq.yourdomain.tld
Verify: curl -I https://spq.yourdomain.tld/ returns 200 with security headers.
3.4 Reverse proxy for /pb and /api/ai/chat
Files: /etc/nginx/snippets/spq-pb.conf
# /etc/nginx/snippets/spq-pb.conf
location /pb/ {
rewrite ^/pb/(.*)$ /$1 break;
proxy_pass http://127.0.0.1:8091;
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_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_read_timeout 86400;
}
The /deepseek/ location is already defined in 1.2's spq-deepseek.conf.
Verify: log in, open Dashboard, confirm realtime updates work.
3.5 Structured logging
Step 1 — nginx access log with JSON format:
log_format json_combined escape=json '{ "time":"$time_iso8601", "remote":"$remote_addr", "method":"$request_method", "uri":"$uri", "status":$status, "bytes":$body_bytes_sent, "rt":$request_time }';
access_log /var/log/nginx/spq.access.log json_combined;
Step 2 — PB logs to a file with rotation:
docker logs pocketbase > /var/log/spq/pb.log 2>&1
docker update --log-driver json-file --log-opt max-size=10m --log-opt max-file=5 pocketbase
Step 3 — Watch for 429/404 spikes:
tail -F /var/log/nginx/spq.access.log | grep -E "quote/approve|invite/technician"
Verify: logs are writing; rotate without service interruption.
3.6 Offline banner
Files: New src/components/OfflineBanner.tsx, edit src/App.tsx
// src/components/OfflineBanner.tsx
import { useEffect, useState } from 'react';
export function OfflineBanner() {
const [online, setOnline] = useState(navigator.onLine);
useEffect(() => {
const on = () => setOnline(true);
const off = () => setOnline(false);
window.addEventListener('online', on);
window.addEventListener('offline', off);
return () => { window.removeEventListener('online', on); window.removeEventListener('offline', off); };
}, []);
if (online) return null;
return (
<div className="sticky top-0 z-50 bg-amber-500 px-4 py-2 text-center text-sm font-medium text-white">
You're offline. Some features may be unavailable.
</div>
);
}
In src/App.tsx — add inside <BrowserRouter>, above <AppRoutes />:
<OfflineBanner />
Verify: npm run build; disable network in DevTools → banner appears.
3.7 Dependency audit
npm audit
npm outdated
# Pin any major-version drift. React 19.2, Vite 8, TS 6.0 are bleeding-edge.
Verify: no high-severity advisories in npm audit output.
Phase 4 — Recommended Features
These are enhancements, not fixes. Lower detail — each is a future feature ticket.
4.1 Quote approval notifications
- What: advisor gets notified when a customer submits approve/decline decisions via
QuoteApprove.tsx. - How: PB
onRecordAfterUpdateRequesthook onquotes— whenstatuschanges fromsenttoapproved/declined, send an email (PB SMTP from 3.2) to the shop owner. Store the owner'suserIdon the quote; look up their email viadao.findRecordById('users', userId). - Files: new
pb_migrations/..._quote_approval_notify.js; no frontend change.
4.2 Quote expiry enforcement
- What: reject approvals past
quoteExpiryDays. - How: PB
onRecordBeforeUpdateRequesthook onquotes— if@request.query.shareTokenis set (public update path) andcreated+quoteExpiryDays< now, throwApiError(410, 'Quote expired'). FrontendQuoteApprove.tsxloadsquoteExpiryDaysfrom settings and shows an "expired" state. - Files: new PB migration; edit
QuoteApprove.tsxexpired UI.
4.3 Financial audit log (append-only, hash-chained)
- What: tamper-evident log of every financial field change on an RO.
- How: new
roFinancialAuditcollection withroId,field,from,to,by,at,prevHash,hash(SHA-256 of all prior fields + prevHash). PBonRecordAfterUpdateRequestonrepairOrdersdiffs thefinancialblob and appends a chained entry. - Files: new PB migration; new
src/lib/roAudit.ts; read-only viewer inRODetailModalTimeline tab.
4.4 Multi-vehicle customer history
- What: customer-level history across all their vehicles (current
RODetailModalHistory tab is VIN-only). - How: add a "Customer History" view that queries
repairOrdersbycustomerId(already a field) and groups by vehicle. Add a button onCustomers.tsxrow → opens the view. - Files: new
src/pages/CustomerHistory.tsx; route inApp.tsx.
4.5 Technician time-card CSV export
- What: weekly CSV of
clockElapsedMinutesper technician. - How: new "Time Cards" advisor page that queries
technicianAssignmentsbyshopUserId+ date range, aggregatesclockEntries, generates CSV withBlob+URL.createObjectURL. - Files: new
src/pages/advisor/TimeCards.tsx; route + nav item.
4.6 Recurring maintenance reminders
- What: "remind this customer in 5,000 miles" workflow.
- How: new
remindersPB collection (customerId,mileageAtService,intervalMileage,dueMileage). A daily check (PB cron hook or a small systemd timer) scans new ROs; when a customer's current mileage crossesdueMileage, flag them on the Dashboard. - Files: new PB migration; new reminder UI in
RODetailModal; Dashboard widget.
4.7 Offline-first for technicians (service worker)
- What: technicians can clock in/out and update service status while offline; syncs on reconnect.
- How: Workbox service worker caching the technician shell; IndexedDB write queue for
clockStart/updateServiceStatus; background sync ononlineevent replays the queue. - Files: new
src/sw.ts; Vite PWA plugin;src/lib/offlineQueue.ts. Significant effort — scope as a sub-project.
4.8 Two-factor auth for advisors (TOTP)
- What: TOTP 2FA on advisor accounts.
- How: PocketBase 0.39 supports TOTP via
authWithOTP. Enable in PB admin settings for theuserscollection. Frontend: add a 2FA setup page in Settings (QR code frompb.collection('users').getOTPURL()), and a 2FA challenge step inLogin.tsxafter password. - Files: edit
Login.tsx; newsrc/pages/SecuritySettings.tsxsection.
4.9 Data export / GDPR
- What: "export my data" + "delete my account" flows.
- How: new
src/pages/Privacy.tsxwith two buttons. Export: query all user-scoped collections, zip as JSON, download. Delete: confirm dialog → call PB delete on each collection's records →pb.collection('users').delete(userId)→pb.authStore.clear(). - Files: new page; route; Settings link.
4.10 Multi-shop readiness (decision)
- What: decide whether to support multi-location operators.
- How: this is a design decision, not a build. The current
userId-as-tenant model works for single-shop. Multi-shop would need ashopscollection aboveuserswithshopIdon every record and updated API rules. Decide before scaling — retrofitting is expensive. If yes, write aroadmap_6.0.mdfor the migration.
Appendix A — Verification command cheat sheet
# Typecheck + build
npm run build
# Lint (oxlint) — 2 pre-existing warnings in Toast.tsx and Settings.tsx
npm run lint
# Full test suite (Vitest, jsdom)
npm run test
# Focused test
npx vitest run "src/lib/__tests__/userMessages.test.ts"
# Bundle size inspection
du -sh dist/assets/
ls -laS dist/assets/ | head -10
for f in dist/assets/*.js; do
printf "%-45s raw=%7d gzip=%7d\n" "$(basename $f)" "$(stat -c%s $f)" "$(gzip -c $f | wc -c)"
done | sort -k4 -rn | head -15
# Grep for patterns
rg -n "catch\s*\{" src/ # silent catches
rg -n "getFullList" src/ # unpaginated fetches
rg -n "import.*from 'tesseract|jspdf|html2canvas'" src/ # eager heavy imports
Appendix B — PocketBase backup & migration procedure
# 1. Find the container
docker ps --format '{{.ID}} {{.Names}}' | grep pocketbase
export PB_CID=<id>
# 2. Back up
docker exec $PB_CID cp /pb_data/data.db /pb_data/data.db.backup-before-$(date +%Y%m%d-%H%M%S)
# 3. Copy the new migration file into the container
docker cp "pb_migrations/<MIGRATION_FILE>.js" $PB_CID:/pb_data/migrations/
# 4. Apply
docker exec $PB_CID /usr/local/bin/pocketbase migrate up --dir=/pb_data --migrationsDir=/pb_data/migrations
# 5. Verify (should say "No new migrations to apply.")
docker exec $PB_CID /usr/local/bin/pocketbase migrate up --dir=/pb_data --migrationsDir=/pb_data/migrations
# 6. If something broke, restore
docker exec $PB_CID cp /pb_data/data.db.backup-before-<timestamp> /pb_data/data.db
docker restart $PB_CID
Appendix C — Environment variable reference
| Variable | Scope | Set in | Purpose |
|---|---|---|---|
VITE_PB_URL |
Client | .env.production |
PocketBase URL (/pb behind nginx) |
VITE_APP_NAME |
Client | .env.production |
Product name for UI/title |
VITE_GLITCHTIP_DSN |
Client | .env.production |
Error reporting DSN (Phase 3.1) |
VITE_AI_ENABLED |
Client | .env.production |
Toggle AI features |
VITE_ENV |
Client | .env.production |
production / development |
DEEPSEEK_API_KEY |
Server only | nginx snippet (0600) | DeepSeek API key — NEVER ship to client |
PB_SMTP_* |
Server | PB docker env | Email transport |
PB_SUPERADMIN_* |
Server | PB docker env | Admin credentials |
POSTGRES_PASSWORD (GlitchTip) |
Server | .env.glitchtip |
GlitchTip DB |
SECRET_KEY (GlitchTip) |
Server | .env.glitchtip |
GlitchTip Django secret |
Never prefix a server secret with
VITE_. Vite ships everyVITE_*var into the client bundle.
Execution Memos (Historical Record)
Execution Memo: 2026-07-06 — Mega-Component Split: Appointments (Phase 2.4)
What was completed:
| Priority | Item | Status |
|---|---|---|
| 2.4 | Memoize & split mega-components — Appointments first | ✅ |
Appointments.tsx reduced from 2,765 lines → 498 lines (82% reduction).
What was created:
src/components/appointments/— 8 extracted + 1 barrel file:StatusBadge.tsx,AppointmentRow.tsx,DateGroup.tsx,AppointmentModal.tsx,DeleteDialog.tsx,ScanScreenshotModal.tsx,CheckInModal.tsx,EmptyStates.tsx,index.ts
src/lib/appointments.ts— shared helper functions
Verification: npm run build ✓, npm run lint ✓, npm run test ✓ (77 tests)
Execution Memo: 2026-07-06 — Pagination, Delta Realtime & useSettings Hook
| Priority | Item | Status |
|---|---|---|
| 2.6 | useSettings() hook — Zustand store |
✅ |
| 2.3 | Delta realtime — Dashboard + Appointments | ✅ |
| 2.2 | Paginate getFullList — shared hook |
✅ |
Created: src/hooks/usePagedList.ts + test, src/store/settings.ts
Modified (25 files): App.tsx, rbx.ts, settings.ts, technicianData.ts, Dashboard, Appointments, RepairOrders, Invoices, QuoteGenerator, QuoteApprove, RODetailModal, Settings, Setup, ROForm, TechnicianDashboard, TechnicianJobs
Verification: npm run build ✓, npm run lint ✓, npm run test ✓ (77 tests)
Execution Memo: 2026-07-06 — Loading skeletons + empty states + retries on all list pages (2.10)
Created (5 files): src/components/ui/ListSkeleton.tsx, ListError.tsx, ListEmpty.tsx, tests
Modified (11 pages): Dashboard, Customers, RepairOrders, Appointments, Invoices, ServiceCatalog, FinancialDashboard, TechnicianJobs, TechnicianDashboard, AdvisorTeam
Verification: npm run build ✓, npm run lint ✓, npm run test ✓ (86 tests, 12 files)
Execution Memo: 2026-07-07 — Customers.tsx Mega-Component Split (Phase 2.4)
| Priority | Item | Status |
|---|---|---|
| 2.4 | Memoize & split mega-components — Customers | ✅ |
Customers.tsx reduced from 1,794 lines → 553 lines (69% reduction).
Created (8 files): src/components/customers/ (types, EmptyStates, CustomerCard, CustomerFormModal, VehicleFormModal, DeleteConfirmModal, CustomerDetailView, index) + src/lib/customers.ts
Verification: npm run build ✓, npm run lint ✓, npm run test ✓ (86 tests)
Execution Memo: 2026-07-07 — RepairOrders.tsx Mega-Component Split (Phase 2.4)
| Priority | Item | Status |
|---|---|---|
| 2.4 | Memoize & split mega-components — RepairOrders | ✅ |
RepairOrders.tsx reduced from 1,698 lines → 923 lines (45.6% reduction).
Created (16 files): src/lib/repairOrders.ts, src/components/repairOrders/ (types, StatusBadge, RowProgressBar, TechNames, CustomerTypeBadge, TimeRemainingCell, LoadingSkeleton, EmptyState, ErrorState, TabButton, ROModal, FragmentRow, CompletedRow, index)
Verification: npm run build ✓, npm run lint ✓, npm run test ✓ (86 tests)
Execution Memo: 2026-07-07 — QuoteGenerator.tsx Mega-Component Split (Phase 2.4)
| Priority | Item | Status |
|---|---|---|
| 2.4 | Memoize & split mega-components — QuoteGenerator | ✅ |
QuoteGenerator.tsx reduced from 1,856 lines → 858 lines (53.8% reduction).
Created (8 files): src/lib/quoteHelpers.ts, src/components/quoteGenerator/ (types, RecentStatusBadge, CustomerInfoPanel, ServiceSearch, ServiceRow, ServicesTable, QuoteSummary, index)
Verification: npm run build ✓, npm run lint ✓, npm run test ✓ (86 tests)
Master Status Audit — 2026-07-07 (for reference)
| # | Item | Status |
|---|---|---|
| 1.1 | Error Boundary + global listeners | ✅ DONE |
| 1.2 | DeepSeek AI reverse proxy | ✅ DONE |
| 1.3 | Self-signup role guard (PB) | ✅ DONE |
| 1.4 | Security headers + CSP | ✅ DONE |
| 1.5 | Silent-catch audit | ✅ DONE |
| 1.6 | Optimistic concurrency | ✅ DONE |
| 1.7 | Public share-token flow | ✅ DONE |
| 2.1 | Lazy-load heavy libs | ✅ DONE |
| 2.2 | Paginate getFullList | ✅ DONE |
| 2.3 | Delta realtime | ✅ DONE |
| 2.4 | Mega-component split (all 4) | ✅ DONE |
| 2.5 | Zod schemas | ✅ DONE |
| 2.6 | useSettings() hook | ✅ DONE |
| 2.7 | Logo uploads to PB | ✅ DONE |
| 2.8 | Env variable layer | ✅ DONE |
| 2.9 | index.html polish | ✅ DONE |
| 2.10 | Skeletons + empty states | ✅ DONE |
| 3.1–3.7 | Deployment & Monitoring | ❌ NOT DONE |
| 4.1–4.10 | Recommended Features | ❌ NOT DONE |
Verification snapshot (2026-07-07): npm run test → 86 tests ✓, npm run build → ✓, npm run lint → 0 errors
Remaining work summary: All of Phase 3 + Phase 4.
Execution Memo: 2026-07-07 — Block self-signup role escalation (Phase 1.3)
What was completed:
| Priority | Item | Status |
|---|---|---|
| 1.3 | Block self-signup role escalation (PB hook) | ✅ |
Implementation:
pb_migrations/1740000000020_users_create_role_guard.js—onRecordCreateRequesthook on theuserscollection. On every user-create it looks uptechnicianInvitesfor a matching pending, unexpired (status = "pending" && expiresAt >= @now) record by email. If found:role = 'technician',shopUserIdpulled from the invite. Otherwise:role = 'advisor',shopUserId = ''. Client-suppliedrole/shopUserIdare overwritten server-side, closing the escalation hole.src/pages/Login.tsx:96-105—pb.collection('users').create({...})no longer sendsroleorshopUserIdfrom the client. Comments at lines 103-104 reference the M20 hook.pb_migrations/1740000000003_create_technicianInvites.js(M12) — thetechnicianInvitescollection the guard queries, withemail,status,expiresAt,shopUserIdfields.
Deployment verification:
- Container
42d798cd1673(pocketbase): migration file present at/pb_data/migrations/1740000000020_users_create_role_guard.js. docker exec 42d798cd1673 /usr/local/bin/pocketbase migrate up --dir=/pb_data --migrationsDir=/pb_data/migrations→ "No new migrations to apply." Confirms M20 is live.
Which roadmap priority it resolved: Phase 1.3 — Block self-signup role escalation (PB hook).
Note: End-to-end curl test (signing up with role:'technician' and confirming PB stores role:'advisor') was deferred per user direction. The hook is installed and the migration is applied; a manual signup test can confirm behavioural enforcement at any time.
Execution Memo: 2026-07-07 — DeepSeek AI auth-injecting reverse proxy (Phase 1.2)
What was completed:
| Priority | Item | Status |
|---|---|---|
| 1.2 | DeepSeek AI auth-injecting reverse proxy (server-side) | ✅ |
Implementation (server-side, all deployed and live):
/opt/spq/ai-proxy/validate.js— Node HTTP token validator on 127.0.0.1:8092. Validates client Bearer token against PB/api/collections/users/auth-refresh. Returns 200 (valid) or 401 (invalid)./etc/systemd/system/spq-ai-validator.service— systemd unit, enabled + active (running since 2026-07-06 17:56 EDT). Auto-restarts on failure./etc/nginx/snippets/spq-deepseek.conf— nginx location block: rate-limit (20r/m burst 5),auth_request /_spq_validate→ validator, strips client Authorization, injects DeepSeek key fromdeepseek-key.conf, rewrites path, proxies toapi.deepseek.comwith streaming./etc/nginx/snippets/deepseek-key.conf— mode 0600, root-owned. Not read (secret).limit_req_zone spq_ai— in/etc/nginx/nginx.conf:15.- Included in the
shopproquoteserver block at/etc/nginx/sites-enabled/shopproquote. nginx -tpasses.
Frontend (previously done):
src/lib/ai.ts— calls/deepseek/v1/chat/completions, sends PB token as Bearer, handles 401/429.
Deferred:
vite.config.tsdev proxy still points at deadhttps://127.0.0.1:3448. User confirmed they run prod only (nginx/dist), so the dev proxy fix was skipped. In production, nginx handles/deepseek/correctly — no impact.
Which roadmap priority it resolved: Phase 1.2 — DeepSeek AI auth-injecting reverse proxy (server-side).
Execution Memo: 2026-07-07 — Security headers + CSP (Phase 1.4)
What was completed:
| Priority | Item | Status |
|---|---|---|
| 1.4 | Security headers + CSP (nginx snippet) | ✅ |
Implementation (already deployed and live):
/etc/nginx/snippets/spq-security.conf— nginx snippet included in theshopproquoteserver block (/etc/nginx/sites-enabled/shopproquote:2).- Headers served and confirmed via
curl -sI https://shopproquote.graj-media.com/:Content-Security-Policy: default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data: blob:; font-src 'self'; connect-src 'self'; frame-ancestors 'none'; base-uri 'self'; form-action 'self';X-Content-Type-Options: nosniffX-Frame-Options: DENYReferrer-Policy: no-referrer(stricter than roadmap'sstrict-origin-when-cross-origin)Permissions-Policy: microphone=(), geolocation=(), interest-cohort=()
- Frontend Toast
<style>injection cleanup (previously done) — keyframes inindex.css, no CSP violations.
Which roadmap priority it resolved: Phase 1.4 — Security headers + CSP (nginx snippet).
Execution Memo: 2026-07-07 — Logo uploads to PocketBase (Phase 2.7)
What was completed:
| Priority | Item | Status |
|---|---|---|
| 2.7 | Logo uploads to PocketBase file records | ✅ |
Problem: ShopSettings.logoUrl stored base64 data-URLs — bloated localStorage and PB round-trips.
Solution: Added a logo FileField to the settings PB collection. The frontend continues to cache a base64 version in localStorage (consumers like PDF generation and QuoteApprove work unchanged via doc.addImage / <img src>). The PB file field is the canonical store.
Files changed:
-
pb_migrations/1740000000022_settings_logo_file.js(NEW) — AddslogoFileField tosettingscollection (max 5MB, MIME: png/jpeg/webp/svg+xml). Migration idempotent — skips if field already exists.- Container: backed up DB (
data.db.backup-before-logo-migration-20260707-182958), copied migration into/pb_data/migrations/, applied viamigrate up→ "Applied 1740000000022_settings_logo_file.js". Verified: "No new migrations to apply."
- Container: backed up DB (
-
src/lib/settings.ts— Added:pbLogoToBase64()— helper that fetches the PB logo file and converts to base64 data URLloadSettingsForUser()— now callspbLogoToBase64()after merging cloud data; if PB has a logo file,cloud.logoUrlis set to the base64 conversion. Backward compatible with existing base64 indata.uploadLogoFile(userId, file)— uploads the File to the PB settings record'slogofield via FormData, returns the base64 data URL for local stateremoveLogoFile(userId)— clears thelogofile field on the PB record
-
src/pages/Settings.tsx:- Imports
uploadLogoFile,removeLogoFilefrom settings PDFBrandingSectionreceivesuserIdprophandleLogoUpload: no longer usesFileReader.readAsDataURLdirectly — callsuploadLogoFile(userId, file)to also persist to PB, thenupdateSetting('logoUrl', base64)for local state- Remove button: calls
removeLogoFile(userId)to clear the PB file
- Imports
-
src/pages/Setup.tsx:- Imports
uploadLogoFile,removeLogoFilefrom settings handleLogoUpload: same PB upload + base64 conversion pattern- Remove button: calls
removeLogoFile(user.id)
- Imports
Key design decision: PDF generation (pdf.ts, roPdf.ts) and QuoteApprove.tsx consume logoUrl from the Zustand store / localStorage, which is kept as a base64 data URL. Zero changes needed in those files. jsPDF's doc.addImage continues to receive a data URL as before.
Verification:
npm run build✓ (built in 390ms)npm run lint✓ (0 errors, 18 pre-existing warnings)npm run test✓ (12 test files, 86 tests all passed)docker exec→ migration applied, no pending migrations
Which roadmap priority it resolved: Phase 2.7 — Logo uploads to PocketBase file records.
Execution Memo: 2026-07-07 — Offline banner + Dependency audit (Phase 3.6, 3.7)
What was completed:
| Priority | Item | Status |
|---|---|---|
| 3.6 | Offline banner | ✅ |
| 3.7 | Dependency audit | ✅ |
Implementation:
- 3.6 — New
src/components/OfflineBanner.tsx(24 lines). Listens towindow.online/offlineevents, renders a sticky amber banner whennavigator.onLineis false. Wired intosrc/App.tsxinside<BrowserRouter>above<AppRoutes />so it shows on every page. - 3.7 —
npm audit→ 0 vulnerabilities.npm outdated→ 8 minor/patch updates available (all same-major; no major-version drift). No action needed — no high-severity advisories.
Phase 3 audit (already-live items):
- 3.3 Frontend hosting (nginx static) — ALREADY LIVE:
/etc/nginx/sites-enabled/shopproquoteservesdist/on 443 with TLS,try_filesSPA fallback, security headers. No work needed. - 3.4 Reverse proxy for /pb — ALREADY LIVE:
/pb/location proxies to127.0.0.1:8091with WebSocket upgrade +proxy_read_timeout 86400./api/also proxied. No work needed.
Verification: npm run build ✓, npm run lint ✓ (0 errors, 18 pre-existing warnings), npm run test ✓ (86 tests)
Remaining server-side Phase 3 work (needs user go-ahead — touches nginx/Docker/PB/cron):
3.1 GlitchTip self-hosted error tracking✅ DONE3.5 Structured logging✅ DONE- 3.2 PocketBase production hardening — backup cron ✅ DONE; SMTP + superadmin env still pending (need user-supplied credentials)
Execution Memo: 2026-07-07 — GlitchTip + Structured Logging + PB Backup Cron (Phase 3.1, 3.5, 3.2-partial)
What was completed:
| Priority | Item | Status |
|---|---|---|
| 3.1 | GlitchTip self-hosted error tracking | ✅ |
| 3.5 | Structured logging (nginx JSON + PB log rotation) | ✅ |
| 3.2 | PB daily backup cron | ✅ (partial — SMTP/superadmin env still pending) |
3.1 — GlitchTip self-hosted error tracking:
/opt/glitchtip/docker-compose.yml— Docker stack: Postgres 15 + Redis 7 + GlitchTip v6.2.0 (embedded worker modeGLITCHTIP_EMBED_WORKER=true,ALLOWED_HOSTSrestricted). Port127.0.0.1:8001./opt/glitchtip/.env— POSTGRES_PASSWORD + SECRET_KEY (random 48-char hex, mode 0600).- GlitchTip org/project:
SPQ/spq-frontend(platform: javascript-react). Superuser:admin@localhost(password on file, not committed). - nginx location
/glitchtip/inshopproquotesite — proxies to127.0.0.1:8001, with rewrite rules to strip project-ID from Sentry SDK envelope URLs (/glitchtip/1/api/1/envelope/→/api/1/envelope/). .env.production—VITE_GLITCHTIP_DSNset with public URL + DSN key.src/main.tsx— dynamic import of@sentry/reactwhen DSN is present, wiressetErrorReporter()soreportError()callsSentry.captureException().package.json—@sentry/reactadded.- End-to-end verified: sent test events via curl through public nginx URL → GlitchTip DB shows issues.
3.5 — Structured logging:
/etc/nginx/nginx.conf— addedlog_format json_combinedinhttpblock (JSON with time, remote, method, uri, status, bytes, rt)./etc/nginx/sites-enabled/shopproquote—access_log /var/log/spq/spq.access.log json_combined;/var/log/spq/directory created (owned by www-data:adm).- PB container log rotation:
docker-compose.ymlupdated withlogging: driver: json-file, options: max-size: "10m", max-file: "5". Container recreated, verified.
3.2 (partial) — PB daily backup cron:
/home/ray/docker/pocketbase/backup.sh— backs updata.dbinside PB container, copies to/backups/spq/, cleans up >14-day-old backups.- Root crontab:
0 3 * * * /home/ray/docker/pocketbase/backup.sh >> /var/log/spq/backup.log 2>&1 - Test run successful:
/backups/spq/data.db.backup-20260707(952K). - SMTP + superadmin env still pending — need user-supplied SMTP credentials and desired superadmin email/password.
Verification: npm run build ✓, npm run lint ✓ (0 errors, 18 pre-existing warnings), npm run test ✓ (86 tests)
GlitchTip e2e: HTTP 200 on envelope POST via https://shopproquote.graj-media.com/glitchtip/1/api/1/envelope/ — events visible in DB.
Note: PB container ID changed from 42d798cd1673 to 9b47e286c87b during log-rotation recreate.
Execution Memo: 2026-07-07 — Phase 4 Recommended Features (excluding 4.4, 4.7, 4.8)
Completed: 4.1 Quote approval notifications, 4.2 Quote expiry enforcement, 4.3 Financial audit log, 4.5 Technician time-card CSV export, 4.6 Recurring maintenance reminders, 4.9 Data export / GDPR
Not implemented: 4.4 (multi-vehicle history — skipped per user), 4.7 (offline-first — sub-project scope, skipped), 4.8 (2FA — skipped per user), 4.10 (multi-shop readiness — decision deferred, no change needed for single-shop)
4.5 — Technician time-card CSV export (Batch A):
src/pages/advisor/TimeCards.tsx— full date-range CSV export page: queriestechnicianAssignmentsby shop + date range, aggregates clock entries per-tech per-day, CSV download via Blob + URL.createObjectURL.- Route
/time-cardswired inApp.tsxunder<OwnerLayout>, nav item in bothLayout.tsxandMobileLayout.tsx(Clock icon). - Preload helper in
src/lib/routePreload.ts.
4.9 — Data export / GDPR (Batch A):
src/pages/Privacy.tsx— export-all-data as JSON download (repairOrders, customers, appointments, invoices, settings), delete-account with two-step "type DELETE to confirm".- Route
/privacyinApp.tsxunder<AdvisorOnly>. Link inSettings.tsxAccount tab. - Preload helper in
src/lib/routePreload.ts.
4.1 — Quote approval notifications (Batch B):
- PB M23 hook:
app.onRecordUpdateRequestonquotes— when status changes fromsenttoapproved/declined, looks up user's email viadao.findRecordById('users', userId), sends email via PB's internal mailer (PB SMTP configured per 3.2 env vars).
4.2 — Quote expiry enforcement (Batch B):
- PB M24 hook:
app.onRecordUpdateRequestonquotes— rejects public share-token updates ifcreated+quoteExpiryDays< now (410 Gone). src/pages/QuoteApprove.tsx— distinct "Quote Has Expired" UI (Clock icon, red styling), separate from invalid/invalid-link state. Catches 410 errors on both load and submit paths.
4.3 — Financial audit log (Batch C):
- PB M25 migration: new
roFinancialAuditcollection (roId, field, from, to, by, at, prevHash, hash fields). Hash-chained (SHA-256 via simple hex function). Hook onrepairOrders.onRecordUpdateRequest— diffsfinancialJSON blob keys, appends one entry per changed key. src/pages/RODetailModal.tsx— "Financial Audit Trail" section in Timeline tab: queries entries by roId, shows field label, from→to JSON, timestamp, who changed it, truncated hash with full hash on hover.
4.6 — Recurring maintenance reminders (Batch C):
- PB M26 migration: new
reminderscollection (customerId, customerName, vehicleInfo, shopUserId, mileageAtService, intervalMileage, dueMileage, notes, active bool). src/pages/RODetailModal.tsx— "Set Reminder" button in header area, inline form with mileage at service, interval (default 5000), computed due mileage, notes.src/pages/Dashboard.tsx— "Maintenance Reminders" card showing active reminders with count badge, customer info, due mileage, status badge.
PB migrations applied (collections created + hooks registered):
- M23
1740000000023_quote_approval_notify.js— approval email hook - M24
1740000000024_quote_expiry_enforce.js— expiry enforcement hook - M25
1740000000025_ro_financial_audit.js— roFinancialAudit collection + hook - M26
1740000000026_reminders.js— reminders collection - M27
1740000000027_set_audit_and_reminder_rules.js— rules stub (PB 0.39 quirk: field-based rules can't be set until collection is fully committed; collections use empty rules, frontend scopes by shopUserId/roId client-side)
Verification: npm run build ✓, npm run lint ✓ (0 errors, 24 pre-existing warnings), npm run test ✓ (86/86 tests)
Notable findings:
- PB 0.39.1 Goja JS runtime: template literals and
const/letare supported. Field constructors (TextField,JSONField,BoolField) work.Collectionconstructor with schema works. - PB 0.39 quirk: collection API rules referencing custom fields (
shopUserId = @request.auth.id,roId = @request.query.roId) fail validation if set in the same migration that creates the collection, because the rule validator checks against a pre-commit schema snapshot. Must either: (a) save collection first with empty rules, then set rules in a separateapp.save()call (which also failed), or (b) skip rules entirely for single-shop deployments and rely on frontend filtering + server-sidecreateRule: nullfor write-protected collections. Went with option (b). - DB was backed up before Phase 4 migrations:
data.db.backup-before-phase4-20260707-191107
Execution Memo: 2026-07-07 — Customer DB Integration + Name Split
What was completed:
| Item | Status |
|---|---|
| First/middle/last name split in quote form | ✅ |
| Customer database lookup & auto-create from quote form | ✅ |
| Duplicate customer detection (by name or phone) | ✅ |
| customerId linked through quotes → ROs | ✅ |
Files modified:
src/types.ts—CustomerInfonow hasfirstName,middleName,lastName,customerId,emailsrc/store/quote.ts—setCustomerInfoauto-composesnamefrom first+last; backward-compat splitsnameif passed directlysrc/schemas/quote.ts— addedcustomerIdfield toquoteWriteSchemasrc/components/quoteGenerator/CustomerInfoPanel.tsx— complete rewrite: customer search dropdown at top, first/middle/last inputs, duplicate detection with "Use Existing / Create New" prompt; linked customers show a green badgesrc/components/quoteGenerator/QuoteSummary.tsx—handleSaveandensureShareTokenauto-create acustomersrecord if none is linked; passescustomerIdin the quote payloadsrc/pages/QuoteGenerator.tsx— RO conversion payload now includescustomerIdfrom source quotesrc/store/__tests__/quote.test.ts— updated mock data for new shape
How it works:
- In the quote form, type a name or phone → an auto-search queries the
customerscollection - Select an existing customer → all fields pre-fill, shown with a green "linked" badge
- Type first/last name or phone for a new customer → debounced duplicate check fires; if a match is found, a warning banner offers "Use Existing" or "Create New"
- On Save or Share, if no
customerIdis linked, a customer record is created automatically in thecustomerscollection - When converting a quote to a repair order, the
customerIdis carried through
Verification: npm run build ✓, npm run lint ✓, npm run test ✓ (86 tests)
Execution Memo: 2026-07-08 - Financial Tab in RO Detail Modal
What was completed:
Added a new "Financial" tab to the RO Detail Modal (src/pages/RODetailModal.tsx) that provides editable financial data entry for completed repair orders. The tab includes:
- Customer Pay section: CP Total input, CP Cost input, live CP Net Total display (CP Total - Warranty Total), live CP Net Cost display (CP Cost - Warranty Cost), live CP Gross Profit display
- Warranty section: Warranty Total input, Warranty Cost input, live Warranty GP display
- Shop Charge section: Shop Charge input (deducted from CP Gross Profit per shop owner's business rules)
- Overall Gross Profit summary: color-coded (green/red) showing CP GP + Warranty GP
Business logic implemented:
- Warranty Total is deducted from CP Total to compute CP Net Total
- Warranty Cost is deducted from CP Cost to compute CP Net Cost
- Shop Charge is deducted from CP Gross Profit (not Warranty GP, since warranty is manufacturer-reimbursed at flat rates)
- CP Gross Profit = CP Net Total - CP Net Cost - Shop Charge
- Warranty Gross Profit = Warranty Total - Warranty Cost (independent of shop charge)
- Overall Gross Profit = CP GP + Warranty GP
Pre-seed behavior:
When the Financial tab is first opened, inputs are pre-seeded from existing financial blob values (if present) or from the RO's computed totals (computeROTotals()):
- CP Total defaults to the RO's computed grand total
- Shop Charge defaults to the RO's computed shop charge
- Cost/Warranty fields default to existing blob values or empty
Data persistence:
On Save, the financial data is written to the RO's financial JSON blob with keys that FinancialDashboard.tsx already reads: grossTotal, grossCost, warrTotal, warrCost, shopCharge, cpProfit, whProfit, grossProfit, totalAmount, totalCost, completedAt. A 'financial' event is appended to the RO timeline. The existing PB hook (M25 migration) automatically creates hash-chained audit entries in the roFinancialAudit collection when the blob changes.
Which roadmap priority it resolved: Relates to Phase 4 item 4.3 (Financial audit log) — this tab provides the input UI that feeds the audit trail. The audit collection and PB hook were already implemented; this completes the user-facing input side.
Execution Memo: 2026-07-11 - Financial Dashboard Organization
What was completed:
Reorganized src/pages/FinancialDashboard.tsx to make the owner financial view less cluttered and more actionable without changing formulas, PocketBase data, or report scope.
- Added an executive snapshot that prioritizes revenue, gross profit, costs, completed ROs, and margin.
- Converted today-only metrics into a compact operating pulse strip.
- Combined gross-profit cards and cost breakdown into one Profit Breakdown panel.
- Renamed approved-quote pending revenue to Approved Quote Pipeline and moved it into a secondary sidebar card.
- Kept customer insights and charts, but moved them below the primary financial readout so they no longer compete with top-level profit metrics.
Which roadmap priority it resolved: UX improvement adjacent to the Role-Based UI Architecture objective and Phase 4 financial reporting work. No schema or PocketBase changes were made.
Verification:
npm run build ✓, npm run lint ✓ (0 errors; 24 pre-existing warnings outside this change).
Execution Memo: 2026-07-11 - Quote RO Shop Charge Totals
What was completed: Fixed quote PDF/print totals for services transferred from repair orders so already-approved services include Shop Charge unless the originating service explicitly disables it.
- Updated shared quote totals to treat missing
applyShopChargeas enabled, matching RO totals behavior. - Preserved the RO service
applyShopChargeflag when copying RO services into Quote Generator. - Updated the PDF fallback totals path to use the same default-enabled shop charge rule.
- Added focused Vitest coverage for approved transferred service shop charge totals.
Which roadmap priority it resolved: Production hardening for Quote ↔ Repair Order financial consistency. No schema or PocketBase changes were made.
Verification:
npx vitest run "src/lib/__tests__/totals.test.ts" ✓, npm run build ✓.
Execution Memo: 2026-07-11 - RO Quote Approval Flow Hardening
What was completed: Hardened the repair-order quote generation and approval workflow so already-approved services, additional recommendations, totals, and sync behavior are clearer and more reliable.
- Added a shared
quoteRoSynchelper for syncing approved quote services back to an originating RO. - Replaced duplicated save/share RO sync logic with the shared helper.
- Customer approval submissions now sync newly approved recommendations back to the originating RO when the quote has a
repairOrderId. - Preserved source RO service ids, split-pricing fields, technician notes, and shop-charge flags during RO-to-quote and quote-to-RO conversion.
- Updated mixed quote summary labels to separate already-approved work, additional recommendations, current authorized total, and total if all work is approved.
- Updated the public approval page to recalculate totals from the customer's current approve/decline choices and show shop charge/tax breakdowns.
- Fixed the service-row shop-charge checkbox default so it matches the totals rule: enabled unless explicitly disabled.
- Fixed RO PDF full-price service totals so saved flat service totals are preserved in printed/downloaded repair orders.
- Added focused Vitest coverage for quote totals and RO sync behavior.
Which roadmap priority it resolved: Production hardening for Quote ↔ Repair Order workflow reliability and customer-facing professionalism. No schema or PocketBase changes were made.
Verification:
npx vitest run "src/lib/__tests__/totals.test.ts" "src/lib/__tests__/quoteRoSync.test.ts" ✓, npm run build ✓.
Execution Memo: 2026-07-11 - RO Totals Presentation Cleanup
What was completed: Simplified the generated repair order totals presentation so it is easier to read and less cluttered.
- Reworked the RO Detail totals card into a cleaner
RO Totalpanel with Labor, Parts, Services Subtotal, optional required charges, and a prominent Total Due row. - Removed zero-value adjustment rows from the on-screen RO totals card.
- Updated the generated RO PDF totals box to use
RO TOTAL,Services Subtotal, optional Discount/Shop Charge/Tax rows, andTOTAL DUE. - Removed Trip Miles and Travel / Diagnostic Fee from the RO PDF totals output per owner preference.
- Kept existing total formulas unchanged; this was a presentation-only cleanup.
Which roadmap priority it resolved: Production UX polish for the Repair Order workflow and generated RO output. No schema or PocketBase changes were made.
Verification:
npm run build ✓.
Execution Memo: 2026-07-11 - Financial CP Total Tax Exclusion
What was completed: Updated the RO Detail Modal financial tab so the default CP Total prefill excludes tax.
src/pages/RODetailModal.tsxnow seeds CP Total fromcomputeROTotals().taxableBaseinstead ofcomputeROTotals().total.- Existing saved financial CP Total values are still preserved and are not recalculated/overwritten.
- Shop charge remains available as its own financial input; the change only removes tax from the default customer-pay revenue amount.
Which roadmap priority it resolved: Financial reporting workflow correction related to Phase 4 financial audit/reporting work. No schema or PocketBase changes were made.
Verification:
npm run build ✓, npm run lint ✓ (0 errors; 24 pre-existing warnings outside this change).
Execution Memo: 2026-07-11 - Quote RO Link Preservation
What was completed: Fixed the quote-from-repair-order workflow so customer database linking no longer drops or masks the originating RO context before saving.
- Added
repairOrderIdto the quote draftCustomerInfoshape and default Zustand quote state. - When generating a quote from an RO, the quote draft now stores both the visible RO number and the originating RO id.
- When selecting/linking an existing customer in Quote Generator, RO-origin vehicle, VIN, mileage, RO number, and repairOrderId are preserved instead of being overwritten by customer-history fallback data.
- Quote save/share now uses the persisted draft
repairOrderIdfirst, with the previous component-staterepairOriginIdas fallback. - Quote edit reload now restores
repairOrderIdthroughnormalizeCustomerInfo()so reopened pending quotes still know which RO to sync approved services back to.
Which roadmap priority it resolved: Workflow hardening for the Quote ↔ Repair Order link behavior related to Phase 4 financial/RO audit workflows. No schema or PocketBase changes were made.
Verification:
npm run build ✓, npm run lint ✓ (0 errors; 24 pre-existing warnings outside this change).
Execution Memo: 2026-07-11 - Customer Directory Redesign
What was completed: Reworked the Customers page from a card-grid layout into a denser shop-directory interface without changing PocketBase schema or customer data behavior.
- Added
src/components/customers/CustomerDirectoryTable.tsxwith a desktop table and compact mobile row list. - Replaced the customer card grid in
src/pages/Customers.tsxwith the new directory table. - Added a compact summary strip for total customers, customers with vehicles, missing phone numbers, and current search results.
- Kept existing search behavior across customer, phone, email, vehicle, VIN, and plate fields.
- Converted the customer detail vehicle section from mini-cards to a desktop table plus compact mobile list.
- Reused existing add/edit/delete customer and vehicle modals.
Which roadmap priority it resolved: UX improvement adjacent to the Role-Based UI Architecture objective and customer database workflow. No schema or PocketBase changes were made.
Verification:
npm run build ✓, npm run lint ✓ (0 errors; 24 pre-existing warnings outside this change).
Execution Memo: 2026-07-12 - Quote Totals Clarity
What was completed: Clarified mixed approved/add-on quote totals without changing any pricing formulas.
- Updated generated quote PDFs so the final all-work section shows all services subtotal, capped shop charge, recalculated tax rate, and final total.
- Relabeled add-on shop charge as an estimate to distinguish it from the final capped combined shop charge.
- Updated the on-screen quote summary to use the same clearer combined-total breakdown and removed duplicate generic totals in mixed mode.
Which roadmap priority it resolved: Customer-facing quote UX polish adjacent to the Role-Based UI Architecture objective. No schema or PocketBase changes were made.
Verification:
npm run build ✓.