Files
spq-v2/.hermes/plans/2026-07-07_164530-customers-memo-split.md
T
2026-07-12 10:01:39 -04:00

860 lines
32 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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.