1273 lines
60 KiB
Markdown
1273 lines
60 KiB
Markdown
# 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](#conventions--pre-flight)
|
||
- [Phase 1 — Critical Fixes — Remaining Work](#phase-1--critical-fixes--remaining-work)
|
||
- [1.2 DeepSeek AI auth-injecting reverse proxy (server-side)](#12-deepseek-ai-auth-injecting-reverse-proxy-server-side)
|
||
- [1.3 Block self-signup role escalation (PB hook)](#13-block-self-signup-role-escalation-pb-hook)
|
||
- [1.4 Security headers + CSP (nginx snippet)](#14-security-headers--csp-nginx-snippet)
|
||
- [Phase 2 — Optimization & UX — Remaining Work](#phase-2--optimization--ux--remaining-work)
|
||
- [2.7 Logo uploads to PocketBase file records](#27-logo-uploads-to-pocketbase-file-records)
|
||
- [Phase 3 — Deployment & Monitoring](#phase-3--deployment--monitoring)
|
||
- [3.1 GlitchTip self-hosted (Docker)](#31-glitchtip-self-hosted-docker)
|
||
- [3.2 PocketBase production hardening](#32-pocketbase-production-hardening)
|
||
- [3.3 Frontend hosting (nginx static)](#33-frontend-hosting-nginx-static)
|
||
- [3.4 Reverse proxy for /pb and /api/ai/chat](#34-reverse-proxy-for-pb-and-apiaichat)
|
||
- [3.5 Structured logging](#35-structured-logging)
|
||
- [3.6 Offline banner](#36-offline-banner)
|
||
- [3.7 Dependency audit](#37-dependency-audit)
|
||
- [Phase 4 — Recommended Features](#phase-4--recommended-features)
|
||
- [4.1 Quote approval notifications](#41-quote-approval-notifications)
|
||
- [4.2 Quote expiry enforcement](#42-quote-expiry-enforcement)
|
||
- [4.3 Financial audit log (append-only, hash-chained)](#43-financial-audit-log-append-only-hash-chained)
|
||
- [4.4 Multi-vehicle customer history](#44-multi-vehicle-customer-history)
|
||
- [4.5 Technician time-card CSV export](#45-technician-time-card-csv-export)
|
||
- [4.6 Recurring maintenance reminders](#46-recurring-maintenance-reminders)
|
||
- [4.7 Offline-first for technicians (service worker)](#47-offline-first-for-technicians-service-worker)
|
||
- [4.8 Two-factor auth for advisors (TOTP)](#48-two-factor-auth-for-advisors-totp)
|
||
- [4.9 Data export / GDPR](#49-data-export--gdpr)
|
||
- [4.10 Multi-shop readiness (decision)](#410-multi-shop-readiness-decision)
|
||
- [Appendix A — Verification command cheat sheet](#appendix-a--verification-command-cheat-sheet)
|
||
- [Appendix B — PocketBase backup & migration procedure](#appendix-b--pocketbase-backup--migration-procedure)
|
||
- [Appendix C — Environment variable reference](#appendix-c--environment-variable-reference)
|
||
- [Execution Memos (Historical Record)](#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.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):
|
||
```bash
|
||
docker ps --format '{{.ID}} {{.Image}} {{.Names}} {{.Ports}}' | grep pocketbase
|
||
```
|
||
Export it for the session:
|
||
```bash
|
||
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):**
|
||
```js
|
||
// /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:**
|
||
```ini
|
||
# /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):**
|
||
```nginx
|
||
# /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:
|
||
```ts
|
||
'/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:**
|
||
```bash
|
||
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`
|
||
|
||
```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):
|
||
```ts
|
||
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:**
|
||
```bash
|
||
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:**
|
||
```nginx
|
||
# /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):
|
||
```nginx
|
||
server {
|
||
listen 443 ssl http2;
|
||
server_name spq.yourdomain.tld;
|
||
include /etc/nginx/snippets/spq-security.conf;
|
||
# ... (rest from 3.3)
|
||
}
|
||
```
|
||
|
||
**Verify:**
|
||
```bash
|
||
# 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`:**
|
||
```js
|
||
// 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:**
|
||
```ts
|
||
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:**
|
||
```bash
|
||
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:**
|
||
```yaml
|
||
# /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:**
|
||
```bash
|
||
npm install @sentry/react
|
||
```
|
||
```ts
|
||
// 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:**
|
||
```bash
|
||
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:
|
||
```bash
|
||
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:
|
||
```bash
|
||
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:**
|
||
```bash
|
||
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`
|
||
|
||
```nginx
|
||
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:**
|
||
```bash
|
||
npm run build
|
||
sudo rsync -av --delete dist/ /var/www/spq/dist/
|
||
sudo nginx -t && sudo systemctl reload nginx
|
||
```
|
||
|
||
**Step 2 — TLS via certbot:**
|
||
```bash
|
||
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`
|
||
|
||
```nginx
|
||
# /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:**
|
||
```nginx
|
||
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:**
|
||
```bash
|
||
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:**
|
||
```bash
|
||
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`
|
||
|
||
```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 />`:
|
||
```tsx
|
||
<OfflineBanner />
|
||
```
|
||
|
||
**Verify:** `npm run build`; disable network in DevTools → banner appears.
|
||
|
||
---
|
||
|
||
### 3.7 Dependency audit
|
||
|
||
```bash
|
||
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 `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
|
||
|
||
```bash
|
||
# 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
|
||
|
||
```bash
|
||
# 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.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` — `onRecordCreateRequest` 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-105` — `pb.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.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/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.production` — `VITE_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/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.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.
|
||
|
||
---
|
||
|
||
### 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: 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.ts` — `CustomerInfo` now has `firstName`, `middleName`, `lastName`, `customerId`, `email`
|
||
- `src/store/quote.ts` — `setCustomerInfo` 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.tsx` — `handleSave` 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).
|
||
|
||
## 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 `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` ✓.
|