initial commit

This commit is contained in:
ray
2026-07-12 10:01:39 -04:00
commit fc8290d668
185 changed files with 38831 additions and 0 deletions
@@ -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 |
|------:|------|-------------|
| 125 | Imports | trimmed in orchestrator |
| 2792 | `CustomerRecord`, `VehicleRecord`, `RepairOrderRecord`, `QuoteRecord`, `CustomerWithVehicles` interfaces | stay in `Customers.tsx` (re-export) |
| 96122 | `formatVehicleLabel`, `vehicleKey`, `escapePbString`, `activeROFilterForCustomer` | `src/lib/customers.ts` |
| 126143 | `CustomersSkeleton` | `src/components/customers/EmptyStates.tsx` |
| 145185 | `EmptyState` | same file `EmptyStates.tsx` |
| 187189 | `ErrorState` | same file `EmptyStates.tsx` |
| 193270 | `CustomerCard` | `src/components/customers/CustomerCard.tsx` (memo) |
| 274441 | `CustomerFormData` interface + `CustomerFormModal` | interface stays in page re-export; component → `src/components/customers/CustomerFormModal.tsx` |
| 444668 | `VehicleFormData` interface + `VehicleFormModal` | interface → `src/lib/customers.ts` (or co-located); component → `src/components/customers/VehicleFormModal.tsx` |
| 672739 | `DeleteConfirmModal` | `src/components/customers/DeleteConfirmModal.tsx` |
| 743783 | `getRoStatusLabel`, `getRoStatusColor`, `formatDate` | `src/lib/customers.ts` |
| 7871170 | `CustomerDetailView` | `src/components/customers/CustomerDetailView.tsx` (memo) |
| 11741794 | 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 2792 (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<VehicleRecord, 'year' | 'make' | 'model' | 'vin'>): 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 126189:
```tsx
import { Search, Users, UserPlus } from 'lucide-react';
import { ListError } from '../ui/ListError';
export function CustomersSkeleton() {
return (
<div className="animate-pulse space-y-4">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
{Array.from({ length: 6 }).map((_, i) => (
<div
key={i}
className="rounded-xl bg-white p-5 ring-1 ring-gray-200 dark:bg-gray-800 dark:ring-gray-700"
>
<div className="mb-3 h-5 w-3/4 rounded bg-gray-200 dark:bg-gray-700" />
<div className="mb-2 h-4 w-1/2 rounded bg-gray-200 dark:bg-gray-700" />
<div className="h-4 w-1/3 rounded bg-gray-200 dark:bg-gray-700" />
</div>
))}
</div>
</div>
);
}
export function EmptyState({ searchTerm, onCreate }: { searchTerm: string; onCreate: () => void }) {
if (searchTerm) {
return (
<div className="flex flex-col items-center justify-center py-20 text-center">
<div className="mb-6 flex h-24 w-24 items-center justify-center rounded-full bg-blue-50 dark:bg-blue-900/20">
<Search className="h-12 w-12 text-blue-500 dark:text-blue-400" />
</div>
<h3 className="mb-2 text-lg font-semibold text-gray-900 dark:text-white">
No matching customers
</h3>
<p className="mb-2 max-w-sm text-sm text-gray-500 dark:text-gray-400">
No customer records matched &ldquo;{searchTerm}&rdquo;.
</p>
<p className="mb-8 text-sm text-gray-400 dark:text-gray-500">
Try searching by customer name, phone number, email address, vehicle, or VIN.
</p>
</div>
);
}
return (
<div className="flex flex-col items-center justify-center py-20 text-center">
<div className="mb-6 flex h-24 w-24 items-center justify-center rounded-full bg-green-50 dark:bg-green-900/20">
<Users className="h-12 w-12 text-green-500 dark:text-green-400" />
</div>
<h3 className="mb-2 text-lg font-semibold text-gray-900 dark:text-white">
No customer records on file
</h3>
<p className="mb-8 max-w-sm text-sm text-gray-500 dark:text-gray-400">
Add your first customer record to keep contact details, vehicles, quotes, and repair history organized in one place.
</p>
<button
onClick={onCreate}
className="inline-flex items-center gap-2 rounded-lg bg-green-600 px-5 py-2.5 text-sm font-medium text-white shadow-sm transition-colors hover:bg-green-700 focus:outline-none focus:ring-2 focus:ring-green-500 focus:ring-offset-2 dark:focus:ring-offset-gray-900"
>
<UserPlus className="h-4 w-4" />
Add Customer
</button>
</div>
);
}
export function ErrorState({ message, onRetry }: { message: string; onRetry: () => void }) {
return <ListError title="Unable to load customers" message={message} onRetry={onRetry} />;
}
```
**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 (
<div className="rounded-xl bg-white p-5 shadow-sm ring-1 ring-gray-200 transition-all hover:shadow-md dark:bg-gray-800 dark:ring-gray-700">
<div className="mb-3 flex items-start justify-between">
<div className="min-w-0 flex-1">
<h3
className="cursor-pointer truncate text-lg font-semibold text-gray-900 hover:text-green-600 dark:text-white dark:hover:text-green-400"
onClick={() => onView(customer.id)}
>
{customer.name}
</h3>
<p className="text-xs font-mono text-gray-400 dark:text-gray-500">ID: {customer.id.slice(0, 8)}</p>
<div className="mt-1 flex flex-wrap items-center gap-x-3 gap-y-1 text-sm text-gray-500 dark:text-gray-400">
{customer.phone && (
<span className="inline-flex items-center gap-1">
<Phone className="h-3.5 w-3.5" />
{formatPhoneDisplay(customer.phone)}
</span>
)}
{customer.email && (
<span className="inline-flex items-center gap-1 truncate">
<Mail className="h-3.5 w-3.5 shrink-0" />
{customer.email}
</span>
)}
</div>
</div>
</div>
<div className="mb-3 flex items-center gap-1.5 text-sm text-gray-500 dark:text-gray-400">
<Car className="h-4 w-4" />
<span>
{customer.vehicleCount === 0
? 'No vehicles'
: `${customer.vehicleCount} vehicle${customer.vehicleCount !== 1 ? 's' : ''}`}
</span>
{customer.vehicles.length > 0 && (
<span className="truncate text-gray-400 dark:text-gray-500">
&mdash; {formatVehicleLabel(customer.vehicles[0])}
</span>
)}
</div>
<div className="flex items-center gap-2 border-t border-gray-100 pt-3 dark:border-gray-700">
<button
onClick={() => onView(customer.id)}
className="inline-flex items-center gap-1.5 rounded-lg bg-green-600 px-3 py-1.5 text-xs font-medium text-white transition-colors hover:bg-green-700"
>
<FileText className="h-3.5 w-3.5" />
View Details
</button>
<button
onClick={() => onEdit(customer)}
className="inline-flex items-center gap-1.5 rounded-lg border border-gray-300 bg-white px-3 py-1.5 text-xs font-medium text-gray-700 transition-colors hover:bg-gray-50 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600"
>
<Pencil className="h-3.5 w-3.5" />
Edit
</button>
<button
onClick={() => onDelete(customer)}
className="ml-auto inline-flex items-center gap-1.5 rounded-lg px-3 py-1.5 text-xs font-medium text-red-600 transition-colors hover:bg-red-50 dark:text-red-400 dark:hover:bg-red-900/20"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</div>
</div>
);
}
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 282441 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<void>;
}) {
// ... identical body to current lines 293440 ...
}
```
Copy the JSX body verbatim (lines 293440 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 456668 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<void>;
}) {
// ... identical body to current lines 469667 ...
}
```
**Step 2:** Create `DeleteConfirmModal.tsx` — move lines 672739 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<void>;
}) {
// ... identical body to current lines 683738 ...
}
```
**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 7871170) 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<string | null>(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 8371168, 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 11741794) 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`, `<CustomerCard ... />` now refers to the memoized import.
- In `renderDetailView`, `<CustomerDetailView ... />` refers to the memoized import.
- In the modals footer, `<CustomerFormModal>`, `<VehicleFormModal>`, `<DeleteConfirmModal>` 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 ~520580 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.
@@ -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 `<OfflineBanner />` inside `<BrowserRouter>`, above the route content
**Steps:**
1. Create `src/components/OfflineBanner.tsx` per the roadmap's existing code spec (lines 1767-1785)
2. Add `<OfflineBanner />` to App.tsx just inside `<BrowserRouter>`, 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.**