initial commit
This commit is contained in:
@@ -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
@@ -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 |
|
||||
|------:|------|-------------|
|
||||
| 1–25 | Imports | trimmed in orchestrator |
|
||||
| 27–92 | `CustomerRecord`, `VehicleRecord`, `RepairOrderRecord`, `QuoteRecord`, `CustomerWithVehicles` interfaces | stay in `Customers.tsx` (re-export) |
|
||||
| 96–122 | `formatVehicleLabel`, `vehicleKey`, `escapePbString`, `activeROFilterForCustomer` | `src/lib/customers.ts` |
|
||||
| 126–143 | `CustomersSkeleton` | `src/components/customers/EmptyStates.tsx` |
|
||||
| 145–185 | `EmptyState` | same file `EmptyStates.tsx` |
|
||||
| 187–189 | `ErrorState` | same file `EmptyStates.tsx` |
|
||||
| 193–270 | `CustomerCard` | `src/components/customers/CustomerCard.tsx` (memo) |
|
||||
| 274–441 | `CustomerFormData` interface + `CustomerFormModal` | interface stays in page re-export; component → `src/components/customers/CustomerFormModal.tsx` |
|
||||
| 444–668 | `VehicleFormData` interface + `VehicleFormModal` | interface → `src/lib/customers.ts` (or co-located); component → `src/components/customers/VehicleFormModal.tsx` |
|
||||
| 672–739 | `DeleteConfirmModal` | `src/components/customers/DeleteConfirmModal.tsx` |
|
||||
| 743–783 | `getRoStatusLabel`, `getRoStatusColor`, `formatDate` | `src/lib/customers.ts` |
|
||||
| 787–1170 | `CustomerDetailView` | `src/components/customers/CustomerDetailView.tsx` (memo) |
|
||||
| 1174–1794 | main `Customers` component | stays in `Customers.tsx` as orchestrator |
|
||||
|
||||
### External importers of `Customers.tsx` (must not break)
|
||||
- `src/components/CustomerPicker.tsx:4` → `import type { CustomerWithVehicles }`
|
||||
- `src/pages/Appointments.tsx:9` → `import type { CustomerRecord, CustomerWithVehicles }`
|
||||
- `src/components/appointments/CheckInModal.tsx:8` → `import type { CustomerWithVehicles }`
|
||||
- `src/components/appointments/ScanScreenshotModal.tsx:8` → `import type { CustomerWithVehicles }`
|
||||
- `src/components/appointments/AppointmentModal.tsx:10` → `import type { CustomerWithVehicles, CustomerFormData }`
|
||||
|
||||
Resolution: `Customers.tsx` keeps `export interface CustomerRecord`, `export interface CustomerWithVehicles`, `export interface CustomerFormData` (re-export from a new `src/components/customers/types.ts` to avoid duplication — but re-export is enough; `import type` callers don't care where the symbol physically lives as long as the page re-exports it).
|
||||
|
||||
Decision: create `src/components/customers/types.ts` holding the shared interfaces; `Customers.tsx` does `export type { CustomerRecord, CustomerWithVehicles, CustomerFormData, VehicleRecord, RepairOrderRecord, QuoteRecord } from '../components/customers/types'`. External importers keep working unchanged.
|
||||
|
||||
---
|
||||
|
||||
## Task 1: Create shared types module
|
||||
|
||||
**Objective:** Move the shared interfaces out of the page into a dedicated types file so both the page and the new component files import from one place.
|
||||
|
||||
**Files:**
|
||||
- Create: `src/components/customers/types.ts`
|
||||
- Modify: `src/pages/Customers.tsx` (replace the inline `interface` declarations with a re-export)
|
||||
|
||||
**Step 1:** Create `src/components/customers/types.ts`:
|
||||
|
||||
```ts
|
||||
export interface CustomerRecord {
|
||||
id: string;
|
||||
name: string;
|
||||
phone: string;
|
||||
email: string;
|
||||
address: string;
|
||||
notes: string;
|
||||
userId: string;
|
||||
created: string;
|
||||
updated: string;
|
||||
}
|
||||
|
||||
export interface VehicleRecord {
|
||||
id: string;
|
||||
customerId: string;
|
||||
userId?: string;
|
||||
make: string;
|
||||
model: string;
|
||||
year: string;
|
||||
vin: string;
|
||||
licensePlate: string;
|
||||
mileage: string;
|
||||
color: string;
|
||||
engine: string;
|
||||
}
|
||||
|
||||
export interface RepairOrderRecord {
|
||||
id: string;
|
||||
roNumber: string;
|
||||
customerId: string;
|
||||
customerName: string;
|
||||
vehicleInfo: string;
|
||||
vin: string;
|
||||
mileage: string;
|
||||
status: string;
|
||||
workStatus: string;
|
||||
createdAt: string;
|
||||
completedTime: string;
|
||||
writeupTime: string;
|
||||
technician: string;
|
||||
notes: string;
|
||||
services: string;
|
||||
total: number;
|
||||
}
|
||||
|
||||
export interface QuoteRecord {
|
||||
id: string;
|
||||
customerId: string;
|
||||
customerName: string;
|
||||
vehicleInfo: string;
|
||||
vin: string;
|
||||
mileage: string;
|
||||
status: string;
|
||||
createdAt: string;
|
||||
total: number;
|
||||
serviceAdvisor: string;
|
||||
services: string;
|
||||
notes: string;
|
||||
}
|
||||
|
||||
export interface CustomerWithVehicles extends CustomerRecord {
|
||||
vehicles: VehicleRecord[];
|
||||
vehicleCount: number;
|
||||
}
|
||||
|
||||
export interface CustomerFormData {
|
||||
name: string;
|
||||
phone: string;
|
||||
email: string;
|
||||
address: string;
|
||||
notes: string;
|
||||
}
|
||||
|
||||
export interface VehicleFormData {
|
||||
make: string;
|
||||
model: string;
|
||||
year: string;
|
||||
vin: string;
|
||||
licensePlate: string;
|
||||
mileage: string;
|
||||
color: string;
|
||||
engine: string;
|
||||
}
|
||||
```
|
||||
|
||||
**Step 2:** In `src/pages/Customers.tsx`, delete lines 27–92 (the inline interface declarations) and replace with:
|
||||
|
||||
```ts
|
||||
export type { CustomerRecord, CustomerWithVehicles, CustomerFormData } from '../components/customers/types';
|
||||
```
|
||||
|
||||
Also add at the top, after the existing imports:
|
||||
|
||||
```ts
|
||||
import type { VehicleRecord, RepairOrderRecord, QuoteRecord, CustomerWithVehicles, CustomerFormData, VehicleFormData } from '../components/customers/types';
|
||||
```
|
||||
|
||||
Keep the `export` on `CustomerRecord` so existing importers don't break — verify the line above re-exports all three externally used types: `CustomerRecord`, `CustomerWithVehicles`, `CustomerFormData`.
|
||||
|
||||
**Step 3 — Verify build:**
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
```
|
||||
Expected: PASS (tsc + vite, 0 errors). The type-only changes don't affect runtime.
|
||||
|
||||
**Step 4 — Commit:**
|
||||
|
||||
```bash
|
||||
git add src/components/customers/types.ts src/pages/Customers.tsx
|
||||
git commit -m "refactor(customers): extract shared interfaces to types module"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: Create `src/lib/customers.ts` (pure helpers)
|
||||
|
||||
**Objective:** Move all pure functions out of the page so they can be shared by the new component files without a circular dependency.
|
||||
|
||||
**Files:**
|
||||
- Create: `src/lib/customers.ts`
|
||||
|
||||
**Step 1:** Create `src/lib/customers.ts` with these functions moved verbatim from `src/pages/Customers.tsx`:
|
||||
|
||||
```ts
|
||||
import type { VehicleRecord, RepairOrderRecord } from '../components/customers/types';
|
||||
|
||||
export function formatVehicleLabel(v: VehicleRecord): string {
|
||||
const parts = [v.year, v.make, v.model].filter(Boolean);
|
||||
return parts.length > 0 ? parts.join(' ') : 'Unknown vehicle';
|
||||
}
|
||||
|
||||
export function vehicleKey(v: Pick<VehicleRecord, 'year' | 'make' | 'model' | 'vin'>): string {
|
||||
return (v.vin || [v.year, v.make, v.model].filter(Boolean).join(' ')).trim().toLowerCase();
|
||||
}
|
||||
|
||||
export function escapePbString(value: string): string {
|
||||
return value.replace(/'/g, "\\'");
|
||||
}
|
||||
|
||||
export function activeROFilterForCustomer(userId: string, customerId: string, previousName: string): string {
|
||||
const activeStatuses = [
|
||||
"status = 'active'",
|
||||
"status = 'in_progress'",
|
||||
"status = 'waiting_parts'",
|
||||
"status = 'waiting_pickup'",
|
||||
].join(' || ');
|
||||
const identityFilters = [
|
||||
`customerId = '${escapePbString(customerId)}'`,
|
||||
previousName.trim() ? `customerName = '${escapePbString(previousName.trim())}'` : '',
|
||||
].filter(Boolean).join(' || ');
|
||||
|
||||
return `userId = '${escapePbString(userId)}' && (${identityFilters}) && (${activeStatuses})`;
|
||||
}
|
||||
|
||||
export function getRoStatusLabel(ro: RepairOrderRecord): string {
|
||||
const s = ro.workStatus || ro.status || '';
|
||||
switch (s) {
|
||||
case 'active': case 'open': return 'Open';
|
||||
case 'in-progress': case 'in_progress': return 'In Progress';
|
||||
case 'waiter': case 'waiting_parts': return 'Waiting Parts';
|
||||
case 'completed': return 'Completed';
|
||||
case 'final_close':
|
||||
case 'delivered': return 'Final Close';
|
||||
case 'cancelled': return 'Cancelled';
|
||||
default: return s || 'Unknown';
|
||||
}
|
||||
}
|
||||
|
||||
export function getRoStatusColor(ro: RepairOrderRecord): string {
|
||||
const s = ro.workStatus || ro.status || '';
|
||||
switch (s) {
|
||||
case 'active': case 'open':
|
||||
return 'bg-blue-50 text-blue-700 dark:bg-blue-900/30 dark:text-blue-400';
|
||||
case 'in-progress': case 'in_progress':
|
||||
return 'bg-yellow-50 text-yellow-700 dark:bg-yellow-900/30 dark:text-yellow-400';
|
||||
case 'waiter': case 'waiting_parts':
|
||||
return 'bg-orange-50 text-orange-700 dark:bg-orange-900/30 dark:text-orange-400';
|
||||
case 'completed': case 'final_close': case 'delivered':
|
||||
return 'bg-green-50 text-green-700 dark:bg-green-900/30 dark:text-green-400';
|
||||
case 'cancelled':
|
||||
return 'bg-red-50 text-red-700 dark:bg-red-900/30 dark:text-red-400';
|
||||
default:
|
||||
return 'bg-gray-50 text-gray-700 dark:bg-gray-900/30 dark:text-gray-400';
|
||||
}
|
||||
}
|
||||
|
||||
export function formatCustomerDate(dateStr: string): string {
|
||||
if (!dateStr) return '\u2014';
|
||||
try {
|
||||
const d = new Date(dateStr);
|
||||
return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' });
|
||||
} catch {
|
||||
return dateStr;
|
||||
}
|
||||
}
|
||||
|
||||
export function parseQuoteServices(raw: unknown): { name: string; price: number; customerDecision: string }[] {
|
||||
if (!raw) return [];
|
||||
let items = raw;
|
||||
if (typeof raw === 'string') {
|
||||
try { items = JSON.parse(raw); } catch { return []; }
|
||||
}
|
||||
if (!Array.isArray(items)) return [];
|
||||
return items.map((s: any) => ({
|
||||
name: s.name || 'Service',
|
||||
price: typeof s.price === 'number' ? s.price : 0,
|
||||
customerDecision: s.customerDecision || 'pending',
|
||||
}));
|
||||
}
|
||||
```
|
||||
|
||||
Note: `formatDate` is renamed to `formatCustomerDate` to avoid a name clash with `src/lib/format.ts` exports. The detail view uses it locally; the move forces a rename. Keep the call-site reference updated in Task 7.
|
||||
|
||||
**Step 2 — Verify build:**
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
```
|
||||
Expected: FAIL — because `Customers.tsx` still has the old inline definitions; that's fine, we'll remove them in Task 8. For now just confirm `src/lib/customers.ts` compiles in isolation by running:
|
||||
|
||||
```bash
|
||||
npx tsc --noEmit src/lib/customers.ts
|
||||
```
|
||||
If tsc complains about project settings, skip this sub-step — the final build happens at the end.
|
||||
|
||||
**Step 3 — Commit:**
|
||||
|
||||
```bash
|
||||
git add src/lib/customers.ts
|
||||
git commit -m "refactor(customers): add pure helpers module"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: Create `src/components/customers/EmptyStates.tsx`
|
||||
|
||||
**Objective:** Extract the three state-display components (skeleton, empty, error) into one file (same pattern as `src/components/appointments/EmptyStates.tsx`).
|
||||
|
||||
**Files:**
|
||||
- Create: `src/components/customers/EmptyStates.tsx`
|
||||
|
||||
**Step 1:** Create the file, moving the JSX verbatim from `Customers.tsx` lines 126–189:
|
||||
|
||||
```tsx
|
||||
import { Search, Users, UserPlus } from 'lucide-react';
|
||||
import { ListError } from '../ui/ListError';
|
||||
|
||||
export function CustomersSkeleton() {
|
||||
return (
|
||||
<div className="animate-pulse space-y-4">
|
||||
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||
{Array.from({ length: 6 }).map((_, i) => (
|
||||
<div
|
||||
key={i}
|
||||
className="rounded-xl bg-white p-5 ring-1 ring-gray-200 dark:bg-gray-800 dark:ring-gray-700"
|
||||
>
|
||||
<div className="mb-3 h-5 w-3/4 rounded bg-gray-200 dark:bg-gray-700" />
|
||||
<div className="mb-2 h-4 w-1/2 rounded bg-gray-200 dark:bg-gray-700" />
|
||||
<div className="h-4 w-1/3 rounded bg-gray-200 dark:bg-gray-700" />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function EmptyState({ searchTerm, onCreate }: { searchTerm: string; onCreate: () => void }) {
|
||||
if (searchTerm) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-20 text-center">
|
||||
<div className="mb-6 flex h-24 w-24 items-center justify-center rounded-full bg-blue-50 dark:bg-blue-900/20">
|
||||
<Search className="h-12 w-12 text-blue-500 dark:text-blue-400" />
|
||||
</div>
|
||||
<h3 className="mb-2 text-lg font-semibold text-gray-900 dark:text-white">
|
||||
No matching customers
|
||||
</h3>
|
||||
<p className="mb-2 max-w-sm text-sm text-gray-500 dark:text-gray-400">
|
||||
No customer records matched “{searchTerm}”.
|
||||
</p>
|
||||
<p className="mb-8 text-sm text-gray-400 dark:text-gray-500">
|
||||
Try searching by customer name, phone number, email address, vehicle, or VIN.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-20 text-center">
|
||||
<div className="mb-6 flex h-24 w-24 items-center justify-center rounded-full bg-green-50 dark:bg-green-900/20">
|
||||
<Users className="h-12 w-12 text-green-500 dark:text-green-400" />
|
||||
</div>
|
||||
<h3 className="mb-2 text-lg font-semibold text-gray-900 dark:text-white">
|
||||
No customer records on file
|
||||
</h3>
|
||||
<p className="mb-8 max-w-sm text-sm text-gray-500 dark:text-gray-400">
|
||||
Add your first customer record to keep contact details, vehicles, quotes, and repair history organized in one place.
|
||||
</p>
|
||||
<button
|
||||
onClick={onCreate}
|
||||
className="inline-flex items-center gap-2 rounded-lg bg-green-600 px-5 py-2.5 text-sm font-medium text-white shadow-sm transition-colors hover:bg-green-700 focus:outline-none focus:ring-2 focus:ring-green-500 focus:ring-offset-2 dark:focus:ring-offset-gray-900"
|
||||
>
|
||||
<UserPlus className="h-4 w-4" />
|
||||
Add Customer
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ErrorState({ message, onRetry }: { message: string; onRetry: () => void }) {
|
||||
return <ListError title="Unable to load customers" message={message} onRetry={onRetry} />;
|
||||
}
|
||||
```
|
||||
|
||||
**Step 2 — Commit:**
|
||||
|
||||
```bash
|
||||
git add src/components/customers/EmptyStates.tsx
|
||||
git commit -m "refactor(customers): extract EmptyStates components"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: Create `src/components/customers/CustomerCard.tsx`
|
||||
|
||||
**Objective:** Extract the card used in the list grid, wrapped in `React.memo`.
|
||||
|
||||
**Files:**
|
||||
- Create: `src/components/customers/CustomerCard.tsx`
|
||||
|
||||
**Step 1:** Create:
|
||||
|
||||
```tsx
|
||||
import { memo } from 'react';
|
||||
import { Phone, Mail, Car, FileText, Pencil, Trash2 } from 'lucide-react';
|
||||
import { formatPhoneDisplay } from '../../lib/phone';
|
||||
import { formatVehicleLabel } from '../../lib/customers';
|
||||
import type { CustomerWithVehicles } from './types';
|
||||
|
||||
interface Props {
|
||||
customer: CustomerWithVehicles;
|
||||
onView: (id: string) => void;
|
||||
onEdit: (c: CustomerWithVehicles) => void;
|
||||
onDelete: (c: CustomerWithVehicles) => void;
|
||||
}
|
||||
|
||||
function CustomerCardImpl({ customer, onView, onEdit, onDelete }: Props) {
|
||||
return (
|
||||
<div className="rounded-xl bg-white p-5 shadow-sm ring-1 ring-gray-200 transition-all hover:shadow-md dark:bg-gray-800 dark:ring-gray-700">
|
||||
<div className="mb-3 flex items-start justify-between">
|
||||
<div className="min-w-0 flex-1">
|
||||
<h3
|
||||
className="cursor-pointer truncate text-lg font-semibold text-gray-900 hover:text-green-600 dark:text-white dark:hover:text-green-400"
|
||||
onClick={() => onView(customer.id)}
|
||||
>
|
||||
{customer.name}
|
||||
</h3>
|
||||
<p className="text-xs font-mono text-gray-400 dark:text-gray-500">ID: {customer.id.slice(0, 8)}</p>
|
||||
<div className="mt-1 flex flex-wrap items-center gap-x-3 gap-y-1 text-sm text-gray-500 dark:text-gray-400">
|
||||
{customer.phone && (
|
||||
<span className="inline-flex items-center gap-1">
|
||||
<Phone className="h-3.5 w-3.5" />
|
||||
{formatPhoneDisplay(customer.phone)}
|
||||
</span>
|
||||
)}
|
||||
{customer.email && (
|
||||
<span className="inline-flex items-center gap-1 truncate">
|
||||
<Mail className="h-3.5 w-3.5 shrink-0" />
|
||||
{customer.email}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mb-3 flex items-center gap-1.5 text-sm text-gray-500 dark:text-gray-400">
|
||||
<Car className="h-4 w-4" />
|
||||
<span>
|
||||
{customer.vehicleCount === 0
|
||||
? 'No vehicles'
|
||||
: `${customer.vehicleCount} vehicle${customer.vehicleCount !== 1 ? 's' : ''}`}
|
||||
</span>
|
||||
{customer.vehicles.length > 0 && (
|
||||
<span className="truncate text-gray-400 dark:text-gray-500">
|
||||
— {formatVehicleLabel(customer.vehicles[0])}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 border-t border-gray-100 pt-3 dark:border-gray-700">
|
||||
<button
|
||||
onClick={() => onView(customer.id)}
|
||||
className="inline-flex items-center gap-1.5 rounded-lg bg-green-600 px-3 py-1.5 text-xs font-medium text-white transition-colors hover:bg-green-700"
|
||||
>
|
||||
<FileText className="h-3.5 w-3.5" />
|
||||
View Details
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onEdit(customer)}
|
||||
className="inline-flex items-center gap-1.5 rounded-lg border border-gray-300 bg-white px-3 py-1.5 text-xs font-medium text-gray-700 transition-colors hover:bg-gray-50 dark:border-gray-600 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600"
|
||||
>
|
||||
<Pencil className="h-3.5 w-3.5" />
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
onClick={() => onDelete(customer)}
|
||||
className="ml-auto inline-flex items-center gap-1.5 rounded-lg px-3 py-1.5 text-xs font-medium text-red-600 transition-colors hover:bg-red-50 dark:text-red-400 dark:hover:bg-red-900/20"
|
||||
>
|
||||
<Trash2 className="h-3.5 w-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export const CustomerCard = memo(CustomerCardImpl);
|
||||
```
|
||||
|
||||
**Step 2 — Commit:**
|
||||
|
||||
```bash
|
||||
git add src/components/customers/CustomerCard.tsx
|
||||
git commit -m "refactor(customers): extract memoized CustomerCard"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 5: Create `src/components/customers/CustomerFormModal.tsx`
|
||||
|
||||
**Objective:** Extract the Add/Edit Customer form modal. **It is NOT wrapped in `memo`** — it holds its own state (`useState`) and re-renders only when its `open` prop flips, so memo would add nothing. Keep the exact same JSX and behavior.
|
||||
|
||||
**Files:**
|
||||
- Create: `src/components/customers/CustomerFormModal.tsx`
|
||||
|
||||
**Step 1:** Create the file moving `CustomerFormModal` (lines 282–441 of `Customers.tsx`) verbatim. Replace the inline `CustomerFormData` type with an import:
|
||||
|
||||
```tsx
|
||||
import { useState, useEffect } from 'react';
|
||||
import { X } from 'lucide-react';
|
||||
import { formatPhoneInput } from '../../lib/phone';
|
||||
import type { CustomerFormData, CustomerWithVehicles } from './types';
|
||||
|
||||
export function CustomerFormModal({
|
||||
open,
|
||||
customer,
|
||||
onClose,
|
||||
onSave,
|
||||
}: {
|
||||
open: boolean;
|
||||
customer: CustomerWithVehicles | null;
|
||||
onClose: () => void;
|
||||
onSave: (data: CustomerFormData) => Promise<void>;
|
||||
}) {
|
||||
// ... identical body to current lines 293–440 ...
|
||||
}
|
||||
```
|
||||
|
||||
Copy the JSX body verbatim (lines 293–440 of `Customers.tsx`).
|
||||
|
||||
**Step 2 — Commit:**
|
||||
|
||||
```bash
|
||||
git add src/components/customers/CustomerFormModal.tsx
|
||||
git commit -m "refactor(customers): extract CustomerFormModal"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 6: Create `src/components/customers/VehicleFormModal.tsx` and `DeleteConfirmModal.tsx`
|
||||
|
||||
**Objective:** Extract the vehicle form and the delete-confirmation modal into their own files.
|
||||
|
||||
**Files:**
|
||||
- Create: `src/components/customers/VehicleFormModal.tsx`
|
||||
- Create: `src/components/customers/DeleteConfirmModal.tsx`
|
||||
|
||||
**Step 1:** Create `VehicleFormModal.tsx` — move lines 456–668 of `Customers.tsx` verbatim, importing `VehicleFormData` and `VehicleRecord` from `./types`:
|
||||
|
||||
```tsx
|
||||
import { useState, useEffect } from 'react';
|
||||
import { X } from 'lucide-react';
|
||||
import type { VehicleFormData, VehicleRecord } from './types';
|
||||
|
||||
export function VehicleFormModal({
|
||||
open,
|
||||
vehicle,
|
||||
customerName,
|
||||
onClose,
|
||||
onSave,
|
||||
}: {
|
||||
open: boolean;
|
||||
vehicle: VehicleRecord | null;
|
||||
customerName: string;
|
||||
onClose: () => void;
|
||||
onSave: (data: VehicleFormData) => Promise<void>;
|
||||
}) {
|
||||
// ... identical body to current lines 469–667 ...
|
||||
}
|
||||
```
|
||||
|
||||
**Step 2:** Create `DeleteConfirmModal.tsx` — move lines 672–739 verbatim:
|
||||
|
||||
```tsx
|
||||
import { useState } from 'react';
|
||||
import { AlertTriangle } from 'lucide-react';
|
||||
|
||||
export function DeleteConfirmModal({
|
||||
open,
|
||||
customerName,
|
||||
onClose,
|
||||
onConfirm,
|
||||
}: {
|
||||
open: boolean;
|
||||
customerName: string;
|
||||
onClose: () => void;
|
||||
onConfirm: () => Promise<void>;
|
||||
}) {
|
||||
// ... identical body to current lines 683–738 ...
|
||||
}
|
||||
```
|
||||
|
||||
**Step 3 — Commit:**
|
||||
|
||||
```bash
|
||||
git add src/components/customers/VehicleFormModal.tsx src/components/customers/DeleteConfirmModal.tsx
|
||||
git commit -m "refactor(customers): extract VehicleFormModal and DeleteConfirmModal"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 7: Create `src/components/customers/CustomerDetailView.tsx`
|
||||
|
||||
**Objective:** Extract the large detail view (lines 787–1170) into its own memoized component. It uses `getRoStatusLabel`, `getRoStatusColor`, `formatDate`, `parseQuoteServices` — all from `src/lib/customers.ts`. `formatDate` is renamed to `formatCustomerDate` in the helpers module to avoid a clash with `src/lib/format.ts`.
|
||||
|
||||
**Files:**
|
||||
- Create: `src/components/customers/CustomerDetailView.tsx`
|
||||
|
||||
**Step 1:** Create:
|
||||
|
||||
```tsx
|
||||
import { useState, memo } from 'react';
|
||||
import { Phone, Mail, MapPin, ChevronLeft, Pencil, Plus, FileText, Car, Trash2 } from 'lucide-react';
|
||||
import { formatPhoneDisplay } from '../../lib/phone';
|
||||
import {
|
||||
formatVehicleLabel,
|
||||
getRoStatusLabel,
|
||||
getRoStatusColor,
|
||||
formatCustomerDate,
|
||||
parseQuoteServices,
|
||||
} from '../../lib/customers';
|
||||
import type {
|
||||
CustomerWithVehicles,
|
||||
VehicleRecord,
|
||||
RepairOrderRecord,
|
||||
QuoteRecord,
|
||||
} from './types';
|
||||
|
||||
interface Props {
|
||||
customer: CustomerWithVehicles;
|
||||
vehicles: VehicleRecord[];
|
||||
repairOrders: RepairOrderRecord[];
|
||||
repairOrdersLoading: boolean;
|
||||
quotes: QuoteRecord[];
|
||||
quotesLoading: boolean;
|
||||
onBack: () => void;
|
||||
onEdit: (c: CustomerWithVehicles) => void;
|
||||
onAddVehicle: () => void;
|
||||
onEditVehicle: (v: VehicleRecord) => void;
|
||||
onDeleteVehicle: (v: VehicleRecord) => void;
|
||||
}
|
||||
|
||||
function CustomerDetailViewImpl({
|
||||
customer,
|
||||
vehicles,
|
||||
repairOrders,
|
||||
repairOrdersLoading,
|
||||
quotes,
|
||||
quotesLoading,
|
||||
onBack,
|
||||
onEdit,
|
||||
onAddVehicle,
|
||||
onEditVehicle,
|
||||
onDeleteVehicle,
|
||||
}: Props) {
|
||||
const [expandedQuoteId, setExpandedQuoteId] = useState<string | null>(null);
|
||||
const toggleQuote = (id: string) => setExpandedQuoteId(expandedQuoteId === id ? null : id);
|
||||
|
||||
function formatCurrency(n: number): string {
|
||||
return '$' + n.toFixed(2);
|
||||
}
|
||||
|
||||
return (
|
||||
// ... copy JSX verbatim from lines 837–1168, replacing formatDate(...) calls
|
||||
// with formatCustomerDate(...) calls, and the inline parseQuoteServices with
|
||||
// the imported parseQuoteServices ...
|
||||
);
|
||||
}
|
||||
|
||||
export const CustomerDetailView = memo(CustomerDetailViewImpl);
|
||||
```
|
||||
|
||||
**Step 2 — Commit:**
|
||||
|
||||
```bash
|
||||
git add src/components/customers/CustomerDetailView.tsx
|
||||
git commit -m "refactor(customers): extract memoized CustomerDetailView"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 8: Create `src/components/customers/index.ts` barrel
|
||||
|
||||
**Objective:** One import line in the orchestrator.
|
||||
|
||||
**Files:**
|
||||
- Create: `src/components/customers/index.ts`
|
||||
|
||||
**Step 1:**
|
||||
|
||||
```ts
|
||||
export { CustomersSkeleton, EmptyState, ErrorState } from './EmptyStates';
|
||||
export { CustomerCard } from './CustomerCard';
|
||||
export { CustomerFormModal } from './CustomerFormModal';
|
||||
export { VehicleFormModal } from './VehicleFormModal';
|
||||
export { DeleteConfirmModal } from './DeleteConfirmModal';
|
||||
export { CustomerDetailView } from './CustomerDetailView';
|
||||
export type {
|
||||
CustomerRecord,
|
||||
VehicleRecord,
|
||||
RepairOrderRecord,
|
||||
QuoteRecord,
|
||||
CustomerWithVehicles,
|
||||
CustomerFormData,
|
||||
VehicleFormData,
|
||||
} from './types';
|
||||
```
|
||||
|
||||
**Step 2 — Commit:**
|
||||
|
||||
```bash
|
||||
git add src/components/customers/index.ts
|
||||
git commit -m "refactor(customers): add barrel index"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 9: Rewrite `Customers.tsx` as the orchestrator
|
||||
|
||||
**Objective:** Remove every inline component and helper. Wire the extracted pieces. The main `Customers` component keeps all state, handlers, and the `renderListView`/`renderDetailView` orchestration. All handlers passed to memoized children are already wrapped in `useCallback`, so the memo is effective.
|
||||
|
||||
**Files:**
|
||||
- Modify (rewrite): `src/pages/Customers.tsx`
|
||||
|
||||
**Step 1:** Replace the top of the file (imports + old interface/helper/component declarations) with:
|
||||
|
||||
```tsx
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { X, Plus } from 'lucide-react';
|
||||
import { pb } from '../lib/pocketbase';
|
||||
import { useToast } from '../components/ui/Toast';
|
||||
import { getSetupRequiredMessage, isMissingCollectionError, logError } from '../lib/userMessages';
|
||||
import { customerWriteSchema } from '../schemas/customer';
|
||||
import {
|
||||
formatVehicleLabel,
|
||||
vehicleKey,
|
||||
escapePbString,
|
||||
activeROFilterForCustomer,
|
||||
} from '../lib/customers';
|
||||
import {
|
||||
CustomersSkeleton,
|
||||
EmptyState,
|
||||
ErrorState,
|
||||
CustomerCard,
|
||||
CustomerFormModal,
|
||||
VehicleFormModal,
|
||||
DeleteConfirmModal,
|
||||
CustomerDetailView,
|
||||
} from '../components/customers';
|
||||
|
||||
export type {
|
||||
CustomerRecord,
|
||||
CustomerWithVehicles,
|
||||
CustomerFormData,
|
||||
} from '../components/customers/types';
|
||||
|
||||
import type {
|
||||
CustomerWithVehicles,
|
||||
CustomerRecord,
|
||||
VehicleRecord,
|
||||
RepairOrderRecord,
|
||||
QuoteRecord,
|
||||
CustomerFormData,
|
||||
VehicleFormData,
|
||||
} from '../components/customers/types';
|
||||
```
|
||||
|
||||
**Step 2:** Keep the main `Customers` function body (lines 1174–1794) intact, with these adjustments:
|
||||
- Remove the now-extracted `CustomerCard`, `CustomerFormModal`, `VehicleFormModal`, `DeleteConfirmModal`, `CustomerDetailView` usages in the footer modals — replace with imported versions (same JSX, different source).
|
||||
- Every handler (`fetchAll`, `handleViewCustomer`, `handleAddCustomer`, `handleEditCustomer`, `handleDeleteCustomer`, `handleAddVehicle`, `handleEditVehicle`, `handleDeleteVehicle`) is already wrapped in `useCallback` — keep them.
|
||||
- In `renderListView`, `<CustomerCard ... />` now refers to the memoized import.
|
||||
- In `renderDetailView`, `<CustomerDetailView ... />` refers to the memoized import.
|
||||
- In the modals footer, `<CustomerFormModal>`, `<VehicleFormModal>`, `<DeleteConfirmModal>` refer to the imports.
|
||||
- The `formatVehicleLabel` reference in the footer's "Delete Vehicle Confirmation" modal (line 1788: `formatVehicleLabel(deletingVehicle)`) now uses the import from `../lib/customers`.
|
||||
|
||||
**Step 3 — Verify build:**
|
||||
|
||||
```bash
|
||||
npm run build
|
||||
```
|
||||
Expected: PASS (0 errors). The orchestrator should be ~520–580 lines (state + handlers + two render functions + modals footer).
|
||||
|
||||
**Step 4 — Lint:**
|
||||
|
||||
```bash
|
||||
npm run lint
|
||||
```
|
||||
Expected: PASS (0 new errors/warnings; the 2 pre-existing Toast.tsx / Settings.tsx warnings remain).
|
||||
|
||||
**Step 5 — Test:**
|
||||
|
||||
```bash
|
||||
npm run test
|
||||
```
|
||||
Expected: the full Vitest suite passes (86+ tests across 12+ files). No new tests are added in this refactor — there are no existing tests for `Customers.tsx` to break. The existing `src/schemas/__tests__/customer.test.ts` (if any) and `src/lib/__tests__/` and `src/store/__tests__/` suites must pass unchanged.
|
||||
|
||||
**Step 6 — Commit:**
|
||||
|
||||
```bash
|
||||
git add src/pages/Customers.tsx
|
||||
git commit -m "refactor(customers): rewrite orchestrator using extracted components"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 10: Verification & bundle-size check
|
||||
|
||||
**Objective:** Confirm the refactor produced a smaller Customers chunk and nothing else regressed.
|
||||
|
||||
**Step 1 — Full verification block:**
|
||||
|
||||
```bash
|
||||
npm run build && npm run lint && npm run test
|
||||
```
|
||||
Expected: all three pass.
|
||||
|
||||
**Step 2 — Inspect chunk sizes (same command from roadmap Appendix A):**
|
||||
|
||||
```bash
|
||||
du -sh dist/assets/
|
||||
ls -laS dist/assets/ | head -10
|
||||
for f in dist/assets/*.js; do
|
||||
printf "%-45s raw=%7d gzip=%7d\n" "$(basename $f)" "$(stat -c%s $f)" "$(gzip -c $f | wc -c)"
|
||||
done
|
||||
```
|
||||
|
||||
Expected: the Customers chunk is now smaller (the detail-view and form modal code is split into smaller lazy-ish chunks, though Vite may or may not split these into separate chunks without explicit `lazy()` — at minimum the page source file is smaller, which improves maintainability and re-render hotspots as described in roadmap 2.4).
|
||||
|
||||
**Step 3 — Manual smoke (optional):**
|
||||
|
||||
No behavior change is expected. If the user wants to verify, `npm run dev` and click through: list → search → customer card → detail view → add/edit customer modal → add/edit vehicle modal → delete customer modal. All UI should look identical.
|
||||
|
||||
**Step 4 — Commit (if any verification fixes):**
|
||||
|
||||
If any verification step required fixes, commit those. Otherwise skip this step.
|
||||
|
||||
---
|
||||
|
||||
## Risks, tradeoffs, and open questions
|
||||
|
||||
**Risks:**
|
||||
1. **External type imports break.** Five files import types from `'../pages/Customers'`. Mitigation: `Customers.tsx` re-exports all three types. Verified in Task 1's re-export line. If any external file still breaks, update its import to `'../components/customers'` (or `../../components/customers/types`) — but the re-export means no change should be needed.
|
||||
2. **`formatDate` rename.** `formatCustomerDate` avoids a clash with `src/lib/format.ts:formatDate` (used elsewhere in the app). All call sites inside `CustomerDetailView` must be updated.
|
||||
3. **`memo` effectiveness.** `CustomerCard` and `CustomerDetailView` will only skip re-renders if every prop is referentially stable. `customer`, `vehicles`, `repairOrders`, `quotes` come from `useState` and are only replaced on fetch — stable between renders. Callbacks (`onView`, `onEdit`, etc.) are wrapped in `useCallback`. The memo is real; if a future change introduces an inline callback, memo breaks silently.
|
||||
4. **No new tests are added.** Roadmap 2.4 for Appointments also didn't add tests for the extracted components; this matches the precedent. If the user wants snapshot tests for `CustomerCard`/`CustomerDetailView`, that's a separate task.
|
||||
|
||||
**Tradeoffs:**
|
||||
- More files to navigate, but each is smaller and single-purpose.
|
||||
- The barrel import makes tree-shaking easier if any component is lazy-loaded later.
|
||||
|
||||
**Open questions:** None. The pattern is proven on Appointments.tsx (Phase 2.4, 2026-07-06 memo). Customer behavior is unchanged.
|
||||
|
||||
---
|
||||
|
||||
## Verification summary
|
||||
|
||||
```
|
||||
npm run build → PASS (0 errors, 0 warnings new)
|
||||
npm run lint → PASS (baseline warnings only)
|
||||
npm run test → PASS (86+ tests; no regressions)
|
||||
```
|
||||
|
||||
**Resolved roadmap priority:** 2.4 (continues the mega-component split, Customers.tsx this time). Next up per the pattern: RepairOrders.tsx (already partially memoized — `FragmentRow`/`CompletedRow` exist) or QuoteGenerator.tsx.
|
||||
@@ -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.**
|
||||
@@ -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 }]
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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
Binary file not shown.
@@ -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.
|
||||
@@ -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.
|
||||
BIN
Binary file not shown.
+18
@@ -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>
|
||||
Generated
+3474
File diff suppressed because it is too large
Load Diff
@@ -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');
|
||||
}
|
||||
}
|
||||
);
|
||||
@@ -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 |
@@ -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 |
+1272
File diff suppressed because it is too large
Load Diff
+237
@@ -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;
|
||||
@@ -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>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>;
|
||||
}
|
||||
@@ -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 && (
|
||||
<> · {suggestedCustomer.phone}</>
|
||||
)}
|
||||
{suggestedCustomer.email && (
|
||||
<> · {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}>±1 hour</option>
|
||||
<option value={120}>±2 hours</option>
|
||||
<option value={180}>±3 hours</option>
|
||||
<option value={240}>±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 — 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);
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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';
|
||||
@@ -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">
|
||||
— {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>
|
||||
);
|
||||
}
|
||||
@@ -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 “{searchTerm}”.
|
||||
</p>
|
||||
<p className="mb-8 text-sm text-gray-400 dark:text-gray-500">
|
||||
Try searching by customer name, phone number, email address, vehicle, or VIN.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center py-20 text-center">
|
||||
<div className="mb-6 flex h-24 w-24 items-center justify-center rounded-full bg-green-50 dark:bg-green-900/20">
|
||||
<Users className="h-12 w-12 text-green-500 dark:text-green-400" />
|
||||
</div>
|
||||
<h3 className="mb-2 text-lg font-semibold text-gray-900 dark:text-white">
|
||||
No customer records on file
|
||||
</h3>
|
||||
<p className="mb-8 max-w-sm text-sm text-gray-500 dark:text-gray-400">
|
||||
Add your first customer record to keep contact details, vehicles, quotes, and repair history organized in one place.
|
||||
</p>
|
||||
<button
|
||||
onClick={onCreate}
|
||||
className="inline-flex items-center gap-2 rounded-lg bg-green-600 px-5 py-2.5 text-sm font-medium text-white shadow-sm transition-colors hover:bg-green-700 focus:outline-none focus:ring-2 focus:ring-green-500 focus:ring-offset-2 dark:focus:ring-offset-gray-900"
|
||||
>
|
||||
<UserPlus className="h-4 w-4" />
|
||||
Add Customer
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ErrorState({ message, onRetry }: { message: string; onRetry: () => void }) {
|
||||
return <ListError title="Unable to load customers" message={message} onRetry={onRetry} />;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 “{query}” 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>
|
||||
);
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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} />;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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())} />;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user