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

32 KiB
Raw Permalink Blame History

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:4import type { CustomerWithVehicles }
  • src/pages/Appointments.tsx:9import type { CustomerRecord, CustomerWithVehicles }
  • src/components/appointments/CheckInModal.tsx:8import type { CustomerWithVehicles }
  • src/components/appointments/ScanScreenshotModal.tsx:8import type { CustomerWithVehicles }
  • src/components/appointments/AppointmentModal.tsx:10import 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:

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:

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 126189:

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:

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">
            &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:

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:

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:

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:

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:

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:

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:

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:

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 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:

npm run build

Expected: PASS (0 errors). The orchestrator should be ~520580 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:

  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.