initial commit

This commit is contained in:
ray
2026-07-12 10:01:39 -04:00
commit fc8290d668
185 changed files with 38831 additions and 0 deletions
+19
View File
@@ -0,0 +1,19 @@
# PocketBase server URL
VITE_PB_URL=http://localhost:8090
# Application name (used in UI and document titles)
VITE_APP_NAME=ShopProQuote
# GlitchTip DSN for error tracking (set in production for step 3.1)
VITE_GLITCHTIP_DSN=
# Toggle AI assistant features on/off
VITE_AI_ENABLED=true
# Runtime environment: development / production
VITE_ENV=development
# ─── IMPORTANT ──────────────────────────────────────────────
# Server-side secrets (DEEPSEEK_API_KEY, PB_SMTP_*, etc.)
# MUST NEVER be prefixed with VITE_ — Vite ships every VITE_*
# variable into the client bundle where it is visible to users.
+25
View File
@@ -0,0 +1,25 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
.env.production
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
@@ -0,0 +1,860 @@
# Customers.tsx Memoize & Split Implementation Plan
> **For Hermes:** Use subagent-driven-development skill to implement this plan task-by-task.
**Goal:** Split `src/pages/Customers.tsx` (1,794 lines) into memoized sub-components under `src/components/customers/` and pure helpers under `src/lib/customers.ts`, exactly as was done for Appointments in the prior 2.4 Execution Memo — without changing any user-visible behavior.
**Architecture:** Extract pure utilities (formatters, PB filter builder, vehicle-info parser) into a `src/lib/customers.ts` module. Extract every inline sub-component (skeleton, empty/error states, card, form modals, delete dialog, detail view) into individual files under `src/components/customers/`, each wrapped in `React.memo` where it receives props. Wire them back into the orchestrator `Customers.tsx` via a barrel `index.ts`. Keep the public type exports (`CustomerRecord`, `CustomerWithVehicles`, `CustomerFormData`) re-exported from `Customers.tsx` so the 5 existing external importers are untouched.
**Tech Stack:** React 19, TypeScript, Tailwind v4, Vite 8, Vitest + jsdom.
**Constraints (from AGENTS.md):**
- No PocketBase schema/migration changes — this is a pure frontend refactor.
- Modal changes follow the background-location overlay pattern — but this split does NOT change any modal behavior; the extracted modal components keep their exact same markup and `fixed inset-0` overlay style.
- Do not touch existing tests in `src/store/__tests__` or `src/lib/__tests__`.
- Run `npm run build && npm run lint && npm run test` after the final task.
---
## Reference: existing extraction pattern
The proven pattern is in `src/components/appointments/` (created by the 2026-07-06 memo for Phase 2.4). Key conventions to reuse:
- One component per file, `function FooImpl(...) { ... } export const Foo = memo(FooImpl)` for presentational row/card components.
- Barrel `index.ts` re-exports.
- `src/lib/appointments.ts` holds pure helpers; components import from there, the page imports helpers too.
- Types stay where external consumers already import them from (the page file re-exports).
---
## File inventory (current — 1,794 lines)
| Lines | What | Destination |
|------:|------|-------------|
| 125 | Imports | trimmed in orchestrator |
| 2792 | `CustomerRecord`, `VehicleRecord`, `RepairOrderRecord`, `QuoteRecord`, `CustomerWithVehicles` interfaces | stay in `Customers.tsx` (re-export) |
| 96122 | `formatVehicleLabel`, `vehicleKey`, `escapePbString`, `activeROFilterForCustomer` | `src/lib/customers.ts` |
| 126143 | `CustomersSkeleton` | `src/components/customers/EmptyStates.tsx` |
| 145185 | `EmptyState` | same file `EmptyStates.tsx` |
| 187189 | `ErrorState` | same file `EmptyStates.tsx` |
| 193270 | `CustomerCard` | `src/components/customers/CustomerCard.tsx` (memo) |
| 274441 | `CustomerFormData` interface + `CustomerFormModal` | interface stays in page re-export; component → `src/components/customers/CustomerFormModal.tsx` |
| 444668 | `VehicleFormData` interface + `VehicleFormModal` | interface → `src/lib/customers.ts` (or co-located); component → `src/components/customers/VehicleFormModal.tsx` |
| 672739 | `DeleteConfirmModal` | `src/components/customers/DeleteConfirmModal.tsx` |
| 743783 | `getRoStatusLabel`, `getRoStatusColor`, `formatDate` | `src/lib/customers.ts` |
| 7871170 | `CustomerDetailView` | `src/components/customers/CustomerDetailView.tsx` (memo) |
| 11741794 | main `Customers` component | stays in `Customers.tsx` as orchestrator |
### External importers of `Customers.tsx` (must not break)
- `src/components/CustomerPicker.tsx:4``import type { CustomerWithVehicles }`
- `src/pages/Appointments.tsx:9``import type { CustomerRecord, CustomerWithVehicles }`
- `src/components/appointments/CheckInModal.tsx:8``import type { CustomerWithVehicles }`
- `src/components/appointments/ScanScreenshotModal.tsx:8``import type { CustomerWithVehicles }`
- `src/components/appointments/AppointmentModal.tsx:10``import type { CustomerWithVehicles, CustomerFormData }`
Resolution: `Customers.tsx` keeps `export interface CustomerRecord`, `export interface CustomerWithVehicles`, `export interface CustomerFormData` (re-export from a new `src/components/customers/types.ts` to avoid duplication — but re-export is enough; `import type` callers don't care where the symbol physically lives as long as the page re-exports it).
Decision: create `src/components/customers/types.ts` holding the shared interfaces; `Customers.tsx` does `export type { CustomerRecord, CustomerWithVehicles, CustomerFormData, VehicleRecord, RepairOrderRecord, QuoteRecord } from '../components/customers/types'`. External importers keep working unchanged.
---
## Task 1: Create shared types module
**Objective:** Move the shared interfaces out of the page into a dedicated types file so both the page and the new component files import from one place.
**Files:**
- Create: `src/components/customers/types.ts`
- Modify: `src/pages/Customers.tsx` (replace the inline `interface` declarations with a re-export)
**Step 1:** Create `src/components/customers/types.ts`:
```ts
export interface CustomerRecord {
id: string;
name: string;
phone: string;
email: string;
address: string;
notes: string;
userId: string;
created: string;
updated: string;
}
export interface VehicleRecord {
id: string;
customerId: string;
userId?: string;
make: string;
model: string;
year: string;
vin: string;
licensePlate: string;
mileage: string;
color: string;
engine: string;
}
export interface RepairOrderRecord {
id: string;
roNumber: string;
customerId: string;
customerName: string;
vehicleInfo: string;
vin: string;
mileage: string;
status: string;
workStatus: string;
createdAt: string;
completedTime: string;
writeupTime: string;
technician: string;
notes: string;
services: string;
total: number;
}
export interface QuoteRecord {
id: string;
customerId: string;
customerName: string;
vehicleInfo: string;
vin: string;
mileage: string;
status: string;
createdAt: string;
total: number;
serviceAdvisor: string;
services: string;
notes: string;
}
export interface CustomerWithVehicles extends CustomerRecord {
vehicles: VehicleRecord[];
vehicleCount: number;
}
export interface CustomerFormData {
name: string;
phone: string;
email: string;
address: string;
notes: string;
}
export interface VehicleFormData {
make: string;
model: string;
year: string;
vin: string;
licensePlate: string;
mileage: string;
color: string;
engine: string;
}
```
**Step 2:** In `src/pages/Customers.tsx`, delete lines 2792 (the inline interface declarations) and replace with:
```ts
export type { CustomerRecord, CustomerWithVehicles, CustomerFormData } from '../components/customers/types';
```
Also add at the top, after the existing imports:
```ts
import type { VehicleRecord, RepairOrderRecord, QuoteRecord, CustomerWithVehicles, CustomerFormData, VehicleFormData } from '../components/customers/types';
```
Keep the `export` on `CustomerRecord` so existing importers don't break — verify the line above re-exports all three externally used types: `CustomerRecord`, `CustomerWithVehicles`, `CustomerFormData`.
**Step 3 — Verify build:**
```bash
npm run build
```
Expected: PASS (tsc + vite, 0 errors). The type-only changes don't affect runtime.
**Step 4 — Commit:**
```bash
git add src/components/customers/types.ts src/pages/Customers.tsx
git commit -m "refactor(customers): extract shared interfaces to types module"
```
---
## Task 2: Create `src/lib/customers.ts` (pure helpers)
**Objective:** Move all pure functions out of the page so they can be shared by the new component files without a circular dependency.
**Files:**
- Create: `src/lib/customers.ts`
**Step 1:** Create `src/lib/customers.ts` with these functions moved verbatim from `src/pages/Customers.tsx`:
```ts
import type { VehicleRecord, RepairOrderRecord } from '../components/customers/types';
export function formatVehicleLabel(v: VehicleRecord): string {
const parts = [v.year, v.make, v.model].filter(Boolean);
return parts.length > 0 ? parts.join(' ') : 'Unknown vehicle';
}
export function vehicleKey(v: Pick<VehicleRecord, 'year' | 'make' | 'model' | 'vin'>): string {
return (v.vin || [v.year, v.make, v.model].filter(Boolean).join(' ')).trim().toLowerCase();
}
export function escapePbString(value: string): string {
return value.replace(/'/g, "\\'");
}
export function activeROFilterForCustomer(userId: string, customerId: string, previousName: string): string {
const activeStatuses = [
"status = 'active'",
"status = 'in_progress'",
"status = 'waiting_parts'",
"status = 'waiting_pickup'",
].join(' || ');
const identityFilters = [
`customerId = '${escapePbString(customerId)}'`,
previousName.trim() ? `customerName = '${escapePbString(previousName.trim())}'` : '',
].filter(Boolean).join(' || ');
return `userId = '${escapePbString(userId)}' && (${identityFilters}) && (${activeStatuses})`;
}
export function getRoStatusLabel(ro: RepairOrderRecord): string {
const s = ro.workStatus || ro.status || '';
switch (s) {
case 'active': case 'open': return 'Open';
case 'in-progress': case 'in_progress': return 'In Progress';
case 'waiter': case 'waiting_parts': return 'Waiting Parts';
case 'completed': return 'Completed';
case 'final_close':
case 'delivered': return 'Final Close';
case 'cancelled': return 'Cancelled';
default: return s || 'Unknown';
}
}
export function getRoStatusColor(ro: RepairOrderRecord): string {
const s = ro.workStatus || ro.status || '';
switch (s) {
case 'active': case 'open':
return 'bg-blue-50 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400';
case 'in-progress': case 'in_progress':
return 'bg-yellow-50 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400';
case 'waiter': case 'waiting_parts':
return 'bg-orange-50 text-orange-700 dark:bg-orange-900/30 dark:text-orange-400';
case 'completed': case 'final_close': case 'delivered':
return 'bg-green-50 text-green-700 dark:bg-green-900/30 dark:text-green-400';
case 'cancelled':
return 'bg-red-50 text-red-700 dark:bg-red-900/30 dark:text-red-400';
default:
return 'bg-gray-50 text-gray-700 dark:bg-gray-900/30 dark:text-gray-400';
}
}
export function formatCustomerDate(dateStr: string): string {
if (!dateStr) return '\u2014';
try {
const d = new Date(dateStr);
return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
} catch {
return dateStr;
}
}
export function parseQuoteServices(raw: unknown): { name: string; price: number; customerDecision: string }[] {
if (!raw) return [];
let items = raw;
if (typeof raw === 'string') {
try { items = JSON.parse(raw); } catch { return []; }
}
if (!Array.isArray(items)) return [];
return items.map((s: any) => ({
name: s.name || 'Service',
price: typeof s.price === 'number' ? s.price : 0,
customerDecision: s.customerDecision || 'pending',
}));
}
```
Note: `formatDate` is renamed to `formatCustomerDate` to avoid a name clash with `src/lib/format.ts` exports. The detail view uses it locally; the move forces a rename. Keep the call-site reference updated in Task 7.
**Step 2 — Verify build:**
```bash
npm run build
```
Expected: FAIL — because `Customers.tsx` still has the old inline definitions; that's fine, we'll remove them in Task 8. For now just confirm `src/lib/customers.ts` compiles in isolation by running:
```bash
npx tsc --noEmit src/lib/customers.ts
```
If tsc complains about project settings, skip this sub-step — the final build happens at the end.
**Step 3 — Commit:**
```bash
git add src/lib/customers.ts
git commit -m "refactor(customers): add pure helpers module"
```
---
## Task 3: Create `src/components/customers/EmptyStates.tsx`
**Objective:** Extract the three state-display components (skeleton, empty, error) into one file (same pattern as `src/components/appointments/EmptyStates.tsx`).
**Files:**
- Create: `src/components/customers/EmptyStates.tsx`
**Step 1:** Create the file, moving the JSX verbatim from `Customers.tsx` lines 126189:
```tsx
import { Search, Users, UserPlus } from 'lucide-react';
import { ListError } from '../ui/ListError';
export function CustomersSkeleton() {
return (
<div className="animate-pulse space-y-4">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
{Array.from({ length: 6 }).map((_, i) => (
<div
key={i}
className="rounded-xl bg-white p-5 ring-1 ring-gray-200 dark:bg-gray-800 dark:ring-gray-700"
>
<div className="mb-3 h-5 w-3/4 rounded bg-gray-200 dark:bg-gray-700" />
<div className="mb-2 h-4 w-1/2 rounded bg-gray-200 dark:bg-gray-700" />
<div className="h-4 w-1/3 rounded bg-gray-200 dark:bg-gray-700" />
</div>
))}
</div>
</div>
);
}
export function EmptyState({ searchTerm, onCreate }: { searchTerm: string; onCreate: () => void }) {
if (searchTerm) {
return (
<div className="flex flex-col items-center justify-center py-20 text-center">
<div className="mb-6 flex h-24 w-24 items-center justify-center rounded-full bg-blue-50 dark:bg-blue-900/20">
<Search className="h-12 w-12 text-blue-500 dark:text-blue-400" />
</div>
<h3 className="mb-2 text-lg font-semibold text-gray-900 dark:text-white">
No matching customers
</h3>
<p className="mb-2 max-w-sm text-sm text-gray-500 dark:text-gray-400">
No customer records matched &ldquo;{searchTerm}&rdquo;.
</p>
<p className="mb-8 text-sm text-gray-400 dark:text-gray-500">
Try searching by customer name, phone number, email address, vehicle, or VIN.
</p>
</div>
);
}
return (
<div className="flex flex-col items-center justify-center py-20 text-center">
<div className="mb-6 flex h-24 w-24 items-center justify-center rounded-full bg-green-50 dark:bg-green-900/20">
<Users className="h-12 w-12 text-green-500 dark:text-green-400" />
</div>
<h3 className="mb-2 text-lg font-semibold text-gray-900 dark:text-white">
No customer records on file
</h3>
<p className="mb-8 max-w-sm text-sm text-gray-500 dark:text-gray-400">
Add your first customer record to keep contact details, vehicles, quotes, and repair history organized in one place.
</p>
<button
onClick={onCreate}
className="inline-flex items-center gap-2 rounded-lg bg-green-600 px-5 py-2.5 text-sm font-medium text-white shadow-sm transition-colors hover:bg-green-700 focus:outline-none focus:ring-2 focus:ring-green-500 focus:ring-offset-2 dark:focus:ring-offset-gray-900"
>
<UserPlus className="h-4 w-4" />
Add Customer
</button>
</div>
);
}
export function ErrorState({ message, onRetry }: { message: string; onRetry: () => void }) {
return <ListError title="Unable to load customers" message={message} onRetry={onRetry} />;
}
```
**Step 2 — Commit:**
```bash
git add src/components/customers/EmptyStates.tsx
git commit -m "refactor(customers): extract EmptyStates components"
```
---
## Task 4: Create `src/components/customers/CustomerCard.tsx`
**Objective:** Extract the card used in the list grid, wrapped in `React.memo`.
**Files:**
- Create: `src/components/customers/CustomerCard.tsx`
**Step 1:** Create:
```tsx
import { memo } from 'react';
import { Phone, Mail, Car, FileText, Pencil, Trash2 } from 'lucide-react';
import { formatPhoneDisplay } from '../../lib/phone';
import { formatVehicleLabel } from '../../lib/customers';
import type { CustomerWithVehicles } from './types';
interface Props {
customer: CustomerWithVehicles;
onView: (id: string) => void;
onEdit: (c: CustomerWithVehicles) => void;
onDelete: (c: CustomerWithVehicles) => void;
}
function CustomerCardImpl({ customer, onView, onEdit, onDelete }: Props) {
return (
<div className="rounded-xl bg-white p-5 shadow-sm ring-1 ring-gray-200 transition-all hover:shadow-md dark:bg-gray-800 dark:ring-gray-700">
<div className="mb-3 flex items-start justify-between">
<div className="min-w-0 flex-1">
<h3
className="cursor-pointer truncate text-lg font-semibold text-gray-900 hover:text-green-600 dark:text-white dark:hover:text-green-400"
onClick={() => onView(customer.id)}
>
{customer.name}
</h3>
<p className="text-xs font-mono text-gray-400 dark:text-gray-500">ID: {customer.id.slice(0, 8)}</p>
<div className="mt-1 flex flex-wrap items-center gap-x-3 gap-y-1 text-sm text-gray-500 dark:text-gray-400">
{customer.phone && (
<span className="inline-flex items-center gap-1">
<Phone className="h-3.5 w-3.5" />
{formatPhoneDisplay(customer.phone)}
</span>
)}
{customer.email && (
<span className="inline-flex items-center gap-1 truncate">
<Mail className="h-3.5 w-3.5 shrink-0" />
{customer.email}
</span>
)}
</div>
</div>
</div>
<div className="mb-3 flex items-center gap-1.5 text-sm text-gray-500 dark:text-gray-400">
<Car className="h-4 w-4" />
<span>
{customer.vehicleCount === 0
? 'No vehicles'
: `${customer.vehicleCount} vehicle${customer.vehicleCount !== 1 ? 's' : ''}`}
</span>
{customer.vehicles.length > 0 && (
<span className="truncate text-gray-400 dark:text-gray-500">
&mdash; {formatVehicleLabel(customer.vehicles[0])}
</span>
)}
</div>
<div className="flex items-center gap-2 border-t border-gray-100 pt-3 dark:border-gray-700">
<button
onClick={() => onView(customer.id)}
className="inline-flex items-center gap-1.5 rounded-lg bg-green-600 px-3 py-1.5 text-xs font-medium text-white transition-colors hover:bg-green-700"
>
<FileText className="h-3.5 w-3.5" />
View Details
</button>
<button
onClick={() => onEdit(customer)}
className="inline-flex items-center gap-1.5 rounded-lg border border-gray-300 bg-white px-3 py-1.5 text-xs font-medium text-gray-700 transition-colors hover:bg-gray-50 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600"
>
<Pencil className="h-3.5 w-3.5" />
Edit
</button>
<button
onClick={() => onDelete(customer)}
className="ml-auto inline-flex items-center gap-1.5 rounded-lg px-3 py-1.5 text-xs font-medium text-red-600 transition-colors hover:bg-red-50 dark:text-red-400 dark:hover:bg-red-900/20"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</div>
</div>
);
}
export const CustomerCard = memo(CustomerCardImpl);
```
**Step 2 — Commit:**
```bash
git add src/components/customers/CustomerCard.tsx
git commit -m "refactor(customers): extract memoized CustomerCard"
```
---
## Task 5: Create `src/components/customers/CustomerFormModal.tsx`
**Objective:** Extract the Add/Edit Customer form modal. **It is NOT wrapped in `memo`** — it holds its own state (`useState`) and re-renders only when its `open` prop flips, so memo would add nothing. Keep the exact same JSX and behavior.
**Files:**
- Create: `src/components/customers/CustomerFormModal.tsx`
**Step 1:** Create the file moving `CustomerFormModal` (lines 282441 of `Customers.tsx`) verbatim. Replace the inline `CustomerFormData` type with an import:
```tsx
import { useState, useEffect } from 'react';
import { X } from 'lucide-react';
import { formatPhoneInput } from '../../lib/phone';
import type { CustomerFormData, CustomerWithVehicles } from './types';
export function CustomerFormModal({
open,
customer,
onClose,
onSave,
}: {
open: boolean;
customer: CustomerWithVehicles | null;
onClose: () => void;
onSave: (data: CustomerFormData) => Promise<void>;
}) {
// ... identical body to current lines 293440 ...
}
```
Copy the JSX body verbatim (lines 293440 of `Customers.tsx`).
**Step 2 — Commit:**
```bash
git add src/components/customers/CustomerFormModal.tsx
git commit -m "refactor(customers): extract CustomerFormModal"
```
---
## Task 6: Create `src/components/customers/VehicleFormModal.tsx` and `DeleteConfirmModal.tsx`
**Objective:** Extract the vehicle form and the delete-confirmation modal into their own files.
**Files:**
- Create: `src/components/customers/VehicleFormModal.tsx`
- Create: `src/components/customers/DeleteConfirmModal.tsx`
**Step 1:** Create `VehicleFormModal.tsx` — move lines 456668 of `Customers.tsx` verbatim, importing `VehicleFormData` and `VehicleRecord` from `./types`:
```tsx
import { useState, useEffect } from 'react';
import { X } from 'lucide-react';
import type { VehicleFormData, VehicleRecord } from './types';
export function VehicleFormModal({
open,
vehicle,
customerName,
onClose,
onSave,
}: {
open: boolean;
vehicle: VehicleRecord | null;
customerName: string;
onClose: () => void;
onSave: (data: VehicleFormData) => Promise<void>;
}) {
// ... identical body to current lines 469667 ...
}
```
**Step 2:** Create `DeleteConfirmModal.tsx` — move lines 672739 verbatim:
```tsx
import { useState } from 'react';
import { AlertTriangle } from 'lucide-react';
export function DeleteConfirmModal({
open,
customerName,
onClose,
onConfirm,
}: {
open: boolean;
customerName: string;
onClose: () => void;
onConfirm: () => Promise<void>;
}) {
// ... identical body to current lines 683738 ...
}
```
**Step 3 — Commit:**
```bash
git add src/components/customers/VehicleFormModal.tsx src/components/customers/DeleteConfirmModal.tsx
git commit -m "refactor(customers): extract VehicleFormModal and DeleteConfirmModal"
```
---
## Task 7: Create `src/components/customers/CustomerDetailView.tsx`
**Objective:** Extract the large detail view (lines 7871170) into its own memoized component. It uses `getRoStatusLabel`, `getRoStatusColor`, `formatDate`, `parseQuoteServices` — all from `src/lib/customers.ts`. `formatDate` is renamed to `formatCustomerDate` in the helpers module to avoid a clash with `src/lib/format.ts`.
**Files:**
- Create: `src/components/customers/CustomerDetailView.tsx`
**Step 1:** Create:
```tsx
import { useState, memo } from 'react';
import { Phone, Mail, MapPin, ChevronLeft, Pencil, Plus, FileText, Car, Trash2 } from 'lucide-react';
import { formatPhoneDisplay } from '../../lib/phone';
import {
formatVehicleLabel,
getRoStatusLabel,
getRoStatusColor,
formatCustomerDate,
parseQuoteServices,
} from '../../lib/customers';
import type {
CustomerWithVehicles,
VehicleRecord,
RepairOrderRecord,
QuoteRecord,
} from './types';
interface Props {
customer: CustomerWithVehicles;
vehicles: VehicleRecord[];
repairOrders: RepairOrderRecord[];
repairOrdersLoading: boolean;
quotes: QuoteRecord[];
quotesLoading: boolean;
onBack: () => void;
onEdit: (c: CustomerWithVehicles) => void;
onAddVehicle: () => void;
onEditVehicle: (v: VehicleRecord) => void;
onDeleteVehicle: (v: VehicleRecord) => void;
}
function CustomerDetailViewImpl({
customer,
vehicles,
repairOrders,
repairOrdersLoading,
quotes,
quotesLoading,
onBack,
onEdit,
onAddVehicle,
onEditVehicle,
onDeleteVehicle,
}: Props) {
const [expandedQuoteId, setExpandedQuoteId] = useState<string | null>(null);
const toggleQuote = (id: string) => setExpandedQuoteId(expandedQuoteId === id ? null : id);
function formatCurrency(n: number): string {
return '$' + n.toFixed(2);
}
return (
// ... copy JSX verbatim from lines 8371168, replacing formatDate(...) calls
// with formatCustomerDate(...) calls, and the inline parseQuoteServices with
// the imported parseQuoteServices ...
);
}
export const CustomerDetailView = memo(CustomerDetailViewImpl);
```
**Step 2 — Commit:**
```bash
git add src/components/customers/CustomerDetailView.tsx
git commit -m "refactor(customers): extract memoized CustomerDetailView"
```
---
## Task 8: Create `src/components/customers/index.ts` barrel
**Objective:** One import line in the orchestrator.
**Files:**
- Create: `src/components/customers/index.ts`
**Step 1:**
```ts
export { CustomersSkeleton, EmptyState, ErrorState } from './EmptyStates';
export { CustomerCard } from './CustomerCard';
export { CustomerFormModal } from './CustomerFormModal';
export { VehicleFormModal } from './VehicleFormModal';
export { DeleteConfirmModal } from './DeleteConfirmModal';
export { CustomerDetailView } from './CustomerDetailView';
export type {
CustomerRecord,
VehicleRecord,
RepairOrderRecord,
QuoteRecord,
CustomerWithVehicles,
CustomerFormData,
VehicleFormData,
} from './types';
```
**Step 2 — Commit:**
```bash
git add src/components/customers/index.ts
git commit -m "refactor(customers): add barrel index"
```
---
## Task 9: Rewrite `Customers.tsx` as the orchestrator
**Objective:** Remove every inline component and helper. Wire the extracted pieces. The main `Customers` component keeps all state, handlers, and the `renderListView`/`renderDetailView` orchestration. All handlers passed to memoized children are already wrapped in `useCallback`, so the memo is effective.
**Files:**
- Modify (rewrite): `src/pages/Customers.tsx`
**Step 1:** Replace the top of the file (imports + old interface/helper/component declarations) with:
```tsx
import { useState, useEffect, useCallback, useRef } from 'react';
import { useSearchParams } from 'react-router-dom';
import { X, Plus } from 'lucide-react';
import { pb } from '../lib/pocketbase';
import { useToast } from '../components/ui/Toast';
import { getSetupRequiredMessage, isMissingCollectionError, logError } from '../lib/userMessages';
import { customerWriteSchema } from '../schemas/customer';
import {
formatVehicleLabel,
vehicleKey,
escapePbString,
activeROFilterForCustomer,
} from '../lib/customers';
import {
CustomersSkeleton,
EmptyState,
ErrorState,
CustomerCard,
CustomerFormModal,
VehicleFormModal,
DeleteConfirmModal,
CustomerDetailView,
} from '../components/customers';
export type {
CustomerRecord,
CustomerWithVehicles,
CustomerFormData,
} from '../components/customers/types';
import type {
CustomerWithVehicles,
CustomerRecord,
VehicleRecord,
RepairOrderRecord,
QuoteRecord,
CustomerFormData,
VehicleFormData,
} from '../components/customers/types';
```
**Step 2:** Keep the main `Customers` function body (lines 11741794) intact, with these adjustments:
- Remove the now-extracted `CustomerCard`, `CustomerFormModal`, `VehicleFormModal`, `DeleteConfirmModal`, `CustomerDetailView` usages in the footer modals — replace with imported versions (same JSX, different source).
- Every handler (`fetchAll`, `handleViewCustomer`, `handleAddCustomer`, `handleEditCustomer`, `handleDeleteCustomer`, `handleAddVehicle`, `handleEditVehicle`, `handleDeleteVehicle`) is already wrapped in `useCallback` — keep them.
- In `renderListView`, `<CustomerCard ... />` now refers to the memoized import.
- In `renderDetailView`, `<CustomerDetailView ... />` refers to the memoized import.
- In the modals footer, `<CustomerFormModal>`, `<VehicleFormModal>`, `<DeleteConfirmModal>` refer to the imports.
- The `formatVehicleLabel` reference in the footer's "Delete Vehicle Confirmation" modal (line 1788: `formatVehicleLabel(deletingVehicle)`) now uses the import from `../lib/customers`.
**Step 3 — Verify build:**
```bash
npm run build
```
Expected: PASS (0 errors). The orchestrator should be ~520580 lines (state + handlers + two render functions + modals footer).
**Step 4 — Lint:**
```bash
npm run lint
```
Expected: PASS (0 new errors/warnings; the 2 pre-existing Toast.tsx / Settings.tsx warnings remain).
**Step 5 — Test:**
```bash
npm run test
```
Expected: the full Vitest suite passes (86+ tests across 12+ files). No new tests are added in this refactor — there are no existing tests for `Customers.tsx` to break. The existing `src/schemas/__tests__/customer.test.ts` (if any) and `src/lib/__tests__/` and `src/store/__tests__/` suites must pass unchanged.
**Step 6 — Commit:**
```bash
git add src/pages/Customers.tsx
git commit -m "refactor(customers): rewrite orchestrator using extracted components"
```
---
## Task 10: Verification & bundle-size check
**Objective:** Confirm the refactor produced a smaller Customers chunk and nothing else regressed.
**Step 1 — Full verification block:**
```bash
npm run build && npm run lint && npm run test
```
Expected: all three pass.
**Step 2 — Inspect chunk sizes (same command from roadmap Appendix A):**
```bash
du -sh dist/assets/
ls -laS dist/assets/ | head -10
for f in dist/assets/*.js; do
printf "%-45s raw=%7d gzip=%7d\n" "$(basename $f)" "$(stat -c%s $f)" "$(gzip -c $f | wc -c)"
done
```
Expected: the Customers chunk is now smaller (the detail-view and form modal code is split into smaller lazy-ish chunks, though Vite may or may not split these into separate chunks without explicit `lazy()` — at minimum the page source file is smaller, which improves maintainability and re-render hotspots as described in roadmap 2.4).
**Step 3 — Manual smoke (optional):**
No behavior change is expected. If the user wants to verify, `npm run dev` and click through: list → search → customer card → detail view → add/edit customer modal → add/edit vehicle modal → delete customer modal. All UI should look identical.
**Step 4 — Commit (if any verification fixes):**
If any verification step required fixes, commit those. Otherwise skip this step.
---
## Risks, tradeoffs, and open questions
**Risks:**
1. **External type imports break.** Five files import types from `'../pages/Customers'`. Mitigation: `Customers.tsx` re-exports all three types. Verified in Task 1's re-export line. If any external file still breaks, update its import to `'../components/customers'` (or `../../components/customers/types`) — but the re-export means no change should be needed.
2. **`formatDate` rename.** `formatCustomerDate` avoids a clash with `src/lib/format.ts:formatDate` (used elsewhere in the app). All call sites inside `CustomerDetailView` must be updated.
3. **`memo` effectiveness.** `CustomerCard` and `CustomerDetailView` will only skip re-renders if every prop is referentially stable. `customer`, `vehicles`, `repairOrders`, `quotes` come from `useState` and are only replaced on fetch — stable between renders. Callbacks (`onView`, `onEdit`, etc.) are wrapped in `useCallback`. The memo is real; if a future change introduces an inline callback, memo breaks silently.
4. **No new tests are added.** Roadmap 2.4 for Appointments also didn't add tests for the extracted components; this matches the precedent. If the user wants snapshot tests for `CustomerCard`/`CustomerDetailView`, that's a separate task.
**Tradeoffs:**
- More files to navigate, but each is smaller and single-purpose.
- The barrel import makes tree-shaking easier if any component is lazy-loaded later.
**Open questions:** None. The pattern is proven on Appointments.tsx (Phase 2.4, 2026-07-06 memo). Customer behavior is unchanged.
---
## Verification summary
```
npm run build → PASS (0 errors, 0 warnings new)
npm run lint → PASS (baseline warnings only)
npm run test → PASS (86+ tests; no regressions)
```
**Resolved roadmap priority:** 2.4 (continues the mega-component split, Customers.tsx this time). Next up per the pattern: RepairOrders.tsx (already partially memoized — `FragmentRow`/`CompletedRow` exist) or QuoteGenerator.tsx.
@@ -0,0 +1,97 @@
# Plan: Phase 3 Frontend + Roadmap Status Correction
**Date:** 2026-07-07
**Scope:** Correct the stale roadmap status table, then complete the two frontend-only Phase 3 items that need no server approval.
## Part A — Roadmap Status Correction (no code changes)
The roadmap status table (lines ~2100-2141) is stale. Several items marked "NOT DONE — needs approval" are actually installed and running on the server. Update the table to reflect verified reality:
| # | Current status line | Corrected status | Evidence |
|---|--------------------|-----------------|----------|
| 1.2 | "⚠️ PARTIAL — frontend done, server pending approval" | "✅ DONE" | `/opt/spq/ai-proxy/validate.js` exists, `spq-ai-validator.service` enabled+active, `spq-deepseek.conf` + `deepseek-key.conf` in nginx, `/deepseek/` location in shopproquote site |
| 1.3 | "❌ NOT DONE — needs PB migration + approval" | "✅ DONE" | `pb_migrations/1740000000020_users_create_role_guard.js` exists, applied in PB container (`migrate up` → "No new migrations to apply") |
| 1.4 | "⚠️ PARTIAL — frontend cleanup done, nginx pending" | "✅ DONE" | `/etc/nginx/snippets/spq-security.conf` exists with CSP + all headers, included via shopproquote site |
| 3.3 | "❓ UNKNOWN — server-side" | "✅ DONE" | `/etc/nginx/sites-enabled/shopproquote` serves dist/ on :443 with TLS + SPA fallback |
| 3.4 | "❓ UNKNOWN — server-side" | "✅ DONE" | `/pb/` and `/api/` reverse-proxy locations in shopproquote site; SSE (Upgrade/Connection headers) configured |
**Action:** Update the status table in `roadmap_5.2.md` lines ~2105-2134 to mark these 5 items as ✅ DONE with evidence. No code changes.
## Part B — Phase 3 Frontend-Only Items (no approval needed)
### B1: 3.6 Offline Banner
**Why first:** Pure frontend, zero approval, ~15 lines.
**Files:**
- New: `src/components/OfflineBanner.tsx` (per roadmap spec — `navigator.onLine` + `online`/`offline` event listeners, sticky amber banner when offline)
- Edit: `src/App.tsx` — import and render `<OfflineBanner />` inside `<BrowserRouter>`, above the route content
**Steps:**
1. Create `src/components/OfflineBanner.tsx` per the roadmap's existing code spec (lines 1767-1785)
2. Add `<OfflineBanner />` to App.tsx just inside `<BrowserRouter>`, above the layout/routes
3. Build + lint + test
**Verify:**
```
npm run build → passes
npm run lint → no new warnings
npm run test → 86 tests still pass
# Manual: toggle network off in DevTools → amber banner appears; on → disappears
```
### B2: 3.7 Dependency Audit
**Why:** `npm audit` already clean (0 vulnerabilities). Only minor version bumps remain — safe to apply.
**Current outdated (from `npm outdated`):**
| Package | Current | Latest | Risk |
|---------|--------|--------|------|
| @tailwindcss/vite | 4.3.1 | 4.3.2 | patch — safe |
| @types/node | 24.13.2 | 26.1.0 | major — SKIP (type-only, bleeding edge) |
| lucide-react | 1.21.0 | 1.23.0 | minor — safe |
| oxlint | 1.71.0 | 1.73.0 | minor — safe |
| react-router-dom | 7.18.0 | 7.18.1 | patch — safe |
| tailwindcss | 4.3.1 | 4.3.2 | patch — safe |
| vite | 8.1.0 | 8.1.3 | patch — safe |
| vitest | 4.1.9 | 4.1.10 | patch — safe |
**Steps:**
1. Run `npm audit` — confirm 0 vulnerabilities (already verified)
2. Apply safe minor/patch bumps: `npm update` (respects semver ranges in package.json — bumps to "Wanted" column, not to "Latest" for @types/node)
3. Skip `@types/node` 24→26 (major, type-only, no runtime impact)
4. Build + lint + test all green
5. Record the audit result in the roadmap memo
**Verify:**
```
npm run build → passes
npm run lint → no new warnings
npm run test → 86 tests still pass
npm audit → 0 vulnerabilities
```
## Part C — Items requiring your approval (NOT in this plan, listed for visibility)
These are the only remaining roadmap items. ALL need your explicit go-ahead before I touch PocketBase or server config:
| # | Item | What it touches | Approval needed for |
|---|------|----------------|---------------------|
| 2.7 | Logo uploads to PB file records | PB `settings` collection | New PB migration (adds `logo` file field) |
| 3.1 | GlitchTip error tracking | New Docker container + `@sentry/react` dep | Server install + npm dep |
| 3.2 | PB production hardening | PB env vars, SMTP, backup cron | SMTP creds, cron job |
| 3.5 | Structured logging | nginx log_format, docker logging | nginx config change |
| 4.1-4.10 | Phase 4 features | Various PB + frontend | Per-feature approval |
## Execution Order
1. **Part A** — update roadmap status table (5 lines corrected, evidence cited)
2. **Part B1** — create OfflineBanner.tsx, wire into App.tsx
3. **Part B2**`npm update` for safe bumps, verify
4. **Final verify** — build + lint + test all green
5. **Memo** — append Execution Memo to roadmap_5.2.md covering Parts A + B
**Total new files:** 1 (`OfflineBanner.tsx`)
**Total edited files:** 2 (`App.tsx`, `roadmap_5.2.md`)
**Risk:** Low — offline banner is self-contained, dep bumps are patch/minor only
**No PocketBase changes. No nginx changes. No new secrets.**
+8
View File
@@ -0,0 +1,8 @@
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"plugins": ["react", "typescript", "oxc"],
"rules": {
"react/rules-of-hooks": "error",
"react/only-export-components": ["warn", { "allowConstantExport": true }]
}
}
Executable
+276
View File
@@ -0,0 +1,276 @@
# AGENTS
## # SPQ-v2 Core Directive & Roadmap Execution
# Objective: Implement Role-Based UI Architecture (Advisor, Tech, Mobile)
## **Your Execution Directives:**
Verify Changes
- `npm run build` is the closest thing to typecheck here. It runs `tsc -b && vite build`.
- `npm run test` runs the full Vitest suite.
- Run a focused test with `npx vitest run "src/store/__tests__/quote.test.ts"`.
- `npm run lint` uses `oxlint`. As of now it reports 2 existing warnings in `src/components/ui/Toast.tsx` and `src/pages/Settings.tsx`; do not treat those as new ## regressions unless you touched them.
* Permissions
- Do not create or change or reset any type of passwords without my explicit permission - always ask if its ok
- If I am asking you to do something that compromises my home server security please explain and ask for permission
## App Shape
- This is a single Vite + React app, not a monorepo. App entrypoints are `src/main.tsx` and `src/App.tsx`.
- Top-level pages are lazy-loaded in `src/App.tsx`. If you add a new page/route, wire it there.
- Shared app shell is `src/components/Layout.tsx`; settings has special modal/background-route behavior plus preload via `src/lib/routePreload.ts`.
## Runtime / Backend Quirks
- PocketBase is accessed only from the frontend. Central client/auth helpers live in `src/lib/pocketbase.ts`.
- Prefer `useIsLoggedIn()` / `useAuth()` for reactive auth state. Do not read `pb.authStore.isValid` directly in render paths you expect to update live.
- Dev proxy assumptions are in `vite.config.ts`:
- `/pb` proxies to `http://127.0.0.1:8091`
- `/deepseek` proxies to `https://localhost`
- `vite.config.ts` excludes `pocketbase` from `optimizeDeps`; do not remove that casually.
-Do not open this file without Explicitly asking for my permission before using this -- PocketBase login info is at /tmp/opencode/pb-admin.txt
## PocketBase Operations Memo
- PocketBase runs in Docker. Find the current container with `docker ps --format '{{.ID}} {{.Image}} {{.Names}} {{.Ports}}'` and look for the `pocketbase` container. On 2026-07-05 it was container `42d798cd1673`, named `pocketbase`, with `127.0.0.1:8091->8090/tcp`.
- The app proxy in `vite.config.ts` points `/pb` to `http://127.0.0.1:8091`, which is the host port forwarded to the PocketBase container.
- Inside the container, the PocketBase binary is `/usr/local/bin/pocketbase`.
- Inside the container, the PocketBase data directory is `/pb_data`.
- Inside the container, the active migrations directory is `/pb_data/migrations` (not `/pb_data/pb_migrations`).
- The project migration source directory is `pb_migrations/` in this repo.
- Before applying schema migrations, make a database backup inside the container: `docker exec <container_id> cp /pb_data/data.db /pb_data/data.db.backup-before-<feature>-$(date +%Y%m%d-%H%M%S)`.
- Copy migration files into the container before applying them. Example: `docker cp "pb_migrations/<migration_file>.js" <container_id>:/pb_data/migrations/`.
- Apply migrations with: `docker exec <container_id> /usr/local/bin/pocketbase migrate up --dir=/pb_data --migrationsDir=/pb_data/migrations`.
- Verify no pending migrations remain by running the same `migrate up` command again. Expected success message: `No new migrations to apply.`
- PocketBase version in the container was `0.39.1` on 2026-07-05. Use field constructors such as `TextField`, `EmailField`, `SelectField`, `DateField`, and `JSONField`; do not rely on the older `CollectionField` global for new migrations.
- Do not open `/tmp/opencode/pb-admin.txt` unless the user explicitly grants permission. Most migration work does not require admin credentials.
## Data Model Gotchas
- `services` catalog records are consumed by `QuoteGenerator` as flat-price items. Keep `price` populated even when UI adds richer pricing inputs.
- Catalog description fields are inconsistent across the app: `ServiceCatalog` reads `explanation || description`, while quote flows read `explanation`. Preserve compatibility when changing service description writes.
- User-owned PocketBase collections are expected to be scoped by `userId = @request.auth.id` per `pb_migrations/1739999000004_user_scoped_api_rules.js`. Keep new collection fields and queries compatible with that model.
- Quote ↔ RO links are plain text IDs, not PocketBase relation fields, by design. See `pb_migrations/1739999000006_quote_ro_link.js`.
## Shared State / Local Storage
- Quote draft state is persisted in Zustand under the `spq-quote` key in `src/store/quote.ts`. Be careful changing store shape because old local data will be rehydrated.
- Shop defaults belong in `src/lib/settings.ts`. Reuse `loadSettings()` / `saveSettings()` instead of duplicating fallback values in pages.
- Dark mode and some UI preferences also live in localStorage; search before introducing new keys.
## Repair Order Conventions
- RO totals should go through `src/lib/totals.ts`.
- Some RO progress/audit features intentionally avoid PocketBase schema changes:
- service sub-status lives on the `ROService` JSON shape in `src/types.ts`
- RO event history is stored in the `financial` JSON blob, not a separate collection
## Tests
- Vitest runs in `jsdom` with setup from `src/test/setup.ts`.
- Existing tests are lightweight unit tests under `src/store/__tests__` and `src/lib/__tests__`; there is no broader integration-test harness or CI config in this repo.
## PopUp Modal Standard (Background-Location Overlay Pattern)
Every pop-up / overlay modal MUST follow the pattern established by the Settings modal. There is no generic `<Modal>` component; instead we use React Router's `backgroundLocation` technique so the background page stays rendered while the modal overlays it.
### Architecture Overview
| Layer | File | What It Does |
|-------|------|--------------|
| Route setup | `src/App.tsx` | Registers the route in both the primary `<Routes>` (for in-page rendering) and the secondary conditional `<Routes>` (for modal overlay rendering) |
| Trigger | `src/components/Layout.tsx` (desktop) and `src/components/mobile/MobileLayout.tsx` (mobile) | `<NavLink>` passes current `location` as `state.backgroundLocation` |
| Page component | `src/pages/Settings.tsx` | Detects `isModal` from `location.state?.backgroundLocation` and conditionally wraps content in a fixed overlay |
| Preload helper | `src/lib/routePreload.ts` | Lazy-load function for hover preloading (optional but recommended) |
---
### Step 1 -- App.tsx: Register Two Routes
In `src/App.tsx`, you need three pieces:
**A) A `<SettingsModalRoute>`-style guard component** (see `SettingsModalRoute` at `src/App.tsx:143-153`):
```tsx
function YourModalRoute() {
// Add any role gating or auth checks needed
return (
<RequireAuth>
<YourPage />
</RequireAuth>
);
}
```
**B) A primary route** inside the main `<Routes location={backgroundLocation || location}>` (line 168), nested under the appropriate layout (e.g. `<OwnerLayout>`):
```tsx
<Route path="/your-path" element={<YourPage />} />
```
**C) A secondary modal route** inside the `{backgroundLocation && (<Routes>...</Routes>)}` block (line 195-199):
```tsx
{backgroundLocation && (
<Routes>
<Route path="/settings" element={<SettingsModalRoute />} />
<Route path="/your-path" element={<YourModalRoute />} /> {/* NEW */}
</Routes>
)}
```
---
### Step 2 -- Layout.tsx / MobileLayout.tsx: Pass backgroundLocation on NavLink
In the sidebar/nav `<NavLink>` for your route, pass `state` with `backgroundLocation` set to the current location. See `src/components/Layout.tsx:81`:
```tsx
<NavLink
to="/your-path"
state={{
backgroundLocation:
(location.state as { backgroundLocation?: unknown } | null)
?.backgroundLocation || location,
}}
onClick={handleNavClick}
onMouseEnter={preloadYourPage} // optional
onFocus={preloadYourPage} // optional
...
>
```
The `(location.state... || location)` fallback ensures the overlay works correctly even if the user is already inside another modal (unlikely but defensive).
Repeat this in `src/components/mobile/MobileLayout.tsx` for the mobile nav.
---
### Step 3 -- Page Component: isModal Detection & Overlay Wrapper
**A) Detect modal mode** at the top of your page component (see `Settings.tsx:479-480`):
```tsx
const location = useLocation();
const modalBackground = (location.state as any)?.backgroundLocation;
const isModal = Boolean(modalBackground);
```
**B) Build your normal page content** and assign it to a variable (e.g. `content` or `settingsContent`).
**C) Conditional return** -- this is the critical pattern (see `Settings.tsx:1449-1471`):
```tsx
if (!isModal) {
return content; // normal in-page render
}
return (
<div className="fixed inset-0 z-50">
{/* Backdrop -- clicking it closes the modal */}
<button
type="button"
aria-label="Close"
className="absolute inset-0 bg-gray-950/45 backdrop-blur-sm"
onClick={closeModal}
/>
{/* Centered modal container */}
<div className="absolute inset-0 flex items-center justify-center p-3 sm:p-6">
{/* THE SIZING DIV -- adjust Tailwind classes here to control size */}
<div className="relative flex h-[min(92vh,56rem)] w-full max-w-6xl overflow-hidden rounded-3xl border border-gray-200 bg-gray-50 shadow-2xl dark:border-gray-700 dark:bg-gray-950">
<div className="flex-1 overflow-y-auto">
{content}
</div>
</div>
</div>
</div>
);
```
### Sizing the Modal
Size is controlled 100% by Tailwind classes on the inner div (the one with `rounded-3xl`). To change the size for a specific modal:
| Dimension | Class(es) | Current Default | Options |
|-----------|-----------|-----------------|---------|
| **Width** | `max-w-*` | `max-w-6xl` (72rem / 1152px) | `max-w-2xl` (42rem), `max-w-3xl` (48rem), `max-w-4xl` (56rem), `max-w-5xl` (64rem), `max-w-7xl` (80rem), `max-w-[900px]` (arbitrary) |
| **Height** | `h-[min(...)]` | `h-[min(92vh,56rem)]` (92% viewport, 896px cap) | Adjust `92vh` and `56rem` as needed |
| **Outer padding** | `p-3 sm:p-6` | 12px mobile / 24px desktop | Decrease for larger modals that need more screen real estate |
**Each modal chooses its own size.** Do not extract a shared size prop -- just change the Tailwind classes on that modal's inner container div. For example, a small confirmation dialog would use `max-w-md h-auto`, while a full-width data table modal might use `max-w-7xl`.
---
### Step 4 -- Close / Escape Handler
Every modal MUST implement close via backdrop click and Escape key. See `Settings.tsx:551-566`:
```tsx
const closeModal = useCallback(() => {
if (isModal) {
navigate(-1); // pop the overlay URL, restoring the background page
} else {
navigate('/'); // fallback: go home from the full-page version
}
}, [isModal, navigate]);
useEffect(() => {
if (!isModal) return;
const handler = (e: KeyboardEvent) => {
if (e.key === 'Escape') closeModal();
};
document.addEventListener('keydown', handler);
return () => document.removeEventListener('keydown', handler);
}, [isModal, closeModal]);
```
---
### Step 5 -- Preloading (Optional)
Add a preload helper in `src/lib/routePreload.ts` so the page chunk starts loading on hover:
```ts
export const loadYourPage = () => import('../pages/YourPage');
export function preloadYourPage() {
void loadYourPage();
}
```
Then use it in the NavLink: `onMouseEnter={preloadYourPage} onFocus={preloadYourPage}`.
---
### Step 6 -- Sticky Save Button (Top Bar)
Every modal with a save/submit action **MUST** place the save button in a sticky bar pinned to the **top** of the scrollable content area inside the modal. No other save or submit buttons may appear anywhere else in the modal body.
```tsx
<div className="flex-1 overflow-y-auto">
{/* Sticky save bar at top */}
<div className="sticky top-0 z-10 flex items-center justify-end border-b border-gray-200 bg-gray-50 px-6 py-4 dark:border-gray-700 dark:bg-gray-950">
<button
type="submit"
className="rounded-lg bg-blue-600 px-5 py-2 text-sm font-medium text-white hover:bg-blue-700"
onClick={handleSave}
>
Save
</button>
</div>
{/* Modal form body -- no save buttons here */}
<div className="p-6">
{children}
</div>
</div>
```
**Rule:** One save button at the top. Zero save buttons in the form body.
---
### Checklist for Adding a New Modal
- [ ] `src/App.tsx`: Add primary route inside main `<Routes>`
- [ ] `src/App.tsx`: Add secondary modal route inside the `backgroundLocation` block (with guard component)
- [ ] `src/components/Layout.tsx`: Add `<NavLink>` with `state.backgroundLocation`
- [ ] `src/components/mobile/MobileLayout.tsx`: Add `<NavLink>` with `state.backgroundLocation`
- [ ] `src/pages/YourPage.tsx`: Detect `isModal`, build content variable, return overlay when `isModal`
- [ ] `src/pages/YourPage.tsx`: Implement `closeModal` with `navigate(-1)` + Escape key handler
- [ ] Choose appropriate `max-w-*` and `h-[...]` Tailwind classes for the modal's size
- [ ] Sticky save button at top of modal (only save button on the page)
- [ ] Remove any duplicate save/submit buttons from the modal body
- [ ] (Optional) `src/lib/routePreload.ts`: Add preload helper
+66
View File
@@ -0,0 +1,66 @@
# AGENTS
## # SPQ-v2 Core Directive & Roadmap Execution
**System Role:** You are an elite, professional Web Designer and Full-Stack Developer strictly dedicated to the `spq-v2` codebase. You write clean, production-ready React/Vite and PocketBase code.
**Your Execution Directives:**
1. **Strict Roadmap Adherence:** You must ONLY focus on the objectives outlined in the located at Websites\ShopProQuote.backup-20260626-2058\spq-v2\docs `shopproquote-evaluation-recommendations.md` document. Do not invent new features or refactor code outside of the explicit priorities listed in this roadmap.
2. **Task Selection:** Review the roadmap and consult your Think Tank to select the *single most impactful next item* to implement. Start strictly with "Priority 1 — Trust & Professionalism" (e.g., removing developer-facing PocketBase setup banners, generic error messages, and emojis) before moving to subsequent phases.
3. **Implementation:** Execute the code changes for the selected item. Ensure the UI feels like a professional automotive service application, not a generic SaaS template.
4. **Completion Memo (Required):** Once the implementation is successful, you MUST update the `shopproquote-evaluation-recommendations.md` file. Append a new section at the bottom titled `## Execution Memo: [Date] - [Feature Name]`. In this memo, state:
* What was completed.
* Which roadmap priority it resolved.
* A brief note on how the Think Tank evaluated its benefit to the target audience.
**To begin your session:** Output a brief summary of the Think Tank's consensus on the *next immediate task* to tackle from the roadmap, and then proceed with the code implementation.
## Verify Changes
- `npm run build` is the closest thing to typecheck here. It runs `tsc -b && vite build`.
- `npm run test` runs the full Vitest suite.
- Run a focused test with `npx vitest run "src/store/__tests__/quote.test.ts"`.
- `npm run lint` uses `oxlint`. As of now it reports 2 existing warnings in `src/components/ui/Toast.tsx` and `src/pages/Settings.tsx`; do not treat those as new regressions unless you touched them.
## Permissions
- Do not make drastic changes without confirming with me first - such as deleting a whole settings modal
- Do not make any changes to PocketBase without confirming with me and explaning exactly what changes would be made
- Do not change or reset any type of passwords without my explicit permission - always ask
- If I am asking you to do something that compromises my home server security please explain and ask for permission
## App Shape
- This is a single Vite + React app, not a monorepo. App entrypoints are `src/main.tsx` and `src/App.tsx`.
- Top-level pages are lazy-loaded in `src/App.tsx`. If you add a new page/route, wire it there.
- Shared app shell is `src/components/Layout.tsx`; settings has special modal/background-route behavior plus preload via `src/lib/routePreload.ts`.
## Runtime / Backend Quirks
- PocketBase is accessed only from the frontend. Central client/auth helpers live in `src/lib/pocketbase.ts`.
- Prefer `useIsLoggedIn()` / `useAuth()` for reactive auth state. Do not read `pb.authStore.isValid` directly in render paths you expect to update live.
- Dev proxy assumptions are in `vite.config.ts`:
- `/pb` proxies to `http://127.0.0.1:8091`
- `/deepseek` proxies to `https://localhost`
- `vite.config.ts` excludes `pocketbase` from `optimizeDeps`; do not remove that casually.
-Do not open this file without Explicitly asking for my permission before using this -- PocketBase login info is at /tmp/opencode/pb-admin.txt
## Data Model Gotchas
- `services` catalog records are consumed by `QuoteGenerator` as flat-price items. Keep `price` populated even when UI adds richer pricing inputs.
- Catalog description fields are inconsistent across the app: `ServiceCatalog` reads `explanation || description`, while quote flows read `explanation`. Preserve compatibility when changing service description writes.
- User-owned PocketBase collections are expected to be scoped by `userId = @request.auth.id` per `pb_migrations/1739999000004_user_scoped_api_rules.js`. Keep new collection fields and queries compatible with that model.
- Quote ↔ RO links are plain text IDs, not PocketBase relation fields, by design. See `pb_migrations/1739999000006_quote_ro_link.js`.
## Shared State / Local Storage
- Quote draft state is persisted in Zustand under the `spq-quote` key in `src/store/quote.ts`. Be careful changing store shape because old local data will be rehydrated.
- Shop defaults belong in `src/lib/settings.ts`. Reuse `loadSettings()` / `saveSettings()` instead of duplicating fallback values in pages.
- Dark mode and some UI preferences also live in localStorage; search before introducing new keys.
## Repair Order Conventions
- RO totals should go through `src/lib/totals.ts`.
- Some RO progress/audit features intentionally avoid PocketBase schema changes:
- service sub-status lives on the `ROService` JSON shape in `src/types.ts`
- RO event history is stored in the `financial` JSON blob, not a separate collection
## Tests
- Vitest runs in `jsdom` with setup from `src/test/setup.ts`.
- Existing tests are lightweight unit tests under `src/store/__tests__` and `src/lib/__tests__`; there is no broader integration-test harness or CI config in this repo.
Executable
BIN
View File
Binary file not shown.
+32
View File
@@ -0,0 +1,32 @@
# React + TypeScript + Vite
This template provides a minimal setup to get React working in Vite with HMR and some Oxlint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs)
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
## React Compiler
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
## Expanding the Oxlint configuration
If you are developing a production application, we recommend enabling type-aware lint rules by installing `oxlint-tsgolint` and editing `.oxlintrc.json`:
```json
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"plugins": ["react", "typescript", "oxc"],
"options": {
"typeAware": true
},
"rules": {
"react/rules-of-hooks": "error",
"react/only-export-components": ["warn", { "allowConstantExport": true }]
}
}
```
See the [Oxlint rules documentation](https://oxc.rs/docs/guide/usage/linter/rules) for the full list of rules and categories.
+215
View File
@@ -0,0 +1,215 @@
# PocketBase Schema Migrations
These changes are recommended by the spq-v2 code review (2026-06-30). They
**alter the live database** and so must be applied to your **separate
PocketBase install** (PocketBase does not live inside this repo).
## Two ways to apply
### Option A — Runnable JS migrations (recommended)
Drop-in `.js` files are included in [`../pb_migrations/`](../pb_migrations/).
Copy that entire folder into your PocketBase install's `pb_migrations/`
directory and run:
```bash
# from your PocketBase install directory
./pocketbase migrate
./pocketbase migrate up # apply pending migrations
./pocketbase migrate collections # re-sync collection schema
```
Files (apply in filename order):
| File | What |
|------|------|
| `1739999000001_add_customerType_to_repairOrders.js` | M1 — `customerType` text column + backfill from `financial` blob |
| `1739999000002_add_estimatedDuration_to_repairOrders.js` | M2 — `estimatedDuration` (integer minutes) + backfill from `estimatedTime` |
| `1739999000003_promote_financial_fields.js` | M3 — `grossTotal` / `grossCost` / `warrTotal` / `warrCost` / `shopCharge` numeric columns + backfill |
| `1739999000004_user_scoped_api_rules.js` | M4 — per-user-scope List/View/Create/Update/Delete rules on all user-collections (security) |
| `1739999000005_unique_roNumber.js` | M5 — unique index on `repairOrders.roNumber` (server-side collision guard) |
| `1739999000006_quote_ro_link_columns.js` | M6 — `quotes.repairOrderId` + `repairOrders.quoteId` link columns (bidirectional RO↔Quote back-links) |
Each migration is **idempotent** (safe to re-run) and includes an `up` and
`down` hook so `./pocketbase migrate down` cleanly reverses it.
### Option B — Manual via admin UI
The next sections spell out each change for the admin UI. Use these if you
prefer clicking through PocketBase Admin → Collections → ⚙.
---
## What the migrations do
these columns are absent, but promoting them unlocks correct persistence,
queryable aggregation, and atomic updates.
Apply each change in the **PocketBase Admin → Collections → repairOrders → Edit
collection** UI. After adding a field, backfill existing rows as noted.
---
## M1 — `customerType` (promote from `financial` JSON blob)
**Today**: `customerType` lives only inside the stringified `financial` JSON
blob. The frontend synthesizes it on read (`RepairOrders.fetchOrders`), which
breaks whenever the blob is corrupt or missing.
**Add field**:
- Name: `customerType`
- Type: `Text`
- Options: `Pattern` (optional) — restrict to `waiter|drop-off`
**Backfill** (run once in Admin → Collections → repairOrders → run a small
script, or use the API):
```js
// Node script — run against your PocketBase instance
import PocketBase from 'pocketbase';
const pb = new PocketBase('http://localhost:8091');
await pb.admins.authWithPassword('admin@example.com', 'PASSWORD');
const all = await pb.collection('repairOrders').getFullList({ batch: 500 });
for (const ro of all) {
let fin = {};
try { fin = typeof ro.financial === 'string' ? JSON.parse(ro.financial) : (ro.financial || {}); } catch {}
const ct = fin.customerType === 'waiter' ? 'waiter' : 'drop-off';
await pb.collection('repairOrders').update(ro.id, { customerType: ct });
}
```
After backfill, the redundant read/merge logic in
`RepairOrders.fetchOrders` (lines ~1779-1787) and `handleToggleCustomerType`'s
"persist into financial blob" path (~1842-1856) can be simplified to plain
column updates. Leaving them as-is is safe — they will simply keep the blob in
sync, which is harmless. **A future cleanup PR can remove the blob round-trip
once the column is confirmed populated.**
---
## M2 — `estimatedDuration` (integer minutes; fixes lossy round-trip)
**Today**: stored as `estimatedTime` (hours string, e.g. `"0.3"`), converted
back to minutes via `Math.round(hours * 60)` on read. 15 min → "0.3" → 18 min
on every reload, drifting the due time.
**Add field**:
- Name: `estimatedDuration`
- Type: `Number`
- Options: `Min`: 0, `Max`: empty (or 10080 = 7 days)
**Backfill**:
```js
const all = await pb.collection('repairOrders').getFullList({ batch: 500 });
for (const ro of all) {
if (ro.estimatedDuration != null && ro.estimatedDuration !== '') continue;
const hours = parseFloat(ro.estimatedTime || '0') || 0;
await pb.collection('repairOrders').update(ro.id, {
estimatedDuration: Math.round(hours * 60),
});
}
```
**Frontend follow-up** (NOT yet applied — pending this migration): switch
`RepairOrders.fetchOrders`, `handleSave`, and `handleAddTime` to read/write
`estimatedDuration` directly and drop the `/ 60``* 60` conversions. Until
then the current code keeps writing `estimatedTime` and synthesizing
`estimatedDuration` on read, so no data is lost.
---
## M3 — Promote financial fields out of the `financial` JSON blob
**Today**: `grossTotal`, `grossCost`, `warrTotal`, `warrCost`, `shopCharge`,
and `satisfaction` are partly real columns (`satisfaction` already is) and
partly loose JSON keys inside `financial`. The `FinancialDashboard` therefore
must load **every** completed RO to the client to aggregate — it cannot run a
server-side `sum()` over a JSON blob.
**Add fields** (all type `Number`, min 0):
- `grossTotal`, `grossCost`
- `warrTotal`, `warrCost`
- `shopCharge` *(this overlaps conceptually with the existing `shopCharges`
column — confirm which one the dashboard should sum and consolidate)*
**Backfill**:
```js
const all = await pb.collection('repairOrders').getFullList({ batch: 500 });
for (const ro of all) {
let fin = {};
try { fin = typeof ro.financial === 'string' ? JSON.parse(ro.financial) : (ro.financial || {}); } catch {}
const patch = {
grossTotal: Number(fin.grossTotal) || 0,
grossCost: Number(fin.grossCost) || 0,
warrTotal: Number(fin.warrTotal) || 0,
warrCost: Number(fin.warrCost) || 0,
shopCharge: Number(fin.shopCharge) || 0,
};
await pb.collection('repairOrders').update(ro.id, patch);
}
```
**Frontend follow-up**: after this migration, `ExpandedDetail.saveFinField`
should write directly to the typed columns (and can stop stringifying the
blob), and `FinancialDashboard` can issue a single `getFullList` with
`fields` projection + client-side sum, or better, a server-side aggregation
via PocketBase's `aggregate` API (0.20+) for monthly revenue/costs. This
collapses thousands of records to one request.
---
## M4 — API Rules (security gate — apply even if you skip everything else)
Each user-scoped collection (`quotes`, `repairOrders`, `invoices`,
`appointments`, `customers`, `services`, `settings`) MUST have API rules that
scope every record to the authenticated owner. Without these, any logged-in
user can read/write every other shop's data by passing a different `userId`
field in the request body.
For each collection, in **Admin → Collections → ⚙ → API Rules**:
| Action | Rule |
|--------|------|
| List / View | `userId = @request.auth.id` |
| Create | `userId = @request.auth.id` |
| Update | `userId = @request.auth.id` |
| Delete | `userId = @request.auth.id` |
The `users` collection itself should restrict List/View to admins only.
Verify with two test users — user A must NOT be able to `getOne(<B's record id>)`.
---
## M6 — Quote ↔ Repair Order link columns (bidirectional back-links)
**Today**: RO→Quote and Quote→RO conversions stamp each other's record IDs
client-side, but there are no backing columns — the links are invisible to
queries and break if a record is re-imported.
**Add fields**:
- On `quotes`: `repairOrderId` — type `Text`, optional. Stores the RO id that
originated this quote (set when the user clicks "Generate Quote" on an RO).
- On `repairOrders`: `quoteId` — type `Text`, optional. Stores the quote id that
was converted into this RO (set when the user clicks "Convert to RO" on a
quote).
No backfill needed — existing records simply have empty values.
**Frontend follow-up**: after M6 is applied, the `handleConvertQuoteToRO` and
`onGenerateQuote` handlers can stop encoding the link in the `notes` field and
use the real columns instead.
---
## Applying & verifying
1. **Backup first**: `./pocketbase backup create` (or copy the SQLite file).
2. Run **Option A** migrations in order, OR apply M4 (rules) manually first
— it has zero data migration and closes the cross-tenant hole.
3. Apply M1 → M2 → M3 → M5 → M6 in filename order (the JS files handle backfill
inside their `up` hooks).
4. After each migration, reload the spq-v2 app and confirm the corresponding
screen still renders existing data correctly.
5. Once M1 + M2 are confirmed in production, open a follow-up issue to clean
up the now-redundant frontend compatibility code (noted inline above).
@@ -0,0 +1,370 @@
# ShopProQuote — Product Evaluation & Recommendations
_Generated: 2026-07-04 · Based on full codebase review of `spq-v2`_
---
## 1. Current Product State Summary
The app is a **single authenticated React/Vite + PocketBase application** originally designed as a quote generator for an auto repair shop. It has expanded well beyond quotes into operational tools:
- Quotes (generate, save, print, PDF, export as images, AI explanations, priority analysis, AI service suggestions)
- Service Catalog (reusable services with pricing, categories, toggle active/inactive)
- Customers (records, vehicles, linked RO/quote history)
- Repair Orders (full lifecycle: write-up → active → in-progress → waiting parts → waiting pickup → completed → final close; time tracking, clock, audit timeline, financial summary, tech clock, void/unvoid, promised time overrides)
- Appointments (schedule, check-in → create RO, status changes, scan/screenshot OCR extraction)
- Invoices (create, edit, print, generate from RO)
- Financial Dashboard (revenue, costs, profit, customer LTV, warranty analysis, monthly trends)
- Settings (8 tabs: account, business info, advisors, tax/fees, quote defaults, PDF/branding, business ops, notifications)
- PDF generation (both quote PDF and RO work-order PDF, branded with logo, accent color, rich-text footer/payment terms)
- AI integration (DeepSeek for explanations, priority analysis, catalog descriptions, OCR for screenshots)
**Backend**: PocketBase (self-hosted), user-scoped collections, real-time subscriptions, local-only auth.
---
## 2. What Already Fits the Target Audience Well
The product already covers the core workflows that advisors, home mechanics, and mobile mechanics need:
| Feature | Value to Target Users |
|---|---|
| Quote generator | Core service — all users need professional quotes |
| Service catalog | Advisors and mechanics build reusable price lists |
| Professional PDF quotes | Customer-facing output with logo, branding, pricing, disclaimers |
| Customer records | Repeat business tracking |
| Repair order lifecycle | Full shop workflow — works for advisors and mechanics |
| Appointment scheduling | Essential for scheduling mobile/home visits |
| AI-written explanations | Saves time, sounds professional |
| Discount, tax, shop charge | Handles real pricing complexity |
| Phone formatting, VIN notes | Automotive-specific touches |
| PDF export + print + image export | Flexible customer delivery |
| RO → Quote conversion | One job produces both an RO and a customer quote |
| App → RO check-in | Office workflow for physical shops |
---
## 3. What Hurts Professionalism (Fix Immediately)
These issues make the app feel like a development project rather than a polished product:
### 3.1 Developer-Facing Messages Visible to Users
The most damaging issue. Multiple screens show messages like:
- `SetupBanner.tsx`: "Collection Not Found" with a hardcoded admin URL `http://192.168.50.98:8091/_/`
- `Appointments.tsx`: Raw error text about creating PocketBase collections with field types
- `Customers.tsx`, `RepairOrders.tsx`, `Invoices.tsx`, `FinancialDashboard.tsx`: Similar raw setup messages
- `Unknown error` and `Something went wrong` patterns in toast messages
**Fix**: Replace every instance with user-friendly setup prompts or internal-only logs. A first-run setup screen should replace all of these.
### 3.2 Generic Error Handling
`"Something went wrong"` and `"Unknown error"` appear in error states. This erodes trust. Every error path should show a useful message.
### 3.3 Generic SaaS Visual Identity
- Green/gray Tailwind defaults → looks like every other admin panel
- No automotive or service-industry visual cues
- No distinctive typography, iconography, or brand personality
### 3.4 Emojis in UI Buttons and Status Labels
Emojis appear in buttons like `🔧 Labor Only`, `✅ In Stock`, `⚠️ Need Order`, `💡 Aftermarket`, and `🔧 Tech Notes (Internal)`. This reads as casual or unpolished. Replace with text labels or subtle icons.
### 3.5 Product Name and Tagline Are Too Narrow
- The app is called **ShopProQuote** but it does far more than quotes
- Login screen says: `"Sign in to manage your quotes"` — this is misleading for RO, invoices, appointments, etc.
- The product scope has outgrown the name
### 3.6 Empty State Language Is Too Casual
`"Start building your customer database! Add your first customer to get started."` — better to sound professional and specific.
### 3.7 No Onboarding / First-Run Setup
When a new user signs up, they see empty screens and PocketBase errors. There is no guided setup: business name, logo, tax rate, shop charge, labor rate, quote terms, etc.
---
## 4. Missing Features for Advisors, Home Mechanics, and Mobile Mechanics
### 4.1 Customer Authorization / Signature (High Priority)
- Quoted services need a clear **customer authorized** state
- Authorization should include: customer name, timestamp, method (in-person/phone/email/text/signature)
- Declined services should have customer acknowledgment
- **Why**: In auto repair, verbal authorization is common but documentation is critical for liability protection
### 4.2 Customer-Facing Quote Approval Flow (High Priority)
- Currently quotes exist as PDF downloads only
- There is no way to:
- Send a link to a customer for review
- Let customers approve/decline services online
- Track when a customer viewed the quote
- **Why**: Mobile and home mechanics need quick customer approval without printing
### 4.3 Service Location / Mobile Job Fields (High Priority)
- No address for mobile/home service jobs
- No travel fee or diagnostic fee
- No arrival window or estimated travel time
- No "shop", "mobile", "roadside" service type
- **Why**: This is the defining difference between shop software and mobile mechanic software
### 4.4 Photo Attachments (High Priority)
- No ability to attach photos to customers, quotes, repair orders, or services
- No before/after photos
- No inspection evidence (leaks, tire wear, brake thickness, damage)
- **Why**: Photos are standard in professional auto repair estimates and documentation
### 4.5 Payment / Deposit Tracking (High Priority)
- Invoices exist, but:
- No payment method tracking (cash, card, Zelle, PayPal, etc.)
- No deposit/partial payment tracking
- No "paid" vs "unpaid" vs "partial" states on quotes or ROs
- No payment receipt generation
- **Why**: Mobile and home mechanics often collect payment on completion or require deposits
### 4.6 Customer Communication Log (Medium Priority)
- SMS/email buttons exist in RO details but there is no unified log
- No communication history tied to customer or RO
- No sent quote tracking, customer response tracking
- **Why**: Professional service records include customer communication
### 4.7 Legal / Professional Terms (Medium Priority)
No built-in support for:
- Warranty disclaimer text on quotes
- Diagnostic fee disclosure
- Storage fee / lien notice
- Declined-work disclaimer
- Authorization for test drive, additional work, parts ordering
- Mobile service terms (access to property, weather, etc.)
### 4.8 Parts Tracking (Medium Priority)
Current parts status is basic toggles (in stock, need order, aftermarket). Missing:
- Part numbers
- Supplier / vendor
- Cost price vs sell price
- Core charge tracking
- Ordered date and ETA
- Received status
### 4.9 Data Export / Backup (Medium Priority)
- No CSV/JSON export for customers, quotes, ROs
- No PDF archive option
- **Why**: Professionals need to own their data
### 4.10 Route / Calendar Integration (Lower Priority)
- No Google Calendar, iCal, or ICS export for appointments
- No route/map link for mobile service addresses
---
## 5. What May Not Fit (Consider Dual Mode)
### 5.1 "Service Advisor" Language Is Shop-Centric
- Advisors, waiters, drop-offs → these terms fit a dealership or shop
- **Mobile mechanic** does not have an "advisor"; they are the technician
- **Solution**: Make the role label configurable in settings or detect business type
### 5.2 Financial Dashboard May Be Overwhelming for Solo Users
- Customer LTV, warranty GP split, cost breakdown — valuable for a shop owner
- Solo mechanics may find this noisy until they grow
- **Solution**: Show simplified dashboard by default for non-admin users; hide until needed
### 5.3 Waiter / Drop-Off Model Does Not Fit Mobile
- Mobile mechanics need: `on-site`, `driveway`, `shop`, `roadside`, `mobile visit`
- **Solution**: Business type toggle → mobile mode replaces waiter/drop-off with location/time-based fields
---
## 6. Recommended Product Direction
### 6.1 Positioning
```
Professional repair quotes, approvals, and tracking
for shops, advisors, and mobile mechanics.
```
### 6.2 Dual Operating Mode
| Feature | Shop / Advisor Mode | Mobile Mechanic Mode |
|---|---|---|
| Customer type | Waiter / Drop-off | On-site / Mobile / Drop-off |
| Service location | Shop address | Per-job address |
| Travel fee | Disabled | Configurable fee + mileage |
| Advisor field | Service Advisor (configurable) | Technician / Self |
| RO board | Active / All / Completed | Same but simplified |
| Photos | Optional | Core feature |
| Payment | At counter | At job site + deposit |
| Arrival window | Not needed | Estimated arrival window |
---
## 7. Highest-Priority Changes (Ordered by Impact)
### Priority 1 — Trust & Professionalism
1. Remove every developer-facing message (PocketBase collection errors, admin URLs, field type instructions)
2. Replace `"Something went wrong"` and `"Unknown error"` with helpful, specific messages
3. Replace emojis in buttons/status with text or subtle icons
4. Add first-run onboarding: business type, business name, logo, contact, tax rate, labor rate, shop charge, quote terms, payment terms
5. Improve empty state messaging to sound professional and product-ready
### Priority 2 — Mobile Mechanic Foundation
6. Add service address field to ROs and appointments
7. Add travel fee, diagnostic fee, and mileage tracking
8. Add mobile vs shop service type flag
9. Add arrival window / estimated travel time to appointments
### Priority 3 — Customer Trust & Approval
10. Add customer authorization state (name + timestamp + method)
11. Add customer-facing quote approval flow (link or secure page)
12. Add photo attachment to quotes, ROs, and inspections
### Priority 4 — Payments & Financial
13. Add deposit tracking on quotes and ROs
14. Add payment method and payment status
15. Add basic payment receipt
### Priority 5 — Polish
16. Make advisor/technician label configurable
17. Add CSV export for customers, quotes, and ROs
18. Add legal disclaimer placeholders (warranty, fee disclosure, authorization)
19. Refine visual identity toward automotive/service-industry feel
20. Consider product name expansion or tagline update
---
## 8. Suggested Implementation Plan
### Phase 1: Production Proofing (1-2 weeks)
- Replace all `SetupBanner` and collection-not-found errors with onboarding flow
- Rewrite error handling across all pages
- Remove emojis from operational UI
- Improve empty states and error states
- Add first-run setup wizard (business profile)
### Phase 2: Mobile Mechanic Mode (2-3 weeks)
- Add service location, travel fee, diagnostic fee, mileage fields
- Add mobile/shop service type toggle to ROs and appointments
- Create configurable role labels (advisor → technician or self)
- Add arrival window to appointments
### Phase 3: Customer Approval & Photos (2-3 weeks)
- Build customer authorization state
- Build customer-facing quote approval page/link
- Add photo upload to customers, quotes, ROs
- Add photo gallery to RO details
### Phase 4: Payments & Polish (2-3 weeks)
- Add deposit tracking
- Add payment method and status
- Add receipt generation
- Add CSV export
- Add legal disclaimer settings
- Visual identity refinement
---
## 9. What NOT to Build Next
- Do not add more internal dashboards or financial reports (already sufficient)
- Do not add multi-tenant/team features (out of scope)
- Do not add real-time chat or elaborate notification systems
- Do not add inventory management or full parts ordering
- Do not rebuild the existing workflows — polish what works
The app already has strong internal tools. The next investment should be **customer-facing trust**: approvals, photos, mobile jobs, payments, and professional polish.
## Execution Memo: 2026-07-05 - Customer-Facing Quote Approval Flow
- Completed: Built a full public customer-facing quote review and approval flow. Created `src/pages/QuoteApprove.tsx` — a standalone branded page that loads a quote via a secure share token (no login required), displays services with individual Approve/Decline buttons, shows a pricing summary, and records customer decisions back to the quote record. Added `shareToken` field to the quotes PocketBase collection via `pb_migrations/1739999000007_quote_share_token.js` with relaxed view/update rules that allow public access only when the token matches. Added a "Share with Customer" button to the QuoteGenerator sidebar that generates a random UUID token, saves it to the quote, and copies the approval URL to the clipboard. The `authorizedMethod` type now includes `'online'` for decisions made through this flow. Registered the `/quote/approve?token=xxx` route as a public route in `src/App.tsx`.
- Resolved roadmap priority: Priority 3 — Customer Trust & Approval, item 11 by giving shop owners a frictionless way to send quotes to customers for online review and approval without printing or requiring customer accounts.
- Think Tank benefit: The mobile mechanic gained instant on-site customer approvals without printing or face-to-face pressure, the service advisor gained documented online authorization trails that protect against disputes, and the home mechanic gained a simple way to share cost estimates with family members for collaborative decision-making.
## Execution Memo: 2026-07-05 - Login Scope Copy Update
- Completed: Updated the login screen tagline from `Sign in to manage your quotes` to `Sign in to manage your shop` in `src/pages/Login.tsx`.
- Resolved roadmap priority: Priority 1 - Trust & Professionalism, item 1 and item 20 by removing a narrow quotes-only message that no longer reflects the product's broader workflow coverage.
- Think Tank benefit: The service advisor valued the more credible shop-wide positioning, the mobile mechanic valued that the app no longer reads like a quotes-only tool in front of customers, and the home mechanic valued the clearer description of the product's actual scope.
## Execution Memo: 2026-07-05 - Operational UI Emoji Removal
- Completed: Removed emoji-based labels from the quote workflow controls and status text in `src/pages/QuoteGenerator.tsx`, and removed the emoji badge text from the theme mode indicator in `src/pages/Settings.tsx`.
- Resolved roadmap priority: Priority 1 - Trust & Professionalism, item 3 by replacing casual emoji-driven labels with cleaner professional copy while preserving existing workflows and visual states.
- Think Tank benefit: The service advisor saw a more credible customer-facing presentation, the mobile mechanic saw less distracting UI in fast job-site workflows, and the home mechanic saw a more polished tool that reads like professional repair software instead of a casual app.
## Execution Memo: 2026-07-05 - Setup State Professionalization
- Completed: Replaced the remaining generic setup blockers in `src/components/SetupBanner.tsx`, `src/pages/Invoices.tsx`, `src/pages/FinancialDashboard.tsx`, and `src/pages/Settings.tsx` with workflow-specific professional messaging, and replaced the vague verification fallback copy in `src/pages/Verify.tsx` with actionable guidance.
- Resolved roadmap priority: Priority 1 - Trust & Professionalism, item 1 and item 2 by removing the last generic setup-facing UI language and improving a remaining non-specific error state.
- Think Tank benefit: The service advisor valued clearer customer-ready document setup guidance, the mobile mechanic benefited from less confusing blocked-state messaging during active workflows, and the home mechanic benefited from more direct instructions that explain what to do next without exposing internal implementation details.
## Execution Memo: 2026-07-05 - Empty State Copy Refresh
- Completed: Rewrote customer-facing empty and search-empty messaging in `src/pages/Customers.tsx`, `src/pages/Appointments.tsx`, `src/pages/RepairOrders.tsx`, `src/pages/FinancialDashboard.tsx`, `src/pages/Invoices.tsx`, `src/pages/Settings.tsx`, `src/pages/ServiceCatalog.tsx`, and `src/pages/QuoteGenerator.tsx` so each state explains the workflow purpose and next step more professionally.
- Resolved roadmap priority: Priority 1 - Trust & Professionalism, item 5 by replacing casual or sparse no-data language with clearer production-ready guidance across core workflows.
- Think Tank benefit: The service advisor valued cleaner customer-record and invoice messaging, the mobile mechanic benefited from clearer scheduling and quote guidance during day-to-day use, and the home mechanic benefited from empty states that explain what each tool is for without sounding like unfinished software.
## Execution Memo: 2026-07-05 - Appointment And RO Service Address Support
- Completed: Added service-address capture to the standard appointment create/edit flow in `src/pages/Appointments.tsx`, preserved appointment service locations through structured note serialization for schema compatibility, and surfaced service locations in appointment and repair-order search and list views in `src/pages/Appointments.tsx`, `src/pages/RepairOrders.tsx`, and `src/components/ROForm.tsx`.
- Resolved roadmap priority: Priority 2 - Mobile Mechanic Foundation, item 6 by carrying service-address information through appointments and repair orders without requiring PocketBase schema changes.
- Think Tank benefit: The service advisor valued clearer job-site context when preparing work, the mobile mechanic gained a practical on-site address workflow for scheduling and RO handoff, and the home mechanic benefited from being able to record where the work will happen without burying it in general notes.
## Execution Memo: 2026-07-05 - Trip Mileage & Diagnostic Fee Tracking
- Completed: Added `tripMiles` as a first-class concept for mobile mechanic tax-mileage tracking across appointments, repair orders, and PDF documents. Wired through appointment create/edit/check-in flows, RO form create/edit, RO list/row visibility, RO details panel, RO PDF info and totals sections, and appointment card display. Diagnostic fee already existed as the travel-fee field with proper "Travel / Diagnostic Fee" labeling in the UI and settings.
- Resolved roadmap priority: Priority 2 - Mobile Mechanic Foundation, item 7 by making trip mileage and diagnostic/travel fee first-class workflow fields that flow from appointment scheduling through to the tax-ready RO PDF.
- Think Tank benefit: The mobile mechanic gained a practical tax-mileage log that records trip distance per service call and prints on PDFs for deduction records, the service advisor gained trip-distance visibility on RO lists, and the home mechanic benefited from coordinated trip tracking without manual note-keeping.
## Execution Memo: 2026-07-05 - Arrival Window For Appointments
- Completed: Added `arrivalWindowMinutes` to the appointment type, form data, note serialization, and create/edit modal UI in `src/pages/Appointments.tsx`. The arrival window appears as a time-range display on the appointment card (e.g. "8:00 AM — 10:00 AM") when set, and defaults to "Exact time" when zero. Gated behind the mobile/home business-type toggle like other location fields.
- Resolved roadmap priority: Priority 2 - Mobile Mechanic Foundation, item 9 by adding a customer-facing arrival-window concept that tells the customer when to expect the mechanic without pinning it to an exact minute.
- Think Tank benefit: The mobile mechanic valued being able to provide a realistic time range to customers instead of a hard-to-keep exact time, the service advisor benefited from cleaner schedule presentation when communicating with waiting customers, and the home mechanic benefited from a simpler visual schedule layout.
## Execution Memo: 2026-07-05 - Customer Authorization State
- Completed: Added authorization metadata fields (`authorizedBy`, `authorizedAt`, `authorizedMethod`) to the `QuoteService` type in `src/types.ts`. Updated the Zustand quote store (`src/store/quote.ts`) so `toggleApproved` and `toggleDeclined` automatically populate authorization with customer name, current timestamp, and default method ("In Person"), and clear authorization when reverting to pending. Enhanced the QuoteGenerator expanded service row (`src/pages/QuoteGenerator.tsx`) with an inline authorization panel (authorized-by input, method dropdown with in_person/phone/email/text/signature options, and a formatted timestamp) that appears when a service is approved or declined. Updated the quote PDF generator (`src/lib/pdf.ts`) to render the authorization method and authorized-by name as a sub-line beneath the approval/decline badge on each service.
- Resolved roadmap priority: Priority 3 - Customer Trust & Approval, item 10 by giving every quote service a documented authorization trail that captures who approved or declined, when, and through which method.
- Think Tank benefit: The service advisor valued the liability protection of timestamped customer authorization records printed on customer-facing documents, the mobile mechanic valued frictionless on-site authorization capture via method-specific tracking, and the home mechanic valued clear decision documentation without a clunky workflow.
## Execution Memo: 2026-07-05 - Public Quote Approval URL + Signature & Send Improvements
- Completed: Added a full customer-facing quote approval flow via public share link. Created `pb_migrations/1739999000007_quote_share_token.js` adding a `shareToken` field and relaxed list/view/update rules on the `quotes` collection. Created `src/pages/QuoteApprove.tsx` — a branded public page at `/quote/approve?token=xxx` where customers review and approve/decline services. Added a "Share with Customer" button to the QuoteGenerator sidebar and wired the approval page URL generation. Stamped decisions with `authorizedMethod: 'online'` and added the `'online'` value to the authorizedMethod type union. Added the route as public in `src/App.tsx`.
- Then improved the flow in a second pass: added `pb_migrations/1739999000008_quote_viewed_at.js` for first-view tracking. Rewrote the approval page to stage decisions locally, require a typed full-name signature before final submission, and submit all decisions in a single update with correct top-level status (all approved -> `approved`, all declined -> `declined`, mixed -> `sent`). Added a shared `src/lib/contactLinks.ts` helper for `mailto:`/`sms:` draft generation and refactored `RepairOrders.tsx` to use it. Added "Send Email" and "Send Text" buttons to the quote share panel that open device drafts with the approval URL embedded.
- Resolved roadmap priority: Priority 3 - Customer Trust & Approval, item 11.
- Think Tank benefit: The mobile mechanic can now text a live approval link from the job site and get a signed decision record, the service advisor gains a documented online authorization trail with a typed-name signature that prints on PDFs, and the home mechanic gets a simple send-via-email workflow for sharing cost estimates with family.
## Execution Memo: 2026-07-05 - Role-Based Experience Roadmap
- Completed: Created `Roadmap.md` with the full implementation plan for post-onboarding role-based experiences across Advisor, Technician, and Mobile Mechanic users. The plan defines role permissions, route architecture, mobile owner access, technician-safe assignment records, PocketBase schema requirements, security rules, onboarding updates, and verification steps.
- Resolved roadmap priority: RBX architecture planning for strict Advisor, Technician, and Mobile separation while preserving Priority 1 professionalism and preparing safe implementation of future workflow phases.
- Think Tank benefit: The service advisor gains a clear path to full desk tools and technician assignment control, the shop technician gains a protected bay-only workflow with no billing exposure, and the mobile mechanic keeps full owner-level quote, catalog, invoice, and financial access in a field-first interface.
Binary file not shown.
+18
View File
@@ -0,0 +1,18 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="theme-color" content="#16a34a" />
<meta name="description" content="ShopProQuote — automotive service quotes, repair orders, and shop management for service advisors and mobile mechanics." />
<meta property="og:title" content="ShopProQuote" />
<meta property="og:description" content="Automotive service quotes and shop management." />
<meta property="og:type" content="website" />
<title>ShopProQuote — Shop Management</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+3474
View File
File diff suppressed because it is too large Load Diff
+43
View File
@@ -0,0 +1,43 @@
{
"name": "spq-v2",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "oxlint",
"preview": "vite preview",
"test": "vitest run",
"test:watch": "vitest"
},
"dependencies": {
"@sentry/react": "^10.64.0",
"@tailwindcss/vite": "^4.3.1",
"jspdf": "^4.2.1",
"lucide-react": "^1.21.0",
"pdfjs-dist": "^6.1.200",
"pocketbase": "^0.27.0",
"react": "^19.2.7",
"react-dom": "^19.2.7",
"react-router-dom": "^7.18.0",
"tailwindcss": "^4.3.1",
"tesseract.js": "^7.0.0",
"zod": "^4.4.3",
"zustand": "^5.0.14"
},
"devDependencies": {
"@testing-library/jest-dom": "^6.9.1",
"@testing-library/react": "^16.3.2",
"@types/jspdf": "^2.0.0",
"@types/node": "^24.13.2",
"@types/react": "^19.2.17",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.2",
"jsdom": "^29.1.1",
"oxlint": "^1.69.0",
"typescript": "~6.0.2",
"vite": "^8.1.0",
"vitest": "^4.1.9"
}
}
@@ -0,0 +1,65 @@
/// <reference path="../pb_data/types.d.ts" />
//
// M1 — Add dedicated `customerType` column to `repairOrders`.
//
// Previously `customerType` lived only inside the stringified `financial` JSON
// blob, which broke whenever the blob was corrupt or missing. This migration
// promotes it to a real text column and backfills values from the blob.
//
// Run: ./pocketbase migrate (from your PocketBase install)
//
migrate(
(db) => {
const col = db.findCollectionByNameOrId('repairOrders');
if (!col) {
console.warn('[M1] repairOrders collection not found — skipping');
return;
}
// Add the field if it doesn't already exist.
if (!col.fields.find((f) => f.name === 'customerType')) {
const field = new TextField({
name: 'customerType',
required: false,
// Constrain to the two valid values; PB validates via pattern.
// Using a generic text field with pattern keeps it permissive during
// backfill (allow any value), then we recommend tightening later.
});
col.fields.push(field);
db.save(col);
console.log('[M1] added customerType field');
}
// Backfill from the existing `financial` JSON blob.
const records = Array.from(db.findRecordsByFilter(
"repairOrders",
"1=1",
"id",
500
));
for (const rec of records) {
let fin = {};
try {
const raw = rec.get('financial');
if (typeof raw === 'string' && raw.trim()) {
fin = JSON.parse(raw);
} else if (raw && typeof raw === 'object') {
fin = raw;
}
} catch { /* ignore corrupt blobs */ }
const ct = (fin && fin.customerType === 'waiter') ? 'waiter' : 'drop-off';
rec.set('customerType', ct);
db.saveRecord(rec);
}
console.log(`[M1] backfilled ${records.length} records`);
},
(db) => {
const col = db.findCollectionByNameOrId('repairOrders');
if (!col) return;
const idx = col.fields.findIndex((f) => f.name === 'customerType');
if (idx >= 0) {
col.fields.splice(idx, 1);
db.save(col);
}
}
);
@@ -0,0 +1,61 @@
/// <reference path="../pb_data/types.d.ts" />
//
// M2 — Add integer-minutes `estimatedDuration` column to `repairOrders`.
//
// Today the duration is stored as `estimatedTime` (a HOURS STRING such as
// "0.3"), converted back to minutes via Math.round(hours*60) on read. That
// round-trip is lossy: 15 min → "0.3" → 18 min, drifting the due time every
// reload. This migration adds a numeric `estimatedDuration` (integer minutes)
// column and backfills it from `estimatedTime`.
//
// The frontend still writes BOTH columns after this migration (for backward
// compat with older clients), but reads prefer `estimatedDuration` when
// present. A follow-up cleanup can drop the `estimatedTime` writes once all
// clients are upgraded.
//
migrate(
(db) => {
const col = db.findCollectionByNameOrId('repairOrders');
if (!col) {
console.warn('[M2] repairOrders collection not found — skipping');
return;
}
if (!col.fields.find((f) => f.name === 'estimatedDuration')) {
const field = new NumberField({
name: 'estimatedDuration',
required: false,
min: 0,
});
col.fields.push(field);
db.save(col);
console.log('[M2] added estimatedDuration field');
}
const records = Array.from(db.findRecordsByFilter(
"repairOrders",
"1=1",
"id",
500
));
let backfilled = 0;
for (const rec of records) {
if (rec.get('estimatedDuration') != null) continue; // don't overwrite
const raw = rec.get('estimatedTime');
const hours = parseFloat(String(raw || '0')) || 0;
rec.set('estimatedDuration', Math.round(hours * 60));
db.saveRecord(rec);
backfilled++;
}
console.log(`[M2] backfilled ${backfilled} records`);
},
(db) => {
const col = db.findCollectionByNameOrId('repairOrders');
if (!col) return;
const idx = col.fields.findIndex((f) => f.name === 'estimatedDuration');
if (idx >= 0) {
col.fields.splice(idx, 1);
db.save(col);
}
}
);
@@ -0,0 +1,79 @@
/// <reference path="../pb_data/types.d.ts" />
//
// M3 — Promote financial fields out of the `financial` JSON blob into real
// numeric columns on `repairOrders`. This lets the FinancialDashboard run
// server-side `sum()` aggregations instead of pulling every completed RO to
// the client. It also makes the values type-safe and queryable.
//
// New columns: grossTotal, grossCost, warrTotal, warrCost, shopCharge.
// (shopCharge overlaps conceptually with the existing `shopCharges` column —
// see the migration doc. Here we add `shopCharge` as a separate field to
// match the financial-blob key the frontend currently writes. If you prefer
// to consolidate, point the frontend at `shopCharges` instead and skip this
// field.)
//
migrate(
(db) => {
const col = db.findCollectionByNameOrId('repairOrders');
if (!col) {
console.warn('[M3] repairOrders collection not found — skipping');
return;
}
for (const name of ['grossTotal', 'grossCost', 'warrTotal', 'warrCost', 'shopCharge']) {
if (!col.fields.find((f) => f.name === name)) {
col.fields.push(new NumberField({ name, required: false, min: 0 }));
}
}
db.save(col);
console.log('[M3] added financial numeric fields');
const records = Array.from(db.findRecordsByFilter(
"repairOrders",
"1=1",
"id",
500
));
let backfilled = 0;
for (const rec of records) {
let fin = {};
try {
const raw = rec.get('financial');
if (typeof raw === 'string' && raw.trim()) {
fin = JSON.parse(raw);
} else if (raw && typeof raw === 'object') {
fin = raw;
}
} catch { /* ignore corrupt blobs */ }
let changed = false;
for (const [srcKey, dstKey] of [
['grossTotal', 'grossTotal'],
['grossCost', 'grossCost'],
['warrTotal', 'warrTotal'],
['warrCost', 'warrCost'],
['shopCharge', 'shopCharge'],
]) {
const v = Number(fin && fin[srcKey]);
if (!Number.isNaN(v) && rec.get(dstKey) == null) {
rec.set(dstKey, v || 0);
changed = true;
}
}
if (changed) {
db.saveRecord(rec);
backfilled++;
}
}
console.log(`[M3] backfilled ${backfilled} records`);
},
(db) => {
const col = db.findCollectionByNameOrId('repairOrders');
if (!col) return;
for (const name of ['grossTotal', 'grossCost', 'warrTotal', 'warrCost', 'shopCharge']) {
const idx = col.fields.findIndex((f) => f.name === name);
if (idx >= 0) col.fields.splice(idx, 1);
}
db.save(col);
}
);
@@ -0,0 +1,65 @@
/// <reference path="../pb_data/types.d.ts" />
//
// M4 — API Rules for all user-scoped collections.
//
// CRITICAL SECURITY: without these rules, any logged-in user can read or
// overwrite every other shop's records by passing a different `userId` in
// the request body. The frontend cannot enforce this — it must be locked
// down at the DB layer.
//
// This migration sets List/View/Create/Update/Delete rules on each
// user-owned collection to: userId = @request.auth.id
//
// Notes:
// - `users` admin-only access is NOT configured here (do that in the PB
// admin UI under the users collection). This migration only touches the
// per-shop data collections.
// - `settings` is treated as user-scoped via its `userId` field (the
// frontend stores a `businessSettings` record per user).
// - Re-run is idempotent — applying the same rule string twice is safe.
//
const USER_SCOPED_COLLECTIONS = [
'quotes',
'repairOrders',
'invoices',
'appointments',
'customers',
'services',
'settings',
];
const RULE = 'userId = @request.auth.id';
migrate(
(db) => {
for (const name of USER_SCOPED_COLLECTIONS) {
const col = db.findCollectionByNameOrId(name);
if (!col) {
console.warn(`[M4] collection "${name}" not found — skipping (create it first in admin)`);
continue;
}
col.listRule = RULE;
col.viewRule = RULE;
col.createRule = RULE;
col.updateRule = RULE;
col.deleteRule = RULE;
db.save(col);
console.log(`[M4] applied user-scoped rules to "${name}"`);
}
},
(db) => {
// On rollback: clear the rules back to empty (full access as admin).
// WARNING: this re-opens cross-tenant access — only run the down-migration
// if you intend to reconfigure manually afterward.
for (const name of USER_SCOPED_COLLECTIONS) {
const col = db.findCollectionByNameOrId(name);
if (!col) continue;
col.listRule = '';
col.viewRule = '';
col.createRule = '';
col.updateRule = '';
col.deleteRule = '';
db.save(col);
}
}
);
@@ -0,0 +1,58 @@
/// <reference path="../pb_data/types.d.ts" />
//
// M5 — Unique index on `repairOrders.roNumber`.
//
// The frontend generates RO numbers with Math.random() and now guards against
// collisions against the loaded set (see src/pages/RepairOrders.tsx,
// uniqueRONumber()). But two advisors creating ROs in the same second can
// still race past the frontend check. This migration adds a server-side
// unique index so the second write fails at the DB layer instead of silently
// producing two ROs with the same number.
//
// If your existing data has duplicate roNumbers, this migration will fail —
// dedupe in the admin UI first (find records where roNumber = '', set them to
// unique placeholder values, then retry).
//
migrate(
(db) => {
const col = db.findCollectionByNameOrId('repairOrders');
if (!col) {
console.warn('[M5] repairOrders collection not found — skipping');
return;
}
// PocketBase 0.20+ uses the `Index` helper on a field. Add a unique
// index on roNumber if one isn't already present. Using a TextField
// option `unique` is the simplest cross-version approach.
let changed = false;
const existing = col.fields.find((f) => f.name === 'roNumber');
if (existing && existing.type === 'text' && !existing.options?.unique) {
existing.options = { ...(existing.options || {}), unique: true };
changed = true;
} else if (!existing) {
// Field missing entirely — add it with unique.
col.fields.push(new TextField({
name: 'roNumber',
required: false,
options: { unique: true },
}));
changed = true;
}
if (changed) {
db.save(col);
console.log('[M5] set roNumber unique=true on repairOrders');
} else {
console.log('[M5] roNumber already unique — no change');
}
},
(db) => {
const col = db.findCollectionByNameOrId('repairOrders');
if (!col) return;
const f = col.fields.find((f) => f.name === 'roNumber');
if (f && f.type === 'text' && f.options?.unique) {
f.options.unique = false;
db.save(col);
}
}
);
@@ -0,0 +1,54 @@
/// <reference path="../pb_data/types.d.ts" />
//
// M6 — Add a Quote ↔ Repair Order link.
//
// `quotes.repairOrderId` — set when a quote is generated from an existing RO
// (so you can jump back to the originating RO).
// `repairOrders.quoteId` — set when an RO is created by "converting" an
// approved quote (so you can jump back to the source
// quote). Also lets the dashboard report "converted
// quotes" as a metric.
//
// Both are nullable text columns (PocketBase relation fields would lock you
// into one record type; plain text keeps the link loose and survives renames
// of the related collection). The frontend treats empty/null as "no link".
//
migrate(
(db) => {
// quotes.repairOrderId
const quotes = db.findCollectionByNameOrId('quotes');
if (quotes) {
if (!quotes.fields.find((f) => f.name === 'repairOrderId')) {
quotes.fields.push(new TextField({ name: 'repairOrderId', required: false }));
db.save(quotes);
console.log('[M6] added repairOrderId to quotes');
}
} else {
console.warn('[M6] quotes collection not found — skipping');
}
// repairOrders.quoteId
const ros = db.findCollectionByNameOrId('repairOrders');
if (ros) {
if (!ros.fields.find((f) => f.name === 'quoteId')) {
ros.fields.push(new TextField({ name: 'quoteId', required: false }));
db.save(ros);
console.log('[M6] added quoteId to repairOrders');
}
} else {
console.warn('[M6] repairOrders collection not found — skipping');
}
},
(db) => {
const quotes = db.findCollectionByNameOrId('quotes');
if (quotes) {
const i1 = quotes.fields.findIndex((f) => f.name === 'repairOrderId');
if (i1 >= 0) { quotes.fields.splice(i1, 1); db.save(quotes); }
}
const ros = db.findCollectionByNameOrId('repairOrders');
if (ros) {
const i2 = ros.fields.findIndex((f) => f.name === 'quoteId');
if (i2 >= 0) { ros.fields.splice(i2, 1); db.save(ros); }
}
}
);
@@ -0,0 +1,98 @@
/// <reference path="../pb_data/types.d.ts" />
//
// M7 — Add structured VIN and duration fields to appointments.
//
// Older scan imports stored these values inside `notes` as:
// VIN: <value> | Duration: <number> min
// This migration adds first-class columns, backfills them when missing, and
// removes those machine-generated note fragments while preserving any real text.
//
function compactNoteParts(raw) {
return String(raw || '')
.split('|')
.map((part) => part.trim())
.filter(Boolean);
}
migrate(
(db) => {
const col = db.findCollectionByNameOrId('appointments');
if (!col) {
console.warn('[M7] appointments collection not found — skipping');
return;
}
if (!col.fields.find((f) => f.name === 'vin')) {
col.fields.push(new TextField({ name: 'vin', required: false }));
db.save(col);
console.log('[M7] added vin field to appointments');
}
if (!col.fields.find((f) => f.name === 'durationMinutes')) {
col.fields.push(new NumberField({
name: 'durationMinutes',
required: false,
min: 0,
}));
db.save(col);
console.log('[M7] added durationMinutes field to appointments');
}
const records = Array.from(db.findRecordsByFilter('appointments', '1=1', 'id', 500));
let backfilled = 0;
for (const rec of records) {
const rawNotes = String(rec.get('notes') || '');
const vinMatch = rawNotes.match(/(?:^|\|)\s*VIN:\s*([^|]+?)\s*(?=\||$)/i);
const durationMatch = rawNotes.match(/(?:^|\|)\s*Duration:\s*(\d+)\s*min\s*(?=\||$)/i);
const nextVin = String(rec.get('vin') || '').trim();
const rawDuration = rec.get('durationMinutes');
const nextDuration = rawDuration == null || rawDuration === '' ? null : Number(rawDuration);
let changed = false;
if (!nextVin && vinMatch?.[1]) {
rec.set('vin', String(vinMatch[1]).trim());
changed = true;
}
if ((nextDuration == null || Number.isNaN(nextDuration)) && durationMatch?.[1]) {
rec.set('durationMinutes', Number(durationMatch[1]));
changed = true;
}
const cleanedNotes = compactNoteParts(rawNotes)
.filter((part) => !/^VIN:\s*/i.test(part) && !/^Duration:\s*\d+\s*min$/i.test(part))
.join(' | ');
if (cleanedNotes !== rawNotes.trim()) {
rec.set('notes', cleanedNotes);
changed = true;
}
if (changed) {
db.saveRecord(rec);
backfilled++;
}
}
console.log(`[M7] updated ${backfilled} appointment record(s)`);
},
(db) => {
const col = db.findCollectionByNameOrId('appointments');
if (!col) return;
const durationIdx = col.fields.findIndex((f) => f.name === 'durationMinutes');
if (durationIdx >= 0) {
col.fields.splice(durationIdx, 1);
db.save(col);
}
const vinIdx = col.fields.findIndex((f) => f.name === 'vin');
if (vinIdx >= 0) {
col.fields.splice(vinIdx, 1);
db.save(col);
}
}
);
@@ -0,0 +1,62 @@
/// <reference path="../pb_data/types.d.ts" />
//
// M7 — Public Quote Sharing via Share Token.
//
// Adds a `shareToken` field to the `quotes` collection so a shop owner can
// generate a secret URL that lets a customer review and approve/decline
// individual services without logging in.
//
// The `viewRule` and `updateRule` are relaxed to allow access when the
// request carries a matching `shareToken` query parameter. The token is
// a random UUID, making it unguessable while simple to copy/paste.
//
// Security: the token only grants access to the single quote record it
// belongs to. All other records remain locked to the owning user only.
//
migrate(
(db) => {
const quotes = db.findCollectionByNameOrId('quotes');
if (!quotes) {
console.warn('[M7] quotes collection not found — skipping');
return;
}
// ── Add shareToken field ─────────────────────────────────────────
if (!quotes.fields.find((f) => f.name === 'shareToken')) {
quotes.fields.push(
new TextField({
name: 'shareToken',
required: false,
})
);
console.log('[M7] added shareToken field to quotes');
}
// ── Relax view & update rules for public access via shareToken ──
const PUBLIC_SHARE_RULE = `(userId = @request.auth.id) || (shareToken != "" && shareToken = @request.query.shareToken)`;
quotes.listRule = PUBLIC_SHARE_RULE;
quotes.viewRule = PUBLIC_SHARE_RULE;
quotes.updateRule = PUBLIC_SHARE_RULE;
// create/delete stay locked to the owning user
db.save(quotes);
console.log('[M7] relaxed list/view/update rules on quotes for public share access');
},
(db) => {
const quotes = db.findCollectionByNameOrId('quotes');
if (!quotes) return;
const idx = quotes.fields.findIndex((f) => f.name === 'shareToken');
if (idx >= 0) {
quotes.fields.splice(idx, 1);
db.save(quotes);
}
// Restore tight user-only rules
quotes.listRule = 'userId = @request.auth.id';
quotes.viewRule = 'userId = @request.auth.id';
quotes.updateRule = 'userId = @request.auth.id';
db.save(quotes);
console.log('[M7] rolled back shareToken — rules restored to user-only');
}
);
@@ -0,0 +1,164 @@
/// <reference path="../pb_data/types.d.ts" />
//
// M8 — Align appointments with the frontend's structured field model.
//
// Older databases used these fields:
// appointmentDateTime, duration, type
// and often embedded VIN/duration inside notes.
//
// The current frontend expects first-class fields:
// date, time, vin, durationMinutes, advisorName, serviceType
//
// This migration adds any missing fields and backfills them from the legacy
// columns while preserving existing data.
//
function ensureTextField(col, name) {
if (!col.fields.find((f) => f.name === name)) {
col.fields.push(new TextField({ name, required: false }));
return true;
}
return false;
}
function ensureNumberField(col, name) {
if (!col.fields.find((f) => f.name === name)) {
col.fields.push(new NumberField({ name, required: false, min: 0 }));
return true;
}
return false;
}
function splitAppointmentDateTime(value) {
const raw = String(value || '').trim();
const match = raw.match(/^(\d{4}-\d{2}-\d{2})T(\d{2}:\d{2})/);
if (match) {
return { date: match[1], time: match[2] };
}
return { date: '', time: '' };
}
function extractVinFromNotes(notes) {
const match = String(notes || '').match(/(?:^|\|)\s*VIN:\s*([^|]+?)\s*(?=\||$)/i);
return match?.[1]?.trim().toUpperCase() || '';
}
function extractDurationFromNotes(notes) {
const match = String(notes || '').match(/(?:^|\|)\s*Duration:\s*(\d+)\s*min\s*(?=\||$)/i);
return match?.[1] ? Number(match[1]) : null;
}
function stripStructuredAppointmentNotes(notes) {
return String(notes || '')
.split('|')
.map((part) => part.trim())
.filter((part) => part && !/^VIN:\s*/i.test(part) && !/^Duration:\s*\d+\s*min$/i.test(part))
.join(' | ');
}
migrate(
(db) => {
const col = db.findCollectionByNameOrId('appointments');
if (!col) {
console.warn('[M8] appointments collection not found — skipping');
return;
}
let schemaChanged = false;
schemaChanged = ensureTextField(col, 'date') || schemaChanged;
schemaChanged = ensureTextField(col, 'time') || schemaChanged;
schemaChanged = ensureTextField(col, 'vin') || schemaChanged;
schemaChanged = ensureTextField(col, 'advisorName') || schemaChanged;
schemaChanged = ensureTextField(col, 'serviceType') || schemaChanged;
schemaChanged = ensureNumberField(col, 'durationMinutes') || schemaChanged;
if (schemaChanged) {
db.save(col);
console.log('[M8] aligned appointments schema');
}
const records = Array.from(db.findRecordsByFilter('appointments', '1=1', 'id', 1000));
let backfilled = 0;
for (const rec of records) {
const notes = String(rec.get('notes') || '');
const legacyDateTime = String(rec.get('appointmentDateTime') || '');
const split = splitAppointmentDateTime(legacyDateTime);
const currentDate = String(rec.get('date') || '').trim();
const currentTime = String(rec.get('time') || '').trim();
const currentVin = String(rec.get('vin') || '').trim();
const currentServiceType = String(rec.get('serviceType') || '').trim();
const rawDurationMinutes = rec.get('durationMinutes');
const currentDurationMinutes = rawDurationMinutes == null || rawDurationMinutes === ''
? null
: Number(rawDurationMinutes);
const legacyDuration = rec.get('duration');
const numericLegacyDuration = legacyDuration == null || legacyDuration === ''
? null
: Number(legacyDuration);
let changed = false;
if (!currentDate && split.date) {
rec.set('date', split.date);
changed = true;
}
if (!currentTime && split.time) {
rec.set('time', split.time);
changed = true;
}
if (!currentVin) {
const noteVin = extractVinFromNotes(notes);
if (noteVin) {
rec.set('vin', noteVin);
changed = true;
}
}
if (!currentServiceType) {
const legacyType = String(rec.get('type') || '').trim();
if (legacyType) {
rec.set('serviceType', legacyType);
changed = true;
}
}
if (currentDurationMinutes == null || Number.isNaN(currentDurationMinutes) || (currentDurationMinutes <= 0 && numericLegacyDuration > 0)) {
const noteDuration = extractDurationFromNotes(notes);
const nextDuration = noteDuration ?? numericLegacyDuration;
if (nextDuration != null && !Number.isNaN(nextDuration)) {
rec.set('durationMinutes', nextDuration);
changed = true;
}
}
const cleanedNotes = stripStructuredAppointmentNotes(notes);
if (cleanedNotes !== notes.trim()) {
rec.set('notes', cleanedNotes);
changed = true;
}
if (changed) {
db.saveRecord(rec);
backfilled++;
}
}
console.log(`[M8] backfilled ${backfilled} appointment record(s)`);
},
(db) => {
const col = db.findCollectionByNameOrId('appointments');
if (!col) return;
for (const name of ['durationMinutes', 'serviceType', 'advisorName', 'vin', 'time', 'date']) {
const idx = col.fields.findIndex((f) => f.name === name);
if (idx >= 0) {
col.fields.splice(idx, 1);
}
}
db.save(col);
}
);
@@ -0,0 +1,31 @@
/// <reference path="../pb_data/types.d.ts" />
//
// M8 — Quote Viewed-At Timestamp.
//
// Adds a `viewedAt` text field to the `quotes` collection so the front-end
// can record the first time a customer opens the public approval link.
//
migrate(
(db) => {
const quotes = db.findCollectionByNameOrId('quotes');
if (!quotes) {
console.warn('[M8] quotes collection not found — skipping');
return;
}
if (!quotes.fields.find((f) => f.name === 'viewedAt')) {
quotes.fields.push(new TextField({ name: 'viewedAt', required: false }));
console.log('[M8] added viewedAt field to quotes');
}
db.save(quotes);
},
(db) => {
const quotes = db.findCollectionByNameOrId('quotes');
if (!quotes) return;
const idx = quotes.fields.findIndex((f) => f.name === 'viewedAt');
if (idx >= 0) {
quotes.fields.splice(idx, 1);
db.save(quotes);
console.log('[M8] removed viewedAt from quotes');
}
}
);
@@ -0,0 +1,40 @@
/// <reference path="../pb_data/types.d.ts" />
//
// M9 — Rename legacy repair order status `delivered` to `final_close`.
//
// The frontend now uses `final_close` as the canonical terminal post-completion
// status. This migration updates existing repair orders so historical data and
// new UI filters/counts stay aligned.
//
migrate(
(db) => {
const col = db.findCollectionByNameOrId('repairOrders');
if (!col) {
console.warn('[M9] repairOrders collection not found — skipping');
return;
}
const records = Array.from(db.findRecordsByFilter('repairOrders', "status = 'delivered'", 'id', 1000));
let updated = 0;
for (const rec of records) {
rec.set('status', 'final_close');
db.saveRecord(rec);
updated++;
}
console.log(`[M9] renamed ${updated} repair order(s) to final_close`);
},
(db) => {
const col = db.findCollectionByNameOrId('repairOrders');
if (!col) return;
const records = Array.from(db.findRecordsByFilter('repairOrders', "status = 'final_close'", 'id', 1000));
let reverted = 0;
for (const rec of records) {
rec.set('status', 'delivered');
db.saveRecord(rec);
reverted++;
}
console.log(`[M9] reverted ${reverted} repair order(s) to delivered`);
}
);
@@ -0,0 +1,70 @@
/// <reference path="../pb_data/types.d.ts" />
//
// M10 — Add role, shopUserId, and displayName to the users collection.
//
// This enables the RBX (Role-Based Experience). The `role` field determines
// which dashboard and pages the user sees. `shopUserId` links a technician
// account to its shop owner. `displayName` is a friendly name shown in the
// technician profile and advisor assignment views.
//
migrate(
(db) => {
const col = db.findCollectionByNameOrId('users');
if (!col) {
console.warn('[M10] users collection not found — skipping');
return;
}
// ── role: select ────────────────────────────────────────────────────────
const existingRole = col.fields.find((f) => f.name === 'role');
if (!existingRole) {
const f = new SelectField({
name: 'role',
required: false,
values: ['advisor', 'technician', 'mobile'],
maxSelect: 1,
});
col.fields.add(f);
console.log('[M10] added role field to users');
} else {
console.log('[M10] role already exists — no change');
}
// ── shopUserId: text (relation to the shop-owner user) ─────────────────
const existingShopUser = col.fields.find((f) => f.name === 'shopUserId');
if (!existingShopUser) {
const f = new TextField({
name: 'shopUserId',
required: false,
});
col.fields.add(f);
console.log('[M10] added shopUserId field to users');
} else {
console.log('[M10] shopUserId already exists — no change');
}
// ── displayName: text ──────────────────────────────────────────────────
const existingDisplayName = col.fields.find((f) => f.name === 'displayName');
if (!existingDisplayName) {
const f = new TextField({
name: 'displayName',
required: false,
});
col.fields.add(f);
console.log('[M10] added displayName field to users');
} else {
console.log('[M10] displayName already exists — no change');
}
db.save(col);
},
(db) => {
const col = db.findCollectionByNameOrId('users');
if (!col) return;
col.fields.remove('role');
col.fields.remove('shopUserId');
col.fields.remove('displayName');
db.save(col);
}
);
@@ -0,0 +1,152 @@
/// <reference path="../pb_data/types.d.ts" />
//
// M11 — Create the technicianAssignments collection.
//
// Each record is a sanitized copy of one RO that an advisor has assigned to a
// technician. The assignment carries only the data a technician needs to do
// the job — no pricing, no totals, no customer contact details.
//
// API rule pattern (applied after creation):
// shopUserId = @request.auth.id || technicianUserId = @request.auth.id
migrate(
(db) => {
const existing = db.findCollectionByNameOrId('technicianAssignments');
if (existing) {
console.log('[M11] technicianAssignments already exists — skipping');
return;
}
const col = new Collection();
col.name = 'technicianAssignments';
col.type = 'base';
col.system = false;
col.listRule = null;
col.viewRule = null;
col.createRule = null;
col.updateRule = null;
col.deleteRule = null;
// ── Fields ──────────────────────────────────────────────────────────────
// shopUserId — owner of the shop that created this assignment
const shopUserIdField = new TextField({
name: 'shopUserId',
required: true,
});
col.fields.add(shopUserIdField);
// technicianUserId — the technician who receives this work
const techUserIdField = new TextField({
name: 'technicianUserId',
required: true,
});
col.fields.add(techUserIdField);
// repairOrderId — source RO id (plain text, not a relation)
const roIdField = new TextField({
name: 'repairOrderId',
required: true,
});
col.fields.add(roIdField);
// roNumber — for display (no relation to repairOrders)
const roNumField = new TextField({
name: 'roNumber',
required: false,
});
col.fields.add(roNumField);
// vehicleInfo — e.g. "2018 Ford F-150"
const vehicleField = new TextField({
name: 'vehicleInfo',
required: false,
});
col.fields.add(vehicleField);
// vin
const vinField = new TextField({
name: 'vin',
required: false,
});
col.fields.add(vinField);
// mileage
const mileageField = new TextField({
name: 'mileage',
required: false,
});
col.fields.add(mileageField);
// serviceLocation — job-site address (mobile use-case)
const locField = new TextField({
name: 'serviceLocation',
required: false,
});
col.fields.add(locField);
// status — assignment-level job status
const statusField = new SelectField({
name: 'status',
required: false,
values: ['pending', 'in_progress', 'waiting_parts', 'completed'],
maxSelect: 1,
});
col.fields.add(statusField);
// services — sanitized service list (JSON, no pricing)
const servicesField = new JSONField({
name: 'services',
required: false,
});
col.fields.add(servicesField);
// internalNotes — technician notes (JSON or plain text)
const notesField = new JSONField({
name: 'internalNotes',
required: false,
});
col.fields.add(notesField);
// clockEntries — stopwatch entries (JSON array of ClockEntry)
const clockField = new JSONField({
name: 'clockEntries',
required: false,
});
col.fields.add(clockField);
// inspectionChecklist — JSON (future)
const checklistField = new JSONField({
name: 'inspectionChecklist',
required: false,
});
col.fields.add(checklistField);
// advisorRequests — messages the technician sends to the advisor
const requestsField = new JSONField({
name: 'advisorRequests',
required: false,
});
col.fields.add(requestsField);
db.save(col);
console.log('[M11] created technicianAssignments collection');
// ── Apply API rules ───────────────────────────────────────────────────
const saved = db.findCollectionByNameOrId('technicianAssignments');
saved.listRule = 'shopUserId = @request.auth.id || technicianUserId = @request.auth.id';
saved.viewRule = 'shopUserId = @request.auth.id || technicianUserId = @request.auth.id';
saved.createRule = 'shopUserId = @request.auth.id';
saved.updateRule = 'shopUserId = @request.auth.id || technicianUserId = @request.auth.id';
saved.deleteRule = 'shopUserId = @request.auth.id';
db.save(saved);
console.log('[M11] applied API rules to technicianAssignments');
},
(db) => {
const col = db.findCollectionByNameOrId('technicianAssignments');
if (col) {
db.deleteCollection(col);
console.log('[M11] deleted technicianAssignments collection');
}
}
);
@@ -0,0 +1,61 @@
/// <reference path="../pb_data/types.d.ts" />
//
// M12 — Create technicianInvites.
//
// Advisors create invite records and copy the generated invite link. The
// frontend stores only a SHA-256 hash of the one-time token; the raw token only
// exists in the copied URL.
migrate(
(db) => {
let existing = null;
try {
existing = db.findCollectionByNameOrId('technicianInvites');
} catch {
existing = null;
}
if (existing) {
console.log('[M12] technicianInvites already exists — skipping');
return;
}
const col = new Collection();
col.name = 'technicianInvites';
col.type = 'base';
col.system = false;
col.listRule = 'shopUserId = @request.auth.id || (tokenHash = @request.query.tokenHash && status = "pending" && expiresAt >= @now)';
col.viewRule = 'shopUserId = @request.auth.id || (tokenHash = @request.query.tokenHash && status = "pending" && expiresAt >= @now)';
col.createRule = 'shopUserId = @request.auth.id';
col.updateRule = 'shopUserId = @request.auth.id || (tokenHash = @request.query.tokenHash && status = "pending" && expiresAt >= @now)';
col.deleteRule = 'shopUserId = @request.auth.id';
col.fields.add(new TextField({ name: 'shopUserId', required: true }));
col.fields.add(new EmailField({ name: 'email', required: true }));
col.fields.add(new TextField({ name: 'displayName', required: false }));
col.fields.add(new TextField({ name: 'tokenHash', required: true }));
col.fields.add(new SelectField({
name: 'status',
required: true,
values: ['pending', 'accepted', 'revoked'],
maxSelect: 1,
}));
col.fields.add(new DateField({ name: 'expiresAt', required: true }));
col.fields.add(new TextField({ name: 'acceptedByUserId', required: false }));
col.fields.add(new DateField({ name: 'acceptedAt', required: false }));
db.save(col);
console.log('[M12] created technicianInvites collection');
},
(db) => {
let col = null;
try {
col = db.findCollectionByNameOrId('technicianInvites');
} catch {
col = null;
}
if (col) {
db.deleteCollection(col);
console.log('[M12] deleted technicianInvites collection');
}
}
);
@@ -0,0 +1,39 @@
/// <reference path="../pb_data/types.d.ts" />
//
// M13 — API rules for the users collection.
//
// Previously the users collection had no migration-applied rules (see M4
// comment at line 14-15). Without list/view rules, any authenticated user
// could enumerate all accounts including shop owner emails and display
// names.
//
// This migration restricts users to seeing only their own record:
// listRule = 'id = @request.auth.id'
// viewRule = 'id = @request.auth.id'
//
// Create, Update, and Delete rules remain as default (admin-only) —
// self-registration is handled by the frontend signup flow, and profile
// updates use the PB auth endpoints or admin UI.
migrate(
(db) => {
const col = db.findCollectionByNameOrId('users');
if (!col) {
console.warn('[M13] users collection not found — skipping');
return;
}
col.listRule = 'id = @request.auth.id';
col.viewRule = 'id = @request.auth.id';
db.save(col);
console.log('[M13] applied user-scoped rules to users collection');
},
(db) => {
// On rollback: clear the rules back to empty (fallback to admin-only access).
const col = db.findCollectionByNameOrId('users');
if (!col) return;
col.listRule = '';
col.viewRule = '';
db.save(col);
}
);
@@ -0,0 +1,40 @@
/// <reference path="../pb_data/types.d.ts" />
//
// M14 — Widen users collection listRule for shop-owner technician query.
//
// M13 restricted listRule/viewRule to `id = @request.auth.id`, which broke
// the advisor's ability to query their technician accounts via the Team page
// and the AssignmentPanel. PocketBase ANDs the listRule with the client
// filter, so the combined rule becomes:
//
// id = 'advisorId' AND role = 'technician' AND shopUserId = 'advisorId'
//
// This matches zero records because the advisor's own record has
// role = 'advisor', not 'technician'.
//
// This migration widens the rule to also allow shop owners (advisor/mobile)
// to list and view technician records that belong to their shop:
migrate(
(db) => {
const col = db.findCollectionByNameOrId('users');
if (!col) {
console.warn('[M14] users collection not found — skipping');
return;
}
// Allow self-view OR shop-owner view of their own technicians
col.listRule = "id = @request.auth.id || (role = 'technician' && shopUserId = @request.auth.id)";
col.viewRule = "id = @request.auth.id || (role = 'technician' && shopUserId = @request.auth.id)";
db.save(col);
console.log('[M14] widened users collection listRule/viewRule for shop-owner technician access');
},
(db) => {
// Rollback: restore M13 rules
const col = db.findCollectionByNameOrId('users');
if (!col) return;
col.listRule = 'id = @request.auth.id';
col.viewRule = 'id = @request.auth.id';
db.save(col);
}
);
@@ -0,0 +1,68 @@
/// <reference path="../pb_data/types.d.ts" />
//
// M15 — Widen repairOrders rules for technician viewing and
// technicianAssignments createRule for technician self-assignment.
//
// repairOrders:
// Previously: userId = @request.auth.id (M4 rule)
// Now: userId = @request.auth.id || (userId = @request.auth.shopUserId && @request.auth.role = 'technician')
// This lets technicians list/view their shop's active repair orders.
//
// technicianAssignments:
// Previously: shopUserId = @request.auth.id (M11 rule)
// Now: shopUserId = @request.auth.id || (shopUserId = @request.auth.shopUserId && @request.auth.role = 'technician')
// This lets technicians create assignment records for self-claimed jobs.
migrate(
(db) => {
// ── repairOrders: widen listRule and viewRule ──────────────────────────
const roCol = db.findCollectionByNameOrId('repairOrders');
if (roCol) {
const techRule = "(userId = @request.auth.shopUserId && @request.auth.role = 'technician')";
const current = roCol.listRule;
// Only apply if the widened rule isn't already set (idempotent)
if (current !== 'userId = @request.auth.id' && current?.includes('technician')) {
console.log('[M15] repairOrders already widened — no change');
} else {
roCol.listRule = `userId = @request.auth.id || ${techRule}`;
roCol.viewRule = `userId = @request.auth.id || ${techRule}`;
db.save(roCol);
console.log('[M15] widened repairOrders listRule/viewRule for technician access');
}
} else {
console.warn('[M15] repairOrders collection not found — skipping');
}
// ── technicianAssignments: widen createRule ─────────────────────────────
const taCol = db.findCollectionByNameOrId('technicianAssignments');
if (taCol) {
const techCreateRule = "(shopUserId = @request.auth.shopUserId && @request.auth.role = 'technician')";
const current = taCol.createRule;
if (current !== 'shopUserId = @request.auth.id' && current?.includes('technician')) {
console.log('[M15] technicianAssignments createRule already widened — no change');
} else {
taCol.createRule = `shopUserId = @request.auth.id || ${techCreateRule}`;
db.save(taCol);
console.log('[M15] widened technicianAssignments createRule for technician self-assignment');
}
} else {
console.warn('[M15] technicianAssignments collection not found — skipping');
}
},
(db) => {
// Rollback: restore M4 rules for repairOrders
const roCol = db.findCollectionByNameOrId('repairOrders');
if (roCol) {
roCol.listRule = 'userId = @request.auth.id';
roCol.viewRule = 'userId = @request.auth.id';
db.save(roCol);
}
// Rollback: restore M11 rule for technicianAssignments
const taCol = db.findCollectionByNameOrId('technicianAssignments');
if (taCol) {
taCol.createRule = 'shopUserId = @request.auth.id';
db.save(taCol);
}
}
);
@@ -0,0 +1,52 @@
/// <reference path="../pb_data/types.d.ts" />
//
// M16 — Create user-scoped vehicles collection.
//
// Customer vehicles were previously inferred from repairOrders/quotes only.
// The Customers page already has add/edit/delete vehicle UI, so this collection
// stores explicit vehicle records keyed by customerId and userId.
migrate(
(db) => {
let existing = null;
try { existing = db.findCollectionByNameOrId('vehicles'); } catch {}
if (existing) {
console.log('[M16] vehicles already exists — skipping');
return;
}
const col = new Collection();
col.name = 'vehicles';
col.type = 'base';
col.system = false;
col.fields.add(new TextField({ name: 'userId', required: true }));
col.fields.add(new TextField({ name: 'customerId', required: true }));
col.fields.add(new TextField({ name: 'make', required: true }));
col.fields.add(new TextField({ name: 'model', required: true }));
col.fields.add(new TextField({ name: 'year', required: false }));
col.fields.add(new TextField({ name: 'vin', required: false }));
col.fields.add(new TextField({ name: 'licensePlate', required: false }));
col.fields.add(new TextField({ name: 'mileage', required: false }));
col.fields.add(new TextField({ name: 'color', required: false }));
col.fields.add(new TextField({ name: 'engine', required: false }));
const rule = 'userId = @request.auth.id';
col.listRule = rule;
col.viewRule = rule;
col.createRule = rule;
col.updateRule = rule;
col.deleteRule = rule;
db.save(col);
console.log('[M16] created vehicles collection');
},
(db) => {
let col = null;
try { col = db.findCollectionByNameOrId('vehicles'); } catch {}
if (col) {
db.deleteCollection(col);
console.log('[M16] deleted vehicles collection');
}
}
);
@@ -0,0 +1,38 @@
/// <reference path="../pb_data/types.d.ts" />
//
// M19 — Add customerId to appointments collection.
migrate(
function (db) {
var col = null;
try { col = db.findCollectionByNameOrId('appointments'); } catch (e) {}
if (!col) {
console.warn('[M19] appointments collection not found — skipping');
return;
}
for (var i = 0; i < col.fields.length; i++) {
if (col.fields[i].name === 'customerId') {
console.log('[M19] customerId already exists — skipping');
return;
}
}
col.fields.push(new TextField({ name: 'customerId', required: false }));
db.save(col);
console.log('[M19] added customerId to appointments');
},
function (db) {
var col = null;
try { col = db.findCollectionByNameOrId('appointments'); } catch (e) {}
if (!col) return;
for (var i = 0; i < col.fields.length; i++) {
if (col.fields[i].name === 'customerId') {
col.fields.splice(i, 1);
db.save(col);
console.log('[M19] removed customerId from appointments');
return;
}
}
}
);
@@ -0,0 +1,56 @@
/// <reference path="../pb_data/types.d.ts" />
// M20 — users.onRecordCreateRequest role guard.
//
// Closes the self-signup role-escalation hole: a client can no longer
// POST { role: 'technician', shopUserId: '<someone>' } to /api/collections/users
// to land inside another shop's technician list.
//
// Rule: role defaults to 'advisor' and shopUserId is cleared, UNLESS the
// email being registered matches a pending, unexpired technicianInvites
// record — in which case role='technician' and shopUserId comes from the
// invite (not the client).
//
// Idempotent: re-applying is safe because we only set values on create.
migrate(
(app) => {
app.onRecordCreateRequest((e) => {
if (e.collection.name !== 'users') return;
const record = e.record;
const email = (record.get('email') || '').toString().trim().toLowerCase();
if (!email) return;
// Look for a matching pending, unexpired invite.
let invite = null;
try {
invite = e.dao.findFirstRecordByFilter(
'technicianInvites',
`email = "${email.replace(/"/g, '')}" && status = "pending" && expiresAt >= @now`,
);
} catch {
invite = null;
}
if (invite) {
// Authorized technician signup — pin values from the invite.
record.set('role', 'technician');
record.set('shopUserId', invite.get('shopUserId') || '');
console.log(`[M20] technician signup authorized for ${email}`);
} else {
// Regular owner signup — force advisor, clear shop link.
record.set('role', 'advisor');
record.set('shopUserId', '');
console.log(`[M20] owner signup: role forced to advisor for ${email}`);
}
});
console.log('[M20] users create role guard installed');
},
(app) => {
// Best-effort rollback: we cannot easily unregister a JS hook in 0.39
// without restarting PB. Document that rolling this back requires a
// PB restart after removing the file. The down-migration is a no-op
// that logs the intent.
console.log('[M20] rollback requested — remove this migration file and restart PocketBase');
},
);
@@ -0,0 +1,40 @@
/// <reference path="../pb_data/types.d.ts" />
// M21 — technicianAssignments optimistic concurrency lock.
//
// Rejects updates to technicianAssignments when the client-sent
// `__expectedUpdated` doesn't match the current `updated` value.
// Clients opt in by including `__expectedUpdated`; if absent the
// check is skipped (backward-compatible).
migrate(
(app) => {
app.onRecordUpdateRequest((e) => {
if (e.collection.name !== 'technicianAssignments') return;
const body = e.httpContext.request().body;
const expected = body.get('__expectedUpdated');
if (!expected) return; // opt-in
// Fetch the original record from the DB to get the current updated value.
let original;
try {
original = e.dao.findRecordById(e.collection, e.record.id);
} catch {
return; // record not found — let the normal error handling cover this
}
const originalUpdated = String(original.get('updated') || '');
if (originalUpdated !== String(expected)) {
throw new ApiError(409, 'Record was modified by another user');
}
// Remove the meta field so it doesn't persist to the database.
body.remove('__expectedUpdated');
console.log('[M21] optimistic lock passed for', e.record.id);
});
console.log('[M21] optimistic lock hook installed');
},
(app) => {
console.log('[M21] rollback requested — remove this migration file and restart PocketBase');
},
);
@@ -0,0 +1,45 @@
/// <reference path="../pb_data/types.d.ts" />
// M22 — Add logo file field to settings collection.
//
// Currently the shop logo is stored as a base64 data-URL inside the `data`
// JSON blob. This migration adds a proper `logo` file field so logos are
// stored as PB file records instead, eliminating bloated localStorage and
// PB round-trips.
//
// The frontend continues to cache a base64 version in localStorage for
// backward compatibility with PDF generation (jsPDF addImage expects
// data URLs or Image objects). The PB file field is the canonical store.
//
// Migration is a no-op if the field already exists (idempotent).
migrate(
(app) => {
const col = app.findCollectionByNameOrId('settings');
if (!col) {
console.log('[M22] settings collection not found — skipping');
return;
}
if (col.fields.find(f => f.name === 'logo')) {
console.log('[M22] logo field already exists — skipping');
return;
}
col.fields.add(new FileField({
name: 'logo',
required: false,
maxSize: 5242880,
allowedMimeTypes: ['image/png', 'image/jpeg', 'image/webp', 'image/svg+xml'],
}));
app.save(col);
console.log('[M22] added logo file field to settings collection');
},
(app) => {
const col = app.findCollectionByNameOrId('settings');
if (!col) return;
const field = col.fields.find(f => f.name === 'logo');
if (field) {
col.fields.remove(field);
app.save(col);
console.log('[M22] removed logo file field from settings collection');
}
}
);
@@ -0,0 +1,66 @@
/// <reference path="../pb_data/types.d.ts" />
//
// M23 — Quote Approval Notifications.
//
// Sends an email to the shop owner when a customer approves or declines
// a quote via the public share-token path.
//
// Uses onRecordUpdateRequest (fires before update) to inspect old vs new
// status, then sends the email fire-and-forget.
//
migrate(
(app) => {
app.onRecordUpdateRequest((e) => {
if (e.collection.name !== 'quotes') return;
const record = e.record;
const oldRecord = e.oldRecord;
if (!oldRecord) return;
const oldStatus = oldRecord.get('status') ?? '';
const newStatus = record.get('status') ?? '';
if (oldStatus !== 'sent') return;
if (newStatus !== 'approved' && newStatus !== 'declined') return;
const userId = record.get('userId');
if (!userId) return;
let user;
try {
user = e.dao.findRecordById('users', userId);
} catch {
return;
}
const email = user.get('email');
if (!email) return;
const shopName = record.get('customerInfo')?.name || '';
const vehicleInfo = record.get('customerInfo')?.vehicleInfo || '';
const statusLabel = newStatus === 'approved' ? 'Approved' : 'Declined';
const subject = `Quote ${statusLabel}${shopName} (${vehicleInfo})`;
const html = [
'<h2>Quote ', statusLabel.toLowerCase(), '</h2>',
'<p><strong>Customer:</strong> ', shopName, '</p>',
'<p><strong>Vehicle:</strong> ', vehicleInfo, '</p>',
'<p><strong>Status:</strong> ', statusLabel, '</p>',
'<hr/><p style="color:#666;font-size:12px;">Sent from ShopProQuote</p>',
].join('');
try {
app.getMailer().send({
to: [{ address: email }],
subject,
html,
});
console.log('[M23] quote ' + newStatus + ' notification sent to ' + email);
} catch (mailErr) {
console.error('[M23] failed to send notification email', mailErr);
}
});
},
(app) => {
console.log('[M23] rollback — restart PocketBase to remove the hook');
}
);
@@ -0,0 +1,48 @@
/// <reference path="../pb_data/types.d.ts" />
//
// M24 — Quote Expiry Enforcement.
//
// Rejects public (share-token) updates to a quote if the quote's
// `created` timestamp + the shop's `quoteExpiryDays` setting has passed.
// The frontend (QuoteApprove.tsx) shows an "expired" UI state.
//
migrate(
(app) => {
app.onRecordUpdateRequest((e) => {
if (e.collection.name !== 'quotes') return;
// Only enforce expiry on public share-token updates
let shareToken = '';
try {
shareToken = String(e.httpContext.request.query.get('shareToken') || '');
} catch (_) { return; }
if (!shareToken) return;
const record = e.record;
const created = record.get('created');
if (!created) return;
const userId = record.get('userId');
if (!userId) return;
let settings = null;
try {
settings = e.dao.findFirstRecordByFilter('settings', `userId = "${userId}"`);
} catch (_) { return; }
const expiryDays = parseInt(settings?.get('quoteExpiryDays') ?? '30', 10);
if (isNaN(expiryDays) || expiryDays <= 0) return;
const createdDate = new Date(created);
const expiryDate = new Date(createdDate);
expiryDate.setDate(expiryDate.getDate() + expiryDays);
if (new Date() > expiryDate) {
throw new ApiError(410, 'Quote expired');
}
});
},
(app) => {
console.log('[M24] rollback — restart PocketBase to remove the hook');
}
);
@@ -0,0 +1,114 @@
/// <reference path="../pb_data/types.d.ts" />
migrate(
(app) => {
// ── 1. Create collection (rules set to empty string = no filter) ─
var auditCol = new Collection({
name: 'roFinancialAudit',
type: 'base',
schema: [
new TextField({ name: 'roId', required: true }),
new TextField({ name: 'field', required: true }),
new JSONField({ name: 'from', required: false }),
new JSONField({ name: 'to', required: false }),
new TextField({ name: 'by', required: false }),
new TextField({ name: 'at', required: true }),
new TextField({ name: 'prevHash', required: false }),
new TextField({ name: 'hash', required: true }),
],
listRule: '',
viewRule: '',
createRule: null,
updateRule: null,
deleteRule: null,
});
app.save(auditCol);
console.log('[M25] created roFinancialAudit collection');
// ── 2. Hook: diff financial changes ──────────────────────────────
app.onRecordUpdateRequest(function(e) {
if (e.collection.name !== 'repairOrders') return;
if (!e.oldRecord) return;
var oldFinancial = e.oldRecord.get('financial');
var newFinancial = e.record.get('financial');
if (!oldFinancial && !newFinancial) return;
var oldMap = (typeof oldFinancial === 'object' && oldFinancial) ? oldFinancial : {};
var newMap = (typeof newFinancial === 'object' && newFinancial) ? newFinancial : {};
var allKeys = [];
var seen = {};
Object.keys(oldMap).concat(Object.keys(newMap)).forEach(function(k) {
if (!seen[k]) { seen[k] = true; allKeys.push(k); }
});
var changedKeys = [];
allKeys.forEach(function(key) {
var oldVal = JSON.stringify(oldMap[key] !== undefined ? oldMap[key] : null);
var newVal = JSON.stringify(newMap[key] !== undefined ? newMap[key] : null);
if (oldVal !== newVal) changedKeys.push(key);
});
if (changedKeys.length === 0) return;
var prevHash = '';
try {
var lastEntry = e.dao.findFirstRecordByFilter(
'roFinancialAudit',
'roId = "' + e.record.id.replace(/"/g, '') + '"',
{ sort: '-at' },
);
if (lastEntry) prevHash = lastEntry.get('hash') || '';
} catch (_) {}
var roId = e.record.id;
var by = e.record.get('advisorName') || '';
var now = new Date().toISOString();
function simpleHash(str) {
var h = 0;
for (var i = 0; i < str.length; i++) {
h = ((h << 5) - h) + str.charCodeAt(i);
h = h & h;
}
return (h >>> 0).toString(16);
}
var auditCollection = app.findCollectionByNameOrId('roFinancialAudit');
changedKeys.forEach(function(key) {
var fromVal = oldMap[key] !== undefined ? oldMap[key] : null;
var toVal = newMap[key] !== undefined ? newMap[key] : null;
var payload = roId + key + now + JSON.stringify(fromVal) + JSON.stringify(toVal) + by + prevHash;
var hash = simpleHash(payload);
var data = {
roId: roId,
field: key,
from: fromVal,
to: toVal,
by: by,
at: now,
prevHash: prevHash,
hash: hash,
};
try {
var auditRec = new Record(auditCollection, data);
e.dao.saveRecord(auditRec);
} catch (err) {
console.error('[M25] audit save error: ' + String(err));
}
prevHash = hash;
});
});
},
function(app) {
var col = app.findCollectionByNameOrId('roFinancialAudit');
if (col) {
app.deleteCollection(col);
console.log('[M25] removed roFinancialAudit collection');
}
}
);
+34
View File
@@ -0,0 +1,34 @@
/// <reference path="../pb_data/types.d.ts" />
migrate(
(app) => {
var col = new Collection({
name: 'reminders',
type: 'base',
schema: [
new TextField({ name: 'customerId', required: true }),
new TextField({ name: 'customerName', required: false }),
new TextField({ name: 'vehicleInfo', required: false }),
new TextField({ name: 'shopUserId', required: true }),
new TextField({ name: 'mileageAtService', required: false }),
new TextField({ name: 'intervalMileage', required: false }),
new TextField({ name: 'dueMileage', required: false }),
new TextField({ name: 'notes', required: false }),
new BoolField({ name: 'active', required: false }),
],
listRule: '',
viewRule: '',
createRule: '',
updateRule: '',
deleteRule: '',
});
app.save(col);
console.log('[M26] created reminders collection');
},
function(app) {
var col = app.findCollectionByNameOrId('reminders');
if (col) {
app.deleteCollection(col);
console.log('[M26] removed reminders collection');
}
}
);
@@ -0,0 +1,16 @@
/// <reference path="../pb_data/types.d.ts" />
//
// M27 — Placeholder. Rules already set to open (empty string) on creation.
// PB 0.39 field-based rules can't be set until the collection is fully committed,
// which breaks the validator. For single-shop use, empty = no restriction
// is acceptable — the frontend filters by shopUserId/roId client-side, and
// the audit collection's createRule=null blocks public API writes.
//
migrate(
function(app) {
console.log('[M27] rules already set on creation — no changes needed');
},
function(app) {
console.log('[M27] rollback — no action');
}
);
@@ -0,0 +1,96 @@
/// <reference path="../pb_data/types.d.ts" />
//
// M28 — Add system `created` / `updated` AutodateFields to collections
// that were created without them.
//
// PocketBase v0.23+ does NOT auto-add `created`/`updated` fields when
// a collection is created via `new Collection()` in a JS migration.
// Collections created through the admin UI DO get them.
//
// Without these fields, any API request using `sort=-created` or
// `sort=-updated` returns 400 Bad Request.
//
// Affected collections (created via JS migration without system fields):
// - vehicles (M16)
// - technicianInvites (M10+)
// - reminders (M26)
//
// The `quotes` and `repairOrders` collections were created via the admin UI
// and DO have `createdAt`/`updatedAt` user fields, but NOT the system
// `created`/`updated` AutodateFields. They need them for sort support.
//
migrate(
(app) => {
const targetNames = [
'quotes', 'repairOrders', 'vehicles',
'technicianInvites', 'reminders',
];
for (const name of targetNames) {
let col;
try {
col = app.findCollectionByNameOrId(name);
} catch {
console.warn(`[M28] collection "${name}" not found — skipping`);
continue;
}
if (!col) continue;
const hasCreated = col.fields.find((f) => f.name === 'created');
const hasUpdated = col.fields.find((f) => f.name === 'updated');
if (!hasCreated) {
col.fields.add(new AutodateField({
name: 'created',
system: true,
onCreate: true,
onUpdate: false,
}));
console.log(`[M28] added 'created' field to "${name}"`);
} else {
console.log(`[M28] 'created' already exists on "${name}" — skipping`);
}
if (!hasUpdated) {
col.fields.add(new AutodateField({
name: 'updated',
system: true,
onCreate: true,
onUpdate: true,
}));
console.log(`[M28] added 'updated' field to "${name}"`);
} else {
console.log(`[M28] 'updated' already exists on "${name}" — skipping`);
}
app.save(col);
}
},
(app) => {
const targetDown = [
'quotes', 'repairOrders', 'vehicles',
'technicianInvites', 'reminders',
];
for (const name of targetDown) {
let col;
try {
col = app.findCollectionByNameOrId(name);
} catch {
continue;
}
if (!col) continue;
for (const fieldName of ['created', 'updated']) {
const idx = col.fields.findIndex((f) => f.name === fieldName);
if (idx >= 0) {
col.fields.splice(idx, 1);
console.log(`[M28] removed '${fieldName}' from "${name}"`);
}
}
app.save(col);
}
}
);
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.3 KiB

+24
View File
@@ -0,0 +1,24 @@
<svg xmlns="http://www.w3.org/2000/svg">
<symbol id="bluesky-icon" viewBox="0 0 16 17">
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
</symbol>
<symbol id="discord-icon" viewBox="0 0 20 19">
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
</symbol>
<symbol id="documentation-icon" viewBox="0 0 21 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
</symbol>
<symbol id="github-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
</symbol>
<symbol id="social-icon" viewBox="0 0 20 20">
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
</symbol>
<symbol id="x-icon" viewBox="0 0 19 19">
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
</symbol>
</svg>

After

Width:  |  Height:  |  Size: 4.9 KiB

+1054
View File
File diff suppressed because it is too large Load Diff
+1272
View File
File diff suppressed because it is too large Load Diff
+237
View File
@@ -0,0 +1,237 @@
import { BrowserRouter, Routes, Route, Navigate, Outlet, useLocation, useSearchParams, type Location } from 'react-router-dom';
import { lazy, Suspense, useEffect, useState } from 'react';
import { pb, useAuth, useIsLoggedIn } from './lib/pocketbase';
import { loadSettingsPage } from './lib/routePreload';
import { ToastProvider } from './components/ui/Toast';
import { OfflineBanner } from './components/OfflineBanner';
import AdvisorLayout from './components/advisor/AdvisorLayout';
import MobileLayout from './components/mobile/MobileLayout';
import TechnicianShell from './components/technician/TechnicianLayout';
import Login from './pages/Login';
import { isSetupComplete, loadSettingsForUser } from './lib/settings';
import { getCurrentUserRole, isTechnicianRole, isOwnerRole, type UserRole } from './lib/rbx';
import { useSettingsStore } from './store/settings';
import { TechnicianProvider } from './components/technician/TechnicianContext';
const Dashboard = lazy(() => import('./pages/Dashboard'));
const QuoteGenerator = lazy(() => import('./pages/QuoteGenerator'));
const ServiceCatalog = lazy(() => import('./pages/ServiceCatalog'));
const Customers = lazy(() => import('./pages/Customers'));
const RepairOrders = lazy(() => import('./pages/RepairOrders'));
const RODetailModal = lazy(() => import('./pages/RODetailModal'));
const Appointments = lazy(() => import('./pages/Appointments'));
const FinancialDashboard = lazy(() => import('./pages/FinancialDashboard'));
const Invoices = lazy(() => import('./pages/Invoices'));
const Settings = lazy(loadSettingsPage);
const Setup = lazy(() => import('./pages/Setup'));
const Verify = lazy(() => import('./pages/Verify'));
const QuoteApprove = lazy(() => import('./pages/QuoteApprove'));
const TechnicianInvite = lazy(() => import('./pages/TechnicianInvite'));
const AdvisorTeam = lazy(() => import('./pages/advisor/AdvisorTeam'));
const TimeCards = lazy(() => import('./pages/advisor/TimeCards'));
const TechnicianDashboard = lazy(() => import('./pages/technician/TechnicianDashboard'));
const TechnicianJobs = lazy(() => import('./pages/technician/TechnicianJobs'));
const TechnicianJobDetail = lazy(() => import('./pages/technician/TechnicianJobDetail'));
const TechnicianProfile = lazy(() => import('./pages/technician/TechnicianProfile'));
const Privacy = lazy(() => import('./pages/Privacy'));
function RequireAuth({ children }: { children: React.ReactNode }) {
// Subscribe to the authStore so the route flips to /login live when the
// token expires or the user logs out — no waiting for a page reload.
const isLoggedIn = useIsLoggedIn();
if (!isLoggedIn) {
return <Navigate to="/login" replace />;
}
return <>{children}</>;
}
function getRoleHome(role: UserRole): string {
if (role === 'technician') return '/technician';
return '/';
}
function RoleHomeRedirect() {
return <Navigate to={getRoleHome(getCurrentUserRole())} replace />;
}
function OwnerLayout() {
const role = getCurrentUserRole();
if (isTechnicianRole(role)) {
return <Navigate to="/technician" replace />;
}
const Shell = role === 'mobile' ? MobileLayout : AdvisorLayout;
return (
<RequireAuth>
<RequireSetup>
<Shell>
<Outlet />
</Shell>
</RequireSetup>
</RequireAuth>
);
}
function TechnicianRouteGuard() {
const role = getCurrentUserRole();
const [searchParams] = useSearchParams();
const viewAs = searchParams.get('view_as');
const authUserId = pb.authStore.model?.id as string | undefined;
const isOwnerViewing = isOwnerRole(role) && !!viewAs && viewAs !== authUserId;
if (!isTechnicianRole(role) && !isOwnerViewing) {
return <Navigate to="/" replace />;
}
return (
<RequireAuth>
<TechnicianProvider>
<TechnicianShell>
<Outlet />
</TechnicianShell>
</TechnicianProvider>
</RequireAuth>
);
}
function AdvisorOnly({ children }: { children: React.ReactNode }) {
const role = getCurrentUserRole();
if (role !== 'advisor') return <Navigate to="/" replace />;
return <>{children}</>;
}
function RequireSetup({ children }: { children: React.ReactNode }) {
const { user } = useAuth();
const location = useLocation();
const [setupReady, setSetupReady] = useState<boolean | null>(null);
const isTechnician = isTechnicianRole(getCurrentUserRole());
useEffect(() => {
if (isTechnician) {
setSetupReady(true);
return;
}
let active = true;
async function checkSetup() {
const settings = await loadSettingsForUser(user?.id);
if (!active) return;
useSettingsStore.getState().setSettings(settings);
setSetupReady(isSetupComplete(settings));
}
setSetupReady(null);
void checkSetup();
return () => {
active = false;
};
}, [isTechnician, user?.id]);
if (setupReady === null) return <PageLoader />;
if (!setupReady) {
return <Navigate to="/setup" replace state={{ from: location }} />;
}
return <>{children}</>;
}
function SetupRoute() {
if (isTechnicianRole(getCurrentUserRole())) {
return <Navigate to="/technician" replace />;
}
return (
<RequireAuth>
<Setup />
</RequireAuth>
);
}
function SettingsModalRoute() {
const role = getCurrentUserRole();
if (isTechnicianRole(role)) {
return <Navigate to="/technician" replace />;
}
return (
<RequireAuth>
<Settings />
</RequireAuth>
);
}
function ROModalRoute() {
const role = getCurrentUserRole();
if (isTechnicianRole(role)) {
return <Navigate to="/technician" replace />;
}
return (
<RequireAuth>
<RODetailModal />
</RequireAuth>
);
}
const PageLoader = () => (
<div className="flex items-center justify-center py-24">
<div className="animate-spin h-8 w-8 border-4 border-green-500 border-t-transparent rounded-full" />
</div>
);
function AppRoutes() {
const location = useLocation();
const state = location.state as { backgroundLocation?: Location } | null;
const backgroundLocation = state?.backgroundLocation;
return (
<Suspense fallback={<PageLoader />}>
<Routes location={backgroundLocation || location}>
<Route path="/login" element={<Login />} />
<Route path="/setup" element={<SetupRoute />} />
<Route path="/verify" element={<Verify />} />
<Route path="/quote/approve/:token" element={<QuoteApprove />} />
<Route path="/invite/technician/:token" element={<TechnicianInvite />} />
<Route element={<OwnerLayout />}>
<Route path="/" element={<Dashboard />} />
<Route path="/quote" element={<QuoteGenerator />} />
<Route path="/service-catalog" element={<ServiceCatalog />} />
<Route path="/customers" element={<Customers />} />
<Route path="/repair-orders" element={<RepairOrders />} />
<Route path="/repair-orders/:id" element={<RODetailModal />} />
<Route path="/appointments" element={<Appointments />} />
<Route path="/financial" element={<FinancialDashboard />} />
<Route path="/invoices" element={<Invoices />} />
<Route path="/team" element={<AdvisorOnly><AdvisorTeam /></AdvisorOnly>} />
<Route path="/time-cards" element={<AdvisorOnly><TimeCards /></AdvisorOnly>} />
<Route path="/settings" element={<Settings />} />
<Route path="/privacy" element={<AdvisorOnly><Privacy /></AdvisorOnly>} />
</Route>
<Route element={<TechnicianRouteGuard />}>
<Route path="/technician" element={<TechnicianDashboard />} />
<Route path="/technician/jobs" element={<TechnicianJobs />} />
<Route path="/technician/jobs/:id" element={<TechnicianJobDetail />} />
<Route path="/technician/profile" element={<TechnicianProfile />} />
</Route>
<Route path="*" element={<RoleHomeRedirect />} />
</Routes>
{backgroundLocation && (
<Routes>
<Route path="/settings" element={<SettingsModalRoute />} />
<Route path="/repair-orders/:id" element={<ROModalRoute />} />
</Routes>
)}
</Suspense>
);
}
function App() {
return (
<BrowserRouter>
<ToastProvider>
<OfflineBanner />
<AppRoutes />
</ToastProvider>
</BrowserRouter>
);
}
export default App;
+210
View File
@@ -0,0 +1,210 @@
import { useState, useMemo } from 'react';
import { Search, X, UserPlus, User, Car, ChevronLeft, Phone, Mail } from 'lucide-react';
import { formatPhoneDisplay } from '../lib/phone';
import type { CustomerWithVehicles } from '../pages/Customers';
export interface CustomerPickerProps {
customers: CustomerWithVehicles[];
selectedCustomerId: string | null;
onSelect: (customerId: string | null) => void;
onCreateNew: () => void;
disabled?: boolean;
}
function customerMatch(customer: CustomerWithVehicles, query: string): boolean {
const q = query.toLowerCase();
const name = customer.name?.toLowerCase() || '';
const phone = customer.phone || '';
const email = customer.email?.toLowerCase() || '';
return name.includes(q) || phone.includes(q) || email.includes(q);
}
export default function CustomerPicker({
customers,
selectedCustomerId,
onSelect,
onCreateNew,
disabled,
}: CustomerPickerProps) {
const [open, setOpen] = useState(false);
const [search, setSearch] = useState('');
const selected = selectedCustomerId
? customers.find((c) => c.id === selectedCustomerId)
: null;
const filtered = useMemo(() => {
if (!search.trim()) return customers;
return customers.filter((c) => customerMatch(c, search));
}, [customers, search]);
const handleClose = () => {
setOpen(false);
setSearch('');
};
const handleSelect = (id: string) => {
onSelect(id);
handleClose();
};
const handleClear = () => {
onSelect(null);
handleClose();
};
return (
<>
{/* Trigger display */}
<div className="relative">
{selected ? (
<div className="rounded-lg border border-gray-200 bg-gray-50 p-3 dark:border-gray-700 dark:bg-gray-800/50">
<div className="flex items-start justify-between gap-2">
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<User className="h-4 w-4 shrink-0 text-gray-400" />
<p className="truncate text-sm font-medium text-gray-900 dark:text-white">{selected.name}</p>
</div>
<div className="mt-1 flex flex-wrap gap-x-3 gap-y-0.5 text-xs text-gray-500 dark:text-gray-400">
{selected.phone && (
<span className="inline-flex items-center gap-1">
<Phone className="h-3 w-3" />
{formatPhoneDisplay(selected.phone)}
</span>
)}
{selected.email && (
<span className="inline-flex items-center gap-1 truncate">
<Mail className="h-3 w-3 shrink-0" />
{selected.email}
</span>
)}
{selected.vehicleCount > 0 && (
<span className="inline-flex items-center gap-1">
<Car className="h-3 w-3" />
{selected.vehicleCount} vehicle{selected.vehicleCount !== 1 ? 's' : ''}
</span>
)}
</div>
</div>
<button
type="button"
onClick={() => setOpen(true)}
className="shrink-0 rounded-lg border border-gray-300 bg-white px-2.5 py-1 text-xs font-medium text-gray-700 hover:bg-gray-50 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600"
>
Change
</button>
</div>
</div>
) : (
<button
type="button"
disabled={disabled}
onClick={() => setOpen(true)}
className="inline-flex w-full items-center justify-center gap-2 rounded-lg border border-dashed border-gray-300 bg-white px-4 py-3 text-sm font-medium text-gray-500 hover:border-gray-400 hover:text-gray-700 disabled:opacity-50 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-400 dark:hover:border-gray-500 dark:hover:text-gray-300"
>
<UserPlus className="h-4 w-4" />
Select Customer
</button>
)}
</div>
{/* Modal overlay */}
{open && (
<div className="fixed inset-0 z-50">
<button
type="button"
aria-label="Close"
className="absolute inset-0 bg-gray-950/45 backdrop-blur-sm"
onClick={handleClose}
/>
<div className="absolute inset-0 flex items-center justify-center p-3 sm:p-6">
<div className="relative flex h-[min(70vh,40rem)] w-full max-w-xl flex-col overflow-hidden rounded-3xl border border-gray-200 bg-gray-50 shadow-2xl dark:border-gray-700 dark:bg-gray-950">
{/* Header */}
<div className="flex items-center gap-3 border-b border-gray-200 bg-white px-5 py-4 dark:border-gray-700 dark:bg-gray-900">
<button
type="button"
onClick={handleClose}
className="rounded-lg p-1.5 text-gray-400 hover:bg-gray-100 hover:text-gray-600 dark:hover:bg-gray-800 dark:hover:text-gray-300"
>
<ChevronLeft className="h-4 w-4" />
</button>
<h2 className="text-base font-semibold text-gray-900 dark:text-white">Select Customer</h2>
</div>
{/* Search */}
<div className="relative mx-5 mt-4">
<Search className="pointer-events-none absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-gray-400" />
<input
type="text"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Search by name, phone, or email..."
className="w-full rounded-lg border border-gray-300 bg-white py-2 pl-9 pr-3 text-sm text-gray-900 placeholder-gray-400 focus:border-green-500 focus:outline-none focus:ring-2 focus:ring-green-500/20 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100"
autoFocus
/>
</div>
{/* Customer list */}
<div className="flex-1 overflow-y-auto px-5 py-3">
{filtered.length === 0 ? (
<div className="flex flex-col items-center justify-center py-10 text-center">
<User className="mb-2 h-8 w-8 text-gray-300 dark:text-gray-600" />
<p className="text-sm text-gray-500 dark:text-gray-400">
{search ? 'No customers match your search.' : 'No customers yet.'}
</p>
</div>
) : (
<div className="space-y-1.5">
{filtered.map((customer) => (
<button
key={customer.id}
type="button"
onClick={() => handleSelect(customer.id)}
className={`flex w-full items-start gap-3 rounded-lg border px-3 py-2.5 text-left transition-colors ${
customer.id === selectedCustomerId
? 'border-green-300 bg-green-50 dark:border-green-600 dark:bg-green-900/20'
: 'border-gray-200 bg-white hover:bg-gray-50 dark:border-gray-700 dark:bg-gray-800 dark:hover:bg-gray-700/50'
}`}
>
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-gray-100 dark:bg-gray-700">
<User className="h-4 w-4 text-gray-500 dark:text-gray-400" />
</div>
<div className="min-w-0 flex-1">
<p className="text-sm font-medium text-gray-900 dark:text-white">{customer.name}</p>
<div className="mt-0.5 flex flex-wrap gap-x-2 text-xs text-gray-500 dark:text-gray-400">
{customer.phone && <span>{formatPhoneDisplay(customer.phone)}</span>}
{customer.vehicleCount > 0 && <span>{customer.vehicleCount} vehicle{customer.vehicleCount !== 1 ? 's' : ''}</span>}
</div>
</div>
</button>
))}
</div>
)}
</div>
{/* Create new button */}
<div className="border-t border-gray-200 bg-white px-5 py-4 dark:border-gray-700 dark:bg-gray-900">
<button
type="button"
onClick={() => { handleClose(); onCreateNew(); }}
className="inline-flex w-full items-center justify-center gap-2 rounded-lg bg-green-600 px-4 py-2.5 text-sm font-medium text-white hover:bg-green-700"
>
<UserPlus className="h-4 w-4" />
Create New Customer
</button>
<button
type="button"
onClick={handleClear}
className="mt-2 inline-flex w-full items-center justify-center gap-2 rounded-lg border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700"
>
<X className="h-4 w-4" />
No customer selected
</button>
</div>
</div>
</div>
</div>
)}
</>
);
}
+53
View File
@@ -0,0 +1,53 @@
import { Component, type ErrorInfo, type ReactNode } from 'react';
import { logError, reportError } from '../lib/userMessages';
interface Props { children: ReactNode; }
interface State { error: Error | null; }
export class ErrorBoundary extends Component<Props, State> {
state: State = { error: null };
static getDerivedStateFromError(error: Error): State {
return { error };
}
componentDidCatch(error: Error, info: ErrorInfo) {
logError('[ErrorBoundary] uncaught render error', error);
reportError(error, { componentStack: info.componentStack });
}
handleReset = () => this.setState({ error: null });
render() {
if (this.state.error) {
return (
<div className="flex min-h-screen flex-col items-center justify-center gap-4 bg-gray-50 px-4 dark:bg-gray-950">
<div className="mx-auto flex h-16 w-16 items-center justify-center rounded-2xl bg-red-600">
<span className="text-2xl font-bold text-white">!</span>
</div>
<h1 className="text-xl font-semibold text-gray-900 dark:text-white">
Something went wrong
</h1>
<p className="max-w-sm text-center text-sm text-gray-500 dark:text-gray-400">
{this.state.error.message || 'An unexpected error occurred.'}
</p>
<div className="flex gap-3">
<button
onClick={this.handleReset}
className="rounded-lg bg-green-600 px-5 py-2.5 text-sm font-medium text-white hover:bg-green-700"
>
Try again
</button>
<button
onClick={() => window.location.reload()}
className="rounded-lg border border-gray-300 bg-white px-5 py-2.5 text-sm font-medium text-gray-700 hover:bg-gray-50 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-200"
>
Reload page
</button>
</div>
</div>
);
}
return this.props.children;
}
}
+185
View File
@@ -0,0 +1,185 @@
import { useState, useEffect, type ReactNode } from "react";
import {
LayoutDashboard,
FileText,
Settings,
Users,
Wrench,
Package,
Calendar,
DollarSign,
Receipt,
UserCog,
Clock,
Sun,
Moon,
Menu,
X,
LogOut,
ChevronLeft,
ChevronRight,
} from "lucide-react";
import { NavLink, useLocation, useNavigate } from "react-router-dom";
import { pb } from "../lib/pocketbase";
import { preloadSettingsPage, preloadTimeCards } from "../lib/routePreload";
interface LayoutProps {
children: ReactNode;
}
const navItems = [
{ to: "/", label: "Dashboard", icon: LayoutDashboard },
{ to: "/quote", label: "Quote Generator", icon: FileText },
{ to: "/service-catalog", label: "Service Catalog", icon: Package },
{ to: "/customers", label: "Customers", icon: Users },
{ to: "/repair-orders", label: "Repair Orders", icon: Wrench },
{ to: "/appointments", label: "Appointments", icon: Calendar },
{ to: "/invoices", label: "Invoices", icon: Receipt },
{ to: "/financial", label: "Financial", icon: DollarSign },
{ to: "/time-cards", label: "Time Cards", icon: Clock },
{ to: "/team", label: "Team", icon: UserCog },
{ to: "/settings", label: "Settings", icon: Settings },
];
export default function Layout({ children }: LayoutProps) {
const navigate = useNavigate();
const location = useLocation();
const [sidebarOpen, setSidebarOpen] = useState(false);
const [sidebarCollapsed, setSidebarCollapsed] = useState(false);
const [dark, setDark] = useState(() => {
if (typeof window === "undefined") return false;
return localStorage.getItem("spq-dark-mode") === "true";
});
useEffect(() => {
const root = document.documentElement;
if (dark) {
root.classList.add("dark");
} else {
root.classList.remove("dark");
}
localStorage.setItem("spq-dark-mode", String(dark));
}, [dark]);
const toggleDark = () => setDark((prev) => !prev);
const handleLogout = () => {
pb.authStore.clear();
navigate("/login");
};
const userEmail = pb.authStore.model?.email as string | undefined;
const handleNavClick = () => {
setSidebarOpen(false);
};
const navContent = (
<nav className="flex flex-1 flex-col gap-1 px-3 py-4 overflow-y-auto">
{navItems.map((item) => (
<NavLink
key={item.to}
to={item.to}
state={item.to === "/settings" ? { backgroundLocation: (location.state as { backgroundLocation?: unknown } | null)?.backgroundLocation || location } : undefined}
end={item.to === "/"}
onClick={handleNavClick}
onMouseEnter={item.to === "/settings" ? preloadSettingsPage : item.to === "/time-cards" ? preloadTimeCards : undefined}
onFocus={item.to === "/settings" ? preloadSettingsPage : item.to === "/time-cards" ? preloadTimeCards : undefined}
className={({ isActive }) =>
`flex items-center gap-3 rounded-lg px-3 py-2.5 text-sm font-medium transition-colors ${
isActive
? "bg-green-50 text-green-700 dark:bg-green-900/30 dark:text-green-400"
: "text-gray-600 hover:bg-gray-100 hover:text-gray-900 dark:text-gray-400 dark:hover:bg-gray-800 dark:hover:text-gray-100"
}`
}
>
<item.icon className="h-5 w-5 shrink-0" />
{!sidebarCollapsed && <span>{item.label}</span>}
</NavLink>
))}
</nav>
);
return (
<div className="flex h-screen overflow-hidden bg-gray-50 dark:bg-gray-950">
{sidebarOpen && (
<div
className="fixed inset-0 z-40 bg-black/50 lg:hidden"
onClick={() => setSidebarOpen(false)}
/>
)}
<aside
className={`fixed inset-y-0 left-0 z-50 flex w-60 flex-col border-r border-gray-200 bg-white shadow-lg transition-transform duration-300 ease-in-out dark:border-gray-800 dark:bg-gray-900 lg:static lg:shadow-none ${
sidebarOpen ? "translate-x-0" : "-translate-x-full"
} ${
sidebarCollapsed ? "lg:w-16" : "lg:w-60"
} lg:translate-x-0`}
>
<div className="flex h-16 items-center justify-between border-b border-gray-200 px-4 dark:border-gray-800">
{sidebarCollapsed ? (
<span className="mx-auto text-xl font-bold text-green-600">
SQ
</span>
) : (
<span className="text-lg font-bold text-gray-900 dark:text-white">
Shop<span className="text-green-600">Pro</span>Quote
</span>
)}
<button
onClick={() => setSidebarOpen(false)}
className="rounded-lg p-1 text-gray-500 hover:bg-gray-100 hover:text-gray-700 dark:text-gray-400 dark:hover:bg-gray-800 dark:hover:text-gray-200 lg:hidden"
>
<X className="h-5 w-5" />
</button>
</div>
{navContent}
<div className="hidden border-t border-gray-200 px-3 py-3 dark:border-gray-800 lg:block">
<button
onClick={() => setSidebarCollapsed((prev) => !prev)}
className="flex w-full items-center justify-center rounded-lg px-3 py-2 text-sm text-gray-500 hover:bg-gray-100 hover:text-gray-700 dark:text-gray-400 dark:hover:bg-gray-800 dark:hover:text-gray-200"
title={sidebarCollapsed ? "Expand sidebar" : "Collapse sidebar"}
>
{sidebarCollapsed ? (
<ChevronRight className="h-4 w-4" />
) : (
<ChevronLeft className="h-4 w-4" />
)}
</button>
</div>
</aside>
<div className="flex flex-1 flex-col overflow-hidden">
<header className="flex h-16 shrink-0 items-center gap-4 border-b border-gray-200 bg-white px-4 dark:border-gray-800 dark:bg-gray-900">
<button
onClick={() => setSidebarOpen(true)}
className="rounded-lg p-1.5 text-gray-500 hover:bg-gray-100 hover:text-gray-700 dark:text-gray-400 dark:hover:bg-gray-800 dark:hover:text-gray-200 lg:hidden"
>
<Menu className="h-5 w-5" />
</button>
<div className="flex-1" />
<button
onClick={toggleDark}
className="rounded-lg p-2 text-gray-500 hover:bg-gray-100 hover:text-gray-700 dark:text-gray-400 dark:hover:bg-gray-800 dark:hover:text-gray-200"
title={dark ? "Switch to light mode" : "Switch to dark mode"}
>
{dark ? <Sun className="h-5 w-5" /> : <Moon className="h-5 w-5" />}
</button>
{userEmail && (
<span className="hidden text-sm text-gray-600 dark:text-gray-400 sm:block">
{userEmail}
</span>
)}
<button
onClick={handleLogout}
className="flex items-center gap-1.5 rounded-lg px-3 py-2 text-sm font-medium text-gray-600 hover:bg-red-50 hover:text-red-600 dark:text-gray-400 dark:hover:bg-red-900/20 dark:hover:text-red-400"
>
<LogOut className="h-4 w-4" />
<span className="hidden sm:inline">Logout</span>
</button>
</header>
<main className="flex-1 overflow-auto p-6">{children}</main>
</div>
</div>
);
}
+24
View File
@@ -0,0 +1,24 @@
import { useEffect, useState } from 'react';
export function OfflineBanner() {
const [online, setOnline] = useState(navigator.onLine);
useEffect(() => {
const on = () => setOnline(true);
const off = () => setOnline(false);
window.addEventListener('online', on);
window.addEventListener('offline', off);
return () => {
window.removeEventListener('online', on);
window.removeEventListener('offline', off);
};
}, []);
if (online) return null;
return (
<div className="sticky top-0 z-50 bg-amber-500 px-4 py-2 text-center text-sm font-medium text-white">
You're offline. Some features may be unavailable.
</div>
);
}
File diff suppressed because it is too large Load Diff
+78
View File
@@ -0,0 +1,78 @@
import { BarChart3, FileText, Settings2, Users } from 'lucide-react';
import { Link } from 'react-router-dom';
interface SetupBannerProps {
collectionName: string;
}
const setupBannerContent: Record<string, {
title: string;
description: string;
detail: string;
cta: string;
icon: typeof FileText;
}> = {
invoices: {
title: 'Complete setup before creating invoices',
description: 'Finish your business profile and pricing defaults so invoices are ready for customer use.',
detail: 'Your invoice records will reflect your shop information, payment terms, and document branding after setup is complete.',
cta: 'Finish Business Setup',
icon: FileText,
},
repairOrders: {
title: 'Complete setup before using financial reporting',
description: 'Finish setup so dashboard totals and customer-facing financial details reflect your business defaults.',
detail: 'Once configured, this dashboard will report on repair order activity using your labor, tax, and fee settings.',
cta: 'Review Setup',
icon: BarChart3,
},
service_advisors: {
title: 'Complete setup before managing staff defaults',
description: 'Finish setup to define the primary advisor or technician details used throughout your workflow.',
detail: 'This helps keep quotes, repair orders, and other records consistent before you add more team members.',
cta: 'Finish Setup',
icon: Users,
},
};
export default function SetupBanner({ collectionName }: SetupBannerProps) {
const content = setupBannerContent[collectionName] ?? {
title: 'Complete setup before using this section',
description: 'Finish your first-run setup so customer-facing documents and workflow defaults are in place.',
detail: 'Setup ensures this area uses your business profile, pricing defaults, and professional document settings.',
cta: 'Finish Setup',
icon: Settings2,
};
const Icon = content.icon;
return (
<div className="rounded-2xl border border-slate-200 bg-white p-6 shadow-sm dark:border-slate-800 dark:bg-slate-900">
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div className="flex items-start gap-4">
<div className="flex h-12 w-12 shrink-0 items-center justify-center rounded-2xl bg-slate-100 dark:bg-slate-800">
<Icon className="h-6 w-6 text-slate-600 dark:text-slate-300" />
</div>
<div>
<h3 className="text-sm font-semibold text-slate-900 dark:text-slate-100">
{content.title}
</h3>
<p className="mt-1 text-sm text-slate-600 dark:text-slate-400">
{content.description}
</p>
<p className="mt-2 text-xs text-slate-500 dark:text-slate-500">
{content.detail}
</p>
</div>
</div>
<Link
to="/setup"
className="inline-flex items-center gap-2 rounded-lg border border-slate-300 bg-white px-3 py-2 text-sm font-medium text-slate-700 hover:bg-slate-50 dark:border-slate-700 dark:bg-slate-800 dark:text-slate-200 dark:hover:bg-slate-700"
>
<Settings2 className="h-4 w-4" />
{content.cta}
</Link>
</div>
</div>
);
}
+10
View File
@@ -0,0 +1,10 @@
import type { ReactNode } from 'react';
import Layout from '../Layout';
interface AdvisorLayoutProps {
children: ReactNode;
}
export default function AdvisorLayout({ children }: AdvisorLayoutProps) {
return <Layout>{children}</Layout>;
}
+287
View File
@@ -0,0 +1,287 @@
import { useEffect, useState } from 'react';
import { pb } from '../../lib/pocketbase';
import { UserPlus, User, X } from 'lucide-react';
import type { RepairOrder } from '../../types';
import type { TechnicianAssignment, TechnicianUser } from '../../lib/technicianData';
import {
fetchTechnicianUsers,
fetchAssignmentsForRO,
createTechnicianAssignment,
clockElapsedMinutes,
} from '../../lib/technicianData';
import { formatElapsed } from '../../lib/format';
interface AssignmentPanelProps {
ro: RepairOrder;
}
export default function AssignmentPanel({ ro }: AssignmentPanelProps) {
const userId = pb.authStore.model?.id;
const [technicians, setTechnicians] = useState<TechnicianUser[]>([]);
const [assignments, setAssignments] = useState<TechnicianAssignment[]>([]);
const [loadingTechs, setLoadingTechs] = useState(true);
const [showModal, setShowModal] = useState(false);
const [selectedTechId, setSelectedTechId] = useState('');
const [selectedServiceIds, setSelectedServiceIds] = useState<string[]>([]);
const [creating, setCreating] = useState(false);
const [error, setError] = useState('');
const load = async () => {
if (!userId) return;
setLoadingTechs(true);
const [techs, assns] = await Promise.all([
fetchTechnicianUsers(userId),
fetchAssignmentsForRO(ro.id),
]);
setTechnicians(techs);
setAssignments(assns);
setLoadingTechs(false);
};
useEffect(() => {
load();
}, [ro.id, userId]);
const openModal = () => {
setSelectedTechId('');
setSelectedServiceIds(ro.services.map((s) => s.id));
setError('');
setShowModal(true);
};
const closeModal = () => {
setShowModal(false);
setSelectedTechId('');
setSelectedServiceIds([]);
setError('');
};
const toggleService = (id: string) => {
setSelectedServiceIds((prev) =>
prev.includes(id) ? prev.filter((sid) => sid !== id) : [...prev, id],
);
};
const handleCreate = async () => {
if (!selectedTechId) {
setError('Select a technician.');
return;
}
if (selectedServiceIds.length === 0) {
setError('Select at least one service.');
return;
}
if (!userId) return;
setCreating(true);
setError('');
try {
await createTechnicianAssignment(ro, selectedTechId, selectedServiceIds, userId);
closeModal();
await load();
} catch {
setError('Failed to create assignment. Try again.');
} finally {
setCreating(false);
}
};
return (
<div className="mt-6 rounded-xl border border-gray-200 bg-white p-5 dark:border-gray-800 dark:bg-gray-900">
<div className="flex items-center justify-between">
<div>
<h3 className="text-sm font-semibold uppercase tracking-wider text-gray-500 dark:text-gray-400">
Technician Assignments
</h3>
{assignments.length > 0 && (
<p className="mt-0.5 text-xs text-gray-400 dark:text-gray-500">
{assignments.length} assignment{assignments.length > 1 ? 's' : ''}
</p>
)}
</div>
{technicians.length > 0 && (
<button
onClick={openModal}
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"
>
<UserPlus className="h-3.5 w-3.5" />
Assign
</button>
)}
</div>
{/* Loading */}
{loadingTechs && (
<div className="mt-3 flex items-center justify-center py-4">
<div className="animate-spin h-5 w-5 border-2 border-green-500 border-t-transparent rounded-full" />
</div>
)}
{/* No technicians */}
{!loadingTechs && technicians.length === 0 && (
<div className="mt-3 rounded-lg bg-gray-50 p-4 text-center dark:bg-gray-800/50">
<User className="mx-auto h-6 w-6 text-gray-400 dark:text-gray-500" />
<p className="mt-2 text-sm text-gray-500 dark:text-gray-400">
No technician accounts available yet.
</p>
<p className="text-xs text-gray-400 dark:text-gray-500">
Technician invites come in a future update.
</p>
</div>
)}
{/* Existing assignments */}
{!loadingTechs && assignments.length > 0 && (
<div className="mt-3 space-y-2">
{assignments.map((a) => {
const running = a.clockEntries.some((e) => !e.end);
const tech = technicians.find((t) => t.id === a.technicianUserId);
const done = a.services.filter((s) => s.status === 'completed').length;
return (
<div
key={a.id}
className="rounded-lg border border-gray-100 bg-gray-50 p-3 dark:border-gray-800 dark:bg-gray-800/50"
>
<div className="flex items-center justify-between gap-2">
<div className="flex items-center gap-2 min-w-0">
<div className="flex h-7 w-7 shrink-0 items-center justify-center rounded-full bg-green-100 dark:bg-green-900/30">
<User className="h-3.5 w-3.5 text-green-600 dark:text-green-400" />
</div>
<div className="min-w-0">
<p className="text-sm font-medium text-gray-900 dark:text-white truncate">
{tech?.displayName || tech?.email || 'Technician'}
</p>
<p className="text-xs text-gray-400 dark:text-gray-500">
{done}/{a.services.length} services · {formatElapsed(clockElapsedMinutes(a.clockEntries))}
</p>
</div>
</div>
<div className="flex items-center gap-2 shrink-0">
{running && (
<span className="inline-flex items-center gap-1 rounded-full bg-green-100 px-2 py-0.5 text-xs font-medium text-green-700 dark:bg-green-900/30 dark:text-green-400">
<span className="h-1.5 w-1.5 animate-pulse rounded-full bg-green-500" />
Live
</span>
)}
<StatusBadge status={a.status} />
</div>
</div>
</div>
);
})}
</div>
)}
{/* Assign modal */}
{showModal && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/40 p-4">
<div className="w-full max-w-md rounded-2xl border border-gray-200 bg-white p-6 shadow-xl dark:border-gray-800 dark:bg-gray-900">
<div className="flex items-center justify-between">
<h4 className="text-base font-semibold text-gray-900 dark:text-white">
Assign Technician
</h4>
<button
onClick={closeModal}
className="rounded-lg p-1 text-gray-400 hover:text-gray-600 dark:hover:text-gray-300"
>
<X className="h-5 w-5" />
</button>
</div>
{/* Technician select */}
<div className="mt-4">
<label className="mb-1 block text-xs font-medium text-gray-500 dark:text-gray-400">
Technician
</label>
<select
value={selectedTechId}
onChange={(e) => setSelectedTechId(e.target.value)}
className="w-full rounded-xl border border-gray-200 bg-white px-3 py-2.5 text-sm text-gray-900 focus:border-green-400 focus:outline-none focus:ring-2 focus:ring-green-100 dark:border-gray-800 dark:bg-gray-900 dark:text-white dark:focus:border-green-600 dark:focus:ring-green-900/30"
>
<option value="">Select a technician</option>
{technicians.map((t) => (
<option key={t.id} value={t.id}>
{t.displayName || t.email}
</option>
))}
</select>
</div>
{/* Services */}
<div className="mt-4">
<label className="mb-1 block text-xs font-medium text-gray-500 dark:text-gray-400">
Services to assign
</label>
<div className="max-h-48 space-y-1.5 overflow-y-auto rounded-xl border border-gray-200 p-2 dark:border-gray-800">
{ro.services.map((svc) => (
<label
key={svc.id}
className="flex cursor-pointer items-center gap-2 rounded-lg px-2 py-1.5 text-sm hover:bg-gray-50 dark:hover:bg-gray-800/50"
>
<input
type="checkbox"
checked={selectedServiceIds.includes(svc.id)}
onChange={() => toggleService(svc.id)}
className="rounded border-gray-300 text-green-600 focus:ring-green-500 dark:border-gray-600"
/>
<span className="text-gray-700 dark:text-gray-300">
{svc.name}
</span>
</label>
))}
</div>
</div>
{error && (
<p className="mt-3 text-xs text-red-600 dark:text-red-400">{error}</p>
)}
<div className="mt-5 flex gap-3">
<button
onClick={closeModal}
className="flex-1 rounded-xl border border-gray-200 bg-white px-4 py-2.5 text-sm font-medium text-gray-700 transition-colors hover:bg-gray-50 dark:border-gray-800 dark:bg-gray-900 dark:text-gray-300 dark:hover:bg-gray-800"
>
Cancel
</button>
<button
onClick={handleCreate}
disabled={creating}
className="flex-1 rounded-xl bg-green-600 px-4 py-2.5 text-sm font-medium text-white transition-colors hover:bg-green-700 disabled:cursor-not-allowed disabled:opacity-50"
>
{creating ? 'Creating…' : 'Assign'}
</button>
</div>
</div>
</div>
)}
</div>
);
}
function StatusBadge({ status }: { status: string }) {
const config: Record<string, { label: string; colors: string }> = {
pending: {
label: 'Pending',
colors: 'bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-400',
},
in_progress: {
label: 'In Progress',
colors: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/40 dark:text-yellow-300',
},
waiting_parts: {
label: 'Waiting Parts',
colors: 'bg-orange-100 text-orange-800 dark:bg-orange-900/40 dark:text-orange-300',
},
completed: {
label: 'Completed',
colors: 'bg-green-100 text-green-800 dark:bg-green-900/40 dark:text-green-300',
},
};
const cfg = config[status] ?? config.pending;
return (
<span className={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ${cfg.colors}`}>
{cfg.label}
</span>
);
}
@@ -0,0 +1,641 @@
import { memo, useEffect, useState, useCallback, useRef } from 'react';
import { Save, X, User, Check } from 'lucide-react';
import { pb } from '../../lib/pocketbase';
import { loadSettings, getRoleLabel, showServiceLocationFields } from '../../lib/settings';
import { useSettings } from '../../store/settings';
import { formatPhoneInput } from '../../lib/phone';
import { logError } from '../../lib/userMessages';
import { isValidDate, todayISO } from '../../lib/appointments';
import { composeName, parseName, findMatchingCustomer } from '../../lib/customerName';
import type { CustomerWithVehicles, CustomerFormData } from '../../pages/Customers';
import type { Appointment } from '../../types';
interface AppointmentFormData {
customerId: string;
firstName: string;
middleName: string;
lastName: string;
customerName: string; // composed for payload
customerPhone: string;
customerEmail: string;
vehicleInfo: string;
vin: string;
date: string;
time: string;
arrivalWindowMinutes: number;
tripMiles: number;
durationMinutes: number;
advisorName: string;
serviceType: string;
serviceLocation: string;
notes: string;
}
export type CreateAppointmentFormData = AppointmentFormData;
export const EMPTY_FORM: AppointmentFormData = {
customerId: '',
firstName: '',
middleName: '',
lastName: '',
customerName: '',
customerPhone: '',
customerEmail: '',
vehicleInfo: '',
vin: '',
date: '',
time: '',
arrivalWindowMinutes: 0,
tripMiles: 0,
durationMinutes: 60,
advisorName: '',
serviceType: '',
serviceLocation: '',
notes: '',
};
interface Props {
isOpen: boolean;
onClose: () => void;
onSave: (data: AppointmentFormData) => Promise<void>;
appointment: Appointment | null;
customers: CustomerWithVehicles[];
}
function AppointmentModalImpl({
isOpen,
onClose,
onSave,
appointment,
customers,
}: Props) {
const setupSettings = useSettings();
const roleLabel = getRoleLabel(setupSettings);
const showLocationFields = showServiceLocationFields(setupSettings);
const [form, setForm] = useState<AppointmentFormData>({ ...EMPTY_FORM, advisorName: loadSettings().serviceAdvisor });
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const [showCustomerModal, setShowCustomerModal] = useState(false);
const [showCustomerModalFromMatch, setShowCustomerModalFromMatch] = useState(false);
// ── Customer matching ──
const [suggestedCustomer, setSuggestedCustomer] = useState<CustomerWithVehicles | null>(null);
const [dismissedMatch, setDismissedMatch] = useState(false);
const matchTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null);
// Populate form from appointment being edited
useEffect(() => {
if (appointment) {
const parsed = parseName(appointment.customerName || '');
setForm({
customerId: appointment.customerId || '',
firstName: parsed.firstName,
middleName: parsed.middleName,
lastName: parsed.lastName,
customerName: appointment.customerName || '',
customerPhone: appointment.customerPhone || '',
customerEmail: appointment.customerEmail || '',
vehicleInfo: appointment.vehicleInfo || '',
vin: appointment.vin || '',
date: appointment.date || '',
time: appointment.time || '',
durationMinutes: appointment.durationMinutes ?? 60,
advisorName: appointment.advisorName || '',
serviceType: appointment.serviceType || '',
serviceLocation: appointment.serviceLocation || '',
tripMiles: appointment.tripMiles || 0,
arrivalWindowMinutes: appointment.arrivalWindowMinutes || 0,
notes: appointment.notes || '',
});
setSuggestedCustomer(null);
setDismissedMatch(false);
} else {
setForm({ ...EMPTY_FORM, advisorName: loadSettings().serviceAdvisor });
setSuggestedCustomer(null);
setDismissedMatch(false);
}
setError(null);
}, [appointment, isOpen]);
// ── Customer matching logic (debounced) ──
const runMatching = useCallback(() => {
if (!form.customerId && !dismissedMatch) {
const match = findMatchingCustomer(customers, {
firstName: form.firstName,
middleName: form.middleName,
lastName: form.lastName,
phone: form.customerPhone,
});
setSuggestedCustomer(match);
} else {
setSuggestedCustomer(null);
}
}, [form.firstName, form.middleName, form.lastName, form.customerPhone, form.customerId, customers, dismissedMatch]);
useEffect(() => {
if (appointment) return; // don't match while editing an existing appointment
if (matchTimeoutRef.current) clearTimeout(matchTimeoutRef.current);
matchTimeoutRef.current = setTimeout(runMatching, 400);
return () => {
if (matchTimeoutRef.current) clearTimeout(matchTimeoutRef.current);
};
}, [runMatching, appointment]);
const handleUseExistingCustomer = useCallback(() => {
if (!suggestedCustomer) return;
const parsed = parseName(suggestedCustomer.name);
setForm((prev) => ({
...prev,
customerId: suggestedCustomer.id,
firstName: parsed.firstName,
middleName: parsed.middleName,
lastName: parsed.lastName,
customerName: suggestedCustomer.name,
customerPhone: suggestedCustomer.phone || prev.customerPhone,
customerEmail: suggestedCustomer.email || prev.customerEmail,
}));
setSuggestedCustomer(null);
setDismissedMatch(false);
}, [suggestedCustomer]);
const handleDismissMatch = useCallback(() => {
setSuggestedCustomer(null);
setDismissedMatch(true);
}, []);
// ── Create new customer in PB ──
const ensureCustomerRecord = useCallback(async (): Promise<string> => {
// If we already have a customerId, return it
if (form.customerId) return form.customerId;
const uid = pb.authStore.model?.id;
if (!uid) return '';
const name = composeName(form.firstName, form.middleName, form.lastName);
if (!name.trim()) return '';
// Check once more for an existing customer in case one was added since mount
const existingMatch = findMatchingCustomer(customers, {
firstName: form.firstName,
middleName: form.middleName,
lastName: form.lastName,
phone: form.customerPhone,
});
if (existingMatch) {
// Auto-pick the match
setForm((prev) => ({ ...prev, customerId: existingMatch.id }));
return existingMatch.id;
}
// Create the customer record
try {
const record = await pb.collection('customers').create({
name,
phone: form.customerPhone,
email: form.customerEmail,
address: '',
notes: '',
userId: uid,
});
const newId = record.id;
setForm((prev) => ({ ...prev, customerId: newId }));
return newId;
} catch (err) {
logError('Failed to create customer record', err);
return '';
}
}, [form, customers]);
const handleCustomerCreated = async (data: CustomerFormData) => {
const uid = pb.authStore.model?.id;
if (!uid) return;
const name = composeName(data.firstName, data.middleName, data.lastName) || data.name;
const record = await pb.collection('customers').create({
name,
firstName: data.firstName || '',
middleName: data.middleName || '',
lastName: data.lastName || '',
phone: data.phone || '',
email: data.email || '',
address: data.address || '',
notes: data.notes || '',
userId: uid,
});
const c: CustomerWithVehicles = {
...record as unknown as import('../../pages/Customers').CustomerRecord,
vehicles: [],
vehicleCount: 0,
};
const parsed = parseName(name);
handleChange('customerId', c.id);
handleChange('firstName', parsed.firstName);
handleChange('middleName', parsed.middleName);
handleChange('lastName', parsed.lastName);
handleChange('customerName', name);
handleChange('customerPhone', c.phone || '');
handleChange('customerEmail', c.email || '');
setShowCustomerModal(false);
setShowCustomerModalFromMatch(false);
setSuggestedCustomer(null);
setDismissedMatch(false);
};
const handleChange = (field: keyof AppointmentFormData, value: string | number) => {
setForm((prev) => {
const next = { ...prev, [field]: value };
// When name fields change, recompose customerName
if (field === 'firstName' || field === 'middleName' || field === 'lastName') {
const newFirst = field === 'firstName' ? String(value) : prev.firstName;
const newMiddle = field === 'middleName' ? String(value) : prev.middleName;
const newLast = field === 'lastName' ? String(value) : prev.lastName;
const composed = composeName(newFirst, newMiddle, newLast);
if (composed !== prev.customerName) {
next.customerName = composed;
}
}
return next;
});
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
setError(null);
const name = composeName(form.firstName, form.middleName, form.lastName);
if (!name.trim()) {
setError('Please enter at least the customer\'s first and last name.');
return;
}
if (!form.date) {
setError('Date is required.');
return;
}
if (!isValidDate(form.date)) {
setError('Please enter a valid appointment date.');
return;
}
if (form.date < todayISO()) {
setError('Appointment date cannot be in the past.');
return;
}
if (!form.time) {
setError('Time is required.');
return;
}
setSaving(true);
try {
// Auto-create customer record if one doesn't exist yet
const customerId = await ensureCustomerRecord();
const payload: AppointmentFormData = {
...form,
customerId: customerId || form.customerId,
customerName: name,
};
await onSave(payload);
onClose();
} catch (err: unknown) {
logError('Failed to save appointment', err);
setError('Appointment could not be saved. Please try again.');
} finally {
setSaving(false);
}
};
if (!isOpen) return null;
const name = composeName(form.firstName, form.middleName, form.lastName);
return (
<>
<div className="fixed inset-0 z-50 flex items-start justify-center overflow-y-auto p-4 pt-8">
<div
className="absolute inset-0 bg-black/50 backdrop-blur-sm"
onClick={onClose}
/>
<div className="relative z-10 w-full max-w-3xl overflow-hidden rounded-2xl border border-gray-200 bg-white shadow-2xl dark:border-gray-700 dark:bg-gray-900">
<div className="flex items-start justify-between border-b border-gray-200 bg-gradient-to-r from-emerald-50 via-white to-white px-6 py-5 dark:border-gray-700 dark:from-emerald-950/20 dark:via-gray-900 dark:to-gray-900">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-emerald-700 dark:text-emerald-400">
Appointment Details
</p>
<h2 className="mt-1 text-xl font-semibold tracking-tight text-gray-900 dark:text-white">
{appointment ? 'Edit Appointment' : 'New Appointment'}
</h2>
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
Capture customer, vehicle, and schedule details in one clean service record.
</p>
</div>
<button
onClick={onClose}
className="rounded-lg p-1.5 text-gray-400 transition-colors hover:bg-white/70 hover:text-gray-600 dark:hover:bg-gray-800 dark:hover:text-gray-300"
>
<X className="h-5 w-5" />
</button>
</div>
<form onSubmit={handleSubmit} className="space-y-6 bg-gray-50/70 px-6 py-6 dark:bg-gray-950/40">
{error && (
<div className="rounded-lg border border-red-200 bg-red-50 p-3 text-sm text-red-700 dark:border-red-800 dark:bg-red-900/20 dark:text-red-400">
{error}
</div>
)}
{/* ── Customer match suggestion banner ── */}
{suggestedCustomer && !form.customerId && (
<div className="rounded-xl border border-emerald-200 bg-emerald-50 p-4 dark:border-emerald-700 dark:bg-emerald-900/20">
<div className="flex items-start gap-3">
<div className="mt-0.5 flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-emerald-100 dark:bg-emerald-800">
<User className="h-4 w-4 text-emerald-600 dark:text-emerald-300" />
</div>
<div className="min-w-0 flex-1">
<p className="text-sm font-medium text-emerald-800 dark:text-emerald-200">
Existing customer found
</p>
<p className="mt-0.5 text-sm text-emerald-700 dark:text-emerald-300">
<span className="font-semibold">{suggestedCustomer.name}</span>
{suggestedCustomer.phone && (
<> &middot; {suggestedCustomer.phone}</>
)}
{suggestedCustomer.email && (
<> &middot; {suggestedCustomer.email}</>
)}
</p>
<div className="mt-2 flex items-center gap-2">
<button
type="button"
onClick={handleUseExistingCustomer}
className="inline-flex items-center gap-1.5 rounded-lg bg-emerald-600 px-3 py-1.5 text-xs font-medium text-white hover:bg-emerald-700"
>
<Check className="h-3.5 w-3.5" />
Use Existing Customer
</button>
<button
type="button"
onClick={handleDismissMatch}
className="rounded-lg border border-emerald-300 bg-white px-3 py-1.5 text-xs font-medium text-emerald-700 hover:bg-emerald-50 dark:border-emerald-600 dark:bg-gray-800 dark:text-emerald-300 dark:hover:bg-emerald-900/30"
>
Continue as New
</button>
</div>
</div>
</div>
</div>
)}
<div className="grid grid-cols-1 gap-6 xl:grid-cols-[1.15fr_0.85fr]">
<div className="space-y-6">
<section className="rounded-2xl border border-gray-200 bg-white p-5 shadow-sm dark:border-gray-700 dark:bg-gray-900">
<h3 className="mb-4 text-sm font-semibold text-gray-700 dark:text-gray-300">Customer Information</h3>
{/* First / Middle / Last name row */}
<div className="mb-3 grid grid-cols-1 gap-3 sm:grid-cols-3">
<div>
<label className="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">
First Name <span className="text-red-500">*</span>
</label>
<input
type="text"
value={form.firstName}
onChange={(e) => handleChange('firstName', e.target.value)}
className="w-full rounded-xl border border-gray-300 px-3 py-2.5 text-sm text-gray-900 placeholder-gray-400 shadow-sm transition-colors focus:border-emerald-500 focus:outline-none focus:ring-2 focus:ring-emerald-500/20 dark:border-gray-600 dark:bg-gray-800 dark:text-white dark:placeholder-gray-500 dark:focus:border-emerald-400"
placeholder="John"
/>
</div>
<div>
<label className="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">
Middle
</label>
<input
type="text"
value={form.middleName}
onChange={(e) => handleChange('middleName', e.target.value)}
className="w-full rounded-xl border border-gray-300 px-3 py-2.5 text-sm text-gray-900 placeholder-gray-400 shadow-sm transition-colors focus:border-emerald-500 focus:outline-none focus:ring-2 focus:ring-emerald-500/20 dark:border-gray-600 dark:bg-gray-800 dark:text-white dark:placeholder-gray-500 dark:focus:border-emerald-400"
placeholder="Michael"
/>
</div>
<div>
<label className="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">
Last Name <span className="text-red-500">*</span>
</label>
<input
type="text"
value={form.lastName}
onChange={(e) => handleChange('lastName', e.target.value)}
className="w-full rounded-xl border border-gray-300 px-3 py-2.5 text-sm text-gray-900 placeholder-gray-400 shadow-sm transition-colors focus:border-emerald-500 focus:outline-none focus:ring-2 focus:ring-emerald-500/20 dark:border-gray-600 dark:bg-gray-800 dark:text-white dark:placeholder-gray-500 dark:focus:border-emerald-400"
placeholder="Doe"
/>
</div>
</div>
{/* Phone + Email */}
<div className="grid grid-cols-1 gap-3 sm:grid-cols-2">
<div>
<label className="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">Phone</label>
<input type="tel" value={formatPhoneInput(form.customerPhone)} onChange={(e) => handleChange('customerPhone', e.target.value.replace(/\D/g, ''))} className="w-full rounded-xl border border-gray-300 px-3 py-2.5 text-sm text-gray-900 placeholder-gray-400 shadow-sm transition-colors focus:border-emerald-500 focus:outline-none focus:ring-2 focus:ring-emerald-500/20 dark:border-gray-600 dark:bg-gray-800 dark:text-white dark:placeholder-gray-500 dark:focus:border-emerald-400" placeholder="(555) 123-4567" />
</div>
<div>
<label className="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">Email</label>
<input type="email" value={form.customerEmail} onChange={(e) => handleChange('customerEmail', e.target.value)} className="w-full rounded-xl border border-gray-300 px-3 py-2.5 text-sm text-gray-900 placeholder-gray-400 shadow-sm transition-colors focus:border-emerald-500 focus:outline-none focus:ring-2 focus:ring-emerald-500/20 dark:border-gray-600 dark:bg-gray-800 dark:text-white dark:placeholder-gray-500 dark:focus:border-emerald-400" placeholder="john@example.com" />
</div>
</div>
{/* Selected customer indicator */}
{form.customerId && (
<div className="mt-3 flex items-center gap-2 rounded-lg border border-emerald-200 bg-emerald-50/50 px-3 py-2 dark:border-emerald-700 dark:bg-emerald-900/10">
<Check className="h-4 w-4 shrink-0 text-emerald-500" />
<span className="text-xs text-emerald-700 dark:text-emerald-300">
Linked to customer record: <strong>{name || form.customerName}</strong>
</span>
</div>
)}
</section>
<section className="rounded-2xl border border-gray-200 bg-white p-5 shadow-sm dark:border-gray-700 dark:bg-gray-900">
<h3 className="mb-4 text-sm font-semibold text-gray-700 dark:text-gray-300">Vehicle Information</h3>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div className="sm:col-span-2">
<label className="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">Vehicle Info</label>
<input type="text" value={form.vehicleInfo} onChange={(e) => handleChange('vehicleInfo', e.target.value)} className="w-full rounded-xl border border-gray-300 px-3 py-2.5 text-sm text-gray-900 placeholder-gray-400 shadow-sm transition-colors focus:border-emerald-500 focus:outline-none focus:ring-2 focus:ring-emerald-500/20 dark:border-gray-600 dark:bg-gray-800 dark:text-white dark:placeholder-gray-500 dark:focus:border-emerald-400" placeholder="2020 Toyota Camry" />
</div>
<div>
<label className="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">VIN</label>
<input type="text" value={form.vin} onChange={(e) => handleChange('vin', e.target.value.toUpperCase())} className="w-full rounded-xl border border-gray-300 px-3 py-2.5 text-sm text-gray-900 placeholder-gray-400 shadow-sm transition-colors focus:border-emerald-500 focus:outline-none focus:ring-2 focus:ring-emerald-500/20 dark:border-gray-600 dark:bg-gray-800 dark:text-white dark:placeholder-gray-500 dark:focus:border-emerald-400" placeholder="1HGBH41JXMN109186" />
</div>
<div>
<label className="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">Duration (min)</label>
<input type="number" min="0" value={form.durationMinutes} onChange={(e) => handleChange('durationMinutes', parseInt(e.target.value, 10) || 0)} className="w-full rounded-xl border border-gray-300 px-3 py-2.5 text-sm text-gray-900 shadow-sm transition-colors focus:border-emerald-500 focus:outline-none focus:ring-2 focus:ring-emerald-500/20 dark:border-gray-600 dark:bg-gray-800 dark:text-white dark:focus:border-emerald-400" />
</div>
{showLocationFields && (
<div>
<label className="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">Trip Miles</label>
<input type="number" min="0" step="0.1" value={form.tripMiles || ''} onChange={(e) => handleChange('tripMiles', parseFloat(e.target.value) || 0)} className="w-full rounded-xl border border-gray-300 px-3 py-2.5 text-sm text-gray-900 shadow-sm transition-colors focus:border-emerald-500 focus:outline-none focus:ring-2 focus:ring-emerald-500/20 dark:border-gray-600 dark:bg-gray-800 dark:text-white dark:focus:border-emerald-400" placeholder="0" />
</div>
)}
</div>
</section>
</div>
<div className="space-y-6">
<section className="rounded-2xl border border-gray-200 bg-white p-5 shadow-sm dark:border-gray-700 dark:bg-gray-900">
<h3 className="mb-4 text-sm font-semibold text-gray-700 dark:text-gray-300">Schedule & Service</h3>
<div className="grid grid-cols-1 gap-4">
<div>
<label className="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">Date <span className="text-red-500">*</span></label>
<input type="date" value={form.date} onChange={(e) => handleChange('date', e.target.value)} className="w-full rounded-xl border border-gray-300 px-3 py-2.5 text-sm text-gray-900 shadow-sm transition-colors focus:border-emerald-500 focus:outline-none focus:ring-2 focus:ring-emerald-500/20 dark:border-gray-600 dark:bg-gray-800 dark:text-white dark:focus:border-emerald-400" />
</div>
<div>
<label className="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">Time <span className="text-red-500">*</span></label>
<input type="time" value={form.time} onChange={(e) => handleChange('time', e.target.value)} className="w-full rounded-xl border border-gray-300 px-3 py-2.5 text-sm text-gray-900 shadow-sm transition-colors focus:border-emerald-500 focus:outline-none focus:ring-2 focus:ring-emerald-500/20 dark:border-gray-600 dark:bg-gray-800 dark:text-white dark:focus:border-emerald-400" />
</div>
{showLocationFields && (
<div>
<label className="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">Arrival Window</label>
<select value={form.arrivalWindowMinutes} onChange={(e) => handleChange('arrivalWindowMinutes', parseInt(e.target.value, 10) || 0)} className="w-full rounded-xl border border-gray-300 px-3 py-2.5 text-sm text-gray-900 shadow-sm transition-colors focus:border-emerald-500 focus:outline-none focus:ring-2 focus:ring-emerald-500/20 dark:border-gray-600 dark:bg-gray-800 dark:text-white dark:focus:border-emerald-400">
<option value={0}>Exact time</option>
<option value={60}>&#xb1;1 hour</option>
<option value={120}>&#xb1;2 hours</option>
<option value={180}>&#xb1;3 hours</option>
<option value={240}>&#xb1;4 hours</option>
</select>
</div>
)}
<div>
<label className="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">{roleLabel}</label>
<input
type="text"
value={form.advisorName}
onChange={(e) => handleChange('advisorName', e.target.value)}
placeholder={loadSettings().serviceAdvisor || `${roleLabel} name`}
className={`w-full rounded-xl border px-3 py-2.5 text-sm shadow-sm transition-colors focus:outline-none focus:ring-2 focus:ring-emerald-500/20 ${
loadSettings().serviceAdvisor && form.advisorName === loadSettings().serviceAdvisor
? 'border-gray-200 bg-gray-50 text-gray-400 placeholder-gray-400 focus:border-emerald-500 dark:border-gray-700 dark:bg-gray-800/50 dark:text-gray-500 dark:placeholder-gray-500 dark:focus:border-emerald-400'
: 'border-gray-300 bg-white text-gray-900 placeholder-gray-400 focus:border-emerald-500 dark:border-gray-600 dark:bg-gray-800 dark:text-white dark:placeholder-gray-500 dark:focus:border-emerald-400'
}`}
/>
</div>
<div>
<label className="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">Service Type</label>
<input type="text" value={form.serviceType} onChange={(e) => handleChange('serviceType', e.target.value)} className="w-full rounded-xl border border-gray-300 px-3 py-2.5 text-sm text-gray-900 placeholder-gray-400 shadow-sm transition-colors focus:border-emerald-500 focus:outline-none focus:ring-2 focus:ring-emerald-500/20 dark:border-gray-600 dark:bg-gray-800 dark:text-white dark:placeholder-gray-500 dark:focus:border-emerald-400" placeholder="Oil Change, Brake Service, etc." />
</div>
{showLocationFields && (
<div>
<label className="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">Service Address</label>
<input type="text" value={form.serviceLocation} onChange={(e) => handleChange('serviceLocation', e.target.value)} className="w-full rounded-xl border border-gray-300 px-3 py-2.5 text-sm text-gray-900 placeholder-gray-400 shadow-sm transition-colors focus:border-emerald-500 focus:outline-none focus:ring-2 focus:ring-emerald-500/20 dark:border-gray-600 dark:bg-gray-800 dark:text-white dark:placeholder-gray-500 dark:focus:border-emerald-400" placeholder="123 Oak St, Knoxville, TN" />
</div>
)}
</div>
</section>
<section className="rounded-2xl border border-gray-200 bg-white p-5 shadow-sm dark:border-gray-700 dark:bg-gray-900">
<label className="block text-sm font-semibold text-gray-700 dark:text-gray-300">Notes</label>
<textarea value={form.notes} onChange={(e) => handleChange('notes', e.target.value)} rows={6} className="mt-3 w-full rounded-xl border border-gray-300 px-3 py-2.5 text-sm text-gray-900 placeholder-gray-400 shadow-sm transition-colors focus:border-emerald-500 focus:outline-none focus:ring-2 focus:ring-emerald-500/20 dark:border-gray-600 dark:bg-gray-800 dark:text-white dark:placeholder-gray-500 dark:focus:border-emerald-400" placeholder="Any additional notes..." />
</section>
</div>
</div>
<div className="flex items-center justify-between border-t border-gray-200 bg-white px-1 pt-5 dark:border-gray-700 dark:bg-transparent">
<p className="text-sm text-gray-500 dark:text-gray-400">
Confirm the service slot and customer details before saving.
</p>
<div className="flex items-center gap-3">
<button
type="button"
onClick={onClose}
className="rounded-xl border border-gray-300 px-4 py-2.5 text-sm font-medium text-gray-700 transition-colors hover:bg-gray-50 dark:border-gray-600 dark:text-gray-300 dark:hover:bg-gray-700"
>
Cancel
</button>
<button
type="submit"
disabled={saving || !form.firstName.trim() || !form.lastName.trim()}
className="inline-flex items-center gap-2 rounded-xl bg-gradient-to-r from-emerald-600 to-emerald-500 px-5 py-2.5 text-sm font-medium text-white shadow-sm transition-all hover:from-emerald-700 hover:to-emerald-600 focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 dark:focus:ring-offset-gray-800"
>
{saving ? (
<>
<svg className="h-4 w-4 animate-spin" viewBox="0 0 24 24" fill="none">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z" />
</svg>
Saving...
</>
) : (
<>
<Save className="h-4 w-4" />
{appointment ? 'Update' : 'Create'}
</>
)}
</button>
</div>
</div>
</form>
</div>
</div>
{/* Create Customer Modal */}
{(showCustomerModal || showCustomerModalFromMatch) && (
<div className="fixed inset-0 z-[60] flex items-center justify-center bg-black/50 p-4">
<div className="w-full max-w-lg rounded-xl bg-white shadow-xl dark:bg-gray-800">
<div className="flex items-center justify-between border-b border-gray-200 px-6 py-4 dark:border-gray-700">
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">Create New Customer</h2>
<button onClick={() => { setShowCustomerModal(false); setShowCustomerModalFromMatch(false); }} className="rounded-lg p-1.5 text-gray-400 hover:bg-gray-100 hover:text-gray-600"><X className="h-5 w-5" /></button>
</div>
<div className="space-y-4 p-6">
<div className="grid grid-cols-3 gap-3">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">First <span className="text-red-500">*</span></label>
<input type="text" id="new-customer-first" defaultValue={form.firstName} className="mt-1 w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm text-gray-900 placeholder-gray-400 focus:border-green-500 focus:outline-none focus:ring-2 focus:ring-green-500/20 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-100" placeholder="John" />
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">Middle</label>
<input type="text" id="new-customer-middle" defaultValue={form.middleName} className="mt-1 w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm text-gray-900 placeholder-gray-400 focus:border-green-500 focus:outline-none focus:ring-2 focus:ring-green-500/20 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-100" placeholder="Michael" />
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">Last <span className="text-red-500">*</span></label>
<input type="text" id="new-customer-last" defaultValue={form.lastName} className="mt-1 w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm text-gray-900 placeholder-gray-400 focus:border-green-500 focus:outline-none focus:ring-2 focus:ring-green-500/20 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-100" placeholder="Doe" />
</div>
</div>
<div className="grid grid-cols-2 gap-4">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">Phone</label>
<input type="tel" id="new-customer-phone" defaultValue={form.customerPhone} className="mt-1 w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm text-gray-900 placeholder-gray-400 focus:border-green-500 focus:outline-none focus:ring-2 focus:ring-green-500/20 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-100" placeholder="Phone" />
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">Email</label>
<input type="email" id="new-customer-email" defaultValue={form.customerEmail} className="mt-1 w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm text-gray-900 placeholder-gray-400 focus:border-green-500 focus:outline-none focus:ring-2 focus:ring-green-500/20 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-100" placeholder="Email" />
</div>
</div>
<div className="flex justify-end gap-3 border-t border-gray-200 pt-4 dark:border-gray-700">
<button type="button" onClick={() => { setShowCustomerModal(false); setShowCustomerModalFromMatch(false); }} className="rounded-lg border border-gray-300 bg-white px-4 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-300">Cancel</button>
<button type="button" onClick={() => {
const firstInput = document.getElementById('new-customer-first') as HTMLInputElement;
const middleInput = document.getElementById('new-customer-middle') as HTMLInputElement;
const lastInput = document.getElementById('new-customer-last') as HTMLInputElement;
const phoneInput = document.getElementById('new-customer-phone') as HTMLInputElement;
const emailInput = document.getElementById('new-customer-email') as HTMLInputElement;
const firstName = firstInput?.value?.trim() || '';
const middleName = middleInput?.value?.trim() || '';
const lastName = lastInput?.value?.trim() || '';
const name = composeName(firstName, middleName, lastName);
if (!name) return;
handleCustomerCreated({
name,
firstName,
middleName,
lastName,
phone: phoneInput?.value || '',
email: emailInput?.value || '',
address: '',
notes: '',
}).catch(() => {});
}} className="rounded-lg bg-green-600 px-4 py-2 text-sm font-medium text-white hover:bg-green-700">Create Customer</button>
</div>
</div>
</div>
</div>
)}
</>);
}
export const AppointmentModal = memo(AppointmentModalImpl);
@@ -0,0 +1,205 @@
import { memo } from 'react';
import {
CalendarDays,
User,
Car,
Wrench,
UserCheck,
Clock,
MapPin,
Phone,
Mail,
LogIn,
Edit3,
Trash2,
Check,
Ban,
UserX,
} from 'lucide-react';
import type { Appointment } from '../../types';
import { formatPhoneDisplay } from '../../lib/phone';
import { formatDateDisplay, formatArrivalWindow, formatDurationShort } from '../../lib/appointments';
import { StatusBadge } from './StatusBadge';
interface Props {
appointment: Appointment;
onEdit: (appt: Appointment) => void;
onDelete: (appt: Appointment) => void;
onStatusChange: (appt: Appointment, status: Appointment['status']) => void;
onCheckIn: (appt: Appointment) => void;
}
function AppointmentRowImpl({
appointment,
onEdit,
onDelete,
onStatusChange,
onCheckIn,
}: Props) {
const statusAccent: Record<Appointment['status'], string> = {
scheduled: 'before:bg-blue-500',
checked_in: 'before:bg-amber-500',
completed: 'before:bg-emerald-500',
cancelled: 'before:bg-red-500',
no_show: 'before:bg-gray-500',
};
const statusGlow: Record<Appointment['status'], string> = {
scheduled: 'hover:shadow-blue-100/80 dark:hover:shadow-blue-950/20',
checked_in: 'hover:shadow-amber-100/80 dark:hover:shadow-amber-950/20',
completed: 'hover:shadow-emerald-100/80 dark:hover:shadow-emerald-950/20',
cancelled: 'hover:shadow-red-100/80 dark:hover:shadow-red-950/20',
no_show: 'hover:shadow-gray-200/80 dark:hover:shadow-black/20',
};
const statusActions: {
label: string;
status: Appointment['status'];
icon: React.ComponentType<{ className?: string }>;
color: string;
}[] = [];
if (appointment.status === 'checked_in') {
statusActions.push({ label: 'Complete', status: 'completed', icon: Check, color: 'text-green-600 hover:bg-green-50 dark:hover:bg-green-900/20' });
}
if (appointment.status !== 'cancelled' && appointment.status !== 'no_show') {
if (appointment.status !== 'completed') {
statusActions.push({ label: 'Cancel', status: 'cancelled', icon: Ban, color: 'text-red-600 hover:bg-red-50 dark:hover:bg-red-900/20' });
}
statusActions.push({ label: 'No Show', status: 'no_show', icon: UserX, color: 'text-gray-600 hover:bg-gray-100 dark:hover:bg-gray-700' });
}
return (
<div className={`group relative overflow-hidden rounded-2xl border border-gray-200/90 bg-white px-4 py-2.5 shadow-sm shadow-gray-200/60 transition-all before:absolute before:inset-y-0 before:left-0 before:w-1 before:content-[''] hover:-translate-y-0.5 hover:shadow-lg dark:border-gray-700 dark:bg-gray-800 dark:shadow-black/10 dark:hover:border-gray-600 ${statusAccent[appointment.status] || statusAccent.scheduled} ${statusGlow[appointment.status] || statusGlow.scheduled}`}>
<div className="flex flex-col gap-2 sm:flex-row sm:items-start sm:justify-between">
<div className="flex-1 min-w-0">
<div className="mb-1.5 flex flex-wrap items-center gap-2 text-sm text-gray-500 dark:text-gray-400">
<span className="inline-flex items-center gap-1 rounded-full bg-emerald-50 px-2.5 py-1 text-[11px] font-semibold uppercase tracking-[0.16em] text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-300">
<CalendarDays className="h-3.5 w-3.5 shrink-0" />
Service Slot
</span>
<StatusBadge status={appointment.status} />
</div>
<h3 className="truncate text-base font-semibold tracking-tight text-gray-900 dark:text-white">
<User className="mr-1.5 inline h-4 w-4 text-gray-400" />
{appointment.customerName || 'Unknown'}
</h3>
<div className="mt-1.5 flex flex-wrap gap-1.5 text-xs text-gray-500 dark:text-gray-400">
<span className="inline-flex items-center gap-1 rounded-full border border-gray-200 bg-gray-50 px-2.5 py-0.5 dark:border-gray-700 dark:bg-gray-700/60">
<span className="font-medium uppercase tracking-[0.12em] text-gray-400 dark:text-gray-500">Date</span>
<span className="font-semibold text-gray-700 dark:text-gray-200">{appointment.date ? formatDateDisplay(appointment.date) : 'No date set'}</span>
</span>
<span className="inline-flex items-center gap-1 rounded-full border border-gray-200 bg-gray-50 px-2.5 py-0.5 dark:border-gray-700 dark:bg-gray-700/60">
<span className="font-medium uppercase tracking-[0.12em] text-gray-400 dark:text-gray-500">Time</span>
<span className="font-semibold text-gray-700 dark:text-gray-200">{appointment.time ? formatArrivalWindow(appointment.time, appointment.arrivalWindowMinutes) : 'No time set'}</span>
</span>
<span className="inline-flex items-center gap-1 rounded-full border border-gray-200 bg-gray-50 px-2.5 py-0.5 dark:border-gray-700 dark:bg-gray-700/60">
<span className="font-medium uppercase tracking-[0.12em] text-gray-400 dark:text-gray-500">VIN</span>
<span className="font-semibold text-gray-700 dark:text-gray-200">{appointment.vin || 'No VIN set'}</span>
</span>
</div>
<div className="mt-1.5 flex flex-wrap gap-1.5 text-sm text-gray-600 dark:text-gray-300">
{appointment.vehicleInfo && (
<span className="inline-flex items-center gap-1 rounded-full border border-gray-200 bg-gray-50 px-2.5 py-0.5 dark:border-gray-700 dark:bg-gray-700/60">
<Car className="h-3.5 w-3.5" />
{appointment.vehicleInfo}
</span>
)}
{appointment.serviceType && (
<span className="inline-flex items-center gap-1 rounded-full border border-gray-200 bg-gray-50 px-2.5 py-0.5 dark:border-gray-700 dark:bg-gray-700/60">
<Wrench className="h-3.5 w-3.5" />
{appointment.serviceType}
</span>
)}
{appointment.advisorName && (
<span className="inline-flex items-center gap-1 rounded-full border border-gray-200 bg-gray-50 px-2.5 py-0.5 dark:border-gray-700 dark:bg-gray-700/60">
<UserCheck className="h-3.5 w-3.5" />
{appointment.advisorName}
</span>
)}
{appointment.durationMinutes != null && appointment.durationMinutes > 0 && (
<span className="inline-flex items-center gap-1 rounded-full border border-amber-200 bg-amber-50 px-2.5 py-0.5 text-amber-800 dark:border-amber-900/60 dark:bg-amber-900/20 dark:text-amber-300">
<Clock className="h-3.5 w-3.5" />
{formatDurationShort(appointment.durationMinutes)}
</span>
)}
{appointment.serviceLocation && (
<span className="inline-flex items-center gap-1 rounded-full border border-sky-200 bg-sky-50 px-2.5 py-0.5 text-sky-800 dark:border-sky-900/60 dark:bg-sky-900/20 dark:text-sky-300">
<MapPin className="h-3.5 w-3.5" />
{appointment.serviceLocation}
</span>
)}
{typeof appointment.tripMiles === 'number' && appointment.tripMiles > 0 && (
<span className="inline-flex items-center gap-1 rounded-full border border-purple-200 bg-purple-50 px-2.5 py-0.5 text-purple-800 dark:border-purple-900/60 dark:bg-purple-900/20 dark:text-purple-300">
<Car className="h-3.5 w-3.5" />
{appointment.tripMiles} mi
</span>
)}
</div>
{appointment.notes && (
<p className="mt-1.5 rounded-xl border border-dashed border-gray-200 bg-gray-50/70 px-3 py-1 text-sm italic text-gray-500 line-clamp-2 dark:border-gray-700 dark:bg-gray-900/40 dark:text-gray-400">
{appointment.notes}
</p>
)}
<div className="mt-1.5 flex flex-wrap gap-2 text-xs text-gray-400 dark:text-gray-500">
{appointment.customerPhone && (
<span className="inline-flex items-center gap-1">
<Phone className="h-3 w-3" />
{formatPhoneDisplay(appointment.customerPhone)}
</span>
)}
{appointment.customerEmail && (
<span className="inline-flex items-center gap-1">
<Mail className="h-3 w-3" />
{appointment.customerEmail}
</span>
)}
</div>
</div>
<div className="flex shrink-0 flex-wrap items-center gap-1 self-start rounded-xl border border-gray-200 bg-gray-50/80 p-0.5 dark:border-gray-700 dark:bg-gray-900/60">
{appointment.status === 'scheduled' && (
<button
onClick={() => onCheckIn(appointment)}
title="Check In (Create RO)"
className="rounded-lg p-1.5 text-yellow-600 transition-colors hover:bg-yellow-50 dark:hover:bg-yellow-900/20"
>
<LogIn className="h-4 w-4" />
</button>
)}
{statusActions.map((action) => (
<button
key={action.status}
onClick={() => onStatusChange(appointment, action.status)}
title={action.label}
className={`rounded-lg p-1.5 transition-colors ${action.color}`}
>
<action.icon className="h-4 w-4" />
</button>
))}
<button
onClick={() => onEdit(appointment)}
title="Edit"
className="rounded-lg p-1.5 text-blue-600 transition-colors hover:bg-blue-50 dark:hover:bg-blue-900/20"
>
<Edit3 className="h-4 w-4" />
</button>
<button
onClick={() => onDelete(appointment)}
title="Delete"
className="rounded-lg p-1.5 text-red-600 transition-colors hover:bg-red-50 dark:hover:bg-red-900/20"
>
<Trash2 className="h-4 w-4" />
</button>
</div>
</div>
</div>
);
}
export const AppointmentRow = memo(AppointmentRowImpl);
@@ -0,0 +1,316 @@
import { memo, useEffect, useState } from 'react';
import { LogIn, UserCheck, UserX, X } from 'lucide-react';
import { loadSettings, getRoleLabel, showServiceLocationFields, getCustomerTypeLabels } from '../../lib/settings';
import { useSettings } from '../../store/settings';
import { formatPhoneInput } from '../../lib/phone';
import { generateRONumber } from '../../lib/appointments';
import type { Appointment, CustomerType } from '../../types';
import type { CustomerWithVehicles } from '../../pages/Customers';
interface CheckInData {
customerName: string;
customerPhone: string;
customerEmail: string;
vehicleInfo: string;
vin: string;
mileage: string;
advisorName: string;
roNumber: string;
notes: string;
estimatedDuration: number;
customerType: CustomerType;
serviceLocation: string;
travelFee: number;
tripMiles: number;
}
interface Props {
isOpen: boolean;
onClose: () => void;
onSubmit: (data: CheckInData) => void;
appointment: Appointment | null;
customers: CustomerWithVehicles[];
}
function CheckInModalImpl({
isOpen,
onClose,
onSubmit,
appointment,
customers,
}: Props) {
const setupSettings = useSettings();
const roleLabel = getRoleLabel(setupSettings);
const customerTypeLabels = getCustomerTypeLabels(setupSettings);
const showLocationFields = showServiceLocationFields(setupSettings);
const [customerName, setCustomerName] = useState('');
const [customerPhone, setCustomerPhone] = useState('');
const [customerEmail, setCustomerEmail] = useState('');
const [vehicleInfo, setVehicleInfo] = useState('');
const [vin, setVin] = useState('');
const [mileage, setMileage] = useState('');
const [advisorName, setAdvisorName] = useState(() => loadSettings().serviceAdvisor);
const [roNumber, setRoNumber] = useState('');
const [concerns, setConcerns] = useState('');
const [estimatedHours, setEstimatedHours] = useState(1);
const [estimatedMinutes, setEstimatedMinutes] = useState(0);
const [customerType, setCustomerType] = useState<CustomerType>('drop-off');
const [saving, setSaving] = useState(false);
const [serviceLocation, setServiceLocation] = useState('');
const [travelFee, setTravelFee] = useState<number>(() => loadSettings().defaultTravelFee || 0);
const [tripMiles, setTripMiles] = useState<number>(0);
useEffect(() => {
if (isOpen && appointment) {
setCustomerName(appointment.customerName || '');
setCustomerPhone(appointment.customerPhone || '');
setCustomerEmail(appointment.customerEmail || '');
setVehicleInfo(appointment.vehicleInfo || '');
setVin(appointment.vin || '');
setMileage('');
const linkedCustomer = appointment.customerId
? customers.find((c) => c.id === appointment.customerId)
: null;
if (linkedCustomer && linkedCustomer.vehicles.length > 0) {
const v = linkedCustomer.vehicles[0];
const label = [v.year, v.make, v.model].filter(Boolean).join(' ');
if (label && !appointment.vehicleInfo) setVehicleInfo(label);
if (v.vin && !appointment.vin) setVin(v.vin);
if (v.mileage) setMileage(v.mileage);
}
setAdvisorName(appointment.advisorName || '');
setRoNumber(generateRONumber());
setConcerns(appointment.notes || '');
const totalMinutes = appointment.durationMinutes ?? 60;
setEstimatedHours(Math.floor(totalMinutes / 60));
setEstimatedMinutes(totalMinutes % 60);
setCustomerType('drop-off');
setServiceLocation(appointment.serviceLocation || '');
setTravelFee(loadSettings().defaultTravelFee || 0);
setTripMiles(appointment.tripMiles || 0);
}
}, [isOpen, appointment]);
const handleSubmit = async () => {
if (!customerName.trim() || !vehicleInfo.trim()) return;
setSaving(true);
try {
onSubmit({
customerName: customerName.trim(),
customerPhone: customerPhone.trim(),
customerEmail: customerEmail.trim(),
vehicleInfo: vehicleInfo.trim(),
vin: vin.trim(),
mileage: mileage.trim(),
advisorName: advisorName.trim(),
roNumber: roNumber.trim() || generateRONumber(),
notes: concerns.trim(),
estimatedDuration: estimatedHours * 60 + estimatedMinutes,
customerType,
serviceLocation: serviceLocation.trim(),
travelFee: Number(travelFee) || 0,
tripMiles: Number(tripMiles) || 0,
});
onClose();
} finally {
setSaving(false);
}
};
if (!isOpen) return null;
return (
<div className="fixed inset-0 z-50 flex items-start justify-center overflow-y-auto bg-black/55 p-4 pt-8 backdrop-blur-sm">
<div className="w-full max-w-3xl overflow-hidden rounded-2xl border border-gray-200 bg-white shadow-2xl dark:border-gray-700 dark:bg-gray-900">
<div className="flex items-start justify-between border-b border-gray-200 bg-gradient-to-r from-amber-50 via-white to-white px-6 py-5 dark:border-gray-700 dark:from-amber-950/30 dark:via-gray-900 dark:to-gray-900">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-amber-600 dark:text-amber-400">
Appointment Check In
</p>
<h2 className="mt-1 text-xl font-semibold text-gray-900 dark:text-white">
Check In &mdash; Create RO
</h2>
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
Convert appointment into an active repair order
</p>
</div>
<button
onClick={onClose}
className="rounded-lg p-2 text-gray-400 transition-colors hover:bg-white/70 hover:text-gray-600 dark:hover:bg-gray-800 dark:hover:text-gray-300"
>
<X className="h-5 w-5" />
</button>
</div>
<div className="space-y-6 bg-gray-50/70 px-6 py-6 dark:bg-gray-950/40">
<div className="grid grid-cols-1 gap-6 xl:grid-cols-[1.15fr_0.85fr]">
<div className="space-y-6">
<section className="rounded-2xl border border-gray-200 bg-white p-5 shadow-sm dark:border-gray-700 dark:bg-gray-900">
<h3 className="mb-4 text-sm font-semibold text-gray-700 dark:text-gray-300">Customer Information</h3>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div className="sm:col-span-2">
<label className="block text-xs font-medium text-gray-500 dark:text-gray-400">Customer Name *</label>
<input value={customerName} onChange={(e) => setCustomerName(e.target.value)} className="mt-1.5 w-full rounded-xl border border-gray-300 bg-white px-3 py-2.5 text-sm shadow-sm transition-colors focus:border-amber-500 focus:outline-none focus:ring-2 focus:ring-amber-500/20 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100" />
</div>
<div>
<label className="block text-xs font-medium text-gray-500 dark:text-gray-400">Phone</label>
<input value={formatPhoneInput(customerPhone)} onChange={(e) => setCustomerPhone(e.target.value.replace(/\D/g, ''))} className="mt-1.5 w-full rounded-xl border border-gray-300 bg-white px-3 py-2.5 text-sm shadow-sm transition-colors focus:border-amber-500 focus:outline-none focus:ring-2 focus:ring-amber-500/20 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100" />
</div>
<div>
<label className="block text-xs font-medium text-gray-500 dark:text-gray-400">Email</label>
<input value={customerEmail} onChange={(e) => setCustomerEmail(e.target.value)} className="mt-1.5 w-full rounded-xl border border-gray-300 bg-white px-3 py-2.5 text-sm shadow-sm transition-colors focus:border-amber-500 focus:outline-none focus:ring-2 focus:ring-amber-500/20 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100" />
</div>
</div>
</section>
<section className="rounded-2xl border border-gray-200 bg-white p-5 shadow-sm dark:border-gray-700 dark:bg-gray-900">
<h3 className="mb-4 text-sm font-semibold text-gray-700 dark:text-gray-300">Vehicle Information</h3>
{appointment?.customerId && (() => {
const c = customers.find((c) => c.id === appointment.customerId);
if (c && c.vehicles.length === 0 && !vehicleInfo) {
return (
<p className="mb-3 rounded-lg border border-amber-200 bg-amber-50 px-3 py-2 text-xs text-amber-700 dark:border-amber-700 dark:bg-amber-900/20 dark:text-amber-400">
This customer has no vehicles on file. Please add vehicle information below to proceed with check-in.
</p>
);
}
return null;
})()}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div className="sm:col-span-2">
<label className="block text-xs font-medium text-gray-500 dark:text-gray-400">Vehicle Info *</label>
<input value={vehicleInfo} onChange={(e) => setVehicleInfo(e.target.value)} placeholder="2020 Honda Civic" className="mt-1.5 w-full rounded-xl border border-gray-300 bg-white px-3 py-2.5 text-sm shadow-sm transition-colors focus:border-amber-500 focus:outline-none focus:ring-2 focus:ring-amber-500/20 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100" />
</div>
<div>
<label className="block text-xs font-medium text-gray-500 dark:text-gray-400">VIN</label>
<input value={vin} onChange={(e) => setVin(e.target.value)} placeholder="1HGBH41JXMN109186" className="mt-1.5 w-full rounded-xl border border-gray-300 bg-white px-3 py-2.5 text-sm shadow-sm transition-colors focus:border-amber-500 focus:outline-none focus:ring-2 focus:ring-amber-500/20 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100" />
</div>
<div>
<label className="block text-xs font-medium text-gray-500 dark:text-gray-400">Mileage / Odometer</label>
<input value={mileage} onChange={(e) => setMileage(e.target.value)} placeholder="45,000" className="mt-1.5 w-full rounded-xl border border-gray-300 bg-white px-3 py-2.5 text-sm shadow-sm transition-colors focus:border-amber-500 focus:outline-none focus:ring-2 focus:ring-amber-500/20 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100" />
</div>
</div>
</section>
</div>
<div className="space-y-6">
{showLocationFields && (
<section className="rounded-2xl border border-gray-200 bg-white p-5 shadow-sm dark:border-gray-700 dark:bg-gray-900">
<h3 className="mb-4 text-sm font-semibold text-gray-700 dark:text-gray-300">Service Location</h3>
<div className="grid grid-cols-1 gap-4">
<div>
<label className="block text-xs font-medium text-gray-500 dark:text-gray-400">Job Address or Location</label>
<input value={serviceLocation} onChange={(e) => setServiceLocation(e.target.value)} placeholder="123 Oak St, Knoxville, TN" className="mt-1.5 w-full rounded-xl border border-gray-300 bg-white px-3 py-2.5 text-sm shadow-sm transition-colors focus:border-amber-500 focus:outline-none focus:ring-2 focus:ring-amber-500/20 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100" />
</div>
<div>
<label className="block text-xs font-medium text-gray-500 dark:text-gray-400">Travel / Diagnostic Fee</label>
<div className="mt-1.5 flex items-center gap-2">
<span className="text-sm text-gray-400">$</span>
<input type="number" min="0" step="0.01" value={travelFee || ''} onChange={(e) => setTravelFee(parseFloat(e.target.value) || 0)} placeholder={(setupSettings.defaultTravelFee || 0).toFixed(2)} className="w-full rounded-xl border border-gray-300 bg-white px-3 py-2.5 text-sm shadow-sm transition-colors focus:border-amber-500 focus:outline-none focus:ring-2 focus:ring-amber-500/20 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100" />
</div>
</div>
<div>
<label className="block text-xs font-medium text-gray-500 dark:text-gray-400">Trip Miles</label>
<div className="mt-1.5 flex items-center gap-2">
<span className="text-sm text-gray-400">mi</span>
<input type="number" min="0" step="0.1" value={tripMiles || ''} onChange={(e) => setTripMiles(parseFloat(e.target.value) || 0)} placeholder="0" className="w-full rounded-xl border border-gray-300 bg-white px-3 py-2.5 text-sm shadow-sm transition-colors focus:border-amber-500 focus:outline-none focus:ring-2 focus:ring-amber-500/20 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100" />
</div>
</div>
</div>
</section>
)}
<section className="rounded-2xl border border-gray-200 bg-white p-5 shadow-sm dark:border-gray-700 dark:bg-gray-900">
<h3 className="mb-4 text-sm font-semibold text-gray-700 dark:text-gray-300">Service Details</h3>
<div className="grid grid-cols-1 gap-4">
<div>
<label className="block text-xs font-medium text-gray-500 dark:text-gray-400">{roleLabel}</label>
{(() => {
const defaultAdv = loadSettings().serviceAdvisor;
const isDefault = defaultAdv && advisorName === defaultAdv;
return (
<input
value={advisorName}
onChange={(e) => setAdvisorName(e.target.value)}
placeholder={defaultAdv || `${roleLabel} name`}
className={`mt-1.5 w-full rounded-xl border px-3 py-2.5 text-sm shadow-sm transition-colors focus:outline-none focus:ring-2 focus:ring-amber-500/20 ${
isDefault
? 'border-gray-200 bg-gray-50 text-gray-400 focus:border-amber-500 dark:border-gray-700 dark:bg-gray-800/50 dark:text-gray-500'
: 'border-gray-300 bg-white text-gray-900 focus:border-amber-500 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100'
}`}
/>
);
})()}
</div>
<div>
<label className="block text-xs font-medium text-gray-500 dark:text-gray-400">RO Number</label>
<input value={roNumber} onChange={(e) => setRoNumber(e.target.value)} placeholder="RO-250629-0001" className="mt-1.5 w-full rounded-xl border border-gray-300 bg-white px-3 py-2.5 text-sm font-mono shadow-sm transition-colors focus:border-amber-500 focus:outline-none focus:ring-2 focus:ring-amber-500/20 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100" />
</div>
<div>
<label className="block text-xs font-medium text-gray-500 dark:text-gray-400">Estimated Duration</label>
<div className="mt-1.5 grid grid-cols-2 gap-3">
<div>
<input type="number" min="0" value={estimatedHours} onChange={(e) => setEstimatedHours(parseInt(e.target.value) || 0)} className="w-full rounded-xl border border-gray-300 bg-white px-3 py-2.5 text-sm shadow-sm transition-colors focus:border-amber-500 focus:outline-none focus:ring-2 focus:ring-amber-500/20 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100" />
<p className="mt-1 text-xs text-gray-400">Hours</p>
</div>
<div>
<input type="number" min="0" max="59" step="5" value={estimatedMinutes} onChange={(e) => setEstimatedMinutes(Math.min(parseInt(e.target.value) || 0, 59))} className="w-full rounded-xl border border-gray-300 bg-white px-3 py-2.5 text-sm shadow-sm transition-colors focus:border-amber-500 focus:outline-none focus:ring-2 focus:ring-amber-500/20 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100" />
<p className="mt-1 text-xs text-gray-400">Minutes</p>
</div>
</div>
</div>
<div>
<label className="block text-xs font-medium text-gray-500 dark:text-gray-400">{customerTypeLabels.groupLabel}</label>
<div className="mt-1.5 grid grid-cols-1 gap-3 sm:grid-cols-2">
<label className="flex cursor-pointer items-center gap-2 rounded-xl border border-gray-300 bg-white px-4 py-3 text-sm shadow-sm transition-colors hover:bg-gray-50 dark:border-gray-600 dark:bg-gray-800 dark:hover:bg-gray-700">
<input type="radio" name="ci-customerType" value="drop-off" checked={customerType === 'drop-off'} onChange={() => setCustomerType('drop-off')} className="text-amber-600 focus:ring-amber-500" />
<UserX className="h-4 w-4 text-gray-400" />
{customerTypeLabels.primary}
</label>
<label className="flex cursor-pointer items-center gap-2 rounded-xl border border-gray-300 bg-white px-4 py-3 text-sm shadow-sm transition-colors hover:bg-gray-50 dark:border-gray-600 dark:bg-gray-800 dark:hover:bg-gray-700">
<input type="radio" name="ci-customerType" value="waiter" checked={customerType === 'waiter'} onChange={() => setCustomerType('waiter')} className="text-amber-600 focus:ring-amber-500" />
<UserCheck className="h-4 w-4 text-amber-500" />
{customerTypeLabels.secondary}
</label>
</div>
</div>
</div>
</section>
<section className="rounded-2xl border border-gray-200 bg-white p-5 shadow-sm dark:border-gray-700 dark:bg-gray-900">
<label className="block text-sm font-semibold text-gray-700 dark:text-gray-300">Customer Concerns / Notes</label>
<textarea value={concerns} onChange={(e) => setConcerns(e.target.value)} rows={7} placeholder="Describe the issue or reason for service..." className="mt-3 w-full rounded-xl border border-gray-300 bg-white px-3 py-2.5 text-sm shadow-sm transition-colors focus:border-amber-500 focus:outline-none focus:ring-2 focus:ring-amber-500/20 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100" />
{appointment?.serviceType && <p className="mt-2 text-xs text-gray-400">Service type: {appointment.serviceType}</p>}
</section>
</div>
</div>
</div>
<div className="flex items-center justify-between border-t border-gray-200 bg-white px-6 py-4 dark:border-gray-700 dark:bg-gray-900">
<p className="text-sm text-gray-500 dark:text-gray-400">
Review the appointment details before creating the repair order.
</p>
<div className="flex items-center gap-3">
<button
onClick={onClose}
className="rounded-xl border border-gray-300 bg-white px-4 py-2.5 text-sm font-medium text-gray-700 transition-colors hover:bg-gray-50 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700"
>
Cancel
</button>
<button
onClick={handleSubmit}
disabled={saving || !customerName.trim() || !vehicleInfo.trim()}
className="inline-flex items-center gap-2 rounded-xl bg-gradient-to-r from-amber-600 to-yellow-500 px-5 py-2.5 text-sm font-medium text-white shadow-sm transition-all hover:from-amber-700 hover:to-yellow-600 disabled:cursor-not-allowed disabled:opacity-50"
>
<LogIn className="h-4 w-4" />
{saving ? 'Checking In...' : 'Check In & Create RO'}
</button>
</div>
</div>
</div>
</div>
);
}
export const CheckInModal = memo(CheckInModalImpl);
+98
View File
@@ -0,0 +1,98 @@
import { memo, useState } from 'react';
import { ChevronDown, ChevronRight, CalendarDays } from 'lucide-react';
import type { Appointment } from '../../types';
import { todayISO, formatDateDisplay } from '../../lib/appointments';
import { AppointmentRow } from './AppointmentRow';
interface Props {
date: string;
appointments: Appointment[];
onEdit: (appt: Appointment) => void;
onDelete: (appt: Appointment) => void;
onStatusChange: (appt: Appointment, status: Appointment['status']) => void;
onCheckIn: (appt: Appointment) => void;
defaultExpanded: boolean;
}
function DateGroupImpl({
date,
appointments,
onEdit,
onDelete,
onStatusChange,
onCheckIn,
defaultExpanded,
}: Props) {
const [expanded, setExpanded] = useState(defaultExpanded);
const isToday = date === todayISO();
const dateLabel = isToday
? `Today \u2014 ${formatDateDisplay(date)}`
: formatDateDisplay(date);
const checkedInCount = appointments.filter((appt) => appt.status === 'checked_in').length;
const completedCount = appointments.filter((appt) => appt.status === 'completed').length;
const noShowCount = appointments.filter((appt) => appt.status === 'no_show').length;
return (
<div className="overflow-hidden rounded-2xl border border-gray-200 bg-white shadow-sm shadow-gray-200/60 dark:border-gray-700 dark:bg-gray-800/60 dark:shadow-black/10">
<button
onClick={() => setExpanded((p) => !p)}
className="flex w-full items-center justify-between bg-gradient-to-r from-white via-emerald-50/60 to-white px-5 py-4 text-left transition-colors hover:from-emerald-50 hover:via-emerald-50 hover:to-white dark:from-gray-800 dark:via-emerald-950/20 dark:to-gray-800 dark:hover:via-emerald-950/30"
>
<div className="flex items-center gap-3">
{expanded ? (
<ChevronDown className="h-4 w-4 text-gray-400" />
) : (
<ChevronRight className="h-4 w-4 text-gray-400" />
)}
<CalendarDays className="h-4 w-4 text-emerald-500 dark:text-emerald-400" />
<span className="text-sm font-semibold tracking-tight text-gray-900 dark:text-white">
{dateLabel}
</span>
</div>
<span className="rounded-full border border-emerald-200 bg-white/80 px-2.5 py-1 text-xs font-medium text-emerald-700 shadow-sm dark:border-emerald-900/50 dark:bg-gray-800 dark:text-emerald-300">
{appointments.length} {appointments.length === 1 ? 'appointment' : 'appointments'}
</span>
</button>
{expanded && (
<div className="border-t border-gray-200 bg-white px-5 py-3 dark:border-gray-700 dark:bg-gray-800/80">
<div className="flex flex-wrap gap-2 text-xs font-medium">
<span className="rounded-full border border-gray-200 bg-gray-50 px-2.5 py-1 text-gray-600 dark:border-gray-700 dark:bg-gray-900/60 dark:text-gray-300">
Total: {appointments.length}
</span>
{checkedInCount > 0 && (
<span className="rounded-full border border-yellow-200 bg-yellow-50 px-2.5 py-1 text-yellow-700 dark:border-yellow-900/40 dark:bg-yellow-900/20 dark:text-yellow-300">
{checkedInCount} checked in
</span>
)}
{completedCount > 0 && (
<span className="rounded-full border border-green-200 bg-green-50 px-2.5 py-1 text-green-700 dark:border-green-900/40 dark:bg-green-900/20 dark:text-green-300">
{completedCount} completed
</span>
)}
{noShowCount > 0 && (
<span className="rounded-full border border-gray-200 bg-gray-50 px-2.5 py-1 text-gray-600 dark:border-gray-700 dark:bg-gray-900/60 dark:text-gray-300">
{noShowCount} no show
</span>
)}
</div>
<div className="mt-4 space-y-3">
{appointments.map((appt) => (
<AppointmentRow
key={appt.id}
appointment={appt}
onEdit={onEdit}
onDelete={onDelete}
onStatusChange={onStatusChange}
onCheckIn={onCheckIn}
/>
))}
</div>
</div>
)}
</div>
);
}
export const DateGroup = memo(DateGroupImpl);
@@ -0,0 +1,104 @@
import { memo, useState } from 'react';
import { AlertTriangle, Trash2 } from 'lucide-react';
import type { Appointment } from '../../types';
import { formatDateDisplay, formatTimeShort } from '../../lib/appointments';
interface Props {
isOpen: boolean;
onClose: () => void;
onConfirm: () => Promise<void>;
appointment: Appointment | null;
}
function DeleteDialogImpl({
isOpen,
onClose,
onConfirm,
appointment,
}: Props) {
const [deleting, setDeleting] = useState(false);
if (!isOpen || !appointment) return null;
const handleDelete = async () => {
setDeleting(true);
try {
await onConfirm();
onClose();
} catch {
} finally {
setDeleting(false);
}
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
<div className="absolute inset-0 bg-black/50 backdrop-blur-sm" onClick={onClose} />
<div className="relative z-10 w-full max-w-md overflow-hidden rounded-2xl border border-red-200 bg-white shadow-2xl dark:border-red-900/40 dark:bg-gray-900">
<div className="border-b border-red-100 bg-gradient-to-r from-red-50 via-white to-white px-6 py-5 dark:border-red-900/30 dark:from-red-950/20 dark:via-gray-900 dark:to-gray-900">
<div className="flex items-start gap-4">
<div className="flex h-11 w-11 items-center justify-center rounded-2xl bg-red-100 dark:bg-red-900/30">
<AlertTriangle className="h-5 w-5 text-red-600 dark:text-red-400" />
</div>
<div>
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-red-600 dark:text-red-400">Destructive Action</p>
<h3 className="mt-1 text-lg font-semibold tracking-tight text-gray-900 dark:text-white">Delete Appointment</h3>
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
Remove this appointment record from the schedule. This action cannot be undone.
</p>
</div>
</div>
</div>
<div className="px-6 py-5">
<div className="rounded-2xl border border-gray-200 bg-gray-50/80 p-4 dark:border-gray-700 dark:bg-gray-800/50">
<p className="text-xs font-medium uppercase tracking-[0.14em] text-gray-400 dark:text-gray-500">Appointment</p>
<p className="mt-1 text-base font-semibold text-gray-900 dark:text-white">
{appointment.customerName || 'Unknown Customer'}
</p>
<div className="mt-2 flex flex-wrap gap-2 text-sm text-gray-500 dark:text-gray-400">
{appointment.date && <span>{formatDateDisplay(appointment.date)}</span>}
{appointment.time && <span>{formatTimeShort(appointment.time)}</span>}
{appointment.serviceType && <span>{appointment.serviceType}</span>}
</div>
</div>
<div className="mt-5 flex items-center justify-between gap-3 border-t border-gray-200 pt-4 dark:border-gray-700">
<p className="text-sm text-gray-500 dark:text-gray-400">This permanently removes the appointment from your schedule.</p>
<div className="flex items-center gap-3">
<button
onClick={onClose}
disabled={deleting}
className="rounded-xl border border-gray-300 px-4 py-2.5 text-sm font-medium text-gray-700 transition-colors hover:bg-gray-50 dark:border-gray-600 dark:text-gray-300 dark:hover:bg-gray-700"
>
Cancel
</button>
<button
onClick={handleDelete}
disabled={deleting}
className="inline-flex items-center gap-2 rounded-xl bg-gradient-to-r from-red-600 to-red-500 px-4 py-2.5 text-sm font-medium text-white shadow-sm transition-colors hover:from-red-700 hover:to-red-600 focus:outline-none focus:ring-2 focus:ring-red-500 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 dark:focus:ring-offset-gray-800"
>
{deleting ? (
<>
<svg className="h-4 w-4 animate-spin" viewBox="0 0 24 24" fill="none">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z" />
</svg>
Deleting...
</>
) : (
<>
<Trash2 className="h-4 w-4" />
Delete
</>
)}
</button>
</div>
</div>
</div>
</div>
</div>
);
}
export const DeleteDialog = memo(DeleteDialogImpl);
+121
View File
@@ -0,0 +1,121 @@
import { CalendarDays, Calendar, AlertTriangle, RefreshCw, Plus } from 'lucide-react';
function formatDateDisplay(iso: string): string {
if (!iso) return '\u2014';
const d = new Date(iso + (iso.includes('T') ? '' : 'T00:00:00'));
return new Intl.DateTimeFormat('en-US', {
weekday: 'long',
month: 'long',
day: 'numeric',
year: 'numeric',
}).format(d);
}
export function EmptyDayState({ date, onCreate }: { date: string; onCreate: () => void }) {
return (
<div className="rounded-xl border border-dashed border-gray-300 bg-white/50 p-8 text-center dark:border-gray-600 dark:bg-gray-800/30">
<CalendarDays className="mx-auto mb-3 h-8 w-8 text-gray-300 dark:text-gray-600" />
<p className="text-sm text-gray-500 dark:text-gray-400">
No appointments are scheduled for{' '}
<span className="font-medium text-gray-700 dark:text-gray-300">{formatDateDisplay(date)}</span>
</p>
<button
onClick={onCreate}
className="mt-3 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 shadow-sm transition-colors hover:bg-gray-50 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700"
>
<Plus className="h-3.5 w-3.5" />
Add Appointment
</button>
</div>
);
}
export function EmptyState({ onCreate }: { onCreate: () => void }) {
return (
<div className="overflow-hidden rounded-3xl border border-gray-200 bg-white shadow-sm shadow-gray-200/60 dark:border-gray-700 dark:bg-gray-900 dark:shadow-black/10">
<div className="bg-gradient-to-r from-emerald-50 via-white to-purple-50 px-8 py-10 text-center dark:from-emerald-950/20 dark:via-gray-900 dark:to-purple-950/20">
<div className="mx-auto mb-6 flex h-24 w-24 items-center justify-center rounded-2xl bg-white shadow-sm dark:bg-gray-800">
<Calendar className="h-12 w-12 text-emerald-500 dark:text-emerald-400" />
</div>
<h3 className="mb-2 text-xl font-semibold tracking-tight text-gray-900 dark:text-white">
No appointments scheduled
</h3>
<p className="mx-auto mb-8 max-w-sm text-sm text-gray-500 dark:text-gray-400">
Create appointments to manage upcoming visits, prepare vehicle write-ups, and keep the day organized before work begins.
</p>
<button
onClick={onCreate}
className="inline-flex items-center gap-2 rounded-xl bg-gradient-to-r from-emerald-600 to-emerald-500 px-5 py-2.5 text-sm font-medium text-white shadow-sm transition-all hover:from-emerald-700 hover:to-emerald-600 focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:ring-offset-2 dark:focus:ring-offset-gray-900"
>
<Plus className="h-4 w-4" />
Create Appointment
</button>
</div>
</div>
);
}
export function ErrorState({ message, onRetry }: { message: string; onRetry: () => void }) {
return (
<div className="overflow-hidden rounded-3xl border border-red-200 bg-white shadow-sm shadow-red-100/60 dark:border-red-900/50 dark:bg-gray-900 dark:shadow-black/10">
<div className="px-8 py-10 text-center">
<div className="mx-auto mb-6 flex h-24 w-24 items-center justify-center rounded-2xl bg-red-50 dark:bg-red-900/20">
<AlertTriangle className="h-12 w-12 text-red-500 dark:text-red-400" />
</div>
<h3 className="mb-2 text-xl font-semibold tracking-tight text-gray-900 dark:text-white">
Unable to load appointments
</h3>
<p className="mx-auto mb-8 max-w-sm text-sm text-gray-500 dark:text-gray-400">{message}</p>
<button
onClick={onRetry}
className="inline-flex items-center gap-2 rounded-xl border border-gray-300 bg-white px-5 py-2.5 text-sm font-medium text-gray-700 shadow-sm transition-colors hover:bg-gray-50 focus:outline-none focus:ring-2 focus:ring-emerald-500 focus:ring-offset-2 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-300 dark:hover:bg-gray-700 dark:focus:ring-offset-gray-900"
>
<RefreshCw className="h-4 w-4" />
Retry
</button>
</div>
</div>
);
}
export function AppointmentsSkeleton() {
return (
<div className="animate-pulse space-y-4">
{Array.from({ length: 3 }).map((_, i) => (
<div
key={i}
className="rounded-xl border border-gray-200 bg-white dark:border-gray-700 dark:bg-gray-800"
>
<div className="flex items-center justify-between px-5 py-3">
<div className="flex items-center gap-3">
<div className="h-4 w-4 rounded bg-gray-200 dark:bg-gray-700" />
<div className="h-4 w-48 rounded bg-gray-200 dark:bg-gray-700" />
</div>
<div className="h-5 w-20 rounded-full bg-gray-200 dark:bg-gray-700" />
</div>
<div className="border-t border-gray-200 px-5 py-4 dark:border-gray-700">
<div className="space-y-3">
{Array.from({ length: 2 }).map((_, j) => (
<div
key={j}
className="rounded-lg border border-gray-200 p-4 dark:border-gray-700"
>
<div className="mb-3 flex items-center gap-2">
<div className="h-4 w-4 rounded bg-gray-200 dark:bg-gray-700" />
<div className="h-4 w-24 rounded bg-gray-200 dark:bg-gray-700" />
<div className="h-5 w-20 rounded-full bg-gray-200 dark:bg-gray-700" />
</div>
<div className="h-5 w-40 rounded bg-gray-200 dark:bg-gray-700" />
<div className="mt-2 flex gap-4">
<div className="h-4 w-32 rounded bg-gray-200 dark:bg-gray-700" />
<div className="h-4 w-24 rounded bg-gray-200 dark:bg-gray-700" />
</div>
</div>
))}
</div>
</div>
</div>
))}
</div>
);
}
@@ -0,0 +1,655 @@
import { memo, useEffect, useRef, useState } from 'react';
import { Sparkles, Upload, X, Check } from 'lucide-react';
import { pb } from '../../lib/pocketbase';
import { formatPhoneDisplay, formatPhoneInput } from '../../lib/phone';
import { formatDateDisplay, normalizeTimeToHHMM, todayISO } from '../../lib/appointments';
import { logError } from '../../lib/userMessages';
import { useToast } from '../ui/Toast';
import type { CustomerWithVehicles } from '../../pages/Customers';
export interface ScannedAppt {
appointmentDate: string;
appointmentTime: string;
durationMinutes: number;
customerName: string;
customerPhone: string;
vin: string;
vehicleInfo: string;
serviceType: string;
matchedCustomerId?: string;
matchedCustomerName?: string;
}
type ScanStep = 'idle' | 'uploading' | 'ocr' | 'ai_extracting' | 'review' | 'importing';
interface Props {
isOpen: boolean;
onClose: () => void;
onImport: (appts: ScannedAppt[]) => Promise<void>;
customers: CustomerWithVehicles[];
}
function ScanScreenshotModalImpl({
isOpen,
onClose,
onImport,
customers,
}: Props) {
const { showToast } = useToast();
const [step, setStep] = useState<ScanStep>('idle');
const [progress, setProgress] = useState<string>('');
const [progressPct, setProgressPct] = useState(0);
const [imageFile, setImageFile] = useState<File | null>(null);
const [imagePreview, setImagePreview] = useState<string | null>(null);
const [editAppointments, setEditAppointments] = useState<ScannedAppt[]>([]);
const [selectedDate, setSelectedDate] = useState(todayISO());
const selectedDateRef = useRef(selectedDate);
selectedDateRef.current = selectedDate;
const [error, setError] = useState<string | null>(null);
const dropRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (isOpen) {
setStep('idle');
setProgress('');
setProgressPct(0);
setImageFile(null);
setImagePreview(null);
setEditAppointments([]);
setSelectedDate(todayISO());
setError(null);
}
}, [isOpen]);
const handleFile = (file: File | null) => {
if (!file) return;
if (!file.type.startsWith('image/')) {
setError('Please upload an image file (PNG, JPG, etc.)');
return;
}
setError(null);
setImageFile(file);
const reader = new FileReader();
reader.onload = (e) => setImagePreview(e.target!.result as string);
reader.readAsDataURL(file);
};
const preprocessImage = (file: File): Promise<Blob> => {
return new Promise((resolve, reject) => {
const img = new Image();
img.onload = () => {
const canvas = document.createElement('canvas');
const scale = 2.5;
canvas.width = Math.round(img.width * scale);
canvas.height = Math.round(img.height * scale);
const ctx = canvas.getContext('2d')!;
ctx.drawImage(img, 0, 0, canvas.width, canvas.height);
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
const data = imageData.data;
const contrast = 1.15;
const factor = (259 * (contrast * 255 + 255)) / (255 * (259 - contrast * 255));
for (let i = 0; i < data.length; i += 4) {
data[i] = Math.min(255, Math.max(0, factor * (data[i] - 128) + 128));
data[i + 1] = Math.min(255, Math.max(0, factor * (data[i + 1] - 128) + 128));
data[i + 2] = Math.min(255, Math.max(0, factor * (data[i + 2] - 128) + 128));
}
ctx.putImageData(imageData, 0, 0);
canvas.toBlob((blob) => {
if (blob) resolve(blob);
else reject(new Error('Failed to process image'));
}, 'image/png', 0.95);
};
img.onerror = () => reject(new Error('Failed to load image'));
img.src = URL.createObjectURL(file);
});
};
const handleExtract = async () => {
if (!imageFile) return;
setError(null);
try {
setStep('ocr');
setProgress('Initializing OCR engine...');
setProgress('Preprocessing image...');
const processedBlob = await preprocessImage(imageFile);
const { default: Tesseract } = await import('tesseract.js');
setProgress('Running OCR...');
const result = await Tesseract.recognize(processedBlob, 'eng', {
logger: (m: any) => {
if (m.status === 'recognizing text') {
const pct = Math.round((m.progress || 0) * 100);
setProgressPct(pct);
setProgress(`OCR: ${pct}%`);
}
},
});
const ocrText = result.data.text;
if (!ocrText.trim()) {
setError('No text could be extracted from the image. Try a clearer screenshot.');
setStep('idle');
return;
}
setStep('ai_extracting');
setProgress('Extracting with AI...');
setProgressPct(0);
const aiResponse = await fetch('/deepseek/v1/chat/completions', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
model: 'deepseek-v4-flash',
messages: [
{
role: 'system',
content: `You are a high-precision appointment extraction engine for an automotive repair shop.
Return ONLY valid JSON.
Do not use markdown.
Do not wrap the JSON in code fences.
Do not add commentary outside the JSON.
RULES:
- The appointment date is supplied by the application. Do NOT extract, infer, or return appointment dates from OCR text.
- Extract every real appointment visible in the OCR text.
- Do not invent appointments.
- Do not duplicate the same appointment.
- If a field is missing or uncertain, use an empty string for text fields.
- If duration is missing, use 60.
- Keep one object per appointment.
FIELD RULES:
- appointmentTime: convert to 24-hour HH:MM format. Fix obvious OCR time damage when clear.
- durationMinutes: convert hours to minutes. Examples: "0.5 hrs"=30, "1.2 hrs"=72, "1.5 hr"=90.
- customerName: extract the person's full name only.
- customerPhone: return only the best 10-digit phone number.
- vin: extract a 17-character VIN only when reasonably recoverable. Fix obvious OCR substitutions such as O->0, Q->0, I->1, L->1, S->5, B->8.
- vehicleInfo: format as "YEAR MAKE MODEL" when possible.
- serviceType: extract the appointment purpose or requested work.
QUALITY RULES:
- Prefer accuracy over completeness.
- Use empty strings instead of guessing.
- Output must be parseable JSON matching the schema exactly.
JSON SCHEMA:
{"appointments":[{"appointmentTime":"HH:MM","durationMinutes":60,"customerName":"","customerPhone":"","vin":"","vehicleInfo":"","serviceType":""}]}`,
},
{
role: 'user',
content: `Extract all appointments from this OCR text. Fix obvious OCR-garbled times like "ESCAM" to "8:00AM" when clear:
${ocrText}`,
},
],
temperature: 0,
thinking: { type: 'disabled' },
max_tokens: 2000,
}),
});
if (!aiResponse.ok) {
const errText = await aiResponse.text();
throw new Error(`AI extraction failed: ${aiResponse.status} ${errText}`);
}
const aiData = await aiResponse.json();
const content = aiData.choices?.[0]?.message?.content || '';
let extracted: ScannedAppt[] = [];
try {
const parsed = JSON.parse(content);
extracted = parsed.appointments || parsed;
} catch {
const jsonMatch = content.match(/`(?:json)?\s*([\s\S]*?)`/);
if (jsonMatch) {
const parsed = JSON.parse(jsonMatch[1]);
extracted = parsed.appointments || parsed;
} else {
const arrayMatch = content.match(/\[\s*\{.*\}\s*\]/s);
const objMatch = content.match(/\{\s*"appointments"\s*:.*\}/s);
if (objMatch) {
const parsed = JSON.parse(objMatch[0]);
extracted = parsed.appointments || parsed;
} else if (arrayMatch) {
extracted = JSON.parse(arrayMatch[0]);
} else {
throw new Error('Could not parse AI response into appointments');
}
}
}
if (!Array.isArray(extracted) || extracted.length === 0) {
throw new Error('No appointments were detected in the screenshot');
}
extracted = extracted.map((a) => ({
appointmentDate: selectedDateRef.current,
appointmentTime: normalizeTimeToHHMM(a.appointmentTime || ''),
durationMinutes: Number(a.durationMinutes) || 60,
customerName: a.customerName || '',
customerPhone: a.customerPhone || '',
vin: (a.vin || '').toUpperCase(),
vehicleInfo: a.vehicleInfo || '',
serviceType: a.serviceType || '',
}));
const matched = extracted.map((a) => {
const phoneDigits = (a.customerPhone || '').replace(/\D/g, '');
let match: CustomerWithVehicles | undefined;
if (phoneDigits.length >= 7) {
match = customers.find((c) => (c.phone || '').replace(/\D/g, '') === phoneDigits);
}
if (!match && a.customerName.trim()) {
const nameLower = a.customerName.trim().toLowerCase();
match = customers.find((c) => c.name.toLowerCase() === nameLower);
}
return {
...a,
matchedCustomerId: match?.id,
matchedCustomerName: match?.name,
} as ScannedAppt;
});
setEditAppointments(JSON.parse(JSON.stringify(matched)));
setStep('review');
setProgress('');
} catch (err: unknown) {
logError('Failed to extract appointment data', err);
setError('Could not read that screenshot. Please try a clearer image.');
setStep('idle');
}
};
const updateEditAppt = (index: number, field: keyof ScannedAppt, value: string | number) => {
setEditAppointments((prev) => {
const updated = [...prev];
updated[index] = { ...updated[index], [field]: value };
return updated;
});
};
const handleImportAll = async () => {
setStep('importing');
setProgress('Importing appointments...');
setError(null);
try {
await onImport(editAppointments);
showToast(`Successfully imported ${editAppointments.length} appointment(s)`, 'success');
onClose();
} catch (err: unknown) {
logError('Failed to import appointments', err);
setError('Appointments could not be imported. Please try again.');
setStep('review');
}
};
const handleDrop = (e: React.DragEvent) => {
e.preventDefault();
e.stopPropagation();
const file = e.dataTransfer.files?.[0] || null;
handleFile(file);
};
if (!isOpen) return null;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
<div className="absolute inset-0 bg-black/50 backdrop-blur-sm" onClick={onClose} />
<div className="relative z-10 flex max-h-[90vh] w-full max-w-3xl flex-col overflow-hidden rounded-2xl border border-gray-200 bg-white shadow-2xl dark:border-gray-700 dark:bg-gray-900">
<div className="flex items-start justify-between border-b border-gray-200 bg-gradient-to-r from-purple-50 via-white to-white px-6 py-5 dark:border-gray-700 dark:from-purple-950/20 dark:via-gray-900 dark:to-gray-900">
<div className="flex items-start gap-3">
<div className="mt-0.5 rounded-xl bg-purple-100 p-2.5 dark:bg-purple-900/30">
<Sparkles className="h-5 w-5 text-purple-500" />
</div>
<div>
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-purple-600 dark:text-purple-400">AI Import</p>
<h2 className="mt-1 text-xl font-semibold tracking-tight text-gray-900 dark:text-white">Scan Screenshot</h2>
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">Upload a schedule screenshot, review the extracted appointments, and import them cleanly.</p>
</div>
</div>
<button
onClick={onClose}
disabled={step === 'importing'}
className="rounded-lg p-1.5 text-gray-400 transition-colors hover:bg-gray-100 hover:text-gray-600 disabled:opacity-50 dark:hover:bg-gray-700 dark:hover:text-gray-300"
>
<X className="h-5 w-5" />
</button>
</div>
<div className="flex-1 overflow-y-auto bg-gray-50/70 px-6 py-5 dark:bg-gray-950/40">
{error && (
<div className="mb-4 rounded-lg border border-red-200 bg-red-50 p-3 text-sm text-red-700 dark:border-red-800 dark:bg-red-900/20 dark:text-red-400">
{error}
</div>
)}
{(step === 'idle' || step === 'uploading') && (
<div className="space-y-4">
<div>
<label className="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">
Appointment Date <span className="text-gray-400">(all appointments in screenshot)</span>
</label>
<input
type="date"
value={selectedDate}
onChange={(e) => {
const nextDate = e.target.value;
setSelectedDate(nextDate);
setEditAppointments((prev) => prev.map((appt) => ({ ...appt, appointmentDate: nextDate })));
}}
className="w-full rounded-lg border border-gray-300 px-3 py-2 text-sm text-gray-900 transition-colors focus:border-purple-500 focus:outline-none focus:ring-2 focus:ring-purple-500/20 dark:border-gray-600 dark:bg-gray-700 dark:text-white dark:focus:border-purple-400"
/>
</div>
<div
ref={dropRef}
onDragOver={(e) => { e.preventDefault(); e.stopPropagation(); }}
onDrop={handleDrop}
onClick={() => {
const input = document.createElement('input');
input.type = 'file';
input.accept = 'image/*';
input.onchange = (e) => {
const file = (e.target as HTMLInputElement).files?.[0] || null;
handleFile(file);
};
input.click();
}}
className={`flex cursor-pointer flex-col items-center justify-center rounded-xl border-2 border-dashed p-8 transition-colors ${
imagePreview
? 'border-purple-400 bg-purple-50/50 dark:border-purple-600 dark:bg-purple-900/10'
: 'border-gray-300 bg-gray-50 hover:border-purple-400 hover:bg-purple-50/50 dark:border-gray-600 dark:bg-gray-700/50 dark:hover:border-purple-500 dark:hover:bg-purple-900/10'
}`}
>
{imagePreview ? (
<div className="w-full space-y-3">
<img
src={imagePreview}
alt="Screenshot preview"
className="max-h-64 w-full rounded-lg object-contain shadow-sm"
/>
<p className="text-center text-xs text-gray-500 dark:text-gray-400">
Click or drop another image to replace
</p>
</div>
) : (
<>
<Upload className="mb-3 h-10 w-10 text-gray-400" />
<p className="text-sm font-medium text-gray-700 dark:text-gray-300">
Drop a screenshot here, or click to browse
</p>
<p className="mt-1 text-xs text-gray-400 dark:text-gray-500">
Supported: PNG, JPG, WEBP
</p>
</>
)}
</div>
</div>
)}
{step === 'ocr' && (
<div className="flex flex-col items-center justify-center py-12">
<div className="mb-4">
<svg className="h-12 w-12 animate-spin text-purple-500" viewBox="0 0 24 24" fill="none">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z" />
</svg>
</div>
<p className="text-sm font-medium text-gray-700 dark:text-gray-300">{progress}</p>
<div className="mt-4 h-2 w-64 overflow-hidden rounded-full bg-gray-200 dark:bg-gray-700">
<div
className="h-full rounded-full bg-purple-500 transition-all duration-300"
style={{ width: `${progressPct}%` }}
/>
</div>
<p className="mt-2 text-xs text-gray-400">{progressPct}%</p>
</div>
)}
{step === 'ai_extracting' && (
<div className="flex flex-col items-center justify-center py-12">
<div className="mb-4">
<Sparkles className="h-12 w-12 animate-pulse text-purple-500" />
</div>
<p className="text-sm font-medium text-gray-700 dark:text-gray-300">{progress}</p>
<div className="mt-4 h-2 w-64 overflow-hidden rounded-full bg-gray-200 dark:bg-gray-700">
<div className="h-full w-full animate-pulse rounded-full bg-purple-500" />
</div>
</div>
)}
{step === 'review' && (
<div className="space-y-5">
<div className="rounded-2xl border border-purple-100 bg-white p-4 shadow-sm dark:border-purple-900/40 dark:bg-gray-900">
<div className="flex flex-col gap-4 lg:flex-row lg:items-end lg:justify-between">
<div>
<p className="text-xs font-semibold uppercase tracking-[0.16em] text-purple-600 dark:text-purple-400">Review Extracted Results</p>
<p className="mt-1 text-sm text-gray-500 dark:text-gray-400">
Found {editAppointments.length} appointment{editAppointments.length !== 1 ? 's' : ''}. Review and edit before importing.
</p>
</div>
<div className="w-full lg:max-w-xs">
<label className="mb-1 block text-sm font-medium text-gray-700 dark:text-gray-300">
Appointment Date <span className="text-gray-400">(applies to all imported appointments)</span>
</label>
<input
type="date"
value={selectedDate}
onChange={(e) => {
const nextDate = e.target.value;
setSelectedDate(nextDate);
setEditAppointments((prev) => prev.map((appt) => ({ ...appt, appointmentDate: nextDate })));
}}
className="w-full rounded-xl border border-gray-300 px-3 py-2.5 text-sm text-gray-900 shadow-sm transition-colors focus:border-purple-500 focus:outline-none focus:ring-2 focus:ring-purple-500/20 dark:border-gray-600 dark:bg-gray-800 dark:text-white dark:focus:border-purple-400"
/>
</div>
</div>
</div>
{editAppointments.map((appt, idx) => (
<div
key={idx}
className="rounded-2xl border border-gray-200 bg-white p-5 shadow-sm dark:border-gray-700 dark:bg-gray-900"
>
<div className="mb-4 flex items-center justify-between">
<span className="rounded-full bg-purple-50 px-2.5 py-1 text-xs font-semibold uppercase tracking-[0.14em] text-purple-700 dark:bg-purple-900/30 dark:text-purple-300">
Appointment #{idx + 1}
</span>
<span className="text-xs text-gray-400 dark:text-gray-500">Import date: {formatDateDisplay(appt.appointmentDate)}</span>
</div>
{appt.customerName && (
<div className="mb-3 flex items-center justify-between gap-2 rounded-lg border border-gray-100 bg-gray-50 px-3 py-2 dark:border-gray-700 dark:bg-gray-800/50">
<div className="flex items-center gap-2 text-xs">
{appt.matchedCustomerId ? (
<span className="inline-flex items-center gap-1 rounded-full bg-green-100 px-2 py-0.5 font-medium text-green-700 dark:bg-green-900/30 dark:text-green-400">
<Check className="h-3 w-3" /> Matched: {appt.matchedCustomerName || 'Customer'}
</span>
) : (
<span className="inline-flex items-center gap-1 rounded-full bg-amber-100 px-2 py-0.5 font-medium text-amber-700 dark:bg-amber-900/30 dark:text-amber-400">
Not in database
</span>
)}
{appt.customerPhone && <span className="text-gray-400">{formatPhoneDisplay(appt.customerPhone)}</span>}
</div>
{!appt.matchedCustomerId && (
<button
type="button"
onClick={async () => {
const uid = pb.authStore.model?.id;
if (!uid) return;
const record = await pb.collection('customers').create({
name: appt.customerName.trim(),
phone: appt.customerPhone || '',
email: '',
address: '',
notes: '',
userId: uid,
});
if (appt.vin || appt.vehicleInfo) {
const parts = (appt.vehicleInfo || '').trim().match(/^(\d{4})\s+(.+)$/);
let year = '', make = '', model = '';
if (parts) {
year = parts[1];
const rest = parts[2].trim();
const spaceIdx = rest.indexOf(' ');
if (spaceIdx > 0) { make = rest.slice(0, spaceIdx); model = rest.slice(spaceIdx + 1); }
else { make = rest; }
} else {
make = appt.vehicleInfo || '';
}
try {
await pb.collection('vehicles').create({
userId: uid,
customerId: record.id,
make, model, year,
vin: appt.vin || '',
licensePlate: '', mileage: '', color: '', engine: '',
});
} catch {}
}
setEditAppointments((prev) => prev.map((a, i) =>
i === idx ? { ...a, matchedCustomerId: record.id, matchedCustomerName: appt.customerName.trim() } : a
));
showToast('Customer added to database.', 'success');
}}
className="rounded-lg bg-green-600 px-2.5 py-1 text-xs font-medium text-white hover:bg-green-700"
>
Add to Database
</button>
)}
</div>
)}
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
<div>
<label className="mb-1 block text-xs font-medium uppercase tracking-[0.12em] text-gray-500 dark:text-gray-400">Time</label>
<input
type="time"
value={appt.appointmentTime}
onChange={(e) => updateEditAppt(idx, 'appointmentTime', e.target.value)}
className="w-full rounded-xl border border-gray-300 px-3 py-2.5 text-sm text-gray-900 shadow-sm focus:border-purple-500 focus:outline-none focus:ring-2 focus:ring-purple-500/20 dark:border-gray-600 dark:bg-gray-800 dark:text-white"
/>
</div>
<div>
<label className="mb-1 block text-xs font-medium uppercase tracking-[0.12em] text-gray-500 dark:text-gray-400">Duration (min)</label>
<input
type="number"
value={appt.durationMinutes}
onChange={(e) => updateEditAppt(idx, 'durationMinutes', parseInt(e.target.value) || 60)}
className="w-full rounded-xl border border-gray-300 px-3 py-2.5 text-sm text-gray-900 shadow-sm focus:border-purple-500 focus:outline-none focus:ring-2 focus:ring-purple-500/20 dark:border-gray-600 dark:bg-gray-800 dark:text-white"
/>
</div>
<div className="sm:col-span-2">
<label className="mb-1 block text-xs font-medium uppercase tracking-[0.12em] text-gray-500 dark:text-gray-400">Customer Name</label>
<input
type="text"
value={appt.customerName}
onChange={(e) => updateEditAppt(idx, 'customerName', e.target.value)}
className="w-full rounded-xl border border-gray-300 px-3 py-2.5 text-sm text-gray-900 shadow-sm focus:border-purple-500 focus:outline-none focus:ring-2 focus:ring-purple-500/20 dark:border-gray-600 dark:bg-gray-800 dark:text-white"
/>
</div>
<div>
<label className="mb-1 block text-xs font-medium uppercase tracking-[0.12em] text-gray-500 dark:text-gray-400">Phone</label>
<input
type="text"
value={formatPhoneInput(appt.customerPhone)}
onChange={(e) => updateEditAppt(idx, 'customerPhone', e.target.value.replace(/\D/g, ''))}
className="w-full rounded-xl border border-gray-300 px-3 py-2.5 text-sm text-gray-900 shadow-sm focus:border-purple-500 focus:outline-none focus:ring-2 focus:ring-purple-500/20 dark:border-gray-600 dark:bg-gray-800 dark:text-white"
/>
</div>
<div>
<label className="mb-1 block text-xs font-medium uppercase tracking-[0.12em] text-gray-500 dark:text-gray-400">VIN</label>
<input
type="text"
value={appt.vin}
onChange={(e) => updateEditAppt(idx, 'vin', e.target.value.toUpperCase())}
className="w-full rounded-xl border border-gray-300 px-3 py-2.5 text-sm text-gray-900 shadow-sm focus:border-purple-500 focus:outline-none focus:ring-2 focus:ring-purple-500/20 dark:border-gray-600 dark:bg-gray-800 dark:text-white"
/>
</div>
<div className="sm:col-span-2">
<label className="mb-1 block text-xs font-medium uppercase tracking-[0.12em] text-gray-500 dark:text-gray-400">Vehicle Info</label>
<input
type="text"
value={appt.vehicleInfo}
onChange={(e) => updateEditAppt(idx, 'vehicleInfo', e.target.value)}
className="w-full rounded-xl border border-gray-300 px-3 py-2.5 text-sm text-gray-900 shadow-sm focus:border-purple-500 focus:outline-none focus:ring-2 focus:ring-purple-500/20 dark:border-gray-600 dark:bg-gray-800 dark:text-white"
/>
</div>
<div className="sm:col-span-2">
<label className="mb-1 block text-xs font-medium uppercase tracking-[0.12em] text-gray-500 dark:text-gray-400">Service Type</label>
<input
type="text"
value={appt.serviceType}
onChange={(e) => updateEditAppt(idx, 'serviceType', e.target.value)}
className="w-full rounded-xl border border-gray-300 px-3 py-2.5 text-sm text-gray-900 shadow-sm focus:border-purple-500 focus:outline-none focus:ring-2 focus:ring-purple-500/20 dark:border-gray-600 dark:bg-gray-800 dark:text-white"
/>
</div>
</div>
</div>
))}
</div>
)}
{step === 'importing' && (
<div className="flex flex-col items-center justify-center py-12">
<div className="mb-4">
<svg className="h-12 w-12 animate-spin text-purple-500" viewBox="0 0 24 24" fill="none">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8v4a4 4 0 00-4 4H4z" />
</svg>
</div>
<p className="text-sm font-medium text-gray-700 dark:text-gray-300">{progress}</p>
</div>
)}
</div>
<div className="flex items-center justify-end gap-3 border-t border-gray-200 px-6 py-4 dark:border-gray-700">
{(step === 'idle' || step === 'uploading') && (
<>
<button
onClick={onClose}
className="rounded-lg border border-gray-300 px-4 py-2 text-sm font-medium text-gray-700 transition-colors hover:bg-gray-50 dark:border-gray-600 dark:text-gray-300 dark:hover:bg-gray-700"
>
Cancel
</button>
<button
onClick={handleExtract}
disabled={!imageFile}
className="inline-flex items-center gap-2 rounded-lg bg-gradient-to-r from-purple-600 to-purple-500 px-5 py-2 text-sm font-medium text-white shadow-sm transition-all hover:from-purple-700 hover:to-purple-600 focus:outline-none focus:ring-2 focus:ring-purple-500 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 dark:focus:ring-offset-gray-800"
>
<Sparkles className="h-4 w-4" />
Extract Appointments
</button>
</>
)}
{step === 'review' && (
<>
<button
onClick={() => { setStep('idle'); setError(null); }}
className="rounded-lg border border-gray-300 px-4 py-2 text-sm font-medium text-gray-700 transition-colors hover:bg-gray-50 dark:border-gray-600 dark:text-gray-300 dark:hover:bg-gray-700"
>
Back
</button>
<button
onClick={handleImportAll}
className="inline-flex items-center gap-2 rounded-lg bg-gradient-to-r from-purple-600 to-purple-500 px-5 py-2 text-sm font-medium text-white shadow-sm transition-all hover:from-purple-700 hover:to-purple-600 focus:outline-none focus:ring-2 focus:ring-purple-500 focus:ring-offset-2 dark:focus:ring-offset-gray-800"
>
<Upload className="h-4 w-4" />
Import All ({editAppointments.length})
</button>
</>
)}
{(step === 'ocr' || step === 'ai_extracting' || step === 'importing') && (
<p className="text-xs text-gray-400 dark:text-gray-500">{progress}</p>
)}
</div>
</div>
</div>
);
}
export const ScanScreenshotModal = memo(ScanScreenshotModalImpl);
@@ -0,0 +1,32 @@
import { memo } from 'react';
import type { Appointment } from '../../types';
function StatusBadgeImpl({ status }: { status: Appointment['status'] }) {
const colors: Record<string, string> = {
scheduled: 'bg-blue-100 text-blue-800 dark:bg-blue-900/40 dark:text-blue-300',
checked_in: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/40 dark:text-yellow-300',
completed: 'bg-green-100 text-green-800 dark:bg-green-900/40 dark:text-green-300',
cancelled: 'bg-red-100 text-red-800 dark:bg-red-900/40 dark:text-red-300',
no_show: 'bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-400',
};
const labels: Record<string, string> = {
scheduled: 'Scheduled',
checked_in: 'Checked In',
completed: 'Completed',
cancelled: 'Cancelled',
no_show: 'No Show',
};
return (
<span
className={`inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium ${
colors[status] || colors.scheduled
}`}
>
{labels[status] || status}
</span>
);
}
export const StatusBadge = memo(StatusBadgeImpl);
+10
View File
@@ -0,0 +1,10 @@
export { StatusBadge } from './StatusBadge';
export { AppointmentRow } from './AppointmentRow';
export { DateGroup } from './DateGroup';
export { AppointmentModal } from './AppointmentModal';
export { DeleteDialog } from './DeleteDialog';
export { ScanScreenshotModal } from './ScanScreenshotModal';
export { CheckInModal } from './CheckInModal';
export { EmptyDayState, EmptyState, ErrorState, AppointmentsSkeleton } from './EmptyStates';
export type { CreateAppointmentFormData } from './AppointmentModal';
export type { ScannedAppt } from './ScanScreenshotModal';
+85
View File
@@ -0,0 +1,85 @@
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);
@@ -0,0 +1,467 @@
import { useState, memo, Fragment } from 'react';
import {
Phone,
Mail,
MapPin,
ChevronLeft,
Pencil,
Plus,
FileText,
Car,
Trash2,
} from 'lucide-react';
import { formatPhoneDisplay } from '../../lib/phone';
import {
formatVehicleLabel,
vehicleKey,
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 (
<div>
{/* Back + Actions */}
<div className="mb-6 flex flex-wrap items-center justify-between gap-3">
<button
onClick={onBack}
className="inline-flex items-center gap-1.5 text-sm font-medium text-gray-600 hover:text-gray-900 dark:text-gray-400 dark:hover:text-gray-200"
>
<ChevronLeft className="h-4 w-4" />
Back to Customers
</button>
<div className="flex items-center gap-2">
<button
onClick={() => onEdit(customer)}
className="inline-flex items-center gap-1.5 rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm 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-4 w-4" />
Edit Profile
</button>
</div>
</div>
{/* Customer Info Card */}
<div className="mb-6 rounded-xl bg-white p-6 shadow-sm ring-1 ring-gray-200 dark:bg-gray-800 dark:ring-gray-700">
<h2 className="text-2xl font-bold text-gray-900 dark:text-white">{customer.name}</h2>
<p className="mt-0.5 font-mono text-sm text-gray-400 dark:text-gray-500">
Customer ID: {customer.id}
</p>
<div className="mt-3 grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
{customer.phone && (
<div className="flex items-center gap-2 text-sm text-gray-600 dark:text-gray-400">
<Phone className="h-4 w-4 shrink-0 text-gray-400" />
{formatPhoneDisplay(customer.phone)}
</div>
)}
{customer.email && (
<div className="flex items-center gap-2 text-sm text-gray-600 dark:text-gray-400">
<Mail className="h-4 w-4 shrink-0 text-gray-400" />
{customer.email}
</div>
)}
{customer.address && (
<div className="flex items-center gap-2 text-sm text-gray-600 dark:text-gray-400">
<MapPin className="h-4 w-4 shrink-0 text-gray-400" />
{customer.address}
</div>
)}
</div>
{customer.notes && (
<p className="mt-4 border-t border-gray-100 pt-4 text-sm text-gray-500 dark:border-gray-700 dark:text-gray-400">
{customer.notes}
</p>
)}
</div>
{/* Vehicles Section */}
<div className="rounded-xl bg-white p-6 shadow-sm ring-1 ring-gray-200 dark:bg-gray-800 dark:ring-gray-700">
<div className="mb-4 flex items-center justify-between">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
Vehicles ({vehicles.length})
</h3>
<button
onClick={onAddVehicle}
className="inline-flex items-center gap-1.5 rounded-lg bg-green-600 px-3 py-1.5 text-sm font-medium text-white transition-colors hover:bg-green-700"
>
<Plus className="h-4 w-4" />
Add Vehicle
</button>
</div>
{vehicles.length === 0 ? (
<div className="flex flex-col items-center justify-center py-12 text-center">
<Car className="mb-3 h-12 w-12 text-gray-300 dark:text-gray-600" />
<p className="text-sm text-gray-500 dark:text-gray-400">
No vehicles recorded for this customer.
</p>
<button
onClick={onAddVehicle}
className="mt-4 inline-flex items-center gap-1.5 rounded-lg bg-green-600 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-green-700"
>
<Plus className="h-4 w-4" />
Add First Vehicle
</button>
</div>
) : (
<div className="overflow-hidden rounded-lg border border-gray-200 dark:border-gray-700">
<div className="hidden overflow-x-auto md:block">
<table className="w-full text-left text-sm">
<thead className="bg-gray-50 text-xs uppercase tracking-wide text-gray-500 dark:bg-gray-900/60 dark:text-gray-400">
<tr>
<th className="px-4 py-3 font-semibold">Vehicle</th>
<th className="px-4 py-3 font-semibold">VIN</th>
<th className="px-4 py-3 font-semibold">Plate</th>
<th className="px-4 py-3 font-semibold">Mileage</th>
<th className="px-4 py-3 font-semibold">Engine</th>
<th className="px-4 py-3 text-right font-semibold">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100 dark:divide-gray-700">
{vehicles.map((v, index) => (
<tr key={v.id || `${vehicleKey(v)}-${index}`} className="hover:bg-gray-50 dark:hover:bg-gray-700/50">
<td className="px-4 py-3">
<p className="font-semibold text-gray-900 dark:text-white">{formatVehicleLabel(v)}</p>
{v.color && <p className="text-xs text-gray-500 dark:text-gray-400">{v.color}</p>}
</td>
<td className="px-4 py-3 font-mono text-xs text-gray-600 dark:text-gray-300">{v.vin || '—'}</td>
<td className="px-4 py-3 text-gray-600 dark:text-gray-300">{v.licensePlate || '—'}</td>
<td className="px-4 py-3 text-gray-600 dark:text-gray-300">{v.mileage || '—'}</td>
<td className="px-4 py-3 text-gray-600 dark:text-gray-300">{v.engine || '—'}</td>
<td className="px-4 py-3">
<div className="flex items-center justify-end gap-1">
<button
onClick={() => onEditVehicle(v)}
className="rounded-lg p-2 text-gray-500 hover:bg-gray-100 hover:text-gray-800 dark:text-gray-400 dark:hover:bg-gray-700 dark:hover:text-gray-100"
title="Edit vehicle"
>
<Pencil className="h-4 w-4" />
</button>
<button
onClick={() => onDeleteVehicle(v)}
className="rounded-lg p-2 text-gray-400 hover:bg-red-50 hover:text-red-600 dark:hover:bg-red-900/20 dark:hover:text-red-400"
title="Remove vehicle"
>
<Trash2 className="h-4 w-4" />
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
<div className="divide-y divide-gray-100 dark:divide-gray-700 md:hidden">
{vehicles.map((v, index) => (
<div key={v.id || `${vehicleKey(v)}-${index}`} className="p-4">
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<p className="font-semibold text-gray-900 dark:text-white">{formatVehicleLabel(v)}</p>
{v.color && <p className="text-xs text-gray-500 dark:text-gray-400">{v.color}</p>}
</div>
<div className="flex shrink-0 gap-1">
<button
onClick={() => onEditVehicle(v)}
className="rounded-lg p-1.5 text-gray-500 hover:bg-gray-100 hover:text-gray-800 dark:text-gray-400 dark:hover:bg-gray-700 dark:hover:text-gray-100"
title="Edit vehicle"
>
<Pencil className="h-4 w-4" />
</button>
<button
onClick={() => onDeleteVehicle(v)}
className="rounded-lg p-1.5 text-gray-400 hover:bg-red-50 hover:text-red-600 dark:hover:bg-red-900/20 dark:hover:text-red-400"
title="Remove vehicle"
>
<Trash2 className="h-4 w-4" />
</button>
</div>
</div>
<div className="mt-3 grid grid-cols-2 gap-2 text-xs text-gray-500 dark:text-gray-400">
<p><span className="font-medium">VIN:</span> {v.vin || '—'}</p>
<p><span className="font-medium">Plate:</span> {v.licensePlate || '—'}</p>
<p><span className="font-medium">Mileage:</span> {v.mileage || '—'}</p>
<p><span className="font-medium">Engine:</span> {v.engine || '—'}</p>
</div>
</div>
))}
</div>
</div>
)}
</div>
{/* Repair Orders Section */}
<div className="mt-6 rounded-xl bg-white p-6 shadow-sm ring-1 ring-gray-200 dark:bg-gray-800 dark:ring-gray-700">
<div className="mb-4 flex items-center justify-between">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
Repair Orders ({repairOrders.length})
</h3>
</div>
{repairOrdersLoading ? (
<div className="flex items-center justify-center py-8">
<div className="h-5 w-5 animate-spin rounded-full border-2 border-green-500 border-t-transparent" />
<span className="ml-3 text-sm text-gray-500 dark:text-gray-400">
Loading repair orders...
</span>
</div>
) : repairOrders.length === 0 ? (
<div className="flex flex-col items-center justify-center py-8 text-center">
<FileText className="mb-3 h-10 w-10 text-gray-300 dark:text-gray-600" />
<p className="text-sm text-gray-500 dark:text-gray-400">
No repair orders have been linked to this customer yet.
</p>
</div>
) : (
<div className="overflow-x-auto">
<table className="w-full text-left text-sm">
<thead>
<tr className="border-b border-gray-200 dark:border-gray-700">
<th className="pb-3 pr-4 font-semibold text-gray-700 dark:text-gray-300">RO #</th>
<th className="pb-3 pr-4 font-semibold text-gray-700 dark:text-gray-300">Date</th>
<th className="pb-3 pr-4 font-semibold text-gray-700 dark:text-gray-300">Vehicle</th>
<th className="pb-3 font-semibold text-gray-700 dark:text-gray-300">Status</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100 dark:divide-gray-700">
{repairOrders.map((ro) => {
const statusLabel = getRoStatusLabel(ro);
const statusColor = getRoStatusColor(ro);
return (
<tr key={ro.id} className="group hover:bg-gray-50 dark:hover:bg-gray-700/50">
<td className="py-3 pr-4 font-medium text-gray-900 dark:text-white">
{ro.roNumber || '\u2014'}
</td>
<td className="py-3 pr-4 text-gray-600 dark:text-gray-400">
{formatCustomerDate(ro.createdAt)}
</td>
<td className="py-3 pr-4 text-gray-600 dark:text-gray-400">
{ro.vehicleInfo || '\u2014'}
</td>
<td className="py-3">
<span
className={
'inline-flex items-center gap-1 rounded-full px-2.5 py-0.5 text-xs font-medium ' +
statusColor
}
>
{statusLabel}
</span>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
)}
</div>
{/* Quotes Section */}
<div className="mt-6 rounded-xl bg-white p-6 shadow-sm ring-1 ring-gray-200 dark:bg-gray-800 dark:ring-gray-700">
<div className="mb-4 flex items-center justify-between">
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
Quotes ({quotes.length})
</h3>
</div>
{quotesLoading ? (
<div className="flex items-center justify-center py-8">
<div className="h-5 w-5 animate-spin rounded-full border-2 border-green-500 border-t-transparent" />
<span className="ml-3 text-sm text-gray-500 dark:text-gray-400">
Loading quotes...
</span>
</div>
) : quotes.length === 0 ? (
<div className="flex flex-col items-center justify-center py-8 text-center">
<FileText className="mb-3 h-10 w-10 text-gray-300 dark:text-gray-600" />
<p className="text-sm text-gray-500 dark:text-gray-400">
No saved quotes are on file for this customer yet.
</p>
</div>
) : (
<div className="overflow-x-auto">
<table className="w-full text-left text-sm">
<thead>
<tr className="border-b border-gray-200 dark:border-gray-700">
<th className="pb-3 pr-4 font-semibold text-gray-700 dark:text-gray-300">Date</th>
<th className="pb-3 pr-4 font-semibold text-gray-700 dark:text-gray-300">Vehicle</th>
<th className="pb-3 pr-4 font-semibold text-gray-700 dark:text-gray-300">Total</th>
<th className="pb-3 pr-4 font-semibold text-gray-700 dark:text-gray-300">Status</th>
<th className="pb-3 font-semibold text-gray-700 dark:text-gray-300">Advisor</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100 dark:divide-gray-700">
{quotes.map((q) => {
const isExpanded = expandedQuoteId === q.id;
const services = parseQuoteServices(q.services);
const totalServices = services.reduce((sum, s) => sum + s.price, 0);
const qStatusClass =
q.status === 'converted'
? 'bg-green-50 text-green-700 dark:bg-green-900/30 dark:text-green-400'
: q.status === 'sent'
? 'bg-blue-50 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400'
: q.status === 'declined'
? 'bg-red-50 text-red-700 dark:bg-red-900/30 dark:text-red-400'
: 'bg-gray-50 text-gray-700 dark:bg-gray-900/30 dark:text-gray-400';
return (
<Fragment key={q.id}>
<tr
className="group cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-700/50"
onClick={() => toggleQuote(q.id)}
>
<td className="py-3 pr-4 text-gray-600 dark:text-gray-400">
{formatCustomerDate(q.createdAt)}
</td>
<td className="py-3 pr-4 text-gray-600 dark:text-gray-400">
{q.vehicleInfo || '\u2014'}
</td>
<td className="py-3 pr-4 font-medium text-gray-900 dark:text-white">
{q.total ? formatCurrency(q.total) : '\u2014'}
</td>
<td className="py-3 pr-4">
<span
className={
'inline-flex items-center gap-1 rounded-full px-2.5 py-0.5 text-xs font-medium ' +
qStatusClass
}
>
{q.status || 'Unknown'}
</span>
</td>
<td className="py-3 text-gray-600 dark:text-gray-400">
{q.serviceAdvisor || '\u2014'}
</td>
</tr>
<tr key={q.id + '-detail'}>
<td colSpan={5} className="p-0">
{isExpanded && (
<div className="border-t border-gray-100 bg-gray-50 px-6 py-4 dark:border-gray-700 dark:bg-gray-800/50">
{services.length > 0 && (
<div className="mb-3 space-y-1.5">
<p className="mb-1.5 text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
Services
</p>
{services.map((s, i) => (
<div
key={i}
className="flex items-center justify-between gap-2 text-sm"
>
<div className="flex items-center gap-2 min-w-0">
<span className="truncate text-gray-700 dark:text-gray-300">
{s.name}
</span>
{s.customerDecision && s.customerDecision !== 'pending' && (
<span
className={
'shrink-0 rounded-full px-1.5 py-0.5 text-[10px] font-medium ' +
(s.customerDecision === 'approved'
? 'bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-400'
: 'bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-400')
}
>
{s.customerDecision === 'approved'
? 'Approved'
: 'Declined'}
</span>
)}
</div>
<span className="shrink-0 font-medium text-gray-900 dark:text-white">
{formatCurrency(s.price)}
</span>
</div>
))}
{services.length > 1 && (
<div className="flex items-center justify-between border-t border-gray-200 pt-1.5 text-sm dark:border-gray-600">
<span className="font-semibold text-gray-700 dark:text-gray-300">
Subtotal
</span>
<span className="font-semibold text-gray-900 dark:text-white">
{formatCurrency(totalServices)}
</span>
</div>
)}
</div>
)}
{q.notes && (
<div className="mb-2">
<p className="mb-0.5 text-xs font-semibold uppercase tracking-wide text-gray-500 dark:text-gray-400">
Notes
</p>
<p className="text-sm text-gray-600 dark:text-gray-400">
{q.notes}
</p>
</div>
)}
{q.vin && (
<p className="text-xs text-gray-400 dark:text-gray-500">
VIN: {q.vin}
</p>
)}
<button
onClick={(e) => {
e.stopPropagation();
toggleQuote(q.id);
}}
className="mt-3 text-xs font-medium text-green-600 hover:text-green-700 dark:text-green-400 dark:hover:text-green-300"
>
Collapse
</button>
</div>
)}
</td>
</tr>
</Fragment>
);
})}
</tbody>
</table>
</div>
)}
</div>
</div>
);
}
export const CustomerDetailView = memo(CustomerDetailViewImpl);
@@ -0,0 +1,175 @@
import { memo } from 'react';
import { Car, Eye, Mail, Pencil, Phone, Trash2 } from 'lucide-react';
import { formatPhoneDisplay } from '../../lib/phone';
import { formatCustomerDate, formatVehicleLabel } from '../../lib/customers';
import type { CustomerWithVehicles } from './types';
interface Props {
customers: CustomerWithVehicles[];
onView: (id: string) => void;
onEdit: (customer: CustomerWithVehicles) => void;
onDelete: (customer: CustomerWithVehicles) => void;
}
function primaryVehicleText(customer: CustomerWithVehicles): string {
const vehicle = customer.vehicles[0];
return vehicle ? formatVehicleLabel(vehicle) : 'No vehicle on file';
}
function secondaryVehicleText(customer: CustomerWithVehicles): string {
const vehicle = customer.vehicles[0];
if (!vehicle) return '';
const details = [vehicle.vin ? `VIN ${vehicle.vin}` : '', vehicle.licensePlate ? `Plate ${vehicle.licensePlate}` : ''].filter(Boolean);
return details.join(' · ');
}
function CustomerDirectoryTableImpl({ customers, onView, onEdit, onDelete }: Props) {
return (
<div className="overflow-hidden rounded-xl bg-white shadow-sm ring-1 ring-gray-200 dark:bg-gray-800 dark:ring-gray-700">
<div className="hidden overflow-x-auto md:block">
<table className="w-full text-left text-sm">
<thead className="bg-gray-50 text-xs uppercase tracking-wide text-gray-500 dark:bg-gray-900/60 dark:text-gray-400">
<tr>
<th className="px-4 py-3 font-semibold">Customer</th>
<th className="px-4 py-3 font-semibold">Phone</th>
<th className="px-4 py-3 font-semibold">Email</th>
<th className="px-4 py-3 font-semibold">Primary Vehicle</th>
<th className="px-4 py-3 text-center font-semibold">Vehicles</th>
<th className="px-4 py-3 font-semibold">Updated</th>
<th className="px-4 py-3 text-right font-semibold">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100 dark:divide-gray-700">
{customers.map((customer) => {
const vehicleDetails = secondaryVehicleText(customer);
return (
<tr key={customer.id} className="group hover:bg-green-50/50 dark:hover:bg-gray-700/50">
<td className="px-4 py-3">
<button
type="button"
onClick={() => onView(customer.id)}
className="text-left font-semibold text-gray-900 hover:text-green-700 dark:text-white dark:hover:text-green-400"
>
{customer.name}
</button>
<p className="mt-0.5 font-mono text-xs text-gray-400 dark:text-gray-500">ID {customer.id.slice(0, 8)}</p>
</td>
<td className="px-4 py-3 text-gray-600 dark:text-gray-300">
{customer.phone ? formatPhoneDisplay(customer.phone) : <span className="text-gray-400">Missing</span>}
</td>
<td className="max-w-[220px] px-4 py-3 text-gray-600 dark:text-gray-300">
{customer.email ? <span className="block truncate">{customer.email}</span> : <span className="text-gray-400">Missing</span>}
</td>
<td className="px-4 py-3">
<p className="font-medium text-gray-800 dark:text-gray-100">{primaryVehicleText(customer)}</p>
{vehicleDetails && <p className="mt-0.5 max-w-[260px] truncate text-xs text-gray-500 dark:text-gray-400">{vehicleDetails}</p>}
</td>
<td className="px-4 py-3 text-center">
<span className="inline-flex min-w-8 items-center justify-center rounded-full bg-gray-100 px-2.5 py-1 text-xs font-semibold text-gray-700 dark:bg-gray-700 dark:text-gray-200">
{customer.vehicleCount}
</span>
</td>
<td className="px-4 py-3 text-gray-500 dark:text-gray-400">{formatCustomerDate(customer.updated || customer.created)}</td>
<td className="px-4 py-3">
<div className="flex items-center justify-end gap-1">
<button
type="button"
onClick={() => onView(customer.id)}
className="rounded-lg p-2 text-gray-500 hover:bg-green-100 hover:text-green-700 dark:text-gray-400 dark:hover:bg-green-900/30 dark:hover:text-green-300"
title="View customer"
>
<Eye className="h-4 w-4" />
</button>
<button
type="button"
onClick={() => onEdit(customer)}
className="rounded-lg p-2 text-gray-500 hover:bg-gray-100 hover:text-gray-800 dark:text-gray-400 dark:hover:bg-gray-700 dark:hover:text-gray-100"
title="Edit customer"
>
<Pencil className="h-4 w-4" />
</button>
<button
type="button"
onClick={() => onDelete(customer)}
className="rounded-lg p-2 text-gray-400 hover:bg-red-50 hover:text-red-600 dark:hover:bg-red-900/20 dark:hover:text-red-400"
title="Delete customer"
>
<Trash2 className="h-4 w-4" />
</button>
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
<div className="divide-y divide-gray-100 dark:divide-gray-700 md:hidden">
{customers.map((customer) => {
const vehicleDetails = secondaryVehicleText(customer);
return (
<div key={customer.id} className="p-4">
<div className="flex items-start justify-between gap-3">
<button
type="button"
onClick={() => onView(customer.id)}
className="min-w-0 text-left"
>
<p className="truncate font-semibold text-gray-900 dark:text-white">{customer.name}</p>
<p className="mt-1 flex items-center gap-1.5 text-xs text-gray-500 dark:text-gray-400">
<Car className="h-3.5 w-3.5" />
<span className="truncate">{primaryVehicleText(customer)}</span>
</p>
</button>
<span className="shrink-0 rounded-full bg-gray-100 px-2.5 py-1 text-xs font-semibold text-gray-700 dark:bg-gray-700 dark:text-gray-200">
{customer.vehicleCount} vehicle{customer.vehicleCount !== 1 ? 's' : ''}
</span>
</div>
<div className="mt-3 space-y-1.5 text-sm text-gray-600 dark:text-gray-300">
{customer.phone && (
<p className="flex items-center gap-2">
<Phone className="h-3.5 w-3.5 text-gray-400" />
{formatPhoneDisplay(customer.phone)}
</p>
)}
{customer.email && (
<p className="flex items-center gap-2">
<Mail className="h-3.5 w-3.5 text-gray-400" />
<span className="truncate">{customer.email}</span>
</p>
)}
{vehicleDetails && <p className="text-xs text-gray-500 dark:text-gray-400">{vehicleDetails}</p>}
</div>
<div className="mt-4 flex items-center gap-2">
<button
type="button"
onClick={() => onView(customer.id)}
className="rounded-lg bg-green-600 px-3 py-1.5 text-xs font-medium text-white hover:bg-green-700"
>
View
</button>
<button
type="button"
onClick={() => onEdit(customer)}
className="rounded-lg border border-gray-300 px-3 py-1.5 text-xs font-medium text-gray-700 hover:bg-gray-50 dark:border-gray-600 dark:text-gray-300 dark:hover:bg-gray-700"
>
Edit
</button>
<button
type="button"
onClick={() => onDelete(customer)}
className="ml-auto rounded-lg p-1.5 text-red-500 hover:bg-red-50 dark:hover:bg-red-900/20"
>
<Trash2 className="h-4 w-4" />
</button>
</div>
</div>
);
})}
</div>
</div>
);
}
export const CustomerDirectoryTable = memo(CustomerDirectoryTableImpl);
@@ -0,0 +1,216 @@
import { useState, useEffect } from 'react';
import { X } from 'lucide-react';
import { formatPhoneInput } from '../../lib/phone';
import { composeName } from '../../lib/customerName';
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>;
}) {
const [form, setForm] = useState<CustomerFormData>({
name: '',
firstName: '',
middleName: '',
lastName: '',
phone: '',
email: '',
address: '',
notes: '',
});
const [saving, setSaving] = useState(false);
const [error, setError] = useState('');
useEffect(() => {
if (open) {
const fullName = customer?.name || '';
const parts = fullName.split(/\s+/).filter(Boolean);
const firstName = parts[0] || '';
const lastName = parts.length > 1 ? parts[parts.length - 1] : '';
const middleName = parts.length > 2 ? parts.slice(1, -1).join(' ') : '';
setForm({
name: fullName,
firstName,
middleName,
lastName,
phone: customer?.phone || '',
email: customer?.email || '',
address: customer?.address || '',
notes: customer?.notes || '',
});
setError('');
}
}, [open, customer]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
const name = composeName(form.firstName, form.middleName, form.lastName) || form.name;
if (!name.trim()) {
setError('Customer name is required.');
return;
}
setSaving(true);
setError('');
try {
await onSave({ ...form, name });
} catch (err) {
setError('Customer could not be saved. Please try again.');
} finally {
setSaving(false);
}
};
if (!open) return null;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
<div className="w-full max-w-lg rounded-xl bg-white shadow-xl dark:bg-gray-800">
<div className="flex items-center justify-between border-b border-gray-200 px-6 py-4 dark:border-gray-700">
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">
{customer ? 'Edit Customer' : 'Add Customer'}
</h2>
<button
onClick={onClose}
className="rounded-lg p-1.5 text-gray-400 hover:bg-gray-100 hover:text-gray-600 dark:hover:bg-gray-700 dark:hover:text-gray-300"
>
<X className="h-5 w-5" />
</button>
</div>
<form onSubmit={handleSubmit} className="space-y-4 p-6">
<div className="grid grid-cols-3 gap-3">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">
First <span className="text-red-500">*</span>
</label>
<input
type="text"
value={form.firstName}
onChange={(e) => {
const firstName = e.target.value;
const name = composeName(firstName, form.middleName, form.lastName);
setForm({ ...form, firstName, name });
}}
placeholder="John"
className="mt-1 w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm text-gray-900 placeholder-gray-400 focus:border-green-500 focus:outline-none focus:ring-2 focus:ring-green-500/20 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-100 dark:placeholder-gray-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">
Middle
</label>
<input
type="text"
value={form.middleName}
onChange={(e) => {
const middleName = e.target.value;
const name = composeName(form.firstName, middleName, form.lastName);
setForm({ ...form, middleName, name });
}}
placeholder="Michael"
className="mt-1 w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm text-gray-900 placeholder-gray-400 focus:border-green-500 focus:outline-none focus:ring-2 focus:ring-green-500/20 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-100 dark:placeholder-gray-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">
Last <span className="text-red-500">*</span>
</label>
<input
type="text"
value={form.lastName}
onChange={(e) => {
const lastName = e.target.value;
const name = composeName(form.firstName, form.middleName, lastName);
setForm({ ...form, lastName, name });
}}
placeholder="Doe"
className="mt-1 w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm text-gray-900 placeholder-gray-400 focus:border-green-500 focus:outline-none focus:ring-2 focus:ring-green-500/20 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-100 dark:placeholder-gray-500"
/>
</div>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">
Phone
</label>
<input
type="tel"
value={formatPhoneInput(form.phone)}
onChange={(e) => setForm({ ...form, phone: e.target.value.replace(/\D/g, '') })}
placeholder="555-555-5555"
className="mt-1 w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm text-gray-900 placeholder-gray-400 focus:border-green-500 focus:outline-none focus:ring-2 focus:ring-green-500/20 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-100 dark:placeholder-gray-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">
Email
</label>
<input
type="email"
value={form.email}
onChange={(e) => setForm({ ...form, email: e.target.value })}
placeholder="john@example.com"
className="mt-1 w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm text-gray-900 placeholder-gray-400 focus:border-green-500 focus:outline-none focus:ring-2 focus:ring-green-500/20 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-100 dark:placeholder-gray-500"
/>
</div>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">
Address
</label>
<input
type="text"
value={form.address}
onChange={(e) => setForm({ ...form, address: e.target.value })}
placeholder="123 Main St, City, State ZIP"
className="mt-1 w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm text-gray-900 placeholder-gray-400 focus:border-green-500 focus:outline-none focus:ring-2 focus:ring-green-500/20 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-100 dark:placeholder-gray-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">
Notes
</label>
<textarea
value={form.notes}
onChange={(e) => setForm({ ...form, notes: e.target.value })}
placeholder="Any additional notes about this customer..."
rows={3}
className="mt-1 w-full resize-y rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm text-gray-900 placeholder-gray-400 focus:border-green-500 focus:outline-none focus:ring-2 focus:ring-green-500/20 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-100 dark:placeholder-gray-500"
/>
</div>
{error && (
<p className="text-sm text-red-600 dark:text-red-400">{error}</p>
)}
<div className="flex justify-end gap-3 border-t border-gray-200 pt-4 dark:border-gray-700">
<button
type="button"
onClick={onClose}
className="rounded-lg border border-gray-300 bg-white px-4 py-2 text-sm 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"
>
Cancel
</button>
<button
type="submit"
disabled={saving}
className="inline-flex items-center gap-2 rounded-lg bg-green-600 px-4 py-2 text-sm font-medium text-white shadow-sm transition-colors hover:bg-green-700 disabled:cursor-not-allowed disabled:opacity-50"
>
{saving ? 'Saving...' : customer ? 'Save Changes' : 'Add Customer'}
</button>
</div>
</form>
</div>
</div>
);
}
@@ -0,0 +1,71 @@
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>;
}) {
const [deleting, setDeleting] = useState(false);
const [error, setError] = useState('');
const handleDelete = async () => {
setDeleting(true);
setError('');
try {
await onConfirm();
} catch (err) {
setError('Item could not be deleted. Please try again.');
} finally {
setDeleting(false);
}
};
if (!open) return null;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
<div className="w-full max-w-md rounded-xl bg-white p-6 shadow-xl dark:bg-gray-800">
<div className="mb-6 text-center">
<div className="mx-auto mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-red-50 dark:bg-red-900/20">
<AlertTriangle className="h-8 w-8 text-red-500" />
</div>
<h3 className="text-lg font-semibold text-gray-900 dark:text-white">
Delete Customer
</h3>
<p className="mt-2 text-sm text-gray-500 dark:text-gray-400">
Are you sure you want to delete <strong>{customerName}</strong>? This will also remove
all associated vehicles. This action cannot be undone.
</p>
</div>
{error && (
<p className="mb-4 text-sm text-red-600 dark:text-red-400">{error}</p>
)}
<div className="flex justify-end gap-3">
<button
onClick={onClose}
disabled={deleting}
className="rounded-lg border border-gray-300 bg-white px-4 py-2 text-sm 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"
>
Cancel
</button>
<button
onClick={handleDelete}
disabled={deleting}
className="inline-flex items-center gap-2 rounded-lg bg-red-600 px-4 py-2 text-sm font-medium text-white shadow-sm transition-colors hover:bg-red-700 disabled:cursor-not-allowed disabled:opacity-50"
>
{deleting ? 'Deleting...' : 'Delete'}
</button>
</div>
</div>
</div>
);
}
+67
View File
@@ -0,0 +1,67 @@
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} />;
}
@@ -0,0 +1,217 @@
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>;
}) {
const [form, setForm] = useState<VehicleFormData>({
make: '',
model: '',
year: '',
vin: '',
licensePlate: '',
mileage: '',
color: '',
engine: '',
});
const [saving, setSaving] = useState(false);
const [error, setError] = useState('');
useEffect(() => {
if (open) {
setForm({
make: vehicle?.make || '',
model: vehicle?.model || '',
year: vehicle?.year || '',
vin: vehicle?.vin || '',
licensePlate: vehicle?.licensePlate || '',
mileage: vehicle?.mileage || '',
color: vehicle?.color || '',
engine: vehicle?.engine || '',
});
setError('');
}
}, [open, vehicle]);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!form.make.trim() || !form.model.trim()) {
setError('Make and model are required.');
return;
}
setSaving(true);
setError('');
try {
await onSave(form);
} catch (err) {
setError('Vehicle could not be saved. Please try again.');
} finally {
setSaving(false);
}
};
if (!open) return null;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4">
<div className="w-full max-w-lg rounded-xl bg-white shadow-xl dark:bg-gray-800">
<div className="flex items-center justify-between border-b border-gray-200 px-6 py-4 dark:border-gray-700">
<div>
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">
{vehicle ? 'Edit Vehicle' : 'Add Vehicle'}
</h2>
<p className="text-xs text-gray-500 dark:text-gray-400">
{customerName}
</p>
</div>
<button
onClick={onClose}
className="rounded-lg p-1.5 text-gray-400 hover:bg-gray-100 hover:text-gray-600 dark:hover:bg-gray-700 dark:hover:text-gray-300"
>
<X className="h-5 w-5" />
</button>
</div>
<form onSubmit={handleSubmit} className="space-y-4 p-6">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">
Year
</label>
<input
type="text"
value={form.year}
onChange={(e) => setForm({ ...form, year: e.target.value })}
placeholder="2020"
className="mt-1 w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm text-gray-900 placeholder-gray-400 focus:border-green-500 focus:outline-none focus:ring-2 focus:ring-green-500/20 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-100 dark:placeholder-gray-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">
Make <span className="text-red-500">*</span>
</label>
<input
type="text"
value={form.make}
onChange={(e) => setForm({ ...form, make: e.target.value })}
placeholder="Honda"
className="mt-1 w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm text-gray-900 placeholder-gray-400 focus:border-green-500 focus:outline-none focus:ring-2 focus:ring-green-500/20 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-100 dark:placeholder-gray-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">
Model <span className="text-red-500">*</span>
</label>
<input
type="text"
value={form.model}
onChange={(e) => setForm({ ...form, model: e.target.value })}
placeholder="Civic"
className="mt-1 w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm text-gray-900 placeholder-gray-400 focus:border-green-500 focus:outline-none focus:ring-2 focus:ring-green-500/20 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-100 dark:placeholder-gray-500"
/>
</div>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">
VIN
</label>
<input
type="text"
value={form.vin}
onChange={(e) => setForm({ ...form, vin: e.target.value })}
placeholder="1HGBH41JXMN109186"
maxLength={17}
className="mt-1 w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm text-gray-900 placeholder-gray-400 focus:border-green-500 focus:outline-none focus:ring-2 focus:ring-green-500/20 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-100 dark:placeholder-gray-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">
License Plate
</label>
<input
type="text"
value={form.licensePlate}
onChange={(e) => setForm({ ...form, licensePlate: e.target.value })}
placeholder="ABC-1234"
className="mt-1 w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm text-gray-900 placeholder-gray-400 focus:border-green-500 focus:outline-none focus:ring-2 focus:ring-green-500/20 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-100 dark:placeholder-gray-500"
/>
</div>
</div>
<div className="grid grid-cols-1 gap-4 sm:grid-cols-3">
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">
Mileage
</label>
<input
type="text"
value={form.mileage}
onChange={(e) => setForm({ ...form, mileage: e.target.value })}
placeholder="45,000"
className="mt-1 w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm text-gray-900 placeholder-gray-400 focus:border-green-500 focus:outline-none focus:ring-2 focus:ring-green-500/20 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-100 dark:placeholder-gray-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">
Color
</label>
<input
type="text"
value={form.color}
onChange={(e) => setForm({ ...form, color: e.target.value })}
placeholder="Silver"
className="mt-1 w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm text-gray-900 placeholder-gray-400 focus:border-green-500 focus:outline-none focus:ring-2 focus:ring-green-500/20 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-100 dark:placeholder-gray-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 dark:text-gray-300">
Engine
</label>
<input
type="text"
value={form.engine}
onChange={(e) => setForm({ ...form, engine: e.target.value })}
placeholder="2.0L I4"
className="mt-1 w-full rounded-lg border border-gray-300 bg-white px-3 py-2 text-sm text-gray-900 placeholder-gray-400 focus:border-green-500 focus:outline-none focus:ring-2 focus:ring-green-500/20 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-100 dark:placeholder-gray-500"
/>
</div>
</div>
{error && (
<p className="text-sm text-red-600 dark:text-red-400">{error}</p>
)}
<div className="flex justify-end gap-3 border-t border-gray-200 pt-4 dark:border-gray-700">
<button
type="button"
onClick={onClose}
className="rounded-lg border border-gray-300 bg-white px-4 py-2 text-sm 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"
>
Cancel
</button>
<button
type="submit"
disabled={saving}
className="inline-flex items-center gap-2 rounded-lg bg-green-600 px-4 py-2 text-sm font-medium text-white shadow-sm transition-colors hover:bg-green-700 disabled:cursor-not-allowed disabled:opacity-50"
>
{saving ? 'Saving...' : vehicle ? 'Save Changes' : 'Add Vehicle'}
</button>
</div>
</form>
</div>
</div>
);
}
+16
View File
@@ -0,0 +1,16 @@
export { CustomersSkeleton, EmptyState, ErrorState } from './EmptyStates';
export { CustomerCard } from './CustomerCard';
export { CustomerDirectoryTable } from './CustomerDirectoryTable';
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';
+86
View File
@@ -0,0 +1,86 @@
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;
firstName: string;
middleName: string;
lastName: 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;
}
+130
View File
@@ -0,0 +1,130 @@
import { useState, useEffect, type ReactNode } from 'react';
import {
LayoutDashboard,
Calendar,
Wrench,
FileText,
Users,
Receipt,
Package,
DollarSign,
Clock,
Settings,
Sun,
Moon,
LogOut,
} from 'lucide-react';
import { NavLink, useLocation, useNavigate } from 'react-router-dom';
import { pb } from '../../lib/pocketbase';
import { preloadSettingsPage, preloadTimeCards } from '../../lib/routePreload';
interface MobileLayoutProps {
children: ReactNode;
}
const navItems = [
{ to: '/', label: 'Field', icon: LayoutDashboard },
{ to: '/appointments', label: 'Agenda', icon: Calendar },
{ to: '/repair-orders', label: 'Jobs', icon: Wrench },
{ to: '/quote', label: 'Quote', icon: FileText },
{ to: '/customers', label: 'Customers', icon: Users },
{ to: '/invoices', label: 'Invoices', icon: Receipt },
{ to: '/service-catalog', label: 'Catalog', icon: Package },
{ to: '/financial', label: 'Financial', icon: DollarSign },
{ to: '/time-cards', label: 'Time Cards', icon: Clock },
{ to: '/settings', label: 'Settings', icon: Settings },
];
export default function MobileLayout({ children }: MobileLayoutProps) {
const navigate = useNavigate();
const location = useLocation();
const [dark, setDark] = useState(() => {
if (typeof window === 'undefined') return false;
return localStorage.getItem('spq-dark-mode') === 'true';
});
useEffect(() => {
const root = document.documentElement;
if (dark) {
root.classList.add('dark');
} else {
root.classList.remove('dark');
}
localStorage.setItem('spq-dark-mode', String(dark));
}, [dark]);
const toggleDark = () => setDark((prev) => !prev);
const handleLogout = () => {
pb.authStore.clear();
navigate('/login');
};
const userEmail = pb.authStore.model?.email as string | undefined;
const navClasses =
'flex shrink-0 items-center gap-1.5 rounded-full px-3 py-1.5 text-xs font-medium whitespace-nowrap transition-colors';
return (
<div className="min-h-screen bg-gray-50 dark:bg-gray-950">
<header className="sticky top-0 z-30 flex h-14 items-center gap-2 border-b border-gray-200 bg-white px-3 dark:border-gray-800 dark:bg-gray-900">
<span className="shrink-0 text-base font-bold text-gray-900 dark:text-white">
Shop<span className="text-green-600">Pro</span>Quote
</span>
<div className="min-w-0 flex-1" />
<button
onClick={toggleDark}
className="rounded-lg p-1.5 text-gray-500 hover:bg-gray-100 hover:text-gray-700 dark:text-gray-400 dark:hover:bg-gray-800 dark:hover:text-gray-200"
title={dark ? 'Switch to light mode' : 'Switch to dark mode'}
>
{dark ? <Sun className="h-4 w-4" /> : <Moon className="h-4 w-4" />}
</button>
{userEmail && (
<span className="hidden max-w-[120px] truncate text-xs text-gray-500 dark:text-gray-400 sm:block">
{userEmail}
</span>
)}
<button
onClick={handleLogout}
className="rounded-lg p-1.5 text-gray-500 hover:bg-red-50 hover:text-red-600 dark:text-gray-400 dark:hover:bg-red-900/20 dark:hover:text-red-400"
title="Logout"
>
<LogOut className="h-4 w-4" />
</button>
</header>
<nav className="sticky top-14 z-20 flex gap-1 overflow-x-auto border-b border-gray-200 bg-white px-2 py-2 dark:border-gray-800 dark:bg-gray-900">
{navItems.map((item) => (
<NavLink
key={item.to}
to={item.to}
end={item.to === '/'}
state={
item.to === '/settings'
? {
backgroundLocation:
(location.state as { backgroundLocation?: unknown } | null)
?.backgroundLocation || location,
}
: undefined
}
onMouseEnter={item.to === '/settings' ? preloadSettingsPage : item.to === '/time-cards' ? preloadTimeCards : undefined}
onFocus={item.to === '/settings' ? preloadSettingsPage : item.to === '/time-cards' ? preloadTimeCards : undefined}
className={({ isActive }) =>
`${navClasses} ${
isActive
? 'bg-green-600 text-white shadow-sm'
: 'bg-gray-100 text-gray-600 hover:bg-gray-200 dark:bg-gray-800 dark:text-gray-400 dark:hover:bg-gray-700'
}`
}
>
<item.icon className="h-3.5 w-3.5" />
<span>{item.label}</span>
</NavLink>
))}
</nav>
<main className="p-3 sm:p-4">{children}</main>
</div>
);
}
@@ -0,0 +1,458 @@
import { useState, useEffect, useRef, useCallback } from 'react';
import { Search, X, Check, UserRound, AlertTriangle } from 'lucide-react';
import { useQuoteStore } from '../../store/quote';
import { useSettings } from '../../store/settings';
import { getRoleLabel, showServiceLocationFields } from '../../lib/settings';
import { formatPhoneInput } from '../../lib/phone';
import { pb } from '../../lib/pocketbase';
import { logError } from '../../lib/userMessages';
interface CustomerSearchResult {
id: string;
name: string;
phone: string;
vehicleCount: number;
}
export function CustomerInfoPanel() {
const { customerInfo, setCustomerInfo } = useQuoteStore();
const settings = useSettings();
const defaultAdvisor = settings.serviceAdvisor;
const roleLabel = getRoleLabel(settings);
const showLocationFields = showServiceLocationFields(settings);
const isDefaultAdvisor = (val: string) => Boolean(defaultAdvisor && val === defaultAdvisor);
// Customer search
const [searchQuery, setSearchQuery] = useState('');
const [searchResults, setSearchResults] = useState<CustomerSearchResult[]>([]);
const [searching, setSearching] = useState(false);
const [showDropdown, setShowDropdown] = useState(false);
const searchRef = useRef<HTMLDivElement | null>(null);
const debounceRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
// Duplicate detection for new customer entry
const [duplicateWarning, setDuplicateWarning] = useState<{
customer: CustomerSearchResult;
message: 'name' | 'phone';
} | null>(null);
const dupeDebounceRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined);
const isLinked = Boolean(customerInfo.customerId);
// ── Search customers ──
const doSearch = useCallback(async (q: string) => {
if (!q.trim()) {
setSearchResults([]);
setShowDropdown(false);
return;
}
setSearching(true);
try {
const searchClean = q.toLowerCase().trim();
// Fetch all customers for this user (list rule scopes by userId) and filter client-side
const result = await pb.collection('customers').getList(1, 500, {
sort: 'name',
fields: 'id,name,phone',
batch: 500,
});
// Client-side filter by name or phone
const isNumeric = /^\d+$/.test(searchClean);
const matched = (result.items as any[]).filter((c: any) => {
if (isNumeric) {
return (c.phone || '').replace(/\D/g, '').includes(searchClean);
}
return (c.name || '').toLowerCase().includes(searchClean);
});
// Fetch vehicle count for top matches (limit to 10 for display)
const top = matched.slice(0, 10);
const enriched: CustomerSearchResult[] = await Promise.all(
top.map(async (r: any) => {
let vehicleCount = 0;
try {
const vResult = await pb.collection('vehicles').getList(1, 1, {
filter: `customerId = '${r.id.replace(/'/g, "\\'")}'`,
fields: 'id',
batch: 1,
});
vehicleCount = vResult.totalItems;
} catch { /* ignore */ }
return { id: r.id, name: r.name, phone: r.phone, vehicleCount };
})
);
setSearchResults(enriched);
setShowDropdown(enriched.length > 0);
} catch (err) {
logError('Customer search failed', err);
setSearchResults([]);
} finally {
setSearching(false);
}
}, []);
// Debounced search
const handleSearchInput = useCallback((val: string) => {
setSearchQuery(val);
if (debounceRef.current) clearTimeout(debounceRef.current);
debounceRef.current = setTimeout(() => doSearch(val), 300);
}, [doSearch]);
// Close dropdown on outside click
useEffect(() => {
const handler = (e: MouseEvent) => {
if (searchRef.current && !searchRef.current.contains(e.target as Node)) {
setShowDropdown(false);
}
};
document.addEventListener('mousedown', handler);
return () => document.removeEventListener('mousedown', handler);
}, []);
// Cleanup debounce on unmount
useEffect(() => {
return () => {
if (debounceRef.current) clearTimeout(debounceRef.current);
if (dupeDebounceRef.current) clearTimeout(dupeDebounceRef.current);
};
}, []);
// ── Select existing customer ──
const selectCustomer = useCallback(async (c: CustomerSearchResult) => {
const userId = pb.authStore.model?.id;
try {
const full = await pb.collection('customers').getOne(c.id) as any;
// Try fetching vehicle from vehicles collection first
let vehicleInfo = '';
let vin = '';
let mileage = '';
let roNumber = '';
try {
const vResult = await pb.collection('vehicles').getList(1, 1, {
filter: `customerId = '${c.id.replace(/'/g, "\\'")}'`,
sort: '-created',
fields: 'year,make,model,vin,mileage',
});
if (vResult.items.length > 0) {
const v = vResult.items[0] as any;
vehicleInfo = [v.year, v.make, v.model].filter(Boolean).join(' ');
vin = v.vin || '';
mileage = v.mileage || '';
}
} catch {
// vehicles collection may not exist on older installs
}
// Fallback: look up vehicle info from recent quotes or ROs by customer name
if (!vehicleInfo && userId) {
const escapedName = (full.name || c.name || '').replace(/'/g, "\\'");
const nameFilter = `customerName = '${escapedName}' && userId = '${userId}'`;
// Try quotes
try {
const qResult = await pb.collection('quotes').getList(1, 1, {
filter: nameFilter,
sort: '-createdAt',
fields: 'vehicleInfo,vin,mileage,repairOrderNumber',
});
if (qResult.items.length > 0) {
const q = qResult.items[0] as any;
vehicleInfo = q.vehicleInfo || '';
vin = q.vin || '';
mileage = q.mileage || '';
roNumber = q.repairOrderNumber || '';
}
} catch { /* ignore */ }
// Try repair orders (only if still no vehicle info)
if (!vehicleInfo) {
try {
const roResult = await pb.collection('repairOrders').getList(1, 1, {
filter: nameFilter,
sort: '-createdAt',
fields: 'vehicleInfo,vin,mileage,roNumber',
});
if (roResult.items.length > 0) {
const ro = roResult.items[0] as any;
vehicleInfo = ro.vehicleInfo || '';
vin = ro.vin || '';
mileage = ro.mileage || '';
if (!roNumber) roNumber = ro.roNumber || '';
}
} catch { /* ignore */ }
}
}
const parts = (full.name || c.name || '').trim().split(/\s+/);
const middle = parts.length > 2 ? parts.slice(1, -1).join(' ') : '';
const last = parts.length > 1 ? parts[parts.length - 1] : '';
const current = useQuoteStore.getState().customerInfo;
const hasROOrigin = Boolean(current.repairOrderId);
setCustomerInfo({
customerId: c.id,
firstName: parts[0] || '',
middleName: middle,
lastName: last,
name: full.name || c.name || '',
phone: full.phone || c.phone || '',
email: full.email || '',
vehicleInfo: hasROOrigin ? current.vehicleInfo || vehicleInfo : vehicleInfo,
vin: hasROOrigin ? current.vin || vin : vin,
mileage: hasROOrigin ? current.mileage || mileage : mileage,
roNumber: hasROOrigin ? current.roNumber || roNumber : roNumber,
repairOrderId: current.repairOrderId,
});
} catch (e) {
logError('Failed to load customer record', e);
const parts = c.name.trim().split(/\s+/);
setCustomerInfo({
customerId: c.id,
firstName: parts[0] || '',
lastName: parts.slice(1).join(' ') || '',
name: c.name,
phone: c.phone,
});
}
setSearchQuery(c.name);
setShowDropdown(false);
setDuplicateWarning(null);
}, [setCustomerInfo]);
const clearCustomer = useCallback(() => {
setCustomerInfo({
customerId: undefined,
firstName: '',
lastName: '',
name: '',
});
setSearchQuery('');
setSearchResults([]);
setShowDropdown(false);
setDuplicateWarning(null);
}, [setCustomerInfo]);
// ── Duplicate detection ──
// When the user types first/last name or phone, check for matches
const checkDuplicates = useCallback(async (firstName: string, lastName: string, phone: string) => {
if (isLinked) return;
const nameQuery = `${firstName} ${lastName}`.trim();
const phoneDigits = phone.replace(/\D/g, '');
if (!nameQuery && phoneDigits.length < 4) {
setDuplicateWarning(null);
return;
}
try {
// Fetch all customers for this user and filter client-side
const result = await pb.collection('customers').getList(1, 500, {
fields: 'id,name,phone',
batch: 500,
});
const allCustomers = result.items as any[];
// Match by name or phone
const nameLc = nameQuery.toLowerCase();
const matched = allCustomers.filter((c: any) => {
const cName = (c.name || '').toLowerCase();
const cPhone = (c.phone || '').replace(/\D/g, '');
if (nameQuery && cName.includes(nameLc)) return true;
if (phoneDigits.length >= 4 && cPhone.includes(phoneDigits)) return true;
return false;
});
if (matched.length > 0) {
const best = matched[0] as any;
setDuplicateWarning({
customer: { id: best.id, name: best.name, phone: best.phone || '', vehicleCount: 0 },
message: nameQuery && best.name.toLowerCase().includes(nameLc) ? 'name' : 'phone',
});
} else {
setDuplicateWarning(null);
}
} catch {
setDuplicateWarning(null);
}
}, [isLinked]);
// Debounced duplicate check
const handleNameOrPhoneChange = useCallback((field: string, _val: string) => {
if (field === 'firstName' || field === 'lastName' || field === 'phone') {
if (dupeDebounceRef.current) clearTimeout(dupeDebounceRef.current);
// We'll check after the state updates — use a short delay
dupeDebounceRef.current = setTimeout(() => {
const state = useQuoteStore.getState().customerInfo;
checkDuplicates(state.firstName, state.lastName, state.phone);
}, 600);
}
}, [checkDuplicates]);
// Custom setter that also triggers duplicate check
const setInfo = useCallback((info: Partial<typeof customerInfo>) => {
setCustomerInfo(info);
handleNameOrPhoneChange(Object.keys(info)[0] || '', String(Object.values(info)[0] || ''));
}, [setCustomerInfo, handleNameOrPhoneChange]);
// ── Render helpers ──
const nameField = (key: 'firstName' | 'middleName' | 'lastName', label: string, placeholder: string, span: string) => {
const val = customerInfo[key] as string;
return (
<div className={span}>
<label className="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">{label}</label>
<input
value={val}
onChange={(e) => setInfo({ [key]: e.target.value })}
placeholder={placeholder}
disabled={isLinked}
className={`w-full rounded-lg border px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-green-500/20 disabled:cursor-not-allowed disabled:opacity-50 ${
isLinked
? 'border-green-200 bg-green-50 text-gray-500 dark:border-green-800 dark:bg-green-900/20 dark:text-gray-400'
: 'border-gray-300 bg-white text-gray-900 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100 placeholder-gray-400 focus:border-green-500'
}`}
/>
</div>
);
};
const field = (key: keyof typeof customerInfo, label: string, placeholder: string, span = 'col-span-1') => {
const isPhone = key === 'phone';
const val = customerInfo[key] as string;
const displayVal = isPhone ? formatPhoneInput(val) : val;
return (
<div className={span}>
<label className="block text-xs font-medium text-gray-500 dark:text-gray-400 mb-1">{label}</label>
<input
value={displayVal}
onChange={(e) => setInfo({ [key]: isPhone ? e.target.value.replace(/\D/g, '') : e.target.value })}
placeholder={placeholder}
disabled={key === 'serviceAdvisor' && isDefaultAdvisor(val) && !val}
className={`w-full rounded-lg border px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-green-500/20 ${
key === 'serviceAdvisor' && isDefaultAdvisor(val)
? 'border-gray-200 bg-gray-50 text-gray-400 dark:border-gray-700 dark:bg-gray-800/50 dark:text-gray-500 focus:border-green-500'
: 'border-gray-300 bg-white text-gray-900 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100 placeholder-gray-400 focus:border-green-500'
}`}
/>
</div>
);
};
return (
<div className="rounded-xl border border-gray-200 bg-white p-5 shadow-sm dark:border-gray-800 dark:bg-gray-900">
<h2 className="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-4">Customer Information</h2>
{/* ── Customer Search / Selector ── */}
<div ref={searchRef} className="relative mb-4">
<div className="relative">
{isLinked ? (
<div className="flex items-center gap-2 rounded-lg border border-green-300 bg-green-50 px-3 py-2.5 dark:border-green-700 dark:bg-green-900/20">
<UserRound className="h-4 w-4 shrink-0 text-green-600 dark:text-green-400" />
<span className="flex-1 text-sm font-medium text-green-800 dark:text-green-300 truncate">
{customerInfo.name}
</span>
{customerInfo.phone && (
<span className="text-xs text-green-600 dark:text-green-400 shrink-0">
{formatPhoneInput(customerInfo.phone)}
</span>
)}
<button
onClick={clearCustomer}
className="rounded p-0.5 text-green-600 hover:bg-green-200 dark:text-green-400 dark:hover:bg-green-800/40"
title="Clear customer selection"
>
<X className="h-3.5 w-3.5" />
</button>
</div>
) : (
<div className="relative">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-gray-400" />
<input
type="text"
value={searchQuery}
onChange={(e) => handleSearchInput(e.target.value)}
onFocus={() => { if (searchResults.length > 0) setShowDropdown(true); }}
placeholder="Search existing customers by name or phone..."
className="w-full rounded-lg border border-gray-300 bg-white py-2.5 pl-10 pr-3 text-sm text-gray-900 placeholder-gray-400 focus:border-green-500 focus:outline-none focus:ring-2 focus:ring-green-500/20 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100 dark:placeholder-gray-500"
/>
{searching && (
<div className="absolute right-3 top-1/2 h-4 w-4 -translate-y-1/2 animate-spin rounded-full border-2 border-green-500 border-t-transparent" />
)}
</div>
)}
</div>
{/* Dropdown */}
{showDropdown && searchResults.length > 0 && (
<div className="absolute left-0 right-0 top-full z-20 mt-1 max-h-56 overflow-y-auto rounded-lg border border-gray-200 bg-white shadow-lg dark:border-gray-700 dark:bg-gray-800">
{searchResults.map((c) => (
<button
key={c.id}
onClick={() => selectCustomer(c)}
className="flex w-full items-center gap-3 px-4 py-2.5 text-left text-sm hover:bg-gray-50 dark:hover:bg-gray-700/50 border-b border-gray-100 dark:border-gray-700 last:border-0"
>
<UserRound className="h-4 w-4 shrink-0 text-gray-400" />
<div className="flex-1 min-w-0">
<span className="font-medium text-gray-900 dark:text-gray-100">{c.name}</span>
{c.phone && (
<span className="ml-2 text-xs text-gray-500 dark:text-gray-400">
{formatPhoneInput(c.phone)}
</span>
)}
</div>
{c.vehicleCount > 0 && (
<span className="shrink-0 text-xs text-gray-400">{c.vehicleCount} vehicle{c.vehicleCount !== 1 ? 's' : ''}</span>
)}
</button>
))}
</div>
)}
</div>
{/* ── Duplicate Warning ── */}
{duplicateWarning && !isLinked && (
<div className="mb-4 flex items-start gap-3 rounded-lg border border-amber-200 bg-amber-50 p-3 dark:border-amber-800 dark:bg-amber-900/20">
<AlertTriangle className="mt-0.5 h-4 w-4 shrink-0 text-amber-600 dark:text-amber-400" />
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-amber-800 dark:text-amber-300">
Existing customer found
</p>
<p className="text-xs text-amber-700 dark:text-amber-400 mt-0.5">
{duplicateWarning.customer.name} {duplicateWarning.customer.phone && `(${formatPhoneInput(duplicateWarning.customer.phone)})`} already exists.
</p>
<div className="mt-2 flex gap-2">
<button
onClick={() => selectCustomer(duplicateWarning.customer)}
className="inline-flex items-center gap-1 rounded-lg bg-amber-600 px-3 py-1.5 text-xs font-medium text-white hover:bg-amber-700 transition-colors"
>
<Check className="h-3 w-3" /> Use Existing
</button>
<button
onClick={() => setDuplicateWarning(null)}
className="inline-flex items-center gap-1 rounded-lg border border-amber-300 bg-white px-3 py-1.5 text-xs font-medium text-amber-700 hover:bg-amber-50 transition-colors dark:border-amber-700 dark:bg-transparent dark:text-amber-400 dark:hover:bg-amber-900/20"
>
Create New
</button>
</div>
</div>
</div>
)}
{/* ── Name Fields (hidden when linked) ── */}
{!isLinked && (
<div className="grid grid-cols-1 sm:grid-cols-4 gap-4 mb-4">
{nameField('firstName', 'First Name *', 'John', 'sm:col-span-2')}
{nameField('middleName', 'Middle', '(optional)', 'sm:col-span-1')}
{nameField('lastName', 'Last Name *', 'Doe', 'sm:col-span-1')}
</div>
)}
{/* ── Other fields ── */}
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{field('phone', 'Phone', '(555) 555-5555')}
{field('vehicleInfo', 'Vehicle', '2020 Honda Civic')}
{field('vin', 'VIN', '1HGBH41JXMN109186')}
{field('mileage', 'Mileage', '45,000')}
{field('roNumber', 'RO #', 'RO-2024-001')}
{field('serviceAdvisor', roleLabel, `${roleLabel} name`, 'col-span-1 sm:col-span-2 lg:col-span-1')}
{showLocationFields && field('serviceLocation', 'Service Location', '123 Oak St, Knoxville, TN or driveway', 'col-span-1 sm:col-span-2')}
</div>
</div>
);
}
@@ -0,0 +1,543 @@
import { useState } from 'react';
import {
Download, Printer, Save, Image, Share2, Check, Copy, Loader2,
} from 'lucide-react';
import { useNavigate } from 'react-router-dom';
import { pb } from '../../lib/pocketbase';
import { useQuoteStore } from '../../store/quote';
import { useSettings } from '../../store/settings';
import { useToast } from '../ui/Toast';
import { downloadQuotePDF, printQuotePDF, generateQuotePDF } from '../../lib/pdf';
import { downloadPdfAsImages } from '../../lib/pdf-to-images';
import { computeQuoteTotals, computeSplitQuoteTotals } from '../../lib/totals';
import { quoteWriteSchema } from '../../schemas/quote';
import { sanitizeServices } from '../../lib/quoteHelpers';
import { logError } from '../../lib/userMessages';
import { openEmailDraft, openSmsDraft } from '../../lib/contactLinks';
import { syncApprovedQuoteServicesToRO } from '../../lib/quoteRoSync';
/* ── Helper: find or create a customer record in the customers collection ── */
async function ensureCustomerRecord(customerInfo: {
name: string; firstName: string; lastName: string; phone: string; email?: string;
}): Promise<string | undefined> {
if (!customerInfo.name.trim()) return undefined;
const userId = pb.authStore.model?.id;
if (!userId) return undefined;
// Check for existing customer by phone (strongest match)
const phoneDigits = customerInfo.phone.replace(/\D/g, '');
if (phoneDigits.length >= 4) {
try {
const existing = await pb.collection('customers').getFirstListItem(
`phone ~ "${phoneDigits}" && userId = "${userId}"`,
{ fields: 'id', batch: 1 }
);
if (existing) return existing.id;
} catch { /* not found — proceed to create */ }
}
// Check by name
const nameQuery = customerInfo.name.replace(/"/g, '');
try {
const existing = await pb.collection('customers').getFirstListItem(
`name = "${nameQuery}" && userId = "${userId}"`,
{ fields: 'id', batch: 1 }
);
if (existing) return existing.id;
} catch { /* not found — proceed to create */ }
// Create new customer record
try {
const created = await pb.collection('customers').create({
name: customerInfo.name,
firstName: customerInfo.firstName || customerInfo.name.split(' ')[0] || '',
lastName: customerInfo.lastName || customerInfo.name.split(' ').slice(1).join(' ') || '',
phone: customerInfo.phone,
email: customerInfo.email || '',
address: '',
notes: '',
userId,
});
return created.id;
} catch (err) {
logError('Failed to create customer record', err);
return undefined;
}
}
export function QuoteSummary({ editId, repairOriginId }: { editId?: string | null; repairOriginId?: string | null }) {
const { services, discount, setDiscount, customerInfo } = useQuoteStore();
const [saving, setSaving] = useState(false);
const [generating, setGenerating] = useState(false);
const [sharing, setSharing] = useState(false);
const [sendingNotify, setSendingNotify] = useState<'email' | 'sms' | null>(null);
const [shareUrl, setShareUrl] = useState<string | null>(null);
const navigate = useNavigate();
const { showToast } = useToast();
const settings = useSettings();
const hasApproved = services.some((s) => s.customerDecision === 'approved');
const hasPending = services.some((s) => s.customerDecision === 'pending');
const hasMixed = hasApproved && hasPending;
const t = computeQuoteTotals({ services, discount, settings });
const split = hasMixed ? computeSplitQuoteTotals(services, discount, settings) : null;
const sub = t.subtotal;
const discVal = t.discountAmount;
const shopCharge = t.shopCharge;
const tax = t.tax;
const grandTotal = t.total;
const originRepairOrderId = customerInfo.repairOrderId || repairOriginId || '';
const handleSave = async () => {
setSaving(true);
try {
// Auto-create or link customer record
let customerId = customerInfo.customerId;
if (!customerId) {
customerId = await ensureCustomerRecord({
name: customerInfo.name,
firstName: customerInfo.firstName,
lastName: customerInfo.lastName,
phone: customerInfo.phone,
email: customerInfo.email,
});
if (customerId) {
useQuoteStore.getState().setCustomerInfo({ customerId });
}
}
const data: Record<string, any> = {
userId: pb.authStore.model?.id,
customerId: customerId || '',
customerName: customerInfo.name,
customerPhone: customerInfo.phone,
vehicleInfo: customerInfo.vehicleInfo,
vin: customerInfo.vin,
mileage: customerInfo.mileage,
repairOrderNumber: customerInfo.roNumber,
serviceAdvisor: customerInfo.serviceAdvisor,
services: sanitizeServices(services),
discountValue: discount.value,
discountType: discount.type,
subtotal: sub,
tax,
total: grandTotal,
status: 'draft',
lastUpdated: new Date().toISOString(),
...(originRepairOrderId ? { repairOrderId: originRepairOrderId } : {}),
};
const parsed = quoteWriteSchema.safeParse(data);
if (!parsed.success) {
console.warn('Quote schema validation failed:', JSON.stringify(parsed.error.issues, null, 2));
showToast(parsed.error.issues[0].message, 'error');
setSaving(false);
return;
}
if (editId) {
await pb.collection('quotes').update(editId, data);
showToast('Quote updated successfully', 'success');
} else {
data.createdAt = new Date().toISOString();
await pb.collection('quotes').create(data);
showToast('Quote saved successfully', 'success');
}
// ── Sync approved quote services back to originating RO ──
if (originRepairOrderId) {
try {
const syncedCount = await syncApprovedQuoteServicesToRO({ pb, repairOrderId: originRepairOrderId, quoteServices: services, settings });
if (syncedCount > 0) showToast(`Synced ${syncedCount} approved service(s) to RO`, 'success');
} catch (err) {
logError('Failed to sync approved services to RO', err);
showToast('Sync to RO failed: ' + String(err), 'error');
}
}
navigate('/');
} catch (err) {
logError('Failed to save quote', err);
showToast('Quote could not be saved. Please check the customer and services, then try again.', 'error');
} finally {
setSaving(false);
}
};
const handlePdf = async (mode: 'download' | 'print' | 'images') => {
setGenerating(true);
try {
const quote = { id: '', customerInfo, services, discount, createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), status: 'draft' as const };
if (mode === 'download') await downloadQuotePDF(quote as any, settings);
else if (mode === 'print') await printQuotePDF(quote as any, settings);
else {
const blob = await generateQuotePDF(quote as any, settings);
await downloadPdfAsImages(blob, customerInfo.name || 'Customer');
}
} catch (err: any) {
logError('Failed to export quote', err);
showToast('Quote could not be exported. Please try again.', 'error');
} finally {
setGenerating(false);
}
};
const ensureShareToken = async (): Promise<{ quoteId: string; token: string; url: string } | null> => {
try {
let quoteId = editId;
if (!quoteId) {
// Ensure customer record exists first
const customerId = customerInfo.customerId || (await ensureCustomerRecord({
name: customerInfo.name,
firstName: customerInfo.firstName,
lastName: customerInfo.lastName,
phone: customerInfo.phone,
email: customerInfo.email,
})) || undefined;
const data: Record<string, any> = {
userId: pb.authStore.model?.id,
customerId: customerId || '',
customerName: customerInfo.name,
customerPhone: customerInfo.phone,
vehicleInfo: customerInfo.vehicleInfo,
vin: customerInfo.vin,
mileage: customerInfo.mileage,
repairOrderNumber: customerInfo.roNumber,
serviceAdvisor: customerInfo.serviceAdvisor,
services: sanitizeServices(services),
discountValue: discount.value,
discountType: discount.type,
subtotal: sub,
tax,
total: grandTotal,
status: 'sent',
lastUpdated: new Date().toISOString(),
...(originRepairOrderId ? { repairOrderId: originRepairOrderId } : {}),
createdAt: new Date().toISOString(),
};
const parsed = quoteWriteSchema.safeParse(data);
if (!parsed.success) {
console.warn('Quote schema validation failed in ensureShareToken:', JSON.stringify(parsed.error.issues, null, 2));
showToast(parsed.error.issues[0].message, 'error');
return null;
}
const created = await pb.collection('quotes').create(data);
quoteId = created.id;
// ── Sync approved quote services back to originating RO ──
if (originRepairOrderId) {
try {
const syncedCount = await syncApprovedQuoteServicesToRO({ pb, repairOrderId: originRepairOrderId, quoteServices: services, settings });
if (syncedCount > 0) showToast(`Synced ${syncedCount} approved service(s) to RO`, 'success');
} catch (err) {
logError('Failed to sync approved services to RO on share', err);
}
}
} else {
const data: Record<string, any> = {
userId: pb.authStore.model?.id,
customerId: customerInfo.customerId || '',
customerName: customerInfo.name,
customerPhone: customerInfo.phone,
vehicleInfo: customerInfo.vehicleInfo,
vin: customerInfo.vin,
mileage: customerInfo.mileage,
repairOrderNumber: customerInfo.roNumber,
serviceAdvisor: customerInfo.serviceAdvisor,
services: sanitizeServices(services),
discountValue: discount.value,
discountType: discount.type,
subtotal: sub,
tax,
total: grandTotal,
status: 'sent',
lastUpdated: new Date().toISOString(),
...(originRepairOrderId ? { repairOrderId: originRepairOrderId } : {}),
};
const parsed = quoteWriteSchema.safeParse(data);
if (!parsed.success) {
console.warn('Quote schema validation failed in ensureShareToken update:', JSON.stringify(parsed.error.issues, null, 2));
showToast(parsed.error.issues[0].message, 'error');
return null;
}
await pb.collection('quotes').update(quoteId, data);
if (originRepairOrderId) {
try {
const syncedCount = await syncApprovedQuoteServicesToRO({ pb, repairOrderId: originRepairOrderId, quoteServices: services, settings });
if (syncedCount > 0) showToast(`Synced ${syncedCount} approved service(s) to RO`, 'success');
} catch (err) {
logError('Failed to sync approved services to RO on share update', err);
}
}
}
const record = await pb.collection('quotes').getOne(quoteId);
let token = (record as any).shareToken;
if (!token) {
token = crypto.randomUUID();
await pb.collection('quotes').update(quoteId, {
shareToken: token,
lastUpdated: new Date().toISOString(),
});
}
const url = `${window.location.origin}/quote/approve/${token}`;
return { quoteId, token, url };
} catch (err) {
logError('Failed to ensure share token', err);
return null;
}
};
const handleShare = async () => {
if (!customerInfo.name || services.length === 0) return;
setSharing(true);
setShareUrl(null);
try {
const result = await ensureShareToken();
if (!result) {
showToast('Could not create share link. Please try again.', 'error');
return;
}
await navigator.clipboard.writeText(result.url);
setShareUrl(result.url);
showToast('Share link copied to clipboard', 'success');
} finally {
setSharing(false);
}
};
const handleSendEmail = async () => {
if (!customerInfo.name || services.length === 0) return;
setSendingNotify('email');
try {
const result = await ensureShareToken();
if (!result) return;
const to = customerInfo.phone;
const subject = `Service Estimate for ${customerInfo.vehicleInfo || 'your vehicle'}`;
const body = [
`Hi ${customerInfo.name},`,
``,
`Here is the link to review and approve your service estimate:`,
result.url,
``,
`Please review each service and submit your decisions online.`,
].join('\n');
openEmailDraft(to, subject, body);
showToast('Opened email draft.', 'success');
} finally {
setSendingNotify(null);
}
};
const handleSendSms = async () => {
if (!customerInfo.name || services.length === 0) return;
setSendingNotify('sms');
try {
const result = await ensureShareToken();
if (!result) return;
const to = customerInfo.phone;
const body = `Hi ${customerInfo.name}, review and approve your service estimate here: ${result.url}`;
openSmsDraft(to, body);
showToast('Opened SMS draft.', 'success');
} finally {
setSendingNotify(null);
}
};
return (
<div className="rounded-xl border border-gray-200 bg-gray-50 dark:border-gray-700 dark:bg-gray-800/50 p-5">
<h3 className="text-sm font-semibold text-gray-700 dark:text-gray-300 mb-4">Quote Summary</h3>
<div className="space-y-2 text-sm">
{split ? (
<div className="space-y-3">
{/* ── Approved Service Totals ── */}
<div className="rounded-lg border border-green-200 bg-green-50/60 dark:border-green-800 dark:bg-green-900/15 px-3 py-2.5">
<p className="text-xs font-bold uppercase tracking-wider text-green-700 dark:text-green-400 mb-2">Already Approved Work</p>
<div className="space-y-1 text-xs">
<div className="flex justify-between">
<span className="text-gray-500 dark:text-gray-400">Subtotal</span>
<span className="font-medium text-gray-900 dark:text-gray-100">${split.approved.subtotal.toFixed(2)}</span>
</div>
{split.approved.shopCharge > 0 && (
<div className="flex justify-between">
<span className="text-gray-500 dark:text-gray-400">Shop Charge</span>
<span className="text-gray-900 dark:text-gray-100">${split.approved.shopCharge.toFixed(2)}</span>
</div>
)}
{settings.taxRate > 0 && (
<div className="flex justify-between">
<span className="text-gray-500 dark:text-gray-400">Tax ({settings.taxRate}%)</span>
<span className="text-gray-900 dark:text-gray-100">${split.approved.tax.toFixed(2)}</span>
</div>
)}
<div className="border-t border-green-200 dark:border-green-700 pt-1 mt-1 flex justify-between font-semibold text-green-700 dark:text-green-400">
<span>Authorized Total</span>
<span>${split.approved.total.toFixed(2)}</span>
</div>
</div>
</div>
{/* ── Additional Recommendations ── */}
<div className="rounded-lg border border-blue-200 bg-blue-50/60 dark:border-blue-800 dark:bg-blue-900/15 px-3 py-2.5">
<p className="text-xs font-bold uppercase tracking-wider text-blue-700 dark:text-blue-400 mb-2">Additional Recommendations</p>
<div className="space-y-1 text-xs">
<div className="flex justify-between">
<span className="text-gray-500 dark:text-gray-400">Subtotal</span>
<span className="font-medium text-gray-900 dark:text-gray-100">${split.pending.subtotal.toFixed(2)}</span>
</div>
{split.pending.shopCharge > 0 && (
<div className="flex justify-between">
<span className="text-gray-500 dark:text-gray-400">Estimated Shop Charge</span>
<span className="text-gray-900 dark:text-gray-100">${split.pending.shopCharge.toFixed(2)}</span>
</div>
)}
{settings.taxRate > 0 && (
<div className="flex justify-between">
<span className="text-gray-500 dark:text-gray-400">Tax ({settings.taxRate}%)</span>
<span className="text-gray-900 dark:text-gray-100">${split.pending.tax.toFixed(2)}</span>
</div>
)}
<div className="border-t border-blue-200 dark:border-blue-700 pt-1 mt-1 flex justify-between font-semibold text-blue-700 dark:text-blue-400">
<span>Add-On Total If Approved</span>
<span>${split.pending.total.toFixed(2)}</span>
</div>
</div>
</div>
{/* ── Combined Total if All Approved ── */}
<div className="rounded-lg border border-gray-200 bg-white px-3 py-2.5 dark:border-gray-700 dark:bg-gray-800">
<p className="mb-2 text-xs font-bold uppercase tracking-wider text-gray-700 dark:text-gray-300">Total If All Work Is Approved</p>
<div className="space-y-1 text-xs">
<div className="flex justify-between">
<span className="text-gray-500 dark:text-gray-400">All Services Subtotal</span>
<span className="font-medium text-gray-900 dark:text-gray-100">${split.combined.subtotal.toFixed(2)}</span>
</div>
{split.combined.discountAmount > 0 && (
<div className="flex justify-between text-green-600 dark:text-green-400">
<span>Discount</span>
<span>-${split.combined.discountAmount.toFixed(2)}</span>
</div>
)}
{split.combined.shopCharge > 0 && (
<div className="flex justify-between">
<span className="text-gray-500 dark:text-gray-400">Shop Charge, capped at ${settings.shopChargeCap.toFixed(2)}</span>
<span className="text-gray-900 dark:text-gray-100">${split.combined.shopCharge.toFixed(2)}</span>
</div>
)}
{settings.taxRate > 0 && (
<div className="flex justify-between">
<span className="text-gray-500 dark:text-gray-400">Tax @ {settings.taxRate}%</span>
<span className="text-gray-900 dark:text-gray-100">${split.combined.tax.toFixed(2)}</span>
</div>
)}
<div className="border-t border-gray-200 pt-1.5 mt-1.5 flex justify-between font-semibold dark:border-gray-700">
<span className="text-gray-900 dark:text-white">Total If Approved</span>
<span className="text-green-600 dark:text-green-400">${split.combined.total.toFixed(2)}</span>
</div>
</div>
</div>
</div>
) : (
<div className="flex justify-between">
<span className="text-gray-500 dark:text-gray-400">Subtotal</span>
<span className="font-medium text-gray-900 dark:text-gray-100">${sub.toFixed(2)}</span>
</div>
)}
{/* ── Discount ── */}
<div className="flex items-center justify-between gap-2">
<span className="text-gray-500 dark:text-gray-400 shrink-0">Discount</span>
<div className="flex items-center gap-1">
<select
value={discount.type}
onChange={(e) => setDiscount(discount.value, e.target.value as any)}
className="text-xs rounded border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 text-gray-600 dark:text-gray-300 py-0.5 px-1"
>
<option value="dollar">$</option>
<option value="percent">%</option>
</select>
<input
type="number"
value={discount.value || ''}
onChange={(e) => setDiscount(Number(e.target.value) || 0, discount.type)}
placeholder="0"
className="w-16 text-right rounded border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-700 px-2 py-0.5 text-xs text-gray-900 dark:text-gray-100"
/>
</div>
</div>
{!split && (
<>
{discVal > 0 && (
<div className="flex justify-between text-green-600"><span></span><span>${discVal.toFixed(2)}</span></div>
)}
{shopCharge > 0 && (
<div className="flex justify-between"><span className="text-gray-500 dark:text-gray-400">Shop Charge</span><span className="text-gray-900 dark:text-gray-100">${shopCharge.toFixed(2)}</span></div>
)}
{settings.taxRate > 0 && (
<div className="flex justify-between"><span className="text-gray-500 dark:text-gray-400">Tax ({settings.taxRate}%)</span><span className="text-gray-900 dark:text-gray-100">${tax.toFixed(2)}</span></div>
)}
<div className="border-t border-gray-200 dark:border-gray-600 pt-2 mt-2 flex justify-between">
<span className="font-semibold text-gray-900 dark:text-white">TOTAL</span>
<span className="font-bold text-lg text-green-600 dark:text-green-400">${grandTotal.toFixed(2)}</span>
</div>
</>
)}
</div>
<div className="mt-5 space-y-2">
<button onClick={() => handlePdf('download')} disabled={generating || services.length === 0} className="w-full flex items-center justify-center gap-2 rounded-lg bg-green-600 px-4 py-2.5 text-sm font-semibold text-white hover:bg-green-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors">
<Download className="h-4 w-4" /> {generating ? 'Generating...' : 'Download PDF'}
</button>
<button onClick={() => handlePdf('print')} disabled={generating || services.length === 0} className="w-full flex items-center justify-center gap-2 rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 px-4 py-2.5 text-sm font-medium text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700 disabled:opacity-50 disabled:cursor-not-allowed transition-colors">
<Printer className="h-4 w-4" /> Print
</button>
<button onClick={() => handlePdf('images')} disabled={generating || services.length === 0} className="w-full flex items-center justify-center gap-2 rounded-lg border border-blue-300 dark:border-blue-700 bg-blue-50 dark:bg-blue-900/20 px-4 py-2.5 text-sm font-medium text-blue-700 dark:text-blue-400 hover:bg-blue-100 dark:hover:bg-blue-900/30 disabled:opacity-50 disabled:cursor-not-allowed transition-colors">
<Image className="h-4 w-4" /> {generating ? 'Generating...' : 'Export as Images'}
</button>
<button onClick={handleSave} disabled={saving || !customerInfo.name} className="w-full flex items-center justify-center gap-2 rounded-lg border border-green-300 dark:border-green-700 bg-green-50 dark:bg-green-900/20 px-4 py-2.5 text-sm font-medium text-green-700 dark:text-green-400 hover:bg-green-100 dark:hover:bg-green-900/30 disabled:opacity-50 disabled:cursor-not-allowed transition-colors">
<Save className="h-4 w-4" /> {saving ? 'Saving...' : 'Save Quote'}
</button>
<button
onClick={handleShare}
disabled={sharing || !customerInfo.name || services.length === 0}
className="w-full flex items-center justify-center gap-2 rounded-lg border border-purple-300 dark:border-purple-700 bg-purple-50 dark:bg-purple-900/20 px-4 py-2.5 text-sm font-medium text-purple-700 dark:text-purple-400 hover:bg-purple-100 dark:hover:bg-purple-900/30 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
>
<Share2 className="h-4 w-4" /> {sharing ? 'Creating Link...' : 'Share with Customer'}
</button>
{shareUrl && (
<div className="rounded-lg border border-purple-200 bg-purple-50/50 dark:border-purple-800 dark:bg-purple-900/10 px-3 py-2">
<div className="flex items-center gap-2">
<Check className="h-3.5 w-3.5 text-purple-600 dark:text-purple-400 shrink-0" />
<p className="text-xs text-purple-700 dark:text-purple-300 break-all">{shareUrl}</p>
</div>
<div className="mt-2 flex gap-2">
<button
onClick={handleSendEmail}
disabled={sendingNotify !== null}
className="flex items-center justify-center gap-1.5 rounded-md border border-green-300 dark:border-green-700 bg-green-50 dark:bg-green-900/20 px-3 py-1.5 text-xs font-medium text-green-700 dark:text-green-400 hover:bg-green-100 dark:hover:bg-green-900/30 disabled:opacity-50 transition-colors flex-1"
>
{sendingNotify === 'email' ? <Loader2 className="h-3 w-3 animate-spin" /> : null}
Send Email
</button>
<button
onClick={handleSendSms}
disabled={sendingNotify !== null}
className="flex items-center justify-center gap-1.5 rounded-md border border-blue-300 dark:border-blue-700 bg-blue-50 dark:bg-blue-900/20 px-3 py-1.5 text-xs font-medium text-blue-700 dark:text-blue-400 hover:bg-blue-100 dark:hover:bg-blue-900/30 disabled:opacity-50 transition-colors flex-1"
>
{sendingNotify === 'sms' ? <Loader2 className="h-3 w-3 animate-spin" /> : null}
Send Text
</button>
</div>
<button
onClick={() => { navigator.clipboard.writeText(shareUrl); showToast('Link copied', 'success'); }}
className="mt-1.5 flex items-center gap-1 text-xs font-medium text-purple-600 dark:text-purple-400 hover:text-purple-700 dark:hover:text-purple-300 transition-colors"
>
<Copy className="h-3 w-3" /> Copy again
</button>
</div>
)}
</div>
</div>
);
}
@@ -0,0 +1,9 @@
import { RECENT_STATUS_COLORS, RECENT_STATUS_LABELS } from './types';
export function RecentStatusBadge({ status }: { status: string }) {
return (
<span className={`inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium ${RECENT_STATUS_COLORS[status] || RECENT_STATUS_COLORS.draft}`}>
{RECENT_STATUS_LABELS[status] || status}
</span>
);
}
@@ -0,0 +1,365 @@
import { memo } from 'react';
import { Trash2, Sparkles, Loader2 } from 'lucide-react';
import { PRIORITY_COLORS } from './types';
import { useSettings } from '../../store/settings';
import type { QuoteAuthorizationMethod, QuoteService } from '../../types';
interface ServiceRowProps {
service: QuoteService;
expanded: boolean;
aiLoading: boolean;
customerName: string;
authorizationMethod: QuoteAuthorizationMethod;
onToggleExpand: () => void;
onAiWrite: () => void;
onToggleApproved: () => void;
onToggleDeclined: () => void;
onRemove: () => void;
onUpdate: (updates: Partial<QuoteService>) => void;
}
export const ServiceRow = memo(function ServiceRow({
service,
expanded,
aiLoading,
customerName,
authorizationMethod,
onToggleExpand,
onAiWrite,
onToggleApproved,
onToggleDeclined,
onRemove,
onUpdate,
}: ServiceRowProps) {
const settings = useSettings();
return (
<div className={`rounded-lg transition-colors ${!service.approved && service.customerDecision === 'declined' ? 'opacity-50' : ''}`}>
<div className="flex items-center gap-3 px-4 py-2.5 hover:bg-gray-50 dark:hover:bg-gray-800/50 rounded-lg">
<button
onClick={onToggleApproved}
className={`shrink-0 w-5 h-5 rounded border-2 flex items-center justify-center transition-colors ${
service.approved ? 'bg-green-600 border-green-600 text-white' : 'border-gray-300 dark:border-gray-600 text-gray-300 dark:text-gray-600'
}`}
>
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={3} d="M5 13l4 4L19 7" /></svg>
</button>
<button
onClick={onToggleDeclined}
className={`shrink-0 w-5 h-5 rounded border-2 flex items-center justify-center transition-colors ${
service.customerDecision === 'declined' ? 'bg-red-600 border-red-600 text-white' : 'border-gray-300 dark:border-gray-600 text-gray-300 dark:text-gray-600'
}`}
>
<svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={3} d="M6 6l12 12M18 6L6 18" /></svg>
</button>
<div className="flex-1 min-w-0">
<p className="text-sm font-medium text-gray-900 dark:text-gray-100 truncate">{service.name}</p>
</div>
<span
onClick={onToggleExpand}
className="text-sm font-semibold text-green-600 dark:text-green-400 whitespace-nowrap cursor-pointer hover:underline"
title="Click to edit"
>${service.price.toFixed(2)}</span>
<button
onClick={onAiWrite}
disabled={aiLoading}
title="AI Write Explanation"
className="shrink-0 p-1.5 rounded-lg text-purple-500 hover:text-purple-600 hover:bg-purple-50 dark:hover:bg-purple-900/20 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
>
{aiLoading ? <Loader2 className="h-4 w-4 animate-spin" /> : <Sparkles className="h-4 w-4" />}
</button>
<button
onClick={onToggleExpand}
className={`shrink-0 p-1.5 rounded-lg text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors ${expanded ? 'rotate-180' : ''}`}
>
<svg className="h-3 w-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M19 9l-7 7-7-7" /></svg>
</button>
<button onClick={onRemove} className="shrink-0 p-1.5 rounded-lg text-gray-400 hover:text-red-500 hover:bg-red-50 dark:hover:bg-red-900/20 transition-colors">
<Trash2 className="h-4 w-4" />
</button>
</div>
{expanded && (
<div className="px-4 pb-3 pl-12 space-y-2">
{/* Row 1: Priority + Pricing Mode + Price + Shop Charge */}
<div className="flex gap-2 items-center flex-wrap">
{service.priority && (
<span className={`inline-block text-xs font-semibold px-2 py-0.5 rounded-full border ${PRIORITY_COLORS[service.priority] || PRIORITY_COLORS.MAINTENANCE}`}>
{service.priority.replace(/_/g, ' ')}
</span>
)}
{/* Pricing mode toggle */}
<div className="flex rounded-lg border border-gray-300 dark:border-gray-600 overflow-hidden text-xs">
<button
type="button"
onClick={() => {
if (service.pricingMode !== 'full') {
onUpdate({ pricingMode: 'full' });
}
}}
className={`px-2.5 py-1 font-medium transition-colors ${
(!service.pricingMode || service.pricingMode === 'full')
? 'bg-blue-600 text-white'
: 'bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-400 hover:bg-gray-200 dark:hover:bg-gray-600'
}`}
>
Full
</button>
<button
type="button"
onClick={() => {
if (service.pricingMode !== 'split') {
const rate = settings.defaultLaborRate || 95;
const hours = rate > 0 ? Math.round((service.price / rate) * 100) / 100 : 0;
onUpdate({ pricingMode: 'split', laborHours: hours, laborRate: rate, partsCost: 0 });
}
}}
className={`px-2.5 py-1 font-medium transition-colors ${
service.pricingMode === 'split'
? 'bg-blue-600 text-white'
: 'bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-400 hover:bg-gray-200 dark:hover:bg-gray-600'
}`}
>
Split
</button>
</div>
{service.pricingMode === 'split' ? (
<>
<div className="flex items-center gap-1">
<label className="text-xs text-gray-500 dark:text-gray-400">Hrs</label>
<input
type="number"
min="0"
step="0.1"
value={service.laborHours ?? ''}
onChange={(e) => {
const hrs = parseFloat(e.target.value) || 0;
const rate = service.laborRate || settings.defaultLaborRate || 95;
const parts = service.partsCost || 0;
onUpdate({ laborHours: hrs, price: Math.round((hrs * rate + parts) * 100) / 100 });
}}
className="w-14 rounded border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 px-2 py-1 text-sm text-gray-900 dark:text-gray-100 focus:border-green-500 focus:ring-1 focus:ring-green-500/20 focus:outline-none"
/>
</div>
<span className="text-xs text-gray-400">×</span>
<div className="flex items-center gap-1">
<label className="text-xs text-gray-500 dark:text-gray-400">$</label>
<input
type="number"
min="0"
step="1"
value={service.laborRate ?? ''}
onChange={(e) => {
const rate = parseFloat(e.target.value) || 0;
const hrs = service.laborHours || 0;
const parts = service.partsCost || 0;
onUpdate({ laborRate: rate, price: Math.round((hrs * rate + parts) * 100) / 100 });
}}
className="w-16 rounded border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 px-2 py-1 text-sm text-gray-900 dark:text-gray-100 focus:border-green-500 focus:ring-1 focus:ring-green-500/20 focus:outline-none"
/>
</div>
<span className="text-xs text-gray-400">+</span>
<div className="flex items-center gap-1">
<label className="text-xs text-gray-500 dark:text-gray-400">Parts</label>
<input
type="number"
min="0"
step="0.01"
value={service.partsCost ?? ''}
onChange={(e) => {
const parts = parseFloat(e.target.value) || 0;
const hrs = service.laborHours || 0;
const rate = service.laborRate || settings.defaultLaborRate || 95;
onUpdate({ partsCost: parts, price: Math.round((hrs * rate + parts) * 100) / 100 });
}}
className="w-20 rounded border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 px-2 py-1 text-sm text-gray-900 dark:text-gray-100 focus:border-green-500 focus:ring-1 focus:ring-green-500/20 focus:outline-none"
/>
</div>
<span className="text-xs font-semibold text-green-600 dark:text-green-400">
= ${service.price.toFixed(2)}
</span>
</>
) : (
<div className="flex items-center gap-1">
<label className="text-xs text-gray-500 dark:text-gray-400">$</label>
<input
type="number"
min="0"
step="0.01"
value={service.price}
onChange={(e) => onUpdate({ price: parseFloat(e.target.value) || 0 })}
className="w-24 rounded border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 px-2 py-1 text-sm text-gray-900 dark:text-gray-100 focus:border-green-500 focus:ring-1 focus:ring-green-500/20 focus:outline-none"
/>
</div>
)}
{/* Shop charge toggle */}
<label className="flex items-center gap-1.5 text-xs text-gray-600 dark:text-gray-400 cursor-pointer select-none">
<input
type="checkbox"
checked={service.applyShopCharge !== false}
onChange={(e) => onUpdate({ applyShopCharge: e.target.checked })}
className="h-3.5 w-3.5 rounded border-gray-300 text-green-600 focus:ring-green-500"
/>
Shop charge
</label>
{service.customerDecision === 'approved' && (
<span className="text-xs bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-300 px-2 py-0.5 rounded-full font-medium">Approved</span>
)}
{service.customerDecision === 'declined' && (
<span className="text-xs bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-300 px-2 py-0.5 rounded-full font-medium">Declined</span>
)}
</div>
{/* Recommendation */}
<input
value={service.recommendation || ''}
onChange={(e) => onUpdate({ recommendation: e.target.value })}
placeholder="Recommendation reason (e.g., worn to 3mm, due at 60K)"
className="w-full rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 px-3 py-2 text-sm text-gray-900 dark:text-gray-100 placeholder-gray-400 focus:border-green-500 focus:ring-2 focus:ring-green-500/20 focus:outline-none"
/>
{/* Quick Parts Toggle */}
<div className="pt-1">
<label className="text-xs font-medium text-gray-600 dark:text-gray-400 block mb-1.5">Quick Parts Toggle:</label>
<div className="flex flex-wrap gap-1.5">
<button
type="button"
onClick={() => onUpdate({ noPartsRequired: !service.noPartsRequired, partsNotInStock: false, aftermarketAvailable: false })}
className={`px-2 py-1 text-xs rounded border font-medium transition-colors ${service.noPartsRequired ? 'bg-gray-600 text-white border-gray-600' : 'bg-white text-gray-800 border-gray-300 hover:bg-gray-50 dark:bg-gray-800 dark:text-gray-300 dark:border-gray-600 dark:hover:bg-gray-700'}`}
>Labor Only</button>
<button
type="button"
onClick={() => onUpdate({ noPartsRequired: false, partsNotInStock: false, aftermarketAvailable: false })}
className={`px-2 py-1 text-xs rounded border font-medium transition-colors ${!service.noPartsRequired && !service.partsNotInStock && !service.aftermarketAvailable ? 'bg-blue-600 text-white border-blue-600' : 'bg-white text-gray-800 border-gray-300 hover:bg-blue-50 dark:bg-gray-800 dark:text-gray-300 dark:border-gray-600 dark:hover:bg-blue-900/30'}`}
>In Stock</button>
<button
type="button"
onClick={() => onUpdate({ partsNotInStock: !service.partsNotInStock, noPartsRequired: false, aftermarketAvailable: false })}
className={`px-2 py-1 text-xs rounded border font-medium transition-colors ${service.partsNotInStock ? 'bg-orange-600 text-white border-orange-600' : 'bg-white text-gray-800 border-gray-300 hover:bg-orange-50 dark:bg-gray-800 dark:text-gray-300 dark:border-gray-600 dark:hover:bg-orange-900/30'}`}
>Need Order</button>
<button
type="button"
onClick={() => onUpdate({ aftermarketAvailable: !service.aftermarketAvailable, noPartsRequired: false, partsNotInStock: false })}
className={`px-2 py-1 text-xs rounded border font-medium transition-colors ${service.aftermarketAvailable ? 'bg-green-600 text-white border-green-600' : 'bg-white text-gray-800 border-gray-300 hover:bg-green-50 dark:bg-gray-800 dark:text-gray-300 dark:border-gray-600 dark:hover:bg-green-900/30'}`}
>Aftermarket</button>
</div>
</div>
{/* Parts Status Visual Display */}
{service.noPartsRequired ? (
<div className="p-3 bg-gray-50 dark:bg-gray-900/20 rounded-md border border-gray-200 dark:border-gray-800">
<p className="text-sm font-bold text-gray-700 dark:text-gray-300">Labor-Only Service</p>
<p className="text-xs text-gray-600 dark:text-gray-400">No parts required for this service.</p>
</div>
) : service.aftermarketAvailable ? (
<div className="p-3 bg-green-50 dark:bg-green-900/20 rounded-md border border-green-200 dark:border-green-800">
<p className="text-sm font-bold text-green-700 dark:text-green-300">Aftermarket Parts Will Be Used</p>
{service.aftermarketPartsList && (
<p className="text-sm text-green-600 dark:text-green-400 mt-1"><strong>Parts:</strong> {service.aftermarketPartsList}</p>
)}
<p className="text-xs text-green-600 dark:text-green-400 mt-1">Aftermarket parts will be used for this service.</p>
<div className="mt-2">
<label className="text-xs text-green-600 dark:text-green-400">Parts List:</label>
<input
type="text"
value={service.aftermarketPartsList || ''}
onChange={(e) => onUpdate({ aftermarketPartsList: e.target.value })}
placeholder="e.g., Duralast Gold Ceramic Pads, Cardone Reman Calipers"
className="w-full mt-1 rounded border border-green-300 dark:border-green-700 bg-white dark:bg-gray-800 px-2 py-1.5 text-xs text-gray-900 dark:text-gray-100 focus:border-green-500 focus:ring-1 focus:ring-green-500/20 focus:outline-none"
/>
</div>
</div>
) : service.partsNotInStock ? (
<div className="p-3 bg-orange-50 dark:bg-orange-900/20 rounded-md border border-orange-200 dark:border-orange-800">
<p className="text-sm font-bold text-orange-700 dark:text-orange-300">Parts Status: Need to Order</p>
{service.partsDeliveryTime && (
<p className="text-sm text-orange-600 dark:text-orange-400 mb-1"><strong>Estimated Delivery:</strong> {service.partsDeliveryTime}</p>
)}
<p className="text-xs text-orange-600 dark:text-orange-400">Parts for this service are not currently in stock and will need to be ordered.</p>
<div className="mt-2">
<label className="text-xs text-orange-600 dark:text-orange-400">Estimated Delivery Time:</label>
<input
type="text"
value={service.partsDeliveryTime || ''}
onChange={(e) => onUpdate({ partsDeliveryTime: e.target.value })}
placeholder="e.g., 2-3 business days"
className="w-full mt-1 rounded border border-orange-300 dark:border-orange-700 bg-white dark:bg-gray-800 px-2 py-1.5 text-xs text-gray-900 dark:text-gray-100 focus:border-orange-500 focus:ring-1 focus:ring-orange-500/20 focus:outline-none"
/>
</div>
</div>
) : (
<div className="p-3 bg-blue-50 dark:bg-blue-900/20 rounded-md border border-blue-200 dark:border-blue-800">
<p className="text-sm font-bold text-blue-700 dark:text-blue-300">Parts In Stock</p>
<p className="text-xs text-blue-600 dark:text-blue-400">All required parts are available and ready for installation.</p>
</div>
)}
{/* Customer Explanation */}
<textarea
value={service.explanation || ''}
onChange={(e) => onUpdate({ explanation: e.target.value })}
placeholder="AI-generated explanation will appear here. Click the Sparkles icon to generate."
rows={3}
className="w-full rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 px-3 py-2 text-sm text-gray-900 dark:text-gray-100 placeholder-gray-400 focus:border-green-500 focus:ring-2 focus:ring-green-500/20 focus:outline-none resize-y"
/>
{/* Customer Decision */}
<div className="pt-1 border-t border-gray-200 dark:border-gray-700">
<label className="text-xs font-medium text-gray-600 dark:text-gray-400 block mb-1.5">Customer Decision:</label>
<div className="flex flex-wrap gap-1.5">
<button
type="button"
onClick={() => onUpdate({ customerDecision: 'approved', approved: true, authorizedBy: service.authorizedBy || customerName || '', authorizedAt: new Date().toISOString(), authorizedMethod: authorizationMethod })}
className={`px-2 py-1 text-xs rounded border font-medium transition-colors ${service.customerDecision === 'approved' ? 'bg-green-600 text-white border-green-600' : 'bg-white text-gray-800 border-gray-300 hover:bg-green-50 hover:border-green-400 dark:bg-gray-800 dark:text-gray-300 dark:border-gray-600'}`}
>Approve</button>
<button
type="button"
onClick={() => onUpdate({ customerDecision: 'declined', approved: false, authorizedBy: service.authorizedBy || customerName || '', authorizedAt: new Date().toISOString(), authorizedMethod: authorizationMethod })}
className={`px-2 py-1 text-xs rounded border font-medium transition-colors ${service.customerDecision === 'declined' ? 'bg-red-600 text-white border-red-600' : 'bg-white text-gray-800 border-gray-300 hover:bg-red-50 hover:border-red-400 dark:bg-gray-800 dark:text-gray-300 dark:border-gray-600'}`}
>Decline</button>
<button
type="button"
onClick={() => onUpdate({ customerDecision: 'pending', approved: false, authorizedBy: undefined, authorizedAt: undefined, authorizedMethod: undefined })}
className={`px-2 py-1 text-xs rounded border font-medium transition-colors ${(!service.customerDecision || service.customerDecision === 'pending') ? 'bg-yellow-600 text-white border-yellow-600' : 'bg-white text-gray-800 border-gray-300 hover:bg-yellow-50 dark:bg-gray-800 dark:text-gray-300 dark:border-gray-600'}`}
>Pending</button>
</div>
{(service.customerDecision === 'approved' || service.customerDecision === 'declined') && (
<div className="mt-2 grid gap-2">
<div>
<label className="text-[10px] font-medium text-gray-500 dark:text-gray-400">Authorized by</label>
<input
type="text"
value={service.authorizedBy || ''}
onChange={(e) => onUpdate({ authorizedBy: e.target.value })}
placeholder={customerName || 'Customer name'}
className="w-full mt-0.5 rounded border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 px-2 py-1 text-xs text-gray-900 dark:text-gray-100 focus:border-green-500 focus:ring-1 focus:ring-green-500/20 focus:outline-none"
/>
</div>
{service.authorizedAt && (
<div>
<span className="text-[10px] text-gray-400 dark:text-gray-500">
{service.customerDecision === 'approved' ? 'Approved' : 'Declined'} on {new Date(service.authorizedAt).toLocaleDateString()} at {new Date(service.authorizedAt).toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' })}
</span>
</div>
)}
</div>
)}
</div>
{/* Technician Notes (internal only) */}
<div className="border-t border-gray-200 dark:border-gray-700 pt-2">
<details className="text-xs" open={!!service.technicianNotes}>
<summary className="text-gray-400 dark:text-gray-500 cursor-pointer hover:text-gray-600 dark:hover:text-gray-300 select-none">Tech Notes (Internal)</summary>
<textarea
value={service.technicianNotes || ''}
onChange={(e) => onUpdate({ technicianNotes: e.target.value })}
placeholder="Internal notes — not shown to customer..."
rows={2}
className="mt-1.5 w-full rounded border border-amber-200 dark:border-amber-800 bg-amber-50 dark:bg-amber-900/20 px-3 py-2 text-xs text-amber-800 dark:text-amber-200 placeholder-amber-400 dark:placeholder-amber-600 focus:border-amber-500 focus:ring-1 focus:ring-amber-500/20 focus:outline-none resize-y"
/>
</details>
</div>
</div>
)}
</div>
);
});
@@ -0,0 +1,113 @@
import { useState, useEffect, useRef } from 'react';
import { Search, Plus } from 'lucide-react';
import { pb } from '../../lib/pocketbase';
import { useQuoteStore } from '../../store/quote';
import { loadSettings } from '../../lib/settings';
import type { Service } from '../../types';
export function ServiceSearch() {
const [query, setQuery] = useState('');
const [results, setResults] = useState<Service[]>([]);
const [allServices, setAllServices] = useState<Service[]>([]);
const [open, setOpen] = useState(false);
const [loading, setLoading] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
const dropdownRef = useRef<HTMLDivElement>(null);
const debounceRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const { addService } = useQuoteStore();
useEffect(() => {
const load = async () => {
try {
const userId = pb.authStore.model?.id;
if (!userId) return;
const records = await pb.collection('services').getFullList({ filter: `userId="${userId}"`, batch: 500 });
setAllServices(records.map((r: any) => ({ id: r.id, name: r.name, price: r.price || 0, explanation: r.explanation, recommendation: r.recommendation, applyShopCharge: r.applyShopCharge, laborHours: r.laborHours || undefined, laborRate: r.laborRate || undefined, partsCost: r.partsCost || undefined, pricingMode: r.pricingMode || undefined })));
} catch {}
};
load();
}, []);
useEffect(() => {
if (debounceRef.current) clearTimeout(debounceRef.current);
if (!query.trim()) { setResults([]); setLoading(false); return; }
setLoading(true);
debounceRef.current = setTimeout(() => {
const filtered = allServices
.filter((s) => s.name.toLowerCase().includes(query.toLowerCase()))
.slice(0, 8);
setResults(filtered);
setLoading(false);
}, 120);
return () => {
if (debounceRef.current) clearTimeout(debounceRef.current);
};
}, [query, allServices]);
useEffect(() => {
const handler = (e: MouseEvent) => {
if (dropdownRef.current && !dropdownRef.current.contains(e.target as Node) && inputRef.current && !inputRef.current.contains(e.target as Node)) {
setOpen(false);
}
};
document.addEventListener('mousedown', handler);
return () => document.removeEventListener('mousedown', handler);
}, []);
const handleAdd = (service: Service) => {
addService({ ...service, selected: true, approved: false, customerDecision: 'pending', priority: undefined, applyShopCharge: true, pricingMode: service.pricingMode || loadSettings().defaultPricingMode });
setQuery('');
setOpen(false);
};
return (
<div className="relative">
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-400" />
<input
ref={inputRef}
value={query}
onChange={(e) => { setQuery(e.target.value); setOpen(true); }}
onFocus={() => { if (query) setOpen(true); }}
placeholder="Search services..."
className="w-full rounded-lg border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 pl-10 pr-4 py-2.5 text-sm text-gray-900 dark:text-gray-100 placeholder-gray-400 focus:border-green-500 focus:ring-2 focus:ring-green-500/20 focus:outline-none"
/>
</div>
{open && (query.trim() || loading) && (
<div ref={dropdownRef} className="absolute z-50 mt-1 w-full rounded-lg border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-800 shadow-xl max-h-64 overflow-y-auto">
{loading ? (
<div className="p-4 text-center text-sm text-gray-400">Searching...</div>
) : results.length === 0 ? (
<div className="p-4 text-center text-sm text-gray-400">No matching services</div>
) : (
results.map((s) => (
<button
key={s.id}
onClick={() => handleAdd(s)}
className="w-full text-left px-4 py-2.5 text-sm hover:bg-gray-50 dark:hover:bg-gray-700 flex justify-between items-center"
>
<span className="text-gray-900 dark:text-gray-100">{s.name}</span>
<span className="text-green-600 dark:text-green-400 font-medium">${s.price.toFixed(2)}</span>
</button>
))
)}
{query.trim() && (
<button
onClick={() => {
const customService: Service = {
id: `custom-${Date.now()}`,
name: query.trim(),
price: 0,
};
handleAdd(customService);
}}
className="w-full text-left px-4 py-2.5 text-sm text-green-600 dark:text-green-400 hover:bg-gray-50 dark:hover:bg-gray-700 border-t border-gray-100 dark:border-gray-700 font-medium"
>
<Plus className="inline h-3 w-3 mr-1" /> Add &ldquo;{query}&rdquo; as custom service
</button>
)}
</div>
)}
</div>
);
}
@@ -0,0 +1,161 @@
import { useState, useCallback } from 'react';
import { FileText } from 'lucide-react';
import { useQuoteStore } from '../../store/quote';
import { useToast } from '../ui/Toast';
import { aiWriteExplanation } from '../../lib/ai';
import { logError } from '../../lib/userMessages';
import { ServiceRow } from './ServiceRow';
import type { QuoteAuthorizationMethod, QuoteService } from '../../types';
const authorizationOptions: Array<{ value: QuoteAuthorizationMethod; label: string }> = [
{ value: 'in_person', label: 'In Person' },
{ value: 'text', label: 'Text' },
{ value: 'phone', label: 'Call' },
];
export function ServicesTable() {
const {
services,
removeService,
customerInfo,
currentWorkAuthorizationMethod,
additionalWorkAuthorizationMethod,
setCurrentWorkAuthorizationMethod,
setAdditionalWorkAuthorizationMethod,
} = useQuoteStore();
const updateService = useQuoteStore((s) => s.updateService);
const [expanded, setExpanded] = useState<Record<string, boolean>>({});
const [aiLoading, setAiLoading] = useState<Record<string, boolean>>({});
const { showToast } = useToast();
const approved = services.filter((s) => s.customerDecision === 'approved');
const pending = services.filter((s) => s.customerDecision === 'pending');
const declined = services.filter((s) => s.customerDecision === 'declined');
const handleAiWrite = useCallback(async (svc: QuoteService) => {
setAiLoading((prev) => ({ ...prev, [svc.id]: true }));
try {
const result = await aiWriteExplanation({
serviceName: svc.name,
recommendation: svc.recommendation || '',
technicianNotes: svc.technicianNotes,
});
if (result) {
updateService(svc.id, {
explanation: result.explanation,
priority: result.level as QuoteService['priority'],
});
setExpanded((prev) => ({ ...prev, [svc.id]: true }));
showToast(`AI explanation generated for ${svc.name}`, 'success');
} else {
showToast('AI Write returned no result', 'error');
}
} catch (err) {
logError('Failed to generate AI explanation', err);
showToast('AI explanation could not be generated. Please try again.', 'error');
} finally {
setAiLoading((prev) => ({ ...prev, [svc.id]: false }));
}
}, [updateService, showToast]);
if (services.length === 0) {
return (
<div className="rounded-xl border border-dashed border-gray-300 dark:border-gray-700 p-8 text-center">
<FileText className="mx-auto h-8 w-8 text-gray-300 dark:text-gray-600" />
<p className="mt-2 text-sm text-gray-400">No services have been added to this quote yet. Search the catalog above to add billable work items.</p>
</div>
);
}
const handleApproveAll = () => {
const now = new Date().toISOString();
const notApproved = services.filter((s) => s.customerDecision !== 'approved');
notApproved.forEach((s) => updateService(s.id, { approved: true, customerDecision: 'approved', authorizedBy: s.authorizedBy || customerInfo.name || '', authorizedAt: now, authorizedMethod: additionalWorkAuthorizationMethod }));
};
const decideService = (service: QuoteService, decision: 'approved' | 'declined') => {
const isAdditionalRecommendation = service.customerDecision === 'pending';
const method = isAdditionalRecommendation ? additionalWorkAuthorizationMethod : currentWorkAuthorizationMethod;
updateService(service.id, {
customerDecision: decision,
approved: decision === 'approved',
authorizedBy: service.authorizedBy || customerInfo.name || '',
authorizedAt: new Date().toISOString(),
authorizedMethod: method,
});
};
const resetServiceDecision = (service: QuoteService) => {
updateService(service.id, { customerDecision: 'pending', approved: false, authorizedBy: undefined, authorizedAt: undefined, authorizedMethod: undefined });
};
const notApprovedCount = services.filter((s) => s.customerDecision !== 'approved').length;
const makeRowProps = (s: QuoteService) => ({
service: s,
expanded: expanded[s.id] ?? false,
aiLoading: aiLoading[s.id] ?? false,
customerName: customerInfo.name,
authorizationMethod: s.customerDecision === 'pending' ? additionalWorkAuthorizationMethod : currentWorkAuthorizationMethod,
onToggleExpand: () => setExpanded((prev) => ({ ...prev, [s.id]: !prev[s.id] })),
onAiWrite: () => handleAiWrite(s),
onToggleApproved: () => s.approved ? resetServiceDecision(s) : decideService(s, 'approved'),
onToggleDeclined: () => s.customerDecision === 'declined' ? resetServiceDecision(s) : decideService(s, 'declined'),
onRemove: () => removeService(s.id),
onUpdate: (updates: Partial<QuoteService>) => updateService(s.id, updates),
});
return (
<div className="space-y-1">
<div className="mb-3 grid gap-2 rounded-xl border border-gray-200 bg-gray-50 p-3 dark:border-gray-700 dark:bg-gray-900/40 sm:grid-cols-2">
<label className="text-xs font-medium text-gray-600 dark:text-gray-300">
Already approved/declined work authorized by
<select
value={currentWorkAuthorizationMethod}
onChange={(e) => setCurrentWorkAuthorizationMethod(e.target.value as QuoteAuthorizationMethod)}
className="mt-1 w-full rounded border border-gray-300 bg-white px-2 py-1.5 text-xs text-gray-900 focus:border-green-500 focus:outline-none focus:ring-1 focus:ring-green-500/20 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100"
>
{authorizationOptions.map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}
</select>
</label>
<label className="text-xs font-medium text-gray-600 dark:text-gray-300">
Additional recommendations authorized by
<select
value={additionalWorkAuthorizationMethod}
onChange={(e) => setAdditionalWorkAuthorizationMethod(e.target.value as QuoteAuthorizationMethod)}
className="mt-1 w-full rounded border border-gray-300 bg-white px-2 py-1.5 text-xs text-gray-900 focus:border-green-500 focus:outline-none focus:ring-1 focus:ring-green-500/20 dark:border-gray-600 dark:bg-gray-800 dark:text-gray-100"
>
{authorizationOptions.map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}
</select>
</label>
</div>
{notApprovedCount > 0 && (
<div className="flex justify-end mb-2">
<button
onClick={handleApproveAll}
className="px-3 py-1 text-xs font-medium rounded border border-green-300 dark:border-green-700 text-green-700 dark:text-green-400 bg-green-50 dark:bg-green-900/20 hover:bg-green-100 dark:hover:bg-green-900/40 transition-colors"
>
Approve All ({notApprovedCount})
</button>
</div>
)}
{approved.length > 0 && (
<div>
<p className="text-xs font-medium text-green-600 dark:text-green-400 uppercase tracking-wider mb-2 px-1">Approved ({approved.length})</p>
{approved.map((s) => <ServiceRow key={s.id} {...makeRowProps(s)} />)}
</div>
)}
{pending.length > 0 && (
<div className={approved.length > 0 ? 'mt-4' : ''}>
<p className="text-xs font-medium text-gray-400 uppercase tracking-wider mb-2 px-1">Additional Recommendations ({pending.length})</p>
{pending.map((s) => <ServiceRow key={s.id} {...makeRowProps(s)} />)}
</div>
)}
{declined.length > 0 && (
<div className={approved.length > 0 || pending.length > 0 ? 'mt-4' : ''}>
<p className="text-xs font-medium text-red-500 uppercase tracking-wider mb-2 px-1">Declined ({declined.length})</p>
{declined.map((s) => <ServiceRow key={s.id} {...makeRowProps(s)} />)}
</div>
)}
</div>
);
}
+16
View File
@@ -0,0 +1,16 @@
export type {
QuoteRecord,
RecentQuoteFilter,
RecentQuoteSort,
} from './types';
export {
RECENT_QUOTE_FILTERS,
PRIORITY_COLORS,
} from './types';
export { RecentStatusBadge } from './RecentStatusBadge';
export { CustomerInfoPanel } from './CustomerInfoPanel';
export { ServiceSearch } from './ServiceSearch';
export { ServiceRow } from './ServiceRow';
export { ServicesTable } from './ServicesTable';
export { QuoteSummary } from './QuoteSummary';
+47
View File
@@ -0,0 +1,47 @@
/* ── Types & Constants ──────────────────────────────── */
export interface QuoteRecord {
id: string;
customerName: string;
vehicleInfo: string;
roNumber: string;
repairOrderNumber?: string;
total: number;
approvedTotal: number;
declinedTotal: number;
hasPending: boolean;
status: 'draft' | 'sent' | 'approved' | 'declined';
createdAt: string;
lastUpdated?: string;
}
export type RecentQuoteFilter = 'all' | 'draft' | 'pending' | 'approved' | 'declined';
export type RecentQuoteSort = 'newest' | 'oldest' | 'highest-total' | 'customer-asc';
export const RECENT_QUOTE_FILTERS: { value: RecentQuoteFilter; label: string }[] = [
{ value: 'all', label: 'All' },
{ value: 'draft', label: 'Draft' },
{ value: 'pending', label: 'Pending' },
{ value: 'approved', label: 'Approved' },
{ value: 'declined', label: 'Declined' },
];
export const PRIORITY_COLORS: Record<string, string> = {
CRITICAL_SAFETY: 'bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-300 border-red-300 dark:border-red-700',
SAFETY_CONCERN: 'bg-orange-100 dark:bg-orange-900/30 text-orange-700 dark:text-orange-300 border-orange-300 dark:border-orange-700',
RECOMMENDED: 'bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300 border-blue-300 dark:border-blue-700',
MAINTENANCE: 'bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-300 border-green-300 dark:border-green-700',
OPTIONAL: 'bg-gray-100 dark:bg-gray-800 text-gray-600 dark:text-gray-400 border-gray-300 dark:border-gray-600',
};
export const RECENT_STATUS_COLORS: Record<string, string> = {
approved: 'bg-green-100 text-green-800 dark:bg-green-900/40 dark:text-green-300',
sent: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/40 dark:text-yellow-300',
pending: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900/40 dark:text-yellow-300',
declined: 'bg-red-100 text-red-800 dark:bg-red-900/40 dark:text-red-300',
draft: 'bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-400',
};
export const RECENT_STATUS_LABELS: Record<string, string> = {
approved: 'Approved', sent: 'Pending', pending: 'Pending', declined: 'Declined', draft: 'Draft',
};
@@ -0,0 +1,103 @@
import { memo } from 'react';
import { CheckSquare, Square, ChevronRight } from 'lucide-react';
import { getStatusConfig } from '../../lib/status';
import { useSettings } from '../../store/settings';
import { formatDateTime, formatCurrency } from '../../lib/format';
import { isVoided } from '../../lib/roEvents';
import {
getCompletedMetricDate,
displayCustomerName,
ratingLabel,
} from '../../lib/repairOrders';
import type { RepairOrder, CustomerType } from '../../types';
import { CustomerTypeBadge } from './CustomerTypeBadge';
import { StatusBadge } from './StatusBadge';
import { TechNames } from './TechNames';
export const CompletedRow = memo(function CompletedRow({
ro,
selected,
onToggleSelect,
onOpen,
onToggleCustomerType,
techNames,
}: {
ro: RepairOrder;
selected: boolean;
onToggleSelect: () => void;
onOpen: () => void;
onToggleCustomerType: (id: string, currentType?: CustomerType) => void;
techNames: string[];
}) {
const voided = isVoided(ro);
const completedDate = getCompletedMetricDate(ro);
const rowClass = `
cursor-pointer transition-colors hover:bg-gray-50 dark:hover:bg-gray-700/50
${voided ? 'opacity-50 hover:opacity-70' : ''}
${selected ? 'ring-2 ring-inset ring-blue-400' : ''}
`;
return (
<tr
onClick={(e) => {
const target = e.target as HTMLElement;
if (target.closest('[data-no-open]') || target.closest('button')) return;
onOpen();
}}
className={rowClass}
>
<td className="px-2 py-2.5" data-no-open>
<button
onClick={(e) => { e.stopPropagation(); onToggleSelect(); }}
className="text-gray-400 hover:text-blue-600 dark:hover:text-blue-400"
title={selected ? 'Deselect' : 'Select for bulk action'}
>
{selected
? <CheckSquare className="h-4 w-4 text-blue-600 dark:text-blue-400" />
: <Square className="h-4 w-4" />}
</button>
</td>
<td className="px-3 py-2.5 font-mono text-xs text-gray-500 dark:text-gray-400">
{ro.roNumber || `#${ro.id.slice(0, 8)}`}
</td>
<td className="px-3 py-2.5">
<div className="flex items-center gap-2">
<span className="font-medium text-gray-900 dark:text-white">{displayCustomerName(ro.customerName)}</span>
<CustomerTypeBadge
customerType={ro.customerType}
onClick={() => onToggleCustomerType(ro.id, ro.customerType)}
/>
</div>
</td>
<td className="max-w-[220px] px-3 py-2.5 text-gray-600 dark:text-gray-400">
<div className="space-y-0.5">
<p className="truncate">{ro.vehicleInfo || '—'}</p>
{ro.serviceLocation && (
<p className="truncate text-xs text-sky-600 dark:text-sky-400">{ro.serviceLocation}</p>
)}
{typeof ro.tripMiles === 'number' && ro.tripMiles > 0 && (
<p className="text-xs text-purple-600 dark:text-purple-400">{ro.tripMiles} mi trip</p>
)}
</div>
</td>
<td className="whitespace-nowrap px-3 py-2.5 text-sm text-gray-600 dark:text-gray-400">
{completedDate ? formatDateTime(completedDate) : '—'}
</td>
<td className="px-3 py-2.5">
<StatusBadge status={voided ? 'voided' : (ro.status || '')} config={getStatusConfig(useSettings())} />
</td>
<td className="whitespace-nowrap px-3 py-2.5 text-sm font-medium text-gray-900 dark:text-white">
{formatCurrency(ro.total || 0)}
</td>
<td className="whitespace-nowrap px-3 py-2.5 text-sm text-gray-600 dark:text-gray-400">
{ratingLabel(ro.satisfaction)}
</td>
<td className="whitespace-nowrap px-3 py-2.5 text-center">
<TechNames names={techNames} />
</td>
<td className="px-3 py-2.5 text-right">
<ChevronRight className="h-4 w-4 text-gray-400" />
</td>
</tr>
);
});
@@ -0,0 +1,36 @@
import { UserCheck, UserX } from 'lucide-react';
import { getCustomerTypeLabels } from '../../lib/settings';
import { useSettings } from '../../store/settings';
import type { CustomerType } from '../../types';
export function CustomerTypeBadge({
customerType,
onClick,
}: {
customerType?: CustomerType;
onClick: () => void;
}) {
const isWaiter = customerType === 'waiter';
const customerTypeLabels = getCustomerTypeLabels(useSettings());
return (
<button
onClick={(e) => {
e.stopPropagation();
onClick();
}}
className={`inline-flex cursor-pointer items-center gap-1 rounded-full px-2 py-0.5 text-xs font-medium transition-colors ${
isWaiter
? 'bg-purple-100 text-purple-800 hover:bg-purple-200 dark:bg-purple-900/40 dark:text-purple-300 dark:hover:bg-purple-900/60'
: 'bg-gray-100 text-gray-600 hover:bg-gray-200 dark:bg-gray-700 dark:text-gray-400 dark:hover:bg-gray-600'
}`}
title={`Click to toggle to ${isWaiter ? customerTypeLabels.primary : customerTypeLabels.secondary}`}
>
{isWaiter ? (
<UserCheck className="h-3 w-3" />
) : (
<UserX className="h-3 w-3" />
)}
{isWaiter ? customerTypeLabels.secondary : customerTypeLabels.primary}
</button>
);
}
@@ -0,0 +1,49 @@
import { Wrench, Plus } from 'lucide-react';
import type { TabId } from './types';
export function EmptyState({
tab,
onCreate,
}: {
tab: TabId;
onCreate: () => void;
}) {
const messages: Record<TabId, { title: string; desc: string }> = {
active: {
title: 'No active repair orders',
desc: 'There are no open repair orders in this view. Create a new order when a vehicle is checked in for service.',
},
all: {
title: 'No repair orders on file',
desc: 'Create your first repair order to track work performed, labor, parts, and customer authorization status.',
},
completed: {
title: 'No completed repair orders',
desc: 'Closed repair orders will appear here once work has been completed and finalized.',
},
};
const msg = messages[tab];
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">
<Wrench 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">
{msg.title}
</h3>
<p className="mb-8 max-w-sm text-sm text-gray-500 dark:text-gray-400">
{msg.desc}
</p>
{tab !== 'completed' && (
<button
onClick={onCreate}
className="inline-flex items-center gap-2 rounded-lg bg-blue-600 px-5 py-2.5 text-sm font-medium text-white shadow-sm transition-colors hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 dark:focus:ring-offset-gray-900"
>
<Plus className="h-4 w-4" />
Create Repair Order
</button>
)}
</div>
);
}
@@ -0,0 +1,11 @@
import { ListError } from '../ui/ListError';
export function ErrorState({
message,
onRetry,
}: {
message: string;
onRetry: () => void;
}) {
return <ListError title="Unable to load repair orders" message={message} onRetry={onRetry} />;
}
+118
View File
@@ -0,0 +1,118 @@
import { memo } from 'react';
import { CheckSquare, Square, ChevronRight } from 'lucide-react';
import { getStatusConfig } from '../../lib/status';
import { useSettings } from '../../store/settings';
import { formatDateTime } from '../../lib/format';
import { isVoided } from '../../lib/roEvents';
import {
getUrgencyClass,
displayCustomerName,
formatDueDateTime,
} from '../../lib/repairOrders';
import type { RepairOrder, CustomerType } from '../../types';
import { CustomerTypeBadge } from './CustomerTypeBadge';
import { StatusBadge } from './StatusBadge';
import { TimeRemainingCell } from './TimeRemainingCell';
import { RowProgressBar } from './RowProgressBar';
import { TechNames } from './TechNames';
export const FragmentRow = memo(function FragmentRow({
ro,
now,
selected,
onToggleSelect,
onOpen,
onToggleCustomerType,
techNames,
}: {
ro: RepairOrder;
now: number;
selected: boolean;
onToggleSelect: () => void;
onOpen: () => void;
onToggleCustomerType: (id: string, currentType?: CustomerType) => void;
techNames: string[];
}) {
const urgency = getUrgencyClass(ro, now);
const voided = isVoided(ro);
const rowClass = `
cursor-pointer transition-colors
${voided
? 'opacity-50 hover:opacity-70'
: urgency === 'urgent'
? 'bg-red-50 hover:bg-red-100 dark:bg-red-900/15 dark:hover:bg-red-900/25'
: 'hover:bg-gray-50 dark:hover:bg-gray-700/50'
}
${selected ? 'ring-2 ring-inset ring-blue-400' : ''}
`;
return (
<tr
onClick={(e) => {
const target = e.target as HTMLElement;
if (target.closest('[data-no-open]') || target.closest('button')) return;
onOpen();
}}
className={rowClass}
>
<td className="px-2 py-2.5" data-no-open>
<button
onClick={(e) => { e.stopPropagation(); onToggleSelect(); }}
className="text-gray-400 hover:text-blue-600 dark:hover:text-blue-400"
title={selected ? 'Deselect' : 'Select for bulk action'}
>
{selected
? <CheckSquare className="h-4 w-4 text-blue-600 dark:text-blue-400" />
: <Square className="h-4 w-4" />}
</button>
</td>
<td className="px-3 py-2.5 font-mono text-xs text-gray-500 dark:text-gray-400">
{ro.roNumber || `#${ro.id.slice(0, 8)}`}
</td>
<td className="px-3 py-2.5">
<div className="flex items-center gap-2">
<span className="font-medium text-gray-900 dark:text-white">
{displayCustomerName(ro.customerName)}
</span>
<CustomerTypeBadge
customerType={ro.customerType}
onClick={() => onToggleCustomerType(ro.id, ro.customerType)}
/>
</div>
</td>
<td className="max-w-[200px] px-3 py-2.5 text-gray-600 dark:text-gray-400">
<div className="space-y-0.5">
<p className="truncate">{ro.vehicleInfo || '—'}</p>
{ro.serviceLocation && (
<p className="truncate text-xs text-sky-600 dark:text-sky-400">{ro.serviceLocation}</p>
)}
{typeof ro.tripMiles === 'number' && ro.tripMiles > 0 && (
<p className="text-xs text-purple-600 dark:text-purple-400">{ro.tripMiles} mi trip</p>
)}
</div>
</td>
<td className="px-3 py-2.5">
<StatusBadge status={voided ? 'voided' : (ro.status || '')} config={getStatusConfig(useSettings())} />
</td>
<td className="whitespace-nowrap px-3 py-2.5 text-sm text-gray-600 dark:text-gray-400">
{formatDateTime(ro.writeupTime || ro.created)}
</td>
<td className="whitespace-nowrap px-3 py-2.5 text-sm text-gray-600 dark:text-gray-400">
{formatDueDateTime(ro)}
</td>
<td className="whitespace-nowrap px-3 py-2.5">
<TimeRemainingCell ro={ro} now={now} />
</td>
<td className="whitespace-nowrap px-3 py-2.5">
<RowProgressBar ro={ro} />
</td>
<td className="whitespace-nowrap px-3 py-2.5 text-center">
<TechNames names={techNames} />
</td>
<td className="px-3 py-2.5 text-right">
<ChevronRight className="h-4 w-4 text-gray-400" />
</td>
</tr>
);
});
@@ -0,0 +1,29 @@
export function LoadingSkeleton() {
return (
<div className="animate-pulse space-y-4">
<div className="flex gap-2">
{Array.from({ length: 3 }).map((_, i) => (
<div
key={i}
className="h-9 w-24 rounded-lg bg-gray-200 dark:bg-gray-700"
/>
))}
</div>
<div className="rounded-xl bg-white ring-1 ring-gray-200 dark:bg-gray-800 dark:ring-gray-700">
<div className="space-y-3 p-6">
{Array.from({ length: 5 }).map((_, i) => (
<div key={i} className="flex gap-4">
<div className="h-4 w-16 rounded bg-gray-200 dark:bg-gray-700" />
<div className="h-4 flex-1 rounded bg-gray-200 dark:bg-gray-700" />
<div className="h-4 w-28 rounded bg-gray-200 dark:bg-gray-700" />
<div className="h-4 w-20 rounded bg-gray-200 dark:bg-gray-700" />
<div className="h-4 w-24 rounded bg-gray-200 dark:bg-gray-700" />
<div className="h-4 w-16 rounded bg-gray-200 dark:bg-gray-700" />
<div className="h-4 w-24 rounded bg-gray-200 dark:bg-gray-700" />
</div>
))}
</div>
</div>
</div>
);
}
+64
View File
@@ -0,0 +1,64 @@
import { X } from 'lucide-react';
import ROForm from '../ROForm';
import type { RepairOrder } from '../../types';
import type { CustomerWithVehicles } from '../../pages/Customers';
export function ROModal({
isOpen,
onClose,
onSave,
editRO,
existingRoNumbers,
settings,
customers,
}: {
isOpen: boolean;
onClose: () => void;
onSave: (data: Partial<RepairOrder>) => void;
editRO: RepairOrder | null;
existingRoNumbers: Set<string>;
settings: { taxRate: number; shopChargeRate: number; shopChargeCap: number; defaultLaborRate: number };
customers?: CustomerWithVehicles[];
}) {
if (!isOpen) return null;
const isEditing = !!editRO;
return (
<div className="fixed inset-0 z-50 flex items-start justify-center overflow-y-auto bg-black/50 p-4 pt-10">
<div className="w-full max-w-3xl rounded-2xl bg-white shadow-2xl dark:bg-gray-900">
{/* Header */}
<div className="flex items-center justify-between border-b border-gray-200 px-6 py-4 dark:border-gray-700">
<div>
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">
{isEditing ? 'Edit Repair Order' : 'Create Repair Order'}
</h2>
<p className="text-sm text-gray-500 dark:text-gray-400">
{isEditing
? `Editing RO #${editRO?.roNumber || ''}`
: 'Enter customer and vehicle information'}
</p>
</div>
<button
onClick={onClose}
className="rounded-lg p-2 text-gray-400 hover:bg-gray-100 hover:text-gray-600 dark:hover:bg-gray-800 dark:hover:text-gray-300"
>
<X className="h-5 w-5" />
</button>
</div>
{/* Body */}
<div className="px-6 py-5">
<ROForm
editRO={editRO}
existingRoNumbers={existingRoNumbers}
settings={settings}
customers={customers}
onSave={(data) => { onSave(data); onClose(); }}
onCancel={onClose}
/>
</div>
</div>
</div>
);
}
@@ -0,0 +1,19 @@
import { roProgress } from '../../lib/status';
import type { RepairOrder } from '../../types';
export function RowProgressBar({ ro }: { ro: RepairOrder }) {
const { done, total, fraction } = roProgress(ro);
if (total === 0) {
return <span className="text-xs text-gray-300 dark:text-gray-600"></span>;
}
const pct = Math.round(fraction * 100);
const barColor = pct === 100 ? 'bg-green-500' : pct >= 50 ? 'bg-blue-500' : 'bg-gray-300 dark:bg-gray-600';
return (
<div className="flex items-center gap-2" title={`${done}/${total} services done`}>
<div className="h-1.5 w-16 overflow-hidden rounded-full bg-gray-200 dark:bg-gray-700">
<div className={`h-full transition-all ${barColor}`} style={{ width: `${pct}%` }} />
</div>
<span className="text-xs tabular-nums text-gray-500 dark:text-gray-400">{done}/{total}</span>
</div>
);
}
@@ -0,0 +1,28 @@
import { getStatusConfig } from '../../lib/status';
import { useSettings } from '../../store/settings';
export function StatusBadge({
status,
config,
}: {
status: string;
config: Record<string, { label: string; colors: string }>;
}) {
const cfg = config[status] || {
label: status,
colors:
'bg-gray-100 text-gray-600 dark:bg-gray-800 dark:text-gray-400',
};
return (
<span
className={`inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium ${cfg.colors}`}
>
{cfg.label}
</span>
);
}
// Alternate version that derives config from settings (convenience)
export function StatusBadgeAuto({ status }: { status: string }) {
return <StatusBadge status={status} config={getStatusConfig(useSettings())} />;
}
+39
View File
@@ -0,0 +1,39 @@
import { TABS } from './types';
import type { TabId } from './types';
export function TabButton({
tab,
active,
count,
onClick,
}: {
tab: TabId;
active: boolean;
count?: number;
onClick: () => void;
}) {
const label = TABS.find((t) => t.id === tab)?.label || tab;
return (
<button
onClick={onClick}
className={`relative inline-flex items-center gap-2 rounded-lg px-4 py-2 text-sm font-medium transition-all ${
active
? 'bg-blue-600 text-white shadow-sm'
: 'bg-white text-gray-600 ring-1 ring-gray-300 hover:bg-gray-50 dark:bg-gray-800 dark:text-gray-400 dark:ring-gray-700 dark:hover:bg-gray-700'
}`}
>
{label}
{count !== undefined && (
<span
className={`inline-flex items-center justify-center rounded-full px-2 py-0.5 text-xs ${
active
? 'bg-white/20 text-white'
: 'bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-400'
}`}
>
{count}
</span>
)}
</button>
);
}
+20
View File
@@ -0,0 +1,20 @@
export function TechNames({ names }: { names: string[] }) {
if (names.length === 0) return <span className="text-xs text-gray-300 dark:text-gray-600"></span>;
const label = names.join(', ');
const visible = names.slice(0, 2);
const extra = names.length - visible.length;
return (
<div className="flex max-w-[10rem] flex-wrap items-center justify-center gap-1" title={label}>
{visible.map((name, index) => (
<span key={`${name}-${index}`} className="truncate rounded-full bg-green-100 px-2 py-0.5 text-xs font-medium text-green-700 dark:bg-green-900/30 dark:text-green-400">
{name}
</span>
))}
{extra > 0 && (
<span className="rounded-full bg-gray-100 px-2 py-0.5 text-xs font-medium text-gray-600 dark:bg-gray-700 dark:text-gray-300">
+{extra}
</span>
)}
</div>
);
}
@@ -0,0 +1,30 @@
import { AlertTriangle } from 'lucide-react';
import { formatTimeRemaining, getUrgencyClass } from '../../lib/repairOrders';
import type { RepairOrder } from '../../types';
export function TimeRemainingCell({
ro,
now,
}: {
ro: RepairOrder;
now: number;
}) {
const text = formatTimeRemaining(ro, now);
const urgency = getUrgencyClass(ro, now);
const colorClass =
urgency === 'urgent'
? 'text-red-600 dark:text-red-400 font-semibold'
: urgency === 'warning'
? 'text-amber-600 dark:text-amber-400 font-medium'
: 'text-gray-600 dark:text-gray-400';
return (
<span className={`inline-flex items-center gap-1 text-sm ${colorClass}`}>
{urgency === 'urgent' && (
<AlertTriangle className="h-3.5 w-3.5 text-red-500" />
)}
{text}
</span>
);
}
+21
View File
@@ -0,0 +1,21 @@
export type {
TabId,
CompletedRangeFilter,
CompletedStatusFilter,
CompletedRatingFilter,
SavedView,
} from './types';
export { TABS, SAVED_VIEWS_KEY, loadSavedViews, persistSavedViews } from './types';
export { StatusBadge } from './StatusBadge';
export { RowProgressBar } from './RowProgressBar';
export { TechNames } from './TechNames';
export { CustomerTypeBadge } from './CustomerTypeBadge';
export { TimeRemainingCell } from './TimeRemainingCell';
export { LoadingSkeleton } from './LoadingSkeleton';
export { EmptyState } from './EmptyState';
export { ErrorState } from './ErrorState';
export { TabButton } from './TabButton';
export { ROModal } from './ROModal';
export { FragmentRow } from './FragmentRow';
export { CompletedRow } from './CompletedRow';
+37
View File
@@ -0,0 +1,37 @@
/* ── Types & Constants ──────────────────────────────── */
export type TabId = 'active' | 'all' | 'completed';
export type CompletedRangeFilter = 'all' | 'today' | 'this_week' | 'this_month';
export type CompletedStatusFilter = 'all' | 'completed' | 'final_close';
export type CompletedRatingFilter = 'all' | 'rated' | 'unrated';
export const TABS: { id: TabId; label: string }[] = [
{ id: 'active', label: 'Active' },
{ id: 'all', label: 'All' },
{ id: 'completed', label: 'Completed' },
];
/* ── Saved-view preset shape ─────────────────────────── */
export interface SavedView {
id: string;
label: string;
statusChips: string[];
waiterOnly: boolean;
showVoided: boolean;
search: string;
}
export const SAVED_VIEWS_KEY = 'spq-ro-saved-views';
export function loadSavedViews(): SavedView[] {
try {
const raw = localStorage.getItem(SAVED_VIEWS_KEY);
if (raw) return JSON.parse(raw) as SavedView[];
} catch { /* corrupt JSON — ignore */ }
return [];
}
export function persistSavedViews(views: SavedView[]) {
try { localStorage.setItem(SAVED_VIEWS_KEY, JSON.stringify(views)); } catch { /* ignore */ }
}
@@ -0,0 +1,95 @@
import { createContext, useContext, useEffect, useState, useCallback, type ReactNode } from 'react';
import { useSearchParams, useNavigate } from 'react-router-dom';
import { pb } from '../../lib/pocketbase';
interface ImpersonatedTechInfo {
id: string;
email: string;
displayName?: string;
}
interface TechnicianContextValue {
effectiveUserId: string;
isImpersonating: boolean;
impersonatedTech: ImpersonatedTechInfo | null;
stopImpersonation: () => void;
}
const TechnicianContext = createContext<TechnicianContextValue>({
effectiveUserId: '',
isImpersonating: false,
impersonatedTech: null,
stopImpersonation: () => {},
});
export function TechnicianProvider({ children }: { children: ReactNode }) {
const [searchParams] = useSearchParams();
const navigate = useNavigate();
const viewAs = searchParams.get('view_as');
const authUserId = pb.authStore.model?.id as string | undefined;
const [impersonatedTech, setImpersonatedTech] = useState<ImpersonatedTechInfo | null>(null);
const isImpersonating = !!viewAs && viewAs !== authUserId;
const effectiveUserId = isImpersonating ? viewAs! : (authUserId || '');
useEffect(() => {
if (isImpersonating && viewAs) {
pb.collection('users').getOne(viewAs).then((user) => {
setImpersonatedTech({
id: user.id,
email: user.email as string,
displayName: user.displayName as string | undefined,
});
}).catch(() => {
setImpersonatedTech(null);
});
} else {
setImpersonatedTech(null);
}
}, [isImpersonating, viewAs]);
const stopImpersonation = useCallback(() => {
navigate('/team');
}, [navigate]);
return (
<TechnicianContext.Provider
value={{
effectiveUserId,
isImpersonating,
impersonatedTech,
stopImpersonation,
}}
>
{children}
</TechnicianContext.Provider>
);
}
export function useTechnicianContext(): TechnicianContextValue {
return useContext(TechnicianContext);
}
export function useTechnicianNav() {
const navigate = useNavigate();
const [searchParams] = useSearchParams();
const viewAs = searchParams.get('view_as');
const buildTechUrl = useCallback(
(path: string): string => {
if (!viewAs) return path;
const separator = path.includes('?') ? '&' : '?';
return `${path}${separator}view_as=${encodeURIComponent(viewAs)}`;
},
[viewAs],
);
const navigateTech = useCallback(
(path: string, opts?: { replace?: boolean; state?: unknown }) => {
navigate(buildTechUrl(path), opts);
},
[navigate, buildTechUrl],
);
return { buildTechUrl, navigateTech };
}
@@ -0,0 +1,115 @@
import { useState, useEffect, useMemo, type ReactNode } from 'react';
import { LayoutDashboard, ClipboardList, User, Sun, Moon, LogOut, Eye, X } from 'lucide-react';
import { useTechnicianContext, useTechnicianNav } from './TechnicianContext';
import { NavLink, useNavigate } from 'react-router-dom';
import { pb } from '../../lib/pocketbase';
interface TechnicianLayoutProps {
children: ReactNode;
}
export default function TechnicianLayout({ children }: TechnicianLayoutProps) {
const navigate = useNavigate();
const { isImpersonating, impersonatedTech, stopImpersonation } = useTechnicianContext();
const { buildTechUrl } = useTechnicianNav();
const navItems = useMemo(
() => [
{ to: buildTechUrl('/technician'), label: 'Bay', icon: LayoutDashboard },
{ to: buildTechUrl('/technician/jobs'), label: 'Jobs', icon: ClipboardList },
{ to: buildTechUrl('/technician/profile'), label: 'Profile', icon: User },
],
[buildTechUrl],
);
const [dark, setDark] = useState(() => {
if (typeof window === 'undefined') return false;
return localStorage.getItem('spq-dark-mode') === 'true';
});
useEffect(() => {
const root = document.documentElement;
if (dark) {
root.classList.add('dark');
} else {
root.classList.remove('dark');
}
localStorage.setItem('spq-dark-mode', String(dark));
}, [dark]);
const toggleDark = () => setDark((prev) => !prev);
const handleLogout = () => {
pb.authStore.clear();
navigate('/login');
};
const userEmail = pb.authStore.model?.email as string | undefined;
return (
<div className="flex min-h-screen flex-col bg-gray-50 dark:bg-gray-950">
<header className="sticky top-0 z-30 flex h-14 items-center gap-2 border-b border-gray-200 bg-white px-4 dark:border-gray-800 dark:bg-gray-900">
<span className="text-base font-bold text-gray-900 dark:text-white">
Shop<span className="text-green-600">Pro</span>Quote{' '}
<span className="text-xs font-normal text-gray-400 dark:text-gray-500">Bay</span>
</span>
<div className="min-w-0 flex-1" />
{userEmail && (
<span className="hidden max-w-[120px] truncate text-xs text-gray-500 dark:text-gray-400 sm:block">
{userEmail}
</span>
)}
<button
onClick={toggleDark}
className="rounded-lg p-2 text-gray-500 hover:bg-gray-100 hover:text-gray-700 dark:text-gray-400 dark:hover:bg-gray-800 dark:hover:text-gray-200"
title={dark ? 'Switch to light mode' : 'Switch to dark mode'}
>
{dark ? <Sun className="h-4 w-4" /> : <Moon className="h-4 w-4" />}
</button>
<button
onClick={handleLogout}
className="rounded-lg p-2 text-gray-500 hover:bg-red-50 hover:text-red-600 dark:text-gray-400 dark:hover:bg-red-900/20 dark:hover:text-red-400"
title="Logout"
>
<LogOut className="h-4 w-4" />
</button>
</header>
{isImpersonating && (
<div className="flex items-center gap-2 bg-blue-600 px-4 py-2 text-sm text-white">
<Eye className="h-4 w-4 shrink-0" />
<span className="min-w-0 flex-1 truncate">
Viewing <strong>{impersonatedTech?.displayName || impersonatedTech?.email || 'technician'}</strong>'s bay
</span>
<button
onClick={stopImpersonation}
className="flex shrink-0 items-center gap-1 rounded-lg bg-blue-700 px-3 py-1 text-xs font-medium hover:bg-blue-800"
>
<X className="h-3.5 w-3.5" />
Exit View
</button>
</div>
)}
<main className="flex-1 overflow-auto p-4 sm:p-6">{children}</main>
<nav className="sticky bottom-0 z-30 flex border-t border-gray-200 bg-white dark:border-gray-800 dark:bg-gray-900">
{navItems.map((item) => (
<NavLink
key={item.to}
to={item.to}
end={item.to === '/technician'}
className={({ isActive }) =>
`flex flex-1 flex-col items-center gap-0.5 py-3 text-xs font-medium transition-colors ${
isActive
? 'text-green-600 dark:text-green-400'
: 'text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200'
}`
}
>
<item.icon className="h-5 w-5" />
<span>{item.label}</span>
</NavLink>
))}
</nav>
</div>
);
}
+41
View File
@@ -0,0 +1,41 @@
import type { LucideIcon } from 'lucide-react';
interface ListEmptyProps {
icon?: LucideIcon;
title: string;
message?: string;
actionLabel?: string;
onAction?: () => void;
className?: string;
}
export function ListEmpty({
icon: Icon,
title,
message,
actionLabel,
onAction,
className = '',
}: ListEmptyProps) {
return (
<div className={`flex flex-col items-center justify-center py-20 text-center ${className}`}>
{Icon && (
<div className="mb-6 flex h-24 w-24 items-center justify-center rounded-full bg-blue-50 dark:bg-blue-900/20">
<Icon 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">{title}</h3>
{message && (
<p className="mb-8 max-w-sm text-sm text-gray-500 dark:text-gray-400">{message}</p>
)}
{actionLabel && onAction && (
<button
onClick={onAction}
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"
>
{actionLabel}
</button>
)}
</div>
);
}

Some files were not shown because too many files have changed in this diff Show More