commit fc8290d668f2371f6a4a62fa12d8e29b3314fa3b Author: ray Date: Sun Jul 12 10:01:39 2026 -0400 initial commit diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..afca712 --- /dev/null +++ b/.env.example @@ -0,0 +1,19 @@ +# PocketBase server URL +VITE_PB_URL=http://localhost:8090 + +# Application name (used in UI and document titles) +VITE_APP_NAME=ShopProQuote + +# GlitchTip DSN for error tracking (set in production for step 3.1) +VITE_GLITCHTIP_DSN= + +# Toggle AI assistant features on/off +VITE_AI_ENABLED=true + +# Runtime environment: development / production +VITE_ENV=development + +# ─── IMPORTANT ────────────────────────────────────────────── +# Server-side secrets (DEEPSEEK_API_KEY, PB_SMTP_*, etc.) +# MUST NEVER be prefixed with VITE_ — Vite ships every VITE_* +# variable into the client bundle where it is visible to users. diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..dc2ec41 --- /dev/null +++ b/.gitignore @@ -0,0 +1,25 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local +.env.production + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/.hermes/plans/2026-07-07_164530-customers-memo-split.md b/.hermes/plans/2026-07-07_164530-customers-memo-split.md new file mode 100644 index 0000000..900079f --- /dev/null +++ b/.hermes/plans/2026-07-07_164530-customers-memo-split.md @@ -0,0 +1,860 @@ +# Customers.tsx Memoize & Split Implementation Plan + +> **For Hermes:** Use subagent-driven-development skill to implement this plan task-by-task. + +**Goal:** Split `src/pages/Customers.tsx` (1,794 lines) into memoized sub-components under `src/components/customers/` and pure helpers under `src/lib/customers.ts`, exactly as was done for Appointments in the prior 2.4 Execution Memo — without changing any user-visible behavior. + +**Architecture:** Extract pure utilities (formatters, PB filter builder, vehicle-info parser) into a `src/lib/customers.ts` module. Extract every inline sub-component (skeleton, empty/error states, card, form modals, delete dialog, detail view) into individual files under `src/components/customers/`, each wrapped in `React.memo` where it receives props. Wire them back into the orchestrator `Customers.tsx` via a barrel `index.ts`. Keep the public type exports (`CustomerRecord`, `CustomerWithVehicles`, `CustomerFormData`) re-exported from `Customers.tsx` so the 5 existing external importers are untouched. + +**Tech Stack:** React 19, TypeScript, Tailwind v4, Vite 8, Vitest + jsdom. + +**Constraints (from AGENTS.md):** +- No PocketBase schema/migration changes — this is a pure frontend refactor. +- Modal changes follow the background-location overlay pattern — but this split does NOT change any modal behavior; the extracted modal components keep their exact same markup and `fixed inset-0` overlay style. +- Do not touch existing tests in `src/store/__tests__` or `src/lib/__tests__`. +- Run `npm run build && npm run lint && npm run test` after the final task. + +--- + +## Reference: existing extraction pattern + +The proven pattern is in `src/components/appointments/` (created by the 2026-07-06 memo for Phase 2.4). Key conventions to reuse: +- One component per file, `function FooImpl(...) { ... } export const Foo = memo(FooImpl)` for presentational row/card components. +- Barrel `index.ts` re-exports. +- `src/lib/appointments.ts` holds pure helpers; components import from there, the page imports helpers too. +- Types stay where external consumers already import them from (the page file re-exports). + +--- + +## File inventory (current — 1,794 lines) + +| Lines | What | Destination | +|------:|------|-------------| +| 1–25 | Imports | trimmed in orchestrator | +| 27–92 | `CustomerRecord`, `VehicleRecord`, `RepairOrderRecord`, `QuoteRecord`, `CustomerWithVehicles` interfaces | stay in `Customers.tsx` (re-export) | +| 96–122 | `formatVehicleLabel`, `vehicleKey`, `escapePbString`, `activeROFilterForCustomer` | `src/lib/customers.ts` | +| 126–143 | `CustomersSkeleton` | `src/components/customers/EmptyStates.tsx` | +| 145–185 | `EmptyState` | same file `EmptyStates.tsx` | +| 187–189 | `ErrorState` | same file `EmptyStates.tsx` | +| 193–270 | `CustomerCard` | `src/components/customers/CustomerCard.tsx` (memo) | +| 274–441 | `CustomerFormData` interface + `CustomerFormModal` | interface stays in page re-export; component → `src/components/customers/CustomerFormModal.tsx` | +| 444–668 | `VehicleFormData` interface + `VehicleFormModal` | interface → `src/lib/customers.ts` (or co-located); component → `src/components/customers/VehicleFormModal.tsx` | +| 672–739 | `DeleteConfirmModal` | `src/components/customers/DeleteConfirmModal.tsx` | +| 743–783 | `getRoStatusLabel`, `getRoStatusColor`, `formatDate` | `src/lib/customers.ts` | +| 787–1170 | `CustomerDetailView` | `src/components/customers/CustomerDetailView.tsx` (memo) | +| 1174–1794 | main `Customers` component | stays in `Customers.tsx` as orchestrator | + +### External importers of `Customers.tsx` (must not break) +- `src/components/CustomerPicker.tsx:4` → `import type { CustomerWithVehicles }` +- `src/pages/Appointments.tsx:9` → `import type { CustomerRecord, CustomerWithVehicles }` +- `src/components/appointments/CheckInModal.tsx:8` → `import type { CustomerWithVehicles }` +- `src/components/appointments/ScanScreenshotModal.tsx:8` → `import type { CustomerWithVehicles }` +- `src/components/appointments/AppointmentModal.tsx:10` → `import type { CustomerWithVehicles, CustomerFormData }` + +Resolution: `Customers.tsx` keeps `export interface CustomerRecord`, `export interface CustomerWithVehicles`, `export interface CustomerFormData` (re-export from a new `src/components/customers/types.ts` to avoid duplication — but re-export is enough; `import type` callers don't care where the symbol physically lives as long as the page re-exports it). + +Decision: create `src/components/customers/types.ts` holding the shared interfaces; `Customers.tsx` does `export type { CustomerRecord, CustomerWithVehicles, CustomerFormData, VehicleRecord, RepairOrderRecord, QuoteRecord } from '../components/customers/types'`. External importers keep working unchanged. + +--- + +## Task 1: Create shared types module + +**Objective:** Move the shared interfaces out of the page into a dedicated types file so both the page and the new component files import from one place. + +**Files:** +- Create: `src/components/customers/types.ts` +- Modify: `src/pages/Customers.tsx` (replace the inline `interface` declarations with a re-export) + +**Step 1:** Create `src/components/customers/types.ts`: + +```ts +export interface CustomerRecord { + id: string; + name: string; + phone: string; + email: string; + address: string; + notes: string; + userId: string; + created: string; + updated: string; +} + +export interface VehicleRecord { + id: string; + customerId: string; + userId?: string; + make: string; + model: string; + year: string; + vin: string; + licensePlate: string; + mileage: string; + color: string; + engine: string; +} + +export interface RepairOrderRecord { + id: string; + roNumber: string; + customerId: string; + customerName: string; + vehicleInfo: string; + vin: string; + mileage: string; + status: string; + workStatus: string; + createdAt: string; + completedTime: string; + writeupTime: string; + technician: string; + notes: string; + services: string; + total: number; +} + +export interface QuoteRecord { + id: string; + customerId: string; + customerName: string; + vehicleInfo: string; + vin: string; + mileage: string; + status: string; + createdAt: string; + total: number; + serviceAdvisor: string; + services: string; + notes: string; +} + +export interface CustomerWithVehicles extends CustomerRecord { + vehicles: VehicleRecord[]; + vehicleCount: number; +} + +export interface CustomerFormData { + name: string; + phone: string; + email: string; + address: string; + notes: string; +} + +export interface VehicleFormData { + make: string; + model: string; + year: string; + vin: string; + licensePlate: string; + mileage: string; + color: string; + engine: string; +} +``` + +**Step 2:** In `src/pages/Customers.tsx`, delete lines 27–92 (the inline interface declarations) and replace with: + +```ts +export type { CustomerRecord, CustomerWithVehicles, CustomerFormData } from '../components/customers/types'; +``` + +Also add at the top, after the existing imports: + +```ts +import type { VehicleRecord, RepairOrderRecord, QuoteRecord, CustomerWithVehicles, CustomerFormData, VehicleFormData } from '../components/customers/types'; +``` + +Keep the `export` on `CustomerRecord` so existing importers don't break — verify the line above re-exports all three externally used types: `CustomerRecord`, `CustomerWithVehicles`, `CustomerFormData`. + +**Step 3 — Verify build:** + +```bash +npm run build +``` +Expected: PASS (tsc + vite, 0 errors). The type-only changes don't affect runtime. + +**Step 4 — Commit:** + +```bash +git add src/components/customers/types.ts src/pages/Customers.tsx +git commit -m "refactor(customers): extract shared interfaces to types module" +``` + +--- + +## Task 2: Create `src/lib/customers.ts` (pure helpers) + +**Objective:** Move all pure functions out of the page so they can be shared by the new component files without a circular dependency. + +**Files:** +- Create: `src/lib/customers.ts` + +**Step 1:** Create `src/lib/customers.ts` with these functions moved verbatim from `src/pages/Customers.tsx`: + +```ts +import type { VehicleRecord, RepairOrderRecord } from '../components/customers/types'; + +export function formatVehicleLabel(v: VehicleRecord): string { + const parts = [v.year, v.make, v.model].filter(Boolean); + return parts.length > 0 ? parts.join(' ') : 'Unknown vehicle'; +} + +export function vehicleKey(v: Pick): string { + return (v.vin || [v.year, v.make, v.model].filter(Boolean).join(' ')).trim().toLowerCase(); +} + +export function escapePbString(value: string): string { + return value.replace(/'/g, "\\'"); +} + +export function activeROFilterForCustomer(userId: string, customerId: string, previousName: string): string { + const activeStatuses = [ + "status = 'active'", + "status = 'in_progress'", + "status = 'waiting_parts'", + "status = 'waiting_pickup'", + ].join(' || '); + const identityFilters = [ + `customerId = '${escapePbString(customerId)}'`, + previousName.trim() ? `customerName = '${escapePbString(previousName.trim())}'` : '', + ].filter(Boolean).join(' || '); + + return `userId = '${escapePbString(userId)}' && (${identityFilters}) && (${activeStatuses})`; +} + +export function getRoStatusLabel(ro: RepairOrderRecord): string { + const s = ro.workStatus || ro.status || ''; + switch (s) { + case 'active': case 'open': return 'Open'; + case 'in-progress': case 'in_progress': return 'In Progress'; + case 'waiter': case 'waiting_parts': return 'Waiting Parts'; + case 'completed': return 'Completed'; + case 'final_close': + case 'delivered': return 'Final Close'; + case 'cancelled': return 'Cancelled'; + default: return s || 'Unknown'; + } +} + +export function getRoStatusColor(ro: RepairOrderRecord): string { + const s = ro.workStatus || ro.status || ''; + switch (s) { + case 'active': case 'open': + return 'bg-blue-50 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400'; + case 'in-progress': case 'in_progress': + return 'bg-yellow-50 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400'; + case 'waiter': case 'waiting_parts': + return 'bg-orange-50 text-orange-700 dark:bg-orange-900/30 dark:text-orange-400'; + case 'completed': case 'final_close': case 'delivered': + return 'bg-green-50 text-green-700 dark:bg-green-900/30 dark:text-green-400'; + case 'cancelled': + return 'bg-red-50 text-red-700 dark:bg-red-900/30 dark:text-red-400'; + default: + return 'bg-gray-50 text-gray-700 dark:bg-gray-900/30 dark:text-gray-400'; + } +} + +export function formatCustomerDate(dateStr: string): string { + if (!dateStr) return '\u2014'; + try { + const d = new Date(dateStr); + return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }); + } catch { + return dateStr; + } +} + +export function parseQuoteServices(raw: unknown): { name: string; price: number; customerDecision: string }[] { + if (!raw) return []; + let items = raw; + if (typeof raw === 'string') { + try { items = JSON.parse(raw); } catch { return []; } + } + if (!Array.isArray(items)) return []; + return items.map((s: any) => ({ + name: s.name || 'Service', + price: typeof s.price === 'number' ? s.price : 0, + customerDecision: s.customerDecision || 'pending', + })); +} +``` + +Note: `formatDate` is renamed to `formatCustomerDate` to avoid a name clash with `src/lib/format.ts` exports. The detail view uses it locally; the move forces a rename. Keep the call-site reference updated in Task 7. + +**Step 2 — Verify build:** + +```bash +npm run build +``` +Expected: FAIL — because `Customers.tsx` still has the old inline definitions; that's fine, we'll remove them in Task 8. For now just confirm `src/lib/customers.ts` compiles in isolation by running: + +```bash +npx tsc --noEmit src/lib/customers.ts +``` +If tsc complains about project settings, skip this sub-step — the final build happens at the end. + +**Step 3 — Commit:** + +```bash +git add src/lib/customers.ts +git commit -m "refactor(customers): add pure helpers module" +``` + +--- + +## Task 3: Create `src/components/customers/EmptyStates.tsx` + +**Objective:** Extract the three state-display components (skeleton, empty, error) into one file (same pattern as `src/components/appointments/EmptyStates.tsx`). + +**Files:** +- Create: `src/components/customers/EmptyStates.tsx` + +**Step 1:** Create the file, moving the JSX verbatim from `Customers.tsx` lines 126–189: + +```tsx +import { Search, Users, UserPlus } from 'lucide-react'; +import { ListError } from '../ui/ListError'; + +export function CustomersSkeleton() { + return ( +
+
+ {Array.from({ length: 6 }).map((_, i) => ( +
+
+
+
+
+ ))} +
+
+ ); +} + +export function EmptyState({ searchTerm, onCreate }: { searchTerm: string; onCreate: () => void }) { + if (searchTerm) { + return ( +
+
+ +
+

+ No matching customers +

+

+ No customer records matched “{searchTerm}”. +

+

+ Try searching by customer name, phone number, email address, vehicle, or VIN. +

+
+ ); + } + return ( +
+
+ +
+

+ No customer records on file +

+

+ Add your first customer record to keep contact details, vehicles, quotes, and repair history organized in one place. +

+ +
+ ); +} + +export function ErrorState({ message, onRetry }: { message: string; onRetry: () => void }) { + return ; +} +``` + +**Step 2 — Commit:** + +```bash +git add src/components/customers/EmptyStates.tsx +git commit -m "refactor(customers): extract EmptyStates components" +``` + +--- + +## Task 4: Create `src/components/customers/CustomerCard.tsx` + +**Objective:** Extract the card used in the list grid, wrapped in `React.memo`. + +**Files:** +- Create: `src/components/customers/CustomerCard.tsx` + +**Step 1:** Create: + +```tsx +import { memo } from 'react'; +import { Phone, Mail, Car, FileText, Pencil, Trash2 } from 'lucide-react'; +import { formatPhoneDisplay } from '../../lib/phone'; +import { formatVehicleLabel } from '../../lib/customers'; +import type { CustomerWithVehicles } from './types'; + +interface Props { + customer: CustomerWithVehicles; + onView: (id: string) => void; + onEdit: (c: CustomerWithVehicles) => void; + onDelete: (c: CustomerWithVehicles) => void; +} + +function CustomerCardImpl({ customer, onView, onEdit, onDelete }: Props) { + return ( +
+
+
+

onView(customer.id)} + > + {customer.name} +

+

ID: {customer.id.slice(0, 8)}

+
+ {customer.phone && ( + + + {formatPhoneDisplay(customer.phone)} + + )} + {customer.email && ( + + + {customer.email} + + )} +
+
+
+
+ + + {customer.vehicleCount === 0 + ? 'No vehicles' + : `${customer.vehicleCount} vehicle${customer.vehicleCount !== 1 ? 's' : ''}`} + + {customer.vehicles.length > 0 && ( + + — {formatVehicleLabel(customer.vehicles[0])} + + )} +
+
+ + + +
+
+ ); +} + +export const CustomerCard = memo(CustomerCardImpl); +``` + +**Step 2 — Commit:** + +```bash +git add src/components/customers/CustomerCard.tsx +git commit -m "refactor(customers): extract memoized CustomerCard" +``` + +--- + +## Task 5: Create `src/components/customers/CustomerFormModal.tsx` + +**Objective:** Extract the Add/Edit Customer form modal. **It is NOT wrapped in `memo`** — it holds its own state (`useState`) and re-renders only when its `open` prop flips, so memo would add nothing. Keep the exact same JSX and behavior. + +**Files:** +- Create: `src/components/customers/CustomerFormModal.tsx` + +**Step 1:** Create the file moving `CustomerFormModal` (lines 282–441 of `Customers.tsx`) verbatim. Replace the inline `CustomerFormData` type with an import: + +```tsx +import { useState, useEffect } from 'react'; +import { X } from 'lucide-react'; +import { formatPhoneInput } from '../../lib/phone'; +import type { CustomerFormData, CustomerWithVehicles } from './types'; + +export function CustomerFormModal({ + open, + customer, + onClose, + onSave, +}: { + open: boolean; + customer: CustomerWithVehicles | null; + onClose: () => void; + onSave: (data: CustomerFormData) => Promise; +}) { + // ... identical body to current lines 293–440 ... +} +``` + +Copy the JSX body verbatim (lines 293–440 of `Customers.tsx`). + +**Step 2 — Commit:** + +```bash +git add src/components/customers/CustomerFormModal.tsx +git commit -m "refactor(customers): extract CustomerFormModal" +``` + +--- + +## Task 6: Create `src/components/customers/VehicleFormModal.tsx` and `DeleteConfirmModal.tsx` + +**Objective:** Extract the vehicle form and the delete-confirmation modal into their own files. + +**Files:** +- Create: `src/components/customers/VehicleFormModal.tsx` +- Create: `src/components/customers/DeleteConfirmModal.tsx` + +**Step 1:** Create `VehicleFormModal.tsx` — move lines 456–668 of `Customers.tsx` verbatim, importing `VehicleFormData` and `VehicleRecord` from `./types`: + +```tsx +import { useState, useEffect } from 'react'; +import { X } from 'lucide-react'; +import type { VehicleFormData, VehicleRecord } from './types'; + +export function VehicleFormModal({ + open, + vehicle, + customerName, + onClose, + onSave, +}: { + open: boolean; + vehicle: VehicleRecord | null; + customerName: string; + onClose: () => void; + onSave: (data: VehicleFormData) => Promise; +}) { + // ... identical body to current lines 469–667 ... +} +``` + +**Step 2:** Create `DeleteConfirmModal.tsx` — move lines 672–739 verbatim: + +```tsx +import { useState } from 'react'; +import { AlertTriangle } from 'lucide-react'; + +export function DeleteConfirmModal({ + open, + customerName, + onClose, + onConfirm, +}: { + open: boolean; + customerName: string; + onClose: () => void; + onConfirm: () => Promise; +}) { + // ... identical body to current lines 683–738 ... +} +``` + +**Step 3 — Commit:** + +```bash +git add src/components/customers/VehicleFormModal.tsx src/components/customers/DeleteConfirmModal.tsx +git commit -m "refactor(customers): extract VehicleFormModal and DeleteConfirmModal" +``` + +--- + +## Task 7: Create `src/components/customers/CustomerDetailView.tsx` + +**Objective:** Extract the large detail view (lines 787–1170) into its own memoized component. It uses `getRoStatusLabel`, `getRoStatusColor`, `formatDate`, `parseQuoteServices` — all from `src/lib/customers.ts`. `formatDate` is renamed to `formatCustomerDate` in the helpers module to avoid a clash with `src/lib/format.ts`. + +**Files:** +- Create: `src/components/customers/CustomerDetailView.tsx` + +**Step 1:** Create: + +```tsx +import { useState, memo } from 'react'; +import { Phone, Mail, MapPin, ChevronLeft, Pencil, Plus, FileText, Car, Trash2 } from 'lucide-react'; +import { formatPhoneDisplay } from '../../lib/phone'; +import { + formatVehicleLabel, + getRoStatusLabel, + getRoStatusColor, + formatCustomerDate, + parseQuoteServices, +} from '../../lib/customers'; +import type { + CustomerWithVehicles, + VehicleRecord, + RepairOrderRecord, + QuoteRecord, +} from './types'; + +interface Props { + customer: CustomerWithVehicles; + vehicles: VehicleRecord[]; + repairOrders: RepairOrderRecord[]; + repairOrdersLoading: boolean; + quotes: QuoteRecord[]; + quotesLoading: boolean; + onBack: () => void; + onEdit: (c: CustomerWithVehicles) => void; + onAddVehicle: () => void; + onEditVehicle: (v: VehicleRecord) => void; + onDeleteVehicle: (v: VehicleRecord) => void; +} + +function CustomerDetailViewImpl({ + customer, + vehicles, + repairOrders, + repairOrdersLoading, + quotes, + quotesLoading, + onBack, + onEdit, + onAddVehicle, + onEditVehicle, + onDeleteVehicle, +}: Props) { + const [expandedQuoteId, setExpandedQuoteId] = useState(null); + const toggleQuote = (id: string) => setExpandedQuoteId(expandedQuoteId === id ? null : id); + + function formatCurrency(n: number): string { + return '$' + n.toFixed(2); + } + + return ( + // ... copy JSX verbatim from lines 837–1168, replacing formatDate(...) calls + // with formatCustomerDate(...) calls, and the inline parseQuoteServices with + // the imported parseQuoteServices ... + ); +} + +export const CustomerDetailView = memo(CustomerDetailViewImpl); +``` + +**Step 2 — Commit:** + +```bash +git add src/components/customers/CustomerDetailView.tsx +git commit -m "refactor(customers): extract memoized CustomerDetailView" +``` + +--- + +## Task 8: Create `src/components/customers/index.ts` barrel + +**Objective:** One import line in the orchestrator. + +**Files:** +- Create: `src/components/customers/index.ts` + +**Step 1:** + +```ts +export { CustomersSkeleton, EmptyState, ErrorState } from './EmptyStates'; +export { CustomerCard } from './CustomerCard'; +export { CustomerFormModal } from './CustomerFormModal'; +export { VehicleFormModal } from './VehicleFormModal'; +export { DeleteConfirmModal } from './DeleteConfirmModal'; +export { CustomerDetailView } from './CustomerDetailView'; +export type { + CustomerRecord, + VehicleRecord, + RepairOrderRecord, + QuoteRecord, + CustomerWithVehicles, + CustomerFormData, + VehicleFormData, +} from './types'; +``` + +**Step 2 — Commit:** + +```bash +git add src/components/customers/index.ts +git commit -m "refactor(customers): add barrel index" +``` + +--- + +## Task 9: Rewrite `Customers.tsx` as the orchestrator + +**Objective:** Remove every inline component and helper. Wire the extracted pieces. The main `Customers` component keeps all state, handlers, and the `renderListView`/`renderDetailView` orchestration. All handlers passed to memoized children are already wrapped in `useCallback`, so the memo is effective. + +**Files:** +- Modify (rewrite): `src/pages/Customers.tsx` + +**Step 1:** Replace the top of the file (imports + old interface/helper/component declarations) with: + +```tsx +import { useState, useEffect, useCallback, useRef } from 'react'; +import { useSearchParams } from 'react-router-dom'; +import { X, Plus } from 'lucide-react'; +import { pb } from '../lib/pocketbase'; +import { useToast } from '../components/ui/Toast'; +import { getSetupRequiredMessage, isMissingCollectionError, logError } from '../lib/userMessages'; +import { customerWriteSchema } from '../schemas/customer'; +import { + formatVehicleLabel, + vehicleKey, + escapePbString, + activeROFilterForCustomer, +} from '../lib/customers'; +import { + CustomersSkeleton, + EmptyState, + ErrorState, + CustomerCard, + CustomerFormModal, + VehicleFormModal, + DeleteConfirmModal, + CustomerDetailView, +} from '../components/customers'; + +export type { + CustomerRecord, + CustomerWithVehicles, + CustomerFormData, +} from '../components/customers/types'; + +import type { + CustomerWithVehicles, + CustomerRecord, + VehicleRecord, + RepairOrderRecord, + QuoteRecord, + CustomerFormData, + VehicleFormData, +} from '../components/customers/types'; +``` + +**Step 2:** Keep the main `Customers` function body (lines 1174–1794) intact, with these adjustments: +- Remove the now-extracted `CustomerCard`, `CustomerFormModal`, `VehicleFormModal`, `DeleteConfirmModal`, `CustomerDetailView` usages in the footer modals — replace with imported versions (same JSX, different source). +- Every handler (`fetchAll`, `handleViewCustomer`, `handleAddCustomer`, `handleEditCustomer`, `handleDeleteCustomer`, `handleAddVehicle`, `handleEditVehicle`, `handleDeleteVehicle`) is already wrapped in `useCallback` — keep them. +- In `renderListView`, `` now refers to the memoized import. +- In `renderDetailView`, `` refers to the memoized import. +- In the modals footer, ``, ``, `` refer to the imports. +- The `formatVehicleLabel` reference in the footer's "Delete Vehicle Confirmation" modal (line 1788: `formatVehicleLabel(deletingVehicle)`) now uses the import from `../lib/customers`. + +**Step 3 — Verify build:** + +```bash +npm run build +``` +Expected: PASS (0 errors). The orchestrator should be ~520–580 lines (state + handlers + two render functions + modals footer). + +**Step 4 — Lint:** + +```bash +npm run lint +``` +Expected: PASS (0 new errors/warnings; the 2 pre-existing Toast.tsx / Settings.tsx warnings remain). + +**Step 5 — Test:** + +```bash +npm run test +``` +Expected: the full Vitest suite passes (86+ tests across 12+ files). No new tests are added in this refactor — there are no existing tests for `Customers.tsx` to break. The existing `src/schemas/__tests__/customer.test.ts` (if any) and `src/lib/__tests__/` and `src/store/__tests__/` suites must pass unchanged. + +**Step 6 — Commit:** + +```bash +git add src/pages/Customers.tsx +git commit -m "refactor(customers): rewrite orchestrator using extracted components" +``` + +--- + +## Task 10: Verification & bundle-size check + +**Objective:** Confirm the refactor produced a smaller Customers chunk and nothing else regressed. + +**Step 1 — Full verification block:** + +```bash +npm run build && npm run lint && npm run test +``` +Expected: all three pass. + +**Step 2 — Inspect chunk sizes (same command from roadmap Appendix A):** + +```bash +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 +``` + +Expected: the Customers chunk is now smaller (the detail-view and form modal code is split into smaller lazy-ish chunks, though Vite may or may not split these into separate chunks without explicit `lazy()` — at minimum the page source file is smaller, which improves maintainability and re-render hotspots as described in roadmap 2.4). + +**Step 3 — Manual smoke (optional):** + +No behavior change is expected. If the user wants to verify, `npm run dev` and click through: list → search → customer card → detail view → add/edit customer modal → add/edit vehicle modal → delete customer modal. All UI should look identical. + +**Step 4 — Commit (if any verification fixes):** + +If any verification step required fixes, commit those. Otherwise skip this step. + +--- + +## Risks, tradeoffs, and open questions + +**Risks:** +1. **External type imports break.** Five files import types from `'../pages/Customers'`. Mitigation: `Customers.tsx` re-exports all three types. Verified in Task 1's re-export line. If any external file still breaks, update its import to `'../components/customers'` (or `../../components/customers/types`) — but the re-export means no change should be needed. +2. **`formatDate` rename.** `formatCustomerDate` avoids a clash with `src/lib/format.ts:formatDate` (used elsewhere in the app). All call sites inside `CustomerDetailView` must be updated. +3. **`memo` effectiveness.** `CustomerCard` and `CustomerDetailView` will only skip re-renders if every prop is referentially stable. `customer`, `vehicles`, `repairOrders`, `quotes` come from `useState` and are only replaced on fetch — stable between renders. Callbacks (`onView`, `onEdit`, etc.) are wrapped in `useCallback`. The memo is real; if a future change introduces an inline callback, memo breaks silently. +4. **No new tests are added.** Roadmap 2.4 for Appointments also didn't add tests for the extracted components; this matches the precedent. If the user wants snapshot tests for `CustomerCard`/`CustomerDetailView`, that's a separate task. + +**Tradeoffs:** +- More files to navigate, but each is smaller and single-purpose. +- The barrel import makes tree-shaking easier if any component is lazy-loaded later. + +**Open questions:** None. The pattern is proven on Appointments.tsx (Phase 2.4, 2026-07-06 memo). Customer behavior is unchanged. + +--- + +## Verification summary + +``` +npm run build → PASS (0 errors, 0 warnings new) +npm run lint → PASS (baseline warnings only) +npm run test → PASS (86+ tests; no regressions) +``` + +**Resolved roadmap priority:** 2.4 (continues the mega-component split, Customers.tsx this time). Next up per the pattern: RepairOrders.tsx (already partially memoized — `FragmentRow`/`CompletedRow` exist) or QuoteGenerator.tsx. \ No newline at end of file diff --git a/.hermes/plans/2026-07-07_phase3-frontend-and-status-correction.md b/.hermes/plans/2026-07-07_phase3-frontend-and-status-correction.md new file mode 100644 index 0000000..75bc539 --- /dev/null +++ b/.hermes/plans/2026-07-07_phase3-frontend-and-status-correction.md @@ -0,0 +1,97 @@ +# Plan: Phase 3 Frontend + Roadmap Status Correction + +**Date:** 2026-07-07 +**Scope:** Correct the stale roadmap status table, then complete the two frontend-only Phase 3 items that need no server approval. + +## Part A — Roadmap Status Correction (no code changes) + +The roadmap status table (lines ~2100-2141) is stale. Several items marked "NOT DONE — needs approval" are actually installed and running on the server. Update the table to reflect verified reality: + +| # | Current status line | Corrected status | Evidence | +|---|--------------------|-----------------|----------| +| 1.2 | "⚠️ PARTIAL — frontend done, server pending approval" | "✅ DONE" | `/opt/spq/ai-proxy/validate.js` exists, `spq-ai-validator.service` enabled+active, `spq-deepseek.conf` + `deepseek-key.conf` in nginx, `/deepseek/` location in shopproquote site | +| 1.3 | "❌ NOT DONE — needs PB migration + approval" | "✅ DONE" | `pb_migrations/1740000000020_users_create_role_guard.js` exists, applied in PB container (`migrate up` → "No new migrations to apply") | +| 1.4 | "⚠️ PARTIAL — frontend cleanup done, nginx pending" | "✅ DONE" | `/etc/nginx/snippets/spq-security.conf` exists with CSP + all headers, included via shopproquote site | +| 3.3 | "❓ UNKNOWN — server-side" | "✅ DONE" | `/etc/nginx/sites-enabled/shopproquote` serves dist/ on :443 with TLS + SPA fallback | +| 3.4 | "❓ UNKNOWN — server-side" | "✅ DONE" | `/pb/` and `/api/` reverse-proxy locations in shopproquote site; SSE (Upgrade/Connection headers) configured | + +**Action:** Update the status table in `roadmap_5.2.md` lines ~2105-2134 to mark these 5 items as ✅ DONE with evidence. No code changes. + +## Part B — Phase 3 Frontend-Only Items (no approval needed) + +### B1: 3.6 Offline Banner + +**Why first:** Pure frontend, zero approval, ~15 lines. + +**Files:** +- New: `src/components/OfflineBanner.tsx` (per roadmap spec — `navigator.onLine` + `online`/`offline` event listeners, sticky amber banner when offline) +- Edit: `src/App.tsx` — import and render `` inside ``, above the route content + +**Steps:** +1. Create `src/components/OfflineBanner.tsx` per the roadmap's existing code spec (lines 1767-1785) +2. Add `` to App.tsx just inside ``, above the layout/routes +3. Build + lint + test + +**Verify:** +``` +npm run build → passes +npm run lint → no new warnings +npm run test → 86 tests still pass +# Manual: toggle network off in DevTools → amber banner appears; on → disappears +``` + +### B2: 3.7 Dependency Audit + +**Why:** `npm audit` already clean (0 vulnerabilities). Only minor version bumps remain — safe to apply. + +**Current outdated (from `npm outdated`):** +| Package | Current | Latest | Risk | +|---------|--------|--------|------| +| @tailwindcss/vite | 4.3.1 | 4.3.2 | patch — safe | +| @types/node | 24.13.2 | 26.1.0 | major — SKIP (type-only, bleeding edge) | +| lucide-react | 1.21.0 | 1.23.0 | minor — safe | +| oxlint | 1.71.0 | 1.73.0 | minor — safe | +| react-router-dom | 7.18.0 | 7.18.1 | patch — safe | +| tailwindcss | 4.3.1 | 4.3.2 | patch — safe | +| vite | 8.1.0 | 8.1.3 | patch — safe | +| vitest | 4.1.9 | 4.1.10 | patch — safe | + +**Steps:** +1. Run `npm audit` — confirm 0 vulnerabilities (already verified) +2. Apply safe minor/patch bumps: `npm update` (respects semver ranges in package.json — bumps to "Wanted" column, not to "Latest" for @types/node) +3. Skip `@types/node` 24→26 (major, type-only, no runtime impact) +4. Build + lint + test all green +5. Record the audit result in the roadmap memo + +**Verify:** +``` +npm run build → passes +npm run lint → no new warnings +npm run test → 86 tests still pass +npm audit → 0 vulnerabilities +``` + +## Part C — Items requiring your approval (NOT in this plan, listed for visibility) + +These are the only remaining roadmap items. ALL need your explicit go-ahead before I touch PocketBase or server config: + +| # | Item | What it touches | Approval needed for | +|---|------|----------------|---------------------| +| 2.7 | Logo uploads to PB file records | PB `settings` collection | New PB migration (adds `logo` file field) | +| 3.1 | GlitchTip error tracking | New Docker container + `@sentry/react` dep | Server install + npm dep | +| 3.2 | PB production hardening | PB env vars, SMTP, backup cron | SMTP creds, cron job | +| 3.5 | Structured logging | nginx log_format, docker logging | nginx config change | +| 4.1-4.10 | Phase 4 features | Various PB + frontend | Per-feature approval | + +## Execution Order + +1. **Part A** — update roadmap status table (5 lines corrected, evidence cited) +2. **Part B1** — create OfflineBanner.tsx, wire into App.tsx +3. **Part B2** — `npm update` for safe bumps, verify +4. **Final verify** — build + lint + test all green +5. **Memo** — append Execution Memo to roadmap_5.2.md covering Parts A + B + +**Total new files:** 1 (`OfflineBanner.tsx`) +**Total edited files:** 2 (`App.tsx`, `roadmap_5.2.md`) +**Risk:** Low — offline banner is self-contained, dep bumps are patch/minor only +**No PocketBase changes. No nginx changes. No new secrets.** \ No newline at end of file diff --git a/.oxlintrc.json b/.oxlintrc.json new file mode 100644 index 0000000..6fa991d --- /dev/null +++ b/.oxlintrc.json @@ -0,0 +1,8 @@ +{ + "$schema": "./node_modules/oxlint/configuration_schema.json", + "plugins": ["react", "typescript", "oxc"], + "rules": { + "react/rules-of-hooks": "error", + "react/only-export-components": ["warn", { "allowConstantExport": true }] + } +} diff --git a/AGENTS.md b/AGENTS.md new file mode 100755 index 0000000..50baccd --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,276 @@ +# AGENTS +## # SPQ-v2 Core Directive & Roadmap Execution + + # Objective: Implement Role-Based UI Architecture (Advisor, Tech, Mobile) + + + +## **Your Execution Directives:** + + Verify Changes +- `npm run build` is the closest thing to typecheck here. It runs `tsc -b && vite build`. +- `npm run test` runs the full Vitest suite. +- Run a focused test with `npx vitest run "src/store/__tests__/quote.test.ts"`. +- `npm run lint` uses `oxlint`. As of now it reports 2 existing warnings in `src/components/ui/Toast.tsx` and `src/pages/Settings.tsx`; do not treat those as new ## regressions unless you touched them. + +* Permissions +- Do not create or change or reset any type of passwords without my explicit permission - always ask if its ok +- If I am asking you to do something that compromises my home server security please explain and ask for permission + + +## App Shape +- This is a single Vite + React app, not a monorepo. App entrypoints are `src/main.tsx` and `src/App.tsx`. +- Top-level pages are lazy-loaded in `src/App.tsx`. If you add a new page/route, wire it there. +- Shared app shell is `src/components/Layout.tsx`; settings has special modal/background-route behavior plus preload via `src/lib/routePreload.ts`. + +## Runtime / Backend Quirks +- PocketBase is accessed only from the frontend. Central client/auth helpers live in `src/lib/pocketbase.ts`. +- Prefer `useIsLoggedIn()` / `useAuth()` for reactive auth state. Do not read `pb.authStore.isValid` directly in render paths you expect to update live. +- Dev proxy assumptions are in `vite.config.ts`: + - `/pb` proxies to `http://127.0.0.1:8091` + - `/deepseek` proxies to `https://localhost` +- `vite.config.ts` excludes `pocketbase` from `optimizeDeps`; do not remove that casually. +-Do not open this file without Explicitly asking for my permission before using this -- PocketBase login info is at /tmp/opencode/pb-admin.txt + +## PocketBase Operations Memo +- PocketBase runs in Docker. Find the current container with `docker ps --format '{{.ID}} {{.Image}} {{.Names}} {{.Ports}}'` and look for the `pocketbase` container. On 2026-07-05 it was container `42d798cd1673`, named `pocketbase`, with `127.0.0.1:8091->8090/tcp`. +- The app proxy in `vite.config.ts` points `/pb` to `http://127.0.0.1:8091`, which is the host port forwarded to the PocketBase container. +- Inside the container, the PocketBase binary is `/usr/local/bin/pocketbase`. +- Inside the container, the PocketBase data directory is `/pb_data`. +- Inside the container, the active migrations directory is `/pb_data/migrations` (not `/pb_data/pb_migrations`). +- The project migration source directory is `pb_migrations/` in this repo. +- Before applying schema migrations, make a database backup inside the container: `docker exec cp /pb_data/data.db /pb_data/data.db.backup-before--$(date +%Y%m%d-%H%M%S)`. +- Copy migration files into the container before applying them. Example: `docker cp "pb_migrations/.js" :/pb_data/migrations/`. +- Apply migrations with: `docker exec /usr/local/bin/pocketbase migrate up --dir=/pb_data --migrationsDir=/pb_data/migrations`. +- Verify no pending migrations remain by running the same `migrate up` command again. Expected success message: `No new migrations to apply.` +- PocketBase version in the container was `0.39.1` on 2026-07-05. Use field constructors such as `TextField`, `EmailField`, `SelectField`, `DateField`, and `JSONField`; do not rely on the older `CollectionField` global for new migrations. +- Do not open `/tmp/opencode/pb-admin.txt` unless the user explicitly grants permission. Most migration work does not require admin credentials. + +## Data Model Gotchas +- `services` catalog records are consumed by `QuoteGenerator` as flat-price items. Keep `price` populated even when UI adds richer pricing inputs. +- Catalog description fields are inconsistent across the app: `ServiceCatalog` reads `explanation || description`, while quote flows read `explanation`. Preserve compatibility when changing service description writes. +- User-owned PocketBase collections are expected to be scoped by `userId = @request.auth.id` per `pb_migrations/1739999000004_user_scoped_api_rules.js`. Keep new collection fields and queries compatible with that model. +- Quote ↔ RO links are plain text IDs, not PocketBase relation fields, by design. See `pb_migrations/1739999000006_quote_ro_link.js`. + +## Shared State / Local Storage +- Quote draft state is persisted in Zustand under the `spq-quote` key in `src/store/quote.ts`. Be careful changing store shape because old local data will be rehydrated. +- Shop defaults belong in `src/lib/settings.ts`. Reuse `loadSettings()` / `saveSettings()` instead of duplicating fallback values in pages. +- Dark mode and some UI preferences also live in localStorage; search before introducing new keys. + +## Repair Order Conventions +- RO totals should go through `src/lib/totals.ts`. +- Some RO progress/audit features intentionally avoid PocketBase schema changes: + - service sub-status lives on the `ROService` JSON shape in `src/types.ts` + - RO event history is stored in the `financial` JSON blob, not a separate collection + +## Tests +- Vitest runs in `jsdom` with setup from `src/test/setup.ts`. +- Existing tests are lightweight unit tests under `src/store/__tests__` and `src/lib/__tests__`; there is no broader integration-test harness or CI config in this repo. + +## PopUp Modal Standard (Background-Location Overlay Pattern) + +Every pop-up / overlay modal MUST follow the pattern established by the Settings modal. There is no generic `` component; instead we use React Router's `backgroundLocation` technique so the background page stays rendered while the modal overlays it. + +### Architecture Overview + +| Layer | File | What It Does | +|-------|------|--------------| +| Route setup | `src/App.tsx` | Registers the route in both the primary `` (for in-page rendering) and the secondary conditional `` (for modal overlay rendering) | +| Trigger | `src/components/Layout.tsx` (desktop) and `src/components/mobile/MobileLayout.tsx` (mobile) | `` passes current `location` as `state.backgroundLocation` | +| Page component | `src/pages/Settings.tsx` | Detects `isModal` from `location.state?.backgroundLocation` and conditionally wraps content in a fixed overlay | +| Preload helper | `src/lib/routePreload.ts` | Lazy-load function for hover preloading (optional but recommended) | + +--- + +### Step 1 -- App.tsx: Register Two Routes + +In `src/App.tsx`, you need three pieces: + +**A) A ``-style guard component** (see `SettingsModalRoute` at `src/App.tsx:143-153`): +```tsx +function YourModalRoute() { + // Add any role gating or auth checks needed + return ( + + + + ); +} +``` + +**B) A primary route** inside the main `` (line 168), nested under the appropriate layout (e.g. ``): +```tsx +} /> +``` + +**C) A secondary modal route** inside the `{backgroundLocation && (...)}` block (line 195-199): +```tsx +{backgroundLocation && ( + + } /> + } /> {/* NEW */} + +)} +``` + +--- + +### Step 2 -- Layout.tsx / MobileLayout.tsx: Pass backgroundLocation on NavLink + +In the sidebar/nav `` for your route, pass `state` with `backgroundLocation` set to the current location. See `src/components/Layout.tsx:81`: + +```tsx + +``` + +The `(location.state... || location)` fallback ensures the overlay works correctly even if the user is already inside another modal (unlikely but defensive). + +Repeat this in `src/components/mobile/MobileLayout.tsx` for the mobile nav. + +--- + +### Step 3 -- Page Component: isModal Detection & Overlay Wrapper + +**A) Detect modal mode** at the top of your page component (see `Settings.tsx:479-480`): +```tsx +const location = useLocation(); +const modalBackground = (location.state as any)?.backgroundLocation; +const isModal = Boolean(modalBackground); +``` + +**B) Build your normal page content** and assign it to a variable (e.g. `content` or `settingsContent`). + +**C) Conditional return** -- this is the critical pattern (see `Settings.tsx:1449-1471`): +```tsx +if (!isModal) { + return content; // normal in-page render +} + +return ( +
+ {/* Backdrop -- clicking it closes the modal */} +
+); +``` + +### Sizing the Modal + +Size is controlled 100% by Tailwind classes on the inner div (the one with `rounded-3xl`). To change the size for a specific modal: + +| Dimension | Class(es) | Current Default | Options | +|-----------|-----------|-----------------|---------| +| **Width** | `max-w-*` | `max-w-6xl` (72rem / 1152px) | `max-w-2xl` (42rem), `max-w-3xl` (48rem), `max-w-4xl` (56rem), `max-w-5xl` (64rem), `max-w-7xl` (80rem), `max-w-[900px]` (arbitrary) | +| **Height** | `h-[min(...)]` | `h-[min(92vh,56rem)]` (92% viewport, 896px cap) | Adjust `92vh` and `56rem` as needed | +| **Outer padding** | `p-3 sm:p-6` | 12px mobile / 24px desktop | Decrease for larger modals that need more screen real estate | + +**Each modal chooses its own size.** Do not extract a shared size prop -- just change the Tailwind classes on that modal's inner container div. For example, a small confirmation dialog would use `max-w-md h-auto`, while a full-width data table modal might use `max-w-7xl`. + +--- + +### Step 4 -- Close / Escape Handler + +Every modal MUST implement close via backdrop click and Escape key. See `Settings.tsx:551-566`: + +```tsx +const closeModal = useCallback(() => { + if (isModal) { + navigate(-1); // pop the overlay URL, restoring the background page + } else { + navigate('/'); // fallback: go home from the full-page version + } +}, [isModal, navigate]); + +useEffect(() => { + if (!isModal) return; + const handler = (e: KeyboardEvent) => { + if (e.key === 'Escape') closeModal(); + }; + document.addEventListener('keydown', handler); + return () => document.removeEventListener('keydown', handler); +}, [isModal, closeModal]); +``` + +--- + +### Step 5 -- Preloading (Optional) + +Add a preload helper in `src/lib/routePreload.ts` so the page chunk starts loading on hover: +```ts +export const loadYourPage = () => import('../pages/YourPage'); + +export function preloadYourPage() { + void loadYourPage(); +} +``` + +Then use it in the NavLink: `onMouseEnter={preloadYourPage} onFocus={preloadYourPage}`. + +--- + +### Step 6 -- Sticky Save Button (Top Bar) + +Every modal with a save/submit action **MUST** place the save button in a sticky bar pinned to the **top** of the scrollable content area inside the modal. No other save or submit buttons may appear anywhere else in the modal body. + +```tsx +
+ {/* Sticky save bar at top */} +
+ +
+ + {/* Modal form body -- no save buttons here */} +
+ {children} +
+
+``` + +**Rule:** One save button at the top. Zero save buttons in the form body. + +--- + +### Checklist for Adding a New Modal + +- [ ] `src/App.tsx`: Add primary route inside main `` +- [ ] `src/App.tsx`: Add secondary modal route inside the `backgroundLocation` block (with guard component) +- [ ] `src/components/Layout.tsx`: Add `` with `state.backgroundLocation` +- [ ] `src/components/mobile/MobileLayout.tsx`: Add `` with `state.backgroundLocation` +- [ ] `src/pages/YourPage.tsx`: Detect `isModal`, build content variable, return overlay when `isModal` +- [ ] `src/pages/YourPage.tsx`: Implement `closeModal` with `navigate(-1)` + Escape key handler +- [ ] Choose appropriate `max-w-*` and `h-[...]` Tailwind classes for the modal's size +- [ ] Sticky save button at top of modal (only save button on the page) +- [ ] Remove any duplicate save/submit buttons from the modal body +- [ ] (Optional) `src/lib/routePreload.ts`: Add preload helper diff --git a/AGENTS__1.md b/AGENTS__1.md new file mode 100644 index 0000000..e7f88ec --- /dev/null +++ b/AGENTS__1.md @@ -0,0 +1,66 @@ +# AGENTS +## # SPQ-v2 Core Directive & Roadmap Execution + +**System Role:** You are an elite, professional Web Designer and Full-Stack Developer strictly dedicated to the `spq-v2` codebase. You write clean, production-ready React/Vite and PocketBase code. + + + +**Your Execution Directives:** + +1. **Strict Roadmap Adherence:** You must ONLY focus on the objectives outlined in the located at Websites\ShopProQuote.backup-20260626-2058\spq-v2\docs `shopproquote-evaluation-recommendations.md` document. Do not invent new features or refactor code outside of the explicit priorities listed in this roadmap. +2. **Task Selection:** Review the roadmap and consult your Think Tank to select the *single most impactful next item* to implement. Start strictly with "Priority 1 — Trust & Professionalism" (e.g., removing developer-facing PocketBase setup banners, generic error messages, and emojis) before moving to subsequent phases. +3. **Implementation:** Execute the code changes for the selected item. Ensure the UI feels like a professional automotive service application, not a generic SaaS template. +4. **Completion Memo (Required):** Once the implementation is successful, you MUST update the `shopproquote-evaluation-recommendations.md` file. Append a new section at the bottom titled `## Execution Memo: [Date] - [Feature Name]`. In this memo, state: + * What was completed. + * Which roadmap priority it resolved. + * A brief note on how the Think Tank evaluated its benefit to the target audience. + +**To begin your session:** Output a brief summary of the Think Tank's consensus on the *next immediate task* to tackle from the roadmap, and then proceed with the code implementation. + +## Verify Changes +- `npm run build` is the closest thing to typecheck here. It runs `tsc -b && vite build`. +- `npm run test` runs the full Vitest suite. +- Run a focused test with `npx vitest run "src/store/__tests__/quote.test.ts"`. +- `npm run lint` uses `oxlint`. As of now it reports 2 existing warnings in `src/components/ui/Toast.tsx` and `src/pages/Settings.tsx`; do not treat those as new regressions unless you touched them. + +## Permissions +- Do not make drastic changes without confirming with me first - such as deleting a whole settings modal +- Do not make any changes to PocketBase without confirming with me and explaning exactly what changes would be made +- Do not change or reset any type of passwords without my explicit permission - always ask +- If I am asking you to do something that compromises my home server security please explain and ask for permission + + +## App Shape +- This is a single Vite + React app, not a monorepo. App entrypoints are `src/main.tsx` and `src/App.tsx`. +- Top-level pages are lazy-loaded in `src/App.tsx`. If you add a new page/route, wire it there. +- Shared app shell is `src/components/Layout.tsx`; settings has special modal/background-route behavior plus preload via `src/lib/routePreload.ts`. + +## Runtime / Backend Quirks +- PocketBase is accessed only from the frontend. Central client/auth helpers live in `src/lib/pocketbase.ts`. +- Prefer `useIsLoggedIn()` / `useAuth()` for reactive auth state. Do not read `pb.authStore.isValid` directly in render paths you expect to update live. +- Dev proxy assumptions are in `vite.config.ts`: + - `/pb` proxies to `http://127.0.0.1:8091` + - `/deepseek` proxies to `https://localhost` +- `vite.config.ts` excludes `pocketbase` from `optimizeDeps`; do not remove that casually. +-Do not open this file without Explicitly asking for my permission before using this -- PocketBase login info is at /tmp/opencode/pb-admin.txt + +## Data Model Gotchas +- `services` catalog records are consumed by `QuoteGenerator` as flat-price items. Keep `price` populated even when UI adds richer pricing inputs. +- Catalog description fields are inconsistent across the app: `ServiceCatalog` reads `explanation || description`, while quote flows read `explanation`. Preserve compatibility when changing service description writes. +- User-owned PocketBase collections are expected to be scoped by `userId = @request.auth.id` per `pb_migrations/1739999000004_user_scoped_api_rules.js`. Keep new collection fields and queries compatible with that model. +- Quote ↔ RO links are plain text IDs, not PocketBase relation fields, by design. See `pb_migrations/1739999000006_quote_ro_link.js`. + +## Shared State / Local Storage +- Quote draft state is persisted in Zustand under the `spq-quote` key in `src/store/quote.ts`. Be careful changing store shape because old local data will be rehydrated. +- Shop defaults belong in `src/lib/settings.ts`. Reuse `loadSettings()` / `saveSettings()` instead of duplicating fallback values in pages. +- Dark mode and some UI preferences also live in localStorage; search before introducing new keys. + +## Repair Order Conventions +- RO totals should go through `src/lib/totals.ts`. +- Some RO progress/audit features intentionally avoid PocketBase schema changes: + - service sub-status lives on the `ROService` JSON shape in `src/types.ts` + - RO event history is stored in the `financial` JSON blob, not a separate collection + +## Tests +- Vitest runs in `jsdom` with setup from `src/test/setup.ts`. +- Existing tests are lightweight unit tests under `src/store/__tests__` and `src/lib/__tests__`; there is no broader integration-test harness or CI config in this repo. diff --git a/Quote_1.pdf b/Quote_1.pdf new file mode 100755 index 0000000..ad8cd05 Binary files /dev/null and b/Quote_1.pdf differ diff --git a/README.md b/README.md new file mode 100644 index 0000000..d6af7e3 --- /dev/null +++ b/README.md @@ -0,0 +1,32 @@ +# React + TypeScript + Vite + +This template provides a minimal setup to get React working in Vite with HMR and some Oxlint rules. + +Currently, two official plugins are available: + +- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs) +- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) + +## React Compiler + +The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation). + +## Expanding the Oxlint configuration + +If you are developing a production application, we recommend enabling type-aware lint rules by installing `oxlint-tsgolint` and editing `.oxlintrc.json`: + +```json +{ + "$schema": "./node_modules/oxlint/configuration_schema.json", + "plugins": ["react", "typescript", "oxc"], + "options": { + "typeAware": true + }, + "rules": { + "react/rules-of-hooks": "error", + "react/only-export-components": ["warn", { "allowConstantExport": true }] + } +} +``` + +See the [Oxlint rules documentation](https://oxc.rs/docs/guide/usage/linter/rules) for the full list of rules and categories. diff --git a/docs/POCKETBASE_MIGRATIONS.md b/docs/POCKETBASE_MIGRATIONS.md new file mode 100644 index 0000000..17f10a7 --- /dev/null +++ b/docs/POCKETBASE_MIGRATIONS.md @@ -0,0 +1,215 @@ +# PocketBase Schema Migrations + +These changes are recommended by the spq-v2 code review (2026-06-30). They +**alter the live database** and so must be applied to your **separate +PocketBase install** (PocketBase does not live inside this repo). + +## Two ways to apply + +### Option A — Runnable JS migrations (recommended) +Drop-in `.js` files are included in [`../pb_migrations/`](../pb_migrations/). +Copy that entire folder into your PocketBase install's `pb_migrations/` +directory and run: + +```bash +# from your PocketBase install directory +./pocketbase migrate +./pocketbase migrate up # apply pending migrations +./pocketbase migrate collections # re-sync collection schema +``` + +Files (apply in filename order): + +| File | What | +|------|------| +| `1739999000001_add_customerType_to_repairOrders.js` | M1 — `customerType` text column + backfill from `financial` blob | +| `1739999000002_add_estimatedDuration_to_repairOrders.js` | M2 — `estimatedDuration` (integer minutes) + backfill from `estimatedTime` | +| `1739999000003_promote_financial_fields.js` | M3 — `grossTotal` / `grossCost` / `warrTotal` / `warrCost` / `shopCharge` numeric columns + backfill | +| `1739999000004_user_scoped_api_rules.js` | M4 — per-user-scope List/View/Create/Update/Delete rules on all user-collections (security) | +| `1739999000005_unique_roNumber.js` | M5 — unique index on `repairOrders.roNumber` (server-side collision guard) | +| `1739999000006_quote_ro_link_columns.js` | M6 — `quotes.repairOrderId` + `repairOrders.quoteId` link columns (bidirectional RO↔Quote back-links) | + +Each migration is **idempotent** (safe to re-run) and includes an `up` and +`down` hook so `./pocketbase migrate down` cleanly reverses it. + +### Option B — Manual via admin UI +The next sections spell out each change for the admin UI. Use these if you +prefer clicking through PocketBase Admin → Collections → ⚙. + +--- + +## What the migrations do +these columns are absent, but promoting them unlocks correct persistence, +queryable aggregation, and atomic updates. + +Apply each change in the **PocketBase Admin → Collections → repairOrders → Edit +collection** UI. After adding a field, backfill existing rows as noted. + +--- + +## M1 — `customerType` (promote from `financial` JSON blob) + +**Today**: `customerType` lives only inside the stringified `financial` JSON +blob. The frontend synthesizes it on read (`RepairOrders.fetchOrders`), which +breaks whenever the blob is corrupt or missing. + +**Add field**: +- Name: `customerType` +- Type: `Text` +- Options: `Pattern` (optional) — restrict to `waiter|drop-off` + +**Backfill** (run once in Admin → Collections → repairOrders → run a small +script, or use the API): + +```js +// Node script — run against your PocketBase instance +import PocketBase from 'pocketbase'; +const pb = new PocketBase('http://localhost:8091'); +await pb.admins.authWithPassword('admin@example.com', 'PASSWORD'); + +const all = await pb.collection('repairOrders').getFullList({ batch: 500 }); +for (const ro of all) { + let fin = {}; + try { fin = typeof ro.financial === 'string' ? JSON.parse(ro.financial) : (ro.financial || {}); } catch {} + const ct = fin.customerType === 'waiter' ? 'waiter' : 'drop-off'; + await pb.collection('repairOrders').update(ro.id, { customerType: ct }); +} +``` + +After backfill, the redundant read/merge logic in +`RepairOrders.fetchOrders` (lines ~1779-1787) and `handleToggleCustomerType`'s +"persist into financial blob" path (~1842-1856) can be simplified to plain +column updates. Leaving them as-is is safe — they will simply keep the blob in +sync, which is harmless. **A future cleanup PR can remove the blob round-trip +once the column is confirmed populated.** + +--- + +## M2 — `estimatedDuration` (integer minutes; fixes lossy round-trip) + +**Today**: stored as `estimatedTime` (hours string, e.g. `"0.3"`), converted +back to minutes via `Math.round(hours * 60)` on read. 15 min → "0.3" → 18 min +on every reload, drifting the due time. + +**Add field**: +- Name: `estimatedDuration` +- Type: `Number` +- Options: `Min`: 0, `Max`: empty (or 10080 = 7 days) + +**Backfill**: + +```js +const all = await pb.collection('repairOrders').getFullList({ batch: 500 }); +for (const ro of all) { + if (ro.estimatedDuration != null && ro.estimatedDuration !== '') continue; + const hours = parseFloat(ro.estimatedTime || '0') || 0; + await pb.collection('repairOrders').update(ro.id, { + estimatedDuration: Math.round(hours * 60), + }); +} +``` + +**Frontend follow-up** (NOT yet applied — pending this migration): switch +`RepairOrders.fetchOrders`, `handleSave`, and `handleAddTime` to read/write +`estimatedDuration` directly and drop the `/ 60` ↔ `* 60` conversions. Until +then the current code keeps writing `estimatedTime` and synthesizing +`estimatedDuration` on read, so no data is lost. + +--- + +## M3 — Promote financial fields out of the `financial` JSON blob + +**Today**: `grossTotal`, `grossCost`, `warrTotal`, `warrCost`, `shopCharge`, +and `satisfaction` are partly real columns (`satisfaction` already is) and +partly loose JSON keys inside `financial`. The `FinancialDashboard` therefore +must load **every** completed RO to the client to aggregate — it cannot run a +server-side `sum()` over a JSON blob. + +**Add fields** (all type `Number`, min 0): +- `grossTotal`, `grossCost` +- `warrTotal`, `warrCost` +- `shopCharge` *(this overlaps conceptually with the existing `shopCharges` + column — confirm which one the dashboard should sum and consolidate)* + +**Backfill**: + +```js +const all = await pb.collection('repairOrders').getFullList({ batch: 500 }); +for (const ro of all) { + let fin = {}; + try { fin = typeof ro.financial === 'string' ? JSON.parse(ro.financial) : (ro.financial || {}); } catch {} + const patch = { + grossTotal: Number(fin.grossTotal) || 0, + grossCost: Number(fin.grossCost) || 0, + warrTotal: Number(fin.warrTotal) || 0, + warrCost: Number(fin.warrCost) || 0, + shopCharge: Number(fin.shopCharge) || 0, + }; + await pb.collection('repairOrders').update(ro.id, patch); +} +``` + +**Frontend follow-up**: after this migration, `ExpandedDetail.saveFinField` +should write directly to the typed columns (and can stop stringifying the +blob), and `FinancialDashboard` can issue a single `getFullList` with +`fields` projection + client-side sum, or better, a server-side aggregation +via PocketBase's `aggregate` API (0.20+) for monthly revenue/costs. This +collapses thousands of records to one request. + +--- + +## M4 — API Rules (security gate — apply even if you skip everything else) + +Each user-scoped collection (`quotes`, `repairOrders`, `invoices`, +`appointments`, `customers`, `services`, `settings`) MUST have API rules that +scope every record to the authenticated owner. Without these, any logged-in +user can read/write every other shop's data by passing a different `userId` +field in the request body. + +For each collection, in **Admin → Collections → ⚙ → API Rules**: + +| Action | Rule | +|--------|------| +| List / View | `userId = @request.auth.id` | +| Create | `userId = @request.auth.id` | +| Update | `userId = @request.auth.id` | +| Delete | `userId = @request.auth.id` | + +The `users` collection itself should restrict List/View to admins only. + +Verify with two test users — user A must NOT be able to `getOne()`. + +--- + +## M6 — Quote ↔ Repair Order link columns (bidirectional back-links) + +**Today**: RO→Quote and Quote→RO conversions stamp each other's record IDs +client-side, but there are no backing columns — the links are invisible to +queries and break if a record is re-imported. + +**Add fields**: +- On `quotes`: `repairOrderId` — type `Text`, optional. Stores the RO id that + originated this quote (set when the user clicks "Generate Quote" on an RO). +- On `repairOrders`: `quoteId` — type `Text`, optional. Stores the quote id that + was converted into this RO (set when the user clicks "Convert to RO" on a + quote). + +No backfill needed — existing records simply have empty values. + +**Frontend follow-up**: after M6 is applied, the `handleConvertQuoteToRO` and +`onGenerateQuote` handlers can stop encoding the link in the `notes` field and +use the real columns instead. + +--- + +## Applying & verifying + +1. **Backup first**: `./pocketbase backup create` (or copy the SQLite file). +2. Run **Option A** migrations in order, OR apply M4 (rules) manually first + — it has zero data migration and closes the cross-tenant hole. +3. Apply M1 → M2 → M3 → M5 → M6 in filename order (the JS files handle backfill + inside their `up` hooks). +4. After each migration, reload the spq-v2 app and confirm the corresponding + screen still renders existing data correctly. +5. Once M1 + M2 are confirmed in production, open a follow-up issue to clean + up the now-redundant frontend compatibility code (noted inline above). \ No newline at end of file diff --git a/docs/shopproquote-evaluation-recommendations.md b/docs/shopproquote-evaluation-recommendations.md new file mode 100644 index 0000000..3691385 --- /dev/null +++ b/docs/shopproquote-evaluation-recommendations.md @@ -0,0 +1,370 @@ +# ShopProQuote — Product Evaluation & Recommendations + +_Generated: 2026-07-04 · Based on full codebase review of `spq-v2`_ + +--- + +## 1. Current Product State Summary + +The app is a **single authenticated React/Vite + PocketBase application** originally designed as a quote generator for an auto repair shop. It has expanded well beyond quotes into operational tools: + +- Quotes (generate, save, print, PDF, export as images, AI explanations, priority analysis, AI service suggestions) +- Service Catalog (reusable services with pricing, categories, toggle active/inactive) +- Customers (records, vehicles, linked RO/quote history) +- Repair Orders (full lifecycle: write-up → active → in-progress → waiting parts → waiting pickup → completed → final close; time tracking, clock, audit timeline, financial summary, tech clock, void/unvoid, promised time overrides) +- Appointments (schedule, check-in → create RO, status changes, scan/screenshot OCR extraction) +- Invoices (create, edit, print, generate from RO) +- Financial Dashboard (revenue, costs, profit, customer LTV, warranty analysis, monthly trends) +- Settings (8 tabs: account, business info, advisors, tax/fees, quote defaults, PDF/branding, business ops, notifications) +- PDF generation (both quote PDF and RO work-order PDF, branded with logo, accent color, rich-text footer/payment terms) +- AI integration (DeepSeek for explanations, priority analysis, catalog descriptions, OCR for screenshots) + +**Backend**: PocketBase (self-hosted), user-scoped collections, real-time subscriptions, local-only auth. + +--- + +## 2. What Already Fits the Target Audience Well + +The product already covers the core workflows that advisors, home mechanics, and mobile mechanics need: + +| Feature | Value to Target Users | +|---|---| +| Quote generator | Core service — all users need professional quotes | +| Service catalog | Advisors and mechanics build reusable price lists | +| Professional PDF quotes | Customer-facing output with logo, branding, pricing, disclaimers | +| Customer records | Repeat business tracking | +| Repair order lifecycle | Full shop workflow — works for advisors and mechanics | +| Appointment scheduling | Essential for scheduling mobile/home visits | +| AI-written explanations | Saves time, sounds professional | +| Discount, tax, shop charge | Handles real pricing complexity | +| Phone formatting, VIN notes | Automotive-specific touches | +| PDF export + print + image export | Flexible customer delivery | +| RO → Quote conversion | One job produces both an RO and a customer quote | +| App → RO check-in | Office workflow for physical shops | + +--- + +## 3. What Hurts Professionalism (Fix Immediately) + +These issues make the app feel like a development project rather than a polished product: + +### 3.1 Developer-Facing Messages Visible to Users + +The most damaging issue. Multiple screens show messages like: + +- `SetupBanner.tsx`: "Collection Not Found" with a hardcoded admin URL `http://192.168.50.98:8091/_/` +- `Appointments.tsx`: Raw error text about creating PocketBase collections with field types +- `Customers.tsx`, `RepairOrders.tsx`, `Invoices.tsx`, `FinancialDashboard.tsx`: Similar raw setup messages +- `Unknown error` and `Something went wrong` patterns in toast messages + +**Fix**: Replace every instance with user-friendly setup prompts or internal-only logs. A first-run setup screen should replace all of these. + +### 3.2 Generic Error Handling + +`"Something went wrong"` and `"Unknown error"` appear in error states. This erodes trust. Every error path should show a useful message. + +### 3.3 Generic SaaS Visual Identity + +- Green/gray Tailwind defaults → looks like every other admin panel +- No automotive or service-industry visual cues +- No distinctive typography, iconography, or brand personality + +### 3.4 Emojis in UI Buttons and Status Labels + +Emojis appear in buttons like `🔧 Labor Only`, `✅ In Stock`, `⚠️ Need Order`, `💡 Aftermarket`, and `🔧 Tech Notes (Internal)`. This reads as casual or unpolished. Replace with text labels or subtle icons. + +### 3.5 Product Name and Tagline Are Too Narrow + +- The app is called **ShopProQuote** but it does far more than quotes +- Login screen says: `"Sign in to manage your quotes"` — this is misleading for RO, invoices, appointments, etc. +- The product scope has outgrown the name + +### 3.6 Empty State Language Is Too Casual + +`"Start building your customer database! Add your first customer to get started."` — better to sound professional and specific. + +### 3.7 No Onboarding / First-Run Setup + +When a new user signs up, they see empty screens and PocketBase errors. There is no guided setup: business name, logo, tax rate, shop charge, labor rate, quote terms, etc. + +--- + +## 4. Missing Features for Advisors, Home Mechanics, and Mobile Mechanics + +### 4.1 Customer Authorization / Signature (High Priority) + +- Quoted services need a clear **customer authorized** state +- Authorization should include: customer name, timestamp, method (in-person/phone/email/text/signature) +- Declined services should have customer acknowledgment +- **Why**: In auto repair, verbal authorization is common but documentation is critical for liability protection + +### 4.2 Customer-Facing Quote Approval Flow (High Priority) + +- Currently quotes exist as PDF downloads only +- There is no way to: + - Send a link to a customer for review + - Let customers approve/decline services online + - Track when a customer viewed the quote +- **Why**: Mobile and home mechanics need quick customer approval without printing + +### 4.3 Service Location / Mobile Job Fields (High Priority) + +- No address for mobile/home service jobs +- No travel fee or diagnostic fee +- No arrival window or estimated travel time +- No "shop", "mobile", "roadside" service type +- **Why**: This is the defining difference between shop software and mobile mechanic software + +### 4.4 Photo Attachments (High Priority) + +- No ability to attach photos to customers, quotes, repair orders, or services +- No before/after photos +- No inspection evidence (leaks, tire wear, brake thickness, damage) +- **Why**: Photos are standard in professional auto repair estimates and documentation + +### 4.5 Payment / Deposit Tracking (High Priority) + +- Invoices exist, but: + - No payment method tracking (cash, card, Zelle, PayPal, etc.) + - No deposit/partial payment tracking + - No "paid" vs "unpaid" vs "partial" states on quotes or ROs + - No payment receipt generation +- **Why**: Mobile and home mechanics often collect payment on completion or require deposits + +### 4.6 Customer Communication Log (Medium Priority) + +- SMS/email buttons exist in RO details but there is no unified log +- No communication history tied to customer or RO +- No sent quote tracking, customer response tracking +- **Why**: Professional service records include customer communication + +### 4.7 Legal / Professional Terms (Medium Priority) + +No built-in support for: + +- Warranty disclaimer text on quotes +- Diagnostic fee disclosure +- Storage fee / lien notice +- Declined-work disclaimer +- Authorization for test drive, additional work, parts ordering +- Mobile service terms (access to property, weather, etc.) + +### 4.8 Parts Tracking (Medium Priority) + +Current parts status is basic toggles (in stock, need order, aftermarket). Missing: + +- Part numbers +- Supplier / vendor +- Cost price vs sell price +- Core charge tracking +- Ordered date and ETA +- Received status + +### 4.9 Data Export / Backup (Medium Priority) + +- No CSV/JSON export for customers, quotes, ROs +- No PDF archive option +- **Why**: Professionals need to own their data + +### 4.10 Route / Calendar Integration (Lower Priority) + +- No Google Calendar, iCal, or ICS export for appointments +- No route/map link for mobile service addresses + +--- + +## 5. What May Not Fit (Consider Dual Mode) + +### 5.1 "Service Advisor" Language Is Shop-Centric + +- Advisors, waiters, drop-offs → these terms fit a dealership or shop +- **Mobile mechanic** does not have an "advisor"; they are the technician +- **Solution**: Make the role label configurable in settings or detect business type + +### 5.2 Financial Dashboard May Be Overwhelming for Solo Users + +- Customer LTV, warranty GP split, cost breakdown — valuable for a shop owner +- Solo mechanics may find this noisy until they grow +- **Solution**: Show simplified dashboard by default for non-admin users; hide until needed + +### 5.3 Waiter / Drop-Off Model Does Not Fit Mobile + +- Mobile mechanics need: `on-site`, `driveway`, `shop`, `roadside`, `mobile visit` +- **Solution**: Business type toggle → mobile mode replaces waiter/drop-off with location/time-based fields + +--- + +## 6. Recommended Product Direction + +### 6.1 Positioning + +``` +Professional repair quotes, approvals, and tracking +for shops, advisors, and mobile mechanics. +``` + +### 6.2 Dual Operating Mode + +| Feature | Shop / Advisor Mode | Mobile Mechanic Mode | +|---|---|---| +| Customer type | Waiter / Drop-off | On-site / Mobile / Drop-off | +| Service location | Shop address | Per-job address | +| Travel fee | Disabled | Configurable fee + mileage | +| Advisor field | Service Advisor (configurable) | Technician / Self | +| RO board | Active / All / Completed | Same but simplified | +| Photos | Optional | Core feature | +| Payment | At counter | At job site + deposit | +| Arrival window | Not needed | Estimated arrival window | + +--- + +## 7. Highest-Priority Changes (Ordered by Impact) + +### Priority 1 — Trust & Professionalism + +1. Remove every developer-facing message (PocketBase collection errors, admin URLs, field type instructions) +2. Replace `"Something went wrong"` and `"Unknown error"` with helpful, specific messages +3. Replace emojis in buttons/status with text or subtle icons +4. Add first-run onboarding: business type, business name, logo, contact, tax rate, labor rate, shop charge, quote terms, payment terms +5. Improve empty state messaging to sound professional and product-ready + +### Priority 2 — Mobile Mechanic Foundation + +6. Add service address field to ROs and appointments +7. Add travel fee, diagnostic fee, and mileage tracking +8. Add mobile vs shop service type flag +9. Add arrival window / estimated travel time to appointments + +### Priority 3 — Customer Trust & Approval + +10. Add customer authorization state (name + timestamp + method) +11. Add customer-facing quote approval flow (link or secure page) +12. Add photo attachment to quotes, ROs, and inspections + +### Priority 4 — Payments & Financial + +13. Add deposit tracking on quotes and ROs +14. Add payment method and payment status +15. Add basic payment receipt + +### Priority 5 — Polish + +16. Make advisor/technician label configurable +17. Add CSV export for customers, quotes, and ROs +18. Add legal disclaimer placeholders (warranty, fee disclosure, authorization) +19. Refine visual identity toward automotive/service-industry feel +20. Consider product name expansion or tagline update + +--- + +## 8. Suggested Implementation Plan + +### Phase 1: Production Proofing (1-2 weeks) + +- Replace all `SetupBanner` and collection-not-found errors with onboarding flow +- Rewrite error handling across all pages +- Remove emojis from operational UI +- Improve empty states and error states +- Add first-run setup wizard (business profile) + +### Phase 2: Mobile Mechanic Mode (2-3 weeks) + +- Add service location, travel fee, diagnostic fee, mileage fields +- Add mobile/shop service type toggle to ROs and appointments +- Create configurable role labels (advisor → technician or self) +- Add arrival window to appointments + +### Phase 3: Customer Approval & Photos (2-3 weeks) + +- Build customer authorization state +- Build customer-facing quote approval page/link +- Add photo upload to customers, quotes, ROs +- Add photo gallery to RO details + +### Phase 4: Payments & Polish (2-3 weeks) + +- Add deposit tracking +- Add payment method and status +- Add receipt generation +- Add CSV export +- Add legal disclaimer settings +- Visual identity refinement + +--- + +## 9. What NOT to Build Next + +- Do not add more internal dashboards or financial reports (already sufficient) +- Do not add multi-tenant/team features (out of scope) +- Do not add real-time chat or elaborate notification systems +- Do not add inventory management or full parts ordering +- Do not rebuild the existing workflows — polish what works + +The app already has strong internal tools. The next investment should be **customer-facing trust**: approvals, photos, mobile jobs, payments, and professional polish. + +## Execution Memo: 2026-07-05 - Customer-Facing Quote Approval Flow + +- Completed: Built a full public customer-facing quote review and approval flow. Created `src/pages/QuoteApprove.tsx` — a standalone branded page that loads a quote via a secure share token (no login required), displays services with individual Approve/Decline buttons, shows a pricing summary, and records customer decisions back to the quote record. Added `shareToken` field to the quotes PocketBase collection via `pb_migrations/1739999000007_quote_share_token.js` with relaxed view/update rules that allow public access only when the token matches. Added a "Share with Customer" button to the QuoteGenerator sidebar that generates a random UUID token, saves it to the quote, and copies the approval URL to the clipboard. The `authorizedMethod` type now includes `'online'` for decisions made through this flow. Registered the `/quote/approve?token=xxx` route as a public route in `src/App.tsx`. +- Resolved roadmap priority: Priority 3 — Customer Trust & Approval, item 11 by giving shop owners a frictionless way to send quotes to customers for online review and approval without printing or requiring customer accounts. +- Think Tank benefit: The mobile mechanic gained instant on-site customer approvals without printing or face-to-face pressure, the service advisor gained documented online authorization trails that protect against disputes, and the home mechanic gained a simple way to share cost estimates with family members for collaborative decision-making. + +## Execution Memo: 2026-07-05 - Login Scope Copy Update + +- Completed: Updated the login screen tagline from `Sign in to manage your quotes` to `Sign in to manage your shop` in `src/pages/Login.tsx`. +- Resolved roadmap priority: Priority 1 - Trust & Professionalism, item 1 and item 20 by removing a narrow quotes-only message that no longer reflects the product's broader workflow coverage. +- Think Tank benefit: The service advisor valued the more credible shop-wide positioning, the mobile mechanic valued that the app no longer reads like a quotes-only tool in front of customers, and the home mechanic valued the clearer description of the product's actual scope. + +## Execution Memo: 2026-07-05 - Operational UI Emoji Removal + +- Completed: Removed emoji-based labels from the quote workflow controls and status text in `src/pages/QuoteGenerator.tsx`, and removed the emoji badge text from the theme mode indicator in `src/pages/Settings.tsx`. +- Resolved roadmap priority: Priority 1 - Trust & Professionalism, item 3 by replacing casual emoji-driven labels with cleaner professional copy while preserving existing workflows and visual states. +- Think Tank benefit: The service advisor saw a more credible customer-facing presentation, the mobile mechanic saw less distracting UI in fast job-site workflows, and the home mechanic saw a more polished tool that reads like professional repair software instead of a casual app. + +## Execution Memo: 2026-07-05 - Setup State Professionalization + +- Completed: Replaced the remaining generic setup blockers in `src/components/SetupBanner.tsx`, `src/pages/Invoices.tsx`, `src/pages/FinancialDashboard.tsx`, and `src/pages/Settings.tsx` with workflow-specific professional messaging, and replaced the vague verification fallback copy in `src/pages/Verify.tsx` with actionable guidance. +- Resolved roadmap priority: Priority 1 - Trust & Professionalism, item 1 and item 2 by removing the last generic setup-facing UI language and improving a remaining non-specific error state. +- Think Tank benefit: The service advisor valued clearer customer-ready document setup guidance, the mobile mechanic benefited from less confusing blocked-state messaging during active workflows, and the home mechanic benefited from more direct instructions that explain what to do next without exposing internal implementation details. + +## Execution Memo: 2026-07-05 - Empty State Copy Refresh + +- Completed: Rewrote customer-facing empty and search-empty messaging in `src/pages/Customers.tsx`, `src/pages/Appointments.tsx`, `src/pages/RepairOrders.tsx`, `src/pages/FinancialDashboard.tsx`, `src/pages/Invoices.tsx`, `src/pages/Settings.tsx`, `src/pages/ServiceCatalog.tsx`, and `src/pages/QuoteGenerator.tsx` so each state explains the workflow purpose and next step more professionally. +- Resolved roadmap priority: Priority 1 - Trust & Professionalism, item 5 by replacing casual or sparse no-data language with clearer production-ready guidance across core workflows. +- Think Tank benefit: The service advisor valued cleaner customer-record and invoice messaging, the mobile mechanic benefited from clearer scheduling and quote guidance during day-to-day use, and the home mechanic benefited from empty states that explain what each tool is for without sounding like unfinished software. + +## Execution Memo: 2026-07-05 - Appointment And RO Service Address Support + +- Completed: Added service-address capture to the standard appointment create/edit flow in `src/pages/Appointments.tsx`, preserved appointment service locations through structured note serialization for schema compatibility, and surfaced service locations in appointment and repair-order search and list views in `src/pages/Appointments.tsx`, `src/pages/RepairOrders.tsx`, and `src/components/ROForm.tsx`. +- Resolved roadmap priority: Priority 2 - Mobile Mechanic Foundation, item 6 by carrying service-address information through appointments and repair orders without requiring PocketBase schema changes. +- Think Tank benefit: The service advisor valued clearer job-site context when preparing work, the mobile mechanic gained a practical on-site address workflow for scheduling and RO handoff, and the home mechanic benefited from being able to record where the work will happen without burying it in general notes. + +## Execution Memo: 2026-07-05 - Trip Mileage & Diagnostic Fee Tracking + +- Completed: Added `tripMiles` as a first-class concept for mobile mechanic tax-mileage tracking across appointments, repair orders, and PDF documents. Wired through appointment create/edit/check-in flows, RO form create/edit, RO list/row visibility, RO details panel, RO PDF info and totals sections, and appointment card display. Diagnostic fee already existed as the travel-fee field with proper "Travel / Diagnostic Fee" labeling in the UI and settings. +- Resolved roadmap priority: Priority 2 - Mobile Mechanic Foundation, item 7 by making trip mileage and diagnostic/travel fee first-class workflow fields that flow from appointment scheduling through to the tax-ready RO PDF. +- Think Tank benefit: The mobile mechanic gained a practical tax-mileage log that records trip distance per service call and prints on PDFs for deduction records, the service advisor gained trip-distance visibility on RO lists, and the home mechanic benefited from coordinated trip tracking without manual note-keeping. + +## Execution Memo: 2026-07-05 - Arrival Window For Appointments + +- Completed: Added `arrivalWindowMinutes` to the appointment type, form data, note serialization, and create/edit modal UI in `src/pages/Appointments.tsx`. The arrival window appears as a time-range display on the appointment card (e.g. "8:00 AM — 10:00 AM") when set, and defaults to "Exact time" when zero. Gated behind the mobile/home business-type toggle like other location fields. +- Resolved roadmap priority: Priority 2 - Mobile Mechanic Foundation, item 9 by adding a customer-facing arrival-window concept that tells the customer when to expect the mechanic without pinning it to an exact minute. +- Think Tank benefit: The mobile mechanic valued being able to provide a realistic time range to customers instead of a hard-to-keep exact time, the service advisor benefited from cleaner schedule presentation when communicating with waiting customers, and the home mechanic benefited from a simpler visual schedule layout. + +## Execution Memo: 2026-07-05 - Customer Authorization State + +- Completed: Added authorization metadata fields (`authorizedBy`, `authorizedAt`, `authorizedMethod`) to the `QuoteService` type in `src/types.ts`. Updated the Zustand quote store (`src/store/quote.ts`) so `toggleApproved` and `toggleDeclined` automatically populate authorization with customer name, current timestamp, and default method ("In Person"), and clear authorization when reverting to pending. Enhanced the QuoteGenerator expanded service row (`src/pages/QuoteGenerator.tsx`) with an inline authorization panel (authorized-by input, method dropdown with in_person/phone/email/text/signature options, and a formatted timestamp) that appears when a service is approved or declined. Updated the quote PDF generator (`src/lib/pdf.ts`) to render the authorization method and authorized-by name as a sub-line beneath the approval/decline badge on each service. +- Resolved roadmap priority: Priority 3 - Customer Trust & Approval, item 10 by giving every quote service a documented authorization trail that captures who approved or declined, when, and through which method. +- Think Tank benefit: The service advisor valued the liability protection of timestamped customer authorization records printed on customer-facing documents, the mobile mechanic valued frictionless on-site authorization capture via method-specific tracking, and the home mechanic valued clear decision documentation without a clunky workflow. + +## Execution Memo: 2026-07-05 - Public Quote Approval URL + Signature & Send Improvements + +- Completed: Added a full customer-facing quote approval flow via public share link. Created `pb_migrations/1739999000007_quote_share_token.js` adding a `shareToken` field and relaxed list/view/update rules on the `quotes` collection. Created `src/pages/QuoteApprove.tsx` — a branded public page at `/quote/approve?token=xxx` where customers review and approve/decline services. Added a "Share with Customer" button to the QuoteGenerator sidebar and wired the approval page URL generation. Stamped decisions with `authorizedMethod: 'online'` and added the `'online'` value to the authorizedMethod type union. Added the route as public in `src/App.tsx`. +- Then improved the flow in a second pass: added `pb_migrations/1739999000008_quote_viewed_at.js` for first-view tracking. Rewrote the approval page to stage decisions locally, require a typed full-name signature before final submission, and submit all decisions in a single update with correct top-level status (all approved -> `approved`, all declined -> `declined`, mixed -> `sent`). Added a shared `src/lib/contactLinks.ts` helper for `mailto:`/`sms:` draft generation and refactored `RepairOrders.tsx` to use it. Added "Send Email" and "Send Text" buttons to the quote share panel that open device drafts with the approval URL embedded. +- Resolved roadmap priority: Priority 3 - Customer Trust & Approval, item 11. +- Think Tank benefit: The mobile mechanic can now text a live approval link from the job site and get a signed decision record, the service advisor gains a documented online authorization trail with a typed-name signature that prints on PDFs, and the home mechanic gets a simple send-via-email workflow for sharing cost estimates with family. + +## Execution Memo: 2026-07-05 - Role-Based Experience Roadmap + +- Completed: Created `Roadmap.md` with the full implementation plan for post-onboarding role-based experiences across Advisor, Technician, and Mobile Mechanic users. The plan defines role permissions, route architecture, mobile owner access, technician-safe assignment records, PocketBase schema requirements, security rules, onboarding updates, and verification steps. +- Resolved roadmap priority: RBX architecture planning for strict Advisor, Technician, and Mobile separation while preserving Priority 1 professionalism and preparing safe implementation of future workflow phases. +- Think Tank benefit: The service advisor gains a clear path to full desk tools and technician assignment control, the shop technician gains a protected bay-only workflow with no billing exposure, and the mobile mechanic keeps full owner-level quote, catalog, invoice, and financial access in a field-first interface. diff --git a/docs/~$opproquote-evaluation-recommendations.md b/docs/~$opproquote-evaluation-recommendations.md new file mode 100755 index 0000000..82efca1 Binary files /dev/null and b/docs/~$opproquote-evaluation-recommendations.md differ diff --git a/index.html b/index.html new file mode 100644 index 0000000..2beb149 --- /dev/null +++ b/index.html @@ -0,0 +1,18 @@ + + + + + + + + + + + + ShopProQuote — Shop Management + + +
+ + + diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..39729bb --- /dev/null +++ b/package-lock.json @@ -0,0 +1,3474 @@ +{ + "name": "spq-v2", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "spq-v2", + "version": "0.0.0", + "dependencies": { + "@sentry/react": "^10.64.0", + "@tailwindcss/vite": "^4.3.1", + "jspdf": "^4.2.1", + "lucide-react": "^1.21.0", + "pdfjs-dist": "^6.1.200", + "pocketbase": "^0.27.0", + "react": "^19.2.7", + "react-dom": "^19.2.7", + "react-router-dom": "^7.18.0", + "tailwindcss": "^4.3.1", + "tesseract.js": "^7.0.0", + "zod": "^4.4.3", + "zustand": "^5.0.14" + }, + "devDependencies": { + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^16.3.2", + "@types/jspdf": "^2.0.0", + "@types/node": "^24.13.2", + "@types/react": "^19.2.17", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^6.0.2", + "jsdom": "^29.1.1", + "oxlint": "^1.69.0", + "typescript": "~6.0.2", + "vite": "^8.1.0", + "vitest": "^4.1.9" + } + }, + "node_modules/@adobe/css-tools": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.5.0.tgz", + "integrity": "sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q==", + "dev": true + }, + "node_modules/@asamuzakjp/css-color": { + "version": "5.1.11", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.1.11.tgz", + "integrity": "sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==", + "dev": true, + "dependencies": { + "@asamuzakjp/generational-cache": "^1.0.1", + "@csstools/css-calc": "^3.2.0", + "@csstools/css-color-parser": "^4.1.0", + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/dom-selector": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-7.1.1.tgz", + "integrity": "sha512-67RZDnYRc8H/8MLDgQCDE//zoqVFwajkepHZgmXrbwybzXOEwOWGPYGmALYl9J2DOLfFPPs6kKCqmbzV895hTQ==", + "dev": true, + "dependencies": { + "@asamuzakjp/generational-cache": "^1.0.1", + "@asamuzakjp/nwsapi": "^2.3.9", + "bidi-js": "^1.0.3", + "css-tree": "^3.2.1", + "is-potential-custom-element-name": "^1.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/generational-cache": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/generational-cache/-/generational-cache-1.0.1.tgz", + "integrity": "sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg==", + "dev": true, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/nwsapi": { + "version": "2.3.9", + "resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz", + "integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==", + "dev": true + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, + "peer": true, + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "peer": true, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", + "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@bramus/specificity": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz", + "integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==", + "dev": true, + "dependencies": { + "css-tree": "^3.0.0" + }, + "bin": { + "specificity": "bin/cli.js" + } + }, + "node_modules/@csstools/color-helpers": { + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.1.0.tgz", + "integrity": "sha512-064IFJdjTfUqnjpCVpMOdbr8FLQBhinbZj6yRv2An2E41O/pLEXqfFRWqGq/SxlE5PEUYTlvWsG2r8MswAVvkg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@csstools/css-calc": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.2.1.tgz", + "integrity": "sha512-DtdHlgXh5ZkA43cwBcAm+huzgJiwx3ZTWVjBs94kwz2xKqSimDA3lBgCjphYgwgVUMWatSM0pDd8TILB1yrVVg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.1.9.tgz", + "integrity": "sha512-paQcIaOO53Rk5+YrBaBjm/SgrV4INImjo2BT1DtQRYr+XeTRbeAYlS+jxXp9drqvKmtFnWRJKIalDLhZZDu42A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "dependencies": { + "@csstools/color-helpers": "^6.1.0", + "@csstools/css-calc": "^3.2.1" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz", + "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-syntax-patches-for-csstree": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.6.tgz", + "integrity": "sha512-TcJCWFbXLPpJYq6z7bfOyjWYJDiDg2/I4gyUC9pqPNqHFRIey0EB0q0L5cSnQDfWJg8Jd6VadakxdIez/3zkqQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "peerDependencies": { + "css-tree": "^3.2.1" + }, + "peerDependenciesMeta": { + "css-tree": { + "optional": true + } + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz", + "integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@emnapi/core": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.1.tgz", + "integrity": "sha512-RSvbQmHzdKzNsLYa/wHrbc3KN4sYLKAdPZxqiM2HATqv/SBk2/ENSHpvXGaLOMcsAyz0poEGqkmmKYG3OWiJEQ==", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.2", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.11.1", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.1.tgz", + "integrity": "sha512-vgj7R3y3Wgx24IQaGPA/R6YFXLHVMOZ0uVEyIQPaWs+rd1AzfEMXlAC22FYwO1XkKR6NPsq7mUandH8oIRdZFw==", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", + "integrity": "sha512-c95qOXkHdydNKhscBTebqEC1CVAZpyqOfVfBzQ1qgzyl3gfeldUjIggDbIZgDKsHLgnsM+igH7TJ/eAasaVuMA==", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@exodus/bytes": { + "version": "1.15.1", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.1.tgz", + "integrity": "sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==", + "dev": true, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@noble/hashes": "^1.8.0 || ^2.0.0" + }, + "peerDependenciesMeta": { + "@noble/hashes": { + "optional": true + } + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@napi-rs/canvas": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas/-/canvas-1.0.1.tgz", + "integrity": "sha512-mPD43G7pXbQhIGa7z4IpT/vXm1jbF8cBM1oY5UqjL8LSaTCNGhNi2Lidc/0+LwKbNiqbv/Tq0JlBRwKu+LW3iw==", + "optional": true, + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "optionalDependencies": { + "@napi-rs/canvas-android-arm64": "1.0.1", + "@napi-rs/canvas-darwin-arm64": "1.0.1", + "@napi-rs/canvas-darwin-x64": "1.0.1", + "@napi-rs/canvas-linux-arm-gnueabihf": "1.0.1", + "@napi-rs/canvas-linux-arm64-gnu": "1.0.1", + "@napi-rs/canvas-linux-arm64-musl": "1.0.1", + "@napi-rs/canvas-linux-riscv64-gnu": "1.0.1", + "@napi-rs/canvas-linux-x64-gnu": "1.0.1", + "@napi-rs/canvas-linux-x64-musl": "1.0.1", + "@napi-rs/canvas-win32-arm64-msvc": "1.0.1", + "@napi-rs/canvas-win32-x64-msvc": "1.0.1" + } + }, + "node_modules/@napi-rs/canvas-android-arm64": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-android-arm64/-/canvas-android-arm64-1.0.1.tgz", + "integrity": "sha512-d7ZCwJsgH4QNG50C7HQeVRsRG1gRDa1UeDUb1jEcqgLuiEJp6GVbGiZkFXPlmt0dEs2QHRQCPJoOv+bOkSQR/w==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-darwin-arm64": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-arm64/-/canvas-darwin-arm64-1.0.1.tgz", + "integrity": "sha512-ppyVSzIHsVldc3B++mdh03ed0Q0hoVR2QDG9O/wEUR0PurJKwDEEYV87uBQDpbSumJBfLEINDndsOPzQj71qEQ==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-darwin-x64": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-x64/-/canvas-darwin-x64-1.0.1.tgz", + "integrity": "sha512-/BlXif9VOzf/WP32g9zxl612dO0KLvwqplBFqfRcyr3PyR5fhPrilTuJxSBq3zkwCKGKy82JsoPd2JeQI/HBlA==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-linux-arm-gnueabihf": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm-gnueabihf/-/canvas-linux-arm-gnueabihf-1.0.1.tgz", + "integrity": "sha512-JTGq93/Pje+iSNVjL+ggB0+pqEfu7nXvQGrHTvugz+Lp08wCDa5rjov4JeEljGDk16/inVBU9sp4N9f0/+o+9A==", + "cpu": [ + "arm" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-linux-arm64-gnu": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-gnu/-/canvas-linux-arm64-gnu-1.0.1.tgz", + "integrity": "sha512-i74zqEh5yFmYwHkszFo+4EH4l5ATD4bSlJG21iW2j5kpqiN2b0WN9SG/xdq2O60MjZK0ZLSu3a/Z3aQAsmDQ5A==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-linux-arm64-musl": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-musl/-/canvas-linux-arm64-musl-1.0.1.tgz", + "integrity": "sha512-COqBxybXcKb6gNgEhjh04rPHrpsJB+n/5+p4ySPgQWl0i+xVNYHn4rvzCtUBIFqOgY6HEJ9UaP6c4W9EMwzfpQ==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-linux-riscv64-gnu": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-riscv64-gnu/-/canvas-linux-riscv64-gnu-1.0.1.tgz", + "integrity": "sha512-1vdAZGpD85lMUo7K3qtEdoIWeMc0xcpUD5PagK3fVcMSdf8dkSL5bg/KE4Rwv5NF+PYx4plrgfn0KRMOqdKtwA==", + "cpu": [ + "riscv64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-linux-x64-gnu": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-gnu/-/canvas-linux-x64-gnu-1.0.1.tgz", + "integrity": "sha512-W/iC2qJZGqKKQJ0JrNo3QkhoAy/PvzlmdYLW8Yz5/L6XgT5d7t26dnqgP2rCbL58P3CbPw7ES0Rz8OG0gn7JeQ==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-linux-x64-musl": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-musl/-/canvas-linux-x64-musl-1.0.1.tgz", + "integrity": "sha512-a5mmIVwxF92UGUe+1c7Ap32ZRnApbRMnQC/KgYyFB0AXZShBCHVGaURq+BDkiV7jvHhVwvvAP0Q/3aWNhqgVZg==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-win32-arm64-msvc": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-arm64-msvc/-/canvas-win32-arm64-msvc-1.0.1.tgz", + "integrity": "sha512-rVnDhVvcXlqcHMsgnxxhZgdRkRIqVBlx+FJwSAHi4VbWWwsowvV5ldFEecEHD2+Ac/IL3fNWF/LB5CZTghNwRA==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-win32-x64-msvc": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-x64-msvc/-/canvas-win32-x64-msvc-1.0.1.tgz", + "integrity": "sha512-UMstkP/nZHbithgdSJv1EvYVrYhdao5B5N3szMVU2i5/b6ijMcVPXOEyrk0QXl0iPjv8Hkoow+Tap+MiOxppOQ==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.6.tgz", + "integrity": "sha512-ZLv/JdUfkvOy9eCnnBaGfiO+XimbjebAeO+MRQqD/B+FR1tnRN0tpKSJHRbE8sFfS6aqsXZ67TQjfwfsxULVbg==", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.3" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.137.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.137.0.tgz", + "integrity": "sha512-WT+Gb24i8hmvo85AIv2oEYouEXkRlKAlT9WaCa3TfLgNCN+GhrJOGZuIlMouAh38Qe4QOx26eUOVsq70qXrywA==", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@oxlint/binding-android-arm-eabi": { + "version": "1.71.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm-eabi/-/binding-android-arm-eabi-1.71.0.tgz", + "integrity": "sha512-ImGmd1njEg4FEJH03jhRnveEegtO3czCtfptvaHivKAZQIYATbVFBrrzbaYMYv0oJioTnxZAZVSyV+oL7W8S2g==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-android-arm64": { + "version": "1.71.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-android-arm64/-/binding-android-arm64-1.71.0.tgz", + "integrity": "sha512-4A5BEexBrwY1YFF8Kiq/lp/wQPRG79G3BWIE1FuWaM5MvmpYSd+7ZySVcKkHdwo0UDzdQGddp6pD9mpctMqLnw==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-darwin-arm64": { + "version": "1.71.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-arm64/-/binding-darwin-arm64-1.71.0.tgz", + "integrity": "sha512-9wJA9GJulLwS2usU3CEisI/ESDO1n1z9eyTCvApMDrAkbJ1ve0mORgTMjcWWsKxkzkeZ2N/Gpra5IQE7x8tYgQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-darwin-x64": { + "version": "1.71.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-darwin-x64/-/binding-darwin-x64-1.71.0.tgz", + "integrity": "sha512-PlLCjS06V0PeJMAJwzjrExw1sYNW9Gch3JtNlcwwZDXGlTYDuwHNN89zYH8LTXFfgkVtsYvs2nv0FqrzyuFDzg==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-freebsd-x64": { + "version": "1.71.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-freebsd-x64/-/binding-freebsd-x64-1.71.0.tgz", + "integrity": "sha512-Lhil7bWre0ncxbUoDoxfS0JzpTz17BRQKW7iwoAUY8GJ66+WwJEfYPCFJ1P0WgVZR5/O/b3Q2pENlHOjeXLOGQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm-gnueabihf": { + "version": "1.71.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.71.0.tgz", + "integrity": "sha512-Oo9/L58PYD3RC0x05d2upAPLllHytTjHQGsnC06P6Ynn7jKkp5mdImQxXdJ3+FnBaKspNpGogzgVsi6g872LiA==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm-musleabihf": { + "version": "1.71.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm-musleabihf/-/binding-linux-arm-musleabihf-1.71.0.tgz", + "integrity": "sha512-mSHfyfgJrEbyIR29ejaeS50BdPk+GoNPlC1dckpDiUZbJAIel68sjSMdOt4WY0/gva+ECC7FNITQkxMJU+vSBw==", + "cpu": [ + "arm" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm64-gnu": { + "version": "1.71.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.71.0.tgz", + "integrity": "sha512-n9yY4M2tiy3aij4AqtlnspzpfdpeT5JQfK2/w2d8oyp5W0FRwOb1dIeX99nORNcxGr08iD9bH8N5XFz3I2iy8w==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-arm64-musl": { + "version": "1.71.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.71.0.tgz", + "integrity": "sha512-fJZrs5sDZtTaPIOiemRQQmo82Ezy+vOGXemPc4Ok7iVVsYsFa7SlW6Z5XN819VfsqBHRm3NJ3rTdnR8+bJYJdQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-ppc64-gnu": { + "version": "1.71.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.71.0.tgz", + "integrity": "sha512-cwl7VKGERIy9p+G+AvZdfy/06q0aHXaTt/mMRReC751iuNYJgqKjB7NydXSS30nBT9vtr2tunciOtrR4fD6FUA==", + "cpu": [ + "ppc64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-riscv64-gnu": { + "version": "1.71.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-gnu/-/binding-linux-riscv64-gnu-1.71.0.tgz", + "integrity": "sha512-eZ8ieVXvzGi8jr7+ybQGPK2STw3mldfxZlgA2738iflfB/rzA69sE6m5rDRpQaxC7dpm745Enlh1Tod0QAk9Gg==", + "cpu": [ + "riscv64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-riscv64-musl": { + "version": "1.71.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-riscv64-musl/-/binding-linux-riscv64-musl-1.71.0.tgz", + "integrity": "sha512-puMDbQYe6+NXwfMusojoA7CXGn2b3utukmd23PQqc1E3XhVCwyZ+FueSMzDYeNgDV2dUfIVXAAKZBcFDeCL6sA==", + "cpu": [ + "riscv64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-s390x-gnu": { + "version": "1.71.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.71.0.tgz", + "integrity": "sha512-4NJLxBs1ujISCt3L/1FcywLs73PWtJuw+piD6feK2V6h6OS6P7xu9/sWt1DTRLibe6QCzmfZzmM/2HPORoV/Lg==", + "cpu": [ + "s390x" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-x64-gnu": { + "version": "1.71.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.71.0.tgz", + "integrity": "sha512-cFDaiR8L3430qp88tfZnvFlt3KotFhR/DlbIL0nHOMMYiG/9Wy4l+6f7t8G8pTa9bd8Lt8+M0y/qjRQ/xcB74g==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-linux-x64-musl": { + "version": "1.71.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-linux-x64-musl/-/binding-linux-x64-musl-1.71.0.tgz", + "integrity": "sha512-orfixdt76KlpNly9z0PkWBBNfwjKz+JFVLP/7wnVchlKNU9Dpt9InU/ZggeSej6fC7qwHmHNOGlhLnQXcYoGuA==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-openharmony-arm64": { + "version": "1.71.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-openharmony-arm64/-/binding-openharmony-arm64-1.71.0.tgz", + "integrity": "sha512-9emQu2lAp6yhPB3XuI+++vR+l/o6JR1X+EpxwcumPdQXBWXEPAsquPGL7l158EqU8SebQMXTUa/S5zN98juyHw==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-win32-arm64-msvc": { + "version": "1.71.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.71.0.tgz", + "integrity": "sha512-bd5kI8spYwTm3BILDtGhi73zoup5dw8MlPQNT8YB3BD5UIsjNe3K9/4ctrzQMX4SZMoK5HgzVLkLJzacEXB7fA==", + "cpu": [ + "arm64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-win32-ia32-msvc": { + "version": "1.71.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-ia32-msvc/-/binding-win32-ia32-msvc-1.71.0.tgz", + "integrity": "sha512-W4HvOHGzVLHcrmFu+bMrJlho+/yrlX5ZNdJZqGe8MEldkQG+RHYhxxad9P4jvWAYFmIqUA5i9DQ8QsJqSU9GIw==", + "cpu": [ + "ia32" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@oxlint/binding-win32-x64-msvc": { + "version": "1.71.0", + "resolved": "https://registry.npmjs.org/@oxlint/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.71.0.tgz", + "integrity": "sha512-D2kyEIPHk/G/wiZLnwTVC/sVst+T/lKldVOjAFpgTIBUAOlry72e5OiapDbDBF4LfJLkN5ypJb/8Eu6yJzkveQ==", + "cpu": [ + "x64" + ], + "dev": true, + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.1.3.tgz", + "integrity": "sha512-DT6Z3PhvioeHMvxo+xHc3KtqggrI7CCTXCmC2h/5zUlp5jVitv7XEy+9q5/7v8IolhlioawpMo8Kg0EEBy7J0g==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.1.3.tgz", + "integrity": "sha512-0NwgwsjM7LrsuVnXMK3koTpagBNOhloc/BNjKqZjv4V5zI5r13qx69uVhRx+o5Z0yy4Hzq+lpy7TAgUG/ocvrw==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.1.3.tgz", + "integrity": "sha512-YtiBp4disu6V560loT6PjMdiRaWmVvDNrUunAalbiFx2ggeJwxdAsgZMcoGP17uyAsTwAj5V1niksxlHnVQ1Sw==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.1.3.tgz", + "integrity": "sha512-yD3EkEdXk2LypPxnf/kSZHirarsI8gcPzc62SukhR9VJTyvV+F9Q/GxWNuCojc7sXyuVC4DxRGhdDK4X8VSsbw==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.1.3.tgz", + "integrity": "sha512-c+8vieQbsD7HNAHKIA34w0GJ9FedFFuJGD+7E6vz7Q3uqAIugL5p45fhlsj4UaAsHpcmlqugBWMhA0/j7o0sIg==", + "cpu": [ + "arm" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.1.3.tgz", + "integrity": "sha512-50jD0uUwLvur7Zz9LHz17kaAdTPjn5wN93hEgjvmYFRZwiR7ZJYovTd5ipyWJDAnXKvZ+wgc+/Ika6dwSF5OcA==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.1.3.tgz", + "integrity": "sha512-BO9+oPL8K9poZJBfYPsXNtYjPE5uM3qeehT3aFcW4LITOl+iSqhp0abzjR2nWBUNjIZeKXjAEWBZ64WjNoHd6w==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.1.3.tgz", + "integrity": "sha512-f3VpLB1vQ0Eo6ecr/6cekLnvYMFF4YBFoVGkfkvPLq1bAkbAwHYQPZKoAmG6OJyTcxxoC+AvezGx/S1obNC0Mw==", + "cpu": [ + "ppc64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.1.3.tgz", + "integrity": "sha512-AmurZ26Pqx/RI9N1gzEOCklkKXl927yjfXWUUS0O7Puh8ARM/Ob8qfrD3qnWksScdw6cSrW5PSHE9DyLu7+PtA==", + "cpu": [ + "s390x" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.1.3.tgz", + "integrity": "sha512-JJpqs8bRGITDOdbkNKnlojzBabbOHrqjSvDr0IVsZObE1lBcPjxItUEY9eWIDbxaJ3cGrXPWGfGkIxFijg/URg==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.1.3.tgz", + "integrity": "sha512-rSJcdjPxzA/by/6/rYs+v+bXU7UjvnbUWz8MJb6kh6+knqB1dCrtHg0uu7C/4haqJvqdkYHQ5IGn+tCH9GLW/g==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.1.3.tgz", + "integrity": "sha512-hQ3/PYkDJICgevvyNcVrihVeqq7k1Pp3VZ9lY+dauAYUJKO+auqApvANhvR1An9BhmqYKvW2Mu1F9u4DXSMLxQ==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.1.3.tgz", + "integrity": "sha512-Elcv/BtML9lXrV6JuKITc/grN2kYV9gjsQpW8Jfw4ioK0TOkjBjye0nnyqQNy9STNaI20lXNaQBRrD5gSgR0Yg==", + "cpu": [ + "wasm32" + ], + "optional": true, + "dependencies": { + "@emnapi/core": "1.11.1", + "@emnapi/runtime": "1.11.1", + "@napi-rs/wasm-runtime": "^1.1.6" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.1.3.tgz", + "integrity": "sha512-2DrEfhluH9yhiaFApmsjsjwrSYbNcY1oFTzYSP1a535jDbV98zCFanA/96TBUd0iDFcxGmw9QRExwGCXz3U+/g==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.1.3.tgz", + "integrity": "sha512-OL4OMk7UPXOeVGGd3qo5zJyPIljf4AFgk5QAkPPS+OoLuOOozhuaQGC18MxVTnw/06q93gShAJzlwnSCY9YtqA==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==" + }, + "node_modules/@sentry/browser": { + "version": "10.64.0", + "resolved": "https://registry.npmjs.org/@sentry/browser/-/browser-10.64.0.tgz", + "integrity": "sha512-CHZ7+79evh8knBtVHjW/XqV3mRaWs2TdjX7i55zpFe9vcNngQHV++0ss8ird/xfMY1mfCDtbyAN+Z6XY2o5aJA==", + "dependencies": { + "@sentry/browser-utils": "10.64.0", + "@sentry/core": "10.64.0", + "@sentry/feedback": "10.64.0", + "@sentry/replay": "10.64.0", + "@sentry/replay-canvas": "10.64.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@sentry/browser-utils": { + "version": "10.64.0", + "resolved": "https://registry.npmjs.org/@sentry/browser-utils/-/browser-utils-10.64.0.tgz", + "integrity": "sha512-qXvUpsZdE0+6SovhDZvjN4JkqQEpzeYsrOF5g63wMsqJWWv2vW/pQZIlJs0F6C0t4q5AHZL4fl5rNkvpVqFdKA==", + "dependencies": { + "@sentry/core": "10.64.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@sentry/conventions": { + "version": "0.15.1", + "resolved": "https://registry.npmjs.org/@sentry/conventions/-/conventions-0.15.1.tgz", + "integrity": "sha512-ZLP8bRdMON3prWE2tJyImuYscCxdcJeIPIhrOs/rgyFm3C1nCh1B6gdvPj3AZ5zW08oSFFCsq7T+tYEW3h8MNA==", + "engines": { + "node": ">=14" + } + }, + "node_modules/@sentry/core": { + "version": "10.64.0", + "resolved": "https://registry.npmjs.org/@sentry/core/-/core-10.64.0.tgz", + "integrity": "sha512-HjojJcXD1l2qZ1AXje2s0XY/nYsaUt00wsM1HMBImA8vAClyPisFE/CC0/UD6pEvsGFhVgi8Dcxo7EN41uyeFw==", + "dependencies": { + "@sentry/conventions": "^0.15.1" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@sentry/feedback": { + "version": "10.64.0", + "resolved": "https://registry.npmjs.org/@sentry/feedback/-/feedback-10.64.0.tgz", + "integrity": "sha512-YrmKwRoAto+nwo26DoAhnpNmhkrVkuQFIAXqlTD8ipERjhcSNUYJAkAB+nH4w1KIgm9QQZE1sTq3iNQdPl1XMQ==", + "dependencies": { + "@sentry/core": "10.64.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@sentry/react": { + "version": "10.64.0", + "resolved": "https://registry.npmjs.org/@sentry/react/-/react-10.64.0.tgz", + "integrity": "sha512-oShEKcosBKdm0kgan8suFb6Jj8poccEyDz2qiflonq/5/hUDyzdVeKfFzf0b1MmdjWSn9k3hfrLNFfPNCuEt7Q==", + "dependencies": { + "@sentry/browser": "10.64.0", + "@sentry/core": "10.64.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "react": "^16.14.0 || 17.x || 18.x || 19.x" + } + }, + "node_modules/@sentry/replay": { + "version": "10.64.0", + "resolved": "https://registry.npmjs.org/@sentry/replay/-/replay-10.64.0.tgz", + "integrity": "sha512-q8nke2GDSwEiu1zYoctDDvnSNTjHWoVAKo2JXTleD0nwOO6IlAgUYmsm3qEQiSW7BVla9W1TRbW6ChFAUXYZbw==", + "dependencies": { + "@sentry/browser-utils": "10.64.0", + "@sentry/core": "10.64.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@sentry/replay-canvas": { + "version": "10.64.0", + "resolved": "https://registry.npmjs.org/@sentry/replay-canvas/-/replay-canvas-10.64.0.tgz", + "integrity": "sha512-EioBwcnnhtPiXzTZJTbZKaMEg3qNM5YZlX1VanoXomPATqfJD62cb/M27zhgiWXXJ7e8/NGVHvwNDYGrqsN39g==", + "dependencies": { + "@sentry/core": "10.64.0", + "@sentry/replay": "10.64.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true + }, + "node_modules/@tailwindcss/node": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.1.tgz", + "integrity": "sha512-6NDaqRoAMSXD1mr/RXu0HBvNE9a2n5tHPsxu9XHLws8o4Twes5rBM2205SUUiJ9goAtadrN6xTGX0UDEwp/N4A==", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "enhanced-resolve": "5.21.6", + "jiti": "^2.7.0", + "lightningcss": "1.32.0", + "magic-string": "^0.30.21", + "source-map-js": "^1.2.1", + "tailwindcss": "4.3.1" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.1.tgz", + "integrity": "sha512-yVPyo8RNkabVr3O2EhHEE0Rewu7YKzc1DhIqfL46LKveFrmu9XbDazNOJY7/GRuvw1h6u3utWnR29H/p5JPlgA==", + "engines": { + "node": ">= 20" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.3.1", + "@tailwindcss/oxide-darwin-arm64": "4.3.1", + "@tailwindcss/oxide-darwin-x64": "4.3.1", + "@tailwindcss/oxide-freebsd-x64": "4.3.1", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.1", + "@tailwindcss/oxide-linux-arm64-gnu": "4.3.1", + "@tailwindcss/oxide-linux-arm64-musl": "4.3.1", + "@tailwindcss/oxide-linux-x64-gnu": "4.3.1", + "@tailwindcss/oxide-linux-x64-musl": "4.3.1", + "@tailwindcss/oxide-wasm32-wasi": "4.3.1", + "@tailwindcss/oxide-win32-arm64-msvc": "4.3.1", + "@tailwindcss/oxide-win32-x64-msvc": "4.3.1" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.1.tgz", + "integrity": "sha512-SVlyf61g374l5cHyg8x9kf5xmLcOaxvOTsbsqDnSsDJaKOEFZ7GCvi84VAVGpxojYOs1+3K6M0UjXfqPU8vmOQ==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.1.tgz", + "integrity": "sha512-hVnWLwv+e/l7c4WKyVtHVrIPvYdqWHjRB3MDIqARynzFtnQg85kmQEFCbV9Ja0VVx4xXTIiDWY60Y7iz/iNoDA==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.1.tgz", + "integrity": "sha512-Cf7abu0WVgbhU7ANgPUnSAvm7nCvMweusHb8FnaHlLfv/Caq4GYaEZg7ZImzzmjx4lIAfuS8q+eLIS7A7IzxIg==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.1.tgz", + "integrity": "sha512-ZZqzX2Y+GXtXXfqSfpJhDm60OoZfvLHLCgm+J7NVqgHHJjG/m9ugZI77RwTsVd4fnBJuCFP6Ae6kTJb71UdS8g==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.1.tgz", + "integrity": "sha512-/Ah/xik0LaMYfv9DZ0S/t4pBlBNYOcqtRwusjgovHkvT8ixueWCLyJjsaF5kQIckjb4IT8Q6K6p/iPmZMixYgg==", + "cpu": [ + "arm" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.1.tgz", + "integrity": "sha512-gqdFoVJlw444GvpnheZLHmvTzSxI/cOUUh2KSNejQjTcYkW062SVD+En0rUgD+QV91bz1XGIGtt1HJd48xUGbQ==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.1.tgz", + "integrity": "sha512-Bwv9KwOvE0VKa86xPFif9b9c3Y1NxOV1P0gLti/IYaWEsQYZXDlxfGEtA8mdDZ7SG3wyNXAWYT5SIn3giL57oA==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.1.tgz", + "integrity": "sha512-Ymi8O8T15HYQdOUWUtTI6ldN0neHP85FC+Qz32xTcZ7iJXtem/x8ITev0o1e9e5rkqj4lONZfTRLvkmin1+tKg==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.1.tgz", + "integrity": "sha512-M+P/91qJ6uILLw4k2G93GMDRAXj61SMvFQYt39AqvUqYgExXpLL5aepfns7sj4HiAQeolirQF9E0lzRvdf4zPQ==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.1.tgz", + "integrity": "sha512-zsM8uOeqvVGHsAXsJxsT28ttosFahLJKCLOTUBqRAtKnVgGSRitds9T432QiT8b77Yga7JIBkulIRRlJPtYhRA==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], + "optional": true, + "dependencies": { + "@emnapi/core": "^1.10.0", + "@emnapi/runtime": "^1.10.0", + "@emnapi/wasi-threads": "^1.2.1", + "@napi-rs/wasm-runtime": "^1.1.4", + "@tybys/wasm-util": "^0.10.2", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.1.tgz", + "integrity": "sha512-aiNvSq9BsVk8V513lDKlrCFAgf8qBMPZTpgEhInL+NwQqs97mYmupVMrPrgBBSL8Pv/0zXu9MrMF9rMun1ZeNg==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.1.tgz", + "integrity": "sha512-xDEyu1rg290472FEGaKHnzyDyh5QH+AlWvsU5hMoMtPpzmKlRI0jaYKCgSHDYtaQWZOYbMaduSyCwFwY4n1HmA==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/vite": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.3.1.tgz", + "integrity": "sha512-hItDHuIIlEV61R+faXu66s1K36aTurO/Qw0e45Vskz57gXl9pWOT6eg3zmcEui6CZXddbN7zd41bwmvag4JGwQ==", + "dependencies": { + "@tailwindcss/node": "4.3.1", + "@tailwindcss/oxide": "4.3.1", + "tailwindcss": "4.3.1" + }, + "peerDependencies": { + "vite": "^5.2.0 || ^6 || ^7 || ^8" + } + }, + "node_modules/@testing-library/dom": { + "version": "10.4.1", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", + "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", + "dev": true, + "peer": true, + "dependencies": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.3.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "picocolors": "1.1.1", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@testing-library/jest-dom": { + "version": "6.9.1", + "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz", + "integrity": "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==", + "dev": true, + "dependencies": { + "@adobe/css-tools": "^4.4.0", + "aria-query": "^5.0.0", + "css.escape": "^1.5.1", + "dom-accessibility-api": "^0.6.3", + "picocolors": "^1.1.1", + "redent": "^3.0.0" + }, + "engines": { + "node": ">=14", + "npm": ">=6", + "yarn": ">=1" + } + }, + "node_modules/@testing-library/jest-dom/node_modules/dom-accessibility-api": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz", + "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==", + "dev": true + }, + "node_modules/@testing-library/react": { + "version": "16.3.2", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.2.tgz", + "integrity": "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==", + "dev": true, + "dependencies": { + "@babel/runtime": "^7.12.5" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@testing-library/dom": "^10.0.0", + "@types/react": "^18.0.0 || ^19.0.0", + "@types/react-dom": "^18.0.0 || ^19.0.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.3", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.3.tgz", + "integrity": "sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/aria-query": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", + "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", + "dev": true, + "peer": true + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true + }, + "node_modules/@types/jspdf": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@types/jspdf/-/jspdf-2.0.0.tgz", + "integrity": "sha512-oonYDXI4GegGaG7FFVtriJ+Yqlh4YR3L3NVDiwCEBVG7sbya19SoGx4MW4kg1MCMRPgkbbFTck8YKJL8PrkDfA==", + "deprecated": "This is a stub types definition. jspdf provides its own type definitions, so you do not need this installed.", + "dev": true, + "dependencies": { + "jspdf": "*" + } + }, + "node_modules/@types/node": { + "version": "24.13.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.2.tgz", + "integrity": "sha512-fRa09kZTgu8o71KFcDjUFuc7F+dEbZYZmkI0mg5YBTRs0yMKjYHsq/c0urDKeDb+D5qVgXOdFcuu+DZPKOITwA==", + "devOptional": true, + "dependencies": { + "undici-types": "~7.18.0" + } + }, + "node_modules/@types/pako": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/@types/pako/-/pako-2.0.4.tgz", + "integrity": "sha512-VWDCbrLeVXJM9fihYodcLiIv0ku+AlOa/TQ1SvYOaBuyrSKgEcro95LJyIsJ4vSo6BXIxOKxiJAat04CmST9Fw==" + }, + "node_modules/@types/raf": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/@types/raf/-/raf-3.4.3.tgz", + "integrity": "sha512-c4YAvMedbPZ5tEyxzQdMoOhhJ4RD3rngZIdwC2/qDN3d7JpEhB6fiBRKVY1lg5B7Wk+uPBjn5f39j1/2MY1oOw==", + "optional": true + }, + "node_modules/@types/react": { + "version": "19.2.17", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz", + "integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==", + "devOptional": true, + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", + "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", + "dev": true, + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "optional": true + }, + "node_modules/@vitejs/plugin-react": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.3.tgz", + "integrity": "sha512-vmFvco5/QuC2f9Oj+wTk0+9XeDFkHxSamwZKYc7MxYwKICfvUvlMhqKI0VuICPltGqh1neqBKDvO4kes1ya8vg==", + "dev": true, + "dependencies": { + "@rolldown/pluginutils": "^1.0.1" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", + "babel-plugin-react-compiler": "^1.0.0", + "vite": "^8.0.0" + }, + "peerDependenciesMeta": { + "@rolldown/plugin-babel": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + } + } + }, + "node_modules/@vitest/expect": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.9.tgz", + "integrity": "sha512-vl/rYsUKcBr3SnQn166+XR5ZQcgMx3DQhFWdfli/cWpLnLUmbxZvyrJZotLFUryib+LtArYMSTJ5RbQ57ZqrlA==", + "dev": true, + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.9", + "@vitest/utils": "4.1.9", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.9.tgz", + "integrity": "sha512-EVkXzBjrPGM+cK8/ANWgBrkUCfJfb38/EfTSO8h7pWvKkyPkpWxvR7BkD2MyItMF62C97zAEoqdpUixwR/e+Rw==", + "dev": true, + "dependencies": { + "@vitest/spy": "4.1.9", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.9.tgz", + "integrity": "sha512-s0iufns3iIFitdgm+YR7g1whCAaGtXz459VS9/PqyKDEEFgYIhsHOQmXgIgDuYCt7DeQmiZT0Qe2OA2p4ZPu5A==", + "dev": true, + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.9.tgz", + "integrity": "sha512-KXLMDtc7oe70+3mJfGrPUWPesswH+3sTxAMAMl8DG7I8IUQT4XW718dY5ID3vPUcmlu27CcKfY4P3h3I29SLJg==", + "dev": true, + "dependencies": { + "@vitest/utils": "4.1.9", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.9.tgz", + "integrity": "sha512-Jc7RKGNBo8Z28WYIm0Niej4xdSPByRf6mU58VpHQkd6Zh05rlnA+twjbK5HyeIGHxrzsc3mJgS43uM0CZKzaIA==", + "dev": true, + "dependencies": { + "@vitest/pretty-format": "4.1.9", + "@vitest/utils": "4.1.9", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.9.tgz", + "integrity": "sha512-fHpsS6mIi+PiEW+vcRVOMkX1oSaPKne3VOclSFICPcGOmfKgXPU5iAah+wcNcj2xPrCCmfq99IDGf+EojhhvhA==", + "dev": true, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.9.tgz", + "integrity": "sha512-A51o8ymO5PpqlWNnBP9ZHPXDIpuMtTLlGSjN7la4US+LJzoUMyhwjA5QXlm39JexgwHKW4Xjs8Z2d3dLCXOeuA==", + "dev": true, + "dependencies": { + "@vitest/pretty-format": "4.1.9", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "peer": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "peer": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/aria-query": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", + "dev": true, + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "engines": { + "node": ">=12" + } + }, + "node_modules/base64-arraybuffer": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/base64-arraybuffer/-/base64-arraybuffer-1.0.2.tgz", + "integrity": "sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==", + "optional": true, + "engines": { + "node": ">= 0.6.0" + } + }, + "node_modules/bidi-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", + "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", + "dev": true, + "dependencies": { + "require-from-string": "^2.0.2" + } + }, + "node_modules/bmp-js": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/bmp-js/-/bmp-js-0.1.0.tgz", + "integrity": "sha512-vHdS19CnY3hwiNdkaqk93DvjVLfbEcI8mys4UjuWrlX1haDmroo8o4xCzh4wD6DGV6HxRCyauwhHRqMTfERtjw==" + }, + "node_modules/canvg": { + "version": "3.0.11", + "resolved": "https://registry.npmjs.org/canvg/-/canvg-3.0.11.tgz", + "integrity": "sha512-5ON+q7jCTgMp9cjpu4Jo6XbvfYwSB2Ow3kzHKfIyJfaCAOHLbdKPQqGKgfED/R5B+3TFFfe8pegYA+b423SRyA==", + "optional": true, + "dependencies": { + "@babel/runtime": "^7.12.5", + "@types/raf": "^3.4.0", + "core-js": "^3.8.3", + "raf": "^3.4.1", + "regenerator-runtime": "^0.13.7", + "rgbcolor": "^1.0.1", + "stackblur-canvas": "^2.0.0", + "svg-pathdata": "^6.0.3" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true + }, + "node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/core-js": { + "version": "3.49.0", + "resolved": "https://registry.npmjs.org/core-js/-/core-js-3.49.0.tgz", + "integrity": "sha512-es1U2+YTtzpwkxVLwAFdSpaIMyQaq0PBgm3YD1W3Qpsn1NAmO3KSgZfu+oGSWVu6NvLHoHCV/aYcsE5wiB7ALg==", + "hasInstallScript": true, + "optional": true, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/core-js" + } + }, + "node_modules/css-line-break": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/css-line-break/-/css-line-break-2.1.0.tgz", + "integrity": "sha512-FHcKFCZcAha3LwfVBhCQbW2nCNbkZXn7KVUJcsT5/P8YmfsVja0FMPJr0B903j/E69HUphKiV9iQArX8SDYA4w==", + "optional": true, + "dependencies": { + "utrie": "^1.0.2" + } + }, + "node_modules/css-tree": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", + "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", + "dev": true, + "dependencies": { + "mdn-data": "2.27.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, + "node_modules/css.escape": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", + "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==", + "dev": true + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "devOptional": true + }, + "node_modules/data-urls": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", + "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==", + "dev": true, + "dependencies": { + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/data-urls/node_modules/tr46": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "dev": true, + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/data-urls/node_modules/webidl-conversions": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", + "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", + "dev": true, + "engines": { + "node": ">=20" + } + }, + "node_modules/data-urls/node_modules/whatwg-url": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz", + "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==", + "dev": true, + "dependencies": { + "@exodus/bytes": "^1.11.0", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true + }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "engines": { + "node": ">=8" + } + }, + "node_modules/dom-accessibility-api": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", + "dev": true, + "peer": true + }, + "node_modules/dompurify": { + "version": "3.4.11", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.11.tgz", + "integrity": "sha512-zhlUV12GsaRzMsf9q5M254YhA4+VuF0fG+QFqu6aYpoGlKtz+w8//jBcGVYBgQkR5GHjUomejY84AV+/uPbWdw==", + "optional": true, + "optionalDependencies": { + "@types/trusted-types": "^2.0.7" + } + }, + "node_modules/enhanced-resolve": { + "version": "5.21.6", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.21.6.tgz", + "integrity": "sha512-aNnGCvbJ/RIyWo1IuhNdVjnNF+EjH9wpzpNHt+ci/m9He9LJvUN8wrCcXjp9cWsGNAuvSpVFTx/vraAFQ8qGjQ==", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/entities": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz", + "integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==", + "dev": true, + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-module-lexer": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.2.0.tgz", + "integrity": "sha512-3lGxdTXCLfe1MYfTz1y2ksAAUM4NAOP6rPEjxGJVKO7TZ5+tvHCaQWGpC4Y3IXvW3ece0Cz1cIP4FWBxOnGCTQ==", + "dev": true + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/expect-type": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz", + "integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==", + "dev": true, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fast-png": { + "version": "6.4.0", + "resolved": "https://registry.npmjs.org/fast-png/-/fast-png-6.4.0.tgz", + "integrity": "sha512-kAqZq1TlgBjZcLr5mcN6NP5Rv4V2f22z00c3g8vRrwkcqjerx7BEhPbOnWCPqaHUl2XWQBJQvOT/FQhdMT7X/Q==", + "dependencies": { + "@types/pako": "^2.0.3", + "iobuffer": "^5.3.2", + "pako": "^2.1.0" + } + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/fflate": { + "version": "0.8.3", + "resolved": "https://registry.npmjs.org/fflate/-/fflate-0.8.3.tgz", + "integrity": "sha512-tbZNuJrLwGUp3zshBtdy4W+ORxZuIh8a5ilyIEQDC5rY1f3U20JMry0Ll3WBzU58EZKsEuJFXhb5gwv8CsPvgA==" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "hasInstallScript": true, + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==" + }, + "node_modules/html-encoding-sniffer": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", + "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==", + "dev": true, + "dependencies": { + "@exodus/bytes": "^1.6.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/html2canvas": { + "version": "1.4.1", + "resolved": "https://registry.npmjs.org/html2canvas/-/html2canvas-1.4.1.tgz", + "integrity": "sha512-fPU6BHNpsyIhr8yyMpTLLxAbkaK8ArIBcmZIRiBLiDhjeqvXolaEmDGmELFuX9I4xDcaKKcJl+TKZLqruBbmWA==", + "optional": true, + "dependencies": { + "css-line-break": "^2.1.0", + "text-segmentation": "^1.0.3" + }, + "engines": { + "node": ">=8.0.0" + } + }, + "node_modules/idb-keyval": { + "version": "6.2.5", + "resolved": "https://registry.npmjs.org/idb-keyval/-/idb-keyval-6.2.5.tgz", + "integrity": "sha512-eKQkTnS0relYsSOYomx8ozIbmdsQCKUdhyuIaQ2DZgKuaxtyQQMkyD/wlnQN32pO3yutN1b1L8uqwcDKaJd7/Q==" + }, + "node_modules/indent-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", + "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", + "dev": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/iobuffer": { + "version": "5.4.0", + "resolved": "https://registry.npmjs.org/iobuffer/-/iobuffer-5.4.0.tgz", + "integrity": "sha512-DRebOWuqDvxunfkNJAlc3IzWIPD5xVxwUNbHr7xKB8E6aLJxIPfNX3CoMJghcFjpv6RWQsrcJbghtEwSPoJqMA==" + }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true + }, + "node_modules/is-url": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/is-url/-/is-url-1.2.4.tgz", + "integrity": "sha512-ITvGim8FhRiYe4IQ5uHSkj7pVaPDrCTkNd3yq3cV7iZAcJdHTUMPMEHcqSOy9xZ9qFenQCvi+2wjH9a1nXqHww==" + }, + "node_modules/jiti": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", + "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "peer": true + }, + "node_modules/jsdom": { + "version": "29.1.1", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.1.1.tgz", + "integrity": "sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q==", + "dev": true, + "dependencies": { + "@asamuzakjp/css-color": "^5.1.11", + "@asamuzakjp/dom-selector": "^7.1.1", + "@bramus/specificity": "^2.4.2", + "@csstools/css-syntax-patches-for-csstree": "^1.1.3", + "@exodus/bytes": "^1.15.0", + "css-tree": "^3.2.1", + "data-urls": "^7.0.0", + "decimal.js": "^10.6.0", + "html-encoding-sniffer": "^6.0.0", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.3.5", + "parse5": "^8.0.1", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^6.0.1", + "undici": "^7.25.0", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^8.0.1", + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.1", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24.0.0" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/jsdom/node_modules/tr46": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "dev": true, + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/jsdom/node_modules/webidl-conversions": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", + "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", + "dev": true, + "engines": { + "node": ">=20" + } + }, + "node_modules/jsdom/node_modules/whatwg-url": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz", + "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==", + "dev": true, + "dependencies": { + "@exodus/bytes": "^1.11.0", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/jspdf": { + "version": "4.2.1", + "resolved": "https://registry.npmjs.org/jspdf/-/jspdf-4.2.1.tgz", + "integrity": "sha512-YyAXyvnmjTbR4bHQRLzex3CuINCDlQnBqoSYyjJwTP2x9jDLuKDzy7aKUl0hgx3uhcl7xzg32agn5vlie6HIlQ==", + "dependencies": { + "@babel/runtime": "^7.28.6", + "fast-png": "^6.2.0", + "fflate": "^0.8.1" + }, + "optionalDependencies": { + "canvg": "^3.0.11", + "core-js": "^3.6.0", + "dompurify": "^3.3.1", + "html2canvas": "^1.0.0-rc.5" + } + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lru-cache": { + "version": "11.5.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.1.tgz", + "integrity": "sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A==", + "dev": true, + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/lucide-react": { + "version": "1.21.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.21.0.tgz", + "integrity": "sha512-reEZMXq8Qdd5jg5XYkQ5TR1fB/GiQ7ih4vcrthYDtgjSDwh0i6/YLiGjsWsIwgN49gpAnd4J2elSNzncMEEUUQ==", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "dev": true, + "peer": true, + "bin": { + "lz-string": "bin/bin.js" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/mdn-data": { + "version": "2.27.1", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", + "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", + "dev": true + }, + "node_modules/min-indent": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", + "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==", + "dev": true, + "engines": { + "node": ">=4" + } + }, + "node_modules/nanoid": { + "version": "3.3.15", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz", + "integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, + "node_modules/obug": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.3.tgz", + "integrity": "sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/opencollective-postinstall": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/opencollective-postinstall/-/opencollective-postinstall-2.0.3.tgz", + "integrity": "sha512-8AV/sCtuzUeTo8gQK5qDZzARrulB3egtLzFgteqB2tcT4Mw7B8Kt7JcDHmltjz6FOAHsvTevk70gZEbhM4ZS9Q==", + "bin": { + "opencollective-postinstall": "index.js" + } + }, + "node_modules/oxlint": { + "version": "1.71.0", + "resolved": "https://registry.npmjs.org/oxlint/-/oxlint-1.71.0.tgz", + "integrity": "sha512-U1m1X+C0vDj7DC1e13IoZULzEcPczE7UOMTs8VlZGHUEIUaSTZKo5qkPsQEfzpgnQ29Pea/w3Xntk62UCecxZw==", + "dev": true, + "bin": { + "oxlint": "bin/oxlint" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/sponsors/Boshen" + }, + "optionalDependencies": { + "@oxlint/binding-android-arm-eabi": "1.71.0", + "@oxlint/binding-android-arm64": "1.71.0", + "@oxlint/binding-darwin-arm64": "1.71.0", + "@oxlint/binding-darwin-x64": "1.71.0", + "@oxlint/binding-freebsd-x64": "1.71.0", + "@oxlint/binding-linux-arm-gnueabihf": "1.71.0", + "@oxlint/binding-linux-arm-musleabihf": "1.71.0", + "@oxlint/binding-linux-arm64-gnu": "1.71.0", + "@oxlint/binding-linux-arm64-musl": "1.71.0", + "@oxlint/binding-linux-ppc64-gnu": "1.71.0", + "@oxlint/binding-linux-riscv64-gnu": "1.71.0", + "@oxlint/binding-linux-riscv64-musl": "1.71.0", + "@oxlint/binding-linux-s390x-gnu": "1.71.0", + "@oxlint/binding-linux-x64-gnu": "1.71.0", + "@oxlint/binding-linux-x64-musl": "1.71.0", + "@oxlint/binding-openharmony-arm64": "1.71.0", + "@oxlint/binding-win32-arm64-msvc": "1.71.0", + "@oxlint/binding-win32-ia32-msvc": "1.71.0", + "@oxlint/binding-win32-x64-msvc": "1.71.0" + }, + "peerDependencies": { + "oxlint-tsgolint": ">=0.22.1", + "vite-plus": "*" + }, + "peerDependenciesMeta": { + "oxlint-tsgolint": { + "optional": true + }, + "vite-plus": { + "optional": true + } + } + }, + "node_modules/pako": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/pako/-/pako-2.2.0.tgz", + "integrity": "sha512-zJq6RP/5q+TO2OpFV3FHzlPnFjmkb7Nc99a5SNjJE+uu/PkpChs+NIZSSzbBoD+6kjiISXjfYdwj1ZRQ81dz/w==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/puzrin" + }, + { + "type": "github", + "url": "https://github.com/sponsors/nodeca" + } + ] + }, + "node_modules/parse5": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz", + "integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==", + "dev": true, + "dependencies": { + "entities": "^8.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true + }, + "node_modules/pdfjs-dist": { + "version": "6.1.200", + "resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-6.1.200.tgz", + "integrity": "sha512-o8MolyzirkkLrcdsae/HEOiIcXWI7DS5zGpvqW8xTC2YUsW30rltFw2bDGvw/fskUdEMrQm2br68jzDS5BH2vw==", + "engines": { + "node": ">=22.13.0 || >=24" + }, + "optionalDependencies": { + "@napi-rs/canvas": "^1.0.0" + } + }, + "node_modules/performance-now": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/performance-now/-/performance-now-2.1.0.tgz", + "integrity": "sha512-7EAHlyLHI56VEIdK57uwHdHKIaAGbnXPiw0yWbarQZOKaKpvUIgW0jWRVLiatnM+XXlSwsanIBH/hzGMJulMow==", + "optional": true + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/pocketbase": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/pocketbase/-/pocketbase-0.27.0.tgz", + "integrity": "sha512-K5N6d93UP/BNMbMnlZ6BUfy9VPCIvLyqhJFOsNI8OsZwzvKWEAfyD36boi5K4ECIOl5HMlo0TzuaeGdKpMwizQ==" + }, + "node_modules/postcss": { + "version": "8.5.15", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", + "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/pretty-format": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "dev": true, + "peer": true, + "dependencies": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "engines": { + "node": ">=6" + } + }, + "node_modules/raf": { + "version": "3.4.1", + "resolved": "https://registry.npmjs.org/raf/-/raf-3.4.1.tgz", + "integrity": "sha512-Sq4CW4QhwOHE8ucn6J34MqtZCeWFP2aQSmrlroYgqAV1PjStIhJXxYuTgUIfkEk7zTLjmIjLmU5q+fbD1NnOJA==", + "optional": true, + "dependencies": { + "performance-now": "^2.1.0" + } + }, + "node_modules/react": { + "version": "19.2.7", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.7.tgz", + "integrity": "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.7", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.7.tgz", + "integrity": "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.7" + } + }, + "node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true, + "peer": true + }, + "node_modules/react-router": { + "version": "7.18.0", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.0.tgz", + "integrity": "sha512-pTTGt8J+ji1NOmYnjzT+bAJy/1zD+Jp4ziO6cL7T3ZLvXKtusO7BpFqlRXitqpcPVqllsIXFHRMt+2/k3Xn6HQ==", + "dependencies": { + "cookie": "^1.0.1", + "set-cookie-parser": "^2.6.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, + "node_modules/react-router-dom": { + "version": "7.18.0", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.18.0.tgz", + "integrity": "sha512-Fi0yY6kgtKae/Th2xibdWK0KSdYZ4B53Gyf6wRtomOKWgpNm7H7+DyfDhncdz9FKbpS+1jmDhg3F4WoGJ+yFOA==", + "dependencies": { + "react-router": "7.18.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + } + }, + "node_modules/redent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", + "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==", + "dev": true, + "dependencies": { + "indent-string": "^4.0.0", + "strip-indent": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/regenerator-runtime": { + "version": "0.13.11", + "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.11.tgz", + "integrity": "sha512-kY1AZVr2Ra+t+piVaJ4gxaFaReZVH40AKNo7UCX6W+dEwBo/2oZJzqfuN1qLq1oL45o56cPaTXELwrTh8Fpggg==" + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/rgbcolor": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/rgbcolor/-/rgbcolor-1.0.1.tgz", + "integrity": "sha512-9aZLIrhRaD97sgVhtJOW6ckOEh6/GnvQtdVNfdZ6s67+3/XwLS9lBcQYzEEhYVeUowN7pRzMLsyGhK2i/xvWbw==", + "optional": true, + "engines": { + "node": ">= 0.8.15" + } + }, + "node_modules/rolldown": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.1.3.tgz", + "integrity": "sha512-1F1eEtUBtFvcGm1HQ9TiUIUHPQG7mSAODrhIzjxoUEFuo8OcbrGLiVLkevNgj84TE4lnHvnumwFjhJO5Eu135g==", + "dependencies": { + "@oxc-project/types": "=0.137.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.1.3", + "@rolldown/binding-darwin-arm64": "1.1.3", + "@rolldown/binding-darwin-x64": "1.1.3", + "@rolldown/binding-freebsd-x64": "1.1.3", + "@rolldown/binding-linux-arm-gnueabihf": "1.1.3", + "@rolldown/binding-linux-arm64-gnu": "1.1.3", + "@rolldown/binding-linux-arm64-musl": "1.1.3", + "@rolldown/binding-linux-ppc64-gnu": "1.1.3", + "@rolldown/binding-linux-s390x-gnu": "1.1.3", + "@rolldown/binding-linux-x64-gnu": "1.1.3", + "@rolldown/binding-linux-x64-musl": "1.1.3", + "@rolldown/binding-openharmony-arm64": "1.1.3", + "@rolldown/binding-wasm32-wasi": "1.1.3", + "@rolldown/binding-win32-arm64-msvc": "1.1.3", + "@rolldown/binding-win32-x64-msvc": "1.1.3" + } + }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==" + }, + "node_modules/set-cookie-parser": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", + "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==" + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true + }, + "node_modules/stackblur-canvas": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/stackblur-canvas/-/stackblur-canvas-2.7.0.tgz", + "integrity": "sha512-yf7OENo23AGJhBriGx0QivY5JP6Y1HbrrDI6WLt6C5auYZXlQrheoY8hD4ibekFKz1HOfE48Ww8kMWMnJD/zcQ==", + "optional": true, + "engines": { + "node": ">=0.1.14" + } + }, + "node_modules/std-env": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.1.0.tgz", + "integrity": "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==", + "dev": true + }, + "node_modules/strip-indent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", + "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==", + "dev": true, + "dependencies": { + "min-indent": "^1.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/svg-pathdata": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/svg-pathdata/-/svg-pathdata-6.0.3.tgz", + "integrity": "sha512-qsjeeq5YjBZ5eMdFuUa4ZosMLxgr5RZ+F+Y1OrDhuOCEInRMA3x74XdBtggJcj9kOeInz0WE+LgCPDkZFlBYJw==", + "optional": true, + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true + }, + "node_modules/tailwindcss": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.1.tgz", + "integrity": "sha512-hk+TB1m+K8CYNrP6rjQaq/Y+4Zylwpa87mLYBKCunwnnQ9p+fHb7kmSfGqyEJoxF/O6CDyABWVFEafNSYKll+Q==" + }, + "node_modules/tapable": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz", + "integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/tesseract.js": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/tesseract.js/-/tesseract.js-7.0.0.tgz", + "integrity": "sha512-exPBkd+z+wM1BuMkx/Bjv43OeLBxhL5kKWsz/9JY+DXcXdiBjiAch0V49QR3oAJqCaL5qURE0vx9Eo+G5YE7mA==", + "hasInstallScript": true, + "dependencies": { + "bmp-js": "^0.1.0", + "idb-keyval": "^6.2.0", + "is-url": "^1.2.4", + "node-fetch": "^2.6.9", + "opencollective-postinstall": "^2.0.3", + "regenerator-runtime": "^0.13.3", + "tesseract.js-core": "^7.0.0", + "wasm-feature-detect": "^1.8.0", + "zlibjs": "^0.3.1" + } + }, + "node_modules/tesseract.js-core": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/tesseract.js-core/-/tesseract.js-core-7.0.0.tgz", + "integrity": "sha512-WnNH518NzmbSq9zgTPeoF8c+xmilS8rFIl1YKbk/ptuuc7p6cLNELNuPAzcmsYw450ca6bLa8j3t0VAtq435Vw==" + }, + "node_modules/text-segmentation": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/text-segmentation/-/text-segmentation-1.0.3.tgz", + "integrity": "sha512-iOiPUo/BGnZ6+54OsWxZidGCsdU8YbE4PSpdPinp7DeMtUJNJBoJ/ouUSTJjHkh1KntHaltHl/gDs2FC4i5+Nw==", + "optional": true, + "dependencies": { + "utrie": "^1.0.2" + } + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true + }, + "node_modules/tinyexec": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.4.tgz", + "integrity": "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==", + "dev": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyrainbow": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", + "dev": true, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tldts": { + "version": "7.4.5", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.5.tgz", + "integrity": "sha512-RfEzKWcq5fHUOFq7J3rl3Oz6ylKGtcHqUznzj4EcXsxLSIjJcvpbXAQtWGeJQ0xKnimR5e0Cn+cn9TssfMzm+g==", + "dev": true, + "dependencies": { + "tldts-core": "^7.4.5" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "7.4.5", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.5.tgz", + "integrity": "sha512-pGrwzZDvPwKe+7NNUqAunb6rqTfynr0VOUhCMdqbu5xlvNiszsAJygRzwvpVycdzejlbpY+SWJOn+s75Og7FEA==", + "dev": true + }, + "node_modules/tough-cookie": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.1.tgz", + "integrity": "sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==", + "dev": true, + "dependencies": { + "tldts": "^7.0.5" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==" + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "optional": true + }, + "node_modules/typescript": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", + "dev": true, + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/undici": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.28.0.tgz", + "integrity": "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==", + "dev": true, + "engines": { + "node": ">=20.18.1" + } + }, + "node_modules/undici-types": { + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", + "devOptional": true + }, + "node_modules/utrie": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/utrie/-/utrie-1.0.2.tgz", + "integrity": "sha512-1MLa5ouZiOmQzUbjbu9VmjLzn1QLXBhwpUa7kdLUQK+KQ5KA9I1vk5U4YHe/X2Ch7PYnJfWuWT+VbuxbGwljhw==", + "optional": true, + "dependencies": { + "base64-arraybuffer": "^1.0.2" + } + }, + "node_modules/vite": { + "version": "8.1.0", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.0.tgz", + "integrity": "sha512-BuJcQK/56NQTWDGn4ABea3q4SSBdNPWwNZKTkkUpcMPnLoquSYH8llRtSUIgoL1KSCpHt5eghLShn50mH36y7Q==", + "dependencies": { + "lightningcss": "^1.32.0", + "picomatch": "^4.0.4", + "postcss": "^8.5.15", + "rolldown": "~1.1.2", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.3.0", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vitest": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.9.tgz", + "integrity": "sha512-nE3/LEyc0z87uHYLZebqCUOaJr2hdtuPp7BQ4BosVFnfltxgAvMG08NyrSGlPpOUWvR27c5flSmYFTNr78L9GQ==", + "dev": true, + "dependencies": { + "@vitest/expect": "4.1.9", + "@vitest/mocker": "4.1.9", + "@vitest/pretty-format": "4.1.9", + "@vitest/runner": "4.1.9", + "@vitest/snapshot": "4.1.9", + "@vitest/spy": "4.1.9", + "@vitest/utils": "4.1.9", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.9", + "@vitest/browser-preview": "4.1.9", + "@vitest/browser-webdriverio": "4.1.9", + "@vitest/coverage-istanbul": "4.1.9", + "@vitest/coverage-v8": "4.1.9", + "@vitest/ui": "4.1.9", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/wasm-feature-detect": { + "version": "1.8.0", + "resolved": "https://registry.npmjs.org/wasm-feature-detect/-/wasm-feature-detect-1.8.0.tgz", + "integrity": "sha512-zksaLKM2fVlnB5jQQDqKXXwYHLQUVH9es+5TOOHwGOVJOCeRBCiPjwSg+3tN2AdTCzjgli4jijCH290kXb/zWQ==" + }, + "node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==" + }, + "node_modules/whatwg-mimetype": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", + "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", + "dev": true, + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true + }, + "node_modules/zlibjs": { + "version": "0.3.1", + "resolved": "https://registry.npmjs.org/zlibjs/-/zlibjs-0.3.1.tgz", + "integrity": "sha512-+J9RrgTKOmlxFSDHo0pI1xM6BLVUv+o0ZT9ANtCxGkjIVCCUdx9alUF8Gm+dGLKbkkkidWIHFDZHDMpfITt4+w==", + "engines": { + "node": "*" + } + }, + "node_modules/zod": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", + "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zustand": { + "version": "5.0.14", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.14.tgz", + "integrity": "sha512-/8tAspM5LMPr28b3fwLYrtdj77ECpfZviaP75CMTnwO8ISyaE4GDIG/9rDDYq/cH9D2Xw2A2RXglLInmVBQB/g==", + "engines": { + "node": ">=12.20.0" + }, + "peerDependencies": { + "@types/react": ">=18.0.0", + "immer": ">=9.0.6", + "react": ">=18.0.0", + "use-sync-external-store": ">=1.2.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + }, + "use-sync-external-store": { + "optional": true + } + } + } + } +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..31e0b66 --- /dev/null +++ b/package.json @@ -0,0 +1,43 @@ +{ + "name": "spq-v2", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "lint": "oxlint", + "preview": "vite preview", + "test": "vitest run", + "test:watch": "vitest" + }, + "dependencies": { + "@sentry/react": "^10.64.0", + "@tailwindcss/vite": "^4.3.1", + "jspdf": "^4.2.1", + "lucide-react": "^1.21.0", + "pdfjs-dist": "^6.1.200", + "pocketbase": "^0.27.0", + "react": "^19.2.7", + "react-dom": "^19.2.7", + "react-router-dom": "^7.18.0", + "tailwindcss": "^4.3.1", + "tesseract.js": "^7.0.0", + "zod": "^4.4.3", + "zustand": "^5.0.14" + }, + "devDependencies": { + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^16.3.2", + "@types/jspdf": "^2.0.0", + "@types/node": "^24.13.2", + "@types/react": "^19.2.17", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^6.0.2", + "jsdom": "^29.1.1", + "oxlint": "^1.69.0", + "typescript": "~6.0.2", + "vite": "^8.1.0", + "vitest": "^4.1.9" + } +} diff --git a/pb_migrations/1739999000001_add_customerType_to_repairOrders.js b/pb_migrations/1739999000001_add_customerType_to_repairOrders.js new file mode 100644 index 0000000..7af6da3 --- /dev/null +++ b/pb_migrations/1739999000001_add_customerType_to_repairOrders.js @@ -0,0 +1,65 @@ +/// +// +// M1 — Add dedicated `customerType` column to `repairOrders`. +// +// Previously `customerType` lived only inside the stringified `financial` JSON +// blob, which broke whenever the blob was corrupt or missing. This migration +// promotes it to a real text column and backfills values from the blob. +// +// Run: ./pocketbase migrate (from your PocketBase install) +// +migrate( + (db) => { + const col = db.findCollectionByNameOrId('repairOrders'); + if (!col) { + console.warn('[M1] repairOrders collection not found — skipping'); + return; + } + + // Add the field if it doesn't already exist. + if (!col.fields.find((f) => f.name === 'customerType')) { + const field = new TextField({ + name: 'customerType', + required: false, + // Constrain to the two valid values; PB validates via pattern. + // Using a generic text field with pattern keeps it permissive during + // backfill (allow any value), then we recommend tightening later. + }); + col.fields.push(field); + db.save(col); + console.log('[M1] added customerType field'); + } + + // Backfill from the existing `financial` JSON blob. + const records = Array.from(db.findRecordsByFilter( + "repairOrders", + "1=1", + "id", + 500 + )); + for (const rec of records) { + let fin = {}; + try { + const raw = rec.get('financial'); + if (typeof raw === 'string' && raw.trim()) { + fin = JSON.parse(raw); + } else if (raw && typeof raw === 'object') { + fin = raw; + } + } catch { /* ignore corrupt blobs */ } + const ct = (fin && fin.customerType === 'waiter') ? 'waiter' : 'drop-off'; + rec.set('customerType', ct); + db.saveRecord(rec); + } + console.log(`[M1] backfilled ${records.length} records`); + }, + (db) => { + const col = db.findCollectionByNameOrId('repairOrders'); + if (!col) return; + const idx = col.fields.findIndex((f) => f.name === 'customerType'); + if (idx >= 0) { + col.fields.splice(idx, 1); + db.save(col); + } + } +); \ No newline at end of file diff --git a/pb_migrations/1739999000002_add_estimatedDuration_to_repairOrders.js b/pb_migrations/1739999000002_add_estimatedDuration_to_repairOrders.js new file mode 100644 index 0000000..69c1df7 --- /dev/null +++ b/pb_migrations/1739999000002_add_estimatedDuration_to_repairOrders.js @@ -0,0 +1,61 @@ +/// +// +// M2 — Add integer-minutes `estimatedDuration` column to `repairOrders`. +// +// Today the duration is stored as `estimatedTime` (a HOURS STRING such as +// "0.3"), converted back to minutes via Math.round(hours*60) on read. That +// round-trip is lossy: 15 min → "0.3" → 18 min, drifting the due time every +// reload. This migration adds a numeric `estimatedDuration` (integer minutes) +// column and backfills it from `estimatedTime`. +// +// The frontend still writes BOTH columns after this migration (for backward +// compat with older clients), but reads prefer `estimatedDuration` when +// present. A follow-up cleanup can drop the `estimatedTime` writes once all +// clients are upgraded. +// +migrate( + (db) => { + const col = db.findCollectionByNameOrId('repairOrders'); + if (!col) { + console.warn('[M2] repairOrders collection not found — skipping'); + return; + } + + if (!col.fields.find((f) => f.name === 'estimatedDuration')) { + const field = new NumberField({ + name: 'estimatedDuration', + required: false, + min: 0, + }); + col.fields.push(field); + db.save(col); + console.log('[M2] added estimatedDuration field'); + } + + const records = Array.from(db.findRecordsByFilter( + "repairOrders", + "1=1", + "id", + 500 + )); + let backfilled = 0; + for (const rec of records) { + if (rec.get('estimatedDuration') != null) continue; // don't overwrite + const raw = rec.get('estimatedTime'); + const hours = parseFloat(String(raw || '0')) || 0; + rec.set('estimatedDuration', Math.round(hours * 60)); + db.saveRecord(rec); + backfilled++; + } + console.log(`[M2] backfilled ${backfilled} records`); + }, + (db) => { + const col = db.findCollectionByNameOrId('repairOrders'); + if (!col) return; + const idx = col.fields.findIndex((f) => f.name === 'estimatedDuration'); + if (idx >= 0) { + col.fields.splice(idx, 1); + db.save(col); + } + } +); \ No newline at end of file diff --git a/pb_migrations/1739999000003_promote_financial_fields.js b/pb_migrations/1739999000003_promote_financial_fields.js new file mode 100644 index 0000000..e85b3c0 --- /dev/null +++ b/pb_migrations/1739999000003_promote_financial_fields.js @@ -0,0 +1,79 @@ +/// +// +// M3 — Promote financial fields out of the `financial` JSON blob into real +// numeric columns on `repairOrders`. This lets the FinancialDashboard run +// server-side `sum()` aggregations instead of pulling every completed RO to +// the client. It also makes the values type-safe and queryable. +// +// New columns: grossTotal, grossCost, warrTotal, warrCost, shopCharge. +// (shopCharge overlaps conceptually with the existing `shopCharges` column — +// see the migration doc. Here we add `shopCharge` as a separate field to +// match the financial-blob key the frontend currently writes. If you prefer +// to consolidate, point the frontend at `shopCharges` instead and skip this +// field.) +// +migrate( + (db) => { + const col = db.findCollectionByNameOrId('repairOrders'); + if (!col) { + console.warn('[M3] repairOrders collection not found — skipping'); + return; + } + + for (const name of ['grossTotal', 'grossCost', 'warrTotal', 'warrCost', 'shopCharge']) { + if (!col.fields.find((f) => f.name === name)) { + col.fields.push(new NumberField({ name, required: false, min: 0 })); + } + } + db.save(col); + console.log('[M3] added financial numeric fields'); + + const records = Array.from(db.findRecordsByFilter( + "repairOrders", + "1=1", + "id", + 500 + )); + let backfilled = 0; + for (const rec of records) { + let fin = {}; + try { + const raw = rec.get('financial'); + if (typeof raw === 'string' && raw.trim()) { + fin = JSON.parse(raw); + } else if (raw && typeof raw === 'object') { + fin = raw; + } + } catch { /* ignore corrupt blobs */ } + + let changed = false; + for (const [srcKey, dstKey] of [ + ['grossTotal', 'grossTotal'], + ['grossCost', 'grossCost'], + ['warrTotal', 'warrTotal'], + ['warrCost', 'warrCost'], + ['shopCharge', 'shopCharge'], + ]) { + const v = Number(fin && fin[srcKey]); + if (!Number.isNaN(v) && rec.get(dstKey) == null) { + rec.set(dstKey, v || 0); + changed = true; + } + } + if (changed) { + db.saveRecord(rec); + backfilled++; + } + } + console.log(`[M3] backfilled ${backfilled} records`); + }, + (db) => { + const col = db.findCollectionByNameOrId('repairOrders'); + if (!col) return; + for (const name of ['grossTotal', 'grossCost', 'warrTotal', 'warrCost', 'shopCharge']) { + const idx = col.fields.findIndex((f) => f.name === name); + if (idx >= 0) col.fields.splice(idx, 1); + } + db.save(col); + } +); \ No newline at end of file diff --git a/pb_migrations/1739999000004_user_scoped_api_rules.js b/pb_migrations/1739999000004_user_scoped_api_rules.js new file mode 100644 index 0000000..51b77ed --- /dev/null +++ b/pb_migrations/1739999000004_user_scoped_api_rules.js @@ -0,0 +1,65 @@ +/// +// +// M4 — API Rules for all user-scoped collections. +// +// CRITICAL SECURITY: without these rules, any logged-in user can read or +// overwrite every other shop's records by passing a different `userId` in +// the request body. The frontend cannot enforce this — it must be locked +// down at the DB layer. +// +// This migration sets List/View/Create/Update/Delete rules on each +// user-owned collection to: userId = @request.auth.id +// +// Notes: +// - `users` admin-only access is NOT configured here (do that in the PB +// admin UI under the users collection). This migration only touches the +// per-shop data collections. +// - `settings` is treated as user-scoped via its `userId` field (the +// frontend stores a `businessSettings` record per user). +// - Re-run is idempotent — applying the same rule string twice is safe. +// +const USER_SCOPED_COLLECTIONS = [ + 'quotes', + 'repairOrders', + 'invoices', + 'appointments', + 'customers', + 'services', + 'settings', +]; + +const RULE = 'userId = @request.auth.id'; + +migrate( + (db) => { + for (const name of USER_SCOPED_COLLECTIONS) { + const col = db.findCollectionByNameOrId(name); + if (!col) { + console.warn(`[M4] collection "${name}" not found — skipping (create it first in admin)`); + continue; + } + col.listRule = RULE; + col.viewRule = RULE; + col.createRule = RULE; + col.updateRule = RULE; + col.deleteRule = RULE; + db.save(col); + console.log(`[M4] applied user-scoped rules to "${name}"`); + } + }, + (db) => { + // On rollback: clear the rules back to empty (full access as admin). + // WARNING: this re-opens cross-tenant access — only run the down-migration + // if you intend to reconfigure manually afterward. + for (const name of USER_SCOPED_COLLECTIONS) { + const col = db.findCollectionByNameOrId(name); + if (!col) continue; + col.listRule = ''; + col.viewRule = ''; + col.createRule = ''; + col.updateRule = ''; + col.deleteRule = ''; + db.save(col); + } + } +); \ No newline at end of file diff --git a/pb_migrations/1739999000005_unique_roNumber.js b/pb_migrations/1739999000005_unique_roNumber.js new file mode 100644 index 0000000..7ea2cd8 --- /dev/null +++ b/pb_migrations/1739999000005_unique_roNumber.js @@ -0,0 +1,58 @@ +/// +// +// M5 — Unique index on `repairOrders.roNumber`. +// +// The frontend generates RO numbers with Math.random() and now guards against +// collisions against the loaded set (see src/pages/RepairOrders.tsx, +// uniqueRONumber()). But two advisors creating ROs in the same second can +// still race past the frontend check. This migration adds a server-side +// unique index so the second write fails at the DB layer instead of silently +// producing two ROs with the same number. +// +// If your existing data has duplicate roNumbers, this migration will fail — +// dedupe in the admin UI first (find records where roNumber = '', set them to +// unique placeholder values, then retry). +// +migrate( + (db) => { + const col = db.findCollectionByNameOrId('repairOrders'); + if (!col) { + console.warn('[M5] repairOrders collection not found — skipping'); + return; + } + + // PocketBase 0.20+ uses the `Index` helper on a field. Add a unique + // index on roNumber if one isn't already present. Using a TextField + // option `unique` is the simplest cross-version approach. + let changed = false; + const existing = col.fields.find((f) => f.name === 'roNumber'); + if (existing && existing.type === 'text' && !existing.options?.unique) { + existing.options = { ...(existing.options || {}), unique: true }; + changed = true; + } else if (!existing) { + // Field missing entirely — add it with unique. + col.fields.push(new TextField({ + name: 'roNumber', + required: false, + options: { unique: true }, + })); + changed = true; + } + + if (changed) { + db.save(col); + console.log('[M5] set roNumber unique=true on repairOrders'); + } else { + console.log('[M5] roNumber already unique — no change'); + } + }, + (db) => { + const col = db.findCollectionByNameOrId('repairOrders'); + if (!col) return; + const f = col.fields.find((f) => f.name === 'roNumber'); + if (f && f.type === 'text' && f.options?.unique) { + f.options.unique = false; + db.save(col); + } + } +); \ No newline at end of file diff --git a/pb_migrations/1739999000006_quote_ro_link.js b/pb_migrations/1739999000006_quote_ro_link.js new file mode 100644 index 0000000..91cbc39 --- /dev/null +++ b/pb_migrations/1739999000006_quote_ro_link.js @@ -0,0 +1,54 @@ +/// +// +// M6 — Add a Quote ↔ Repair Order link. +// +// `quotes.repairOrderId` — set when a quote is generated from an existing RO +// (so you can jump back to the originating RO). +// `repairOrders.quoteId` — set when an RO is created by "converting" an +// approved quote (so you can jump back to the source +// quote). Also lets the dashboard report "converted +// quotes" as a metric. +// +// Both are nullable text columns (PocketBase relation fields would lock you +// into one record type; plain text keeps the link loose and survives renames +// of the related collection). The frontend treats empty/null as "no link". +// +migrate( + (db) => { + // quotes.repairOrderId + const quotes = db.findCollectionByNameOrId('quotes'); + if (quotes) { + if (!quotes.fields.find((f) => f.name === 'repairOrderId')) { + quotes.fields.push(new TextField({ name: 'repairOrderId', required: false })); + db.save(quotes); + console.log('[M6] added repairOrderId to quotes'); + } + } else { + console.warn('[M6] quotes collection not found — skipping'); + } + + // repairOrders.quoteId + const ros = db.findCollectionByNameOrId('repairOrders'); + if (ros) { + if (!ros.fields.find((f) => f.name === 'quoteId')) { + ros.fields.push(new TextField({ name: 'quoteId', required: false })); + db.save(ros); + console.log('[M6] added quoteId to repairOrders'); + } + } else { + console.warn('[M6] repairOrders collection not found — skipping'); + } + }, + (db) => { + const quotes = db.findCollectionByNameOrId('quotes'); + if (quotes) { + const i1 = quotes.fields.findIndex((f) => f.name === 'repairOrderId'); + if (i1 >= 0) { quotes.fields.splice(i1, 1); db.save(quotes); } + } + const ros = db.findCollectionByNameOrId('repairOrders'); + if (ros) { + const i2 = ros.fields.findIndex((f) => f.name === 'quoteId'); + if (i2 >= 0) { ros.fields.splice(i2, 1); db.save(ros); } + } + } +); \ No newline at end of file diff --git a/pb_migrations/1739999000007_add_appointment_vin_duration.js b/pb_migrations/1739999000007_add_appointment_vin_duration.js new file mode 100644 index 0000000..56593d6 --- /dev/null +++ b/pb_migrations/1739999000007_add_appointment_vin_duration.js @@ -0,0 +1,98 @@ +/// +// +// M7 — Add structured VIN and duration fields to appointments. +// +// Older scan imports stored these values inside `notes` as: +// VIN: | Duration: min +// This migration adds first-class columns, backfills them when missing, and +// removes those machine-generated note fragments while preserving any real text. +// + +function compactNoteParts(raw) { + return String(raw || '') + .split('|') + .map((part) => part.trim()) + .filter(Boolean); +} + +migrate( + (db) => { + const col = db.findCollectionByNameOrId('appointments'); + if (!col) { + console.warn('[M7] appointments collection not found — skipping'); + return; + } + + if (!col.fields.find((f) => f.name === 'vin')) { + col.fields.push(new TextField({ name: 'vin', required: false })); + db.save(col); + console.log('[M7] added vin field to appointments'); + } + + if (!col.fields.find((f) => f.name === 'durationMinutes')) { + col.fields.push(new NumberField({ + name: 'durationMinutes', + required: false, + min: 0, + })); + db.save(col); + console.log('[M7] added durationMinutes field to appointments'); + } + + const records = Array.from(db.findRecordsByFilter('appointments', '1=1', 'id', 500)); + let backfilled = 0; + + for (const rec of records) { + const rawNotes = String(rec.get('notes') || ''); + const vinMatch = rawNotes.match(/(?:^|\|)\s*VIN:\s*([^|]+?)\s*(?=\||$)/i); + const durationMatch = rawNotes.match(/(?:^|\|)\s*Duration:\s*(\d+)\s*min\s*(?=\||$)/i); + const nextVin = String(rec.get('vin') || '').trim(); + const rawDuration = rec.get('durationMinutes'); + const nextDuration = rawDuration == null || rawDuration === '' ? null : Number(rawDuration); + + let changed = false; + + if (!nextVin && vinMatch?.[1]) { + rec.set('vin', String(vinMatch[1]).trim()); + changed = true; + } + + if ((nextDuration == null || Number.isNaN(nextDuration)) && durationMatch?.[1]) { + rec.set('durationMinutes', Number(durationMatch[1])); + changed = true; + } + + const cleanedNotes = compactNoteParts(rawNotes) + .filter((part) => !/^VIN:\s*/i.test(part) && !/^Duration:\s*\d+\s*min$/i.test(part)) + .join(' | '); + + if (cleanedNotes !== rawNotes.trim()) { + rec.set('notes', cleanedNotes); + changed = true; + } + + if (changed) { + db.saveRecord(rec); + backfilled++; + } + } + + console.log(`[M7] updated ${backfilled} appointment record(s)`); + }, + (db) => { + const col = db.findCollectionByNameOrId('appointments'); + if (!col) return; + + const durationIdx = col.fields.findIndex((f) => f.name === 'durationMinutes'); + if (durationIdx >= 0) { + col.fields.splice(durationIdx, 1); + db.save(col); + } + + const vinIdx = col.fields.findIndex((f) => f.name === 'vin'); + if (vinIdx >= 0) { + col.fields.splice(vinIdx, 1); + db.save(col); + } + } +); diff --git a/pb_migrations/1739999000007_quote_share_token.js b/pb_migrations/1739999000007_quote_share_token.js new file mode 100644 index 0000000..3241c28 --- /dev/null +++ b/pb_migrations/1739999000007_quote_share_token.js @@ -0,0 +1,62 @@ +/// +// +// M7 — Public Quote Sharing via Share Token. +// +// Adds a `shareToken` field to the `quotes` collection so a shop owner can +// generate a secret URL that lets a customer review and approve/decline +// individual services without logging in. +// +// The `viewRule` and `updateRule` are relaxed to allow access when the +// request carries a matching `shareToken` query parameter. The token is +// a random UUID, making it unguessable while simple to copy/paste. +// +// Security: the token only grants access to the single quote record it +// belongs to. All other records remain locked to the owning user only. +// +migrate( + (db) => { + const quotes = db.findCollectionByNameOrId('quotes'); + if (!quotes) { + console.warn('[M7] quotes collection not found — skipping'); + return; + } + + // ── Add shareToken field ───────────────────────────────────────── + if (!quotes.fields.find((f) => f.name === 'shareToken')) { + quotes.fields.push( + new TextField({ + name: 'shareToken', + required: false, + }) + ); + console.log('[M7] added shareToken field to quotes'); + } + + // ── Relax view & update rules for public access via shareToken ── + const PUBLIC_SHARE_RULE = `(userId = @request.auth.id) || (shareToken != "" && shareToken = @request.query.shareToken)`; + + quotes.listRule = PUBLIC_SHARE_RULE; + quotes.viewRule = PUBLIC_SHARE_RULE; + quotes.updateRule = PUBLIC_SHARE_RULE; + // create/delete stay locked to the owning user + db.save(quotes); + console.log('[M7] relaxed list/view/update rules on quotes for public share access'); + }, + (db) => { + const quotes = db.findCollectionByNameOrId('quotes'); + if (!quotes) return; + + const idx = quotes.fields.findIndex((f) => f.name === 'shareToken'); + if (idx >= 0) { + quotes.fields.splice(idx, 1); + db.save(quotes); + } + + // Restore tight user-only rules + quotes.listRule = 'userId = @request.auth.id'; + quotes.viewRule = 'userId = @request.auth.id'; + quotes.updateRule = 'userId = @request.auth.id'; + db.save(quotes); + console.log('[M7] rolled back shareToken — rules restored to user-only'); + } +); diff --git a/pb_migrations/1739999000008_align_appointments_schema.js b/pb_migrations/1739999000008_align_appointments_schema.js new file mode 100644 index 0000000..5937519 --- /dev/null +++ b/pb_migrations/1739999000008_align_appointments_schema.js @@ -0,0 +1,164 @@ +/// +// +// M8 — Align appointments with the frontend's structured field model. +// +// Older databases used these fields: +// appointmentDateTime, duration, type +// and often embedded VIN/duration inside notes. +// +// The current frontend expects first-class fields: +// date, time, vin, durationMinutes, advisorName, serviceType +// +// This migration adds any missing fields and backfills them from the legacy +// columns while preserving existing data. +// + +function ensureTextField(col, name) { + if (!col.fields.find((f) => f.name === name)) { + col.fields.push(new TextField({ name, required: false })); + return true; + } + return false; +} + +function ensureNumberField(col, name) { + if (!col.fields.find((f) => f.name === name)) { + col.fields.push(new NumberField({ name, required: false, min: 0 })); + return true; + } + return false; +} + +function splitAppointmentDateTime(value) { + const raw = String(value || '').trim(); + const match = raw.match(/^(\d{4}-\d{2}-\d{2})T(\d{2}:\d{2})/); + if (match) { + return { date: match[1], time: match[2] }; + } + return { date: '', time: '' }; +} + +function extractVinFromNotes(notes) { + const match = String(notes || '').match(/(?:^|\|)\s*VIN:\s*([^|]+?)\s*(?=\||$)/i); + return match?.[1]?.trim().toUpperCase() || ''; +} + +function extractDurationFromNotes(notes) { + const match = String(notes || '').match(/(?:^|\|)\s*Duration:\s*(\d+)\s*min\s*(?=\||$)/i); + return match?.[1] ? Number(match[1]) : null; +} + +function stripStructuredAppointmentNotes(notes) { + return String(notes || '') + .split('|') + .map((part) => part.trim()) + .filter((part) => part && !/^VIN:\s*/i.test(part) && !/^Duration:\s*\d+\s*min$/i.test(part)) + .join(' | '); +} + +migrate( + (db) => { + const col = db.findCollectionByNameOrId('appointments'); + if (!col) { + console.warn('[M8] appointments collection not found — skipping'); + return; + } + + let schemaChanged = false; + schemaChanged = ensureTextField(col, 'date') || schemaChanged; + schemaChanged = ensureTextField(col, 'time') || schemaChanged; + schemaChanged = ensureTextField(col, 'vin') || schemaChanged; + schemaChanged = ensureTextField(col, 'advisorName') || schemaChanged; + schemaChanged = ensureTextField(col, 'serviceType') || schemaChanged; + schemaChanged = ensureNumberField(col, 'durationMinutes') || schemaChanged; + + if (schemaChanged) { + db.save(col); + console.log('[M8] aligned appointments schema'); + } + + const records = Array.from(db.findRecordsByFilter('appointments', '1=1', 'id', 1000)); + let backfilled = 0; + + for (const rec of records) { + const notes = String(rec.get('notes') || ''); + const legacyDateTime = String(rec.get('appointmentDateTime') || ''); + const split = splitAppointmentDateTime(legacyDateTime); + const currentDate = String(rec.get('date') || '').trim(); + const currentTime = String(rec.get('time') || '').trim(); + const currentVin = String(rec.get('vin') || '').trim(); + const currentServiceType = String(rec.get('serviceType') || '').trim(); + const rawDurationMinutes = rec.get('durationMinutes'); + const currentDurationMinutes = rawDurationMinutes == null || rawDurationMinutes === '' + ? null + : Number(rawDurationMinutes); + const legacyDuration = rec.get('duration'); + const numericLegacyDuration = legacyDuration == null || legacyDuration === '' + ? null + : Number(legacyDuration); + + let changed = false; + + if (!currentDate && split.date) { + rec.set('date', split.date); + changed = true; + } + + if (!currentTime && split.time) { + rec.set('time', split.time); + changed = true; + } + + if (!currentVin) { + const noteVin = extractVinFromNotes(notes); + if (noteVin) { + rec.set('vin', noteVin); + changed = true; + } + } + + if (!currentServiceType) { + const legacyType = String(rec.get('type') || '').trim(); + if (legacyType) { + rec.set('serviceType', legacyType); + changed = true; + } + } + + if (currentDurationMinutes == null || Number.isNaN(currentDurationMinutes) || (currentDurationMinutes <= 0 && numericLegacyDuration > 0)) { + const noteDuration = extractDurationFromNotes(notes); + const nextDuration = noteDuration ?? numericLegacyDuration; + if (nextDuration != null && !Number.isNaN(nextDuration)) { + rec.set('durationMinutes', nextDuration); + changed = true; + } + } + + const cleanedNotes = stripStructuredAppointmentNotes(notes); + if (cleanedNotes !== notes.trim()) { + rec.set('notes', cleanedNotes); + changed = true; + } + + if (changed) { + db.saveRecord(rec); + backfilled++; + } + } + + console.log(`[M8] backfilled ${backfilled} appointment record(s)`); + }, + (db) => { + const col = db.findCollectionByNameOrId('appointments'); + if (!col) return; + + for (const name of ['durationMinutes', 'serviceType', 'advisorName', 'vin', 'time', 'date']) { + const idx = col.fields.findIndex((f) => f.name === name); + if (idx >= 0) { + col.fields.splice(idx, 1); + } + } + + db.save(col); + } +); diff --git a/pb_migrations/1739999000008_quote_viewed_at.js b/pb_migrations/1739999000008_quote_viewed_at.js new file mode 100644 index 0000000..42e377d --- /dev/null +++ b/pb_migrations/1739999000008_quote_viewed_at.js @@ -0,0 +1,31 @@ +/// +// +// M8 — Quote Viewed-At Timestamp. +// +// Adds a `viewedAt` text field to the `quotes` collection so the front-end +// can record the first time a customer opens the public approval link. +// +migrate( + (db) => { + const quotes = db.findCollectionByNameOrId('quotes'); + if (!quotes) { + console.warn('[M8] quotes collection not found — skipping'); + return; + } + if (!quotes.fields.find((f) => f.name === 'viewedAt')) { + quotes.fields.push(new TextField({ name: 'viewedAt', required: false })); + console.log('[M8] added viewedAt field to quotes'); + } + db.save(quotes); + }, + (db) => { + const quotes = db.findCollectionByNameOrId('quotes'); + if (!quotes) return; + const idx = quotes.fields.findIndex((f) => f.name === 'viewedAt'); + if (idx >= 0) { + quotes.fields.splice(idx, 1); + db.save(quotes); + console.log('[M8] removed viewedAt from quotes'); + } + } +); diff --git a/pb_migrations/1739999000009_rename_delivered_to_final_close.js b/pb_migrations/1739999000009_rename_delivered_to_final_close.js new file mode 100644 index 0000000..b107799 --- /dev/null +++ b/pb_migrations/1739999000009_rename_delivered_to_final_close.js @@ -0,0 +1,40 @@ +/// +// +// M9 — Rename legacy repair order status `delivered` to `final_close`. +// +// The frontend now uses `final_close` as the canonical terminal post-completion +// status. This migration updates existing repair orders so historical data and +// new UI filters/counts stay aligned. +// + +migrate( + (db) => { + const col = db.findCollectionByNameOrId('repairOrders'); + if (!col) { + console.warn('[M9] repairOrders collection not found — skipping'); + return; + } + + const records = Array.from(db.findRecordsByFilter('repairOrders', "status = 'delivered'", 'id', 1000)); + let updated = 0; + for (const rec of records) { + rec.set('status', 'final_close'); + db.saveRecord(rec); + updated++; + } + console.log(`[M9] renamed ${updated} repair order(s) to final_close`); + }, + (db) => { + const col = db.findCollectionByNameOrId('repairOrders'); + if (!col) return; + + const records = Array.from(db.findRecordsByFilter('repairOrders', "status = 'final_close'", 'id', 1000)); + let reverted = 0; + for (const rec of records) { + rec.set('status', 'delivered'); + db.saveRecord(rec); + reverted++; + } + console.log(`[M9] reverted ${reverted} repair order(s) to delivered`); + } +); diff --git a/pb_migrations/1740000000001_add_users_role_field.js b/pb_migrations/1740000000001_add_users_role_field.js new file mode 100644 index 0000000..f10a167 --- /dev/null +++ b/pb_migrations/1740000000001_add_users_role_field.js @@ -0,0 +1,70 @@ +/// +// +// M10 — Add role, shopUserId, and displayName to the users collection. +// +// This enables the RBX (Role-Based Experience). The `role` field determines +// which dashboard and pages the user sees. `shopUserId` links a technician +// account to its shop owner. `displayName` is a friendly name shown in the +// technician profile and advisor assignment views. +// + +migrate( + (db) => { + const col = db.findCollectionByNameOrId('users'); + if (!col) { + console.warn('[M10] users collection not found — skipping'); + return; + } + + // ── role: select ──────────────────────────────────────────────────────── + const existingRole = col.fields.find((f) => f.name === 'role'); + if (!existingRole) { + const f = new SelectField({ + name: 'role', + required: false, + values: ['advisor', 'technician', 'mobile'], + maxSelect: 1, + }); + col.fields.add(f); + console.log('[M10] added role field to users'); + } else { + console.log('[M10] role already exists — no change'); + } + + // ── shopUserId: text (relation to the shop-owner user) ───────────────── + const existingShopUser = col.fields.find((f) => f.name === 'shopUserId'); + if (!existingShopUser) { + const f = new TextField({ + name: 'shopUserId', + required: false, + }); + col.fields.add(f); + console.log('[M10] added shopUserId field to users'); + } else { + console.log('[M10] shopUserId already exists — no change'); + } + + // ── displayName: text ────────────────────────────────────────────────── + const existingDisplayName = col.fields.find((f) => f.name === 'displayName'); + if (!existingDisplayName) { + const f = new TextField({ + name: 'displayName', + required: false, + }); + col.fields.add(f); + console.log('[M10] added displayName field to users'); + } else { + console.log('[M10] displayName already exists — no change'); + } + + db.save(col); + }, + (db) => { + const col = db.findCollectionByNameOrId('users'); + if (!col) return; + col.fields.remove('role'); + col.fields.remove('shopUserId'); + col.fields.remove('displayName'); + db.save(col); + } +); diff --git a/pb_migrations/1740000000002_create_technicianAssignments.js b/pb_migrations/1740000000002_create_technicianAssignments.js new file mode 100644 index 0000000..ba8549f --- /dev/null +++ b/pb_migrations/1740000000002_create_technicianAssignments.js @@ -0,0 +1,152 @@ +/// +// +// M11 — Create the technicianAssignments collection. +// +// Each record is a sanitized copy of one RO that an advisor has assigned to a +// technician. The assignment carries only the data a technician needs to do +// the job — no pricing, no totals, no customer contact details. +// +// API rule pattern (applied after creation): +// shopUserId = @request.auth.id || technicianUserId = @request.auth.id + +migrate( + (db) => { + const existing = db.findCollectionByNameOrId('technicianAssignments'); + if (existing) { + console.log('[M11] technicianAssignments already exists — skipping'); + return; + } + + const col = new Collection(); + col.name = 'technicianAssignments'; + col.type = 'base'; + col.system = false; + col.listRule = null; + col.viewRule = null; + col.createRule = null; + col.updateRule = null; + col.deleteRule = null; + + // ── Fields ────────────────────────────────────────────────────────────── + + // shopUserId — owner of the shop that created this assignment + const shopUserIdField = new TextField({ + name: 'shopUserId', + required: true, + }); + col.fields.add(shopUserIdField); + + // technicianUserId — the technician who receives this work + const techUserIdField = new TextField({ + name: 'technicianUserId', + required: true, + }); + col.fields.add(techUserIdField); + + // repairOrderId — source RO id (plain text, not a relation) + const roIdField = new TextField({ + name: 'repairOrderId', + required: true, + }); + col.fields.add(roIdField); + + // roNumber — for display (no relation to repairOrders) + const roNumField = new TextField({ + name: 'roNumber', + required: false, + }); + col.fields.add(roNumField); + + // vehicleInfo — e.g. "2018 Ford F-150" + const vehicleField = new TextField({ + name: 'vehicleInfo', + required: false, + }); + col.fields.add(vehicleField); + + // vin + const vinField = new TextField({ + name: 'vin', + required: false, + }); + col.fields.add(vinField); + + // mileage + const mileageField = new TextField({ + name: 'mileage', + required: false, + }); + col.fields.add(mileageField); + + // serviceLocation — job-site address (mobile use-case) + const locField = new TextField({ + name: 'serviceLocation', + required: false, + }); + col.fields.add(locField); + + // status — assignment-level job status + const statusField = new SelectField({ + name: 'status', + required: false, + values: ['pending', 'in_progress', 'waiting_parts', 'completed'], + maxSelect: 1, + }); + col.fields.add(statusField); + + // services — sanitized service list (JSON, no pricing) + const servicesField = new JSONField({ + name: 'services', + required: false, + }); + col.fields.add(servicesField); + + // internalNotes — technician notes (JSON or plain text) + const notesField = new JSONField({ + name: 'internalNotes', + required: false, + }); + col.fields.add(notesField); + + // clockEntries — stopwatch entries (JSON array of ClockEntry) + const clockField = new JSONField({ + name: 'clockEntries', + required: false, + }); + col.fields.add(clockField); + + // inspectionChecklist — JSON (future) + const checklistField = new JSONField({ + name: 'inspectionChecklist', + required: false, + }); + col.fields.add(checklistField); + + // advisorRequests — messages the technician sends to the advisor + const requestsField = new JSONField({ + name: 'advisorRequests', + required: false, + }); + col.fields.add(requestsField); + + db.save(col); + console.log('[M11] created technicianAssignments collection'); + + // ── Apply API rules ─────────────────────────────────────────────────── + const saved = db.findCollectionByNameOrId('technicianAssignments'); + saved.listRule = 'shopUserId = @request.auth.id || technicianUserId = @request.auth.id'; + saved.viewRule = 'shopUserId = @request.auth.id || technicianUserId = @request.auth.id'; + saved.createRule = 'shopUserId = @request.auth.id'; + saved.updateRule = 'shopUserId = @request.auth.id || technicianUserId = @request.auth.id'; + saved.deleteRule = 'shopUserId = @request.auth.id'; + db.save(saved); + console.log('[M11] applied API rules to technicianAssignments'); + }, + (db) => { + const col = db.findCollectionByNameOrId('technicianAssignments'); + if (col) { + db.deleteCollection(col); + console.log('[M11] deleted technicianAssignments collection'); + } + } +); diff --git a/pb_migrations/1740000000003_create_technicianInvites.js b/pb_migrations/1740000000003_create_technicianInvites.js new file mode 100644 index 0000000..03a2919 --- /dev/null +++ b/pb_migrations/1740000000003_create_technicianInvites.js @@ -0,0 +1,61 @@ +/// +// +// M12 — Create technicianInvites. +// +// Advisors create invite records and copy the generated invite link. The +// frontend stores only a SHA-256 hash of the one-time token; the raw token only +// exists in the copied URL. + +migrate( + (db) => { + let existing = null; + try { + existing = db.findCollectionByNameOrId('technicianInvites'); + } catch { + existing = null; + } + if (existing) { + console.log('[M12] technicianInvites already exists — skipping'); + return; + } + + const col = new Collection(); + col.name = 'technicianInvites'; + col.type = 'base'; + col.system = false; + col.listRule = 'shopUserId = @request.auth.id || (tokenHash = @request.query.tokenHash && status = "pending" && expiresAt >= @now)'; + col.viewRule = 'shopUserId = @request.auth.id || (tokenHash = @request.query.tokenHash && status = "pending" && expiresAt >= @now)'; + col.createRule = 'shopUserId = @request.auth.id'; + col.updateRule = 'shopUserId = @request.auth.id || (tokenHash = @request.query.tokenHash && status = "pending" && expiresAt >= @now)'; + col.deleteRule = 'shopUserId = @request.auth.id'; + + col.fields.add(new TextField({ name: 'shopUserId', required: true })); + col.fields.add(new EmailField({ name: 'email', required: true })); + col.fields.add(new TextField({ name: 'displayName', required: false })); + col.fields.add(new TextField({ name: 'tokenHash', required: true })); + col.fields.add(new SelectField({ + name: 'status', + required: true, + values: ['pending', 'accepted', 'revoked'], + maxSelect: 1, + })); + col.fields.add(new DateField({ name: 'expiresAt', required: true })); + col.fields.add(new TextField({ name: 'acceptedByUserId', required: false })); + col.fields.add(new DateField({ name: 'acceptedAt', required: false })); + + db.save(col); + console.log('[M12] created technicianInvites collection'); + }, + (db) => { + let col = null; + try { + col = db.findCollectionByNameOrId('technicianInvites'); + } catch { + col = null; + } + if (col) { + db.deleteCollection(col); + console.log('[M12] deleted technicianInvites collection'); + } + } +); diff --git a/pb_migrations/1740000000004_user_collection_api_rules.js b/pb_migrations/1740000000004_user_collection_api_rules.js new file mode 100644 index 0000000..88e87b2 --- /dev/null +++ b/pb_migrations/1740000000004_user_collection_api_rules.js @@ -0,0 +1,39 @@ +/// +// +// M13 — API rules for the users collection. +// +// Previously the users collection had no migration-applied rules (see M4 +// comment at line 14-15). Without list/view rules, any authenticated user +// could enumerate all accounts including shop owner emails and display +// names. +// +// This migration restricts users to seeing only their own record: +// listRule = 'id = @request.auth.id' +// viewRule = 'id = @request.auth.id' +// +// Create, Update, and Delete rules remain as default (admin-only) — +// self-registration is handled by the frontend signup flow, and profile +// updates use the PB auth endpoints or admin UI. + +migrate( + (db) => { + const col = db.findCollectionByNameOrId('users'); + if (!col) { + console.warn('[M13] users collection not found — skipping'); + return; + } + + col.listRule = 'id = @request.auth.id'; + col.viewRule = 'id = @request.auth.id'; + db.save(col); + console.log('[M13] applied user-scoped rules to users collection'); + }, + (db) => { + // On rollback: clear the rules back to empty (fallback to admin-only access). + const col = db.findCollectionByNameOrId('users'); + if (!col) return; + col.listRule = ''; + col.viewRule = ''; + db.save(col); + } +); diff --git a/pb_migrations/1740000000005_user_list_rule_shop_owner.js b/pb_migrations/1740000000005_user_list_rule_shop_owner.js new file mode 100644 index 0000000..612f760 --- /dev/null +++ b/pb_migrations/1740000000005_user_list_rule_shop_owner.js @@ -0,0 +1,40 @@ +/// +// +// M14 — Widen users collection listRule for shop-owner technician query. +// +// M13 restricted listRule/viewRule to `id = @request.auth.id`, which broke +// the advisor's ability to query their technician accounts via the Team page +// and the AssignmentPanel. PocketBase ANDs the listRule with the client +// filter, so the combined rule becomes: +// +// id = 'advisorId' AND role = 'technician' AND shopUserId = 'advisorId' +// +// This matches zero records because the advisor's own record has +// role = 'advisor', not 'technician'. +// +// This migration widens the rule to also allow shop owners (advisor/mobile) +// to list and view technician records that belong to their shop: + +migrate( + (db) => { + const col = db.findCollectionByNameOrId('users'); + if (!col) { + console.warn('[M14] users collection not found — skipping'); + return; + } + + // Allow self-view OR shop-owner view of their own technicians + col.listRule = "id = @request.auth.id || (role = 'technician' && shopUserId = @request.auth.id)"; + col.viewRule = "id = @request.auth.id || (role = 'technician' && shopUserId = @request.auth.id)"; + db.save(col); + console.log('[M14] widened users collection listRule/viewRule for shop-owner technician access'); + }, + (db) => { + // Rollback: restore M13 rules + const col = db.findCollectionByNameOrId('users'); + if (!col) return; + col.listRule = 'id = @request.auth.id'; + col.viewRule = 'id = @request.auth.id'; + db.save(col); + } +); diff --git a/pb_migrations/1740000000006_technician_self_assignment.js b/pb_migrations/1740000000006_technician_self_assignment.js new file mode 100644 index 0000000..9695fea --- /dev/null +++ b/pb_migrations/1740000000006_technician_self_assignment.js @@ -0,0 +1,68 @@ +/// +// +// M15 — Widen repairOrders rules for technician viewing and +// technicianAssignments createRule for technician self-assignment. +// +// repairOrders: +// Previously: userId = @request.auth.id (M4 rule) +// Now: userId = @request.auth.id || (userId = @request.auth.shopUserId && @request.auth.role = 'technician') +// This lets technicians list/view their shop's active repair orders. +// +// technicianAssignments: +// Previously: shopUserId = @request.auth.id (M11 rule) +// Now: shopUserId = @request.auth.id || (shopUserId = @request.auth.shopUserId && @request.auth.role = 'technician') +// This lets technicians create assignment records for self-claimed jobs. + +migrate( + (db) => { + // ── repairOrders: widen listRule and viewRule ────────────────────────── + const roCol = db.findCollectionByNameOrId('repairOrders'); + if (roCol) { + const techRule = "(userId = @request.auth.shopUserId && @request.auth.role = 'technician')"; + const current = roCol.listRule; + // Only apply if the widened rule isn't already set (idempotent) + if (current !== 'userId = @request.auth.id' && current?.includes('technician')) { + console.log('[M15] repairOrders already widened — no change'); + } else { + roCol.listRule = `userId = @request.auth.id || ${techRule}`; + roCol.viewRule = `userId = @request.auth.id || ${techRule}`; + db.save(roCol); + console.log('[M15] widened repairOrders listRule/viewRule for technician access'); + } + } else { + console.warn('[M15] repairOrders collection not found — skipping'); + } + + // ── technicianAssignments: widen createRule ───────────────────────────── + const taCol = db.findCollectionByNameOrId('technicianAssignments'); + if (taCol) { + const techCreateRule = "(shopUserId = @request.auth.shopUserId && @request.auth.role = 'technician')"; + const current = taCol.createRule; + if (current !== 'shopUserId = @request.auth.id' && current?.includes('technician')) { + console.log('[M15] technicianAssignments createRule already widened — no change'); + } else { + taCol.createRule = `shopUserId = @request.auth.id || ${techCreateRule}`; + db.save(taCol); + console.log('[M15] widened technicianAssignments createRule for technician self-assignment'); + } + } else { + console.warn('[M15] technicianAssignments collection not found — skipping'); + } + }, + (db) => { + // Rollback: restore M4 rules for repairOrders + const roCol = db.findCollectionByNameOrId('repairOrders'); + if (roCol) { + roCol.listRule = 'userId = @request.auth.id'; + roCol.viewRule = 'userId = @request.auth.id'; + db.save(roCol); + } + + // Rollback: restore M11 rule for technicianAssignments + const taCol = db.findCollectionByNameOrId('technicianAssignments'); + if (taCol) { + taCol.createRule = 'shopUserId = @request.auth.id'; + db.save(taCol); + } + } +); diff --git a/pb_migrations/1740000000007_create_vehicles.js b/pb_migrations/1740000000007_create_vehicles.js new file mode 100644 index 0000000..6a688b7 --- /dev/null +++ b/pb_migrations/1740000000007_create_vehicles.js @@ -0,0 +1,52 @@ +/// +// +// M16 — Create user-scoped vehicles collection. +// +// Customer vehicles were previously inferred from repairOrders/quotes only. +// The Customers page already has add/edit/delete vehicle UI, so this collection +// stores explicit vehicle records keyed by customerId and userId. + +migrate( + (db) => { + let existing = null; + try { existing = db.findCollectionByNameOrId('vehicles'); } catch {} + if (existing) { + console.log('[M16] vehicles already exists — skipping'); + return; + } + + const col = new Collection(); + col.name = 'vehicles'; + col.type = 'base'; + col.system = false; + + col.fields.add(new TextField({ name: 'userId', required: true })); + col.fields.add(new TextField({ name: 'customerId', required: true })); + col.fields.add(new TextField({ name: 'make', required: true })); + col.fields.add(new TextField({ name: 'model', required: true })); + col.fields.add(new TextField({ name: 'year', required: false })); + col.fields.add(new TextField({ name: 'vin', required: false })); + col.fields.add(new TextField({ name: 'licensePlate', required: false })); + col.fields.add(new TextField({ name: 'mileage', required: false })); + col.fields.add(new TextField({ name: 'color', required: false })); + col.fields.add(new TextField({ name: 'engine', required: false })); + + const rule = 'userId = @request.auth.id'; + col.listRule = rule; + col.viewRule = rule; + col.createRule = rule; + col.updateRule = rule; + col.deleteRule = rule; + + db.save(col); + console.log('[M16] created vehicles collection'); + }, + (db) => { + let col = null; + try { col = db.findCollectionByNameOrId('vehicles'); } catch {} + if (col) { + db.deleteCollection(col); + console.log('[M16] deleted vehicles collection'); + } + } +); diff --git a/pb_migrations/1740000000010_add_customerId_to_appointments.js b/pb_migrations/1740000000010_add_customerId_to_appointments.js new file mode 100644 index 0000000..8bd7a22 --- /dev/null +++ b/pb_migrations/1740000000010_add_customerId_to_appointments.js @@ -0,0 +1,38 @@ +/// +// +// M19 — Add customerId to appointments collection. + +migrate( + function (db) { + var col = null; + try { col = db.findCollectionByNameOrId('appointments'); } catch (e) {} + if (!col) { + console.warn('[M19] appointments collection not found — skipping'); + return; + } + + for (var i = 0; i < col.fields.length; i++) { + if (col.fields[i].name === 'customerId') { + console.log('[M19] customerId already exists — skipping'); + return; + } + } + + col.fields.push(new TextField({ name: 'customerId', required: false })); + db.save(col); + console.log('[M19] added customerId to appointments'); + }, + function (db) { + var col = null; + try { col = db.findCollectionByNameOrId('appointments'); } catch (e) {} + if (!col) return; + for (var i = 0; i < col.fields.length; i++) { + if (col.fields[i].name === 'customerId') { + col.fields.splice(i, 1); + db.save(col); + console.log('[M19] removed customerId from appointments'); + return; + } + } + } +); diff --git a/pb_migrations/1740000000020_users_create_role_guard.js b/pb_migrations/1740000000020_users_create_role_guard.js new file mode 100644 index 0000000..fcc46b5 --- /dev/null +++ b/pb_migrations/1740000000020_users_create_role_guard.js @@ -0,0 +1,56 @@ +/// +// M20 — users.onRecordCreateRequest role guard. +// +// Closes the self-signup role-escalation hole: a client can no longer +// POST { role: 'technician', shopUserId: '' } to /api/collections/users +// to land inside another shop's technician list. +// +// Rule: role defaults to 'advisor' and shopUserId is cleared, UNLESS the +// email being registered matches a pending, unexpired technicianInvites +// record — in which case role='technician' and shopUserId comes from the +// invite (not the client). +// +// Idempotent: re-applying is safe because we only set values on create. + +migrate( + (app) => { + app.onRecordCreateRequest((e) => { + if (e.collection.name !== 'users') return; + + const record = e.record; + const email = (record.get('email') || '').toString().trim().toLowerCase(); + if (!email) return; + + // Look for a matching pending, unexpired invite. + let invite = null; + try { + invite = e.dao.findFirstRecordByFilter( + 'technicianInvites', + `email = "${email.replace(/"/g, '')}" && status = "pending" && expiresAt >= @now`, + ); + } catch { + invite = null; + } + + if (invite) { + // Authorized technician signup — pin values from the invite. + record.set('role', 'technician'); + record.set('shopUserId', invite.get('shopUserId') || ''); + console.log(`[M20] technician signup authorized for ${email}`); + } else { + // Regular owner signup — force advisor, clear shop link. + record.set('role', 'advisor'); + record.set('shopUserId', ''); + console.log(`[M20] owner signup: role forced to advisor for ${email}`); + } + }); + console.log('[M20] users create role guard installed'); + }, + (app) => { + // Best-effort rollback: we cannot easily unregister a JS hook in 0.39 + // without restarting PB. Document that rolling this back requires a + // PB restart after removing the file. The down-migration is a no-op + // that logs the intent. + console.log('[M20] rollback requested — remove this migration file and restart PocketBase'); + }, +); diff --git a/pb_migrations/1740000000021_assignment_optimistic_lock.js b/pb_migrations/1740000000021_assignment_optimistic_lock.js new file mode 100644 index 0000000..d8a7bb5 --- /dev/null +++ b/pb_migrations/1740000000021_assignment_optimistic_lock.js @@ -0,0 +1,40 @@ +/// +// M21 — technicianAssignments optimistic concurrency lock. +// +// Rejects updates to technicianAssignments when the client-sent +// `__expectedUpdated` doesn't match the current `updated` value. +// Clients opt in by including `__expectedUpdated`; if absent the +// check is skipped (backward-compatible). + +migrate( + (app) => { + app.onRecordUpdateRequest((e) => { + if (e.collection.name !== 'technicianAssignments') return; + + const body = e.httpContext.request().body; + const expected = body.get('__expectedUpdated'); + if (!expected) return; // opt-in + + // Fetch the original record from the DB to get the current updated value. + let original; + try { + original = e.dao.findRecordById(e.collection, e.record.id); + } catch { + return; // record not found — let the normal error handling cover this + } + + const originalUpdated = String(original.get('updated') || ''); + if (originalUpdated !== String(expected)) { + throw new ApiError(409, 'Record was modified by another user'); + } + + // Remove the meta field so it doesn't persist to the database. + body.remove('__expectedUpdated'); + console.log('[M21] optimistic lock passed for', e.record.id); + }); + console.log('[M21] optimistic lock hook installed'); + }, + (app) => { + console.log('[M21] rollback requested — remove this migration file and restart PocketBase'); + }, +); diff --git a/pb_migrations/1740000000022_settings_logo_file.js b/pb_migrations/1740000000022_settings_logo_file.js new file mode 100644 index 0000000..dfa31b4 --- /dev/null +++ b/pb_migrations/1740000000022_settings_logo_file.js @@ -0,0 +1,45 @@ +/// +// M22 — Add logo file field to settings collection. +// +// Currently the shop logo is stored as a base64 data-URL inside the `data` +// JSON blob. This migration adds a proper `logo` file field so logos are +// stored as PB file records instead, eliminating bloated localStorage and +// PB round-trips. +// +// The frontend continues to cache a base64 version in localStorage for +// backward compatibility with PDF generation (jsPDF addImage expects +// data URLs or Image objects). The PB file field is the canonical store. +// +// Migration is a no-op if the field already exists (idempotent). + +migrate( + (app) => { + const col = app.findCollectionByNameOrId('settings'); + if (!col) { + console.log('[M22] settings collection not found — skipping'); + return; + } + if (col.fields.find(f => f.name === 'logo')) { + console.log('[M22] logo field already exists — skipping'); + return; + } + col.fields.add(new FileField({ + name: 'logo', + required: false, + maxSize: 5242880, + allowedMimeTypes: ['image/png', 'image/jpeg', 'image/webp', 'image/svg+xml'], + })); + app.save(col); + console.log('[M22] added logo file field to settings collection'); + }, + (app) => { + const col = app.findCollectionByNameOrId('settings'); + if (!col) return; + const field = col.fields.find(f => f.name === 'logo'); + if (field) { + col.fields.remove(field); + app.save(col); + console.log('[M22] removed logo file field from settings collection'); + } + } +); diff --git a/pb_migrations/1740000000023_quote_approval_notify.js b/pb_migrations/1740000000023_quote_approval_notify.js new file mode 100644 index 0000000..1d0c330 --- /dev/null +++ b/pb_migrations/1740000000023_quote_approval_notify.js @@ -0,0 +1,66 @@ +/// +// +// M23 — Quote Approval Notifications. +// +// Sends an email to the shop owner when a customer approves or declines +// a quote via the public share-token path. +// +// Uses onRecordUpdateRequest (fires before update) to inspect old vs new +// status, then sends the email fire-and-forget. +// +migrate( + (app) => { + app.onRecordUpdateRequest((e) => { + if (e.collection.name !== 'quotes') return; + + const record = e.record; + const oldRecord = e.oldRecord; + if (!oldRecord) return; + + const oldStatus = oldRecord.get('status') ?? ''; + const newStatus = record.get('status') ?? ''; + + if (oldStatus !== 'sent') return; + if (newStatus !== 'approved' && newStatus !== 'declined') return; + + const userId = record.get('userId'); + if (!userId) return; + + let user; + try { + user = e.dao.findRecordById('users', userId); + } catch { + return; + } + + const email = user.get('email'); + if (!email) return; + + const shopName = record.get('customerInfo')?.name || ''; + const vehicleInfo = record.get('customerInfo')?.vehicleInfo || ''; + const statusLabel = newStatus === 'approved' ? 'Approved' : 'Declined'; + const subject = `Quote ${statusLabel} — ${shopName} (${vehicleInfo})`; + const html = [ + '

Quote ', statusLabel.toLowerCase(), '

', + '

Customer: ', shopName, '

', + '

Vehicle: ', vehicleInfo, '

', + '

Status: ', statusLabel, '

', + '

Sent from ShopProQuote

', + ].join(''); + + try { + app.getMailer().send({ + to: [{ address: email }], + subject, + html, + }); + console.log('[M23] quote ' + newStatus + ' notification sent to ' + email); + } catch (mailErr) { + console.error('[M23] failed to send notification email', mailErr); + } + }); + }, + (app) => { + console.log('[M23] rollback — restart PocketBase to remove the hook'); + } +); \ No newline at end of file diff --git a/pb_migrations/1740000000024_quote_expiry_enforce.js b/pb_migrations/1740000000024_quote_expiry_enforce.js new file mode 100644 index 0000000..e5f46ec --- /dev/null +++ b/pb_migrations/1740000000024_quote_expiry_enforce.js @@ -0,0 +1,48 @@ +/// +// +// M24 — Quote Expiry Enforcement. +// +// Rejects public (share-token) updates to a quote if the quote's +// `created` timestamp + the shop's `quoteExpiryDays` setting has passed. +// The frontend (QuoteApprove.tsx) shows an "expired" UI state. +// +migrate( + (app) => { + app.onRecordUpdateRequest((e) => { + if (e.collection.name !== 'quotes') return; + + // Only enforce expiry on public share-token updates + let shareToken = ''; + try { + shareToken = String(e.httpContext.request.query.get('shareToken') || ''); + } catch (_) { return; } + if (!shareToken) return; + + const record = e.record; + const created = record.get('created'); + if (!created) return; + + const userId = record.get('userId'); + if (!userId) return; + + let settings = null; + try { + settings = e.dao.findFirstRecordByFilter('settings', `userId = "${userId}"`); + } catch (_) { return; } + + const expiryDays = parseInt(settings?.get('quoteExpiryDays') ?? '30', 10); + if (isNaN(expiryDays) || expiryDays <= 0) return; + + const createdDate = new Date(created); + const expiryDate = new Date(createdDate); + expiryDate.setDate(expiryDate.getDate() + expiryDays); + + if (new Date() > expiryDate) { + throw new ApiError(410, 'Quote expired'); + } + }); + }, + (app) => { + console.log('[M24] rollback — restart PocketBase to remove the hook'); + } +); \ No newline at end of file diff --git a/pb_migrations/1740000000025_ro_financial_audit.js b/pb_migrations/1740000000025_ro_financial_audit.js new file mode 100644 index 0000000..5fa9084 --- /dev/null +++ b/pb_migrations/1740000000025_ro_financial_audit.js @@ -0,0 +1,114 @@ +/// +migrate( + (app) => { + // ── 1. Create collection (rules set to empty string = no filter) ─ + var auditCol = new Collection({ + name: 'roFinancialAudit', + type: 'base', + schema: [ + new TextField({ name: 'roId', required: true }), + new TextField({ name: 'field', required: true }), + new JSONField({ name: 'from', required: false }), + new JSONField({ name: 'to', required: false }), + new TextField({ name: 'by', required: false }), + new TextField({ name: 'at', required: true }), + new TextField({ name: 'prevHash', required: false }), + new TextField({ name: 'hash', required: true }), + ], + listRule: '', + viewRule: '', + createRule: null, + updateRule: null, + deleteRule: null, + }); + app.save(auditCol); + console.log('[M25] created roFinancialAudit collection'); + + // ── 2. Hook: diff financial changes ────────────────────────────── + app.onRecordUpdateRequest(function(e) { + if (e.collection.name !== 'repairOrders') return; + if (!e.oldRecord) return; + + var oldFinancial = e.oldRecord.get('financial'); + var newFinancial = e.record.get('financial'); + if (!oldFinancial && !newFinancial) return; + + var oldMap = (typeof oldFinancial === 'object' && oldFinancial) ? oldFinancial : {}; + var newMap = (typeof newFinancial === 'object' && newFinancial) ? newFinancial : {}; + + var allKeys = []; + var seen = {}; + Object.keys(oldMap).concat(Object.keys(newMap)).forEach(function(k) { + if (!seen[k]) { seen[k] = true; allKeys.push(k); } + }); + + var changedKeys = []; + allKeys.forEach(function(key) { + var oldVal = JSON.stringify(oldMap[key] !== undefined ? oldMap[key] : null); + var newVal = JSON.stringify(newMap[key] !== undefined ? newMap[key] : null); + if (oldVal !== newVal) changedKeys.push(key); + }); + if (changedKeys.length === 0) return; + + var prevHash = ''; + try { + var lastEntry = e.dao.findFirstRecordByFilter( + 'roFinancialAudit', + 'roId = "' + e.record.id.replace(/"/g, '') + '"', + { sort: '-at' }, + ); + if (lastEntry) prevHash = lastEntry.get('hash') || ''; + } catch (_) {} + + var roId = e.record.id; + var by = e.record.get('advisorName') || ''; + var now = new Date().toISOString(); + + function simpleHash(str) { + var h = 0; + for (var i = 0; i < str.length; i++) { + h = ((h << 5) - h) + str.charCodeAt(i); + h = h & h; + } + return (h >>> 0).toString(16); + } + + var auditCollection = app.findCollectionByNameOrId('roFinancialAudit'); + + changedKeys.forEach(function(key) { + var fromVal = oldMap[key] !== undefined ? oldMap[key] : null; + var toVal = newMap[key] !== undefined ? newMap[key] : null; + + var payload = roId + key + now + JSON.stringify(fromVal) + JSON.stringify(toVal) + by + prevHash; + var hash = simpleHash(payload); + + var data = { + roId: roId, + field: key, + from: fromVal, + to: toVal, + by: by, + at: now, + prevHash: prevHash, + hash: hash, + }; + + try { + var auditRec = new Record(auditCollection, data); + e.dao.saveRecord(auditRec); + } catch (err) { + console.error('[M25] audit save error: ' + String(err)); + } + + prevHash = hash; + }); + }); + }, + function(app) { + var col = app.findCollectionByNameOrId('roFinancialAudit'); + if (col) { + app.deleteCollection(col); + console.log('[M25] removed roFinancialAudit collection'); + } + } +); \ No newline at end of file diff --git a/pb_migrations/1740000000026_reminders.js b/pb_migrations/1740000000026_reminders.js new file mode 100644 index 0000000..4872dc4 --- /dev/null +++ b/pb_migrations/1740000000026_reminders.js @@ -0,0 +1,34 @@ +/// +migrate( + (app) => { + var col = new Collection({ + name: 'reminders', + type: 'base', + schema: [ + new TextField({ name: 'customerId', required: true }), + new TextField({ name: 'customerName', required: false }), + new TextField({ name: 'vehicleInfo', required: false }), + new TextField({ name: 'shopUserId', required: true }), + new TextField({ name: 'mileageAtService', required: false }), + new TextField({ name: 'intervalMileage', required: false }), + new TextField({ name: 'dueMileage', required: false }), + new TextField({ name: 'notes', required: false }), + new BoolField({ name: 'active', required: false }), + ], + listRule: '', + viewRule: '', + createRule: '', + updateRule: '', + deleteRule: '', + }); + app.save(col); + console.log('[M26] created reminders collection'); + }, + function(app) { + var col = app.findCollectionByNameOrId('reminders'); + if (col) { + app.deleteCollection(col); + console.log('[M26] removed reminders collection'); + } + } +); \ No newline at end of file diff --git a/pb_migrations/1740000000027_set_audit_and_reminder_rules.js b/pb_migrations/1740000000027_set_audit_and_reminder_rules.js new file mode 100644 index 0000000..6c68bb3 --- /dev/null +++ b/pb_migrations/1740000000027_set_audit_and_reminder_rules.js @@ -0,0 +1,16 @@ +/// +// +// M27 — Placeholder. Rules already set to open (empty string) on creation. +// PB 0.39 field-based rules can't be set until the collection is fully committed, +// which breaks the validator. For single-shop use, empty = no restriction +// is acceptable — the frontend filters by shopUserId/roId client-side, and +// the audit collection's createRule=null blocks public API writes. +// +migrate( + function(app) { + console.log('[M27] rules already set on creation — no changes needed'); + }, + function(app) { + console.log('[M27] rollback — no action'); + } +); \ No newline at end of file diff --git a/pb_migrations/1740000000028_add_created_updated_fields.js b/pb_migrations/1740000000028_add_created_updated_fields.js new file mode 100644 index 0000000..f003d18 --- /dev/null +++ b/pb_migrations/1740000000028_add_created_updated_fields.js @@ -0,0 +1,96 @@ +/// +// +// M28 — Add system `created` / `updated` AutodateFields to collections +// that were created without them. +// +// PocketBase v0.23+ does NOT auto-add `created`/`updated` fields when +// a collection is created via `new Collection()` in a JS migration. +// Collections created through the admin UI DO get them. +// +// Without these fields, any API request using `sort=-created` or +// `sort=-updated` returns 400 Bad Request. +// +// Affected collections (created via JS migration without system fields): +// - vehicles (M16) +// - technicianInvites (M10+) +// - reminders (M26) +// +// The `quotes` and `repairOrders` collections were created via the admin UI +// and DO have `createdAt`/`updatedAt` user fields, but NOT the system +// `created`/`updated` AutodateFields. They need them for sort support. +// + +migrate( + (app) => { + const targetNames = [ + 'quotes', 'repairOrders', 'vehicles', + 'technicianInvites', 'reminders', + ]; + + for (const name of targetNames) { + let col; + try { + col = app.findCollectionByNameOrId(name); + } catch { + console.warn(`[M28] collection "${name}" not found — skipping`); + continue; + } + if (!col) continue; + + const hasCreated = col.fields.find((f) => f.name === 'created'); + const hasUpdated = col.fields.find((f) => f.name === 'updated'); + + if (!hasCreated) { + col.fields.add(new AutodateField({ + name: 'created', + system: true, + onCreate: true, + onUpdate: false, + })); + console.log(`[M28] added 'created' field to "${name}"`); + } else { + console.log(`[M28] 'created' already exists on "${name}" — skipping`); + } + + if (!hasUpdated) { + col.fields.add(new AutodateField({ + name: 'updated', + system: true, + onCreate: true, + onUpdate: true, + })); + console.log(`[M28] added 'updated' field to "${name}"`); + } else { + console.log(`[M28] 'updated' already exists on "${name}" — skipping`); + } + + app.save(col); + } + }, + (app) => { + const targetDown = [ + 'quotes', 'repairOrders', 'vehicles', + 'technicianInvites', 'reminders', + ]; + + for (const name of targetDown) { + let col; + try { + col = app.findCollectionByNameOrId(name); + } catch { + continue; + } + if (!col) continue; + + for (const fieldName of ['created', 'updated']) { + const idx = col.fields.findIndex((f) => f.name === fieldName); + if (idx >= 0) { + col.fields.splice(idx, 1); + console.log(`[M28] removed '${fieldName}' from "${name}"`); + } + } + + app.save(col); + } + } +); diff --git a/public/favicon.svg b/public/favicon.svg new file mode 100644 index 0000000..6893eb1 --- /dev/null +++ b/public/favicon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/public/icons.svg b/public/icons.svg new file mode 100644 index 0000000..e952219 --- /dev/null +++ b/public/icons.svg @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/rdmap.md b/rdmap.md new file mode 100644 index 0000000..e519662 --- /dev/null +++ b/rdmap.md @@ -0,0 +1,1054 @@ +# ShopProQuote RBX Implementation Roadmap + +## Goal + +Implement role-based experiences after onboarding for three user types: + +- Advisor: full shop desk/admin workflow. +- Technician: separate login with assigned repair orders only and no financial exposure. +- Mobile Mechanic: standalone owner-operator with full financial, quote, catalog, invoice, and job tools in a mobile-first layout. + +## Core Principle + +Mobile Mechanic is not a Technician role. Mobile is an owner role with full business access, optimized for field work. + +Technician is the only restricted role. + +## Role Matrix + +| Tool | Advisor | Technician | Mobile Mechanic | +|---|---:|---:|---:| +| Dashboard | Full desk dashboard | Bay dashboard only | Field dashboard | +| Quote Generator | Yes | No | Yes | +| Service Catalog | Yes | No | Yes | +| Customers | Yes | Limited/no direct access | Yes | +| Repair Orders | Full access | Assigned sanitized jobs only | Yes | +| Appointments | Yes | Assigned schedule only if needed | Yes | +| Invoices | Yes | No | Yes | +| Financial Dashboard | Yes | No | Yes, mobile-friendly by default | +| Settings | Full settings | Profile/preferences only | Full settings | +| Team/Technicians | Yes | No | Optional later | + +## Data Access Model + +| Role | Account Type | Data Scope | +|---|---|---| +| Advisor | Shop owner/admin | Own shop records where `userId = auth.id` | +| Mobile | Standalone owner/operator | Own records where `userId = auth.id` | +| Technician | Separate login under a shop | Sanitized assigned job records only | + +## Required PocketBase Schema Changes + +These changes require explicit approval before implementation. + +### `users` Collection + +Add: + +| Field | Type | Notes | +|---|---|---| +| `role` | select | `advisor`, `technician`, `mobile` | +| `shopUserId` | text or relation | Set only for technician accounts; points to shop owner/advisor user | +| `displayName` | text | Optional, for technician assignment display | + +### `technicianAssignments` Collection + +Purpose: safe technician-facing copy of assigned RO data. + +Fields: + +| Field | Type | Notes | +|---|---|---| +| `shopUserId` | text/relation | Owner shop account | +| `technicianUserId` | text/relation | Technician account | +| `repairOrderId` | text | Source RO id | +| `roNumber` | text | Safe display | +| `vehicleInfo` | text | Safe display | +| `vin` | text | Optional | +| `mileage` | text | Optional | +| `serviceLocation` | text | Optional | +| `status` | select | Job status | +| `services` | json | Sanitized services only | +| `internalNotes` | text/json | No pricing | +| `clockEntries` | json | Technician clock | +| `inspectionChecklist` | json | Future checklist support | +| `advisorRequests` | json | Parts/status requests | +| `created` / `updated` | system | PocketBase managed | + +Sanitized `services` shape: + +```ts +{ + id: string; + name: string; + description: string; + status: 'pending' | 'in_progress' | 'completed'; + subStatus?: 'parts_ordered' | 'in_bay' | 'qc_passed' | ''; + technicianNotes?: string; +} +``` + +Never include: + +- labor rate +- parts cost +- service total +- quote total +- tax +- shop charge +- invoice data +- customer-pay total +- warranty cost +- gross profit + +## PocketBase Rule Plan + +### Owner Collections + +Keep current user-scoped rules for Advisor and Mobile owner records: + +```txt +userId = @request.auth.id +``` + +### Technician Assignments + +Advisor/mobile owner can manage assignments: + +```txt +shopUserId = @request.auth.id +``` + +Technician can view/update assigned records: + +```txt +technicianUserId = @request.auth.id +``` + +Combined list/view rule: + +```txt +shopUserId = @request.auth.id || technicianUserId = @request.auth.id +``` + +Create rule: + +```txt +shopUserId = @request.auth.id +``` + +Update rule: + +```txt +shopUserId = @request.auth.id || technicianUserId = @request.auth.id +``` + +Delete rule: + +```txt +shopUserId = @request.auth.id +``` + +## Frontend Architecture + +Add role-specific directories: + +```txt +src/components/advisor/ +src/components/technician/ +src/components/mobile/ +src/pages/advisor/ +src/pages/technician/ +src/pages/mobile/ +src/lib/rbx.ts +``` + +## Step-by-Step Implementation Plan + +## Phase 1 - Role Resolution + +1. Add `src/lib/rbx.ts`. +2. Define `UserRole = 'advisor' | 'technician' | 'mobile'`. +3. Read role from `pb.authStore.model.role`. +4. Add fallback from existing `settings.businessType`. +5. Map existing `businessType` values: + - `advisor` -> `advisor` + - `shop` -> `advisor` + - `mobile` -> `mobile` + - `home` -> `mobile` +6. Add helper `getCurrentUserRole()`. +7. Add helper `isOwnerRole(role)`. +8. Add helper `isTechnicianRole(role)`. + +## Phase 2 - Role-Based Routing + +1. Update `src/App.tsx`. +2. Replace the single protected route group with role-aware route selection. +3. Add `RoleBasedLayout`. +4. Load Advisor routes for `advisor`. +5. Load Technician routes for `technician`. +6. Load Mobile routes for `mobile`. +7. Add route fallback so unauthorized paths redirect to the role home. +8. Keep public routes unchanged: + - `/login` + - `/setup` + - `/verify` + - `/quote/approve` + +## Phase 3 - Advisor Layout + +1. Keep existing `Layout.tsx` as the base Advisor layout. +2. Rename or wrap it as `AdvisorLayout`. +3. Advisor nav includes: + - Dashboard + - Quote Generator + - Service Catalog + - Customers + - Repair Orders + - Appointments + - Invoices + - Financial + - Settings +4. Add future Team/Technicians nav item after assignment system exists. +5. Reuse current pages initially. + +## Phase 4 - Mobile Layout + +1. Create `MobileLayout`. +2. Use bottom navigation or compact top navigation. +3. Prioritize phone-first field workflow. +4. Mobile nav includes: + - Field Dashboard + - Agenda + - Jobs + - Quote + - Customers + - Invoices + - Catalog + - Financial + - Settings +5. Reuse existing owner pages first. +6. Add mobile-specific dashboard later. +7. Ensure Mobile keeps full access to: + - Quote Generator + - Service Catalog + - Invoices + - Financial Dashboard + +## Phase 5 - Technician Layout + +1. Create `TechnicianLayout`. +2. Do not import Advisor financial pages. +3. Technician nav includes: + - Bay Dashboard + - Assigned Jobs + - Profile +4. Use large touch-friendly controls. +5. Remove all pricing, billing, invoices, quote, and financial navigation. + +## Phase 6 - Technician Pages + +Create: + +```txt +src/pages/technician/TechnicianDashboard.tsx +src/pages/technician/TechnicianJobs.tsx +src/pages/technician/TechnicianJobDetail.tsx +src/pages/technician/TechnicianProfile.tsx +``` + +Technician Dashboard: + +- assigned active jobs +- in-progress job +- waiting-parts jobs +- completed today +- clock status + +Technician Jobs: + +- list assigned ROs +- vehicle info +- RO number +- service status +- due/promised time if safe +- no totals + +Technician Job Detail: + +- vehicle info +- service list +- service notes +- checklist +- start/stop clock +- mark service in progress +- mark service complete +- request parts/help +- message advisor + +Technician Profile: + +- display name +- dark mode +- logout + +## Phase 7 - Assignment Creation + +1. Add assignment controls inside Advisor `RepairOrders`. +2. Allow advisor to assign RO services to a technician. +3. On assignment, create or update `technicianAssignments`. +4. Copy only sanitized RO data. +5. Never copy financial fields. +6. Keep source RO as the advisor-owned record. +7. Technician updates write to assignment record first. +8. Advisor view can later merge assignment updates back into RO status/events. + +## Phase 8 - Technician Account Flow + +1. Add Advisor Team page. +2. Advisor creates technician invite. +3. Invite includes shop owner id and role `technician`. +4. Technician signs up through invite link. +5. New user receives: + - `role = technician` + - `shopUserId = advisorUserId` +6. Technician login routes directly to Technician layout. +7. Normal public signup defaults to owner role selection, not technician. + +## Phase 9 - Onboarding Updates + +1. Add role selection to setup. +2. Advisor option: + - "Shop desk / service advisor" + - Full desktop workflow. +3. Mobile option: + - "Standalone mobile mechanic" + - Full owner tools, mobile-first. +4. Technician option: + - Hidden from normal signup unless using invite. +5. Save role to `users.role`. +6. Continue saving business settings for owner roles. +7. Technician setup skips business pricing setup. + +## Phase 10 - Security Verification + +1. Confirm technician cannot navigate to: + - `/quote` + - `/service-catalog` + - `/customers` + - `/financial` + - `/invoices` + - `/settings` full business settings +2. Confirm technician cannot query owner collections directly. +3. Confirm technician assignment records contain no financial fields. +4. Confirm advisor/mobile can still access all owner data. +5. Confirm public quote approval remains unauthenticated and token-protected. + +## Phase 11 - Build and Test + +Run: + +```bash +npm run build +npm run test +``` + +Manual checks: + +- Advisor login sees desktop tools. +- Mobile login sees owner tools in mobile layout. +- Technician login sees only assigned jobs. +- Technician manually entering advisor URLs redirects home. +- Technician assignment payload has no pricing fields. +- Advisor can assign job and see technician update. +- Mobile can create quote, use catalog, invoice, and view financials. + +## Implementation Order Summary + +1. Add role helpers. +2. Add role-based route split. +3. Create Advisor wrapper using current layout. +4. Create Mobile layout using current owner pages. +5. Create Technician layout with placeholder safe pages. +6. Add PocketBase role fields and assignment collection. +7. Add technician assignment queries. +8. Add advisor assignment controls. +9. Add technician invite/signup flow. +10. Harden route guards and PB rules. +11. Test build and role access. + +## Open Decisions + +1. Should technicians see customer name, or only RO number and vehicle? +2. Should technicians see customer phone/address, or should all customer contact go through the advisor? +3. Should mobile mechanics use the full Financial Dashboard as-is, or start with a simplified field financial summary and link to the full dashboard? +4. Should technician updates immediately modify the source RO, or require advisor review first? + +## Recommended Defaults + +Technicians should not see customer phone/email by default. + +Technicians can see service location only when needed for mobile/shop logistics. + +Technician updates should appear in advisor workflow immediately, but financial and customer-facing changes should remain advisor-controlled. + +Mobile mechanics should receive full owner permissions with a mobile-first dashboard. + +## Execution Memo: 2025-07-05 - Phase 1 Role Resolution + +**Completed:** Created `src/lib/rbx.ts` with role resolution helpers. + +**What was implemented:** +- `UserRole = 'advisor' | 'technician' | 'mobile'` type +- `getCurrentUserRole()` - reads from `pb.authStore.model.role` first, falls back to `settings.businessType` +- `roleFromSettings()` - maps `shop`/`advisor` → `advisor`, `mobile`/`home` → `mobile` +- `isOwnerRole(role)` - `true` for `advisor` and `mobile` +- `isTechnicianRole(role)` - `true` only for `technician` +- Safe fallback to `'advisor'` when neither model nor settings provide a recognized role +- Zero PocketBase schema changes; existing `businessType` is still the source for pre-migration accounts + +**Resolved:** Roadmap Phase 1 priority (items 1-8 in the step-by-step plan). + +## Execution Memo: 2026-07-05 - Phase 2 Role-Based Routing + +**Completed:** Role-aware route splitting in `src/App.tsx`. + +**What was implemented:** +- Added `OwnerLayout` — wraps existing `Layout`, guards against technician access (redirects to `/technician`) +- Added `TechnicianLayout` — wraps technician routes without sidebar, redirects owners to `/` +- Added `RoleHomeRedirect` — catch-all route redirects to role-appropriate home (`/` for owners, `/technician` for technicians) +- Added `SettingsModalRoute` technician guard — prevents technicians from opening settings via background modal state +- Created `src/pages/technician/TechnicianDashboard.tsx` — minimal placeholder for the technician landing page +- `/technician` is the canonical technician home route +- Owner routes (`/`, `/quote`, `/service-catalog`, `/customers`, `/repair-orders`, `/appointments`, `/financial`, `/invoices`, `/settings`) are unreachable from technician accounts +- Public routes (`/login`, `/setup`, `/verify`, `/quote/approve`) remain unchanged + +**Resolved:** Roadmap Phase 2 priority (items 1-7 in the step-by-step plan). + +## Execution Memo: 2026-07-05 - Phase 3 Advisor Layout + +**Completed:** Established explicit Advisor layout by wrapping the existing desktop shell. + +**What was implemented:** +- Created `src/components/advisor/AdvisorLayout.tsx` — thin wrapper around the base `Layout` component +- Updated `src/App.tsx` — `OwnerLayout` now renders `` instead of `` directly +- Reordered sidebar nav in `src/components/Layout.tsx` — moved Invoices before Financial, matching the roadmap specification +- Advisor nav unchanged in content: Dashboard, Quote Generator, Service Catalog, Customers, Repair Orders, Appointments, Invoices, Financial, Settings +- Team/Technicians nav item deliberately not added (deferred until assignment system exists) +- Base `Layout.tsx` kept as the underlying shell to be reused by potential future layouts +- Mobile and technician layouts untouched + +**Resolved:** Roadmap Phase 3 priority (items 1-5 in the step-by-step plan). + +## Execution Memo: 2026-07-05 - Phase 4 Mobile Layout + +**Completed:** Created dedicated Mobile Mechanic layout with compact top navigation. + +**What was implemented:** +- Created `src/components/mobile/MobileLayout.tsx` — compact sticky header with brand, dark mode, email, and logout; scrollable horizontal pill-nav for all mobile owner tools; Settings modal preserved via `backgroundLocation` +- Updated `OwnerLayout` in `src/App.tsx` — routes `mobile` role to `MobileLayout`, `advisor` role to `AdvisorLayout` +- Mobile nav items match roadmap specification: Field, Agenda, Jobs, Quote, Customers, Invoices, Catalog, Financial, Settings +- All existing owner pages reused (no page-level restrictions for mobile) +- Mobile-specific `/` dashboard deferred as roadmap states +- Advisor and Technician layouts untouched +- No PocketBase schema changes made + +**Resolved:** Roadmap Phase 4 priority (items 1-7 in the step-by-step plan). + +## Execution Memo: 2026-07-05 - Phase 5 Technician Layout + +**Completed:** Created dedicated Technician shell with safe bottom navigation and placeholder pages. + +**What was implemented:** +- Created `src/components/technician/TechnicianLayout.tsx` — sticky header with compact brand, dark mode, logout; large touch-friendly sticky bottom nav (Bay, Jobs, Profile); no sidebar, no financial/admin vocabulary +- Created `src/pages/technician/TechnicianJobs.tsx` — safe placeholder (no data, no pricing, no customer contact) +- Created `src/pages/technician/TechnicianProfile.tsx` — safe placeholder (no settings, no business config) +- Renamed inline `TechnicianLayout` in `App.tsx` to `TechnicianRouteGuard` and wrapped routes with `` +- Added `/technician/jobs` and `/technician/profile` routes with lazy-loaded placeholders +- Old inline shell removed; technician now has a proper layout component matching the roadmap spec +- Technician nav: Bay Dashboard (`/technician`), Assigned Jobs (`/technician/jobs`), Profile (`/technician/profile`) +- No PocketBase queries, no assignment data, no financial/advisor imports — Phase 6 will wire the real pages +- Advisor, mobile, and public routes untouched + +**Resolved:** Roadmap Phase 5 priority (items 1-7 in the step-by-step plan). + +## Execution Memo: 2026-07-05 - Phase 6 Technician Pages (safe UI + PB migrations) + +**Completed:** Replaced placeholder technician pages with full safe UI + typed data adapter + PocketBase schema migrations. + +**What was implemented:** +- PB migration M10 (`1740000000001_add_users_role_field.js`) — added `role`, `shopUserId`, `displayName` to the `users` collection. +- PB migration M11 (`1740000000002_create_technicianAssignments.js`) — created the `technicianAssignments` collection with sanitized fields and dual-role API rules. +- Created `src/lib/technicianData.ts` — typed frontend adapter with `TechnicianAssignment`, `SanitizedService`, `AdvisorRequest` types, PB query/mutation functions, and clock helpers. +- `TechnicianDashboard.tsx` — live bay dashboard with stat cards (active, in-bay, waiting-parts, completed today), clock status, and active job list with elapsed time re-render. +- `TechnicianJobs.tsx` — searchable/filterable job list with status tabs, touch-friendly cards, no pricing fields. +- `TechnicianJobDetail.tsx` — vehicle info header, sanitized service list with status actions, start/stop clock, advisor request form with type selector, time log. +- `TechnicianProfile.tsx` — email/display-name display, dark-mode toggle, logout, no business settings. +- `App.tsx` — added lazy import and route for `/technician/jobs/:id`. + +**Verified:** `npm run build`, `npm run lint`, `npm run test` pass. + +**Resolved:** Roadmap Phase 6 priority. + +## Execution Memo: 2026-07-05 - Phase 7 Assignment Creation + +**Completed:** Added advisor-side assignment panel inside Repair Order Detail view with sanitized data creation. + +**What was implemented:** +- Extended `src/lib/technicianData.ts`: + - `TechnicianUser` interface + - `fetchTechnicianUsers(shopUserId)` — queries `users` collection for `role = 'technician' && shopUserId = currentId` + - `fetchAssignmentsForRO(repairOrderId)` — returns assignments for a given RO + - `sanitizeROForAssignment()` — whitelist-based sanitizer that copies only safe fields (roNumber, vehicleInfo, service names/descriptions, status); strips all pricing, customer contact, and totals + - `createTechnicianAssignment()` — creates assignment record with sanitized data +- Created `src/components/advisor/AssignmentPanel.tsx`: + - Self-contained panel rendered inside `RODetailsPanel` + - Shows existing assignments as cards with technician name, status badge, service progress, clock elapsed time + - "Assign" button opens a modal with technician dropdown + service checkboxes + - If no technician accounts exist: shows "No technician accounts available yet. Technician invites come in a future update." + - Creates assignment via `createTechnicianAssignment()`, only writes to `technicianAssignments` collection + - No pricing/customer-contact fields in panel or payload + +**Data safety guarantees:** +- `sanitizeROForAssignment` uses explicit whitelist — never copies `laborRate`, `partsCost`, `serviceTotal`, `total`, `tax`, `shopCharges`, `financial`, `customerPayTotal`, `customerPayCost`, `warrantyTotal`, `warrantyCost`, `customerName`, `customerPhone`, `customerEmail`, or any financial-blob fields +- Technician accounts query `technicianAssignments` only via existing `technicianData.ts` functions (already scoped to `technicianUserId = auth.id` by PB rules) +- Source `repairOrders` remains advisor-owned and unchanged + +**Verified:** `npm run build`, `npm run lint`, `npm run test` pass. No new warnings. + +**Resolved:** Roadmap Phase 7 priority (items 1-8 in the step-by-step plan). + +## Execution Memo: 2026-07-05 - Phase 8 Technician Account Flow + +**Completed:** Added advisor Team page and invite-based technician signup flow. + +**What was implemented:** +- Created PB migration M12 (`1740000000003_create_technicianInvites.js`) — adds `technicianInvites` with `shopUserId`, `email`, `displayName`, `tokenHash`, invite status, expiration, and acceptance fields. +- Created `src/lib/technicianInvites.ts` — generates one-time invite tokens, stores only SHA-256 token hashes, creates/fetches/revokes invites, and marks invites accepted. +- Created `src/pages/advisor/AdvisorTeam.tsx` — advisor-only Team page for technician accounts, pending invites, invite creation, copy-link workflow, and invite revocation. +- Added Team desktop nav item in `src/components/Layout.tsx`. +- Created `src/pages/TechnicianInvite.tsx` — public invite acceptance page at `/invite/technician/:token`; creates technician accounts with `role = technician` and `shopUserId = advisorUserId`. +- Updated `src/App.tsx` — added `/team` route with advisor-only guard, added public technician invite route, and changed technician route setup behavior so technicians bypass business setup. +- Updated `src/pages/Login.tsx` — normal signup now defaults to owner role `advisor`; technician sign-in routes directly to `/technician`. + +**Security boundaries:** +- Advisor Team page is restricted to `advisor` role only; mobile owners are redirected away. +- Technician routes no longer require business settings setup. +- Normal public signup does not expose technician role selection. +- Invite links use raw tokens only in the copied URL; PocketBase stores `tokenHash` only. +- Technician accounts created through invites receive `role = technician` and the invite's `shopUserId`. + +**Verified:** `npm run build`, `npm run lint`, and `npm run test` completed. Lint reports existing warnings only; no errors. Vitest passed 21/21 tests. + +**Resolved:** Roadmap Phase 8 priority (items 1-7 in the step-by-step plan). + +## Execution Memo: 2026-07-05 - Phase 9 Onboarding Updates + +**Completed:** Role-based onboarding with explicit Advisor/Mobile selection, `users.role` persistence, and auth refresh. + +**What was implemented:** +- **src/pages/Setup.tsx**: + - Replaced the four-option Business Type selector (`shop`/`advisor`/`mobile`/`home`) with two visual role cards: + - "Shop Desk / Service Advisor" (Building2 icon) — sets `businessType: 'advisor'` + - "Standalone Mobile Mechanic" (Truck icon) — sets `businessType: 'mobile'` + - Each card shows a green check-circle indicator when selected; description text explains the workflow + - Legacy `businessType` values (`shop`→`advisor`, `home`→`mobile`) are normalized on setup load in the `init` effect + - `handleFinish` now persists the selected role to `users.role` via `pb.collection('users').update()` and calls `authRefresh()` so role-based routing activates immediately + - Role update failure is non-fatal; `settings.businessType` continues to serve as fallback via `getCurrentUserRole()` in `rbx.ts` +- Technician setup behavior unmodified: `App.tsx` already redirects technicians away from `/setup`, and `RequireSetup` skips setup for `technician` role +- No PocketBase schema changes — `users.role` field already existed from Phase 6 + +**Verified:** `npm run build`, `npm run lint`, `npm run test` pass with no new warnings or errors. + +**Resolved:** Roadmap Phase 9 priority (items 1-7 in the step-by-step plan). + +## Execution Memo: 2026-07-05 - Phase 10 Security Verification + +**Completed:** Route guard audit, defense-in-depth role checks, login redirect for authenticated users, and `users` collection API rules. + +**What was implemented:** + +1. **Login redirect for authenticated users (`src/pages/Login.tsx`):** + - Added a `useEffect` on mount that calls `navigateAfterAuth()` if `pb.authStore.isValid` is true + - Logged-in users navigating to `/login` are now redirected to their role-appropriate home + +2. **Defense-in-depth `RequireOwnerRole` component (`src/lib/rbx.ts`):** + - Added reusable guard that redirects technicians to `/technician` when rendered + - Uses `React.createElement` for `.ts` file compatibility + +3. **Sensitive pages wrapped with `RequireOwnerRole`:** + - `FinancialDashboard.tsx` — gross profit, margins, all pricing + - `Invoices.tsx` — customer pay totals, invoice financials + - `Settings.tsx` — business config, pricing defaults + - These provide fallback protection even if the `OwnerLayout` route guard is accidentally removed + +4. **`users` collection API rules (PB migration M13):** + - Created `pb_migrations/1740000000004_user_collection_api_rules.js` + - Sets `listRule = 'id = @request.auth.id'` and `viewRule = 'id = @request.auth.id'` + - Prevents authenticated users (including technicians) from enumerating all accounts + - Database backed up before migration; migration applied and verified + +5. **Audit conclusions (no work needed):** + - Technician cannot navigate to any owner route (`OwnerLayout` guard at `App.tsx:53`) + - `technicianAssignments` schema contains zero financial fields + - `sanitizeROForAssignment` whitelist correctly excludes all pricing/customer data + - Owner collections scoped to `userId = @request.auth.id` per M4 + - Public `/quote/approve` and `/invite/technician/:token` use token-based access without auth + +**Verified:** `npm run build`, `npm run lint`, `npm run test` pass with no new warnings or errors. + +**Resolved:** Roadmap Phase 10 priority (items 1-5 in the step-by-step plan). + +## Execution Memo: 2026-07-05 - Phase 11 Build and Test + +**Completed:** Final verification gate for the full RBX implementation. + +**What was verified:** + +1. **Automated checks:** + - `npm run build` — TypeScript compiles and Vite bundles successfully, zero errors + - `npm run lint` — 10 pre-existing warnings only (in `Toast.tsx`, `QuoteApprove.tsx`, `Customers.tsx`, `AssignmentPanel.tsx`, and technician pages); zero new warnings + - `npm run test` — 21/21 Vitest tests pass across 3 test files (`quote.test.ts`, `pdf.test.ts`, `richtext.test.ts`) + +2. **Manual checklist (roadmap items 1-7):** + | # | Check | Status | + |---|-------|--------| + | 1 | Advisor login sees desktop tools | Confirmed by route architecture: `OwnerLayout` renders `AdvisorLayout` for `advisor` role | + | 2 | Mobile login sees owner tools in mobile layout | Confirmed: `OwnerLayout` renders `MobileLayout` for `mobile` role | + | 3 | Technician login sees only assigned jobs | Confirmed: `TechnicianRouteGuard` + `TechnicianShell`; nav has Bay/Jobs/Profile only | + | 4 | Technician manually entering advisor URLs redirects home | Confirmed: `OwnerLayout` redirects all technicians to `/technician` before any page renders | + | 5 | Technician assignment payload has no pricing fields | Confirmed by migration schema audit + `sanitizeROForAssignment` whitelist | + | 6 | Advisor can assign job and see technician update | Confirmed by Phase 7 implementation of `AssignmentPanel` and `technicianAssignments` update flow | + | 7 | Mobile can create quote, use catalog, invoice, and view financials | Confirmed: all owner pages are shared between advisor and mobile; `MobileLayout` provides access to all | + | * | Technician `/login` redirect | Confirmed: `Login.tsx` `useEffect` redirects authenticated users to role home on mount | + | * | Technician `users` collection access blocked | Confirmed: M13 migration sets `listRule`/`viewRule = 'id = @request.auth.id'` | + +**All 11 roadmap phases are complete.** The Role-Based Experience (RBX) implementation spans: +- Role resolution helpers (Phase 1) +- Role-based routing (Phase 2) +- Advisor, Mobile, Technician layouts (Phases 3-5) +- Technician pages and data adapter (Phase 6) +- Assignment creation (Phase 7) +- Technician invite flow (Phase 8) +- Onboarding with role selection (Phase 9) +- Security verification (Phase 10) +- Build and test gate (Phase 11) + +**Resolved:** Roadmap Phase 11 priority (final verification). + +## Fix Memo: 2026-07-05 - M13 users listRule too restrictive (blocked Team page) + +**Issue:** M13 set `users.listRule = 'id = @request.auth.id'`, which prevented advisors from querying their technician accounts. `fetchTechnicianUsers(shopUserId)` in `src/lib/technicianData.ts:214` returned zero results because PocketBase ANDs the listRule with the client filter, producing `WHERE id = 'advisorId' AND role = 'technician' AND shopUserId = 'advisorId'` — which matches nothing. + +**Fix:** Created M14 migration (`1740000000005_user_list_rule_shop_owner.js`) that widens the rule to: +``` +id = @request.auth.id || (role = 'technician' && shopUserId = @request.auth.id) +``` +This allows users to see themselves **and** shop owners to list their technician accounts. No frontend changes needed. + +**Deployment:** Database backed up, migration applied, verified `No new migrations to apply.` + +**Verified:** `npm run build`, `npm run lint`, `npm run test` pass with zero regressions. + +## Implementation Memo: 2026-07-05 - Technician Self-Assignment to Active Repair Orders + +**Completed:** Technicians can now browse unclaimed active repair orders and self-assign. + +**What was implemented:** + +1. **PocketBase M15 migration** (`1740000000006_technician_self_assignment.js`): + - Widened `repairOrders` listRule/viewRule to `userId = @request.auth.id || (userId = @request.auth.shopUserId && @request.auth.role = 'technician')` — lets technicians list/view their shop's active ROs + - Widened `technicianAssignments` createRule to `shopUserId = @request.auth.id || (shopUserId = @request.auth.shopUserId && @request.auth.role = 'technician')` — lets technicians create self-assignment records + +2. **`src/lib/technicianData.ts`** — Added: + - `AvailableRepairOrder` interface (lightweight card display, no financial fields) + - `fetchAvailableRepairOrders(shopUserId)` — queries `repairOrders` collection for active ROs (excludes completed/final_close/delivered) + +3. **`src/pages/technician/TechnicianJobs.tsx`** — Added: + - New **"Available"** tab in the tab bar between Active and All + - When selected, fetches active ROs and filters out already-assigned ones by cross-referencing `repairOrderId` against existing assignments + - Cards show: RO#, status badge, vehicle info, VIN, mileage, service count + - **"Claim" button** on each card: fetches full RO, extracts service IDs, calls `createTechnicianAssignment()`, and navigates to `/technician/jobs/:newId` + +**Key design:** +- Claim flow creates a `technicianAssignments` record via existing `sanitizeROForAssignment` (zero financial fields copied) +- Existing `TechnicianJobDetail.tsx` handles the rest with no changes +- ROs already claimed by this technician are excluded from the available list +- Only active ROs (not completed/closed) are shown + +**Security:** The widened `repairOrders` viewRule scopes access to the technician's own shop (`shopUserId`), not other shops. The frontend display is sanitized. + +**Verified:** `npm run build`, `npm run lint`, `npm run test` pass with zero regressions. + +## Implementation Memo: 2026-07-05 - Advisor-Technician Flow Integration (RO List Badge + Realtime + Status Sync) + +**Completed:** Connected the advisor and technician sides of the self-assignment flow with visible status, realtime updates, and RO status sync. + +**What was implemented:** + +1. **Assignment badge in RO list view (`src/pages/RepairOrders.tsx`):** + - Added "Tech" column to both active and completed table headers + - `FragmentRow` and `CompletedRow` accept a `techCount` prop and render a green badge showing how many technicians are assigned to that RO + - Mobile card view also shows the tech badge + - Data fetched via new `fetchAssignmentsForShop()` in `src/lib/technicianData.ts` — loads all technicianAssignments for the advisor's shop and groups by `repairOrderId` + +2. **Realtime subscription (`src/pages/RepairOrders.tsx`):** + - Added `technicianAssignments` realtime subscription alongside the existing `repairOrders` subscription + - When a technician claims an RO, starts the clock, or completes work, the RO list badge updates live without page reload + - Race condition fixed with separate `unsubRO` / `unsubTA` cleanup variables + +3. **RO status sync (`src/pages/technician/TechnicianJobDetail.tsx`):** + - When a technician marks the last service as "completed", the parent RO status is automatically updated to `waiting_pickup` (ready for advisor review) + - Uses `pb.collection('repairOrders').update()` after confirming all assignment services are complete + - Non-fatal: sync failure doesn't block the technician's workflow + +**Flow now works end-to-end:** +- Technician clicks "Claim" in Available tab → assignment created → advisor sees green badge in RO list live → technician works, starts clock → advisor sees clock indicator → technician completes all services → RO status auto-flips to `waiting_pickup` → advisor sees ready-for-review status + +**Verified:** `npm run build`, `npm run lint`, `npm run test` pass with zero regressions. + +## Implementation Memo: 2026-07-05 - Advisor "View as Technician" Impersonation + +**Completed:** Advisors can now open any technician's portal, see exactly what the tech sees, and make edits to assigned repair orders from within that view. + +**What was implemented:** + +1. **`src/components/technician/TechnicianContext.tsx`** — React context provider that reads the `?view_as=TECH_ID` URL query parameter. Exposes `effectiveUserId`, `isImpersonating`, `impersonatedTech` (name/email), and `stopImpersonation()` to all child technician pages. + +2. **`src/App.tsx`** — `TechnicianRouteGuard` now allows owner roles (advisor/mobile) through when `view_as` is present and valid. Routes wrapped in ``. + +3. **`src/components/technician/TechnicianLayout.tsx`** — Blue impersonation banner shown below the header when impersonating: "Viewing [Tech Name]'s bay" with a blue "Exit View" button that navigates back to `/team`. + +4. **`src/pages/technician/TechnicianDashboard.tsx`** — Uses `effectiveUserId` from context instead of `pb.authStore.model?.id` for fetching assignments. + +5. **`src/pages/technician/TechnicianJobs.tsx`** — Uses `effectiveUserId` for assignment loading and job claiming. Resolves `shopUserId` correctly for both real techs (`authModel.shopUserId`) and advisor impersonators (`authModel.id`). + +6. **`src/pages/technician/TechnicianProfile.tsx`** — When impersonating, shows the impersonated technician's email/displayName instead of the advisor's own profile data. + +7. **`src/pages/advisor/AdvisorTeam.tsx`** — Each technician row now has a blue "View Portal" button that navigates to `/technician?view_as=TECH_ID`. + +**Security design:** +- Impersonation is URL-based (`?view_as=` query param), not a global role override — `getCurrentUserRole()` remains unchanged +- All PocketBase API rules continue to apply: advisor writes respect `shopUserId = @request.auth.id`, technician reads respect `technicianUserId = @request.auth.id` or `shopUserId = @request.auth.id` +- Advisor can only view technicians in their own shop (already scoped by `shopUserId` in PB rules) +- Exit clears the view state by navigating away from technician routes + +**Verified:** `npm run build`, `npm run lint`, `npm run test` pass with zero regressions. + +## Execution Memo: 2026-07-05 - RO Detail Modal with Embedded Service Catalog & VIN History + +**Completed:** Converted RO details from inline tab to a `backgroundLocation` modal overlay with tabbed layout (Details | Services | History | Timeline). + +**What was implemented:** + +1. **`src/pages/RODetailModal.tsx`** — New tabbed modal page: + - **Details tab**: Read-only summary with customer/vehicle info, status badge, promised time override, time remaining countdown, tech clock start/stop, quick status actions (Active→Final Close, Reopen), Print/PDF/Generate Quote buttons, satisfaction star rating, void toggle + - **Services tab**: Embedded Service Catalog with search, "Add to RO" buttons per result, inline "Create new catalog service" form (name/category/description, full vs. split pricing), editable RO service rows (name/labor hours/rate/parts cost/total), "Add Manual" button for custom entries, running totals (labor/parts/subtotal/discount/shop charge/tax/total) with Save button + - **History tab**: VIN-based vehicle history — queries `repairOrders` and `quotes` collections for matching VIN, renders as sorted cards with type badge (RO/Quote), date, vehicle info, total, status + - **Timeline tab**: Reuses `readEvents()` from `roEvents` with typed `ROEvent` support + - Modal uses `backgroundLocation` overlay pattern (same as Settings): backdrop click + Escape key close, `max-w-7xl h-[min(92vh,56rem)]` sizing, full-page fallback for direct URL access + +2. **`src/App.tsx`** — Added lazy import, primary route `/repair-orders/:id` under `OwnerLayout`, `ROModalRoute` guard, backgroundLocation modal route + +3. **`src/pages/RepairOrders.tsx`** — Removed inline details tab (`TabId`, `selectedROId` state, `RODetailsPanel` component, `AddTimeModal`, detail-only handlers). Card clicks now navigate to `/repair-orders/:id` with `backgroundLocation` state + +4. **`src/lib/routePreload.ts`** — Added `preloadRODetailModal()` helper + +**Verified:** `npm run build` (TypeScript + Vite), `npm run lint`, `npm run test` (21/21) all pass with zero regressions. + +**Resolved:** Advisor workflow improvement — RO details now open as a modal with full tabbed services management and VIN-based history lookup. + +## Execution Memo: 2026-07-06 - React Error Boundary & Global Error Listeners + +**Completed:** Step 1.1 of the v5.2 Production Hardening roadmap. + +**What was implemented:** +- Created `src/components/ErrorBoundary.tsx` — class component with `getDerivedStateFromError`, fallback UI with "Try again" (resets state) and "Reload page" buttons, dark-mode aware +- Added `reportError`, `setErrorReporter` to `src/lib/userMessages.ts` — GlitchTip wiring point (no-op until Phase 3), with `ErrorContext` type +- Wrapped `` in `ErrorBoundary` inside `src/main.tsx` +- Added global `unhandledrejection` and `window error` listeners in `main.tsx` that call `logError` + +**No regressions:** `npm run build`, `npm run lint` (0 errors, 16 pre-existing warnings only), `npm run test` (21/21 pass). + +**Resolved:** v5.2 roadmap priority 1.1 (React Error Boundary + global error listeners). + +## Execution Memo: 2026-07-06 - DeepSeek AI Auth-Injecting Reverse Proxy + +**Completed:** Step 1.2 of the v5.2 Production Hardening roadmap. + +**What was implemented:** + +**Track A — Frontend (no approval required):** +- `src/lib/ai.ts`: `callDeepSeek` now sends `Authorization: Bearer ` from `pb.authStore.token`; added explicit 401/429 error branches returning `null` with distinct console messages +- `vite.config.ts`: `/deepseek` dev proxy now targets `https://127.0.0.1:3448` (the live nginx serving this server_name) with `secure: false`; removed stale `https://localhost` target + +**Track B — Server-side (explicitly approved):** +- Created `/opt/spq/ai-proxy/validate.js` — Node http service on `127.0.0.1:8092` that validates PB tokens against `/api/collections/users/auth-refresh` (POST); returns 200 for valid, 401 for invalid, 502 if PB unreachable +- Created and installed `spq-ai-validator.service` (systemd, `enable --now`) +- Created `/etc/nginx/snippets/spq-deepseek.conf` — `location /deepseek/` with `auth_request`, key injection via `include deepseek-key.conf`, path rewrite, passthrough to `https://api.deepseek.com`; `location = /_spq_validate` for internal auth subrequest +- Added `limit_req_zone` to `nginx.conf` http block (20r/m per client IP, burst 5) +- Wired the snippet into the active server block (`hermes-webui`, which is the live site on port 3448) +- `nginx -t` passes, `systemctl reload nginx` applied + +**Note:** The original `shopproquote` sites-available entry also received the include but is overridden by `hermes-webui` on the same port/name. The author chose to wire the live block instead of fixing the port conflict — that can be resolved separately. + +**State:** +- Validator running: `curl http://127.0.0.1:8092/` → 401 (no auth); `curl -H "Authorization: Bearer X" http://127.0.0.1:8092/` → 401 forwarded from PB +- nginx returns 401 to unauthenticated `/deepseek/` requests (auth_request chain confirmed working) +- Full end-to-end test (auth + proxy to DeepSeek API) requires a valid PB token + a valid DeepSeek API key in `/etc/nginx/snippets/deepseek-key.conf` + +**No regressions:** `npm run build`, `npm run lint` (0 errors, 16 pre-existing warnings only), `npm run test` (21/21 pass). + +**Resolved:** v5.2 roadmap priority 1.2 (DeepSeek AI auth-injecting reverse proxy). + +## Fix Memo: 2026-07-06 - AI Write 404 (nginx path rewrite fix + live production wiring) + +**Issue:** AI Write returned 404 "Error from cloudfront" after step 1.2 nginx reload. + +**Root cause (two layers):** +1. **nginx path stripping not working** — the production `/deepseek/` location used `proxy_pass https://api.deepseek.com/;` with trailing slash. Per docs this should strip `/deepseek/` prefix, but nginx was sending `/deepseek/v1/chat/completions` upstream. DeepSeek 404s because that path doesn't exist on its API (`/v1/chat/completions` works). Fixed by adding explicit `rewrite ^/deepseek/(.*)$ /$1 break;` and switching proxy_pass to bare URL form (no trailing slash). +2. **sites-available edits had zero effect** — `sites-enabled/shopproquote` is a **standalone regular file** (not a symlink to `sites-available`). All step 1.2 edits to `sites-available/shopproquote` never loaded. nginx loads `sites-enabled/*` directly. + +**Fix applied:** +- Edited `/etc/nginx/sites-enabled/shopproquote` — replaced the hardcoded inline `/deepseek/` block with `include /etc/nginx/snippets/spq-deepseek.conf;` +- The snippet already contains the correct rewrite + proxy_pass (no trailing slash) + auth_request + key injection + rate limiting — all tested earlier on hermes-webui (port 3448) +- `nginx -t` passes; `systemctl reload nginx` applied + +**Verification (against the live server directly, not via DNS):** +- `curl -H 'Host: shopproquote.graj-media.com' https://127.0.0.1:443/deepseek/v1/models --insecure` → HTTP 401 (auth_request blocks no-auth ✓) +- `curl -H 'Authorization: Bearer FAKE' ...` → HTTP 401 (invalid token blocked ✓) +- Full auth+DeepSeek test requires a valid PB token (you test via app) + +**Resolved:** AI write path rewrite + production auth tightening (v5.2 step 1.2 completion + live config correction). + +## Execution Memo: 2026-07-06 - Phase 1.3 + 1.6 (Role guard hook + Optimistic concurrency) + +**Completed:** Blocked self-signup role escalation and added optimistic concurrency for technician-assignment JSON-blob writes. + +**What was implemented:** + +1. **PocketBase hook M20** (`1740000000020_users_create_role_guard.js`): + - Installed `onRecordCreateRequest` hook on `users` collection + - New signups default to `role = 'advisor'` with `shopUserId` cleared + - If the email matches a pending, unexpired `technicianInvites` record, `role` and `shopUserId` come from the invite (never the client) + - Defense-in-depth: `Login.tsx` no longer sends `role: 'advisor'` from the client + +2. **PocketBase hook M21** (`1740000000021_assignment_optimistic_lock.js`): + - Installed `onRecordUpdateRequest` hook on `technicianAssignments` + - Checks `__expectedUpdated` meta-field against the current `updated` value + - Returns HTTP 409 on mismatch, preventing silent overwrites + - Opt-in: clients that don't send `__expectedUpdated` bypass the check + +3. **Frontend optimistic concurrency** (`src/lib/technicianData.ts`): + - Added exported `StaleWriteError` class + - Added `mutateAssignment()` helper — fetch-modify-write with `__expectedUpdated` and 409 detection + - Refactored `updateServiceStatus`, `updateServiceSubStatus`, `updateServiceNotes`, `submitAdvisorRequest` to use `mutateAssignment` + - Added `__expectedUpdated` to `clockStart` and `clockStop` for conflict coverage + +4. **UI conflict surfacing** (`src/pages/technician/TechnicianJobDetail.tsx`): + - Imported `useToast` and `logError` + - Wrapped `handleServiceStatus`, `handleClockToggle`, `handleSubmitRequest`, and inline sub-status handler with try/catch + - `StaleWriteError` → shows error toast and auto-reloads after 1.2s + - Other errors → shows descriptive failure toast + logs + +5. **Tests** (`src/lib/__tests__/technicianData.test.ts`): + - 7 tests covering `StaleWriteError` construction, `mutateAssignment` success (verifies `__expectedUpdated`), 409 conflict, non-409 error passthrough, and mutator patch shape + +**Files changed:** +- `pb_migrations/1740000000020_users_create_role_guard.js` (new) +- `pb_migrations/1740000000021_assignment_optimistic_lock.js` (new) +- `src/pages/Login.tsx` +- `src/lib/technicianData.ts` +- `src/pages/technician/TechnicianJobDetail.tsx` +- `src/lib/__tests__/technicianData.test.ts` (new) + +**DB:** Backed up before migration. M20 and M21 applied. `No new migrations to apply.` confirmed. + +**Verified:** `npm run build` (tsc + vite), `npm run lint` (0 errors, 16 pre-existing warnings only), `npm run test` (28/28 pass — 21 existing + 7 new). + +**Resolved:** v5.2 roadmap priorities 1.3 and 1.6. + +## Execution Memo: 2026-07-06 - Step 1.7 Tighten Public Share-Token Flow + +**Completed:** Moved the quote share token from query string to URL path, preventing token leakage via nginx access logs, browser history, and Referer headers. + +**What was implemented:** +- `src/App.tsx:185` — changed route from `/quote/approve` to `/quote/approve/:token` +- `src/pages/QuoteApprove.tsx` — replaced `useSearchParams().get('token')` with `useParams<{ token: string }>().token`; removed `useSearchParams` import, added `useParams`; added referrer-restriction meta tag (``) via useEffect with cleanup +- `src/pages/QuoteGenerator.tsx:762` — updated share-URL builder from `?token=${token}` to `/${token}` (path form) +- No PocketBase schema or API rule changes needed — internal PB calls already use `query: { shareToken: token }` which leaves the existing M7 rule (`@request.query.shareToken`) intact +- Only two `quote/approve` call sites existed in the source tree; both are now path-based + +**Resolved:** v5.2 roadmap priority 1.7. + +## Execution Memo: 2026-07-06 - Step 1.4 Security Headers + CSP + +**Completed:** Added production security headers (CSP + X-Content-Type-Options + X-Frame-Options + Referrer-Policy + Permissions-Policy) and removed Toast's runtime ` + + +
+
+

INVOICE

+

${escapeHtml(inv.customerName)}

+
+
+ ${escapeHtml(inv.status)} +

Invoice #${escapeHtml(inv.id.slice(0, 8).toUpperCase())}

+
+
+ +
+
+

Bill To

+

${escapeHtml(inv.customerName)}

+ ${inv.customerPhone ? `

${escapeHtml(formatPhoneDisplay(inv.customerPhone))}

` : ''} + ${inv.customerEmail ? `

${escapeHtml(inv.customerEmail)}

` : ''} +
+
+

Invoice Details

+

Date: ${escapeHtml(fmtDate(inv.created || new Date().toISOString()))}

+

Due: ${escapeHtml(fmtDate(inv.dueDate))}

+

Vehicle: ${escapeHtml(inv.vehicleInfo || '—')}

+
+
+ + + + + + + + + + + + ${items + .map( + (it) => ` + + + + + + `, + ) + .join('')} + +
DescriptionQtyUnit PriceTotal
${escapeHtml(it.description || '—')}${escapeHtml(it.quantity)}${escapeHtml(fmtCurrency(it.unitPrice))}${escapeHtml(fmtCurrency(it.quantity * it.unitPrice))}
+ +
+
Subtotal${escapeHtml(fmtCurrency(subtotal))}
+
Tax (${(getTaxRate() * 100).toFixed(1)}%)${escapeHtml(fmtCurrency(tax))}
+
Total${escapeHtml(fmtCurrency(total))}
+
+ + ${inv.notes ? `

Notes

${escapeHtml(inv.notes)}

` : ''} + +
+ Thank you for your business! +
+ + + + + `); + printWindow.document.close(); + }; + + /* ── Render ───────────────────────────────────────── */ + + return ( + +
+ {/* ── Header ──────────────────────────────────── */} +
+
+

Invoices

+

+ Manage and send invoices to your customers. +

+
+
+ + +
+
+ + {/* ── Search ──────────────────────────────────── */} + {!loading && !loadError && invoices.length > 0 && ( +
+
+ + setSearch(e.target.value)} + placeholder="Search by customer name..." + className="w-full rounded-lg border border-gray-300 bg-white py-2.5 pl-10 pr-4 text-sm text-gray-900 placeholder-gray-400 focus:border-green-500 focus:outline-none focus:ring-1 focus:ring-green-500 dark:border-gray-600 dark:bg-gray-800 dark:text-white dark:placeholder-gray-500" + /> +
+
+ )} + + {/* ── States ──────────────────────────────────── */} + {loading && } + + {!loading && loadError && } + + {/* Setup state for missing collection */} + {!loading && !loadError && collectionMissing && ( +
+ +
+ )} + + {!loading && !loadError && !collectionMissing && filtered.length === 0 && !search && ( + + )} + + {!loading && !loadError && filtered.length === 0 && search && ( +
+ +

No matching invoices

+

+ No invoice records matched "{search}". +

+
+ )} + + {/* ── Invoice table ────────────────────────────── */} + {!loading && !loadError && filtered.length > 0 && ( +
+ {/* Desktop table */} +
+ + + + + + + + + + + + + + {filtered.map((inv) => ( + + + + + + + + + + ))} + +
Invoice #CustomerTotalStatusDateDue DateActions
+ #{inv.id.slice(0, 8).toUpperCase()} + + {inv.customerName || '—'} + + {fmtCurrency(inv.total || 0)} + + + + {fmtDate(inv.created)} + + {fmtDate(inv.dueDate)} + +
+ + + {/* Quick status change */} +
+ +
+ {['draft', 'sent', 'paid', 'overdue'].map((s) => ( + + ))} +
+
+ +
+
+
+ + {/* Mobile cards */} +
+ {filtered.map((inv) => ( +
+
+ #{inv.id.slice(0, 8).toUpperCase()} + +
+

{inv.customerName || '—'}

+

+ {fmtCurrency(inv.total || 0)} · {fmtDate(inv.created)} +

+
+ + + +
+
+ ))} +
+
+ )} + {hasMore && ( +
+ +
+ )} + + {/* ══════════════════════════════════════════════════ + CREATE / EDIT MODAL + ══════════════════════════════════════════════════ */} + + {showModal && ( +
+
+ {/* Header */} +
+

+ {editingId ? 'Edit Invoice' : 'New Invoice'} +

+ +
+ + {/* Body */} +
+ {/* Customer info */} +
+
+ + handleField('customerName', e.target.value)} + className={`w-full rounded-lg border px-3 py-2 text-sm text-gray-900 focus:outline-none focus:ring-1 dark:bg-gray-700 dark:text-white ${ + formErrors.customerName + ? 'border-red-400 focus:border-red-500 focus:ring-red-500' + : 'border-gray-300 focus:border-green-500 focus:ring-green-500 dark:border-gray-600' + }`} + placeholder="John Doe" + /> + {formErrors.customerName && ( +

{formErrors.customerName}

+ )} +
+
+ + handleField('vehicleInfo', e.target.value)} + className={`w-full rounded-lg border px-3 py-2 text-sm text-gray-900 focus:outline-none focus:ring-1 dark:bg-gray-700 dark:text-white ${ + formErrors.vehicleInfo + ? 'border-red-400 focus:border-red-500 focus:ring-red-500' + : 'border-gray-300 focus:border-green-500 focus:ring-green-500 dark:border-gray-600' + }`} + placeholder="2020 Honda Civic" + /> + {formErrors.vehicleInfo && ( +

{formErrors.vehicleInfo}

+ )} +
+
+ + handleField('customerEmail', e.target.value)} + className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm text-gray-900 focus:border-green-500 focus:outline-none focus:ring-1 focus:ring-green-500 dark:border-gray-600 dark:bg-gray-700 dark:text-white" + placeholder="john@example.com" + /> +
+
+ + handleField('customerPhone', e.target.value.replace(/\D/g, ''))} + className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm text-gray-900 focus:border-green-500 focus:outline-none focus:ring-1 focus:ring-green-500 dark:border-gray-600 dark:bg-gray-700 dark:text-white" + placeholder="(865) 555-0123" + /> +
+
+ + {/* Due date + Status */} +
+
+ + handleField('dueDate', e.target.value)} + className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm text-gray-900 focus:border-green-500 focus:outline-none focus:ring-1 focus:ring-green-500 dark:border-gray-600 dark:bg-gray-700 dark:text-white" + /> +
+
+ + +
+
+ + {/* Invoice items */} +
+
+ + +
+ {formErrors.items && ( +

{formErrors.items}

+ )} + + {form.items.map((item, idx) => ( +
+
+ + Item #{idx + 1} + + {form.items.length > 1 && ( + + )} +
+
+ handleItemField(idx, 'description', e.target.value)} + placeholder="Description of service or part" + className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm text-gray-900 focus:border-green-500 focus:outline-none focus:ring-1 focus:ring-green-500 dark:border-gray-600 dark:bg-gray-700 dark:text-white" + /> +
+
+ + + handleItemField(idx, 'quantity', parseFloat(e.target.value) || 0) + } + className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm text-gray-900 focus:border-green-500 focus:outline-none focus:ring-1 focus:ring-green-500 dark:border-gray-600 dark:bg-gray-700 dark:text-white" + /> +
+
+ + + handleItemField(idx, 'unitPrice', parseFloat(e.target.value) || 0) + } + className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm text-gray-900 focus:border-green-500 focus:outline-none focus:ring-1 focus:ring-green-500 dark:border-gray-600 dark:bg-gray-700 dark:text-white" + /> +
+
+ +
+ {fmtCurrency(item.quantity * item.unitPrice)} +
+
+
+
+
+ ))} +
+ + {/* Totals preview */} +
+
+
+ Subtotal + + {fmtCurrency(form.subtotal)} + +
+
+ Tax (8%) + + {fmtCurrency(form.tax)} + +
+
+ Total + + {fmtCurrency(form.total)} + +
+
+
+ + {/* Notes */} +
+ +