initial commit
This commit is contained in:
@@ -0,0 +1,860 @@
|
||||
# Customers.tsx Memoize & Split Implementation Plan
|
||||
|
||||
> **For Hermes:** Use subagent-driven-development skill to implement this plan task-by-task.
|
||||
|
||||
**Goal:** Split `src/pages/Customers.tsx` (1,794 lines) into memoized sub-components under `src/components/customers/` and pure helpers under `src/lib/customers.ts`, exactly as was done for Appointments in the prior 2.4 Execution Memo — without changing any user-visible behavior.
|
||||
|
||||
**Architecture:** Extract pure utilities (formatters, PB filter builder, vehicle-info parser) into a `src/lib/customers.ts` module. Extract every inline sub-component (skeleton, empty/error states, card, form modals, delete dialog, detail view) into individual files under `src/components/customers/`, each wrapped in `React.memo` where it receives props. Wire them back into the orchestrator `Customers.tsx` via a barrel `index.ts`. Keep the public type exports (`CustomerRecord`, `CustomerWithVehicles`, `CustomerFormData`) re-exported from `Customers.tsx` so the 5 existing external importers are untouched.
|
||||
|
||||
**Tech Stack:** React 19, TypeScript, Tailwind v4, Vite 8, Vitest + jsdom.
|
||||
|
||||
**Constraints (from AGENTS.md):**
|
||||
- No PocketBase schema/migration changes — this is a pure frontend refactor.
|
||||
- Modal changes follow the background-location overlay pattern — but this split does NOT change any modal behavior; the extracted modal components keep their exact same markup and `fixed inset-0` overlay style.
|
||||
- Do not touch existing tests in `src/store/__tests__` or `src/lib/__tests__`.
|
||||
- Run `npm run build && npm run lint && npm run test` after the final task.
|
||||
|
||||
---
|
||||
|
||||
## Reference: existing extraction pattern
|
||||
|
||||
The proven pattern is in `src/components/appointments/` (created by the 2026-07-06 memo for Phase 2.4). Key conventions to reuse:
|
||||
- One component per file, `function FooImpl(...) { ... } export const Foo = memo(FooImpl)` for presentational row/card components.
|
||||
- Barrel `index.ts` re-exports.
|
||||
- `src/lib/appointments.ts` holds pure helpers; components import from there, the page imports helpers too.
|
||||
- Types stay where external consumers already import them from (the page file re-exports).
|
||||
|
||||
---
|
||||
|
||||
## File inventory (current — 1,794 lines)
|
||||
|
||||
| Lines | What | Destination |
|
||||
|------:|------|-------------|
|
||||
| 1–25 | Imports | trimmed in orchestrator |
|
||||
| 27–92 | `CustomerRecord`, `VehicleRecord`, `RepairOrderRecord`, `QuoteRecord`, `CustomerWithVehicles` interfaces | stay in `Customers.tsx` (re-export) |
|
||||
| 96–122 | `formatVehicleLabel`, `vehicleKey`, `escapePbString`, `activeROFilterForCustomer` | `src/lib/customers.ts` |
|
||||
| 126–143 | `CustomersSkeleton` | `src/components/customers/EmptyStates.tsx` |
|
||||
| 145–185 | `EmptyState` | same file `EmptyStates.tsx` |
|
||||
| 187–189 | `ErrorState` | same file `EmptyStates.tsx` |
|
||||
| 193–270 | `CustomerCard` | `src/components/customers/CustomerCard.tsx` (memo) |
|
||||
| 274–441 | `CustomerFormData` interface + `CustomerFormModal` | interface stays in page re-export; component → `src/components/customers/CustomerFormModal.tsx` |
|
||||
| 444–668 | `VehicleFormData` interface + `VehicleFormModal` | interface → `src/lib/customers.ts` (or co-located); component → `src/components/customers/VehicleFormModal.tsx` |
|
||||
| 672–739 | `DeleteConfirmModal` | `src/components/customers/DeleteConfirmModal.tsx` |
|
||||
| 743–783 | `getRoStatusLabel`, `getRoStatusColor`, `formatDate` | `src/lib/customers.ts` |
|
||||
| 787–1170 | `CustomerDetailView` | `src/components/customers/CustomerDetailView.tsx` (memo) |
|
||||
| 1174–1794 | main `Customers` component | stays in `Customers.tsx` as orchestrator |
|
||||
|
||||
### External importers of `Customers.tsx` (must not break)
|
||||
- `src/components/CustomerPicker.tsx:4` → `import type { CustomerWithVehicles }`
|
||||
- `src/pages/Appointments.tsx:9` → `import type { CustomerRecord, CustomerWithVehicles }`
|
||||
- `src/components/appointments/CheckInModal.tsx:8` → `import type { CustomerWithVehicles }`
|
||||
- `src/components/appointments/ScanScreenshotModal.tsx:8` → `import type { CustomerWithVehicles }`
|
||||
- `src/components/appointments/AppointmentModal.tsx:10` → `import type { CustomerWithVehicles, CustomerFormData }`
|
||||
|
||||
Resolution: `Customers.tsx` keeps `export interface CustomerRecord`, `export interface CustomerWithVehicles`, `export interface CustomerFormData` (re-export from a new `src/components/customers/types.ts` to avoid duplication — but re-export is enough; `import type` callers don't care where the symbol physically lives as long as the page re-exports it).
|
||||
|
||||
Decision: create `src/components/customers/types.ts` holding the shared interfaces; `Customers.tsx` does `export type { CustomerRecord, CustomerWithVehicles, CustomerFormData, VehicleRecord, RepairOrderRecord, QuoteRecord } from '../components/customers/types'`. External importers keep working unchanged.
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Create shared types module
|
||||
|
||||
**Objective:** Move the shared interfaces out of the page into a dedicated types file so both the page and the new component files import from one place.
|
||||
|
||||
**Files:**
|
||||
- Create: `src/components/customers/types.ts`
|
||||
- Modify: `src/pages/Customers.tsx` (replace the inline `interface` declarations with a re-export)
|
||||
|
||||
**Step 1:** Create `src/components/customers/types.ts`:
|
||||
|
||||
```ts
|
||||
export interface CustomerRecord {
|
||||
id: string;
|
||||
name: string;
|
||||
phone: string;
|
||||
email: string;
|
||||
address: string;
|
||||
notes: string;
|
||||
userId: string;
|
||||
created: string;
|
||||
updated: string;
|
||||
}
|
||||
|
||||
export interface VehicleRecord {
|
||||
id: string;
|
||||
customerId: string;
|
||||
userId?: string;
|
||||
make: string;
|
||||
model: string;
|
||||
year: string;
|
||||
vin: string;
|
||||
licensePlate: string;
|
||||
mileage: string;
|
||||
color: string;
|
||||
engine: string;
|
||||
}
|
||||
|
||||
export interface RepairOrderRecord {
|
||||
id: string;
|
||||
roNumber: string;
|
||||
customerId: string;
|
||||
customerName: string;
|
||||
vehicleInfo: string;
|
||||
vin: string;
|
||||
mileage: string;
|
||||
status: string;
|
||||
workStatus: string;
|
||||
createdAt: string;
|
||||
completedTime: string;
|
||||
writeupTime: string;
|
||||
technician: string;
|
||||
notes: string;
|
||||
services: string;
|
||||
total: number;
|
||||
}
|
||||
|
||||
export interface QuoteRecord {
|
||||
id: string;
|
||||
customerId: string;
|
||||
customerName: string;
|
||||
vehicleInfo: string;
|
||||
vin: string;
|
||||
mileage: string;
|
||||
status: string;
|
||||
createdAt: string;
|
||||
total: number;
|
||||
serviceAdvisor: string;
|
||||
services: string;
|
||||
notes: string;
|
||||
}
|
||||
|
||||
export interface CustomerWithVehicles extends CustomerRecord {
|
||||
vehicles: VehicleRecord[];
|
||||
vehicleCount: number;
|
||||
}
|
||||
|
||||
export interface CustomerFormData {
|
||||
name: string;
|
||||
phone: string;
|
||||
email: string;
|
||||
address: string;
|
||||
notes: string;
|
||||
}
|
||||
|
||||
export interface VehicleFormData {
|
||||
make: string;
|
||||
model: string;
|
||||
year: string;
|
||||
vin: string;
|
||||
licensePlate: string;
|
||||
mileage: string;
|
||||
color: string;
|
||||
engine: string;
|
||||
}
|
||||
```
|
||||
|
||||
**Step 2:** In `src/pages/Customers.tsx`, delete lines 27–92 (the inline interface declarations) and replace with:
|
||||
|
||||
```ts
|
||||
export type { CustomerRecord, CustomerWithVehicles, CustomerFormData } from '../components/customers/types';
|
||||
```
|
||||
|
||||
Also add at the top, after the existing imports:
|
||||
|
||||
```ts
|
||||
import type { VehicleRecord, RepairOrderRecord, QuoteRecord, CustomerWithVehicles, CustomerFormData, VehicleFormData } from '../components/customers/types';
|
||||
```
|
||||
|
||||
Keep the `export` on `CustomerRecord` so existing importers don't break — verify the line above re-exports all three externally used types: `CustomerRecord`, `CustomerWithVehicles`, `CustomerFormData`.
|
||||
|
||||
**Step 3 — Verify build:**
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
```
|
||||
Expected: PASS (tsc + vite, 0 errors). The type-only changes don't affect runtime.
|
||||
|
||||
**Step 4 — Commit:**
|
||||
|
||||
```bash
|
||||
git add src/components/customers/types.ts src/pages/Customers.tsx
|
||||
git commit -m "refactor(customers): extract shared interfaces to types module"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Create `src/lib/customers.ts` (pure helpers)
|
||||
|
||||
**Objective:** Move all pure functions out of the page so they can be shared by the new component files without a circular dependency.
|
||||
|
||||
**Files:**
|
||||
- Create: `src/lib/customers.ts`
|
||||
|
||||
**Step 1:** Create `src/lib/customers.ts` with these functions moved verbatim from `src/pages/Customers.tsx`:
|
||||
|
||||
```ts
|
||||
import type { VehicleRecord, RepairOrderRecord } from '../components/customers/types';
|
||||
|
||||
export function formatVehicleLabel(v: VehicleRecord): string {
|
||||
const parts = [v.year, v.make, v.model].filter(Boolean);
|
||||
return parts.length > 0 ? parts.join(' ') : 'Unknown vehicle';
|
||||
}
|
||||
|
||||
export function vehicleKey(v: Pick<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 126–189:
|
||||
|
||||
```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 “{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:**
|
||||
|
||||
```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">
|
||||
— {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 282–441 of `Customers.tsx`) verbatim. Replace the inline `CustomerFormData` type with an import:
|
||||
|
||||
```tsx
|
||||
import { useState, useEffect } from 'react';
|
||||
import { X } from 'lucide-react';
|
||||
import { formatPhoneInput } from '../../lib/phone';
|
||||
import type { CustomerFormData, CustomerWithVehicles } from './types';
|
||||
|
||||
export function CustomerFormModal({
|
||||
open,
|
||||
customer,
|
||||
onClose,
|
||||
onSave,
|
||||
}: {
|
||||
open: boolean;
|
||||
customer: CustomerWithVehicles | null;
|
||||
onClose: () => void;
|
||||
onSave: (data: CustomerFormData) => Promise<void>;
|
||||
}) {
|
||||
// ... identical body to current lines 293–440 ...
|
||||
}
|
||||
```
|
||||
|
||||
Copy the JSX body verbatim (lines 293–440 of `Customers.tsx`).
|
||||
|
||||
**Step 2 — Commit:**
|
||||
|
||||
```bash
|
||||
git add src/components/customers/CustomerFormModal.tsx
|
||||
git commit -m "refactor(customers): extract CustomerFormModal"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 6: Create `src/components/customers/VehicleFormModal.tsx` and `DeleteConfirmModal.tsx`
|
||||
|
||||
**Objective:** Extract the vehicle form and the delete-confirmation modal into their own files.
|
||||
|
||||
**Files:**
|
||||
- Create: `src/components/customers/VehicleFormModal.tsx`
|
||||
- Create: `src/components/customers/DeleteConfirmModal.tsx`
|
||||
|
||||
**Step 1:** Create `VehicleFormModal.tsx` — move lines 456–668 of `Customers.tsx` verbatim, importing `VehicleFormData` and `VehicleRecord` from `./types`:
|
||||
|
||||
```tsx
|
||||
import { useState, useEffect } from 'react';
|
||||
import { X } from 'lucide-react';
|
||||
import type { VehicleFormData, VehicleRecord } from './types';
|
||||
|
||||
export function VehicleFormModal({
|
||||
open,
|
||||
vehicle,
|
||||
customerName,
|
||||
onClose,
|
||||
onSave,
|
||||
}: {
|
||||
open: boolean;
|
||||
vehicle: VehicleRecord | null;
|
||||
customerName: string;
|
||||
onClose: () => void;
|
||||
onSave: (data: VehicleFormData) => Promise<void>;
|
||||
}) {
|
||||
// ... identical body to current lines 469–667 ...
|
||||
}
|
||||
```
|
||||
|
||||
**Step 2:** Create `DeleteConfirmModal.tsx` — move lines 672–739 verbatim:
|
||||
|
||||
```tsx
|
||||
import { useState } from 'react';
|
||||
import { AlertTriangle } from 'lucide-react';
|
||||
|
||||
export function DeleteConfirmModal({
|
||||
open,
|
||||
customerName,
|
||||
onClose,
|
||||
onConfirm,
|
||||
}: {
|
||||
open: boolean;
|
||||
customerName: string;
|
||||
onClose: () => void;
|
||||
onConfirm: () => Promise<void>;
|
||||
}) {
|
||||
// ... identical body to current lines 683–738 ...
|
||||
}
|
||||
```
|
||||
|
||||
**Step 3 — Commit:**
|
||||
|
||||
```bash
|
||||
git add src/components/customers/VehicleFormModal.tsx src/components/customers/DeleteConfirmModal.tsx
|
||||
git commit -m "refactor(customers): extract VehicleFormModal and DeleteConfirmModal"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 7: Create `src/components/customers/CustomerDetailView.tsx`
|
||||
|
||||
**Objective:** Extract the large detail view (lines 787–1170) into its own memoized component. It uses `getRoStatusLabel`, `getRoStatusColor`, `formatDate`, `parseQuoteServices` — all from `src/lib/customers.ts`. `formatDate` is renamed to `formatCustomerDate` in the helpers module to avoid a clash with `src/lib/format.ts`.
|
||||
|
||||
**Files:**
|
||||
- Create: `src/components/customers/CustomerDetailView.tsx`
|
||||
|
||||
**Step 1:** Create:
|
||||
|
||||
```tsx
|
||||
import { useState, memo } from 'react';
|
||||
import { Phone, Mail, MapPin, ChevronLeft, Pencil, Plus, FileText, Car, Trash2 } from 'lucide-react';
|
||||
import { formatPhoneDisplay } from '../../lib/phone';
|
||||
import {
|
||||
formatVehicleLabel,
|
||||
getRoStatusLabel,
|
||||
getRoStatusColor,
|
||||
formatCustomerDate,
|
||||
parseQuoteServices,
|
||||
} from '../../lib/customers';
|
||||
import type {
|
||||
CustomerWithVehicles,
|
||||
VehicleRecord,
|
||||
RepairOrderRecord,
|
||||
QuoteRecord,
|
||||
} from './types';
|
||||
|
||||
interface Props {
|
||||
customer: CustomerWithVehicles;
|
||||
vehicles: VehicleRecord[];
|
||||
repairOrders: RepairOrderRecord[];
|
||||
repairOrdersLoading: boolean;
|
||||
quotes: QuoteRecord[];
|
||||
quotesLoading: boolean;
|
||||
onBack: () => void;
|
||||
onEdit: (c: CustomerWithVehicles) => void;
|
||||
onAddVehicle: () => void;
|
||||
onEditVehicle: (v: VehicleRecord) => void;
|
||||
onDeleteVehicle: (v: VehicleRecord) => void;
|
||||
}
|
||||
|
||||
function CustomerDetailViewImpl({
|
||||
customer,
|
||||
vehicles,
|
||||
repairOrders,
|
||||
repairOrdersLoading,
|
||||
quotes,
|
||||
quotesLoading,
|
||||
onBack,
|
||||
onEdit,
|
||||
onAddVehicle,
|
||||
onEditVehicle,
|
||||
onDeleteVehicle,
|
||||
}: Props) {
|
||||
const [expandedQuoteId, setExpandedQuoteId] = useState<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:**
|
||||
|
||||
```bash
|
||||
git add src/components/customers/CustomerDetailView.tsx
|
||||
git commit -m "refactor(customers): extract memoized CustomerDetailView"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 8: Create `src/components/customers/index.ts` barrel
|
||||
|
||||
**Objective:** One import line in the orchestrator.
|
||||
|
||||
**Files:**
|
||||
- Create: `src/components/customers/index.ts`
|
||||
|
||||
**Step 1:**
|
||||
|
||||
```ts
|
||||
export { CustomersSkeleton, EmptyState, ErrorState } from './EmptyStates';
|
||||
export { CustomerCard } from './CustomerCard';
|
||||
export { CustomerFormModal } from './CustomerFormModal';
|
||||
export { VehicleFormModal } from './VehicleFormModal';
|
||||
export { DeleteConfirmModal } from './DeleteConfirmModal';
|
||||
export { CustomerDetailView } from './CustomerDetailView';
|
||||
export type {
|
||||
CustomerRecord,
|
||||
VehicleRecord,
|
||||
RepairOrderRecord,
|
||||
QuoteRecord,
|
||||
CustomerWithVehicles,
|
||||
CustomerFormData,
|
||||
VehicleFormData,
|
||||
} from './types';
|
||||
```
|
||||
|
||||
**Step 2 — Commit:**
|
||||
|
||||
```bash
|
||||
git add src/components/customers/index.ts
|
||||
git commit -m "refactor(customers): add barrel index"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 9: Rewrite `Customers.tsx` as the orchestrator
|
||||
|
||||
**Objective:** Remove every inline component and helper. Wire the extracted pieces. The main `Customers` component keeps all state, handlers, and the `renderListView`/`renderDetailView` orchestration. All handlers passed to memoized children are already wrapped in `useCallback`, so the memo is effective.
|
||||
|
||||
**Files:**
|
||||
- Modify (rewrite): `src/pages/Customers.tsx`
|
||||
|
||||
**Step 1:** Replace the top of the file (imports + old interface/helper/component declarations) with:
|
||||
|
||||
```tsx
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { X, Plus } from 'lucide-react';
|
||||
import { pb } from '../lib/pocketbase';
|
||||
import { useToast } from '../components/ui/Toast';
|
||||
import { getSetupRequiredMessage, isMissingCollectionError, logError } from '../lib/userMessages';
|
||||
import { customerWriteSchema } from '../schemas/customer';
|
||||
import {
|
||||
formatVehicleLabel,
|
||||
vehicleKey,
|
||||
escapePbString,
|
||||
activeROFilterForCustomer,
|
||||
} from '../lib/customers';
|
||||
import {
|
||||
CustomersSkeleton,
|
||||
EmptyState,
|
||||
ErrorState,
|
||||
CustomerCard,
|
||||
CustomerFormModal,
|
||||
VehicleFormModal,
|
||||
DeleteConfirmModal,
|
||||
CustomerDetailView,
|
||||
} from '../components/customers';
|
||||
|
||||
export type {
|
||||
CustomerRecord,
|
||||
CustomerWithVehicles,
|
||||
CustomerFormData,
|
||||
} from '../components/customers/types';
|
||||
|
||||
import type {
|
||||
CustomerWithVehicles,
|
||||
CustomerRecord,
|
||||
VehicleRecord,
|
||||
RepairOrderRecord,
|
||||
QuoteRecord,
|
||||
CustomerFormData,
|
||||
VehicleFormData,
|
||||
} from '../components/customers/types';
|
||||
```
|
||||
|
||||
**Step 2:** Keep the main `Customers` function body (lines 1174–1794) intact, with these adjustments:
|
||||
- Remove the now-extracted `CustomerCard`, `CustomerFormModal`, `VehicleFormModal`, `DeleteConfirmModal`, `CustomerDetailView` usages in the footer modals — replace with imported versions (same JSX, different source).
|
||||
- Every handler (`fetchAll`, `handleViewCustomer`, `handleAddCustomer`, `handleEditCustomer`, `handleDeleteCustomer`, `handleAddVehicle`, `handleEditVehicle`, `handleDeleteVehicle`) is already wrapped in `useCallback` — keep them.
|
||||
- In `renderListView`, `<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 ~520–580 lines (state + handlers + two render functions + modals footer).
|
||||
|
||||
**Step 4 — Lint:**
|
||||
|
||||
```bash
|
||||
npm run lint
|
||||
```
|
||||
Expected: PASS (0 new errors/warnings; the 2 pre-existing Toast.tsx / Settings.tsx warnings remain).
|
||||
|
||||
**Step 5 — Test:**
|
||||
|
||||
```bash
|
||||
npm run test
|
||||
```
|
||||
Expected: the full Vitest suite passes (86+ tests across 12+ files). No new tests are added in this refactor — there are no existing tests for `Customers.tsx` to break. The existing `src/schemas/__tests__/customer.test.ts` (if any) and `src/lib/__tests__/` and `src/store/__tests__/` suites must pass unchanged.
|
||||
|
||||
**Step 6 — Commit:**
|
||||
|
||||
```bash
|
||||
git add src/pages/Customers.tsx
|
||||
git commit -m "refactor(customers): rewrite orchestrator using extracted components"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 10: Verification & bundle-size check
|
||||
|
||||
**Objective:** Confirm the refactor produced a smaller Customers chunk and nothing else regressed.
|
||||
|
||||
**Step 1 — Full verification block:**
|
||||
|
||||
```bash
|
||||
npm run build && npm run lint && npm run test
|
||||
```
|
||||
Expected: all three pass.
|
||||
|
||||
**Step 2 — Inspect chunk sizes (same command from roadmap Appendix A):**
|
||||
|
||||
```bash
|
||||
du -sh dist/assets/
|
||||
ls -laS dist/assets/ | head -10
|
||||
for f in dist/assets/*.js; do
|
||||
printf "%-45s raw=%7d gzip=%7d\n" "$(basename $f)" "$(stat -c%s $f)" "$(gzip -c $f | wc -c)"
|
||||
done
|
||||
```
|
||||
|
||||
Expected: the Customers chunk is now smaller (the detail-view and form modal code is split into smaller lazy-ish chunks, though Vite may or may not split these into separate chunks without explicit `lazy()` — at minimum the page source file is smaller, which improves maintainability and re-render hotspots as described in roadmap 2.4).
|
||||
|
||||
**Step 3 — Manual smoke (optional):**
|
||||
|
||||
No behavior change is expected. If the user wants to verify, `npm run dev` and click through: list → search → customer card → detail view → add/edit customer modal → add/edit vehicle modal → delete customer modal. All UI should look identical.
|
||||
|
||||
**Step 4 — Commit (if any verification fixes):**
|
||||
|
||||
If any verification step required fixes, commit those. Otherwise skip this step.
|
||||
|
||||
---
|
||||
|
||||
## Risks, tradeoffs, and open questions
|
||||
|
||||
**Risks:**
|
||||
1. **External type imports break.** Five files import types from `'../pages/Customers'`. Mitigation: `Customers.tsx` re-exports all three types. Verified in Task 1's re-export line. If any external file still breaks, update its import to `'../components/customers'` (or `../../components/customers/types`) — but the re-export means no change should be needed.
|
||||
2. **`formatDate` rename.** `formatCustomerDate` avoids a clash with `src/lib/format.ts:formatDate` (used elsewhere in the app). All call sites inside `CustomerDetailView` must be updated.
|
||||
3. **`memo` effectiveness.** `CustomerCard` and `CustomerDetailView` will only skip re-renders if every prop is referentially stable. `customer`, `vehicles`, `repairOrders`, `quotes` come from `useState` and are only replaced on fetch — stable between renders. Callbacks (`onView`, `onEdit`, etc.) are wrapped in `useCallback`. The memo is real; if a future change introduces an inline callback, memo breaks silently.
|
||||
4. **No new tests are added.** Roadmap 2.4 for Appointments also didn't add tests for the extracted components; this matches the precedent. If the user wants snapshot tests for `CustomerCard`/`CustomerDetailView`, that's a separate task.
|
||||
|
||||
**Tradeoffs:**
|
||||
- More files to navigate, but each is smaller and single-purpose.
|
||||
- The barrel import makes tree-shaking easier if any component is lazy-loaded later.
|
||||
|
||||
**Open questions:** None. The pattern is proven on Appointments.tsx (Phase 2.4, 2026-07-06 memo). Customer behavior is unchanged.
|
||||
|
||||
---
|
||||
|
||||
## Verification summary
|
||||
|
||||
```
|
||||
npm run build → PASS (0 errors, 0 warnings new)
|
||||
npm run lint → PASS (baseline warnings only)
|
||||
npm run test → PASS (86+ tests; no regressions)
|
||||
```
|
||||
|
||||
**Resolved roadmap priority:** 2.4 (continues the mega-component split, Customers.tsx this time). Next up per the pattern: RepairOrders.tsx (already partially memoized — `FragmentRow`/`CompletedRow` exist) or QuoteGenerator.tsx.
|
||||
Reference in New Issue
Block a user