Files
spq-v2/roadmap_5.2.md
2026-07-12 10:01:39 -04:00

60 KiB
Raw Permalink Blame History

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

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.txt without 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 shopUserIdunless 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 (wire setErrorReporter)
  • 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.


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 onRecordAfterUpdateRequest hook on quotes — when status changes from sent to approved/declined, send an email (PB SMTP from 3.2) to the shop owner. Store the owner's userId on the quote; look up their email via dao.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 onRecordBeforeUpdateRequest hook on quotes — if @request.query.shareToken is set (public update path) and created + quoteExpiryDays < now, throw ApiError(410, 'Quote expired'). Frontend QuoteApprove.tsx loads quoteExpiryDays from settings and shows an "expired" state.
  • Files: new PB migration; edit QuoteApprove.tsx expired UI.

4.3 Financial audit log (append-only, hash-chained)

  • What: tamper-evident log of every financial field change on an RO.
  • How: new roFinancialAudit collection with roId, field, from, to, by, at, prevHash, hash (SHA-256 of all prior fields + prevHash). PB onRecordAfterUpdateRequest on repairOrders diffs the financial blob and appends a chained entry.
  • Files: new PB migration; new src/lib/roAudit.ts; read-only viewer in RODetailModal Timeline tab.

4.4 Multi-vehicle customer history

  • What: customer-level history across all their vehicles (current RODetailModal History tab is VIN-only).
  • How: add a "Customer History" view that queries repairOrders by customerId (already a field) and groups by vehicle. Add a button on Customers.tsx row → opens the view.
  • Files: new src/pages/CustomerHistory.tsx; route in App.tsx.

4.5 Technician time-card CSV export

  • What: weekly CSV of clockElapsedMinutes per technician.
  • How: new "Time Cards" advisor page that queries technicianAssignments by shopUserId + date range, aggregates clockEntries, generates CSV with Blob + 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 reminders PB 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 crosses dueMileage, 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 on online event 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 the users collection. Frontend: add a 2FA setup page in Settings (QR code from pb.collection('users').getOTPURL()), and a 2FA challenge step in Login.tsx after password.
  • Files: edit Login.tsx; new src/pages/SecuritySettings.tsx section.

4.9 Data export / GDPR

  • What: "export my data" + "delete my account" flows.
  • How: new src/pages/Privacy.tsx with 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 a shops collection above users with shopId on every record and updated API rules. Decide before scaling — retrofitting is expensive. If yes, write a roadmap_6.0.md for 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 every VITE_* 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.13.7 Deployment & Monitoring NOT DONE
4.14.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.jsonRecordCreateRequest hook on the users collection. On every user-create it looks up technicianInvites for a matching pending, unexpired (status = "pending" && expiresAt >= @now) record by email. If found: role = 'technician', shopUserId pulled from the invite. Otherwise: role = 'advisor', shopUserId = ''. Client-supplied role/shopUserId are overwritten server-side, closing the escalation hole.
  • src/pages/Login.tsx:96-105pb.collection('users').create({...}) no longer sends role or shopUserId from the client. Comments at lines 103-104 reference the M20 hook.
  • pb_migrations/1740000000003_create_technicianInvites.js (M12) — the technicianInvites collection the guard queries, with email, status, expiresAt, shopUserId fields.

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 from deepseek-key.conf, rewrites path, proxies to api.deepseek.com with 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 shopproquote server block at /etc/nginx/sites-enabled/shopproquote.
  • nginx -t passes.

Frontend (previously done):

  • src/lib/ai.ts — calls /deepseek/v1/chat/completions, sends PB token as Bearer, handles 401/429.

Deferred:

  • vite.config.ts dev proxy still points at dead https://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 the shopproquote server 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: nosniff
    • X-Frame-Options: DENY
    • Referrer-Policy: no-referrer (stricter than roadmap's strict-origin-when-cross-origin)
    • Permissions-Policy: microphone=(), geolocation=(), interest-cohort=()
  • Frontend Toast <style> injection cleanup (previously done) — keyframes in index.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) — Adds logo FileField to settings collection (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 via migrate up → "Applied 1740000000022_settings_logo_file.js". Verified: "No new migrations to apply."
  • src/lib/settings.ts — Added:

    • pbLogoToBase64() — helper that fetches the PB logo file and converts to base64 data URL
    • loadSettingsForUser() — now calls pbLogoToBase64() after merging cloud data; if PB has a logo file, cloud.logoUrl is set to the base64 conversion. Backward compatible with existing base64 in data.
    • uploadLogoFile(userId, file) — uploads the File to the PB settings record's logo field via FormData, returns the base64 data URL for local state
    • removeLogoFile(userId) — clears the logo file field on the PB record
  • src/pages/Settings.tsx:

    • Imports uploadLogoFile, removeLogoFile from settings
    • PDFBrandingSection receives userId prop
    • handleLogoUpload: no longer uses FileReader.readAsDataURL directly — calls uploadLogoFile(userId, file) to also persist to PB, then updateSetting('logoUrl', base64) for local state
    • Remove button: calls removeLogoFile(userId) to clear the PB file
  • src/pages/Setup.tsx:

    • Imports uploadLogoFile, removeLogoFile from settings
    • handleLogoUpload: same PB upload + base64 conversion pattern
    • Remove button: calls removeLogoFile(user.id)

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 to window.online/offline events, renders a sticky amber banner when navigator.onLine is false. Wired into src/App.tsx inside <BrowserRouter> above <AppRoutes /> so it shows on every page.
  • 3.7npm 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/shopproquote serves dist/ on 443 with TLS, try_files SPA fallback, security headers. No work needed.
  • 3.4 Reverse proxy for /pb — ALREADY LIVE: /pb/ location proxies to 127.0.0.1:8091 with 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 DONE
  • 3.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 mode GLITCHTIP_EMBED_WORKER=true, ALLOWED_HOSTS restricted). Port 127.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/ in shopproquote site — proxies to 127.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.productionVITE_GLITCHTIP_DSN set with public URL + DSN key.
  • src/main.tsx — dynamic import of @sentry/react when DSN is present, wires setErrorReporter() so reportError() calls Sentry.captureException().
  • package.json@sentry/react added.
  • 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 — added log_format json_combined in http block (JSON with time, remote, method, uri, status, bytes, rt).
  • /etc/nginx/sites-enabled/shopproquoteaccess_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.yml updated with logging: 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 up data.db inside 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.


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: queries technicianAssignments by shop + date range, aggregates clock entries per-tech per-day, CSV download via Blob + URL.createObjectURL.
  • Route /time-cards wired in App.tsx under <OwnerLayout>, nav item in both Layout.tsx and MobileLayout.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 /privacy in App.tsx under <AdvisorOnly>. Link in Settings.tsx Account tab.
  • Preload helper in src/lib/routePreload.ts.

4.1 — Quote approval notifications (Batch B):

  • PB M23 hook: app.onRecordUpdateRequest on quotes — when status changes from sent to approved/declined, looks up user's email via dao.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.onRecordUpdateRequest on quotes — rejects public share-token updates if created + 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 roFinancialAudit collection (roId, field, from, to, by, at, prevHash, hash fields). Hash-chained (SHA-256 via simple hex function). Hook on repairOrders.onRecordUpdateRequest — diffs financial JSON 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 reminders collection (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/let are supported. Field constructors (TextField, JSONField, BoolField) work. Collection constructor 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 separate app.save() call (which also failed), or (b) skip rules entirely for single-shop deployments and rely on frontend filtering + server-side createRule: null for 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.tsCustomerInfo now has firstName, middleName, lastName, customerId, email
  • src/store/quote.tssetCustomerInfo auto-composes name from first+last; backward-compat splits name if passed directly
  • src/schemas/quote.ts — added customerId field to quoteWriteSchema
  • src/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 badge
  • src/components/quoteGenerator/QuoteSummary.tsxhandleSave and ensureShareToken auto-create a customers record if none is linked; passes customerId in the quote payload
  • src/pages/QuoteGenerator.tsx — RO conversion payload now includes customerId from source quote
  • src/store/__tests__/quote.test.ts — updated mock data for new shape

How it works:

  1. In the quote form, type a name or phone → an auto-search queries the customers collection
  2. Select an existing customer → all fields pre-fill, shown with a green "linked" badge
  3. 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"
  4. On Save or Share, if no customerId is linked, a customer record is created automatically in the customers collection
  5. When converting a quote to a repair order, the customerId is 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 applyShopCharge as enabled, matching RO totals behavior.
  • Preserved the RO service applyShopCharge flag 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 quoteRoSync helper 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 Total panel 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, and TOTAL 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.tsx now seeds CP Total from computeROTotals().taxableBase instead of computeROTotals().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).

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 repairOrderId to the quote draft CustomerInfo shape 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 repairOrderId first, with the previous component-state repairOriginId as fallback.
  • Quote edit reload now restores repairOrderId through normalizeCustomerInfo() 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.tsx with a desktop table and compact mobile row list.
  • Replaced the customer card grid in src/pages/Customers.tsx with 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 ✓.