32 KiB
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-0overlay style. - Do not touch existing tests in
src/store/__tests__orsrc/lib/__tests__. - Run
npm run build && npm run lint && npm run testafter 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.tsre-exports. src/lib/appointments.tsholds 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 inlineinterfacedeclarations with a re-export)
Step 1: Create src/components/customers/types.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:
export type { CustomerRecord, CustomerWithVehicles, CustomerFormData } from '../components/customers/types';
Also add at the top, after the existing imports:
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:
npm run build
Expected: PASS (tsc + vite, 0 errors). The type-only changes don't affect runtime.
Step 4 — Commit:
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:
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:
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:
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:
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:
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 “{searchTerm}”.
</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:
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:
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">
— {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:
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:
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 293–440 ...
}
Copy the JSX body verbatim (lines 293–440 of Customers.tsx).
Step 2 — Commit:
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:
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 469–667 ...
}
Step 2: Create DeleteConfirmModal.tsx — move lines 672–739 verbatim:
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 683–738 ...
}
Step 3 — Commit:
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:
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 837–1168, replacing formatDate(...) calls
// with formatCustomerDate(...) calls, and the inline parseQuoteServices with
// the imported parseQuoteServices ...
);
}
export const CustomerDetailView = memo(CustomerDetailViewImpl);
Step 2 — Commit:
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:
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:
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:
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,CustomerDetailViewusages 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 inuseCallback— 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
formatVehicleLabelreference in the footer's "Delete Vehicle Confirmation" modal (line 1788:formatVehicleLabel(deletingVehicle)) now uses the import from../lib/customers.
Step 3 — Verify build:
npm run build
Expected: PASS (0 errors). The orchestrator should be ~520–580 lines (state + handlers + two render functions + modals footer).
Step 4 — Lint:
npm run lint
Expected: PASS (0 new errors/warnings; the 2 pre-existing Toast.tsx / Settings.tsx warnings remain).
Step 5 — Test:
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:
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:
npm run build && npm run lint && npm run test
Expected: all three pass.
Step 2 — Inspect chunk sizes (same command from roadmap Appendix A):
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:
- External type imports break. Five files import types from
'../pages/Customers'. Mitigation:Customers.tsxre-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. formatDaterename.formatCustomerDateavoids a clash withsrc/lib/format.ts:formatDate(used elsewhere in the app). All call sites insideCustomerDetailViewmust be updated.memoeffectiveness.CustomerCardandCustomerDetailViewwill only skip re-renders if every prop is referentially stable.customer,vehicles,repairOrders,quotescome fromuseStateand are only replaced on fetch — stable between renders. Callbacks (onView,onEdit, etc.) are wrapped inuseCallback. The memo is real; if a future change introduces an inline callback, memo breaks silently.- 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.