Initial commit — Shop Manager for Tony Moon

Full auto mechanic shop management tool: jobs, invoices, estimates,
inspections, inventory, recommendations, technicians, appointments,
reports, file uploads, kanban board, and more.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-04-07 00:08:35 -05:00
commit 8839412b52
100 changed files with 18510 additions and 0 deletions

24
client/.gitignore vendored Normal file
View File

@@ -0,0 +1,24 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
dist
dist-ssr
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
.DS_Store
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?

73
client/README.md Normal file
View File

@@ -0,0 +1,73 @@
# React + TypeScript + Vite
This template provides a minimal setup to get React working in Vite with HMR and some ESLint 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 ESLint configuration
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
```js
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Remove tseslint.configs.recommended and replace with this
tseslint.configs.recommendedTypeChecked,
// Alternatively, use this for stricter rules
tseslint.configs.strictTypeChecked,
// Optionally, add this for stylistic rules
tseslint.configs.stylisticTypeChecked,
// Other configs...
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
```js
// eslint.config.js
import reactX from 'eslint-plugin-react-x'
import reactDom from 'eslint-plugin-react-dom'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Enable lint rules for React
reactX.configs['recommended-typescript'],
// Enable lint rules for React DOM
reactDom.configs.recommended,
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```

23
client/eslint.config.js Normal file
View File

@@ -0,0 +1,23 @@
import js from '@eslint/js'
import globals from 'globals'
import reactHooks from 'eslint-plugin-react-hooks'
import reactRefresh from 'eslint-plugin-react-refresh'
import tseslint from 'typescript-eslint'
import { defineConfig, globalIgnores } from 'eslint/config'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
js.configs.recommended,
tseslint.configs.recommended,
reactHooks.configs.flat.recommended,
reactRefresh.configs.vite,
],
languageOptions: {
ecmaVersion: 2020,
globals: globals.browser,
},
},
])

13
client/index.html Normal file
View File

@@ -0,0 +1,13 @@
<!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" />
<title>client</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

3367
client/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

37
client/package.json Normal file
View File

@@ -0,0 +1,37 @@
{
"name": "client",
"private": true,
"version": "0.0.0",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"preview": "vite preview"
},
"dependencies": {
"@tailwindcss/vite": "^4.2.2",
"@tanstack/react-query": "^5.96.2",
"date-fns": "^4.1.0",
"lucide-react": "^1.7.0",
"react": "^19.2.4",
"react-dom": "^19.2.4",
"react-router-dom": "^7.14.0",
"tailwindcss": "^4.2.2",
"zustand": "^5.0.12"
},
"devDependencies": {
"@eslint/js": "^9.39.4",
"@types/node": "^24.12.0",
"@types/react": "^19.2.14",
"@types/react-dom": "^19.2.3",
"@vitejs/plugin-react": "^6.0.1",
"eslint": "^9.39.4",
"eslint-plugin-react-hooks": "^7.0.1",
"eslint-plugin-react-refresh": "^0.5.2",
"globals": "^17.4.0",
"typescript": "~5.9.3",
"typescript-eslint": "^8.57.0",
"vite": "^8.0.1"
}
}

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.3 KiB

24
client/public/icons.svg Normal file
View File

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

After

Width:  |  Height:  |  Size: 4.9 KiB

74
client/src/App.tsx Normal file
View File

@@ -0,0 +1,74 @@
import { useEffect } from 'react';
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { useStore } from './store';
import { api } from './api/client';
import Layout from './components/Layout';
import Login from './pages/Login';
import Dashboard from './pages/Dashboard';
import Shops from './pages/Shops';
import ShopDetail from './pages/ShopDetail';
import Owners from './pages/Owners';
import Vehicles from './pages/Vehicles';
import Jobs from './pages/Jobs';
import JobDetail from './pages/JobDetail';
import Invoices from './pages/Invoices';
import InvoiceDetail from './pages/InvoiceDetail';
import VehicleDetail from './pages/VehicleDetail';
import OwnerDetail from './pages/OwnerDetail';
import Estimates from './pages/Estimates';
import EstimateDetail from './pages/EstimateDetail';
import Technicians from './pages/Technicians';
import Appointments from './pages/Appointments';
import InventoryPage from './pages/Inventory';
import Reports from './pages/Reports';
import Settings from './pages/Settings';
const queryClient = new QueryClient({
defaultOptions: { queries: { staleTime: 30000, retry: 1 } },
});
function AuthGuard({ children }: { children: React.ReactNode }) {
const { token, setUser } = useStore();
useEffect(() => {
if (token) {
api.me().then(setUser).catch(() => useStore.getState().logout());
}
}, [token, setUser]);
if (!token) return <Navigate to="/login" replace />;
return <>{children}</>;
}
export default function App() {
return (
<QueryClientProvider client={queryClient}>
<BrowserRouter>
<Routes>
<Route path="/login" element={<Login />} />
<Route element={<AuthGuard><Layout /></AuthGuard>}>
<Route path="/" element={<Dashboard />} />
<Route path="/shops" element={<Shops />} />
<Route path="/shops/:id" element={<ShopDetail />} />
<Route path="/owners" element={<Owners />} />
<Route path="/owners/:id" element={<OwnerDetail />} />
<Route path="/vehicles" element={<Vehicles />} />
<Route path="/vehicles/:id" element={<VehicleDetail />} />
<Route path="/jobs" element={<Jobs />} />
<Route path="/jobs/:id" element={<JobDetail />} />
<Route path="/estimates" element={<Estimates />} />
<Route path="/estimates/:id" element={<EstimateDetail />} />
<Route path="/invoices" element={<Invoices />} />
<Route path="/invoices/:id" element={<InvoiceDetail />} />
<Route path="/technicians" element={<Technicians />} />
<Route path="/appointments" element={<Appointments />} />
<Route path="/inventory" element={<InventoryPage />} />
<Route path="/reports" element={<Reports />} />
<Route path="/settings" element={<Settings />} />
</Route>
</Routes>
</BrowserRouter>
</QueryClientProvider>
);
}

353
client/src/api/client.ts Normal file
View File

@@ -0,0 +1,353 @@
const API_BASE = '/api';
async function apiFetch<T>(url: string, options?: RequestInit): Promise<T> {
const token = localStorage.getItem('autoking_token');
const res = await fetch(`${API_BASE}${url}`, {
...options,
headers: {
'Content-Type': 'application/json',
...(token && { Authorization: `Bearer ${token}` }),
...options?.headers,
},
});
if (res.status === 401) {
localStorage.removeItem('autoking_token');
window.location.href = '/login';
throw new Error('Unauthorized');
}
if (!res.ok) {
const err = await res.json().catch(() => ({ error: res.statusText }));
throw new Error(err.error || res.statusText);
}
if (res.status === 204) return undefined as T;
return res.json();
}
export const api = {
// Auth
setupRequired: () => apiFetch<{ setupRequired: boolean }>('/auth/setup-required'),
register: (data: { username: string; password: string; displayName?: string }) =>
apiFetch<{ token: string; user: any }>('/auth/register', { method: 'POST', body: JSON.stringify(data) }),
login: (data: { username: string; password: string }) =>
apiFetch<{ token: string; user: any }>('/auth/login', { method: 'POST', body: JSON.stringify(data) }),
me: () => apiFetch<any>('/auth/me'),
// Shops
getShops: (params?: { search?: string; active?: number }) => {
const qs = new URLSearchParams();
if (params?.search) qs.set('search', params.search);
if (params?.active !== undefined) qs.set('active', String(params.active));
return apiFetch<any[]>(`/shops?${qs}`);
},
getShop: (id: number) => apiFetch<any>(`/shops/${id}`),
createShop: (data: any) => apiFetch<any>('/shops', { method: 'POST', body: JSON.stringify(data) }),
updateShop: (id: number, data: any) => apiFetch<any>(`/shops/${id}`, { method: 'PUT', body: JSON.stringify(data) }),
deleteShop: (id: number) => apiFetch<any>(`/shops/${id}`, { method: 'DELETE' }),
getShopJobs: (id: number) => apiFetch<any[]>(`/shops/${id}/jobs`),
getShopInvoices: (id: number) => apiFetch<any[]>(`/shops/${id}/invoices`),
// Owners
getOwners: (params?: { search?: string }) => {
const qs = new URLSearchParams();
if (params?.search) qs.set('search', params.search);
return apiFetch<any[]>(`/owners?${qs}`);
},
getOwner: (id: number) => apiFetch<any>(`/owners/${id}`),
createOwner: (data: any) => apiFetch<any>('/owners', { method: 'POST', body: JSON.stringify(data) }),
updateOwner: (id: number, data: any) => apiFetch<any>(`/owners/${id}`, { method: 'PUT', body: JSON.stringify(data) }),
deleteOwner: (id: number) => apiFetch<any>(`/owners/${id}`, { method: 'DELETE' }),
// Vehicles
getVehicles: (params?: { search?: string; ownerId?: number; inventory?: number }) => {
const qs = new URLSearchParams();
if (params?.search) qs.set('search', params.search);
if (params?.ownerId) qs.set('ownerId', String(params.ownerId));
if (params?.inventory !== undefined) qs.set('inventory', String(params.inventory));
return apiFetch<any[]>(`/vehicles?${qs}`);
},
getVehicle: (id: number) => apiFetch<any>(`/vehicles/${id}`),
createVehicle: (data: any) => apiFetch<any>('/vehicles', { method: 'POST', body: JSON.stringify(data) }),
updateVehicle: (id: number, data: any) => apiFetch<any>(`/vehicles/${id}`, { method: 'PUT', body: JSON.stringify(data) }),
deleteVehicle: (id: number) => apiFetch<any>(`/vehicles/${id}`, { method: 'DELETE' }),
getVehicleHistory: (id: number) => apiFetch<any>(`/vehicles/${id}/history`),
// Jobs
getJobs: (params?: { status?: string; shopId?: number; search?: string }) => {
const qs = new URLSearchParams();
if (params?.status) qs.set('status', params.status);
if (params?.shopId) qs.set('shopId', String(params.shopId));
if (params?.search) qs.set('search', params.search);
return apiFetch<any[]>(`/jobs?${qs}`);
},
getJob: (id: number) => apiFetch<any>(`/jobs/${id}`),
createJob: (data: any) => apiFetch<any>('/jobs', { method: 'POST', body: JSON.stringify(data) }),
updateJob: (id: number, data: any) => apiFetch<any>(`/jobs/${id}`, { method: 'PUT', body: JSON.stringify(data) }),
deleteJob: (id: number) => apiFetch<any>(`/jobs/${id}`, { method: 'DELETE' }),
// Labor items
addLabor: (jobId: number, data: any) => apiFetch<any>(`/jobs/${jobId}/labor`, { method: 'POST', body: JSON.stringify(data) }),
updateLabor: (jobId: number, id: number, data: any) => apiFetch<any>(`/jobs/${jobId}/labor/${id}`, { method: 'PUT', body: JSON.stringify(data) }),
deleteLabor: (jobId: number, id: number) => apiFetch<any>(`/jobs/${jobId}/labor/${id}`, { method: 'DELETE' }),
// Parts items
addParts: (jobId: number, data: any) => apiFetch<any>(`/jobs/${jobId}/parts`, { method: 'POST', body: JSON.stringify(data) }),
updateParts: (jobId: number, id: number, data: any) => apiFetch<any>(`/jobs/${jobId}/parts/${id}`, { method: 'PUT', body: JSON.stringify(data) }),
deleteParts: (jobId: number, id: number) => apiFetch<any>(`/jobs/${jobId}/parts/${id}`, { method: 'DELETE' }),
// Job notes
addNote: (jobId: number, note: string, visibility: 'internal' | 'customer' = 'internal') => apiFetch<any>(`/jobs/${jobId}/notes`, { method: 'POST', body: JSON.stringify({ note, visibility }) }),
updateNote: (jobId: number, id: number, data: { note: string; visibility: string }) => apiFetch<any>(`/jobs/${jobId}/notes/${id}`, { method: 'PUT', body: JSON.stringify(data) }),
deleteNote: (jobId: number, id: number) => apiFetch<any>(`/jobs/${jobId}/notes/${id}`, { method: 'DELETE' }),
// Misc/Sublet items
addMisc: (jobId: number, data: any) => apiFetch<any>(`/jobs/${jobId}/misc`, { method: 'POST', body: JSON.stringify(data) }),
updateMisc: (jobId: number, id: number, data: any) => apiFetch<any>(`/jobs/${jobId}/misc/${id}`, { method: 'PUT', body: JSON.stringify(data) }),
deleteMisc: (jobId: number, id: number) => apiFetch<any>(`/jobs/${jobId}/misc/${id}`, { method: 'DELETE' }),
// Recommendations
addRecommendation: (jobId: number, data: any) => apiFetch<any>(`/jobs/${jobId}/recommendations`, { method: 'POST', body: JSON.stringify(data) }),
updateRecommendation: (jobId: number, id: number, data: any) => apiFetch<any>(`/jobs/${jobId}/recommendations/${id}`, { method: 'PUT', body: JSON.stringify(data) }),
deleteRecommendation: (jobId: number, id: number) => apiFetch<any>(`/jobs/${jobId}/recommendations/${id}`, { method: 'DELETE' }),
createEstimateFromRec: (jobId: number, recId: number) => apiFetch<any>(`/jobs/${jobId}/recommendations/${recId}/estimate`, { method: 'POST' }),
// One-click invoice from job
invoiceJob: (jobId: number) => apiFetch<any>(`/jobs/${jobId}/invoice`, { method: 'POST' }),
// Invoices
getInvoices: (params?: { status?: string; shopId?: number; ownerId?: number }) => {
const qs = new URLSearchParams();
if (params?.status) qs.set('status', params.status);
if (params?.shopId) qs.set('shopId', String(params.shopId));
if (params?.ownerId) qs.set('ownerId', String(params.ownerId));
return apiFetch<any[]>(`/invoices?${qs}`);
},
getInvoice: (id: number) => apiFetch<any>(`/invoices/${id}`),
createInvoice: (data: { shopId?: number; ownerId?: number; jobIds: number[]; notes?: string }) =>
apiFetch<any>('/invoices', { method: 'POST', body: JSON.stringify(data) }),
updateInvoice: (id: number, data: any) => apiFetch<any>(`/invoices/${id}`, { method: 'PUT', body: JSON.stringify(data) }),
deleteInvoice: (id: number) => apiFetch<any>(`/invoices/${id}`, { method: 'DELETE' }),
getInvoicePdfUrl: (id: number) => {
const token = localStorage.getItem('autoking_token');
return `${API_BASE}/invoices/${id}/pdf?token=${token}`;
},
// Payments
addPayment: (invoiceId: number, data: any) => apiFetch<any>(`/invoices/${invoiceId}/payments`, { method: 'POST', body: JSON.stringify(data) }),
deletePayment: (invoiceId: number, id: number) => apiFetch<any>(`/invoices/${invoiceId}/payments/${id}`, { method: 'DELETE' }),
// Dashboard
getDashboardStats: () => apiFetch<any>('/dashboard/stats'),
// Settings
getSettings: () => apiFetch<any>('/settings'),
updateSettings: (data: any) => apiFetch<any>('/settings', { method: 'PUT', body: JSON.stringify(data) }),
// Backup
getBackupInfo: () => apiFetch<any>('/backup/info'),
downloadBackup: () => {
const token = localStorage.getItem('autoking_token');
window.location.href = `${API_BASE}/backup/download?token=${token}`;
},
// Tags
getTags: () => apiFetch<any[]>('/tags'),
createTag: (data: { name: string; color: string }) => apiFetch<any>('/tags', { method: 'POST', body: JSON.stringify(data) }),
deleteTag: (id: number) => apiFetch<any>(`/tags/${id}`, { method: 'DELETE' }),
addTagToJob: (jobId: number, tagId: number) => apiFetch<any>(`/tags/job/${jobId}`, { method: 'POST', body: JSON.stringify({ tagId }) }),
removeTagFromJob: (jobId: number, tagId: number) => apiFetch<any>(`/tags/job/${jobId}/${tagId}`, { method: 'DELETE' }),
// Estimates
getEstimates: (params?: { status?: string; shopId?: number; ownerId?: number }) => {
const qs = new URLSearchParams();
if (params?.status) qs.set('status', params.status);
if (params?.shopId) qs.set('shopId', String(params.shopId));
if (params?.ownerId) qs.set('ownerId', String(params.ownerId));
return apiFetch<any[]>(`/estimates?${qs}`);
},
getEstimate: (id: number) => apiFetch<any>(`/estimates/${id}`),
createEstimate: (data: any) => apiFetch<any>('/estimates', { method: 'POST', body: JSON.stringify(data) }),
updateEstimate: (id: number, data: any) => apiFetch<any>(`/estimates/${id}`, { method: 'PUT', body: JSON.stringify(data) }),
deleteEstimate: (id: number) => apiFetch<any>(`/estimates/${id}`, { method: 'DELETE' }),
addEstimateItem: (estimateId: number, data: any) => apiFetch<any>(`/estimates/${estimateId}/items`, { method: 'POST', body: JSON.stringify(data) }),
updateEstimateItem: (estimateId: number, itemId: number, data: any) => apiFetch<any>(`/estimates/${estimateId}/items/${itemId}`, { method: 'PUT', body: JSON.stringify(data) }),
deleteEstimateItem: (estimateId: number, itemId: number) => apiFetch<any>(`/estimates/${estimateId}/items/${itemId}`, { method: 'DELETE' }),
convertEstimateToJob: (estimateId: number) => apiFetch<any>(`/estimates/${estimateId}/convert`, { method: 'POST' }),
// Canned Services
getCannedServices: (params?: { active?: number }) => {
const qs = new URLSearchParams();
if (params?.active !== undefined) qs.set('active', String(params.active));
return apiFetch<any[]>(`/canned-services?${qs}`);
},
getCannedService: (id: number) => apiFetch<any>(`/canned-services/${id}`),
createCannedService: (data: any) => apiFetch<any>('/canned-services', { method: 'POST', body: JSON.stringify(data) }),
updateCannedService: (id: number, data: any) => apiFetch<any>(`/canned-services/${id}`, { method: 'PUT', body: JSON.stringify(data) }),
deleteCannedService: (id: number) => apiFetch<any>(`/canned-services/${id}`, { method: 'DELETE' }),
// Technicians
getTechnicians: (params?: { active?: number }) => {
const qs = new URLSearchParams();
if (params?.active !== undefined) qs.set('active', String(params.active));
return apiFetch<any[]>(`/technicians?${qs}`);
},
getTechnician: (id: number) => apiFetch<any>(`/technicians/${id}`),
createTechnician: (data: any) => apiFetch<any>('/technicians', { method: 'POST', body: JSON.stringify(data) }),
updateTechnician: (id: number, data: any) => apiFetch<any>(`/technicians/${id}`, { method: 'PUT', body: JSON.stringify(data) }),
deleteTechnician: (id: number) => apiFetch<any>(`/technicians/${id}`, { method: 'DELETE' }),
clockIn: (techId: number, jobId: number) => apiFetch<any>(`/technicians/${techId}/clock-in`, { method: 'POST', body: JSON.stringify({ jobId }) }),
clockOut: (techId: number) => apiFetch<any>(`/technicians/${techId}/clock-out`, { method: 'POST' }),
getTimeEntries: (jobId: number) => apiFetch<any[]>(`/technicians/time-entries/job/${jobId}`),
// Messages
getMessages: (params?: { jobId?: number; ownerId?: number }) => {
const qs = new URLSearchParams();
if (params?.jobId) qs.set('jobId', String(params.jobId));
if (params?.ownerId) qs.set('ownerId', String(params.ownerId));
return apiFetch<any[]>(`/messages?${qs}`);
},
sendMessage: (data: any) => apiFetch<any>('/messages', { method: 'POST', body: JSON.stringify(data) }),
deleteMessage: (id: number) => apiFetch<any>(`/messages/${id}`, { method: 'DELETE' }),
getMessageTemplates: () => apiFetch<any[]>('/messages/templates'),
createMessageTemplate: (data: any) => apiFetch<any>('/messages/templates', { method: 'POST', body: JSON.stringify(data) }),
updateMessageTemplate: (id: number, data: any) => apiFetch<any>(`/messages/templates/${id}`, { method: 'PUT', body: JSON.stringify(data) }),
deleteMessageTemplate: (id: number) => apiFetch<any>(`/messages/templates/${id}`, { method: 'DELETE' }),
getEmailSettings: () => apiFetch<any>('/messages/email-settings'),
updateEmailSettings: (data: any) => apiFetch<any>('/messages/email-settings', { method: 'PUT', body: JSON.stringify(data) }),
// Appointments
getAppointments: (params?: { status?: string; date?: string; ownerId?: number }) => {
const qs = new URLSearchParams();
if (params?.status) qs.set('status', params.status);
if (params?.date) qs.set('date', params.date);
if (params?.ownerId) qs.set('ownerId', String(params.ownerId));
return apiFetch<any[]>(`/appointments?${qs}`);
},
getAppointment: (id: number) => apiFetch<any>(`/appointments/${id}`),
createAppointment: (data: any) => apiFetch<any>('/appointments', { method: 'POST', body: JSON.stringify(data) }),
updateAppointment: (id: number, data: any) => apiFetch<any>(`/appointments/${id}`, { method: 'PUT', body: JSON.stringify(data) }),
deleteAppointment: (id: number) => apiFetch<any>(`/appointments/${id}`, { method: 'DELETE' }),
// Deferred Services
getDeferredServices: (params?: { status?: string; vehicleId?: number }) => {
const qs = new URLSearchParams();
if (params?.status) qs.set('status', params.status);
if (params?.vehicleId) qs.set('vehicleId', String(params.vehicleId));
return apiFetch<any[]>(`/appointments/deferred?${qs}`);
},
createDeferredService: (data: any) => apiFetch<any>('/appointments/deferred', { method: 'POST', body: JSON.stringify(data) }),
updateDeferredService: (id: number, data: any) => apiFetch<any>(`/appointments/deferred/${id}`, { method: 'PUT', body: JSON.stringify(data) }),
deleteDeferredService: (id: number) => apiFetch<any>(`/appointments/deferred/${id}`, { method: 'DELETE' }),
// Inventory
getInventoryItems: (params?: { itemType?: string; category?: string; lowStock?: number; search?: string }) => {
const qs = new URLSearchParams();
if (params?.itemType) qs.set('itemType', params.itemType);
if (params?.category) qs.set('category', params.category);
if (params?.lowStock) qs.set('lowStock', '1');
if (params?.search) qs.set('search', params.search);
return apiFetch<any[]>(`/inventory?${qs}`);
},
getInventoryItem: (id: number) => apiFetch<any>(`/inventory/${id}`),
createInventoryItem: (data: any) => apiFetch<any>('/inventory', { method: 'POST', body: JSON.stringify(data) }),
updateInventoryItem: (id: number, data: any) => apiFetch<any>(`/inventory/${id}`, { method: 'PUT', body: JSON.stringify(data) }),
deleteInventoryItem: (id: number) => apiFetch<any>(`/inventory/${id}`, { method: 'DELETE' }),
addInventoryTransaction: (itemId: number, data: any) => apiFetch<any>(`/inventory/${itemId}/transactions`, { method: 'POST', body: JSON.stringify(data) }),
// Purchase Orders
getPurchaseOrders: () => apiFetch<any[]>('/inventory/purchase-orders'),
getPurchaseOrder: (id: number) => apiFetch<any>(`/inventory/purchase-orders/${id}`),
createPurchaseOrder: (data: any) => apiFetch<any>('/inventory/purchase-orders', { method: 'POST', body: JSON.stringify(data) }),
updatePurchaseOrder: (id: number, data: any) => apiFetch<any>(`/inventory/purchase-orders/${id}`, { method: 'PUT', body: JSON.stringify(data) }),
// Tools
getTools: (params?: { category?: string }) => {
const qs = new URLSearchParams();
if (params?.category) qs.set('category', params.category);
return apiFetch<any[]>(`/inventory/tools?${qs}`);
},
createTool: (data: any) => apiFetch<any>('/inventory/tools', { method: 'POST', body: JSON.stringify(data) }),
updateTool: (id: number, data: any) => apiFetch<any>(`/inventory/tools/${id}`, { method: 'PUT', body: JSON.stringify(data) }),
deleteTool: (id: number) => apiFetch<any>(`/inventory/tools/${id}`, { method: 'DELETE' }),
// Reports
getRevenueReport: (params?: { startDate?: string; endDate?: string }) => {
const qs = new URLSearchParams();
if (params?.startDate) qs.set('startDate', params.startDate);
if (params?.endDate) qs.set('endDate', params.endDate);
return apiFetch<any>(`/reports/revenue?${qs}`);
},
getProfitabilityReport: (params?: { startDate?: string; endDate?: string }) => {
const qs = new URLSearchParams();
if (params?.startDate) qs.set('startDate', params.startDate);
if (params?.endDate) qs.set('endDate', params.endDate);
return apiFetch<any>(`/reports/profitability?${qs}`);
},
getJobsByStatusReport: () => apiFetch<any[]>('/reports/jobs-by-status'),
getRevenueByShopReport: (params?: { startDate?: string; endDate?: string }) => {
const qs = new URLSearchParams();
if (params?.startDate) qs.set('startDate', params.startDate);
if (params?.endDate) qs.set('endDate', params.endDate);
return apiFetch<any>(`/reports/revenue-by-shop?${qs}`);
},
getAgingReport: () => apiFetch<any>('/reports/aging'),
getTechProductivityReport: (params?: { startDate?: string; endDate?: string }) => {
const qs = new URLSearchParams();
if (params?.startDate) qs.set('startDate', params.startDate);
if (params?.endDate) qs.set('endDate', params.endDate);
return apiFetch<any>(`/reports/technician-productivity?${qs}`);
},
getTopCustomersReport: (params?: { startDate?: string; endDate?: string }) => {
const qs = new URLSearchParams();
if (params?.startDate) qs.set('startDate', params.startDate);
if (params?.endDate) qs.set('endDate', params.endDate);
return apiFetch<any>(`/reports/top-customers?${qs}`);
},
getInventorySummaryReport: () => apiFetch<any>('/reports/inventory-summary'),
// Fleets
getFleets: () => apiFetch<any[]>('/fleets'),
getFleet: (id: number) => apiFetch<any>(`/fleets/${id}`),
createFleet: (data: any) => apiFetch<any>('/fleets', { method: 'POST', body: JSON.stringify(data) }),
updateFleet: (id: number, data: any) => apiFetch<any>(`/fleets/${id}`, { method: 'PUT', body: JSON.stringify(data) }),
deleteFleet: (id: number) => apiFetch<any>(`/fleets/${id}`, { method: 'DELETE' }),
addFleetVehicle: (fleetId: number, vehicleId: number) => apiFetch<any>(`/fleets/${fleetId}/vehicles`, { method: 'POST', body: JSON.stringify({ vehicleId }) }),
removeFleetVehicle: (fleetId: number, vehicleId: number) => apiFetch<any>(`/fleets/${fleetId}/vehicles/${vehicleId}`, { method: 'DELETE' }),
// Inspections
getInspectionTemplates: () => apiFetch<any[]>('/inspections/templates'),
createInspectionTemplate: (data: any) => apiFetch<any>('/inspections/templates', { method: 'POST', body: JSON.stringify(data) }),
deleteInspectionTemplate: (id: number) => apiFetch<any>(`/inspections/templates/${id}`, { method: 'DELETE' }),
getJobInspections: (jobId: number) => apiFetch<any[]>(`/inspections/job/${jobId}`),
getInspection: (id: number) => apiFetch<any>(`/inspections/${id}`),
createInspection: (data: { jobId?: number; vehicleId?: number; templateId?: number; technicianId?: number }) =>
apiFetch<any>('/inspections', { method: 'POST', body: JSON.stringify(data) }),
getVehicleInspections: (vehicleId: number) => apiFetch<any[]>(`/inspections/vehicle/${vehicleId}`),
getCannedInspectionNotes: () => apiFetch<any[]>('/inspections/canned-notes'),
updateInspectionItem: (inspectionId: number, itemId: number, data: { condition?: string; notes?: string }) =>
apiFetch<any>(`/inspections/${inspectionId}/items/${itemId}`, { method: 'PUT', body: JSON.stringify(data) }),
addInspectionItem: (inspectionId: number, data: { section?: string; label: string }) =>
apiFetch<any>(`/inspections/${inspectionId}/items`, { method: 'POST', body: JSON.stringify(data) }),
completeInspection: (id: number) => apiFetch<any>(`/inspections/${id}/complete`, { method: 'POST' }),
deleteInspection: (id: number) => apiFetch<any>(`/inspections/${id}`, { method: 'DELETE' }),
// Files
getFiles: (entityType: string, entityId: number) => apiFetch<any[]>(`/files/${entityType}/${entityId}`),
uploadFiles: async (entityType: string, entityId: number, files: FileList | File[]) => {
const token = localStorage.getItem('autoking_token');
const formData = new FormData();
Array.from(files).forEach((f) => formData.append('files', f));
const res = await fetch(`${API_BASE}/files/${entityType}/${entityId}`, {
method: 'POST',
headers: { ...(token && { Authorization: `Bearer ${token}` }) },
body: formData,
});
if (!res.ok) throw new Error((await res.json()).error || 'Upload failed');
return res.json();
},
getFileUrl: (id: number) => {
const token = localStorage.getItem('autoking_token');
return `${API_BASE}/files/download/${id}?token=${token}`;
},
deleteFile: (id: number) => apiFetch<any>(`/files/${id}`, { method: 'DELETE' }),
};

View File

@@ -0,0 +1,90 @@
import { useRef } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { Upload, Trash2, FileIcon, Image, File } from 'lucide-react';
import { api } from '../api/client';
import type { FileRecord } from '../types';
interface FileUploadProps {
entityType: string;
entityId: number;
}
function formatSize(bytes: number | null): string {
if (!bytes) return '';
if (bytes < 1024) return `${bytes}B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)}MB`;
}
function isImage(mimeType: string | null): boolean {
return !!mimeType && mimeType.startsWith('image/');
}
export default function FileUpload({ entityType, entityId }: FileUploadProps) {
const inputRef = useRef<HTMLInputElement>(null);
const queryClient = useQueryClient();
const queryKey = ['files', entityType, entityId];
const { data: files = [] } = useQuery<FileRecord[]>({
queryKey,
queryFn: () => api.getFiles(entityType, entityId),
});
const uploadMutation = useMutation({
mutationFn: (fileList: FileList) => api.uploadFiles(entityType, entityId, fileList),
onSuccess: () => queryClient.invalidateQueries({ queryKey }),
});
const deleteMutation = useMutation({
mutationFn: (id: number) => api.deleteFile(id),
onSuccess: () => queryClient.invalidateQueries({ queryKey }),
});
return (
<div className="space-y-3">
<div className="flex items-center justify-between">
<h3 className="text-sm font-medium text-gray-700">Files</h3>
<button onClick={() => inputRef.current?.click()}
className="inline-flex items-center gap-1.5 px-2.5 py-1.5 text-xs font-medium text-gray-600 bg-white border border-gray-300 rounded-lg hover:bg-gray-50">
<Upload size={14} /> Upload
</button>
<input ref={inputRef} type="file" multiple className="hidden"
onChange={(e) => { if (e.target.files?.length) { uploadMutation.mutate(e.target.files); e.target.value = ''; } }} />
</div>
{uploadMutation.isPending && <p className="text-xs text-blue-600">Uploading...</p>}
{files.length > 0 && (
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-2">
{files.map((file) => (
<div key={file.id} className="group relative border border-gray-200 rounded-lg overflow-hidden">
{isImage(file.mime_type) ? (
<a href={api.getFileUrl(file.id)} target="_blank" rel="noopener noreferrer">
<img src={api.getFileUrl(file.id)} alt={file.original_name}
className="w-full h-24 object-cover" />
</a>
) : (
<a href={api.getFileUrl(file.id)} target="_blank" rel="noopener noreferrer"
className="flex flex-col items-center justify-center h-24 bg-gray-50 hover:bg-gray-100">
<File size={24} className="text-gray-400" />
</a>
)}
<div className="px-2 py-1.5">
<p className="text-xs text-gray-700 truncate" title={file.original_name}>{file.original_name}</p>
<p className="text-xs text-gray-400">{formatSize(file.size)}</p>
</div>
<button onClick={() => deleteMutation.mutate(file.id)}
className="absolute top-1 right-1 p-1 bg-white/80 rounded text-gray-400 hover:text-red-500 opacity-0 group-hover:opacity-100 transition-opacity">
<Trash2 size={12} />
</button>
</div>
))}
</div>
)}
{files.length === 0 && !uploadMutation.isPending && (
<p className="text-xs text-gray-400">No files attached.</p>
)}
</div>
);
}

View File

@@ -0,0 +1,227 @@
import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { ClipboardCheck, Plus, CheckCircle, Camera, ChevronDown, ChevronRight } from 'lucide-react';
import { api } from '../api/client';
import { Card, Button, Badge, Modal } from './ui';
import FileUpload from './FileUpload';
import type { Inspection, InspectionItem, InspectionTemplate, InspectionCondition } from '../types';
const conditionColors: Record<string, string> = {
good: 'bg-green-500',
fair: 'bg-yellow-500',
poor: 'bg-orange-500',
critical: 'bg-red-500',
not_inspected: 'bg-gray-300',
};
const conditionLabels: Record<string, string> = {
good: 'Good', fair: 'Fair', poor: 'Poor', critical: 'Critical', not_inspected: 'N/A',
};
interface InspectionPanelProps {
jobId: number;
}
export default function InspectionPanel({ jobId }: InspectionPanelProps) {
const queryClient = useQueryClient();
const [showStartModal, setShowStartModal] = useState(false);
const [activeInspection, setActiveInspection] = useState<number | null>(null);
const { data: inspections = [] } = useQuery<Inspection[]>({
queryKey: ['inspections', jobId],
queryFn: () => api.getJobInspections(jobId),
});
const invalidate = () => queryClient.invalidateQueries({ queryKey: ['inspections', jobId] });
return (
<Card>
<div className="p-4 border-b border-gray-200 flex items-center justify-between">
<h2 className="font-semibold text-gray-900 flex items-center gap-2">
<ClipboardCheck size={18} /> Inspections
</h2>
<Button variant="ghost" size="sm" onClick={() => setShowStartModal(true)}>
<Plus size={14} /> New Inspection
</Button>
</div>
{inspections.length === 0 ? (
<p className="p-4 text-sm text-gray-500">No inspections yet.</p>
) : (
<div className="divide-y divide-gray-100">
{inspections.map((insp) => (
<div key={insp.id}>
<button
onClick={() => setActiveInspection(activeInspection === insp.id ? null : insp.id)}
className="w-full flex items-center justify-between p-3 hover:bg-gray-50 text-left"
>
<div>
<p className="text-sm font-medium text-gray-900">
{insp.template_name || 'Custom Inspection'}
</p>
<p className="text-xs text-gray-500">
{new Date(insp.created_at).toLocaleDateString()}
{insp.technician_name && `${insp.technician_name}`}
</p>
</div>
<div className="flex items-center gap-2">
<Badge color={insp.status === 'completed' ? 'green' : 'yellow'}>
{insp.status === 'completed' ? 'Complete' : 'In Progress'}
</Badge>
{activeInspection === insp.id ? <ChevronDown size={16} /> : <ChevronRight size={16} />}
</div>
</button>
{activeInspection === insp.id && (
<InspectionDetail inspectionId={insp.id} onUpdate={invalidate} />
)}
</div>
))}
</div>
)}
<StartInspectionModal open={showStartModal} onClose={() => setShowStartModal(false)} jobId={jobId} onDone={invalidate} />
</Card>
);
}
function InspectionDetail({ inspectionId, onUpdate }: { inspectionId: number; onUpdate: () => void }) {
const queryClient = useQueryClient();
const [expandedItem, setExpandedItem] = useState<number | null>(null);
const { data: inspection } = useQuery<Inspection>({
queryKey: ['inspection', inspectionId],
queryFn: () => api.getInspection(inspectionId),
});
const updateItemMutation = useMutation({
mutationFn: ({ itemId, data }: { itemId: number; data: any }) =>
api.updateInspectionItem(inspectionId, itemId, data),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['inspection', inspectionId] }),
});
const completeMutation = useMutation({
mutationFn: () => api.completeInspection(inspectionId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['inspection', inspectionId] });
onUpdate();
},
});
if (!inspection) return <p className="p-3 text-xs text-gray-400">Loading...</p>;
const items = inspection.items || [];
const sections = [...new Set(items.map(i => i.section))];
return (
<div className="px-3 pb-3 space-y-3">
{/* Summary bar */}
{inspection.summary && (
<div className="flex gap-3 text-xs">
{Object.entries(inspection.summary).map(([cond, count]) => (
<span key={cond} className="flex items-center gap-1">
<span className={`w-2.5 h-2.5 rounded-full ${conditionColors[cond]}`} />
{conditionLabels[cond]}: {count}
</span>
))}
</div>
)}
{sections.map(section => (
<div key={section}>
<h4 className="text-xs font-medium text-gray-500 uppercase mb-1">{section}</h4>
<div className="space-y-1">
{items.filter(i => i.section === section).map(item => (
<div key={item.id} className="bg-gray-50 rounded-lg">
<div className="flex items-center justify-between px-3 py-2">
<button
onClick={() => setExpandedItem(expandedItem === item.id ? null : item.id)}
className="flex-1 text-left text-sm text-gray-700"
>
{item.label}
</button>
<div className="flex gap-1">
{(['good', 'fair', 'poor', 'critical'] as InspectionCondition[]).map(cond => (
<button
key={cond}
onClick={() => updateItemMutation.mutate({ itemId: item.id, data: { condition: cond } })}
className={`w-6 h-6 rounded-full border-2 transition-all ${
item.condition === cond
? `${conditionColors[cond]} border-gray-700 ring-2 ring-offset-1 ring-gray-400`
: `${conditionColors[cond]} border-transparent opacity-40 hover:opacity-70`
}`}
title={conditionLabels[cond]}
/>
))}
</div>
</div>
{expandedItem === item.id && (
<div className="px-3 pb-2 space-y-2">
<textarea
className="w-full text-xs border border-gray-200 rounded px-2 py-1 resize-none"
placeholder="Notes..."
rows={2}
defaultValue={item.notes || ''}
onBlur={(e) => {
if (e.target.value !== (item.notes || '')) {
updateItemMutation.mutate({ itemId: item.id, data: { notes: e.target.value } });
}
}}
/>
<FileUpload entityType="inspection_item" entityId={item.id} />
</div>
)}
</div>
))}
</div>
</div>
))}
{inspection.status !== 'completed' && (
<Button size="sm" onClick={() => completeMutation.mutate()} disabled={completeMutation.isPending}>
<CheckCircle size={14} /> {completeMutation.isPending ? 'Completing...' : 'Complete Inspection'}
</Button>
)}
</div>
);
}
function StartInspectionModal({ open, onClose, jobId, onDone }: {
open: boolean; onClose: () => void; jobId: number; onDone: () => void;
}) {
const [templateId, setTemplateId] = useState('');
const { data: templates = [] } = useQuery<InspectionTemplate[]>({
queryKey: ['inspection-templates'],
queryFn: () => api.getInspectionTemplates(),
enabled: open,
});
const createMutation = useMutation({
mutationFn: () => api.createInspection({ jobId, templateId: templateId ? Number(templateId) : undefined }),
onSuccess: () => { onDone(); onClose(); },
});
return (
<Modal open={open} onClose={onClose} title="Start Inspection">
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Template</label>
<select className="block w-full rounded-lg border border-gray-300 px-3 py-2 text-sm"
value={templateId} onChange={(e) => setTemplateId(e.target.value)}>
<option value="">No template (blank)</option>
{templates.map(t => (
<option key={t.id} value={t.id}>{t.name} ({t.items?.length || 0} items)</option>
))}
</select>
</div>
<div className="flex justify-end gap-2">
<Button variant="secondary" onClick={onClose}>Cancel</Button>
<Button onClick={() => createMutation.mutate()} disabled={createMutation.isPending}>
{createMutation.isPending ? 'Starting...' : 'Start Inspection'}
</Button>
</div>
</div>
</Modal>
);
}

View File

@@ -0,0 +1,95 @@
import { Link } from 'react-router-dom';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { api } from '../api/client';
import { Badge } from './ui';
import type { Job, Tag } from '../types';
const columns: { status: string; label: string; color: string }[] = [
{ status: 'received', label: 'Received', color: 'border-t-blue-400' },
{ status: 'in_progress', label: 'In Progress', color: 'border-t-yellow-400' },
{ status: 'completed', label: 'Completed', color: 'border-t-green-400' },
{ status: 'invoiced', label: 'Invoiced', color: 'border-t-purple-400' },
];
const tagColorClasses: Record<string, string> = {
gray: 'bg-gray-100 text-gray-600',
red: 'bg-red-100 text-red-600',
yellow: 'bg-yellow-100 text-yellow-600',
green: 'bg-green-100 text-green-600',
blue: 'bg-blue-100 text-blue-600',
purple: 'bg-purple-100 text-purple-600',
};
interface KanbanBoardProps {
jobs: Job[];
}
export default function KanbanBoard({ jobs }: KanbanBoardProps) {
const queryClient = useQueryClient();
const statusMutation = useMutation({
mutationFn: ({ id, status }: { id: number; status: string }) => api.updateJob(id, { status }),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['jobs'] }),
});
const handleDrop = (e: React.DragEvent, status: string) => {
e.preventDefault();
const jobId = parseInt(e.dataTransfer.getData('jobId'));
if (jobId) statusMutation.mutate({ id: jobId, status });
};
return (
<div className="flex gap-3 overflow-x-auto pb-4" style={{ minHeight: '60vh' }}>
{columns.map((col) => {
const colJobs = jobs.filter((j) => j.status === col.status);
return (
<div key={col.status}
className={`flex-shrink-0 w-72 bg-gray-50 rounded-lg border-t-4 ${col.color}`}
onDragOver={(e) => e.preventDefault()}
onDrop={(e) => handleDrop(e, col.status)}
>
<div className="px-3 py-2.5 flex items-center justify-between">
<h3 className="text-sm font-semibold text-gray-700">{col.label}</h3>
<span className="text-xs bg-gray-200 text-gray-600 px-1.5 py-0.5 rounded-full">{colJobs.length}</span>
</div>
<div className="px-2 pb-2 space-y-2">
{colJobs.map((job) => (
<div key={job.id} draggable
onDragStart={(e) => e.dataTransfer.setData('jobId', String(job.id))}
className="bg-white rounded-lg border border-gray-200 p-3 shadow-sm hover:shadow-md transition-shadow cursor-grab active:cursor-grabbing"
>
<Link to={`/jobs/${job.id}`} className="block">
<div className="flex items-center justify-between mb-1">
<span className="font-mono text-xs font-semibold text-gray-900">{job.job_number}</span>
</div>
<p className="text-sm text-gray-700 leading-tight">
{[job.year, job.make, job.model].filter(Boolean).join(' ')}
</p>
<p className="text-xs text-gray-500 mt-0.5">
{job.first_name && job.last_name ? `${job.first_name} ${job.last_name}` : ''}
{job.shop_name ? (job.first_name ? `${job.shop_name}` : job.shop_name) : ''}
</p>
{job.description && <p className="text-xs text-gray-400 mt-1 line-clamp-2">{job.description}</p>}
</Link>
{job.tags && job.tags.length > 0 && (
<div className="flex flex-wrap gap-1 mt-2">
{job.tags.map((tag: Tag) => (
<span key={tag.id} className={`px-1.5 py-0.5 rounded text-[10px] font-medium ${tagColorClasses[tag.color] || tagColorClasses.gray}`}>
{tag.name}
</span>
))}
</div>
)}
</div>
))}
{colJobs.length === 0 && (
<div className="text-center py-8 text-xs text-gray-400">
Drop jobs here
</div>
)}
</div>
</div>
);
})}
</div>
);
}

View File

@@ -0,0 +1,125 @@
import { Link, useLocation, Outlet } from 'react-router-dom';
import { LayoutDashboard, Wrench, Building2, FileText, Settings, Menu, X, Users, Car, LogOut, ClipboardList, HardHat, Calendar, Package, BarChart3 } from 'lucide-react';
import { useStore } from '../store';
const navItems = [
{ to: '/', icon: LayoutDashboard, label: 'Dashboard' },
{ to: '/jobs', icon: Wrench, label: 'Jobs' },
{ to: '/estimates', icon: ClipboardList, label: 'Estimates' },
{ to: '/shops', icon: Building2, label: 'Shops' },
{ to: '/owners', icon: Users, label: 'Owners' },
{ to: '/vehicles', icon: Car, label: 'Vehicles' },
{ to: '/invoices', icon: FileText, label: 'Invoices' },
{ to: '/technicians', icon: HardHat, label: 'Technicians' },
{ to: '/appointments', icon: Calendar, label: 'Appointments' },
{ to: '/inventory', icon: Package, label: 'Inventory' },
{ to: '/reports', icon: BarChart3, label: 'Reports' },
{ to: '/settings', icon: Settings, label: 'Settings' },
];
export default function Layout() {
const location = useLocation();
const { sidebarOpen, toggleSidebar, logout, user } = useStore();
return (
<div className="min-h-screen bg-gray-50 flex">
{/* Desktop Sidebar */}
<aside className="hidden md:flex flex-col w-60 bg-white border-r border-gray-200 fixed h-full">
<div className="p-4 border-b border-gray-200">
<h1 className="text-xl font-bold text-gray-900">Shop Manager</h1>
{user && <p className="text-sm text-gray-500 mt-0.5">{user.displayName || user.username}</p>}
</div>
<nav className="flex-1 p-3 space-y-1">
{navItems.map(({ to, icon: Icon, label }) => {
const active = to === '/' ? location.pathname === '/' : location.pathname.startsWith(to);
return (
<Link
key={to}
to={to}
className={`flex items-center gap-3 px-3 py-2 rounded-lg text-sm font-medium transition-colors ${
active
? 'bg-blue-50 text-blue-700'
: 'text-gray-600 hover:bg-gray-100 hover:text-gray-900'
}`}
>
<Icon size={18} />
{label}
</Link>
);
})}
</nav>
<button
onClick={() => { logout(); window.location.href = '/login'; }}
className="flex items-center gap-3 px-6 py-3 text-sm text-gray-500 hover:text-gray-700 border-t border-gray-200"
>
<LogOut size={18} />
Sign out
</button>
</aside>
{/* Mobile Header */}
<div className="md:hidden fixed top-0 left-0 right-0 bg-white border-b border-gray-200 z-30 flex items-center justify-between px-4 h-14">
<h1 className="text-lg font-bold text-gray-900">Shop Manager</h1>
<button onClick={toggleSidebar} className="p-2 text-gray-600">
{sidebarOpen ? <X size={22} /> : <Menu size={22} />}
</button>
</div>
{/* Mobile Sidebar Overlay */}
{sidebarOpen && (
<div className="md:hidden fixed inset-0 z-40">
<div className="absolute inset-0 bg-black/30" onClick={toggleSidebar} />
<aside className="absolute left-0 top-0 bottom-0 w-64 bg-white shadow-xl">
<div className="p-4 border-b border-gray-200">
<h1 className="text-xl font-bold text-gray-900">Shop Manager</h1>
</div>
<nav className="p-3 space-y-1">
{navItems.map(({ to, icon: Icon, label }) => {
const active = to === '/' ? location.pathname === '/' : location.pathname.startsWith(to);
return (
<Link
key={to}
to={to}
onClick={toggleSidebar}
className={`flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium ${
active ? 'bg-blue-50 text-blue-700' : 'text-gray-600 hover:bg-gray-100'
}`}
>
<Icon size={18} />
{label}
</Link>
);
})}
</nav>
</aside>
</div>
)}
{/* Main Content */}
<main className="flex-1 md:ml-60 pt-14 md:pt-0">
<div className="p-4 md:p-6 max-w-6xl">
<Outlet />
</div>
</main>
{/* Mobile Bottom Nav */}
<nav className="md:hidden fixed bottom-0 left-0 right-0 bg-white border-t border-gray-200 z-30 flex">
{navItems.slice(0, 5).map(({ to, icon: Icon, label }) => {
const active = to === '/' ? location.pathname === '/' : location.pathname.startsWith(to);
return (
<Link
key={to}
to={to}
className={`flex-1 flex flex-col items-center py-2 text-xs ${
active ? 'text-blue-600' : 'text-gray-500'
}`}
>
<Icon size={20} />
<span className="mt-0.5">{label}</span>
</Link>
);
})}
</nav>
</div>
);
}

View File

@@ -0,0 +1,104 @@
import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { X, Plus, Tag as TagIcon } from 'lucide-react';
import { api } from '../api/client';
import type { Tag } from '../types';
const TAG_COLORS = ['gray', 'red', 'yellow', 'green', 'blue', 'purple'];
interface TagPickerProps {
jobId: number;
tags: Tag[];
}
export default function TagPicker({ jobId, tags }: TagPickerProps) {
const [showPicker, setShowPicker] = useState(false);
const [newTagName, setNewTagName] = useState('');
const [newTagColor, setNewTagColor] = useState('gray');
const queryClient = useQueryClient();
const { data: allTags = [] } = useQuery<Tag[]>({ queryKey: ['tags'], queryFn: api.getTags });
const invalidate = () => {
queryClient.invalidateQueries({ queryKey: ['jobs', String(jobId)] });
queryClient.invalidateQueries({ queryKey: ['jobs'] });
};
const addMutation = useMutation({ mutationFn: (tagId: number) => api.addTagToJob(jobId, tagId), onSuccess: invalidate });
const removeMutation = useMutation({ mutationFn: (tagId: number) => api.removeTagFromJob(jobId, tagId), onSuccess: invalidate });
const createMutation = useMutation({
mutationFn: api.createTag,
onSuccess: (tag: Tag) => {
queryClient.invalidateQueries({ queryKey: ['tags'] });
addMutation.mutate(tag.id);
setNewTagName('');
setNewTagColor('gray');
},
});
const availableTags = allTags.filter((t) => !tags.some((jt) => jt.id === t.id));
const colorClasses: Record<string, string> = {
gray: 'bg-gray-100 text-gray-700 border-gray-200',
red: 'bg-red-100 text-red-700 border-red-200',
yellow: 'bg-yellow-100 text-yellow-700 border-yellow-200',
green: 'bg-green-100 text-green-700 border-green-200',
blue: 'bg-blue-100 text-blue-700 border-blue-200',
purple: 'bg-purple-100 text-purple-700 border-purple-200',
};
const dotClasses: Record<string, string> = {
gray: 'bg-gray-400', red: 'bg-red-500', yellow: 'bg-yellow-500', green: 'bg-green-500', blue: 'bg-blue-500', purple: 'bg-purple-500',
};
return (
<div>
<div className="flex flex-wrap gap-1.5 items-center">
{tags.map((tag) => (
<span key={tag.id} className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium border ${colorClasses[tag.color] || colorClasses.gray}`}>
{tag.name}
<button onClick={() => removeMutation.mutate(tag.id)} className="hover:opacity-70">
<X size={12} />
</button>
</span>
))}
<button onClick={() => setShowPicker(!showPicker)} className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium border border-dashed border-gray-300 text-gray-500 hover:border-blue-400 hover:text-blue-600">
<Plus size={12} /> Tag
</button>
</div>
{showPicker && (
<div className="mt-2 border border-gray-200 rounded-lg p-3 bg-white shadow-sm space-y-2">
{availableTags.length > 0 && (
<div className="flex flex-wrap gap-1.5">
{availableTags.map((tag) => (
<button key={tag.id} onClick={() => { addMutation.mutate(tag.id); setShowPicker(false); }}
className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium border hover:opacity-80 ${colorClasses[tag.color] || colorClasses.gray}`}>
<Plus size={10} /> {tag.name}
</button>
))}
</div>
)}
<div className="flex gap-2 items-end">
<div className="flex-1">
<input className="block w-full border border-gray-300 rounded px-2 py-1 text-xs focus:border-blue-500 outline-none"
placeholder="New tag name..." value={newTagName} onChange={(e) => setNewTagName(e.target.value)}
onKeyDown={(e) => { if (e.key === 'Enter' && newTagName.trim()) { e.preventDefault(); createMutation.mutate({ name: newTagName.trim(), color: newTagColor }); } }}
/>
</div>
<div className="flex gap-1">
{TAG_COLORS.map((c) => (
<button key={c} onClick={() => setNewTagColor(c)}
className={`w-5 h-5 rounded-full ${dotClasses[c]} ${newTagColor === c ? 'ring-2 ring-offset-1 ring-blue-400' : ''}`} />
))}
</div>
<button onClick={() => { if (newTagName.trim()) createMutation.mutate({ name: newTagName.trim(), color: newTagColor }); }}
disabled={!newTagName.trim()} className="px-2 py-1 text-xs font-medium bg-blue-600 text-white rounded hover:bg-blue-700 disabled:opacity-50">
Add
</button>
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,448 @@
import { useState, useRef } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { ClipboardCheck, Plus, CheckCircle, ChevronLeft, ChevronRight, Camera, MessageSquare, X, AlertTriangle, Check, Trash2 } from 'lucide-react';
import { api } from '../api/client';
import { Card, Button, Badge, Modal } from './ui';
import type { InspectionTemplate } from '../types';
const conditionConfig = [
{ value: 'good', label: 'Good', bg: 'bg-green-500 text-white', outline: 'border-green-400 text-green-700 hover:bg-green-50', icon: '✓' },
{ value: 'fair', label: 'Attention', bg: 'bg-yellow-500 text-white', outline: 'border-yellow-400 text-yellow-700 hover:bg-yellow-50', icon: '~' },
{ value: 'poor', label: 'Needs Work', bg: 'bg-red-500 text-white', outline: 'border-red-400 text-red-700 hover:bg-red-50', icon: '!' },
];
const conditionBorder: Record<string, string> = {
good: 'border-green-300 bg-green-50/50', fair: 'border-yellow-300 bg-yellow-50/50',
poor: 'border-red-300 bg-red-50/50', critical: 'border-red-500 bg-red-100',
not_inspected: 'border-gray-200 bg-white',
};
const conditionDot: Record<string, string> = {
good: 'bg-green-500', fair: 'bg-yellow-500', poor: 'bg-red-500', critical: 'bg-red-700', not_inspected: 'bg-gray-300',
};
interface Props {
vehicleId: number;
}
export default function VehicleInspectionPanel({ vehicleId }: Props) {
const queryClient = useQueryClient();
const [showStartModal, setShowStartModal] = useState(false);
const [activeInspectionId, setActiveInspectionId] = useState<number | null>(null);
const { data: inspections = [] } = useQuery<any[]>({
queryKey: ['vehicle-inspections', vehicleId],
queryFn: () => api.getVehicleInspections(vehicleId),
});
const invalidate = () => queryClient.invalidateQueries({ queryKey: ['vehicle-inspections', vehicleId] });
return (
<>
<Card>
<div className="p-4 border-b border-gray-200 flex items-center justify-between">
<h2 className="font-semibold text-gray-900 flex items-center gap-2">
<ClipboardCheck size={18} /> Inspections
</h2>
<Button variant="ghost" size="sm" onClick={() => setShowStartModal(true)}>
<Plus size={14} /> New Inspection
</Button>
</div>
{inspections.length === 0 ? (
<div className="p-6 text-center">
<ClipboardCheck size={32} className="mx-auto text-gray-300 mb-2" />
<p className="text-sm text-gray-500">No inspections yet.</p>
<Button size="sm" className="mt-3" onClick={() => setShowStartModal(true)}>Start Inspection</Button>
</div>
) : (
<div className="divide-y divide-gray-100">
{inspections.map((insp: any) => (
<button key={insp.id} onClick={() => setActiveInspectionId(insp.id)}
className="w-full flex items-center justify-between p-3 hover:bg-gray-50 text-left">
<div>
<p className="text-sm font-medium text-gray-900">{insp.template_name || 'Inspection'}</p>
<p className="text-xs text-gray-500">
{new Date(insp.created_at).toLocaleDateString()}
{insp.technician_name && `${insp.technician_name}`}
{insp.job_number && `${insp.job_number}`}
</p>
</div>
<Badge color={insp.status === 'completed' ? 'green' : 'yellow'}>
{insp.status === 'completed' ? 'Complete' : 'In Progress'}
</Badge>
</button>
))}
</div>
)}
</Card>
<StartModal open={showStartModal} onClose={() => setShowStartModal(false)} vehicleId={vehicleId}
onDone={(id) => { invalidate(); setActiveInspectionId(id); }} />
{activeInspectionId && (
<MobileInspection inspectionId={activeInspectionId}
onClose={() => { setActiveInspectionId(null); invalidate(); }} />
)}
</>
);
}
function StartModal({ open, onClose, vehicleId, onDone }: {
open: boolean; onClose: () => void; vehicleId: number; onDone: (id: number) => void;
}) {
const [templateId, setTemplateId] = useState('');
const { data: templates = [] } = useQuery<InspectionTemplate[]>({
queryKey: ['inspection-templates'],
queryFn: () => api.getInspectionTemplates(),
enabled: open,
});
const createMutation = useMutation({
mutationFn: () => api.createInspection({ vehicleId, templateId: templateId ? Number(templateId) : undefined }),
onSuccess: (data: any) => { onDone(data.id); onClose(); },
});
return (
<Modal open={open} onClose={onClose} title="Start Inspection">
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-2">Choose Template</label>
<div className="space-y-2">
{templates.map(t => (
<button key={t.id} onClick={() => setTemplateId(String(t.id))}
className={`w-full text-left p-3 rounded-lg border-2 transition-colors ${
templateId === String(t.id) ? 'border-blue-500 bg-blue-50' : 'border-gray-200 hover:border-gray-300'
}`}>
<p className="text-sm font-medium text-gray-900">{t.name}</p>
{(t as any).description && <p className="text-xs text-gray-500 mt-0.5">{(t as any).description}</p>}
</button>
))}
<button onClick={() => setTemplateId('')}
className={`w-full text-left p-3 rounded-lg border-2 transition-colors ${
templateId === '' ? 'border-blue-500 bg-blue-50' : 'border-gray-200 hover:border-gray-300'
}`}>
<p className="text-sm font-medium text-gray-900">Blank Inspection</p>
<p className="text-xs text-gray-500 mt-0.5">Start from scratch</p>
</button>
</div>
</div>
<div className="flex justify-end gap-2">
<Button variant="secondary" onClick={onClose}>Cancel</Button>
<Button onClick={() => createMutation.mutate()} disabled={createMutation.isPending}>
{createMutation.isPending ? 'Starting...' : 'Start Inspection'}
</Button>
</div>
</div>
</Modal>
);
}
function MobileInspection({ inspectionId, onClose }: { inspectionId: number; onClose: () => void }) {
const queryClient = useQueryClient();
const [currentSection, setCurrentSection] = useState(0);
const [expandedItem, setExpandedItem] = useState<number | null>(null);
const [showSummary, setShowSummary] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const [photoItemId, setPhotoItemId] = useState<number | null>(null);
const { data: inspection } = useQuery<any>({
queryKey: ['inspection', inspectionId],
queryFn: () => api.getInspection(inspectionId),
});
const { data: cannedNotes = [] } = useQuery<any[]>({
queryKey: ['canned-inspection-notes'],
queryFn: () => api.getCannedInspectionNotes(),
});
const updateItemMutation = useMutation({
mutationFn: ({ itemId, data }: { itemId: number; data: any }) =>
api.updateInspectionItem(inspectionId, itemId, data),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['inspection', inspectionId] }),
});
const completeMutation = useMutation({
mutationFn: () => api.completeInspection(inspectionId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['inspection', inspectionId] });
onClose();
},
});
const uploadMutation = useMutation({
mutationFn: ({ itemId, files }: { itemId: number; files: FileList }) =>
api.uploadFiles('inspection_item', itemId, files),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['inspection', inspectionId] }),
});
if (!inspection) return null;
const items: any[] = inspection.items || [];
const sections = [...new Set(items.map((i: any) => i.section))];
const sectionItems = showSummary ? items : items.filter((i: any) => i.section === sections[currentSection]);
const totalItems = items.length;
const inspectedItems = items.filter((i: any) => i.condition !== 'not_inspected').length;
const progress = totalItems > 0 ? Math.round((inspectedItems / totalItems) * 100) : 0;
const redItems = items.filter((i: any) => i.condition === 'poor' || i.condition === 'critical');
const yellowItems = items.filter((i: any) => i.condition === 'fair');
const greenItems = items.filter((i: any) => i.condition === 'good');
const unratedItems = items.filter((i: any) => i.condition === 'not_inspected');
return (
<div className="fixed inset-0 z-50 bg-white flex flex-col">
{/* Header */}
<div className="bg-gray-900 text-white px-4 py-3 flex items-center justify-between safe-area-top">
<button onClick={onClose} className="p-1"><X size={24} /></button>
<div className="text-center">
<p className="text-sm font-semibold">{inspection.template_name || 'Inspection'}</p>
<p className="text-xs text-gray-400">{inspectedItems}/{totalItems} {progress}%</p>
</div>
<button onClick={() => setShowSummary(!showSummary)}
className={`text-xs px-2 py-1 rounded ${showSummary ? 'bg-white text-gray-900' : 'bg-gray-700 text-white'}`}>
{showSummary ? 'Items' : 'Summary'}
</button>
</div>
{/* Progress bar */}
<div className="h-1.5 bg-gray-200">
<div className="h-full bg-green-500 transition-all duration-300" style={{ width: `${progress}%` }} />
</div>
{showSummary ? (
/* Summary View */
<div className="flex-1 overflow-y-auto p-4 space-y-4">
{redItems.length > 0 && (
<div>
<h3 className="text-sm font-semibold text-red-700 flex items-center gap-1.5 mb-2">
<span className="w-3 h-3 rounded-full bg-red-500" /> Needs Work ({redItems.length})
</h3>
{redItems.map((item: any) => (
<div key={item.id} className="bg-red-50 rounded-lg p-3 mb-2 border border-red-200">
<p className="text-sm font-medium text-gray-900">{item.label}</p>
{item.notes && <p className="text-xs text-gray-600 mt-0.5">{item.notes}</p>}
{item.measurement && <p className="text-xs text-gray-500 mt-0.5">Measured: {item.measurement}{item.measurement_unit || ''}</p>}
</div>
))}
</div>
)}
{yellowItems.length > 0 && (
<div>
<h3 className="text-sm font-semibold text-yellow-700 flex items-center gap-1.5 mb-2">
<span className="w-3 h-3 rounded-full bg-yellow-500" /> Attention ({yellowItems.length})
</h3>
{yellowItems.map((item: any) => (
<div key={item.id} className="bg-yellow-50 rounded-lg p-3 mb-2 border border-yellow-200">
<p className="text-sm font-medium text-gray-900">{item.label}</p>
{item.notes && <p className="text-xs text-gray-600 mt-0.5">{item.notes}</p>}
</div>
))}
</div>
)}
{greenItems.length > 0 && (
<div>
<h3 className="text-sm font-semibold text-green-700 flex items-center gap-1.5 mb-2">
<span className="w-3 h-3 rounded-full bg-green-500" /> Good ({greenItems.length})
</h3>
<div className="bg-green-50 rounded-lg p-3 border border-green-200">
<p className="text-xs text-gray-600">{greenItems.map((i: any) => i.label).join(', ')}</p>
</div>
</div>
)}
{unratedItems.length > 0 && (
<div>
<h3 className="text-sm font-semibold text-gray-500 flex items-center gap-1.5 mb-2">
<span className="w-3 h-3 rounded-full bg-gray-300" /> Not Inspected ({unratedItems.length})
</h3>
<div className="bg-gray-50 rounded-lg p-3 border border-gray-200">
<p className="text-xs text-gray-500">{unratedItems.map((i: any) => i.label).join(', ')}</p>
</div>
</div>
)}
</div>
) : (
<>
{/* Section tabs */}
<div className="flex border-b border-gray-200 overflow-x-auto">
{sections.map((section, idx) => {
const sItems = items.filter((i: any) => i.section === section);
const done = sItems.filter((i: any) => i.condition !== 'not_inspected').length;
const allDone = done === sItems.length;
return (
<button key={section} onClick={() => setCurrentSection(idx)}
className={`flex-shrink-0 px-4 py-2.5 text-sm font-medium border-b-2 transition-colors ${
currentSection === idx ? 'border-blue-500 text-blue-600' : 'border-transparent text-gray-500'
}`}>
{allDone && <Check size={12} className="inline mr-1 text-green-500" />}
{section} <span className="text-xs text-gray-400 ml-1">{done}/{sItems.length}</span>
</button>
);
})}
</div>
{/* Items */}
<div className="flex-1 overflow-y-auto">
<div className="p-3 space-y-3">
{sectionItems.map((item: any) => (
<div key={item.id} className={`rounded-xl border-2 p-4 transition-all ${conditionBorder[item.condition] || conditionBorder.not_inspected}`}>
<p className="text-sm font-semibold text-gray-900 mb-3">{item.label}</p>
{/* 3 big condition buttons */}
<div className="grid grid-cols-3 gap-2">
{conditionConfig.map(c => (
<button key={c.value}
onClick={() => updateItemMutation.mutate({ itemId: item.id, data: { condition: c.value } })}
className={`py-3.5 rounded-xl text-sm font-bold border-2 transition-all ${
item.condition === c.value ? c.bg : `bg-white ${c.outline}`
}`}>
{c.label}
</button>
))}
</div>
{/* Critical safety flag — shows when red */}
{(item.condition === 'poor' || item.condition === 'critical') && (
<button onClick={() => updateItemMutation.mutate({ itemId: item.id, data: { condition: item.condition === 'critical' ? 'poor' : 'critical' } })}
className={`mt-2 w-full py-2 rounded-lg text-xs font-bold border-2 transition-all flex items-center justify-center gap-1.5 ${
item.condition === 'critical'
? 'bg-red-700 border-red-700 text-white'
: 'border-red-300 text-red-600 hover:bg-red-50'
}`}>
<AlertTriangle size={14} />
{item.condition === 'critical' ? 'CRITICAL SAFETY ISSUE' : 'Flag as Critical Safety Issue'}
</button>
)}
{/* Measurement — for brake/tire items */}
{item.measurement_type && (
<div className="mt-3 flex items-center gap-2">
<span className="text-xs text-gray-500">
{item.measurement_type === 'brake_mm' ? 'Pad thickness:' : 'Tread depth:'}
</span>
<input
className="w-16 border border-gray-300 rounded-lg px-2 py-1.5 text-sm text-center focus:border-blue-500 outline-none"
type="number" step="0.5" min="0"
defaultValue={item.measurement || ''}
onBlur={(e) => {
if (e.target.value !== (item.measurement || '')) {
updateItemMutation.mutate({
itemId: item.id,
data: {
measurement: e.target.value || null,
measurementUnit: item.measurement_type === 'brake_mm' ? 'mm' : '/32"',
},
});
}
}}
/>
<span className="text-xs text-gray-400">{item.measurement_type === 'brake_mm' ? 'mm' : '/32"'}</span>
</div>
)}
{/* Action row */}
<div className="flex gap-2 mt-3">
<button onClick={() => setExpandedItem(expandedItem === item.id ? null : item.id)}
className={`flex items-center gap-1.5 px-3 py-2 rounded-lg text-xs font-medium transition-colors ${
item.notes ? 'bg-blue-100 text-blue-700' : 'bg-gray-100 text-gray-600 hover:bg-gray-200'
}`}>
<MessageSquare size={14} /> {item.notes ? 'Notes' : 'Add Note'}
</button>
<button onClick={() => { setPhotoItemId(item.id); fileInputRef.current?.click(); }}
className="flex items-center gap-1.5 px-3 py-2 rounded-lg text-xs font-medium bg-gray-100 text-gray-600 hover:bg-gray-200">
<Camera size={14} /> Photo
</button>
</div>
{/* Expanded: notes + canned notes */}
{expandedItem === item.id && (
<div className="mt-3 space-y-2">
{/* Canned notes chips */}
<div className="flex flex-wrap gap-1.5">
{cannedNotes.map((cn: any) => {
const isApplied = item.notes?.includes(cn.note_text);
return (
<button key={cn.id}
onClick={() => {
const newNotes = isApplied
? (item.notes || '').replace(cn.note_text, '').replace(/\n+/g, '\n').trim()
: [item.notes, cn.note_text].filter(Boolean).join('\n');
updateItemMutation.mutate({ itemId: item.id, data: { notes: newNotes } });
}}
className={`px-2.5 py-1.5 rounded-lg text-xs font-medium border transition-colors ${
isApplied ? 'bg-blue-100 border-blue-300 text-blue-700' : 'bg-white border-gray-200 text-gray-600 hover:bg-gray-50'
}`}>
{cn.label}
</button>
);
})}
</div>
{/* Custom note */}
<textarea
className="w-full border border-gray-200 rounded-lg px-3 py-2 text-sm resize-none focus:border-blue-500 outline-none"
placeholder="Custom notes..."
rows={2}
defaultValue={item.notes || ''}
onBlur={(e) => {
if (e.target.value !== (item.notes || '')) {
updateItemMutation.mutate({ itemId: item.id, data: { notes: e.target.value } });
}
}}
/>
</div>
)}
</div>
))}
</div>
</div>
</>
)}
{/* Hidden file input */}
<input ref={fileInputRef} type="file" accept="image/*" capture="environment" className="hidden"
onChange={(e) => {
if (e.target.files?.length && photoItemId) {
uploadMutation.mutate({ itemId: photoItemId, files: e.target.files });
e.target.value = '';
}
}}
/>
{/* Bottom nav */}
<div className="border-t border-gray-200 bg-white px-4 py-3 flex items-center justify-between safe-area-bottom">
{showSummary ? (
<>
<button onClick={() => setShowSummary(false)} className="flex items-center gap-1 px-4 py-2.5 rounded-lg text-sm font-medium text-gray-600 hover:bg-gray-100">
<ChevronLeft size={18} /> Back to Items
</button>
<button onClick={() => completeMutation.mutate()} disabled={completeMutation.isPending}
className="flex items-center gap-1.5 px-5 py-2.5 rounded-xl text-sm font-bold bg-green-600 text-white hover:bg-green-700 disabled:opacity-50">
<CheckCircle size={18} /> {completeMutation.isPending ? 'Saving...' : 'Complete Inspection'}
</button>
</>
) : (
<>
<button onClick={() => setCurrentSection(Math.max(0, currentSection - 1))}
disabled={currentSection === 0}
className="flex items-center gap-1 px-4 py-2.5 rounded-lg text-sm font-medium disabled:text-gray-300 text-gray-600 hover:bg-gray-100">
<ChevronLeft size={18} /> Prev
</button>
<span className="text-sm text-gray-500">{sections[currentSection]}</span>
{currentSection === sections.length - 1 ? (
<button onClick={() => setShowSummary(true)}
className="flex items-center gap-1 px-4 py-2.5 rounded-xl text-sm font-bold bg-blue-600 text-white hover:bg-blue-700">
Review <ChevronRight size={18} />
</button>
) : (
<button onClick={() => setCurrentSection(currentSection + 1)}
className="flex items-center gap-1 px-4 py-2.5 rounded-lg text-sm font-medium text-blue-600 hover:bg-blue-50">
Next <ChevronRight size={18} />
</button>
)}
</>
)}
</div>
</div>
);
}

View File

@@ -0,0 +1,36 @@
const colors: Record<string, string> = {
blue: 'bg-blue-100 text-blue-700',
green: 'bg-green-100 text-green-700',
yellow: 'bg-yellow-100 text-yellow-700',
red: 'bg-red-100 text-red-700',
gray: 'bg-gray-100 text-gray-700',
purple: 'bg-purple-100 text-purple-700',
};
const statusColors: Record<string, string> = {
received: 'blue',
in_progress: 'yellow',
completed: 'green',
invoiced: 'purple',
closed: 'gray',
draft: 'gray',
sent: 'blue',
paid: 'green',
partial: 'yellow',
overdue: 'red',
};
interface BadgeProps {
children: React.ReactNode;
color?: string;
status?: string;
}
export function Badge({ children, color, status }: BadgeProps) {
const c = color || (status ? statusColors[status] : 'gray') || 'gray';
return (
<span className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium ${colors[c]}`}>
{children}
</span>
);
}

View File

@@ -0,0 +1,28 @@
import type { ButtonHTMLAttributes } from 'react';
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
variant?: 'primary' | 'secondary' | 'danger' | 'ghost';
size?: 'sm' | 'md' | 'lg';
}
const variants = {
primary: 'bg-blue-600 text-white hover:bg-blue-700 shadow-sm',
secondary: 'bg-white text-gray-700 border border-gray-300 hover:bg-gray-50 shadow-sm',
danger: 'bg-red-600 text-white hover:bg-red-700 shadow-sm',
ghost: 'text-gray-600 hover:bg-gray-100 hover:text-gray-900',
};
const sizes = {
sm: 'px-2.5 py-1.5 text-xs',
md: 'px-3.5 py-2 text-sm',
lg: 'px-5 py-2.5 text-base',
};
export function Button({ variant = 'primary', size = 'md', className = '', ...props }: ButtonProps) {
return (
<button
className={`inline-flex items-center justify-center gap-2 font-medium rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed ${variants[variant]} ${sizes[size]} ${className}`}
{...props}
/>
);
}

View File

@@ -0,0 +1,9 @@
import type { HTMLAttributes } from 'react';
export function Card({ className = '', children, ...props }: HTMLAttributes<HTMLDivElement>) {
return (
<div className={`bg-white rounded-lg border border-gray-200 shadow-sm ${className}`} {...props}>
{children}
</div>
);
}

View File

@@ -0,0 +1,19 @@
import type { LucideIcon } from 'lucide-react';
interface EmptyStateProps {
icon: LucideIcon;
title: string;
description: string;
action?: React.ReactNode;
}
export function EmptyState({ icon: Icon, title, description, action }: EmptyStateProps) {
return (
<div className="text-center py-12">
<Icon size={40} className="mx-auto text-gray-300 mb-3" />
<h3 className="text-sm font-medium text-gray-900">{title}</h3>
<p className="text-sm text-gray-500 mt-1">{description}</p>
{action && <div className="mt-4">{action}</div>}
</div>
);
}

View File

@@ -0,0 +1,47 @@
import type { InputHTMLAttributes, TextareaHTMLAttributes, SelectHTMLAttributes } from 'react';
const inputBase = 'block w-full rounded-lg border border-gray-300 px-3 py-2 text-sm shadow-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 outline-none disabled:bg-gray-50';
interface InputProps extends InputHTMLAttributes<HTMLInputElement> {
label?: string;
}
export function Input({ label, className = '', ...props }: InputProps) {
return (
<div>
{label && <label className="block text-sm font-medium text-gray-700 mb-1">{label}</label>}
<input className={`${inputBase} ${className}`} {...props} />
</div>
);
}
interface TextAreaProps extends TextareaHTMLAttributes<HTMLTextAreaElement> {
label?: string;
}
export function TextArea({ label, className = '', ...props }: TextAreaProps) {
return (
<div>
{label && <label className="block text-sm font-medium text-gray-700 mb-1">{label}</label>}
<textarea className={`${inputBase} ${className}`} rows={3} {...props} />
</div>
);
}
interface SelectProps extends SelectHTMLAttributes<HTMLSelectElement> {
label?: string;
options: { value: string | number; label: string }[];
}
export function Select({ label, options, className = '', ...props }: SelectProps) {
return (
<div>
{label && <label className="block text-sm font-medium text-gray-700 mb-1">{label}</label>}
<select className={`${inputBase} ${className}`} {...props}>
{options.map((o) => (
<option key={o.value} value={o.value}>{o.label}</option>
))}
</select>
</div>
);
}

View File

@@ -0,0 +1,33 @@
import { useEffect } from 'react';
import { X } from 'lucide-react';
interface ModalProps {
open: boolean;
onClose: () => void;
title: string;
children: React.ReactNode;
}
export function Modal({ open, onClose, title, children }: ModalProps) {
useEffect(() => {
if (open) document.body.style.overflow = 'hidden';
return () => { document.body.style.overflow = ''; };
}, [open]);
if (!open) return null;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
<div className="absolute inset-0 bg-black/40" onClick={onClose} />
<div className="relative bg-white rounded-xl shadow-xl w-full max-w-lg max-h-[90vh] overflow-y-auto">
<div className="flex items-center justify-between p-4 border-b border-gray-200">
<h2 className="text-lg font-semibold text-gray-900">{title}</h2>
<button onClick={onClose} className="p-1 text-gray-400 hover:text-gray-600 rounded">
<X size={20} />
</button>
</div>
<div className="p-4">{children}</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,6 @@
export { Badge } from './Badge';
export { Button } from './Button';
export { Card } from './Card';
export { EmptyState } from './EmptyState';
export { Input, TextArea, Select } from './Input';
export { Modal } from './Modal';

13
client/src/index.css Normal file
View File

@@ -0,0 +1,13 @@
@import "tailwindcss";
@theme {
--color-primary: #2563eb;
--color-primary-dark: #1d4ed8;
--color-primary-light: #dbeafe;
}
body {
margin: 0;
font-family: system-ui, -apple-system, sans-serif;
-webkit-font-smoothing: antialiased;
}

10
client/src/main.tsx Normal file
View File

@@ -0,0 +1,10 @@
import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import './index.css'
import App from './App'
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
)

View File

@@ -0,0 +1,161 @@
import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { Calendar, Plus, Clock } from 'lucide-react';
import { api } from '../api/client';
import { Card, Button, Badge, Input, Modal, EmptyState } from '../components/ui';
import type { Appointment, Owner, Vehicle } from '../types';
const statusLabel: Record<string, string> = {
pending: 'Pending', confirmed: 'Confirmed', cancelled: 'Cancelled', completed: 'Completed',
};
const statusColors: Record<string, string> = {
pending: 'yellow', confirmed: 'blue', cancelled: 'gray', completed: 'green',
};
export default function Appointments() {
const [showModal, setShowModal] = useState(false);
const [dateFilter, setDateFilter] = useState('');
const queryClient = useQueryClient();
const { data: appointments = [], isLoading } = useQuery({
queryKey: ['appointments', dateFilter],
queryFn: () => api.getAppointments({ date: dateFilter || undefined }),
});
const createMutation = useMutation({
mutationFn: api.createAppointment,
onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['appointments'] }); setShowModal(false); },
});
const statusMutation = useMutation({
mutationFn: ({ id, status }: { id: number; status: string }) => api.updateAppointment(id, { status }),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['appointments'] }),
});
// Group appointments by date
const grouped = appointments.reduce((acc: Record<string, Appointment[]>, appt: Appointment) => {
const date = appt.requested_date;
if (!acc[date]) acc[date] = [];
acc[date].push(appt);
return acc;
}, {});
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold text-gray-900">Appointments</h1>
<Button onClick={() => setShowModal(true)}><Plus size={16} /> New Appointment</Button>
</div>
<div className="flex gap-3 items-center">
<Input type="date" value={dateFilter} onChange={(e) => setDateFilter(e.target.value)} />
{dateFilter && <button onClick={() => setDateFilter('')} className="text-sm text-gray-500 hover:text-gray-700">Clear</button>}
</div>
{isLoading ? <p className="text-gray-500 py-4">Loading...</p> : appointments.length === 0 ? (
<EmptyState icon={Calendar} title="No appointments" description="Schedule appointments for customers." action={<Button onClick={() => setShowModal(true)}>New Appointment</Button>} />
) : (
Object.entries(grouped).map(([date, appts]) => (
<div key={date} className="space-y-2">
<h3 className="text-sm font-medium text-gray-500">{new Date(date + 'T00:00').toLocaleDateString(undefined, { weekday: 'long', month: 'long', day: 'numeric' })}</h3>
{(appts as Appointment[]).map((appt) => (
<Card key={appt.id} className="p-4">
<div className="flex items-center justify-between">
<div>
<div className="flex items-center gap-2">
<Clock size={14} className="text-gray-400" />
<span className="text-sm font-medium text-gray-900">{appt.requested_time || 'No time set'}</span>
<Badge color={statusColors[appt.status]}>{statusLabel[appt.status]}</Badge>
</div>
<p className="text-sm text-gray-700 mt-1">
{appt.owner_first_name && `${appt.owner_first_name} ${appt.owner_last_name}`}
{appt.year && `${[appt.year, appt.make, appt.model].filter(Boolean).join(' ')}`}
</p>
{appt.service_description && <p className="text-xs text-gray-500 mt-0.5">{appt.service_description}</p>}
</div>
<div className="flex gap-1">
{appt.status === 'pending' && (
<Button variant="ghost" size="sm" onClick={() => statusMutation.mutate({ id: appt.id, status: 'confirmed' })}>Confirm</Button>
)}
{(appt.status === 'pending' || appt.status === 'confirmed') && (
<Button variant="ghost" size="sm" onClick={() => statusMutation.mutate({ id: appt.id, status: 'cancelled' })}>Cancel</Button>
)}
{appt.status === 'confirmed' && (
<Button variant="ghost" size="sm" onClick={() => statusMutation.mutate({ id: appt.id, status: 'completed' })}>Complete</Button>
)}
</div>
</div>
</Card>
))}
</div>
))
)}
<NewAppointmentModal open={showModal} onClose={() => setShowModal(false)} onSave={(data) => createMutation.mutate(data)} saving={createMutation.isPending} />
</div>
);
}
function NewAppointmentModal({ open, onClose, onSave, saving }: {
open: boolean; onClose: () => void; onSave: (data: any) => void; saving: boolean;
}) {
const [ownerId, setOwnerId] = useState('');
const [vehicleId, setVehicleId] = useState('');
const [requestedDate, setRequestedDate] = useState('');
const [requestedTime, setRequestedTime] = useState('');
const [serviceDescription, setServiceDescription] = useState('');
const [notes, setNotes] = useState('');
const { data: owners = [] } = useQuery({ queryKey: ['owners'], queryFn: () => api.getOwners() });
const { data: vehicles = [] } = useQuery({
queryKey: ['vehicles', ownerId],
queryFn: () => api.getVehicles(ownerId ? { ownerId: Number(ownerId) } : {}),
enabled: !!ownerId,
});
return (
<Modal open={open} onClose={onClose} title="New Appointment">
<form onSubmit={(e) => {
e.preventDefault();
onSave({
ownerId: ownerId ? Number(ownerId) : undefined,
vehicleId: vehicleId ? Number(vehicleId) : undefined,
requestedDate,
requestedTime: requestedTime || undefined,
serviceDescription: serviceDescription || undefined,
notes: notes || undefined,
});
}} className="space-y-3">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Customer</label>
<select className="block w-full rounded-lg border border-gray-300 px-3 py-2 text-sm" value={ownerId} onChange={(e) => { setOwnerId(e.target.value); setVehicleId(''); }}>
<option value="">Select customer...</option>
{owners.map((o: Owner) => <option key={o.id} value={o.id}>{o.first_name} {o.last_name}</option>)}
</select>
</div>
{ownerId && (
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Vehicle</label>
<select className="block w-full rounded-lg border border-gray-300 px-3 py-2 text-sm" value={vehicleId} onChange={(e) => setVehicleId(e.target.value)}>
<option value="">Select vehicle...</option>
{vehicles.map((v: Vehicle) => <option key={v.id} value={v.id}>{[v.year, v.make, v.model].filter(Boolean).join(' ')}</option>)}
</select>
</div>
)}
<div className="grid grid-cols-2 gap-3">
<Input label="Date *" type="date" value={requestedDate} onChange={(e) => setRequestedDate(e.target.value)} required />
<Input label="Time" type="time" value={requestedTime} onChange={(e) => setRequestedTime(e.target.value)} />
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Service Description</label>
<textarea className="block w-full rounded-lg border border-gray-300 px-3 py-2 text-sm" rows={2} value={serviceDescription} onChange={(e) => setServiceDescription(e.target.value)} />
</div>
<div className="flex justify-end gap-2 pt-2">
<Button variant="secondary" type="button" onClick={onClose}>Cancel</Button>
<Button type="submit" disabled={saving}>{saving ? 'Saving...' : 'Save'}</Button>
</div>
</form>
</Modal>
);
}

View File

@@ -0,0 +1,159 @@
import { useQuery } from '@tanstack/react-query';
import { Link } from 'react-router-dom';
import { Wrench, FileText, DollarSign, AlertTriangle } from 'lucide-react';
import { api } from '../api/client';
import { Card, Badge } from '../components/ui';
import type { DashboardStats } from '../types';
const fmt = (n: number) => '$' + n.toFixed(2);
const statusLabel: Record<string, string> = {
received: 'Received',
in_progress: 'In Progress',
completed: 'Completed',
invoiced: 'Invoiced',
closed: 'Closed',
};
export default function Dashboard() {
const { data: stats, isLoading } = useQuery<DashboardStats>({
queryKey: ['dashboard'],
queryFn: api.getDashboardStats,
});
if (isLoading || !stats) return <p className="text-gray-500 py-8">Loading dashboard...</p>;
return (
<div className="space-y-6">
<h1 className="text-2xl font-bold text-gray-900">Dashboard</h1>
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
{[
{ label: 'Active Jobs', value: stats.activeJobs, icon: Wrench, color: 'text-blue-600 bg-blue-50' },
{ label: 'Completed This Month', value: stats.completedThisMonth, icon: Wrench, color: 'text-green-600 bg-green-50' },
{ label: 'Outstanding', value: fmt(stats.outstandingAmount), icon: FileText, color: 'text-yellow-600 bg-yellow-50' },
{ label: 'Revenue This Month', value: fmt(stats.revenueThisMonth), icon: DollarSign, color: 'text-green-600 bg-green-50' },
].map((stat) => (
<Card key={stat.label} className="p-4">
<div className="flex items-center gap-3">
<div className={`p-2 rounded-lg ${stat.color}`}>
<stat.icon size={20} />
</div>
<div>
<p className="text-2xl font-bold text-gray-900">{stat.value}</p>
<p className="text-xs text-gray-500">{stat.label}</p>
</div>
</div>
</Card>
))}
</div>
{/* Recommendations to communicate */}
{(stats as any).urgentRecs?.length > 0 && (
<Card>
<div className="p-4 border-b border-gray-200 flex items-center gap-2">
<AlertTriangle size={16} className="text-orange-500" />
<h2 className="font-semibold text-gray-900">Recommend to Customer</h2>
<span className="text-xs bg-orange-100 text-orange-700 px-1.5 py-0.5 rounded-full">{(stats as any).urgentRecs.length}</span>
</div>
<div className="divide-y divide-gray-100">
{(stats as any).urgentRecs.map((rec: any) => (
<Link key={rec.id} to={`/jobs/${rec.job_id}`} className="block p-3 hover:bg-gray-50 transition-colors">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-gray-900">{rec.description}</p>
<p className="text-xs text-gray-500 mt-0.5">
{rec.first_name && <span className="font-medium">{rec.first_name} {rec.last_name}'s </span>}
{[rec.year, rec.make, rec.model].filter(Boolean).join(' ')}
<span className="text-gray-400"> — {rec.job_number}</span>
</p>
</div>
{rec.estimated_cost && <span className="text-sm font-medium text-gray-700">~{fmt(rec.estimated_cost)}</span>}
</div>
</Link>
))}
</div>
</Card>
)}
{(stats as any).soonRecs?.length > 0 && (
<Card>
<div className="p-4 border-b border-gray-200 flex items-center gap-2">
<h2 className="font-semibold text-gray-900">Follow-Up Recommendations</h2>
<span className="text-xs bg-gray-100 text-gray-600 px-1.5 py-0.5 rounded-full">{(stats as any).soonRecs.length}</span>
</div>
<div className="divide-y divide-gray-100">
{(stats as any).soonRecs.map((rec: any) => (
<Link key={rec.id} to={`/jobs/${rec.job_id}`} className="block p-3 hover:bg-gray-50 transition-colors">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-gray-900">{rec.description}</p>
<p className="text-xs text-gray-500 mt-0.5">
{rec.first_name && <span className="font-medium">{rec.first_name} {rec.last_name}'s </span>}
{[rec.year, rec.make, rec.model].filter(Boolean).join(' ')}
<span className="text-gray-400"> {rec.job_number}</span>
</p>
</div>
{rec.estimated_cost && <span className="text-sm text-gray-500">~{fmt(rec.estimated_cost)}</span>}
</div>
</Link>
))}
</div>
</Card>
)}
<div className="grid lg:grid-cols-2 gap-6">
<Card>
<div className="p-4 border-b border-gray-200">
<h2 className="font-semibold text-gray-900">Recent Jobs</h2>
</div>
<div className="divide-y divide-gray-100">
{stats.recentJobs.length === 0 ? (
<p className="p-4 text-sm text-gray-500">No jobs yet</p>
) : (
stats.recentJobs.map((job: any) => (
<Link key={job.id} to={`/jobs/${job.id}`} className="block p-3 hover:bg-gray-50 transition-colors">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-gray-900">{job.job_number}</p>
<p className="text-xs text-gray-500">
{[job.year, job.make, job.model].filter(Boolean).join(' ')} {job.shop_name}
</p>
</div>
<Badge status={job.status}>{statusLabel[job.status] || job.status}</Badge>
</div>
</Link>
))
)}
</div>
</Card>
<Card>
<div className="p-4 border-b border-gray-200">
<h2 className="font-semibold text-gray-900">Outstanding Invoices</h2>
</div>
<div className="divide-y divide-gray-100">
{stats.outstandingInvoices.length === 0 ? (
<p className="p-4 text-sm text-gray-500">All caught up!</p>
) : (
stats.outstandingInvoices.map((inv: any) => (
<Link key={inv.id} to={`/invoices/${inv.id}`} className="block p-3 hover:bg-gray-50 transition-colors">
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium text-gray-900">{inv.invoice_number}</p>
<p className="text-xs text-gray-500">{inv.shop_name}</p>
</div>
<div className="text-right">
<p className="text-sm font-medium text-gray-900">{fmt(inv.total)}</p>
<Badge status={inv.status}>{inv.status}</Badge>
</div>
</div>
</Link>
))
)}
</div>
</Card>
</div>
</div>
);
}

View File

@@ -0,0 +1,363 @@
import { useState } from 'react';
import { useParams, Link, useNavigate } from 'react-router-dom';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { ArrowLeft, Plus, Trash2, Send, CheckCircle, XCircle, Wrench, Pencil } from 'lucide-react';
import { api } from '../api/client';
import { Card, Button, Badge, Input, Modal } from '../components/ui';
import type { Estimate, EstimateItem, CannedService } from '../types';
const fmt = (n: number) => '$' + n.toFixed(2);
const statusLabel: Record<string, string> = {
draft: 'Draft', sent: 'Sent', approved: 'Approved', declined: 'Declined', expired: 'Expired', converted: 'Converted',
};
export default function EstimateDetail() {
const { id } = useParams();
const navigate = useNavigate();
const queryClient = useQueryClient();
const [showItemModal, setShowItemModal] = useState(false);
const [editItem, setEditItem] = useState<EstimateItem | null>(null);
const [showCannedModal, setShowCannedModal] = useState(false);
const { data: estimate, isLoading } = useQuery<Estimate>({
queryKey: ['estimates', id],
queryFn: () => api.getEstimate(Number(id)),
});
const invalidate = () => queryClient.invalidateQueries({ queryKey: ['estimates', id] });
const statusMutation = useMutation({
mutationFn: (status: string) => api.updateEstimate(Number(id), { status }),
onSuccess: invalidate,
});
const deleteItemMutation = useMutation({
mutationFn: (itemId: number) => api.deleteEstimateItem(Number(id), itemId),
onSuccess: invalidate,
});
const convertMutation = useMutation({
mutationFn: () => api.convertEstimateToJob(Number(id)),
onSuccess: (job: any) => {
queryClient.invalidateQueries({ queryKey: ['estimates'] });
queryClient.invalidateQueries({ queryKey: ['jobs'] });
navigate(`/jobs/${job.id}`);
},
});
const deleteMutation = useMutation({
mutationFn: () => api.deleteEstimate(Number(id)),
onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['estimates'] }); navigate('/estimates'); },
});
if (isLoading || !estimate) return <p className="text-gray-500 py-4">Loading...</p>;
const items = estimate.items || [];
const laborItems = items.filter(i => i.item_type === 'labor');
const partsItems = items.filter(i => i.item_type === 'parts');
const vehicleStr = [estimate.year, estimate.make, estimate.model].filter(Boolean).join(' ');
const customerName = estimate.shop_name || (estimate.owner_first_name ? `${estimate.owner_first_name} ${estimate.owner_last_name}` : '—');
return (
<div className="space-y-6">
<Link to="/estimates" className="inline-flex items-center gap-1.5 text-sm text-gray-500 hover:text-gray-700">
<ArrowLeft size={16} /> Back to Estimates
</Link>
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<div>
<div className="flex items-center gap-3">
<h1 className="text-2xl font-bold text-gray-900">{estimate.estimate_number}</h1>
<Badge status={estimate.status}>{statusLabel[estimate.status]}</Badge>
</div>
<p className="text-gray-700 mt-1">{customerName} {vehicleStr && `${vehicleStr}`}</p>
</div>
<div className="flex gap-2 flex-wrap">
{estimate.status === 'draft' && (
<Button onClick={() => statusMutation.mutate('sent')}><Send size={16} /> Mark Sent</Button>
)}
{estimate.status === 'sent' && (
<>
<Button onClick={() => statusMutation.mutate('approved')}><CheckCircle size={16} /> Approved</Button>
<Button variant="secondary" onClick={() => statusMutation.mutate('declined')}><XCircle size={16} /> Declined</Button>
</>
)}
{estimate.status === 'approved' && estimate.vehicle_id && (
<Button onClick={() => convertMutation.mutate()} disabled={convertMutation.isPending}>
<Wrench size={16} /> {convertMutation.isPending ? 'Converting...' : 'Convert to Job'}
</Button>
)}
<Button variant="danger" onClick={() => { if (confirm('Delete this estimate?')) deleteMutation.mutate(); }}>
<Trash2 size={16} /> Delete
</Button>
</div>
</div>
{/* Labor Items */}
<Card>
<div className="p-4 border-b border-gray-200 flex items-center justify-between">
<h2 className="font-semibold text-gray-900">Labor</h2>
<div className="flex gap-2">
<Button variant="ghost" size="sm" onClick={() => setShowCannedModal(true)}>
<Plus size={14} /> Canned Service
</Button>
<Button variant="ghost" size="sm" onClick={() => { setEditItem(null); setShowItemModal(true); }}>
<Plus size={14} /> Add Labor
</Button>
</div>
</div>
{laborItems.length > 0 ? (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<tbody className="divide-y divide-gray-100">
{laborItems.map((item) => {
const lineTotal = item.billing_type === 'flat_rate' ? (item.flat_rate || 0) : (item.hours || 0) * (item.rate || 0);
return (
<tr key={item.id} className="hover:bg-gray-50">
<td className="px-4 py-2 text-gray-700">{item.description}</td>
<td className="px-4 py-2 text-right text-gray-500">
{item.billing_type === 'flat_rate' ? 'Flat rate' : `${item.hours}h @ ${fmt(item.rate || 0)}/hr`}
</td>
<td className="px-4 py-2 text-right font-medium w-24">{fmt(lineTotal)}</td>
<td className="px-4 py-2 w-16">
<div className="flex gap-1">
<button onClick={() => { setEditItem(item); setShowItemModal(true); }} className="text-gray-400 hover:text-blue-500"><Pencil size={14} /></button>
<button onClick={() => deleteItemMutation.mutate(item.id)} className="text-gray-400 hover:text-red-500"><Trash2 size={14} /></button>
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
) : (
<p className="p-4 text-sm text-gray-500">No labor items.</p>
)}
</Card>
{/* Parts Items */}
<Card>
<div className="p-4 border-b border-gray-200 flex items-center justify-between">
<h2 className="font-semibold text-gray-900">Parts</h2>
<Button variant="ghost" size="sm" onClick={() => { setEditItem({ item_type: 'parts' } as any); setShowItemModal(true); }}>
<Plus size={14} /> Add Parts
</Button>
</div>
{partsItems.length > 0 ? (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<tbody className="divide-y divide-gray-100">
{partsItems.map((item) => {
const lineTotal = item.quantity * item.unit_price * (1 + item.markup);
return (
<tr key={item.id} className="hover:bg-gray-50">
<td className="px-4 py-2 text-gray-700">{item.description}</td>
<td className="px-4 py-2 text-right text-gray-500">{item.quantity} x {fmt(item.unit_price * (1 + item.markup))}</td>
<td className="px-4 py-2 text-right font-medium w-24">{fmt(lineTotal)}</td>
<td className="px-4 py-2 w-16">
<div className="flex gap-1">
<button onClick={() => { setEditItem(item); setShowItemModal(true); }} className="text-gray-400 hover:text-blue-500"><Pencil size={14} /></button>
<button onClick={() => deleteItemMutation.mutate(item.id)} className="text-gray-400 hover:text-red-500"><Trash2 size={14} /></button>
</div>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
) : (
<p className="p-4 text-sm text-gray-500">No parts items.</p>
)}
</Card>
{/* Totals */}
<Card className="p-4 space-y-2">
<div className="flex justify-between text-sm">
<span className="text-gray-600">Subtotal</span>
<span className="font-medium">{fmt(estimate.subtotal)}</span>
</div>
{estimate.tax_rate > 0 && (
<div className="flex justify-between text-sm">
<span className="text-gray-600">Tax ({(estimate.tax_rate * 100).toFixed(1)}%)</span>
<span className="font-medium">{fmt(estimate.tax_amount)}</span>
</div>
)}
<div className="flex justify-between text-lg font-bold border-t border-gray-200 pt-2">
<span>Total</span>
<span>{fmt(estimate.total)}</span>
</div>
</Card>
{estimate.notes && (
<Card className="p-4">
<h3 className="text-xs font-medium text-gray-500 uppercase mb-1">Notes</h3>
<p className="text-sm text-gray-700">{estimate.notes}</p>
</Card>
)}
<EstimateItemModal
open={showItemModal}
onClose={() => { setShowItemModal(false); setEditItem(null); }}
estimateId={Number(id)}
item={editItem}
onDone={invalidate}
/>
<CannedServicePickerModal
open={showCannedModal}
onClose={() => setShowCannedModal(false)}
estimateId={Number(id)}
onDone={invalidate}
/>
</div>
);
}
function EstimateItemModal({ open, onClose, estimateId, item, onDone }: {
open: boolean; onClose: () => void; estimateId: number; item: EstimateItem | null; onDone: () => void;
}) {
const isEditing = item?.id !== undefined;
const isPartsMode = item?.item_type === 'parts';
const [itemType, setItemType] = useState<'labor' | 'parts'>(item?.item_type || 'labor');
const [description, setDescription] = useState(item?.description || '');
const [billingType, setBillingType] = useState(item?.billing_type || 'hourly');
const [hours, setHours] = useState(item?.hours?.toString() || '');
const [rate, setRate] = useState(item?.rate?.toString() || '75');
const [flatRate, setFlatRate] = useState(item?.flat_rate?.toString() || '');
const [quantity, setQuantity] = useState(item?.quantity?.toString() || '1');
const [unitPrice, setUnitPrice] = useState(item?.unit_price?.toString() || '');
const [markup, setMarkup] = useState(item ? String(item.markup * 100) : '30');
const mutation = useMutation({
mutationFn: (data: any) => isEditing
? api.updateEstimateItem(estimateId, item!.id, data)
: api.addEstimateItem(estimateId, data),
onSuccess: () => { onDone(); onClose(); },
});
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (itemType === 'labor') {
mutation.mutate({
itemType: 'labor', description, billingType,
hours: billingType === 'hourly' ? parseFloat(hours) : null,
rate: billingType === 'hourly' ? parseFloat(rate) : null,
flatRate: billingType === 'flat_rate' ? parseFloat(flatRate) : null,
});
} else {
mutation.mutate({
itemType: 'parts', description,
quantity: parseFloat(quantity),
unitPrice: parseFloat(unitPrice),
markup: parseFloat(markup) / 100,
});
}
};
return (
<Modal open={open} onClose={onClose} title={isEditing ? 'Edit Item' : 'Add Item'}>
<form onSubmit={handleSubmit} className="space-y-3">
{!isEditing && !isPartsMode && (
<div className="flex gap-2">
<button type="button" onClick={() => setItemType('labor')}
className={`flex-1 px-3 py-2 text-sm font-medium rounded-lg border ${itemType === 'labor' ? 'bg-blue-50 border-blue-300 text-blue-700' : 'border-gray-300 text-gray-600'}`}>
Labor
</button>
<button type="button" onClick={() => setItemType('parts')}
className={`flex-1 px-3 py-2 text-sm font-medium rounded-lg border ${itemType === 'parts' ? 'bg-blue-50 border-blue-300 text-blue-700' : 'border-gray-300 text-gray-600'}`}>
Parts
</button>
</div>
)}
<Input label="Description *" value={description} onChange={(e) => setDescription(e.target.value)} required />
{itemType === 'labor' ? (
<>
<div className="flex gap-2">
<button type="button" onClick={() => setBillingType('hourly')}
className={`flex-1 px-3 py-2 text-sm rounded-lg border ${billingType === 'hourly' ? 'bg-blue-50 border-blue-300 text-blue-700' : 'border-gray-300 text-gray-600'}`}>
Hourly
</button>
<button type="button" onClick={() => setBillingType('flat_rate')}
className={`flex-1 px-3 py-2 text-sm rounded-lg border ${billingType === 'flat_rate' ? 'bg-blue-50 border-blue-300 text-blue-700' : 'border-gray-300 text-gray-600'}`}>
Flat Rate
</button>
</div>
{billingType === 'hourly' ? (
<div className="grid grid-cols-2 gap-3">
<Input label="Hours *" type="number" step="0.1" value={hours} onChange={(e) => setHours(e.target.value)} required />
<Input label="Rate ($/hr) *" type="number" step="0.01" value={rate} onChange={(e) => setRate(e.target.value)} required />
</div>
) : (
<Input label="Flat Rate *" type="number" step="0.01" value={flatRate} onChange={(e) => setFlatRate(e.target.value)} required />
)}
</>
) : (
<div className="grid grid-cols-3 gap-3">
<Input label="Qty *" type="number" step="1" value={quantity} onChange={(e) => setQuantity(e.target.value)} required />
<Input label="Unit Cost *" type="number" step="0.01" value={unitPrice} onChange={(e) => setUnitPrice(e.target.value)} required />
<Input label="Markup %" type="number" step="1" value={markup} onChange={(e) => setMarkup(e.target.value)} />
</div>
)}
<div className="flex justify-end gap-2 pt-2">
<Button variant="secondary" type="button" onClick={onClose}>Cancel</Button>
<Button type="submit" disabled={mutation.isPending}>{mutation.isPending ? 'Saving...' : 'Save'}</Button>
</div>
</form>
</Modal>
);
}
function CannedServicePickerModal({ open, onClose, estimateId, onDone }: {
open: boolean; onClose: () => void; estimateId: number; onDone: () => void;
}) {
const { data: services = [] } = useQuery({
queryKey: ['canned-services'],
queryFn: () => api.getCannedServices({ active: 1 }),
enabled: open,
});
const addItems = useMutation({
mutationFn: async (service: CannedService) => {
for (const item of service.items || []) {
await api.addEstimateItem(estimateId, {
itemType: item.item_type,
description: item.description,
billingType: item.billing_type || 'hourly',
hours: item.hours,
rate: item.rate,
flatRate: item.flat_rate,
quantity: item.quantity,
unitPrice: item.unit_cost,
markup: item.markup,
});
}
},
onSuccess: () => { onDone(); onClose(); },
});
return (
<Modal open={open} onClose={onClose} title="Add Canned Service">
{services.length === 0 ? (
<p className="text-sm text-gray-500 py-4">No canned services available. Create them in Settings.</p>
) : (
<div className="space-y-2 max-h-80 overflow-y-auto">
{services.map((svc: CannedService) => (
<button key={svc.id} onClick={() => addItems.mutate(svc)} disabled={addItems.isPending}
className="w-full text-left p-3 border border-gray-200 rounded-lg hover:bg-gray-50 transition-colors">
<p className="font-medium text-gray-900">{svc.name}</p>
{svc.description && <p className="text-xs text-gray-500 mt-0.5">{svc.description}</p>}
<p className="text-xs text-gray-400 mt-1">{(svc.items?.length || 0)} items {svc.category && `${svc.category}`}</p>
</button>
))}
</div>
)}
</Modal>
);
}

View File

@@ -0,0 +1,178 @@
import { useState } from 'react';
import { Link } from 'react-router-dom';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { FileText, Plus } from 'lucide-react';
import { api } from '../api/client';
import { Card, Button, Badge, Modal, EmptyState } from '../components/ui';
import type { Estimate, Shop, Owner, Vehicle } from '../types';
const fmt = (n: number) => '$' + n.toFixed(2);
const statusLabel: Record<string, string> = {
draft: 'Draft', sent: 'Sent', approved: 'Approved', declined: 'Declined', expired: 'Expired', converted: 'Converted',
};
const statusFilters = [
{ value: '', label: 'All' },
{ value: 'draft', label: 'Draft' },
{ value: 'sent', label: 'Sent' },
{ value: 'approved', label: 'Approved' },
{ value: 'declined', label: 'Declined' },
{ value: 'converted', label: 'Converted' },
];
export default function Estimates() {
const [statusFilter, setStatusFilter] = useState('');
const [showNewEstimate, setShowNewEstimate] = useState(false);
const queryClient = useQueryClient();
const { data: estimates = [], isLoading } = useQuery({
queryKey: ['estimates', statusFilter],
queryFn: () => api.getEstimates({ status: statusFilter || undefined }),
});
const createMutation = useMutation({
mutationFn: api.createEstimate,
onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['estimates'] }); setShowNewEstimate(false); },
});
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold text-gray-900">Estimates</h1>
<Button onClick={() => setShowNewEstimate(true)}><Plus size={16} /> New Estimate</Button>
</div>
<div className="flex gap-1 flex-wrap">
{statusFilters.map((s) => (
<button key={s.value} onClick={() => setStatusFilter(s.value)}
className={`px-3 py-1.5 text-xs font-medium rounded-lg transition-colors ${statusFilter === s.value ? 'bg-blue-100 text-blue-700' : 'bg-gray-100 text-gray-600 hover:bg-gray-200'}`}>
{s.label}
</button>
))}
</div>
{isLoading ? <p className="text-gray-500 py-4">Loading...</p> : estimates.length === 0 ? (
<EmptyState icon={FileText} title="No estimates" description="Create an estimate to quote work for customers." action={<Button onClick={() => setShowNewEstimate(true)}>New Estimate</Button>} />
) : (
<Card>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-gray-200 text-left text-xs text-gray-500 uppercase">
<th className="px-4 py-3">Estimate</th>
<th className="px-4 py-3">Customer</th>
<th className="px-4 py-3">Vehicle</th>
<th className="px-4 py-3">Date</th>
<th className="px-4 py-3 text-right">Total</th>
<th className="px-4 py-3">Status</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{estimates.map((est: Estimate) => (
<tr key={est.id} className="hover:bg-gray-50">
<td className="px-4 py-3">
<Link to={`/estimates/${est.id}`} className="font-medium text-blue-600 hover:underline">{est.estimate_number}</Link>
</td>
<td className="px-4 py-3 text-gray-700">
{est.shop_name || (est.owner_first_name ? `${est.owner_first_name} ${est.owner_last_name}` : '—')}
</td>
<td className="px-4 py-3 text-gray-500">
{[est.year, est.make, est.model].filter(Boolean).join(' ') || '—'}
</td>
<td className="px-4 py-3 text-gray-500">{new Date(est.created_at).toLocaleDateString()}</td>
<td className="px-4 py-3 text-right font-medium">{fmt(est.total)}</td>
<td className="px-4 py-3"><Badge status={est.status}>{statusLabel[est.status]}</Badge></td>
</tr>
))}
</tbody>
</table>
</div>
</Card>
)}
<NewEstimateModal open={showNewEstimate} onClose={() => setShowNewEstimate(false)} onSave={(data) => createMutation.mutate(data)} saving={createMutation.isPending} />
</div>
);
}
function NewEstimateModal({ open, onClose, onSave, saving }: { open: boolean; onClose: () => void; onSave: (data: any) => void; saving: boolean }) {
const [customerType, setCustomerType] = useState<'shop' | 'owner'>('owner');
const [shopId, setShopId] = useState('');
const [ownerId, setOwnerId] = useState('');
const [vehicleId, setVehicleId] = useState('');
const [notes, setNotes] = useState('');
const { data: shops = [] } = useQuery({ queryKey: ['shops'], queryFn: () => api.getShops({ active: 1 }) });
const { data: owners = [] } = useQuery({ queryKey: ['owners'], queryFn: () => api.getOwners() });
const { data: vehicles = [] } = useQuery({
queryKey: ['vehicles', ownerId],
queryFn: () => api.getVehicles(ownerId ? { ownerId: Number(ownerId) } : {}),
enabled: customerType === 'owner' ? !!ownerId : true,
});
return (
<Modal open={open} onClose={onClose} title="New Estimate">
<form onSubmit={(e) => {
e.preventDefault();
onSave({
customerType: customerType === 'shop' ? 'shop' : 'owner',
shopId: customerType === 'shop' ? Number(shopId) : undefined,
ownerId: customerType === 'owner' ? Number(ownerId) : undefined,
vehicleId: vehicleId ? Number(vehicleId) : undefined,
notes: notes || undefined,
});
}} className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Customer Type</label>
<div className="flex gap-2">
<button type="button" onClick={() => { setCustomerType('owner'); setShopId(''); }}
className={`flex-1 px-3 py-2 text-sm font-medium rounded-lg border transition-colors ${customerType === 'owner' ? 'bg-blue-50 border-blue-300 text-blue-700' : 'border-gray-300 text-gray-600 hover:bg-gray-50'}`}>
Customer
</button>
<button type="button" onClick={() => { setCustomerType('shop'); setOwnerId(''); }}
className={`flex-1 px-3 py-2 text-sm font-medium rounded-lg border transition-colors ${customerType === 'shop' ? 'bg-blue-50 border-blue-300 text-blue-700' : 'border-gray-300 text-gray-600 hover:bg-gray-50'}`}>
Shop
</button>
</div>
</div>
{customerType === 'shop' ? (
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Shop *</label>
<select className="block w-full rounded-lg border border-gray-300 px-3 py-2 text-sm" value={shopId} onChange={(e) => setShopId(e.target.value)} required>
<option value="">Select a shop...</option>
{shops.map((s: Shop) => <option key={s.id} value={s.id}>{s.name}</option>)}
</select>
</div>
) : (
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Customer *</label>
<select className="block w-full rounded-lg border border-gray-300 px-3 py-2 text-sm" value={ownerId} onChange={(e) => { setOwnerId(e.target.value); setVehicleId(''); }} required>
<option value="">Select a customer...</option>
{owners.map((o: Owner) => <option key={o.id} value={o.id}>{o.first_name} {o.last_name}</option>)}
</select>
</div>
)}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Vehicle</label>
<select className="block w-full rounded-lg border border-gray-300 px-3 py-2 text-sm" value={vehicleId} onChange={(e) => setVehicleId(e.target.value)}>
<option value="">Select a vehicle...</option>
{vehicles.map((v: Vehicle) => <option key={v.id} value={v.id}>{[v.year, v.make, v.model].filter(Boolean).join(' ')}</option>)}
</select>
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Notes</label>
<textarea className="block w-full rounded-lg border border-gray-300 px-3 py-2 text-sm" rows={2} value={notes} onChange={(e) => setNotes(e.target.value)} />
</div>
<div className="flex justify-end gap-2 pt-2">
<Button variant="secondary" type="button" onClick={onClose}>Cancel</Button>
<Button type="submit" disabled={saving}>{saving ? 'Creating...' : 'Create Estimate'}</Button>
</div>
</form>
</Modal>
);
}

View File

@@ -0,0 +1,154 @@
import { useState } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { Package, Plus, Search, AlertTriangle } from 'lucide-react';
import { api } from '../api/client';
import { Card, Button, Badge, Input, Modal, EmptyState } from '../components/ui';
const fmt = (n: number) => '$' + (n || 0).toFixed(2);
const typeFilters = [
{ value: '', label: 'All' },
{ value: 'parts', label: 'Parts' },
{ value: 'supply', label: 'Supplies' },
{ value: 'consumable', label: 'Consumables' },
];
export default function InventoryPage() {
const [search, setSearch] = useState('');
const [typeFilter, setTypeFilter] = useState('');
const [lowStockOnly, setLowStockOnly] = useState(false);
const [showModal, setShowModal] = useState(false);
const [editItem, setEditItem] = useState<any>(null);
const queryClient = useQueryClient();
const { data: items = [], isLoading } = useQuery({
queryKey: ['inventory', search, typeFilter, lowStockOnly],
queryFn: () => api.getInventoryItems({
search: search || undefined,
itemType: typeFilter || undefined,
lowStock: lowStockOnly ? 1 : undefined,
}),
});
const saveMutation = useMutation({
mutationFn: (data: any) => editItem ? api.updateInventoryItem(editItem.id, data) : api.createInventoryItem(data),
onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['inventory'] }); setShowModal(false); setEditItem(null); },
});
const lowStockCount = items.filter((i: any) => i.quantity_on_hand <= i.reorder_point && i.reorder_point > 0).length;
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold text-gray-900">Inventory</h1>
<Button onClick={() => { setEditItem(null); setShowModal(true); }}><Plus size={16} /> Add Item</Button>
</div>
<div className="flex gap-3 flex-col sm:flex-row">
<div className="relative flex-1">
<Search size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400" />
<input className="w-full pl-9 pr-3 py-2 text-sm border border-gray-300 rounded-lg focus:border-blue-500 focus:ring-1 focus:ring-blue-500 outline-none" placeholder="Search inventory..." value={search} onChange={(e) => setSearch(e.target.value)} />
</div>
<div className="flex gap-1">
{typeFilters.map(f => (
<button key={f.value} onClick={() => setTypeFilter(f.value)}
className={`px-3 py-1.5 text-xs font-medium rounded-lg ${typeFilter === f.value ? 'bg-blue-100 text-blue-700' : 'bg-gray-100 text-gray-600 hover:bg-gray-200'}`}>
{f.label}
</button>
))}
<button onClick={() => setLowStockOnly(!lowStockOnly)}
className={`px-3 py-1.5 text-xs font-medium rounded-lg flex items-center gap-1 ${lowStockOnly ? 'bg-red-100 text-red-700' : 'bg-gray-100 text-gray-600 hover:bg-gray-200'}`}>
<AlertTriangle size={12} /> Low ({lowStockCount})
</button>
</div>
</div>
{isLoading ? <p className="text-gray-500 py-4">Loading...</p> : items.length === 0 ? (
<EmptyState icon={Package} title="No inventory items" description="Add parts, supplies, and consumables to track your inventory." action={<Button onClick={() => setShowModal(true)}>Add Item</Button>} />
) : (
<Card>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-gray-200 text-left text-xs text-gray-500 uppercase">
<th className="px-4 py-3">Item</th>
<th className="px-4 py-3">Part #</th>
<th className="px-4 py-3">Type</th>
<th className="px-4 py-3 text-right">On Hand</th>
<th className="px-4 py-3 text-right">Reorder Pt</th>
<th className="px-4 py-3 text-right">Unit Cost</th>
<th className="px-4 py-3">Supplier</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{items.map((item: any) => {
const isLow = item.quantity_on_hand <= item.reorder_point && item.reorder_point > 0;
return (
<tr key={item.id} className={`hover:bg-gray-50 ${isLow ? 'bg-red-50' : ''}`}>
<td className="px-4 py-3">
<button onClick={() => { setEditItem(item); setShowModal(true); }} className="font-medium text-blue-600 hover:underline">{item.name}</button>
{item.category && <p className="text-xs text-gray-400">{item.category}</p>}
</td>
<td className="px-4 py-3 text-gray-500 font-mono text-xs">{item.part_number || '—'}</td>
<td className="px-4 py-3"><Badge color="gray">{item.item_type}</Badge></td>
<td className={`px-4 py-3 text-right font-medium ${isLow ? 'text-red-600' : ''}`}>{item.quantity_on_hand}</td>
<td className="px-4 py-3 text-right text-gray-500">{item.reorder_point}</td>
<td className="px-4 py-3 text-right">{fmt(item.unit_cost)}</td>
<td className="px-4 py-3 text-gray-500">{item.supplier || '—'}</td>
</tr>
);
})}
</tbody>
</table>
</div>
</Card>
)}
<InventoryItemModal open={showModal} onClose={() => { setShowModal(false); setEditItem(null); }}
item={editItem} onSave={(data) => saveMutation.mutate(data)} saving={saveMutation.isPending} />
</div>
);
}
function InventoryItemModal({ open, onClose, item, onSave, saving }: {
open: boolean; onClose: () => void; item: any; onSave: (data: any) => void; saving: boolean;
}) {
const [form, setForm] = useState({
name: item?.name || '', partNumber: item?.part_number || '', description: item?.description || '',
category: item?.category || '', itemType: item?.item_type || 'parts',
quantityOnHand: item?.quantity_on_hand?.toString() || '0', reorderPoint: item?.reorder_point?.toString() || '0',
unitCost: item?.unit_cost?.toString() || '0', supplier: item?.supplier || '', location: item?.location || '',
});
return (
<Modal open={open} onClose={onClose} title={item ? 'Edit Item' : 'Add Inventory Item'}>
<form onSubmit={(e) => { e.preventDefault(); onSave({ ...form, quantityOnHand: parseFloat(form.quantityOnHand), reorderPoint: parseFloat(form.reorderPoint), unitCost: parseFloat(form.unitCost) }); }} className="space-y-3">
<Input label="Name *" value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} required />
<div className="grid grid-cols-2 gap-3">
<Input label="Part Number" value={form.partNumber} onChange={(e) => setForm({ ...form, partNumber: e.target.value })} />
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Type</label>
<select className="block w-full rounded-lg border border-gray-300 px-3 py-2 text-sm" value={form.itemType} onChange={(e) => setForm({ ...form, itemType: e.target.value })}>
<option value="parts">Parts</option>
<option value="supply">Supply</option>
<option value="consumable">Consumable</option>
</select>
</div>
</div>
<div className="grid grid-cols-3 gap-3">
<Input label="Qty On Hand" type="number" value={form.quantityOnHand} onChange={(e) => setForm({ ...form, quantityOnHand: e.target.value })} />
<Input label="Reorder Point" type="number" value={form.reorderPoint} onChange={(e) => setForm({ ...form, reorderPoint: e.target.value })} />
<Input label="Unit Cost" type="number" step="0.01" value={form.unitCost} onChange={(e) => setForm({ ...form, unitCost: e.target.value })} />
</div>
<div className="grid grid-cols-2 gap-3">
<Input label="Supplier" value={form.supplier} onChange={(e) => setForm({ ...form, supplier: e.target.value })} />
<Input label="Category" value={form.category} onChange={(e) => setForm({ ...form, category: e.target.value })} />
</div>
<div className="flex justify-end gap-2 pt-2">
<Button variant="secondary" type="button" onClick={onClose}>Cancel</Button>
<Button type="submit" disabled={saving}>{saving ? 'Saving...' : 'Save'}</Button>
</div>
</form>
</Modal>
);
}

View File

@@ -0,0 +1,312 @@
import { useState } from 'react';
import { useParams, Link, useNavigate } from 'react-router-dom';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { ArrowLeft, Download, Send, Trash2, DollarSign } from 'lucide-react';
import { api } from '../api/client';
import { Card, Button, Badge, Modal, Input } from '../components/ui';
import FileUpload from '../components/FileUpload';
import type { Invoice, Payment, LaborItem, PartsItem } from '../types';
const fmt = (n: number) => '$' + n.toFixed(2);
const statusLabel: Record<string, string> = {
draft: 'Draft', sent: 'Sent', paid: 'Paid', partial: 'Partial', overdue: 'Overdue',
};
export default function InvoiceDetail() {
const { id } = useParams();
const navigate = useNavigate();
const queryClient = useQueryClient();
const [showPaymentModal, setShowPaymentModal] = useState(false);
const { data: invoice, isLoading } = useQuery<Invoice>({
queryKey: ['invoices', id],
queryFn: () => api.getInvoice(Number(id)),
});
const invalidate = () => { queryClient.invalidateQueries({ queryKey: ['invoices', id] }); queryClient.invalidateQueries({ queryKey: ['invoices'] }); };
const statusMutation = useMutation({
mutationFn: (status: string) => api.updateInvoice(Number(id), { status }),
onSuccess: invalidate,
});
const deletePaymentMutation = useMutation({
mutationFn: (pid: number) => api.deletePayment(Number(id), pid),
onSuccess: invalidate,
});
const paymentMutation = useMutation({
mutationFn: (data: any) => api.addPayment(Number(id), data),
onSuccess: () => { invalidate(); setShowPaymentModal(false); },
});
const deleteMutation = useMutation({
mutationFn: () => api.deleteInvoice(Number(id)),
onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['invoices'] }); navigate('/invoices'); },
});
if (isLoading || !invoice) return <p className="text-gray-500 py-4">Loading...</p>;
const openPdf = () => {
const token = localStorage.getItem('autoking_token');
window.open(`/api/invoices/${id}/pdf?token=${token}`, '_blank');
};
return (
<div className="space-y-6">
<Link to="/invoices" className="inline-flex items-center gap-1.5 text-sm text-gray-500 hover:text-gray-700">
<ArrowLeft size={16} /> Back to Invoices
</Link>
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<div>
<div className="flex items-center gap-3">
<h1 className="text-2xl font-bold text-gray-900">{invoice.invoice_number}</h1>
<Badge status={invoice.status}>{statusLabel[invoice.status]}</Badge>
</div>
{invoice.shop_id ? (
<Link to={`/shops/${invoice.shop_id}`} className="text-sm text-blue-600 hover:underline">{(invoice as any).shop_name}</Link>
) : invoice.owner_id ? (
<Link to={`/owners/${invoice.owner_id}`} className="text-sm text-blue-600 hover:underline">{(invoice as any).owner_first_name} {(invoice as any).owner_last_name}</Link>
) : null}
<p className="text-sm text-gray-500 mt-0.5">
Issued: {new Date(invoice.invoice_date).toLocaleDateString()}
{invoice.due_date && ` — Due: ${new Date(invoice.due_date).toLocaleDateString()}`}
</p>
</div>
<div className="flex gap-2 flex-wrap">
<Button variant="secondary" onClick={openPdf}><Download size={16} /> PDF</Button>
{invoice.status === 'draft' && (
<Button onClick={() => statusMutation.mutate('sent')}><Send size={16} /> Mark Sent</Button>
)}
<Button variant="secondary" onClick={() => setShowPaymentModal(true)}><DollarSign size={16} /> Record Payment</Button>
<Button variant="danger" onClick={() => { if (confirm('Delete this invoice? Jobs will revert to completed.')) deleteMutation.mutate(); }}>
<Trash2 size={16} /> Delete
</Button>
</div>
</div>
{/* Bill To */}
<Card className="p-4">
<h3 className="text-xs font-medium text-gray-500 uppercase mb-2">Bill To</h3>
{invoice.shop_id ? (
<>
<p className="font-medium text-gray-900">{(invoice as any).shop_name}</p>
{(invoice as any).shop_contact && <p className="text-sm text-gray-700">Attn: {(invoice as any).shop_contact}</p>}
{(invoice as any).shop_address && <p className="text-sm text-gray-500">{(invoice as any).shop_address}</p>}
{((invoice as any).shop_city || (invoice as any).shop_state) && (
<p className="text-sm text-gray-500">{[(invoice as any).shop_city, (invoice as any).shop_state, (invoice as any).shop_zip].filter(Boolean).join(', ')}</p>
)}
</>
) : invoice.owner_id ? (
<>
<p className="font-medium text-gray-900">{(invoice as any).owner_first_name} {(invoice as any).owner_last_name}</p>
{(invoice as any).owner_phone && <p className="text-sm text-gray-500">{(invoice as any).owner_phone}</p>}
{(invoice as any).owner_email && <p className="text-sm text-gray-500">{(invoice as any).owner_email}</p>}
</>
) : null}
</Card>
{/* Jobs & Line Items */}
{invoice.jobs?.map((job: any) => (
<Card key={job.id}>
<div className="p-4 border-b border-gray-200">
<div className="flex items-center justify-between">
<div>
<Link to={`/jobs/${job.id}`} className="font-medium text-blue-600 hover:underline">{job.job_number}</Link>
<p className="text-sm text-gray-500">{[job.year, job.make, job.model].filter(Boolean).join(' ')} {job.first_name} {job.last_name}</p>
</div>
</div>
{job.description && <p className="text-sm text-gray-600 mt-1">{job.description}</p>}
</div>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<tbody className="divide-y divide-gray-100">
{job.laborItems?.map((item: LaborItem) => {
const lineTotal = item.billing_type === 'flat_rate' ? (item.flat_rate || 0) : (item.hours || 0) * (item.rate || 0);
return (
<tr key={`l-${item.id}`}>
<td className="px-4 py-2 text-gray-700">{item.description}</td>
<td className="px-4 py-2 text-gray-500 text-right">{item.billing_type === 'flat_rate' ? 'Flat rate' : `${item.hours}h @ ${fmt(item.rate || 0)}/hr`}</td>
<td className="px-4 py-2 text-right font-medium w-24">{fmt(lineTotal)}</td>
</tr>
);
})}
{job.partsItems?.map((item: PartsItem) => (
<tr key={`p-${item.id}`}>
<td className="px-4 py-2 text-gray-700">{item.description} {item.part_number && <span className="text-gray-400 text-xs">({item.part_number})</span>}</td>
<td className="px-4 py-2 text-gray-500 text-right">{item.quantity} x {fmt(item.unit_cost * (1 + item.markup))}</td>
<td className="px-4 py-2 text-right font-medium w-24">{fmt(item.quantity * item.unit_cost * (1 + item.markup))}</td>
</tr>
))}
</tbody>
</table>
</div>
</Card>
))}
{/* Totals */}
<Card className="p-4 space-y-2">
<div className="flex justify-between text-sm">
<span className="text-gray-600">Subtotal</span>
<span className="font-medium">{fmt(invoice.subtotal)}</span>
</div>
{invoice.tax_rate > 0 && (
<div className="flex justify-between text-sm">
<span className="text-gray-600">Tax ({(invoice.tax_rate * 100).toFixed(1)}%)</span>
<span className="font-medium">{fmt(invoice.tax_amount)}</span>
</div>
)}
<div className="flex justify-between text-lg font-bold border-t border-gray-200 pt-2">
<span>Total</span>
<span>{fmt(invoice.total)}</span>
</div>
{(invoice.amountPaid ?? 0) > 0 && (
<>
<div className="flex justify-between text-sm text-green-600">
<span>Paid</span>
<span>-{fmt(invoice.amountPaid!)}</span>
</div>
<div className="flex justify-between text-lg font-bold text-yellow-600">
<span>Amount Due</span>
<span>{fmt(invoice.amountDue || 0)}</span>
</div>
</>
)}
</Card>
{/* Payments */}
{invoice.payments && invoice.payments.length > 0 && (
<Card>
<div className="p-4 border-b border-gray-200">
<h2 className="font-semibold text-gray-900">Payments</h2>
</div>
<div className="divide-y divide-gray-100">
{invoice.payments.map((p: Payment) => (
<div key={p.id} className="flex items-center justify-between p-3">
<div>
<p className="text-sm font-medium text-gray-900">{fmt(p.amount)}</p>
<p className="text-xs text-gray-500">
{new Date(p.payment_date).toLocaleDateString()} {p.method || 'Unknown'}
{p.reference && ` (${p.reference})`}
</p>
</div>
<button onClick={() => deletePaymentMutation.mutate(p.id)} className="text-gray-400 hover:text-red-500">
<Trash2 size={14} />
</button>
</div>
))}
</div>
</Card>
)}
{/* Files */}
<Card className="p-4">
<FileUpload entityType="invoice" entityId={Number(id)} />
</Card>
<InvoiceNotes invoiceId={Number(id)} notes={invoice.notes || ''} onUpdate={invalidate} />
{/* Version History */}
{(invoice as any).versions?.length > 0 && (
<Card>
<div className="p-4 border-b border-gray-200">
<h2 className="font-semibold text-gray-900">Revision History</h2>
</div>
<div className="divide-y divide-gray-100">
{(invoice as any).versions.map((v: any) => (
<div key={v.id} className="p-3 text-sm">
<div className="flex items-center justify-between">
<span className="text-gray-500">v{v.version_number} {new Date(v.created_at).toLocaleString()}</span>
<span className="font-medium text-gray-700">{fmt(v.total)}</span>
</div>
{v.reason && <p className="text-xs text-gray-400 mt-0.5">{v.reason}</p>}
{v.total !== (invoice as any).versions[0]?.total && (
<p className="text-xs text-gray-400">
Subtotal: {fmt(v.subtotal)}{v.tax_amount > 0 && ` + tax: ${fmt(v.tax_amount)}`}
</p>
)}
</div>
))}
</div>
</Card>
)}
<PaymentModal open={showPaymentModal} onClose={() => setShowPaymentModal(false)} onSave={(data) => paymentMutation.mutate(data)} saving={paymentMutation.isPending} balanceDue={invoice.amountDue || invoice.total} />
</div>
);
}
function InvoiceNotes({ invoiceId, notes, onUpdate }: { invoiceId: number; notes: string; onUpdate: () => void }) {
const [editing, setEditing] = useState(false);
const [value, setValue] = useState(notes);
const mutation = useMutation({
mutationFn: (newNotes: string) => api.updateInvoice(invoiceId, { notes: newNotes }),
onSuccess: () => { onUpdate(); setEditing(false); },
});
return (
<Card className="p-4">
<div className="flex items-center justify-between mb-2">
<h3 className="text-xs font-medium text-gray-500 uppercase">Invoice Notes</h3>
{!editing && (
<button onClick={() => { setValue(notes); setEditing(true); }} className="text-xs text-blue-600 hover:text-blue-800">
{notes ? 'Edit' : 'Add notes'}
</button>
)}
</div>
{editing ? (
<div className="space-y-2">
<textarea
className="block w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 outline-none"
rows={4} value={value} onChange={(e) => setValue(e.target.value)}
placeholder="Describe what was done, process notes, etc. This prints on the PDF."
/>
<div className="flex gap-2">
<Button size="sm" onClick={() => mutation.mutate(value)} disabled={mutation.isPending}>
{mutation.isPending ? 'Saving...' : 'Save'}
</Button>
<Button size="sm" variant="ghost" onClick={() => setEditing(false)}>Cancel</Button>
</div>
</div>
) : notes ? (
<p className="text-sm text-gray-700 whitespace-pre-wrap">{notes}</p>
) : (
<p className="text-sm text-gray-400">No notes yet. Click "Add notes" to describe your process.</p>
)}
</Card>
);
}
function PaymentModal({ open, onClose, onSave, saving, balanceDue }: { open: boolean; onClose: () => void; onSave: (data: any) => void; saving: boolean; balanceDue: number }) {
const [amount, setAmount] = useState(String(balanceDue));
const [paymentDate, setPaymentDate] = useState(new Date().toISOString().split('T')[0]);
const [method, setMethod] = useState('check');
const [reference, setReference] = useState('');
return (
<Modal open={open} onClose={onClose} title="Record Payment">
<form onSubmit={(e) => { e.preventDefault(); onSave({ amount: parseFloat(amount), paymentDate, method, reference: reference || null }); }} className="space-y-3">
<Input label="Amount *" type="number" step="0.01" value={amount} onChange={(e) => setAmount(e.target.value)} required />
<Input label="Date *" type="date" value={paymentDate} onChange={(e) => setPaymentDate(e.target.value)} required />
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Method</label>
<select className="block w-full rounded-lg border border-gray-300 px-3 py-2 text-sm" value={method} onChange={(e) => setMethod(e.target.value)}>
<option value="check">Check</option>
<option value="cash">Cash</option>
<option value="card">Card</option>
<option value="ach">ACH</option>
<option value="other">Other</option>
</select>
</div>
<Input label="Reference (check #, etc.)" value={reference} onChange={(e) => setReference(e.target.value)} />
<div className="flex justify-end gap-2 pt-2">
<Button variant="secondary" type="button" onClick={onClose}>Cancel</Button>
<Button type="submit" disabled={saving}>{saving ? 'Saving...' : 'Record Payment'}</Button>
</div>
</form>
</Modal>
);
}

View File

@@ -0,0 +1,209 @@
import { useState } from 'react';
import { Link } from 'react-router-dom';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { FileText, Plus } from 'lucide-react';
import { api } from '../api/client';
import { Card, Button, Badge, Modal, EmptyState } from '../components/ui';
import type { Invoice, Shop, Owner, Job } from '../types';
const fmt = (n: number) => '$' + n.toFixed(2);
const statusLabel: Record<string, string> = {
draft: 'Draft', sent: 'Sent', paid: 'Paid', partial: 'Partial', overdue: 'Overdue',
};
const statusFilters = [
{ value: '', label: 'All' },
{ value: 'draft', label: 'Draft' },
{ value: 'sent', label: 'Sent' },
{ value: 'partial', label: 'Partial' },
{ value: 'overdue', label: 'Overdue' },
{ value: 'paid', label: 'Paid' },
];
export default function Invoices() {
const [statusFilter, setStatusFilter] = useState('');
const [showNewInvoice, setShowNewInvoice] = useState(false);
const queryClient = useQueryClient();
const { data: invoices = [], isLoading } = useQuery({
queryKey: ['invoices', statusFilter],
queryFn: () => api.getInvoices({ status: statusFilter || undefined }),
});
const createMutation = useMutation({
mutationFn: api.createInvoice,
onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['invoices'] }); queryClient.invalidateQueries({ queryKey: ['jobs'] }); setShowNewInvoice(false); },
});
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold text-gray-900">Invoices</h1>
<Button onClick={() => setShowNewInvoice(true)}><Plus size={16} /> New Invoice</Button>
</div>
<div className="flex gap-1 flex-wrap">
{statusFilters.map((s) => (
<button
key={s.value}
onClick={() => setStatusFilter(s.value)}
className={`px-3 py-1.5 text-xs font-medium rounded-lg transition-colors ${
statusFilter === s.value ? 'bg-blue-100 text-blue-700' : 'bg-gray-100 text-gray-600 hover:bg-gray-200'
}`}
>
{s.label}
</button>
))}
</div>
{isLoading ? <p className="text-gray-500 py-4">Loading...</p> : invoices.length === 0 ? (
<EmptyState icon={FileText} title="No invoices" description="Create an invoice from completed jobs." action={<Button onClick={() => setShowNewInvoice(true)}>New Invoice</Button>} />
) : (
<Card>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-gray-200 text-left text-xs text-gray-500 uppercase">
<th className="px-4 py-3">Invoice</th>
<th className="px-4 py-3">Bill To</th>
<th className="px-4 py-3">Date</th>
<th className="px-4 py-3 text-right">Total</th>
<th className="px-4 py-3 text-right">Due</th>
<th className="px-4 py-3">Status</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{invoices.map((inv: Invoice) => (
<tr key={inv.id} className="hover:bg-gray-50">
<td className="px-4 py-3">
<Link to={`/invoices/${inv.id}`} className="font-medium text-blue-600 hover:underline">{inv.invoice_number}</Link>
</td>
<td className="px-4 py-3 text-gray-700">
{inv.display_name || inv.shop_name || (inv.owner_first_name ? `${inv.owner_first_name} ${inv.owner_last_name}` : '—')}
</td>
<td className="px-4 py-3 text-gray-500">{new Date(inv.invoice_date).toLocaleDateString()}</td>
<td className="px-4 py-3 text-right font-medium">{fmt(inv.total)}</td>
<td className="px-4 py-3 text-right font-medium text-yellow-600">{fmt(inv.amountDue || 0)}</td>
<td className="px-4 py-3"><Badge status={inv.status}>{statusLabel[inv.status]}</Badge></td>
</tr>
))}
</tbody>
</table>
</div>
</Card>
)}
<NewInvoiceModal open={showNewInvoice} onClose={() => setShowNewInvoice(false)} onSave={(data) => createMutation.mutate(data)} saving={createMutation.isPending} />
</div>
);
}
function NewInvoiceModal({ open, onClose, onSave, saving }: { open: boolean; onClose: () => void; onSave: (data: any) => void; saving: boolean }) {
const [billType, setBillType] = useState<'shop' | 'owner'>('shop');
const [shopId, setShopId] = useState('');
const [ownerId, setOwnerId] = useState('');
const [selectedJobs, setSelectedJobs] = useState<number[]>([]);
const [notes, setNotes] = useState('');
const { data: shops = [] } = useQuery({ queryKey: ['shops'], queryFn: () => api.getShops({ active: 1 }) });
const { data: owners = [] } = useQuery({ queryKey: ['owners'], queryFn: () => api.getOwners() });
const selectedId = billType === 'shop' ? shopId : ownerId;
const jobQueryParams = billType === 'shop'
? { status: 'completed', shopId: Number(shopId) }
: { status: 'completed' };
const { data: allJobs = [] } = useQuery({
queryKey: ['jobs', 'completed', billType, selectedId],
queryFn: () => api.getJobs(jobQueryParams),
enabled: billType === 'shop' ? !!shopId : !!ownerId,
});
// Filter owner jobs client-side (jobs where owner_id matches and customer_type is 'owner')
const jobs = billType === 'owner'
? allJobs.filter((j: Job) => j.owner_id === Number(ownerId) && j.customer_type === 'owner')
: allJobs;
const toggleJob = (id: number) => {
setSelectedJobs((prev) => prev.includes(id) ? prev.filter((j) => j !== id) : [...prev, id]);
};
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
const data: any = { jobIds: selectedJobs, notes: notes || undefined };
if (billType === 'shop') data.shopId = Number(shopId);
else data.ownerId = Number(ownerId);
onSave(data);
};
return (
<Modal open={open} onClose={onClose} title="Create Invoice">
<form onSubmit={handleSubmit} className="space-y-4">
{/* Bill type toggle */}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Invoice Type</label>
<div className="flex gap-2">
<button type="button" onClick={() => { setBillType('shop'); setSelectedJobs([]); setOwnerId(''); }}
className={`flex-1 px-3 py-2 text-sm font-medium rounded-lg border transition-colors ${billType === 'shop' ? 'bg-blue-50 border-blue-300 text-blue-700' : 'border-gray-300 text-gray-600 hover:bg-gray-50'}`}>
Shop
</button>
<button type="button" onClick={() => { setBillType('owner'); setSelectedJobs([]); setShopId(''); }}
className={`flex-1 px-3 py-2 text-sm font-medium rounded-lg border transition-colors ${billType === 'owner' ? 'bg-blue-50 border-blue-300 text-blue-700' : 'border-gray-300 text-gray-600 hover:bg-gray-50'}`}>
Customer
</button>
</div>
</div>
{billType === 'shop' ? (
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Shop *</label>
<select className="block w-full rounded-lg border border-gray-300 px-3 py-2 text-sm" value={shopId} onChange={(e) => { setShopId(e.target.value); setSelectedJobs([]); }} required>
<option value="">Select a shop...</option>
{shops.map((s: Shop) => <option key={s.id} value={s.id}>{s.name}</option>)}
</select>
</div>
) : (
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Customer *</label>
<select className="block w-full rounded-lg border border-gray-300 px-3 py-2 text-sm" value={ownerId} onChange={(e) => { setOwnerId(e.target.value); setSelectedJobs([]); }} required>
<option value="">Select a customer...</option>
{owners.map((o: Owner) => <option key={o.id} value={o.id}>{o.first_name} {o.last_name}</option>)}
</select>
</div>
)}
{(billType === 'shop' ? shopId : ownerId) && (
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Completed Jobs *</label>
{jobs.length === 0 ? (
<p className="text-sm text-gray-500">No completed jobs for this {billType === 'shop' ? 'shop' : 'customer'}.</p>
) : (
<div className="border border-gray-200 rounded-lg divide-y divide-gray-100 max-h-60 overflow-y-auto">
{jobs.map((job: Job) => (
<label key={job.id} className="flex items-center gap-3 p-3 hover:bg-gray-50 cursor-pointer">
<input type="checkbox" checked={selectedJobs.includes(job.id)} onChange={() => toggleJob(job.id)} className="rounded" />
<div className="flex-1">
<p className="text-sm font-medium text-gray-900">{job.job_number}</p>
<p className="text-xs text-gray-500">{[job.year, job.make, job.model].filter(Boolean).join(' ')} {job.first_name} {job.last_name}</p>
</div>
</label>
))}
</div>
)}
</div>
)}
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Notes</label>
<textarea className="block w-full rounded-lg border border-gray-300 px-3 py-2 text-sm" rows={2} value={notes} onChange={(e) => setNotes(e.target.value)} />
</div>
<div className="flex justify-end gap-2 pt-2">
<Button variant="secondary" type="button" onClick={onClose}>Cancel</Button>
<Button type="submit" disabled={saving || selectedJobs.length === 0}>{saving ? 'Creating...' : 'Create Invoice'}</Button>
</div>
</form>
</Modal>
);
}

View File

@@ -0,0 +1,972 @@
import { useState } from 'react';
import { useParams, Link, useNavigate } from 'react-router-dom';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { ArrowLeft, Plus, Trash2, FileText, AlertTriangle, Pencil, Eye, EyeOff } from 'lucide-react';
import { api } from '../api/client';
import { Card, Button, Input, Modal } from '../components/ui';
import TagPicker from '../components/TagPicker';
import FileUpload from '../components/FileUpload';
import InspectionPanel from '../components/InspectionPanel';
import type { Job, LaborItem, PartsItem, JobNote, JobStatus } from '../types';
const fmt = (n: number) => '$' + n.toFixed(2);
const statusLabel: Record<string, string> = {
received: 'Received', in_progress: 'In Progress', completed: 'Completed', invoiced: 'Invoiced', closed: 'Closed',
};
const statusSteps: { status: JobStatus; label: string; color: string; activeColor: string }[] = [
{ status: 'received', label: 'Received', color: 'bg-gray-200', activeColor: 'bg-blue-500' },
{ status: 'in_progress', label: 'In Progress', color: 'bg-gray-200', activeColor: 'bg-yellow-500' },
{ status: 'completed', label: 'Completed', color: 'bg-gray-200', activeColor: 'bg-green-500' },
{ status: 'invoiced', label: 'Invoiced', color: 'bg-gray-200', activeColor: 'bg-purple-500' },
{ status: 'closed', label: 'Closed', color: 'bg-gray-200', activeColor: 'bg-gray-500' },
];
function StatusTracker({ currentStatus, onChangeStatus, disabled }: { currentStatus: string; onChangeStatus: (s: string) => void; disabled: boolean }) {
const currentIdx = statusSteps.findIndex((s) => s.status === currentStatus);
return (
<div className="flex rounded-xl overflow-hidden border border-gray-200 shadow-sm">
{statusSteps.map((step, idx) => {
const isActive = idx <= currentIdx;
const isCurrent = idx === currentIdx;
const bgColor = isActive
? step.status === 'received' ? 'bg-blue-500'
: step.status === 'in_progress' ? 'bg-yellow-500'
: step.status === 'completed' ? 'bg-green-500'
: step.status === 'invoiced' ? 'bg-purple-500'
: 'bg-gray-500'
: 'bg-gray-100';
const textColor = isActive ? 'text-white' : 'text-gray-400';
return (
<button
key={step.status}
onClick={() => { if (!disabled && step.status !== currentStatus) onChangeStatus(step.status); }}
disabled={disabled}
className={`flex-1 py-2.5 px-2 text-center text-xs font-semibold transition-all relative ${bgColor} ${textColor} ${
disabled ? 'cursor-wait' : 'cursor-pointer hover:brightness-110'
} ${isCurrent ? 'ring-2 ring-inset ring-white/40' : ''}`}
>
{step.label}
</button>
);
})}
</div>
);
}
function MileageField({ label, value, onSave }: { label: string; value: number | null; onSave: (v: number | null) => void }) {
const [editing, setEditing] = useState(false);
const [input, setInput] = useState(value?.toString() || '');
if (editing) {
return (
<span className="inline-flex items-center gap-1">
{label}:
<input className="w-20 border border-gray-300 rounded px-1.5 py-0.5 text-xs focus:border-blue-500 outline-none"
type="number" value={input} onChange={(e) => setInput(e.target.value)} autoFocus
onBlur={() => { onSave(input ? parseInt(input) : null); setEditing(false); }}
onKeyDown={(e) => { if (e.key === 'Enter') { onSave(input ? parseInt(input) : null); setEditing(false); } if (e.key === 'Escape') setEditing(false); }}
/>
</span>
);
}
return (
<span className="cursor-pointer hover:text-blue-600" onClick={() => { setInput(value?.toString() || ''); setEditing(true); }}>
{label}: <span className="font-medium">{value ? value.toLocaleString() : 'click to set'}</span>
</span>
);
}
export default function JobDetail() {
const { id } = useParams();
const navigate = useNavigate();
const queryClient = useQueryClient();
const [showLaborModal, setShowLaborModal] = useState(false);
const [showPartsModal, setShowPartsModal] = useState(false);
const [editLabor, setEditLabor] = useState<LaborItem | null>(null);
const [editParts, setEditParts] = useState<PartsItem | null>(null);
const [newNote, setNewNote] = useState('');
const [noteVisibility, setNoteVisibility] = useState<'internal' | 'customer'>('internal');
const [noteTab, setNoteTab] = useState<'all' | 'internal' | 'customer'>('all');
const [editingNoteId, setEditingNoteId] = useState<number | null>(null);
const [editNoteText, setEditNoteText] = useState('');
const [editNoteVis, setEditNoteVis] = useState<'internal' | 'customer'>('internal');
const [newRec, setNewRec] = useState('');
const [newRecUrgency, setNewRecUrgency] = useState('soon');
const [newRecCost, setNewRecCost] = useState('');
const [newRecNotes, setNewRecNotes] = useState('');
const [showEditRecModal, setShowEditRecModal] = useState(false);
const [editRecData, setEditRecData] = useState<any>(null);
const { data: job, isLoading } = useQuery<Job>({
queryKey: ['jobs', id],
queryFn: () => api.getJob(Number(id)),
});
const invalidate = () => queryClient.invalidateQueries({ queryKey: ['jobs', id] });
const statusMutation = useMutation({
mutationFn: (status: string) => api.updateJob(Number(id), { status }),
onSuccess: invalidate,
});
const [showMiscModal, setShowMiscModal] = useState(false);
const [editMisc, setEditMisc] = useState<any>(null);
const deleteLaborMutation = useMutation({
mutationFn: (lid: number) => api.deleteLabor(Number(id), lid),
onSuccess: invalidate,
});
const deletePartsMutation = useMutation({
mutationFn: (pid: number) => api.deleteParts(Number(id), pid),
onSuccess: invalidate,
});
const deleteMiscMutation = useMutation({
mutationFn: (mid: number) => api.deleteMisc(Number(id), mid),
onSuccess: invalidate,
});
const toggleHidden = (type: 'labor' | 'parts' | 'misc', itemId: number, item: any) => {
const newHidden = item.hidden ? 0 : 1;
const fn = type === 'labor' ? api.updateLabor(Number(id), itemId, { ...item, billingType: item.billing_type, flatRate: item.flat_rate, hidden: newHidden })
: type === 'parts' ? api.updateParts(Number(id), itemId, { ...item, partNumber: item.part_number, unitCost: item.unit_cost, chargePrice: item.charge_price, hidden: newHidden })
: api.updateMisc(Number(id), itemId, { ...item, yourCost: item.your_cost, chargePrice: item.charge_price, hidden: newHidden });
fn.then(invalidate);
};
const addNoteMutation = useMutation({
mutationFn: ({ note, visibility }: { note: string; visibility: 'internal' | 'customer' }) => api.addNote(Number(id), note, visibility),
onSuccess: () => { invalidate(); setNewNote(''); },
});
const updateNoteMutation = useMutation({
mutationFn: ({ nid, data }: { nid: number; data: { note: string; visibility: string } }) => api.updateNote(Number(id), nid, data),
onSuccess: () => { invalidate(); setEditingNoteId(null); },
});
const deleteNoteMutation = useMutation({
mutationFn: (nid: number) => api.deleteNote(Number(id), nid),
onSuccess: invalidate,
});
const addRecMutation = useMutation({
mutationFn: (data: any) => api.addRecommendation(Number(id), data),
onSuccess: () => { invalidate(); setNewRec(''); setNewRecCost(''); setNewRecUrgency('soon'); },
});
const updateRecMutation = useMutation({
mutationFn: ({ rid, data }: { rid: number; data: any }) => api.updateRecommendation(Number(id), rid, data),
onSuccess: () => { invalidate(); setShowEditRecModal(false); setEditRecData(null); },
});
const deleteRecMutation = useMutation({
mutationFn: (rid: number) => api.deleteRecommendation(Number(id), rid),
onSuccess: invalidate,
});
const createEstimateFromRecMutation = useMutation({
mutationFn: (rid: number) => api.createEstimateFromRec(Number(id), rid),
onSuccess: (data: any) => { invalidate(); if (!data.existing) navigate(`/estimates/${data.estimateId}`); },
});
const invoiceJobMutation = useMutation({
mutationFn: () => api.invoiceJob(Number(id)),
onSuccess: (invoice: any) => { invalidate(); navigate(`/invoices/${invoice.id}`); },
});
if (isLoading || !job) return <p className="text-gray-500 py-4">Loading...</p>;
const vehicleStr = [job.year, job.make, job.model].filter(Boolean).join(' ');
return (
<div className="space-y-6">
<Link to="/jobs" className="inline-flex items-center gap-1.5 text-sm text-gray-500 hover:text-gray-700">
<ArrowLeft size={16} /> Back to Jobs
</Link>
<div className="flex flex-col sm:flex-row sm:items-start justify-between gap-4">
<div>
<h1 className="text-2xl font-bold text-gray-900">{job.job_number}</h1>
<p className="text-gray-700 mt-1">{vehicleStr} {job.first_name} {job.last_name}</p>
{job.shop_id && job.shop_name ? (
<Link to={`/shops/${job.shop_id}`} className="text-sm text-blue-600 hover:underline">{job.shop_name}</Link>
) : (
<p className="text-sm text-gray-500">Direct customer</p>
)}
</div>
<div className="flex gap-2">
{(job as any).invoiceId ? (
<>
<Button variant="secondary" onClick={() => invoiceJobMutation.mutate()} disabled={invoiceJobMutation.isPending}>
<FileText size={16} /> {invoiceJobMutation.isPending ? 'Updating...' : 'Update Invoice'}
</Button>
<Link to={`/invoices/${(job as any).invoiceId}`}>
<Button variant="ghost" size="sm">View Invoice</Button>
</Link>
</>
) : job.status === 'completed' ? (
<Button variant="secondary" onClick={() => invoiceJobMutation.mutate()} disabled={invoiceJobMutation.isPending}>
<FileText size={16} /> {invoiceJobMutation.isPending ? 'Creating...' : 'Invoice This Job'}
</Button>
) : null}
</div>
</div>
{/* Status Tracker */}
<StatusTracker currentStatus={job.status} onChangeStatus={(s) => statusMutation.mutate(s)} disabled={statusMutation.isPending} />
{/* Tags */}
<TagPicker jobId={Number(id)} tags={job.tags || []} />
{job.description && (
<Card className="p-4">
<h3 className="text-xs font-medium text-gray-500 uppercase mb-1">Description</h3>
<p className="text-sm text-gray-700">{job.description}</p>
</Card>
)}
{/* Vehicle Info + Mileage */}
<div className="flex flex-wrap gap-4 text-xs text-gray-500">
{job.vin && <span>VIN: <span className="font-mono">{job.vin}</span></span>}
<MileageField label="Miles In" value={(job as any).mileage_in} onSave={(v) => api.updateJob(Number(id), { mileageIn: v }).then(invalidate)} />
<MileageField label="Miles Out" value={(job as any).mileage_out} onSave={(v) => api.updateJob(Number(id), { mileageOut: v }).then(invalidate)} />
</div>
{/* Labor Items */}
<Card>
<div className="p-4 border-b border-gray-200 flex items-center justify-between">
<h2 className="font-semibold text-gray-900">Labor</h2>
<Button variant="ghost" size="sm" onClick={() => { setEditLabor(null); setShowLaborModal(true); }}>
<Plus size={14} /> Add Labor
</Button>
</div>
{job.laborItems && job.laborItems.length > 0 ? (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-gray-200 text-left text-xs text-gray-500">
<th className="px-4 py-2">Description</th>
<th className="px-4 py-2 text-right">Details</th>
<th className="px-4 py-2 text-right">Total</th>
<th className="px-4 py-2 w-10"></th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{job.laborItems.map((item: LaborItem) => {
const lineTotal = item.billing_type === 'flat_rate' ? (item.flat_rate || 0) : (item.hours || 0) * (item.rate || 0);
const isHidden = !!(item as any).hidden;
return (
<tr key={item.id} className={`hover:bg-gray-50 ${isHidden ? 'opacity-40' : ''}`}>
<td className="px-4 py-2 text-gray-700">{item.description} {isHidden && <span className="text-[10px] text-gray-400">(hidden from invoice)</span>}</td>
<td className="px-4 py-2 text-right text-gray-700">
{item.billing_type === 'flat_rate' ? <span className="text-xs text-gray-500">Flat rate</span> : `${item.hours}h @ ${fmt(item.rate || 0)}/hr`}
</td>
<td className="px-4 py-2 text-right font-medium">{fmt(lineTotal)}</td>
<td className="px-4 py-2">
<div className="flex gap-1">
<button onClick={() => toggleHidden('labor', item.id, item)} className={isHidden ? 'text-gray-300 hover:text-gray-500' : 'text-gray-400 hover:text-gray-600'} title={isHidden ? 'Show on invoice' : 'Hide from invoice'}>
{isHidden ? <EyeOff size={14} /> : <Eye size={14} />}
</button>
<button onClick={() => { setEditLabor(item); setShowLaborModal(true); }} className="text-gray-400 hover:text-blue-500">
<Pencil size={14} />
</button>
<button onClick={() => deleteLaborMutation.mutate(item.id)} className="text-gray-400 hover:text-red-500">
<Trash2 size={14} />
</button>
</div>
</td>
</tr>
);
})}
</tbody>
<tfoot>
<tr className="border-t border-gray-200 font-medium">
<td colSpan={3} className="px-4 py-2 text-right text-gray-700">Labor Total</td>
<td className="px-4 py-2 text-right">{fmt(job.laborTotal || 0)}</td>
<td></td>
</tr>
</tfoot>
</table>
</div>
) : (
<p className="p-4 text-sm text-gray-500">No labor items added yet.</p>
)}
</Card>
{/* Parts Items */}
<Card>
<div className="p-4 border-b border-gray-200 flex items-center justify-between">
<h2 className="font-semibold text-gray-900">Parts</h2>
<Button variant="ghost" size="sm" onClick={() => { setEditParts(null); setShowPartsModal(true); }}>
<Plus size={14} /> Add Parts
</Button>
</div>
{job.partsItems && job.partsItems.length > 0 ? (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-gray-200 text-left text-xs text-gray-500">
<th className="px-4 py-2">Description</th>
<th className="px-4 py-2">Part #</th>
<th className="px-4 py-2 text-right">Qty</th>
<th className="px-4 py-2 text-right">Cost</th>
<th className="px-4 py-2 text-right">Markup</th>
<th className="px-4 py-2 text-right">Total</th>
<th className="px-4 py-2 w-10"></th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{job.partsItems.map((item: PartsItem) => {
const calcPrice = item.quantity * item.unit_cost * (1 + item.markup);
const chargedPrice = item.charge_price !== null && item.charge_price !== undefined ? item.charge_price : calcPrice;
const isOverridden = item.charge_price !== null && item.charge_price !== undefined;
const isHidden = !!(item as any).hidden;
return (
<tr key={item.id} className={`hover:bg-gray-50 ${isHidden ? 'opacity-40' : ''}`}>
<td className="px-4 py-2 text-gray-700">{item.description} {isHidden && <span className="text-[10px] text-gray-400">(hidden)</span>}</td>
<td className="px-4 py-2 text-gray-500 text-xs font-mono">{item.part_number || '—'}</td>
<td className="px-4 py-2 text-right text-gray-700">{item.quantity}</td>
<td className="px-4 py-2 text-right text-gray-700">{fmt(item.unit_cost)}</td>
<td className="px-4 py-2 text-right text-gray-700">{(item.markup * 100).toFixed(0)}%</td>
<td className="px-4 py-2 text-right font-medium">
{fmt(chargedPrice)}
{isOverridden && <span className="block text-[10px] text-gray-400 line-through">{fmt(calcPrice)}</span>}
</td>
<td className="px-4 py-2">
<div className="flex gap-1">
<button onClick={() => toggleHidden('parts', item.id, item)} className={isHidden ? 'text-gray-300 hover:text-gray-500' : 'text-gray-400 hover:text-gray-600'} title={isHidden ? 'Show on invoice' : 'Hide from invoice'}>
{isHidden ? <EyeOff size={14} /> : <Eye size={14} />}
</button>
<button onClick={() => { setEditParts(item); setShowPartsModal(true); }} className="text-gray-400 hover:text-blue-500">
<Pencil size={14} />
</button>
<button onClick={() => deletePartsMutation.mutate(item.id)} className="text-gray-400 hover:text-red-500">
<Trash2 size={14} />
</button>
</div>
</td>
</tr>
);
})}
</tbody>
<tfoot>
<tr className="border-t border-gray-200 font-medium">
<td colSpan={5} className="px-4 py-2 text-right text-gray-700">Parts Total</td>
<td className="px-4 py-2 text-right">{fmt(job.partsTotal || 0)}</td>
<td></td>
</tr>
</tfoot>
</table>
</div>
) : (
<p className="p-4 text-sm text-gray-500">No parts items added yet.</p>
)}
</Card>
{/* Misc/Sublet Items */}
<Card>
<div className="p-4 border-b border-gray-200 flex items-center justify-between">
<h2 className="font-semibold text-gray-900">Sublet / Misc</h2>
<Button variant="ghost" size="sm" onClick={() => { setEditMisc(null); setShowMiscModal(true); }}>
<Plus size={14} /> Add
</Button>
</div>
{(job as any).miscItems && (job as any).miscItems.length > 0 ? (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-gray-200 text-left text-xs text-gray-500">
<th className="px-4 py-2">Description</th>
<th className="px-4 py-2">Category</th>
<th className="px-4 py-2 text-right">Your Cost</th>
<th className="px-4 py-2 text-right">Charged</th>
<th className="px-4 py-2 w-16"></th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{(job as any).miscItems.map((item: any) => (
<tr key={item.id} className={`hover:bg-gray-50 ${item.hidden ? 'opacity-40' : ''}`}>
<td className="px-4 py-2 text-gray-700">{item.description} {!!item.hidden && <span className="text-[10px] text-gray-400">(hidden)</span>}</td>
<td className="px-4 py-2 text-gray-500 text-xs capitalize">{item.category}</td>
<td className="px-4 py-2 text-right text-gray-500">{fmt(item.your_cost)}</td>
<td className="px-4 py-2 text-right font-medium">{fmt(item.charge_price)}</td>
<td className="px-4 py-2">
<div className="flex gap-1">
<button onClick={() => toggleHidden('misc', item.id, item)} className={item.hidden ? 'text-gray-300 hover:text-gray-500' : 'text-gray-400 hover:text-gray-600'} title={item.hidden ? 'Show on invoice' : 'Hide from invoice'}>
{item.hidden ? <EyeOff size={14} /> : <Eye size={14} />}
</button>
<button onClick={() => { setEditMisc(item); setShowMiscModal(true); }} className="text-gray-400 hover:text-blue-500">
<Pencil size={14} />
</button>
<button onClick={() => deleteMiscMutation.mutate(item.id)} className="text-gray-400 hover:text-red-500">
<Trash2 size={14} />
</button>
</div>
</td>
</tr>
))}
</tbody>
<tfoot>
<tr className="border-t border-gray-200 font-medium">
<td colSpan={2} className="px-4 py-2 text-right text-gray-500 text-xs">Cost: {fmt((job as any).miscCost || 0)}</td>
<td className="px-4 py-2"></td>
<td className="px-4 py-2 text-right">{fmt((job as any).miscTotal || 0)}</td>
<td></td>
</tr>
</tfoot>
</table>
</div>
) : (
<p className="p-4 text-sm text-gray-500">No sublet or misc costs. Tows, outside work, etc.</p>
)}
</Card>
{/* Job Total */}
<Card className="p-4">
<div className="flex justify-between items-center">
<span className="text-lg font-semibold text-gray-900">Job Total</span>
<span className="text-2xl font-bold text-gray-900">{fmt(job.total || 0)}</span>
</div>
</Card>
{/* Recommendations */}
<Card>
<div className="p-4 border-b border-gray-200 flex items-center justify-between">
<h2 className="font-semibold text-gray-900 flex items-center gap-2"><AlertTriangle size={16} /> Recommendations</h2>
</div>
<div className="p-4 space-y-3">
{/* Add form */}
<form onSubmit={(e) => { e.preventDefault(); if (newRec.trim()) addRecMutation.mutate({ description: newRec.trim(), urgency: newRecUrgency, estimatedCost: newRecCost ? parseFloat(newRecCost) : null, notes: newRecNotes.trim() || null }); setNewRecNotes(''); }} className="space-y-2">
<div className="flex gap-2">
<input className="flex-1 border border-gray-300 rounded-lg px-3 py-2 text-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 outline-none" placeholder="e.g. CV boots torn, recommend replacing..." value={newRec} onChange={(e) => setNewRec(e.target.value)} />
<Button type="submit" size="sm" disabled={!newRec.trim()}>Add</Button>
</div>
<div className="flex gap-2">
<div className="flex gap-1">
{(['now', 'soon', 'monitor'] as const).map((u) => (
<button key={u} type="button" onClick={() => setNewRecUrgency(u)}
className={`px-2 py-1 text-xs rounded-lg border transition-colors ${newRecUrgency === u
? u === 'now' ? 'bg-red-50 border-red-300 text-red-700'
: u === 'soon' ? 'bg-yellow-50 border-yellow-300 text-yellow-700'
: 'bg-blue-50 border-blue-300 text-blue-700'
: 'border-gray-300 text-gray-500 hover:bg-gray-50'}`}>
{u === 'now' ? 'Needs Now' : u === 'soon' ? 'Soon' : 'Monitor'}
</button>
))}
</div>
<input className="w-28 border border-gray-300 rounded-lg px-2 py-1 text-xs focus:border-blue-500 outline-none" type="number" step="0.01" placeholder="Est. cost" value={newRecCost} onChange={(e) => setNewRecCost(e.target.value)} />
</div>
<input className="w-full border border-gray-300 rounded-lg px-3 py-1.5 text-xs focus:border-blue-500 outline-none" placeholder="Notes (optional)..." value={newRecNotes} onChange={(e) => setNewRecNotes(e.target.value)} />
</form>
{/* List */}
{(job as any).recommendations?.length > 0 ? (
<div className="space-y-2">
{(job as any).recommendations.map((rec: any) => (
<div key={rec.id} className={`rounded-lg p-3 ${
rec.urgency === 'now' ? 'bg-red-50' : rec.urgency === 'soon' ? 'bg-yellow-50' : 'bg-blue-50'
}`}>
<div className="flex items-start justify-between">
<div className="flex-1">
<p className="text-sm text-gray-700">{rec.description}</p>
<div className="flex items-center gap-2 mt-1 flex-wrap">
<span className={`text-xs font-medium ${
rec.urgency === 'now' ? 'text-red-600' : rec.urgency === 'soon' ? 'text-yellow-600' : 'text-blue-600'
}`}>
{rec.urgency === 'now' ? 'Needs Now' : rec.urgency === 'soon' ? 'Soon' : 'Monitor'}
</span>
{rec.estimate_total != null ? (
<span className="text-xs font-medium text-gray-700">{fmt(rec.estimate_total)} <span className="text-gray-400">({rec.estimate_number} {rec.estimate_status})</span></span>
) : rec.estimated_cost ? (
<span className="text-xs text-gray-500">~{fmt(rec.estimated_cost)}</span>
) : null}
</div>
</div>
<div className="flex gap-1 ml-2 mt-0.5">
<button onClick={() => { setEditRecData(rec); setShowEditRecModal(true); }} className="text-gray-400 hover:text-blue-500">
<Pencil size={14} />
</button>
<button onClick={() => deleteRecMutation.mutate(rec.id)} className="text-gray-400 hover:text-red-500">
<Trash2 size={14} />
</button>
</div>
</div>
{rec.notes && <p className="text-xs text-gray-500 mt-1">{rec.notes}</p>}
<div className="mt-2 flex items-center gap-3">
{rec.estimate_id ? (
<Link to={`/estimates/${rec.estimate_id}`} className="text-xs text-blue-600 hover:underline font-medium">
View Estimate
</Link>
) : rec.status === 'info_only' ? (
<span className="text-xs text-gray-400 italic">For customer awareness not a service we offer</span>
) : (
<>
<button
onClick={() => createEstimateFromRecMutation.mutate(rec.id)}
disabled={createEstimateFromRecMutation.isPending}
className="text-xs text-blue-600 hover:text-blue-800 font-medium"
>
{createEstimateFromRecMutation.isPending ? 'Creating...' : 'Create Estimate'}
</button>
<span className="text-gray-300">|</span>
<button
onClick={() => updateRecMutation.mutate({ rid: rec.id, data: { description: rec.description, urgency: rec.urgency, estimatedCost: rec.estimated_cost, notes: rec.notes, status: 'info_only' } })}
className="text-xs text-gray-400 hover:text-gray-600"
>
Not our service
</button>
</>
)}
</div>
</div>
))}
</div>
) : (
<p className="text-sm text-gray-400">No recommendations yet.</p>
)}
</div>
</Card>
{/* Inspections */}
<InspectionPanel jobId={Number(id)} />
{/* Files */}
<Card className="p-4">
<FileUpload entityType="job" entityId={Number(id)} />
</Card>
{/* Notes */}
<Card>
<div className="p-4 border-b border-gray-200">
<h2 className="font-semibold text-gray-900">Notes</h2>
</div>
<div className="p-4">
{/* Add note form */}
<form onSubmit={(e) => { e.preventDefault(); if (newNote.trim()) addNoteMutation.mutate({ note: newNote.trim(), visibility: noteVisibility }); }} className="space-y-2 mb-4">
<div className="flex gap-2">
<input className="flex-1 border border-gray-300 rounded-lg px-3 py-2 text-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 outline-none" placeholder="Add a note..." value={newNote} onChange={(e) => setNewNote(e.target.value)} />
<Button type="submit" size="sm" disabled={!newNote.trim()}>Add</Button>
</div>
<div className="flex gap-2">
<button type="button" onClick={() => setNoteVisibility('internal')}
className={`px-2.5 py-1 text-xs rounded-lg border transition-colors ${noteVisibility === 'internal' ? 'bg-gray-700 border-gray-700 text-white' : 'border-gray-300 text-gray-500 hover:bg-gray-50'}`}>
Internal
</button>
<button type="button" onClick={() => setNoteVisibility('customer')}
className={`px-2.5 py-1 text-xs rounded-lg border transition-colors ${noteVisibility === 'customer' ? 'bg-blue-500 border-blue-500 text-white' : 'border-gray-300 text-gray-500 hover:bg-gray-50'}`}>
Customer-facing
</button>
</div>
</form>
{/* Filter tabs */}
<div className="flex gap-1 mb-3">
{([['all', 'All'], ['internal', 'Internal'], ['customer', 'Customer']] as const).map(([key, label]) => (
<button key={key} onClick={() => setNoteTab(key)}
className={`px-2.5 py-1 text-xs font-medium rounded-lg transition-colors ${noteTab === key ? 'bg-blue-100 text-blue-700' : 'bg-gray-100 text-gray-600 hover:bg-gray-200'}`}>
{label}
</button>
))}
</div>
{/* Notes list */}
<div className="space-y-3">
{((job as any).notes as JobNote[])?.filter?.((note: any) => noteTab === 'all' || note.visibility === noteTab)?.map?.((note: any) => (
<div key={note.id} className={`rounded-lg p-3 ${
note.visibility === 'customer' ? 'bg-blue-50 border border-blue-100' : 'bg-gray-50'
}`}>
{editingNoteId === note.id ? (
<div className="space-y-2">
<textarea className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:border-blue-500 outline-none"
rows={2} value={editNoteText} onChange={(e) => setEditNoteText(e.target.value)} autoFocus />
<div className="flex items-center gap-2">
<button type="button" onClick={() => setEditNoteVis('internal')}
className={`px-2 py-1 text-xs rounded border ${editNoteVis === 'internal' ? 'bg-gray-700 border-gray-700 text-white' : 'border-gray-300 text-gray-500'}`}>
Internal
</button>
<button type="button" onClick={() => setEditNoteVis('customer')}
className={`px-2 py-1 text-xs rounded border ${editNoteVis === 'customer' ? 'bg-blue-500 border-blue-500 text-white' : 'border-gray-300 text-gray-500'}`}>
Customer
</button>
<div className="flex-1" />
<Button size="sm" onClick={() => updateNoteMutation.mutate({ nid: note.id, data: { note: editNoteText, visibility: editNoteVis } })}
disabled={updateNoteMutation.isPending}>Save</Button>
<Button size="sm" variant="ghost" onClick={() => setEditingNoteId(null)}>Cancel</Button>
</div>
</div>
) : (
<div className="flex items-start justify-between">
<div>
<div className="flex items-center gap-2 mb-0.5">
<span className={`text-[10px] font-medium px-1.5 py-0.5 rounded ${
note.visibility === 'customer' ? 'bg-blue-100 text-blue-700' : 'bg-gray-200 text-gray-600'
}`}>
{note.visibility === 'customer' ? 'Customer' : 'Internal'}
</span>
<span className="text-xs text-gray-400">{new Date(note.created_at).toLocaleString()}</span>
</div>
<p className="text-sm text-gray-700">{note.note}</p>
</div>
<div className="flex gap-1 ml-2 mt-0.5">
<button onClick={() => { setEditingNoteId(note.id); setEditNoteText(note.note); setEditNoteVis(note.visibility || 'internal'); }} className="text-gray-400 hover:text-blue-500">
<Pencil size={14} />
</button>
<button onClick={() => deleteNoteMutation.mutate(note.id)} className="text-gray-400 hover:text-red-500">
<Trash2 size={14} />
</button>
</div>
</div>
)}
</div>
)) || <p className="text-sm text-gray-500">No notes yet.</p>}
</div>
</div>
</Card>
<LaborModal open={showLaborModal} onClose={() => { setShowLaborModal(false); setEditLabor(null); }} jobId={Number(id)} item={editLabor} defaultRate={job.default_labor_rate || 75} onDone={invalidate} />
<PartsModal open={showPartsModal} onClose={() => { setShowPartsModal(false); setEditParts(null); }} jobId={Number(id)} item={editParts} defaultMarkup={job.default_parts_markup || 0.3} onDone={invalidate} />
<MiscModal open={showMiscModal} onClose={() => { setShowMiscModal(false); setEditMisc(null); }} jobId={Number(id)} item={editMisc} onDone={invalidate} />
{showEditRecModal && editRecData && (
<EditRecModal open={showEditRecModal} onClose={() => { setShowEditRecModal(false); setEditRecData(null); }} rec={editRecData}
onSave={(data) => updateRecMutation.mutate({ rid: editRecData.id, data })} saving={updateRecMutation.isPending} />
)}
</div>
);
}
function LaborModal({ open, onClose, jobId, item, defaultRate, onDone }: { open: boolean; onClose: () => void; jobId: number; item: LaborItem | null; defaultRate: number; onDone: () => void }) {
const [billingType, setBillingType] = useState<'hourly' | 'flat_rate'>(item?.billing_type || 'hourly');
const [description, setDescription] = useState(item?.description || '');
const [hours, setHours] = useState(item?.hours?.toString() || '');
const [rate, setRate] = useState(item?.rate?.toString() || String(defaultRate));
const [flatRate, setFlatRate] = useState(item?.flat_rate?.toString() || '');
useState(() => {
setBillingType(item?.billing_type || 'hourly');
setDescription(item?.description || '');
setHours(item?.hours?.toString() || '');
setRate(item?.rate?.toString() || String(defaultRate));
setFlatRate(item?.flat_rate?.toString() || '');
});
const mutation = useMutation({
mutationFn: (data: any) => item ? api.updateLabor(jobId, item.id, data) : api.addLabor(jobId, data),
onSuccess: () => { onDone(); onClose(); },
});
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (billingType === 'flat_rate') {
mutation.mutate({ description, billingType: 'flat_rate', flatRate: parseFloat(flatRate) });
} else {
mutation.mutate({ description, billingType: 'hourly', hours: parseFloat(hours), rate: parseFloat(rate) });
}
};
const total = billingType === 'flat_rate'
? (flatRate ? parseFloat(flatRate) : 0)
: (hours && rate ? parseFloat(hours) * parseFloat(rate) : 0);
return (
<Modal open={open} onClose={onClose} title={item ? 'Edit Labor' : 'Add Labor'}>
<form onSubmit={handleSubmit} className="space-y-3">
<Input label="Description *" value={description} onChange={(e) => setDescription(e.target.value)} required placeholder="e.g. Brake pad replacement" />
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Billing Type</label>
<div className="flex gap-2">
<button type="button" onClick={() => setBillingType('hourly')}
className={`flex-1 px-3 py-2 text-sm font-medium rounded-lg border transition-colors ${billingType === 'hourly' ? 'bg-blue-50 border-blue-300 text-blue-700' : 'border-gray-300 text-gray-600 hover:bg-gray-50'}`}>
Hourly
</button>
<button type="button" onClick={() => setBillingType('flat_rate')}
className={`flex-1 px-3 py-2 text-sm font-medium rounded-lg border transition-colors ${billingType === 'flat_rate' ? 'bg-blue-50 border-blue-300 text-blue-700' : 'border-gray-300 text-gray-600 hover:bg-gray-50'}`}>
Flat Rate
</button>
</div>
</div>
{billingType === 'hourly' ? (
<div className="grid grid-cols-2 gap-3">
<Input label="Hours *" type="number" step="0.1" min="0" value={hours} onChange={(e) => setHours(e.target.value)} required />
<Input label="Rate ($/hr) *" type="number" step="0.01" min="0" value={rate} onChange={(e) => setRate(e.target.value)} required />
</div>
) : (
<Input label="Flat Rate Amount *" type="number" step="0.01" min="0" value={flatRate} onChange={(e) => setFlatRate(e.target.value)} required placeholder="e.g. 350.00" />
)}
{total > 0 && <p className="text-sm text-gray-500">Total: <span className="font-medium">${total.toFixed(2)}</span></p>}
<div className="flex justify-end gap-2 pt-2">
<Button variant="secondary" type="button" onClick={onClose}>Cancel</Button>
<Button type="submit" disabled={mutation.isPending}>{mutation.isPending ? 'Saving...' : 'Save'}</Button>
</div>
</form>
</Modal>
);
}
function PartsModal({ open, onClose, jobId, item, defaultMarkup, onDone }: { open: boolean; onClose: () => void; jobId: number; item: PartsItem | null; defaultMarkup: number; onDone: () => void }) {
const [description, setDescription] = useState(item?.description || '');
const [partNumber, setPartNumber] = useState(item?.part_number || '');
const [quantity, setQuantity] = useState(item?.quantity?.toString() || '1');
const [unitCost, setUnitCost] = useState(item?.unit_cost?.toString() || '');
const [markup, setMarkup] = useState(item ? String(item.markup * 100) : String(defaultMarkup * 100));
const [overridePrice, setOverridePrice] = useState(item?.charge_price !== null && item?.charge_price !== undefined);
const [chargePrice, setChargePrice] = useState(item?.charge_price?.toString() || '');
const [inventoryItemId, setInventoryItemId] = useState<number | null>(null);
useState(() => {
setDescription(item?.description || '');
setPartNumber(item?.part_number || '');
setQuantity(item?.quantity?.toString() || '1');
setUnitCost(item?.unit_cost?.toString() || '');
setMarkup(item ? String(item.markup * 100) : String(defaultMarkup * 100));
setOverridePrice(item?.charge_price !== null && item?.charge_price !== undefined);
setChargePrice(item?.charge_price?.toString() || '');
setInventoryItemId(null);
});
const { data: inventoryItems = [] } = useQuery({
queryKey: ['inventory-parts'],
queryFn: () => api.getInventoryItems({}),
enabled: open && !item,
});
const selectFromInventory = (invItem: any) => {
setDescription(invItem.name);
setPartNumber(invItem.part_number || '');
setUnitCost(String(invItem.unit_cost || 0));
setInventoryItemId(invItem.id);
};
const mutation = useMutation({
mutationFn: (data: any) => item ? api.updateParts(jobId, item.id, data) : api.addParts(jobId, data),
onSuccess: () => { onDone(); onClose(); },
});
const calculatedPrice = quantity && unitCost ? parseFloat(quantity) * parseFloat(unitCost) * (1 + parseFloat(markup || '0') / 100) : 0;
const yourCost = quantity && unitCost ? parseFloat(quantity) * parseFloat(unitCost) : 0;
const customerPrice = overridePrice && chargePrice !== '' ? parseFloat(chargePrice) : calculatedPrice;
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
mutation.mutate({
description,
partNumber: partNumber || null,
quantity: parseFloat(quantity),
unitCost: parseFloat(unitCost),
markup: parseFloat(markup) / 100,
chargePrice: overridePrice ? parseFloat(chargePrice || '0') : null,
inventoryItemId,
});
};
return (
<Modal open={open} onClose={onClose} title={item ? 'Edit Part' : 'Add Part'}>
<form onSubmit={handleSubmit} className="space-y-3">
{/* Inventory picker — only on new parts */}
{!item && inventoryItems.length > 0 && (
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">From Inventory</label>
<select
className="block w-full rounded-lg border border-gray-300 px-3 py-2 text-sm"
value={inventoryItemId || ''}
onChange={(e) => {
const id = e.target.value ? Number(e.target.value) : null;
if (id) {
const inv = inventoryItems.find((i: any) => i.id === id);
if (inv) selectFromInventory(inv);
} else {
setInventoryItemId(null);
}
}}
>
<option value="">Manual entry...</option>
{inventoryItems.map((i: any) => (
<option key={i.id} value={i.id}>
[{i.item_type}] {i.name}{i.part_number ? ` (${i.part_number})` : ''} {i.quantity_on_hand} in stock @ ${(i.unit_cost || 0).toFixed(2)}
</option>
))}
</select>
{inventoryItemId && <p className="text-xs text-blue-600 mt-1">Will deduct from inventory on save</p>}
</div>
)}
<Input label="Description *" value={description} onChange={(e) => setDescription(e.target.value)} required placeholder="e.g. Brake pads (front)" />
<Input label="Part Number" value={partNumber} onChange={(e) => setPartNumber(e.target.value)} />
<div className="grid grid-cols-3 gap-3">
<Input label="Quantity *" type="number" step="0.1" min="0.1" value={quantity} onChange={(e) => setQuantity(e.target.value)} required />
<Input label="Your Cost *" type="number" step="0.01" min="0" value={unitCost} onChange={(e) => setUnitCost(e.target.value)} required />
<Input label="Markup (%)" type="number" step="1" value={markup} onChange={(e) => setMarkup(e.target.value)} />
</div>
{/* Price summary */}
{yourCost > 0 && (
<div className="bg-gray-50 rounded-lg p-3 text-sm space-y-1">
<div className="flex justify-between text-gray-600">
<span>Your cost</span>
<span>{fmt(yourCost)}</span>
</div>
<div className="flex justify-between text-gray-600">
<span>Calculated price ({markup}% markup)</span>
<span>{fmt(calculatedPrice)}</span>
</div>
<div className="flex justify-between font-medium text-gray-900 border-t border-gray-200 pt-1">
<span>Customer pays</span>
<span>{fmt(customerPrice)}</span>
</div>
{yourCost > 0 && <div className="flex justify-between text-xs text-green-600">
<span>Profit</span>
<span>{fmt(customerPrice - yourCost)}</span>
</div>}
</div>
)}
{/* Override toggle */}
<label className="flex items-center gap-2 cursor-pointer">
<input type="checkbox" checked={overridePrice} onChange={(e) => { setOverridePrice(e.target.checked); if (e.target.checked && !chargePrice) setChargePrice(calculatedPrice.toFixed(2)); }} className="rounded" />
<span className="text-sm text-gray-700">Override customer price</span>
</label>
{overridePrice && (
<Input label="Charge Price" type="number" step="0.01" min="0" value={chargePrice} onChange={(e) => setChargePrice(e.target.value)} placeholder="0.00 to not charge" />
)}
<div className="flex justify-end gap-2 pt-2">
<Button variant="secondary" type="button" onClick={onClose}>Cancel</Button>
<Button type="submit" disabled={mutation.isPending}>{mutation.isPending ? 'Saving...' : 'Save'}</Button>
</div>
</form>
</Modal>
);
}
function MiscModal({ open, onClose, jobId, item, onDone }: { open: boolean; onClose: () => void; jobId: number; item: any; onDone: () => void }) {
const [description, setDescription] = useState(item?.description || '');
const [category, setCategory] = useState(item?.category || 'sublet');
const [yourCost, setYourCost] = useState(item?.your_cost?.toString() || '');
const [chargePrice, setChargePrice] = useState(item?.charge_price?.toString() || '0');
useState(() => {
setDescription(item?.description || '');
setCategory(item?.category || 'sublet');
setYourCost(item?.your_cost?.toString() || '');
setChargePrice(item?.charge_price?.toString() || '0');
});
const mutation = useMutation({
mutationFn: (data: any) => item ? api.updateMisc(jobId, item.id, data) : api.addMisc(jobId, data),
onSuccess: () => { onDone(); onClose(); },
});
return (
<Modal open={open} onClose={onClose} title={item ? 'Edit Sublet/Misc' : 'Add Sublet/Misc'}>
<form onSubmit={(e) => { e.preventDefault(); mutation.mutate({ description, category, yourCost: parseFloat(yourCost || '0'), chargePrice: parseFloat(chargePrice || '0') }); }} className="space-y-3">
<Input label="Description *" value={description} onChange={(e) => setDescription(e.target.value)} required placeholder="e.g. Tow to shop, Machine work, etc." />
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Category</label>
<select className="block w-full rounded-lg border border-gray-300 px-3 py-2 text-sm" value={category} onChange={(e) => setCategory(e.target.value)}>
<option value="sublet">Sublet</option>
<option value="tow">Tow</option>
<option value="machine_work">Machine Work</option>
<option value="disposal">Disposal Fee</option>
<option value="other">Other</option>
</select>
</div>
<div className="grid grid-cols-2 gap-3">
<Input label="Your Cost" type="number" step="0.01" min="0" value={yourCost} onChange={(e) => setYourCost(e.target.value)} placeholder="What it cost you" />
<Input label="Charge Customer" type="number" step="0.01" min="0" value={chargePrice} onChange={(e) => setChargePrice(e.target.value)} placeholder="0 = absorbed" />
</div>
{yourCost && (
<div className="bg-gray-50 rounded-lg p-3 text-sm space-y-1">
<div className="flex justify-between text-gray-600">
<span>Your cost</span><span>{fmt(parseFloat(yourCost || '0'))}</span>
</div>
<div className="flex justify-between text-gray-600">
<span>Customer pays</span><span>{fmt(parseFloat(chargePrice || '0'))}</span>
</div>
<div className="flex justify-between text-xs text-green-600 border-t border-gray-200 pt-1">
<span>{parseFloat(chargePrice || '0') >= parseFloat(yourCost || '0') ? 'Profit' : 'Absorbed'}</span>
<span>{fmt(parseFloat(chargePrice || '0') - parseFloat(yourCost || '0'))}</span>
</div>
</div>
)}
<div className="flex justify-end gap-2 pt-2">
<Button variant="secondary" type="button" onClick={onClose}>Cancel</Button>
<Button type="submit" disabled={mutation.isPending}>{mutation.isPending ? 'Saving...' : 'Save'}</Button>
</div>
</form>
</Modal>
);
}
function EditRecModal({ open, onClose, rec, onSave, saving }: { open: boolean; onClose: () => void; rec: any; onSave: (data: any) => void; saving: boolean }) {
const [description, setDescription] = useState(rec?.description || '');
const [urgency, setUrgency] = useState(rec?.urgency || 'soon');
const [estimatedCost, setEstimatedCost] = useState(rec?.estimated_cost?.toString() || '');
const [notes, setNotes] = useState(rec?.notes || '');
const [status, setStatus] = useState(rec?.status || 'open');
useState(() => {
setDescription(rec?.description || '');
setUrgency(rec?.urgency || 'soon');
setEstimatedCost(rec?.estimated_cost?.toString() || '');
setNotes(rec?.notes || '');
setStatus(rec?.status || 'open');
});
return (
<Modal open={open} onClose={onClose} title="Edit Recommendation">
<form onSubmit={(e) => { e.preventDefault(); onSave({ description, urgency, estimatedCost: estimatedCost ? parseFloat(estimatedCost) : null, notes: notes || null, status }); }} className="space-y-3">
<Input label="Description *" value={description} onChange={(e) => setDescription(e.target.value)} required />
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Urgency</label>
<div className="flex gap-2">
{(['now', 'soon', 'monitor'] as const).map((u) => (
<button key={u} type="button" onClick={() => setUrgency(u)}
className={`flex-1 px-3 py-2 text-sm font-medium rounded-lg border transition-colors ${urgency === u
? u === 'now' ? 'bg-red-50 border-red-300 text-red-700'
: u === 'soon' ? 'bg-yellow-50 border-yellow-300 text-yellow-700'
: 'bg-blue-50 border-blue-300 text-blue-700'
: 'border-gray-300 text-gray-500 hover:bg-gray-50'}`}>
{u === 'now' ? 'Needs Now' : u === 'soon' ? 'Soon' : 'Monitor'}
</button>
))}
</div>
</div>
<Input label="Estimated Cost" type="number" step="0.01" value={estimatedCost} onChange={(e) => setEstimatedCost(e.target.value)} />
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Notes</label>
<textarea className="block w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 outline-none"
rows={3} value={notes} onChange={(e) => setNotes(e.target.value)} placeholder="Additional details..." />
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Status</label>
<div className="flex gap-2">
<button type="button" onClick={() => setStatus('open')}
className={`flex-1 px-3 py-2 text-sm font-medium rounded-lg border transition-colors ${status === 'open' ? 'bg-blue-50 border-blue-300 text-blue-700' : 'border-gray-300 text-gray-500 hover:bg-gray-50'}`}>
Open
</button>
<button type="button" onClick={() => setStatus('info_only')}
className={`flex-1 px-3 py-2 text-sm font-medium rounded-lg border transition-colors ${status === 'info_only' ? 'bg-orange-50 border-orange-300 text-orange-700' : 'border-gray-300 text-gray-500 hover:bg-gray-50'}`}>
Info Only
</button>
<button type="button" onClick={() => setStatus('dismissed')}
className={`flex-1 px-3 py-2 text-sm font-medium rounded-lg border transition-colors ${status === 'dismissed' ? 'bg-gray-200 border-gray-400 text-gray-700' : 'border-gray-300 text-gray-500 hover:bg-gray-50'}`}>
Dismissed
</button>
<button type="button" onClick={() => setStatus('completed')}
className={`flex-1 px-3 py-2 text-sm font-medium rounded-lg border transition-colors ${status === 'completed' ? 'bg-green-50 border-green-300 text-green-700' : 'border-gray-300 text-gray-500 hover:bg-gray-50'}`}>
Done
</button>
</div>
</div>
<div className="flex justify-end gap-2 pt-2">
<Button variant="secondary" type="button" onClick={onClose}>Cancel</Button>
<Button type="submit" disabled={saving}>{saving ? 'Saving...' : 'Save'}</Button>
</div>
</form>
</Modal>
);
}

343
client/src/pages/Jobs.tsx Normal file
View File

@@ -0,0 +1,343 @@
import { useState } from 'react';
import { Link, useSearchParams } from 'react-router-dom';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { Wrench, Plus, Search, LayoutGrid, List } from 'lucide-react';
import { api } from '../api/client';
import { Card, Button, Badge, Modal, Input, EmptyState } from '../components/ui';
import KanbanBoard from '../components/KanbanBoard';
import type { Job, Tag, Shop, Owner, Vehicle } from '../types';
const statuses: { value: string; label: string }[] = [
{ value: '', label: 'All' },
{ value: 'received', label: 'Received' },
{ value: 'in_progress', label: 'In Progress' },
{ value: 'completed', label: 'Completed' },
{ value: 'invoiced', label: 'Invoiced' },
{ value: 'closed', label: 'Closed' },
];
const statusLabel: Record<string, string> = {
received: 'Received', in_progress: 'In Progress', completed: 'Completed', invoiced: 'Invoiced', closed: 'Closed',
};
export default function Jobs() {
const [searchParams] = useSearchParams();
const [search, setSearch] = useState('');
const [statusFilter, setStatusFilter] = useState(searchParams.get('status') || '');
const [showNewJob, setShowNewJob] = useState(false);
const [viewMode, setViewMode] = useState<'list' | 'kanban'>('list');
const queryClient = useQueryClient();
const { data: jobs = [], isLoading } = useQuery({
queryKey: ['jobs', statusFilter, search],
queryFn: () => api.getJobs({ status: statusFilter || undefined, search: search || undefined }),
});
const createMutation = useMutation({
mutationFn: api.createJob,
onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['jobs'] }); setShowNewJob(false); },
});
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<h1 className="text-2xl font-bold text-gray-900">Jobs</h1>
<div className="flex border border-gray-300 rounded-lg overflow-hidden">
<button onClick={() => setViewMode('list')}
className={`p-1.5 ${viewMode === 'list' ? 'bg-blue-50 text-blue-600' : 'text-gray-400 hover:bg-gray-50'}`}>
<List size={18} />
</button>
<button onClick={() => setViewMode('kanban')}
className={`p-1.5 ${viewMode === 'kanban' ? 'bg-blue-50 text-blue-600' : 'text-gray-400 hover:bg-gray-50'}`}>
<LayoutGrid size={18} />
</button>
</div>
</div>
<Button onClick={() => setShowNewJob(true)}><Plus size={16} /> New Job</Button>
</div>
<div className="flex flex-col sm:flex-row gap-3">
<div className="relative flex-1">
<Search size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400" />
<input className="w-full pl-9 pr-3 py-2 text-sm border border-gray-300 rounded-lg focus:border-blue-500 focus:ring-1 focus:ring-blue-500 outline-none" placeholder="Search jobs..." value={search} onChange={(e) => setSearch(e.target.value)} />
</div>
<div className="flex gap-1 flex-wrap">
{statuses.map((s) => (
<button
key={s.value}
onClick={() => setStatusFilter(s.value)}
className={`px-3 py-1.5 text-xs font-medium rounded-lg transition-colors ${
statusFilter === s.value ? 'bg-blue-100 text-blue-700' : 'bg-gray-100 text-gray-600 hover:bg-gray-200'
}`}
>
{s.label}
</button>
))}
</div>
</div>
{isLoading ? <p className="text-gray-500 py-4">Loading...</p> : jobs.length === 0 ? (
<EmptyState icon={Wrench} title="No jobs found" description="Create your first job to start tracking work." action={<Button onClick={() => setShowNewJob(true)}>New Job</Button>} />
) : viewMode === 'kanban' ? (
<KanbanBoard jobs={jobs} />
) : (
<div className="space-y-2">
{jobs.map((job: Job) => (
<Link key={job.id} to={`/jobs/${job.id}`}>
<Card className="p-4 hover:border-blue-300 transition-colors">
<div className="flex items-center justify-between">
<div>
<div className="flex items-center gap-2">
<span className="font-mono text-sm font-semibold text-gray-900">{job.job_number}</span>
<Badge status={job.status}>{statusLabel[job.status]}</Badge>
{job.tags && job.tags.map((tag: Tag) => (
<span key={tag.id} className={`px-1.5 py-0.5 rounded text-[10px] font-medium ${
tag.color === 'red' ? 'bg-red-100 text-red-600' :
tag.color === 'yellow' ? 'bg-yellow-100 text-yellow-600' :
tag.color === 'green' ? 'bg-green-100 text-green-600' :
tag.color === 'blue' ? 'bg-blue-100 text-blue-600' :
tag.color === 'purple' ? 'bg-purple-100 text-purple-600' :
'bg-gray-100 text-gray-600'
}`}>{tag.name}</span>
))}
</div>
<p className="text-sm text-gray-700 mt-1">
{[job.year, job.make, job.model].filter(Boolean).join(' ')}
<span className="text-gray-400"> </span>
{job.first_name} {job.last_name}
</p>
{job.shop_name && <p className="text-xs text-gray-500 mt-0.5">{job.shop_name}</p>}
</div>
<div className="text-right text-xs text-gray-500">
{new Date(job.received_date).toLocaleDateString()}
</div>
</div>
{job.description && <p className="text-sm text-gray-500 mt-2 line-clamp-1">{job.description}</p>}
</Card>
</Link>
))}
</div>
)}
<NewJobModal open={showNewJob} onClose={() => setShowNewJob(false)} onSave={(data) => createMutation.mutate(data)} saving={createMutation.isPending} />
</div>
);
}
function InlineQuickAdd({ label, onSave, fields, saving }: { label: string; onSave: (data: any) => void; fields: { key: string; label: string; required?: boolean; type?: string }[]; saving: boolean }) {
const [open, setOpen] = useState(false);
const [form, setForm] = useState<Record<string, string>>({});
if (!open) {
return (
<button type="button" onClick={() => setOpen(true)} className="text-xs text-blue-600 hover:text-blue-800 font-medium flex items-center gap-1">
<Plus size={12} /> {label}
</button>
);
}
return (
<div className="border border-blue-200 bg-blue-50/50 rounded-lg p-3 space-y-2">
<p className="text-xs font-medium text-blue-700">{label}</p>
<div className="grid grid-cols-2 gap-2">
{fields.map((f) => (
<input
key={f.key}
className="block w-full rounded border border-gray-300 px-2 py-1.5 text-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 outline-none"
placeholder={f.label + (f.required ? ' *' : '')}
type={f.type || 'text'}
value={form[f.key] || ''}
onChange={(e) => setForm({ ...form, [f.key]: e.target.value })}
/>
))}
</div>
<div className="flex gap-2">
<button type="button" onClick={() => { onSave(form); setForm({}); setOpen(false); }} disabled={saving || !fields.filter((f) => f.required).every((f) => form[f.key]?.trim())}
className="px-2.5 py-1 text-xs font-medium bg-blue-600 text-white rounded hover:bg-blue-700 disabled:opacity-50">
{saving ? 'Saving...' : 'Add'}
</button>
<button type="button" onClick={() => { setOpen(false); setForm({}); }} className="px-2.5 py-1 text-xs text-gray-600 hover:text-gray-800">Cancel</button>
</div>
</div>
);
}
function NewJobModal({ open, onClose, onSave, saving }: { open: boolean; onClose: () => void; onSave: (data: any) => void; saving: boolean }) {
const [customerType, setCustomerType] = useState<'owner' | 'shop' | 'internal'>('owner');
const [shopId, setShopId] = useState('');
const [ownerId, setOwnerId] = useState('');
const [vehicleId, setVehicleId] = useState('');
const [description, setDescription] = useState('');
const queryClient = useQueryClient();
const { data: shops = [] } = useQuery({ queryKey: ['shops'], queryFn: () => api.getShops({ active: 1 }) });
const { data: owners = [] } = useQuery({ queryKey: ['owners'], queryFn: () => api.getOwners() });
const { data: vehicles = [] } = useQuery({
queryKey: ['vehicles', { ownerId }],
queryFn: () => api.getVehicles({ ownerId: ownerId ? Number(ownerId) : undefined }),
enabled: customerType !== 'internal' && !!ownerId,
});
const { data: inventoryVehicles = [] } = useQuery({
queryKey: ['vehicles', 'inventory'],
queryFn: () => api.getVehicles({ inventory: 1 } as any),
enabled: customerType === 'internal',
});
const createShopMutation = useMutation({
mutationFn: api.createShop,
onSuccess: (data: any) => { queryClient.invalidateQueries({ queryKey: ['shops'] }); setShopId(String(data.id)); },
});
const createOwnerMutation = useMutation({
mutationFn: api.createOwner,
onSuccess: (data: any) => { queryClient.invalidateQueries({ queryKey: ['owners'] }); setOwnerId(String(data.id)); setVehicleId(''); },
});
const createVehicleMutation = useMutation({
mutationFn: api.createVehicle,
onSuccess: (data: any) => { queryClient.invalidateQueries({ queryKey: ['vehicles'] }); setVehicleId(String(data.id)); },
});
const canSubmit = vehicleId && (
customerType === 'internal' ||
(customerType === 'owner' && ownerId) ||
(customerType === 'shop' && shopId && ownerId)
);
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
onSave({
customerType,
shopId: customerType === 'shop' ? Number(shopId) : null,
ownerId: ownerId ? Number(ownerId) : null,
vehicleId: Number(vehicleId),
description: description || null,
});
};
const typeBtn = (type: 'owner' | 'shop' | 'internal', label: string) => (
<button type="button" onClick={() => { setCustomerType(type); setShopId(''); setVehicleId(''); }}
className={`flex-1 px-3 py-2 text-sm font-medium rounded-lg border transition-colors ${customerType === type ? 'bg-blue-50 border-blue-300 text-blue-700' : 'border-gray-300 text-gray-600 hover:bg-gray-50'}`}>
{label}
</button>
);
return (
<Modal open={open} onClose={onClose} title="New Job">
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Customer Type *</label>
<div className="flex gap-2">
{typeBtn('owner', 'Vehicle Owner')}
{typeBtn('shop', 'Referring Shop')}
{typeBtn('internal', 'Internal / Flip')}
</div>
</div>
{customerType === 'shop' && (
<div>
<div className="flex items-center justify-between mb-1">
<label className="block text-sm font-medium text-gray-700">Referring Shop *</label>
<InlineQuickAdd
label="New Shop"
saving={createShopMutation.isPending}
fields={[
{ key: 'name', label: 'Shop Name', required: true },
{ key: 'contactName', label: 'Contact Name', required: true },
{ key: 'phone', label: 'Phone' },
{ key: 'email', label: 'Email' },
]}
onSave={(data) => createShopMutation.mutate(data)}
/>
</div>
<select className="block w-full rounded-lg border border-gray-300 px-3 py-2 text-sm" value={shopId} onChange={(e) => setShopId(e.target.value)} required>
<option value="">Select a shop...</option>
{shops.map((s: Shop) => <option key={s.id} value={s.id}>{s.name}</option>)}
</select>
</div>
)}
{customerType === 'internal' ? (
<div>
<div className="flex items-center justify-between mb-1">
<label className="block text-sm font-medium text-gray-700">Inventory Vehicle *</label>
<InlineQuickAdd
label="New Inventory Vehicle"
saving={createVehicleMutation.isPending}
fields={[
{ key: 'year', label: 'Year', type: 'number' },
{ key: 'make', label: 'Make', required: true },
{ key: 'model', label: 'Model', required: true },
{ key: 'vin', label: 'VIN' },
{ key: 'color', label: 'Color' },
{ key: 'mileage', label: 'Mileage', type: 'number' },
{ key: 'purchasePrice', label: 'Purchase Price', type: 'number' },
]}
onSave={(data) => createVehicleMutation.mutate({ ...data, isInventory: true, year: data.year ? parseInt(data.year) : null, mileage: data.mileage ? parseInt(data.mileage) : null, purchasePrice: data.purchasePrice ? parseFloat(data.purchasePrice) : null })}
/>
</div>
<select className="block w-full rounded-lg border border-gray-300 px-3 py-2 text-sm" value={vehicleId} onChange={(e) => setVehicleId(e.target.value)} required>
<option value="">Select an inventory vehicle...</option>
{inventoryVehicles.map((v: Vehicle) => <option key={v.id} value={v.id}>{[v.year, v.make, v.model].filter(Boolean).join(' ')}</option>)}
</select>
</div>
) : (
<>
<div>
<div className="flex items-center justify-between mb-1">
<label className="block text-sm font-medium text-gray-700">Vehicle Owner *</label>
<InlineQuickAdd
label="New Owner"
saving={createOwnerMutation.isPending}
fields={[
{ key: 'firstName', label: 'First Name', required: true },
{ key: 'lastName', label: 'Last Name', required: true },
{ key: 'phone', label: 'Phone' },
{ key: 'email', label: 'Email' },
]}
onSave={(data) => createOwnerMutation.mutate(data)}
/>
</div>
<select className="block w-full rounded-lg border border-gray-300 px-3 py-2 text-sm" value={ownerId} onChange={(e) => { setOwnerId(e.target.value); setVehicleId(''); }} required>
<option value="">Select an owner...</option>
{owners.map((o: Owner) => <option key={o.id} value={o.id}>{o.first_name} {o.last_name}</option>)}
</select>
</div>
{ownerId && (
<div>
<div className="flex items-center justify-between mb-1">
<label className="block text-sm font-medium text-gray-700">Vehicle *</label>
<InlineQuickAdd
label="New Vehicle"
saving={createVehicleMutation.isPending}
fields={[
{ key: 'year', label: 'Year', type: 'number' },
{ key: 'make', label: 'Make', required: true },
{ key: 'model', label: 'Model', required: true },
{ key: 'vin', label: 'VIN' },
{ key: 'color', label: 'Color' },
{ key: 'licensePlate', label: 'License Plate' },
]}
onSave={(data) => createVehicleMutation.mutate({ ...data, ownerId: Number(ownerId), year: data.year ? parseInt(data.year) : null })}
/>
</div>
<select className="block w-full rounded-lg border border-gray-300 px-3 py-2 text-sm" value={vehicleId} onChange={(e) => setVehicleId(e.target.value)} required>
<option value="">Select a vehicle...</option>
{vehicles.map((v: Vehicle) => <option key={v.id} value={v.id}>{[v.year, v.make, v.model].filter(Boolean).join(' ')}</option>)}
</select>
</div>
)}
</>
)}
<Input label="Description / Chief Complaint" value={description} onChange={(e) => setDescription(e.target.value)} placeholder="What needs to be done?" />
<div className="flex justify-end gap-2 pt-2">
<Button variant="secondary" type="button" onClick={onClose}>Cancel</Button>
<Button type="submit" disabled={saving || !canSubmit}>{saving ? 'Creating...' : 'Create Job'}</Button>
</div>
</form>
</Modal>
);
}

View File

@@ -0,0 +1,61 @@
import { useState, useEffect } from 'react';
import type { FormEvent } from 'react';
import { useNavigate } from 'react-router-dom';
import { api } from '../api/client';
import { useStore } from '../store';
import { Button, Input } from '../components/ui';
export default function Login() {
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [displayName, setDisplayName] = useState('');
const [isSetup, setIsSetup] = useState(false);
const [error, setError] = useState('');
const [loading, setLoading] = useState(true);
const { login, token } = useStore();
const navigate = useNavigate();
useEffect(() => {
if (token) { navigate('/'); return; }
api.setupRequired().then(({ setupRequired }) => {
setIsSetup(setupRequired);
setLoading(false);
});
}, [token, navigate]);
const handleSubmit = async (e: FormEvent) => {
e.preventDefault();
setError('');
try {
const result = isSetup
? await api.register({ username, password, displayName })
: await api.login({ username, password });
login(result.token, result.user);
navigate('/');
} catch (err: any) {
setError(err.message);
}
};
if (loading) return <div className="min-h-screen flex items-center justify-center"><p className="text-gray-500">Loading...</p></div>;
return (
<div className="min-h-screen flex items-center justify-center bg-gray-50 px-4">
<div className="w-full max-w-sm">
<h1 className="text-2xl font-bold text-center text-gray-900 mb-1">Shop Manager</h1>
<p className="text-center text-sm text-gray-500 mb-6">
{isSetup ? 'Create your admin account' : 'Sign in to your account'}
</p>
<form onSubmit={handleSubmit} className="bg-white rounded-xl border border-gray-200 shadow-sm p-6 space-y-4">
{isSetup && (
<Input label="Display Name" value={displayName} onChange={(e) => setDisplayName(e.target.value)} placeholder="Tony" />
)}
<Input label="Username" value={username} onChange={(e) => setUsername(e.target.value)} required autoFocus />
<Input label="Password" type="password" value={password} onChange={(e) => setPassword(e.target.value)} required />
{error && <p className="text-sm text-red-600">{error}</p>}
<Button type="submit" className="w-full">{isSetup ? 'Create Account' : 'Sign In'}</Button>
</form>
</div>
</div>
);
}

View File

@@ -0,0 +1,190 @@
import { useState } from 'react';
import { useParams, Link } from 'react-router-dom';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { ArrowLeft, Phone, Mail, Car, Edit2, Plus } from 'lucide-react';
import { api } from '../api/client';
import { Card, Button, Badge, Input, Modal } from '../components/ui';
import type { Owner, Vehicle, Job } from '../types';
const statusLabel: Record<string, string> = {
received: 'Received', in_progress: 'In Progress', completed: 'Completed', invoiced: 'Invoiced', closed: 'Closed',
};
export default function OwnerDetail() {
const { id } = useParams();
const queryClient = useQueryClient();
const [showEditModal, setShowEditModal] = useState(false);
const { data: owner, isLoading } = useQuery<Owner & { vehicles: Vehicle[] }>({
queryKey: ['owners', id],
queryFn: () => api.getOwner(Number(id)),
});
// Get all jobs for this owner
const { data: jobs = [] } = useQuery({
queryKey: ['jobs', { ownerId: id }],
queryFn: async () => {
const allJobs = await api.getJobs({});
return allJobs.filter((j: Job) => j.owner_id === Number(id));
},
enabled: !!id,
});
// Get invoices for this owner
const { data: invoices = [] } = useQuery({
queryKey: ['invoices', { ownerId: id }],
queryFn: () => api.getInvoices({ ownerId: Number(id) }),
enabled: !!id,
});
const updateMutation = useMutation({
mutationFn: (data: any) => api.updateOwner(Number(id), data),
onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['owners', id] }); setShowEditModal(false); },
});
if (isLoading || !owner) return <p className="text-gray-500 py-4">Loading...</p>;
const vehicles = owner.vehicles || [];
return (
<div className="space-y-6">
<Link to="/owners" className="inline-flex items-center gap-1.5 text-sm text-gray-500 hover:text-gray-700">
<ArrowLeft size={16} /> Back to Owners
</Link>
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<div>
<h1 className="text-2xl font-bold text-gray-900">{owner.first_name} {owner.last_name}</h1>
<div className="flex gap-4 mt-1 text-sm text-gray-500">
{owner.phone && <span className="flex items-center gap-1"><Phone size={14} /> {owner.phone}</span>}
{owner.email && <span className="flex items-center gap-1"><Mail size={14} /> {owner.email}</span>}
</div>
</div>
<Button variant="secondary" onClick={() => setShowEditModal(true)}>
<Edit2 size={16} /> Edit
</Button>
</div>
{owner.notes && (
<Card className="p-4">
<h3 className="text-xs font-medium text-gray-500 uppercase mb-1">Notes</h3>
<p className="text-sm text-gray-700">{owner.notes}</p>
</Card>
)}
{/* Vehicles */}
<Card>
<div className="p-4 border-b border-gray-200 flex items-center justify-between">
<h2 className="font-semibold text-gray-900">Vehicles ({vehicles.length})</h2>
</div>
{vehicles.length === 0 ? (
<p className="p-4 text-sm text-gray-500">No vehicles registered.</p>
) : (
<div className="divide-y divide-gray-100">
{vehicles.map((v: Vehicle) => (
<Link key={v.id} to={`/vehicles/${v.id}`} className="flex items-center gap-3 p-3 hover:bg-gray-50">
<Car size={16} className="text-gray-400" />
<div>
<p className="text-sm font-medium text-gray-900">{[v.year, v.make, v.model].filter(Boolean).join(' ')}</p>
<p className="text-xs text-gray-500">
{[v.color, v.vin && `VIN: ${v.vin}`, v.license_plate && `Plate: ${v.license_plate}`].filter(Boolean).join(' — ')}
</p>
</div>
</Link>
))}
</div>
)}
</Card>
{/* Job History */}
<Card>
<div className="p-4 border-b border-gray-200">
<h2 className="font-semibold text-gray-900">Job History ({jobs.length})</h2>
</div>
{jobs.length === 0 ? (
<p className="p-4 text-sm text-gray-500">No jobs yet.</p>
) : (
<div className="divide-y divide-gray-100">
{jobs.map((job: Job) => (
<div key={job.id} className="flex items-center justify-between p-3 hover:bg-gray-50">
<div>
<Link to={`/jobs/${job.id}`} className="text-sm font-medium text-blue-600 hover:underline">{job.job_number}</Link>
<p className="text-xs text-gray-500">
{[job.year, job.make, job.model].filter(Boolean).join(' ')}
{job.description && `${job.description.slice(0, 50)}${job.description.length > 50 ? '...' : ''}`}
</p>
</div>
<Badge status={job.status}>{statusLabel[job.status]}</Badge>
</div>
))}
</div>
)}
</Card>
{/* Invoices */}
{invoices.length > 0 && (
<Card>
<div className="p-4 border-b border-gray-200">
<h2 className="font-semibold text-gray-900">Invoices ({invoices.length})</h2>
</div>
<div className="divide-y divide-gray-100">
{invoices.map((inv: any) => (
<div key={inv.id} className="flex items-center justify-between p-3 hover:bg-gray-50">
<div>
<Link to={`/invoices/${inv.id}`} className="text-sm font-medium text-blue-600 hover:underline">{inv.invoice_number}</Link>
<p className="text-xs text-gray-500">{new Date(inv.invoice_date).toLocaleDateString()} ${inv.total.toFixed(2)}</p>
</div>
<Badge status={inv.status}>{inv.status}</Badge>
</div>
))}
</div>
</Card>
)}
{showEditModal && (
<EditOwnerModal
owner={owner}
open={showEditModal}
onClose={() => setShowEditModal(false)}
onSave={(data) => updateMutation.mutate(data)}
saving={updateMutation.isPending}
/>
)}
</div>
);
}
function EditOwnerModal({ owner, open, onClose, onSave, saving }: {
owner: Owner; open: boolean; onClose: () => void; onSave: (data: any) => void; saving: boolean;
}) {
const [form, setForm] = useState({
firstName: owner.first_name,
lastName: owner.last_name,
phone: owner.phone || '',
email: owner.email || '',
notes: owner.notes || '',
});
return (
<Modal open={open} onClose={onClose} title="Edit Owner">
<form onSubmit={(e) => { e.preventDefault(); onSave(form); }} className="space-y-3">
<div className="grid grid-cols-2 gap-3">
<Input label="First Name *" value={form.firstName} onChange={(e) => setForm({ ...form, firstName: e.target.value })} required />
<Input label="Last Name *" value={form.lastName} onChange={(e) => setForm({ ...form, lastName: e.target.value })} required />
</div>
<div className="grid grid-cols-2 gap-3">
<Input label="Phone" value={form.phone} onChange={(e) => setForm({ ...form, phone: e.target.value })} />
<Input label="Email" value={form.email} onChange={(e) => setForm({ ...form, email: e.target.value })} />
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Notes</label>
<textarea className="block w-full rounded-lg border border-gray-300 px-3 py-2 text-sm" rows={2} value={form.notes} onChange={(e) => setForm({ ...form, notes: e.target.value })} />
</div>
<div className="flex justify-end gap-2 pt-2">
<Button variant="secondary" type="button" onClick={onClose}>Cancel</Button>
<Button type="submit" disabled={saving}>{saving ? 'Saving...' : 'Save'}</Button>
</div>
</form>
</Modal>
);
}

157
client/src/pages/Owners.tsx Normal file
View File

@@ -0,0 +1,157 @@
import { useState } from 'react';
import { Link } from 'react-router-dom';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { Users, Plus, Search, Phone, Mail, Car } from 'lucide-react';
import { api } from '../api/client';
import { Card, Button, Input, Modal, EmptyState } from '../components/ui';
import type { Owner, Vehicle } from '../types';
export default function Owners() {
const [search, setSearch] = useState('');
const [showOwnerModal, setShowOwnerModal] = useState(false);
const [showVehicleModal, setShowVehicleModal] = useState(false);
const [editOwner, setEditOwner] = useState<Owner | null>(null);
const [selectedOwnerId, setSelectedOwnerId] = useState<number | null>(null);
const [expandedOwner, setExpandedOwner] = useState<number | null>(null);
const queryClient = useQueryClient();
const { data: owners = [], isLoading } = useQuery({
queryKey: ['owners', search],
queryFn: () => api.getOwners({ search: search || undefined }),
});
const saveMutation = useMutation({
mutationFn: (data: any) => editOwner ? api.updateOwner(editOwner.id, data) : api.createOwner(data),
onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['owners'] }); setShowOwnerModal(false); setEditOwner(null); },
});
const saveVehicleMutation = useMutation({
mutationFn: (data: any) => api.createVehicle(data),
onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['owners'] }); queryClient.invalidateQueries({ queryKey: ['vehicles'] }); setShowVehicleModal(false); },
});
const toggleExpand = (id: number) => {
setExpandedOwner(expandedOwner === id ? null : id);
};
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold text-gray-900">Vehicle Owners</h1>
<Button onClick={() => { setEditOwner(null); setShowOwnerModal(true); }}><Plus size={16} /> Add Owner</Button>
</div>
<div className="relative">
<Search size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400" />
<input className="w-full pl-9 pr-3 py-2 text-sm border border-gray-300 rounded-lg focus:border-blue-500 focus:ring-1 focus:ring-blue-500 outline-none" placeholder="Search owners..." value={search} onChange={(e) => setSearch(e.target.value)} />
</div>
{isLoading ? <p className="text-gray-500 py-4">Loading...</p> : owners.length === 0 ? (
<EmptyState icon={Users} title="No owners yet" description="Add vehicle owners to track whose cars you're working on." action={<Button onClick={() => setShowOwnerModal(true)}>Add Owner</Button>} />
) : (
<div className="space-y-3">
{owners.map((owner: Owner) => (
<Card key={owner.id}>
<div className="p-4 cursor-pointer hover:bg-gray-50 transition-colors" onClick={() => toggleExpand(owner.id)}>
<div className="flex items-center justify-between">
<div>
<Link to={`/owners/${owner.id}`} className="font-medium text-blue-600 hover:underline" onClick={(e) => e.stopPropagation()}>{owner.first_name} {owner.last_name}</Link>
<div className="flex gap-3 mt-1 text-xs text-gray-500">
{owner.phone && <span className="flex items-center gap-1"><Phone size={11} />{owner.phone}</span>}
{owner.email && <span className="flex items-center gap-1"><Mail size={11} />{owner.email}</span>}
</div>
</div>
<div className="flex gap-2">
<Button variant="ghost" size="sm" onClick={(e) => { e.stopPropagation(); setEditOwner(owner); setShowOwnerModal(true); }}>Edit</Button>
<Button variant="ghost" size="sm" onClick={(e) => { e.stopPropagation(); setSelectedOwnerId(owner.id); setShowVehicleModal(true); }}>
<Car size={14} /> Add Vehicle
</Button>
</div>
</div>
</div>
{expandedOwner === owner.id && <OwnerVehicles ownerId={owner.id} />}
</Card>
))}
</div>
)}
<OwnerModal open={showOwnerModal} onClose={() => { setShowOwnerModal(false); setEditOwner(null); }} owner={editOwner} onSave={(data) => saveMutation.mutate(data)} saving={saveMutation.isPending} />
<VehicleModal open={showVehicleModal} onClose={() => setShowVehicleModal(false)} ownerId={selectedOwnerId} onSave={(data) => saveVehicleMutation.mutate(data)} saving={saveVehicleMutation.isPending} />
</div>
);
}
function OwnerVehicles({ ownerId }: { ownerId: number }) {
const { data: vehicles = [] } = useQuery({
queryKey: ['vehicles', { ownerId }],
queryFn: () => api.getVehicles({ ownerId }),
});
if (vehicles.length === 0) return <p className="px-4 pb-3 text-sm text-gray-500">No vehicles registered.</p>;
return (
<div className="px-4 pb-3 space-y-2">
{vehicles.map((v: Vehicle) => (
<Link key={v.id} to={`/vehicles/${v.id}`} className="flex items-center gap-2 text-sm bg-gray-50 rounded-lg px-3 py-2 hover:bg-gray-100">
<Car size={14} className="text-gray-400" />
<span className="font-medium text-blue-600">{[v.year, v.make, v.model].filter(Boolean).join(' ')}</span>
{v.vin && <span className="text-gray-400 text-xs">VIN: {v.vin}</span>}
{v.color && <span className="text-gray-400 text-xs">({v.color})</span>}
</Link>
))}
</div>
);
}
function OwnerModal({ open, onClose, owner, onSave, saving }: { open: boolean; onClose: () => void; owner: Owner | null; onSave: (data: any) => void; saving: boolean }) {
const [form, setForm] = useState({ firstName: '', lastName: '', phone: '', email: '', notes: '' });
useState(() => {
if (owner) setForm({ firstName: owner.first_name, lastName: owner.last_name, phone: owner.phone || '', email: owner.email || '', notes: owner.notes || '' });
else setForm({ firstName: '', lastName: '', phone: '', email: '', notes: '' });
});
return (
<Modal open={open} onClose={onClose} title={owner ? 'Edit Owner' : 'Add Owner'}>
<form onSubmit={(e) => { e.preventDefault(); onSave(form); }} className="space-y-3">
<div className="grid grid-cols-2 gap-3">
<Input label="First Name *" value={form.firstName} onChange={(e) => setForm({ ...form, firstName: e.target.value })} required />
<Input label="Last Name *" value={form.lastName} onChange={(e) => setForm({ ...form, lastName: e.target.value })} required />
</div>
<div className="grid grid-cols-2 gap-3">
<Input label="Phone" value={form.phone} onChange={(e) => setForm({ ...form, phone: e.target.value })} />
<Input label="Email" value={form.email} onChange={(e) => setForm({ ...form, email: e.target.value })} />
</div>
<div className="flex justify-end gap-2 pt-2">
<Button variant="secondary" type="button" onClick={onClose}>Cancel</Button>
<Button type="submit" disabled={saving}>{saving ? 'Saving...' : 'Save'}</Button>
</div>
</form>
</Modal>
);
}
function VehicleModal({ open, onClose, ownerId, onSave, saving }: { open: boolean; onClose: () => void; ownerId: number | null; onSave: (data: any) => void; saving: boolean }) {
const [form, setForm] = useState({ year: '', make: '', model: '', vin: '', color: '', licensePlate: '' });
return (
<Modal open={open} onClose={onClose} title="Add Vehicle">
<form onSubmit={(e) => { e.preventDefault(); onSave({ ...form, ownerId, year: form.year ? parseInt(form.year) : null }); }} className="space-y-3">
<div className="grid grid-cols-3 gap-3">
<Input label="Year" type="number" value={form.year} onChange={(e) => setForm({ ...form, year: e.target.value })} />
<Input label="Make *" value={form.make} onChange={(e) => setForm({ ...form, make: e.target.value })} required />
<Input label="Model *" value={form.model} onChange={(e) => setForm({ ...form, model: e.target.value })} required />
</div>
<Input label="VIN" value={form.vin} onChange={(e) => setForm({ ...form, vin: e.target.value })} />
<div className="grid grid-cols-2 gap-3">
<Input label="Color" value={form.color} onChange={(e) => setForm({ ...form, color: e.target.value })} />
<Input label="License Plate" value={form.licensePlate} onChange={(e) => setForm({ ...form, licensePlate: e.target.value })} />
</div>
<div className="flex justify-end gap-2 pt-2">
<Button variant="secondary" type="button" onClick={onClose}>Cancel</Button>
<Button type="submit" disabled={saving}>{saving ? 'Saving...' : 'Save'}</Button>
</div>
</form>
</Modal>
);
}

View File

@@ -0,0 +1,251 @@
import { useState } from 'react';
import { useQuery } from '@tanstack/react-query';
import { BarChart3 } from 'lucide-react';
import { api } from '../api/client';
import { Card, Input } from '../components/ui';
const fmt = (n: number) => '$' + (n || 0).toFixed(2);
const reports = [
{ id: 'revenue', label: 'Revenue Summary' },
{ id: 'profitability', label: 'Job Profitability' },
{ id: 'by-shop', label: 'Revenue by Shop' },
{ id: 'aging', label: 'Receivables Aging' },
{ id: 'tech-productivity', label: 'Technician Productivity' },
{ id: 'top-customers', label: 'Top Customers' },
{ id: 'jobs-status', label: 'Jobs by Status' },
{ id: 'inventory', label: 'Inventory Summary' },
];
export default function Reports() {
const [activeReport, setActiveReport] = useState('revenue');
const now = new Date();
const [startDate, setStartDate] = useState(new Date(now.getFullYear(), 0, 1).toISOString().split('T')[0]);
const [endDate, setEndDate] = useState(now.toISOString().split('T')[0]);
return (
<div className="space-y-4">
<h1 className="text-2xl font-bold text-gray-900">Reports</h1>
<div className="flex gap-3 flex-col sm:flex-row">
<div className="flex gap-1 flex-wrap flex-1">
{reports.map(r => (
<button key={r.id} onClick={() => setActiveReport(r.id)}
className={`px-3 py-1.5 text-xs font-medium rounded-lg transition-colors ${activeReport === r.id ? 'bg-blue-100 text-blue-700' : 'bg-gray-100 text-gray-600 hover:bg-gray-200'}`}>
{r.label}
</button>
))}
</div>
<div className="flex gap-2 items-center">
<Input type="date" value={startDate} onChange={(e) => setStartDate(e.target.value)} />
<span className="text-gray-400">to</span>
<Input type="date" value={endDate} onChange={(e) => setEndDate(e.target.value)} />
</div>
</div>
{activeReport === 'revenue' && <RevenueReport startDate={startDate} endDate={endDate} />}
{activeReport === 'profitability' && <ProfitabilityReport startDate={startDate} endDate={endDate} />}
{activeReport === 'by-shop' && <RevenueByShopReport startDate={startDate} endDate={endDate} />}
{activeReport === 'aging' && <AgingReport />}
{activeReport === 'tech-productivity' && <TechProductivityReport startDate={startDate} endDate={endDate} />}
{activeReport === 'top-customers' && <TopCustomersReport startDate={startDate} endDate={endDate} />}
{activeReport === 'jobs-status' && <JobsByStatusReport />}
{activeReport === 'inventory' && <InventorySummaryReport />}
</div>
);
}
function RevenueReport({ startDate, endDate }: { startDate: string; endDate: string }) {
const { data } = useQuery({ queryKey: ['report-revenue', startDate, endDate], queryFn: () => api.getRevenueReport({ startDate, endDate }) });
if (!data) return <p className="text-gray-500">Loading...</p>;
return (
<div className="space-y-4">
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
<Card className="p-4"><p className="text-xs text-gray-500">Total Revenue</p><p className="text-2xl font-bold text-gray-900">{fmt(data.total)}</p></Card>
<Card className="p-4"><p className="text-xs text-gray-500">Invoices</p><p className="text-2xl font-bold text-gray-900">{data.invoiceCount}</p></Card>
<Card className="p-4"><p className="text-xs text-gray-500">Avg Repair Order</p><p className="text-2xl font-bold text-gray-900">{fmt(data.averageRepairOrder)}</p></Card>
<Card className="p-4"><p className="text-xs text-gray-500">Payment Methods</p><p className="text-2xl font-bold text-gray-900">{data.byMethod?.length || 0}</p></Card>
</div>
<Card><div className="p-4"><h3 className="font-medium text-gray-900 mb-3">Monthly Revenue</h3>
<div className="space-y-2">{data.monthly?.map((m: any) => (
<div key={m.month} className="flex items-center justify-between text-sm">
<span className="text-gray-600">{m.month}</span>
<div className="flex items-center gap-3">
<div className="w-48 bg-gray-100 rounded-full h-2"><div className="bg-blue-500 h-2 rounded-full" style={{ width: `${Math.min(100, (m.total / (data.total || 1)) * 100 * data.monthly.length)}%` }} /></div>
<span className="font-medium w-24 text-right">{fmt(m.total)}</span>
</div>
</div>
))}</div></div>
</Card>
<Card><div className="p-4"><h3 className="font-medium text-gray-900 mb-3">By Payment Method</h3>
<div className="space-y-2">{data.byMethod?.map((m: any) => (
<div key={m.method} className="flex justify-between text-sm"><span className="text-gray-600 capitalize">{m.method}</span><span className="font-medium">{fmt(m.total)} ({m.count})</span></div>
))}</div></div>
</Card>
</div>
);
}
function ProfitabilityReport({ startDate, endDate }: { startDate: string; endDate: string }) {
const { data } = useQuery({ queryKey: ['report-profit', startDate, endDate], queryFn: () => api.getProfitabilityReport({ startDate, endDate }) });
if (!data) return <p className="text-gray-500">Loading...</p>;
return (
<div className="space-y-4">
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
<Card className="p-4"><p className="text-xs text-gray-500">Total Billed</p><p className="text-2xl font-bold">{fmt(data.summary?.totalBilled)}</p></Card>
<Card className="p-4"><p className="text-xs text-gray-500">Labor Revenue</p><p className="text-2xl font-bold">{fmt(data.summary?.totalLabor)}</p></Card>
<Card className="p-4"><p className="text-xs text-gray-500">Parts Margin</p><p className="text-2xl font-bold text-green-600">{fmt(data.summary?.totalPartsMargin)}</p></Card>
<Card className="p-4"><p className="text-xs text-gray-500">Jobs</p><p className="text-2xl font-bold">{data.summary?.jobCount}</p></Card>
</div>
<Card><div className="overflow-x-auto"><table className="w-full text-sm">
<thead><tr className="border-b text-left text-xs text-gray-500 uppercase">
<th className="px-4 py-3">Job</th><th className="px-4 py-3">Vehicle</th><th className="px-4 py-3 text-right">Labor</th>
<th className="px-4 py-3 text-right">Parts Cost</th><th className="px-4 py-3 text-right">Parts Billed</th>
<th className="px-4 py-3 text-right">Margin</th><th className="px-4 py-3 text-right">Total</th>
</tr></thead>
<tbody className="divide-y divide-gray-100">{data.jobs?.slice(0, 50).map((j: any) => (
<tr key={j.id}><td className="px-4 py-2 font-medium text-gray-900">{j.job_number}</td>
<td className="px-4 py-2 text-gray-500">{[j.year, j.make, j.model].filter(Boolean).join(' ')}</td>
<td className="px-4 py-2 text-right">{fmt(j.labor_total)}</td>
<td className="px-4 py-2 text-right text-gray-500">{fmt(j.parts_cost)}</td>
<td className="px-4 py-2 text-right">{fmt(j.parts_billed)}</td>
<td className="px-4 py-2 text-right text-green-600">{fmt(j.parts_margin)}</td>
<td className="px-4 py-2 text-right font-medium">{fmt(j.total_billed)}</td></tr>
))}</tbody></table></div>
</Card>
</div>
);
}
function RevenueByShopReport({ startDate, endDate }: { startDate: string; endDate: string }) {
const { data = [] } = useQuery({ queryKey: ['report-by-shop', startDate, endDate], queryFn: () => api.getRevenueByShopReport({ startDate, endDate }) });
return (
<Card><div className="overflow-x-auto"><table className="w-full text-sm">
<thead><tr className="border-b text-left text-xs text-gray-500 uppercase">
<th className="px-4 py-3">Shop</th><th className="px-4 py-3 text-right">Invoices</th>
<th className="px-4 py-3 text-right">Invoiced</th><th className="px-4 py-3 text-right">Paid</th>
<th className="px-4 py-3 text-right">Outstanding</th>
</tr></thead>
<tbody className="divide-y divide-gray-100">{(data as any[]).map((s: any) => (
<tr key={s.shop_id}><td className="px-4 py-2 font-medium">{s.shop_name}</td>
<td className="px-4 py-2 text-right">{s.invoice_count}</td>
<td className="px-4 py-2 text-right">{fmt(s.total_invoiced)}</td>
<td className="px-4 py-2 text-right text-green-600">{fmt(s.total_paid)}</td>
<td className="px-4 py-2 text-right text-yellow-600">{fmt(s.total_invoiced - s.total_paid)}</td></tr>
))}</tbody></table></div>
</Card>
);
}
function AgingReport() {
const { data } = useQuery({ queryKey: ['report-aging'], queryFn: () => api.getAgingReport() });
if (!data) return <p className="text-gray-500">Loading...</p>;
const buckets = [
{ label: 'Current', items: data.aging?.current || [], total: data.totals?.current || 0, color: 'green' },
{ label: '1-30 Days', items: data.aging?.over30 || [], total: data.totals?.over30 || 0, color: 'yellow' },
{ label: '31-60 Days', items: data.aging?.over60 || [], total: data.totals?.over60 || 0, color: 'orange' },
{ label: '90+ Days', items: data.aging?.over90 || [], total: data.totals?.over90 || 0, color: 'red' },
];
return (
<div className="space-y-4">
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
{buckets.map(b => (
<Card key={b.label} className="p-4"><p className="text-xs text-gray-500">{b.label}</p>
<p className="text-2xl font-bold">{fmt(b.total)}</p><p className="text-xs text-gray-400">{b.items.length} invoices</p></Card>
))}
</div>
<Card className="p-4"><p className="text-lg font-bold">Total Outstanding: {fmt(data.total || 0)}</p></Card>
</div>
);
}
function TechProductivityReport({ startDate, endDate }: { startDate: string; endDate: string }) {
const { data = [] } = useQuery({ queryKey: ['report-tech', startDate, endDate], queryFn: () => api.getTechProductivityReport({ startDate, endDate }) });
return (
<Card><div className="overflow-x-auto"><table className="w-full text-sm">
<thead><tr className="border-b text-left text-xs text-gray-500 uppercase">
<th className="px-4 py-3">Technician</th><th className="px-4 py-3 text-right">Hours</th>
<th className="px-4 py-3 text-right">Jobs</th><th className="px-4 py-3 text-right">Clock Entries</th>
<th className="px-4 py-3 text-right">Labor Cost</th>
</tr></thead>
<tbody className="divide-y divide-gray-100">{(data as any[]).map((t: any) => (
<tr key={t.id}><td className="px-4 py-2 font-medium">{t.name}</td>
<td className="px-4 py-2 text-right">{t.total_hours}h</td>
<td className="px-4 py-2 text-right">{t.jobs_worked}</td>
<td className="px-4 py-2 text-right">{t.clock_entries}</td>
<td className="px-4 py-2 text-right">{fmt(t.labor_cost)}</td></tr>
))}</tbody></table></div>
</Card>
);
}
function TopCustomersReport({ startDate, endDate }: { startDate: string; endDate: string }) {
const { data } = useQuery({ queryKey: ['report-customers', startDate, endDate], queryFn: () => api.getTopCustomersReport({ startDate, endDate }) });
if (!data) return <p className="text-gray-500">Loading...</p>;
return (
<div className="space-y-4">
<Card><div className="p-4 border-b"><h3 className="font-medium">Top Shops</h3></div><div className="overflow-x-auto"><table className="w-full text-sm">
<tbody className="divide-y divide-gray-100">{data.shops?.map((s: any) => (
<tr key={s.id}><td className="px-4 py-2 font-medium">{s.name}</td>
<td className="px-4 py-2 text-right text-gray-500">{s.job_count} jobs</td>
<td className="px-4 py-2 text-right font-medium">{fmt(s.total_invoiced)}</td></tr>
))}</tbody></table></div>
</Card>
<Card><div className="p-4 border-b"><h3 className="font-medium">Top Customers</h3></div><div className="overflow-x-auto"><table className="w-full text-sm">
<tbody className="divide-y divide-gray-100">{data.owners?.map((o: any) => (
<tr key={o.id}><td className="px-4 py-2 font-medium">{o.first_name} {o.last_name}</td>
<td className="px-4 py-2 text-right text-gray-500">{o.job_count} jobs</td>
<td className="px-4 py-2 text-right font-medium">{fmt(o.total_invoiced)}</td></tr>
))}</tbody></table></div>
</Card>
</div>
);
}
function JobsByStatusReport() {
const { data = [] } = useQuery({ queryKey: ['report-jobs-status'], queryFn: () => api.getJobsByStatusReport() });
const total = (data as any[]).reduce((s: number, j: any) => s + j.count, 0);
return (
<Card className="p-4"><h3 className="font-medium mb-3">Jobs by Status</h3>
<div className="space-y-3">{(data as any[]).map((j: any) => (
<div key={j.status} className="flex items-center justify-between">
<span className="text-sm capitalize text-gray-700">{j.status.replace('_', ' ')}</span>
<div className="flex items-center gap-3">
<div className="w-48 bg-gray-100 rounded-full h-2"><div className="bg-blue-500 h-2 rounded-full" style={{ width: `${(j.count / (total || 1)) * 100}%` }} /></div>
<span className="text-sm font-medium w-8 text-right">{j.count}</span>
</div>
</div>
))}</div>
</Card>
);
}
function InventorySummaryReport() {
const { data } = useQuery({ queryKey: ['report-inventory'], queryFn: () => api.getInventorySummaryReport() });
if (!data) return <p className="text-gray-500">Loading...</p>;
return (
<div className="space-y-4">
<Card className="p-4"><p className="text-xs text-gray-500">Total Inventory Value</p><p className="text-2xl font-bold">{fmt(data.totalValue)}</p></Card>
{data.lowStock?.length > 0 && (
<Card><div className="p-4 border-b"><h3 className="font-medium text-red-600">Low Stock Items ({data.lowStock.length})</h3></div>
<div className="divide-y divide-gray-100">{data.lowStock.map((i: any) => (
<div key={i.id} className="flex justify-between p-3 text-sm">
<span className="font-medium">{i.name} {i.part_number && <span className="text-gray-400">({i.part_number})</span>}</span>
<span className="text-red-600">{i.quantity_on_hand} / {i.reorder_point} min</span>
</div>
))}</div>
</Card>
)}
<Card><div className="p-4"><h3 className="font-medium mb-3">By Category</h3>
<div className="space-y-2">{data.byCategory?.map((c: any) => (
<div key={c.category} className="flex justify-between text-sm">
<span className="text-gray-600">{c.category || 'Uncategorized'}</span>
<span className="font-medium">{c.item_count} items {fmt(c.value)}</span>
</div>
))}</div></div>
</Card>
</div>
);
}

View File

@@ -0,0 +1,109 @@
import { useState, useEffect } from 'react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { Download, Database } from 'lucide-react';
import { api } from '../api/client';
import { Card, Button, Input } from '../components/ui';
import type { Settings as SettingsType } from '../types';
export default function Settings() {
const queryClient = useQueryClient();
const { data: settings, isLoading } = useQuery<SettingsType>({ queryKey: ['settings'], queryFn: api.getSettings });
const [form, setForm] = useState({
shopName: '', shopAddress: '', shopCity: '', shopState: '', shopZip: '', shopPhone: '', shopEmail: '', defaultTaxRate: '0', invoiceTerms: '',
});
const [saved, setSaved] = useState(false);
useEffect(() => {
if (settings) {
setForm({
shopName: settings.shop_name || '',
shopAddress: settings.shop_address || '',
shopCity: settings.shop_city || '',
shopState: settings.shop_state || '',
shopZip: settings.shop_zip || '',
shopPhone: settings.shop_phone || '',
shopEmail: settings.shop_email || '',
defaultTaxRate: String((settings.default_tax_rate || 0) * 100),
invoiceTerms: settings.invoice_terms || '',
});
}
}, [settings]);
const mutation = useMutation({
mutationFn: (data: any) => api.updateSettings(data),
onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['settings'] }); setSaved(true); setTimeout(() => setSaved(false), 2000); },
});
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
mutation.mutate({ ...form, defaultTaxRate: parseFloat(form.defaultTaxRate) / 100 });
};
if (isLoading) return <p className="text-gray-500 py-4">Loading...</p>;
return (
<div className="space-y-6">
<h1 className="text-2xl font-bold text-gray-900">Settings</h1>
<Card className="p-6">
<h2 className="font-semibold text-gray-900 mb-4">Business Information</h2>
<p className="text-sm text-gray-500 mb-4">This info appears on your invoices.</p>
<form onSubmit={handleSubmit} className="space-y-4 max-w-lg">
<Input label="Shop Name" value={form.shopName} onChange={(e) => setForm({ ...form, shopName: e.target.value })} />
<Input label="Address" value={form.shopAddress} onChange={(e) => setForm({ ...form, shopAddress: e.target.value })} />
<div className="grid grid-cols-3 gap-3">
<Input label="City" value={form.shopCity} onChange={(e) => setForm({ ...form, shopCity: e.target.value })} />
<Input label="State" value={form.shopState} onChange={(e) => setForm({ ...form, shopState: e.target.value })} />
<Input label="ZIP" value={form.shopZip} onChange={(e) => setForm({ ...form, shopZip: e.target.value })} />
</div>
<div className="grid grid-cols-2 gap-3">
<Input label="Phone" value={form.shopPhone} onChange={(e) => setForm({ ...form, shopPhone: e.target.value })} />
<Input label="Email" value={form.shopEmail} onChange={(e) => setForm({ ...form, shopEmail: e.target.value })} />
</div>
<Input label="Default Tax Rate (%)" type="number" step="0.1" value={form.defaultTaxRate} onChange={(e) => setForm({ ...form, defaultTaxRate: e.target.value })} />
<Input label="Invoice Terms" value={form.invoiceTerms} onChange={(e) => setForm({ ...form, invoiceTerms: e.target.value })} placeholder="e.g. Net 30" />
<div className="flex items-center gap-3">
<Button type="submit" disabled={mutation.isPending}>{mutation.isPending ? 'Saving...' : 'Save Settings'}</Button>
{saved && <span className="text-sm text-green-600">Saved!</span>}
</div>
</form>
</Card>
<BackupSection />
</div>
);
}
function BackupSection() {
const { data: info } = useQuery({ queryKey: ['backup-info'], queryFn: api.getBackupInfo });
const formatSize = (bytes: number) => {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
};
return (
<Card className="p-6">
<h2 className="font-semibold text-gray-900 mb-4 flex items-center gap-2"><Database size={18} /> Backup & Restore</h2>
<div className="space-y-4 max-w-lg">
{info?.exists && (
<div className="text-sm text-gray-600 space-y-1">
<p>Database size: <span className="font-medium">{formatSize(info.size)}</span></p>
<p>Tables: <span className="font-medium">{info.tables}</span></p>
<p>Jobs: <span className="font-medium">{info.jobs}</span></p>
<p>Last modified: <span className="font-medium">{new Date(info.lastModified).toLocaleString()}</span></p>
</div>
)}
<div className="flex gap-3">
<Button variant="secondary" onClick={() => api.downloadBackup()}>
<Download size={16} /> Download Backup
</Button>
</div>
<p className="text-xs text-gray-400">
To restore: stop the server, replace <code className="bg-gray-100 px-1 rounded">data/autoking.db</code> with your backup file, restart the server.
</p>
</div>
</Card>
);
}

View File

@@ -0,0 +1,109 @@
import { useParams, Link } from 'react-router-dom';
import { useQuery } from '@tanstack/react-query';
import { ArrowLeft, Phone, Mail, MapPin } from 'lucide-react';
import { api } from '../api/client';
import { Card, Badge, Button } from '../components/ui';
const fmt = (n: number) => '$' + n.toFixed(2);
const statusLabel: Record<string, string> = {
received: 'Received', in_progress: 'In Progress', completed: 'Completed',
invoiced: 'Invoiced', closed: 'Closed', draft: 'Draft', sent: 'Sent',
paid: 'Paid', partial: 'Partial', overdue: 'Overdue',
};
export default function ShopDetail() {
const { id } = useParams();
const { data: shop, isLoading } = useQuery({ queryKey: ['shops', id], queryFn: () => api.getShop(Number(id)) });
const { data: jobs = [] } = useQuery({ queryKey: ['shops', id, 'jobs'], queryFn: () => api.getShopJobs(Number(id)) });
const { data: invoices = [] } = useQuery({ queryKey: ['shops', id, 'invoices'], queryFn: () => api.getShopInvoices(Number(id)) });
if (isLoading || !shop) return <p className="text-gray-500 py-4">Loading...</p>;
return (
<div className="space-y-6">
<Link to="/shops" className="inline-flex items-center gap-1.5 text-sm text-gray-500 hover:text-gray-700">
<ArrowLeft size={16} /> Back to Shops
</Link>
<div className="flex items-start justify-between">
<div>
<h1 className="text-2xl font-bold text-gray-900">{shop.name}</h1>
{shop.contact_name && <p className="text-gray-500">{shop.contact_name}</p>}
</div>
<div className="text-right">
<p className="text-sm text-gray-500">{shop.jobCount} jobs</p>
{shop.outstandingBalance > 0 && (
<p className="text-sm font-medium text-yellow-600">Outstanding: {fmt(shop.outstandingBalance)}</p>
)}
</div>
</div>
<div className="grid sm:grid-cols-3 gap-4">
<Card className="p-4">
<h3 className="text-xs font-medium text-gray-500 uppercase mb-2">Contact</h3>
<div className="space-y-1.5 text-sm">
{shop.phone && <div className="flex items-center gap-2 text-gray-700"><Phone size={14} />{shop.phone}</div>}
{shop.email && <div className="flex items-center gap-2 text-gray-700"><Mail size={14} />{shop.email}</div>}
{shop.address && <div className="flex items-center gap-2 text-gray-700"><MapPin size={14} />{shop.address}, {[shop.city, shop.state, shop.zip].filter(Boolean).join(', ')}</div>}
</div>
</Card>
<Card className="p-4">
<h3 className="text-xs font-medium text-gray-500 uppercase mb-2">Rates</h3>
<p className="text-sm text-gray-700">Labor: <span className="font-medium">${shop.default_labor_rate}/hr</span></p>
<p className="text-sm text-gray-700">Parts Markup: <span className="font-medium">{(shop.default_parts_markup * 100).toFixed(0)}%</span></p>
</Card>
<Card className="p-4">
<h3 className="text-xs font-medium text-gray-500 uppercase mb-2">Notes</h3>
<p className="text-sm text-gray-700">{shop.notes || 'No notes'}</p>
</Card>
</div>
<Card>
<div className="p-4 border-b border-gray-200 flex items-center justify-between">
<h2 className="font-semibold text-gray-900">Jobs</h2>
<Link to={`/jobs?shopId=${shop.id}`}><Button variant="ghost" size="sm">View All</Button></Link>
</div>
<div className="divide-y divide-gray-100">
{jobs.length === 0 ? (
<p className="p-4 text-sm text-gray-500">No jobs for this shop yet.</p>
) : (
jobs.slice(0, 10).map((job: any) => (
<Link key={job.id} to={`/jobs/${job.id}`} className="flex items-center justify-between p-3 hover:bg-gray-50">
<div>
<p className="text-sm font-medium text-gray-900">{job.job_number}</p>
<p className="text-xs text-gray-500">{[job.year, job.make, job.model].filter(Boolean).join(' ')} {job.first_name} {job.last_name}</p>
</div>
<Badge status={job.status}>{statusLabel[job.status]}</Badge>
</Link>
))
)}
</div>
</Card>
<Card>
<div className="p-4 border-b border-gray-200">
<h2 className="font-semibold text-gray-900">Invoices</h2>
</div>
<div className="divide-y divide-gray-100">
{invoices.length === 0 ? (
<p className="p-4 text-sm text-gray-500">No invoices yet.</p>
) : (
invoices.slice(0, 10).map((inv: any) => (
<Link key={inv.id} to={`/invoices/${inv.id}`} className="flex items-center justify-between p-3 hover:bg-gray-50">
<div>
<p className="text-sm font-medium text-gray-900">{inv.invoice_number}</p>
<p className="text-xs text-gray-500">{new Date(inv.invoice_date).toLocaleDateString()}</p>
</div>
<div className="text-right">
<p className="text-sm font-medium">{fmt(inv.total)}</p>
<Badge status={inv.status}>{statusLabel[inv.status]}</Badge>
</div>
</Link>
))
)}
</div>
</Card>
</div>
);
}

122
client/src/pages/Shops.tsx Normal file
View File

@@ -0,0 +1,122 @@
import { useState } from 'react';
import { Link } from 'react-router-dom';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { Building2, Plus, Search, Phone, Mail } from 'lucide-react';
import { api } from '../api/client';
import { Card, Button, Input, Modal, EmptyState } from '../components/ui';
import type { Shop } from '../types';
export default function Shops() {
const [search, setSearch] = useState('');
const [showModal, setShowModal] = useState(false);
const [editShop, setEditShop] = useState<Shop | null>(null);
const queryClient = useQueryClient();
const { data: shops = [], isLoading } = useQuery({
queryKey: ['shops', search],
queryFn: () => api.getShops({ search: search || undefined, active: 1 }),
});
const saveMutation = useMutation({
mutationFn: (data: any) => editShop ? api.updateShop(editShop.id, data) : api.createShop(data),
onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['shops'] }); setShowModal(false); setEditShop(null); },
});
const openNew = () => { setEditShop(null); setShowModal(true); };
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold text-gray-900">Referring Shops</h1>
<Button onClick={openNew}><Plus size={16} /> Add Shop</Button>
</div>
<div className="relative">
<Search size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400" />
<input
className="w-full pl-9 pr-3 py-2 text-sm border border-gray-300 rounded-lg focus:border-blue-500 focus:ring-1 focus:ring-blue-500 outline-none"
placeholder="Search shops..."
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
</div>
{isLoading ? (
<p className="text-gray-500 py-4">Loading...</p>
) : shops.length === 0 ? (
<EmptyState icon={Building2} title="No shops yet" description="Add your first referring shop to get started." action={<Button onClick={openNew}>Add Shop</Button>} />
) : (
<div className="grid sm:grid-cols-2 lg:grid-cols-3 gap-4">
{shops.map((shop: Shop) => (
<Link key={shop.id} to={`/shops/${shop.id}`}>
<Card className="p-4 hover:border-blue-300 transition-colors h-full">
<h3 className="font-semibold text-gray-900">{shop.name}</h3>
{shop.contact_name && <p className="text-sm text-gray-500 mt-0.5">{shop.contact_name}</p>}
<div className="mt-2 space-y-1 text-xs text-gray-500">
{shop.phone && <div className="flex items-center gap-1.5"><Phone size={12} />{shop.phone}</div>}
{shop.email && <div className="flex items-center gap-1.5"><Mail size={12} />{shop.email}</div>}
</div>
<div className="mt-3 flex gap-3 text-xs text-gray-500">
<span>Rate: ${shop.default_labor_rate}/hr</span>
<span>Markup: {(shop.default_parts_markup * 100).toFixed(0)}%</span>
</div>
</Card>
</Link>
))}
</div>
)}
<ShopModal open={showModal} onClose={() => { setShowModal(false); setEditShop(null); }} shop={editShop} onSave={(data) => saveMutation.mutate(data)} saving={saveMutation.isPending} />
</div>
);
}
function ShopModal({ open, onClose, shop, onSave, saving }: { open: boolean; onClose: () => void; shop: Shop | null; onSave: (data: any) => void; saving: boolean }) {
const [form, setForm] = useState({
name: '', contactName: '', phone: '', email: '', address: '', city: '', state: '', zip: '', notes: '', defaultLaborRate: '75', defaultPartsMarkup: '30',
});
useState(() => {
if (shop) {
setForm({
name: shop.name, contactName: shop.contact_name || '', phone: shop.phone || '', email: shop.email || '',
address: shop.address || '', city: shop.city || '', state: shop.state || '', zip: shop.zip || '',
notes: shop.notes || '', defaultLaborRate: String(shop.default_labor_rate), defaultPartsMarkup: String(shop.default_parts_markup * 100),
});
} else {
setForm({ name: '', contactName: '', phone: '', email: '', address: '', city: '', state: '', zip: '', notes: '', defaultLaborRate: '75', defaultPartsMarkup: '30' });
}
});
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
onSave({ ...form, defaultLaborRate: parseFloat(form.defaultLaborRate), defaultPartsMarkup: parseFloat(form.defaultPartsMarkup) / 100 });
};
return (
<Modal open={open} onClose={onClose} title={shop ? 'Edit Shop' : 'Add Shop'}>
<form onSubmit={handleSubmit} className="space-y-3">
<Input label="Shop Name *" value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} required />
<Input label="Contact Name" value={form.contactName} onChange={(e) => setForm({ ...form, contactName: e.target.value })} />
<div className="grid grid-cols-2 gap-3">
<Input label="Phone" value={form.phone} onChange={(e) => setForm({ ...form, phone: e.target.value })} />
<Input label="Email" value={form.email} onChange={(e) => setForm({ ...form, email: e.target.value })} />
</div>
<Input label="Address" value={form.address} onChange={(e) => setForm({ ...form, address: e.target.value })} />
<div className="grid grid-cols-3 gap-3">
<Input label="City" value={form.city} onChange={(e) => setForm({ ...form, city: e.target.value })} />
<Input label="State" value={form.state} onChange={(e) => setForm({ ...form, state: e.target.value })} />
<Input label="ZIP" value={form.zip} onChange={(e) => setForm({ ...form, zip: e.target.value })} />
</div>
<div className="grid grid-cols-2 gap-3">
<Input label="Labor Rate ($/hr)" type="number" step="0.01" value={form.defaultLaborRate} onChange={(e) => setForm({ ...form, defaultLaborRate: e.target.value })} />
<Input label="Parts Markup (%)" type="number" step="1" value={form.defaultPartsMarkup} onChange={(e) => setForm({ ...form, defaultPartsMarkup: e.target.value })} />
</div>
<div className="flex justify-end gap-2 pt-2">
<Button variant="secondary" type="button" onClick={onClose}>Cancel</Button>
<Button type="submit" disabled={saving}>{saving ? 'Saving...' : 'Save'}</Button>
</div>
</form>
</Modal>
);
}

View File

@@ -0,0 +1,94 @@
import { useState } from 'react';
import { Link } from 'react-router-dom';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { HardHat, Plus, Phone, Mail } from 'lucide-react';
import { api } from '../api/client';
import { Card, Button, Badge, Input, Modal, EmptyState } from '../components/ui';
import type { Technician } from '../types';
const fmt = (n: number) => '$' + n.toFixed(2);
export default function Technicians() {
const [showModal, setShowModal] = useState(false);
const [editTech, setEditTech] = useState<Technician | null>(null);
const queryClient = useQueryClient();
const { data: technicians = [], isLoading } = useQuery({
queryKey: ['technicians'],
queryFn: () => api.getTechnicians(),
});
const saveMutation = useMutation({
mutationFn: (data: any) => editTech ? api.updateTechnician(editTech.id, data) : api.createTechnician(data),
onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['technicians'] }); setShowModal(false); setEditTech(null); },
});
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold text-gray-900">Technicians</h1>
<Button onClick={() => { setEditTech(null); setShowModal(true); }}><Plus size={16} /> Add Technician</Button>
</div>
{isLoading ? <p className="text-gray-500 py-4">Loading...</p> : technicians.length === 0 ? (
<EmptyState icon={HardHat} title="No technicians" description="Add technicians to assign them to jobs and track time." action={<Button onClick={() => setShowModal(true)}>Add Technician</Button>} />
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
{technicians.map((tech: Technician) => (
<Card key={tech.id} className="p-4">
<div className="flex items-start justify-between">
<div>
<Link to={`/technicians/${tech.id}`} className="font-medium text-blue-600 hover:underline">{tech.name}</Link>
{tech.specialty && <p className="text-xs text-gray-500 mt-0.5">{tech.specialty}</p>}
<div className="flex flex-col gap-1 mt-2 text-xs text-gray-500">
{tech.phone && <span className="flex items-center gap-1"><Phone size={11} /> {tech.phone}</span>}
{tech.email && <span className="flex items-center gap-1"><Mail size={11} /> {tech.email}</span>}
</div>
<p className="text-xs text-gray-400 mt-2">Rate: {fmt(tech.hourly_rate)}/hr</p>
</div>
<div className="flex items-center gap-2">
<Badge color={tech.is_active ? 'green' : 'gray'}>{tech.is_active ? 'Active' : 'Inactive'}</Badge>
<Button variant="ghost" size="sm" onClick={() => { setEditTech(tech); setShowModal(true); }}>Edit</Button>
</div>
</div>
</Card>
))}
</div>
)}
<TechnicianModal open={showModal} onClose={() => { setShowModal(false); setEditTech(null); }} tech={editTech} onSave={(data) => saveMutation.mutate(data)} saving={saveMutation.isPending} />
</div>
);
}
function TechnicianModal({ open, onClose, tech, onSave, saving }: {
open: boolean; onClose: () => void; tech: Technician | null; onSave: (data: any) => void; saving: boolean;
}) {
const [form, setForm] = useState({
name: tech?.name || '',
phone: tech?.phone || '',
email: tech?.email || '',
specialty: tech?.specialty || '',
hourlyRate: tech?.hourly_rate?.toString() || '0',
});
return (
<Modal open={open} onClose={onClose} title={tech ? 'Edit Technician' : 'Add Technician'}>
<form onSubmit={(e) => { e.preventDefault(); onSave({ ...form, hourlyRate: parseFloat(form.hourlyRate) || 0 }); }} className="space-y-3">
<Input label="Name *" value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} required />
<div className="grid grid-cols-2 gap-3">
<Input label="Phone" value={form.phone} onChange={(e) => setForm({ ...form, phone: e.target.value })} />
<Input label="Email" value={form.email} onChange={(e) => setForm({ ...form, email: e.target.value })} />
</div>
<div className="grid grid-cols-2 gap-3">
<Input label="Specialty" value={form.specialty} onChange={(e) => setForm({ ...form, specialty: e.target.value })} placeholder="e.g. Brakes, Engine" />
<Input label="Hourly Rate" type="number" step="0.01" value={form.hourlyRate} onChange={(e) => setForm({ ...form, hourlyRate: e.target.value })} />
</div>
<div className="flex justify-end gap-2 pt-2">
<Button variant="secondary" type="button" onClick={onClose}>Cancel</Button>
<Button type="submit" disabled={saving}>{saving ? 'Saving...' : 'Save'}</Button>
</div>
</form>
</Modal>
);
}

View File

@@ -0,0 +1,287 @@
import { useState } from 'react';
import { useParams, Link } from 'react-router-dom';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { ArrowLeft, Wrench, Edit2, FileText, ClipboardCheck } from 'lucide-react';
import { api } from '../api/client';
import { Card, Button, Badge, Input, Modal } from '../components/ui';
import FileUpload from '../components/FileUpload';
import VehicleInspectionPanel from '../components/VehicleInspectionPanel';
import type { Vehicle, Job } from '../types';
const fmt = (n: number) => '$' + n.toFixed(2);
const statusLabel: Record<string, string> = {
received: 'Received', in_progress: 'In Progress', completed: 'Completed', invoiced: 'Invoiced', closed: 'Closed',
};
const invStatusLabel: Record<string, string> = {
stock: 'In Stock', in_progress: 'In Progress', listed: 'Listed', sold: 'Sold',
};
const invStatusColor: Record<string, string> = {
stock: 'blue', in_progress: 'yellow', listed: 'purple', sold: 'green',
};
export default function VehicleDetail() {
const { id } = useParams();
const queryClient = useQueryClient();
const [showEditModal, setShowEditModal] = useState(false);
const [showHistory, setShowHistory] = useState(false);
const { data: vehicle, isLoading } = useQuery<Vehicle & { jobs: Job[] }>({
queryKey: ['vehicles', id],
queryFn: () => api.getVehicle(Number(id)),
});
const { data: history } = useQuery({
queryKey: ['vehicles', id, 'history'],
queryFn: () => api.getVehicleHistory(Number(id)),
enabled: showHistory,
});
const updateMutation = useMutation({
mutationFn: (data: any) => api.updateVehicle(Number(id), data),
onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['vehicles', id] }); setShowEditModal(false); },
});
if (isLoading || !vehicle) return <p className="text-gray-500 py-4">Loading...</p>;
const vehicleStr = [vehicle.year, vehicle.make, vehicle.model].filter(Boolean).join(' ');
const jobs = (vehicle as any).jobs || [];
return (
<div className="space-y-6">
<Link to="/vehicles" className="inline-flex items-center gap-1.5 text-sm text-gray-500 hover:text-gray-700">
<ArrowLeft size={16} /> Back to Vehicles
</Link>
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
<div>
<div className="flex items-center gap-3">
<h1 className="text-2xl font-bold text-gray-900">{vehicleStr}</h1>
{vehicle.is_inventory ? (
<Badge color={invStatusColor[vehicle.inventory_status]}>{invStatusLabel[vehicle.inventory_status]}</Badge>
) : (
<Badge color="gray">Customer</Badge>
)}
</div>
{vehicle.owner_id && vehicle.first_name && (
<Link to={`/owners/${vehicle.owner_id}`} className="text-sm text-blue-600 hover:underline">
{vehicle.first_name} {vehicle.last_name}
</Link>
)}
</div>
<div className="flex gap-2">
<Button variant="secondary" onClick={() => setShowHistory(!showHistory)}>
<FileText size={16} /> {showHistory ? 'Hide' : 'Service'} History
</Button>
<Button variant="secondary" onClick={() => setShowEditModal(true)}>
<Edit2 size={16} /> Edit
</Button>
</div>
</div>
{/* Vehicle Info */}
<Card className="p-4">
<div className="grid grid-cols-2 sm:grid-cols-3 gap-4 text-sm">
{vehicle.vin && (
<div>
<span className="text-xs text-gray-500 uppercase">VIN</span>
<p className="font-mono text-gray-900">{vehicle.vin}</p>
</div>
)}
{vehicle.license_plate && (
<div>
<span className="text-xs text-gray-500 uppercase">License Plate</span>
<p className="text-gray-900">{vehicle.license_plate}</p>
</div>
)}
{vehicle.color && (
<div>
<span className="text-xs text-gray-500 uppercase">Color</span>
<p className="text-gray-900">{vehicle.color}</p>
</div>
)}
{vehicle.mileage && (
<div>
<span className="text-xs text-gray-500 uppercase">Mileage</span>
<p className="text-gray-900">{vehicle.mileage.toLocaleString()}</p>
</div>
)}
{vehicle.is_inventory === 1 && vehicle.purchase_price != null && (
<div>
<span className="text-xs text-gray-500 uppercase">Purchase Price</span>
<p className="text-gray-900">{fmt(vehicle.purchase_price)}</p>
</div>
)}
{vehicle.is_inventory === 1 && vehicle.sale_price != null && (
<div>
<span className="text-xs text-gray-500 uppercase">Sale Price</span>
<p className="text-gray-900">{fmt(vehicle.sale_price)}</p>
</div>
)}
</div>
{vehicle.notes && (
<div className="mt-4 pt-4 border-t border-gray-200">
<span className="text-xs text-gray-500 uppercase">Notes</span>
<p className="text-sm text-gray-700 mt-1">{vehicle.notes}</p>
</div>
)}
</Card>
{/* Inspections */}
<VehicleInspectionPanel vehicleId={Number(id)} />
{/* Service History Report */}
{showHistory && history && (
<Card>
<div className="p-4 border-b border-gray-200">
<h2 className="font-semibold text-gray-900">Complete Service History</h2>
<div className="flex gap-4 mt-2 text-sm text-gray-600">
<span><span className="font-medium">{history.summary.totalJobs}</span> jobs</span>
<span><span className="font-medium">{fmt(history.summary.totalSpent)}</span> total</span>
{history.summary.firstService && <span>Since {new Date(history.summary.firstService).toLocaleDateString()}</span>}
</div>
</div>
<div className="divide-y divide-gray-100">
{history.jobs.map((job: any) => (
<div key={job.id} className="p-4">
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<Link to={`/jobs/${job.id}`} className="font-mono text-sm font-semibold text-blue-600 hover:underline">{job.job_number}</Link>
<Badge status={job.status}>{statusLabel[job.status]}</Badge>
</div>
<div className="text-right">
<p className="text-sm font-medium">{fmt(job.total)}</p>
<p className="text-xs text-gray-500">{new Date(job.received_date).toLocaleDateString()}</p>
</div>
</div>
{job.description && <p className="text-sm text-gray-600 mb-2">{job.description}</p>}
{job.shop_name && <p className="text-xs text-gray-500 mb-2">Via: {job.shop_name}</p>}
{job.laborItems.length > 0 && (
<div className="ml-3 mb-1">
{job.laborItems.map((item: any) => (
<p key={item.id} className="text-xs text-gray-600">
<span className="text-gray-400">Labor:</span> {item.description}
{item.billing_type === 'flat_rate' ? `${fmt(item.flat_rate || 0)}` : `${item.hours}h @ ${fmt(item.rate || 0)}/hr`}
</p>
))}
</div>
)}
{job.partsItems.length > 0 && (
<div className="ml-3 mb-1">
{job.partsItems.map((item: any) => (
<p key={item.id} className="text-xs text-gray-600">
<span className="text-gray-400">Parts:</span> {item.description}
{item.part_number && <span className="text-gray-400"> ({item.part_number})</span>}
{' — '}{fmt(item.quantity * item.unit_cost * (1 + item.markup))}
</p>
))}
</div>
)}
{job.recommendations.length > 0 && (
<div className="ml-3 mt-1">
{job.recommendations.map((rec: any) => (
<p key={rec.id} className={`text-xs ${rec.urgency === 'now' ? 'text-red-600' : rec.urgency === 'soon' ? 'text-yellow-600' : 'text-blue-600'}`}>
Rec: {rec.description} {rec.estimated_cost && `(~${fmt(rec.estimated_cost)})`}
</p>
))}
</div>
)}
</div>
))}
</div>
</Card>
)}
{/* Files */}
<Card className="p-4">
<FileUpload entityType="vehicle" entityId={Number(id)} />
</Card>
{/* Job History */}
<Card>
<div className="p-4 border-b border-gray-200">
<h2 className="font-semibold text-gray-900">Service History ({jobs.length})</h2>
</div>
{jobs.length === 0 ? (
<p className="p-4 text-sm text-gray-500">No jobs for this vehicle yet.</p>
) : (
<div className="divide-y divide-gray-100">
{jobs.map((job: any) => (
<div key={job.id} className="flex items-center justify-between p-3 hover:bg-gray-50">
<div>
<Link to={`/jobs/${job.id}`} className="text-sm font-medium text-blue-600 hover:underline">{job.job_number}</Link>
<p className="text-xs text-gray-500">
{job.description?.slice(0, 60)}{job.description?.length > 60 ? '...' : ''}
{job.shop_name && `${job.shop_name}`}
</p>
</div>
<Badge status={job.status}>{statusLabel[job.status]}</Badge>
</div>
))}
</div>
)}
</Card>
{showEditModal && (
<EditVehicleModal
vehicle={vehicle}
open={showEditModal}
onClose={() => setShowEditModal(false)}
onSave={(data) => updateMutation.mutate(data)}
saving={updateMutation.isPending}
/>
)}
</div>
);
}
function EditVehicleModal({ vehicle, open, onClose, onSave, saving }: {
vehicle: Vehicle; open: boolean; onClose: () => void; onSave: (data: any) => void; saving: boolean;
}) {
const [form, setForm] = useState({
year: vehicle.year?.toString() || '',
make: vehicle.make,
model: vehicle.model,
vin: vehicle.vin || '',
color: vehicle.color || '',
licensePlate: vehicle.license_plate || '',
mileage: vehicle.mileage?.toString() || '',
notes: vehicle.notes || '',
});
return (
<Modal open={open} onClose={onClose} title="Edit Vehicle">
<form onSubmit={(e) => {
e.preventDefault();
onSave({
...form,
year: form.year ? parseInt(form.year) : null,
mileage: form.mileage ? parseInt(form.mileage) : null,
});
}} className="space-y-3">
<div className="grid grid-cols-3 gap-3">
<Input label="Year" type="number" value={form.year} onChange={(e) => setForm({ ...form, year: e.target.value })} />
<Input label="Make *" value={form.make} onChange={(e) => setForm({ ...form, make: e.target.value })} required />
<Input label="Model *" value={form.model} onChange={(e) => setForm({ ...form, model: e.target.value })} required />
</div>
<Input label="VIN" value={form.vin} onChange={(e) => setForm({ ...form, vin: e.target.value })} />
<div className="grid grid-cols-3 gap-3">
<Input label="Color" value={form.color} onChange={(e) => setForm({ ...form, color: e.target.value })} />
<Input label="License Plate" value={form.licensePlate} onChange={(e) => setForm({ ...form, licensePlate: e.target.value })} />
<Input label="Mileage" type="number" value={form.mileage} onChange={(e) => setForm({ ...form, mileage: e.target.value })} />
</div>
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Notes</label>
<textarea className="block w-full rounded-lg border border-gray-300 px-3 py-2 text-sm" rows={2} value={form.notes} onChange={(e) => setForm({ ...form, notes: e.target.value })} />
</div>
<div className="flex justify-end gap-2 pt-2">
<Button variant="secondary" type="button" onClick={onClose}>Cancel</Button>
<Button type="submit" disabled={saving}>{saving ? 'Saving...' : 'Save'}</Button>
</div>
</form>
</Modal>
);
}

View File

@@ -0,0 +1,195 @@
import { useState } from 'react';
import { Link } from 'react-router-dom';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { Car, Search, Plus } from 'lucide-react';
import { api } from '../api/client';
import { Card, Badge, Button, Input, Modal, EmptyState } from '../components/ui';
import type { Vehicle, Owner } from '../types';
const invStatusLabel: Record<string, string> = {
stock: 'In Stock', in_progress: 'In Progress', listed: 'Listed', sold: 'Sold',
};
const invStatusColor: Record<string, string> = {
stock: 'blue', in_progress: 'yellow', listed: 'purple', sold: 'green',
};
export default function Vehicles() {
const [search, setSearch] = useState('');
const [tab, setTab] = useState<'all' | 'customer' | 'inventory'>('all');
const [showAddModal, setShowAddModal] = useState(false);
const queryClient = useQueryClient();
const { data: vehicles = [], isLoading } = useQuery({
queryKey: ['vehicles', search, tab],
queryFn: () => api.getVehicles({
search: search || undefined,
...(tab === 'inventory' ? { inventory: 1 } : tab === 'customer' ? { inventory: 0 } : {}),
}),
});
const createMutation = useMutation({
mutationFn: api.createVehicle,
onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['vehicles'] }); setShowAddModal(false); },
});
return (
<div className="space-y-4">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold text-gray-900">Vehicles</h1>
<Button onClick={() => setShowAddModal(true)}><Plus size={16} /> Add Vehicle</Button>
</div>
<div className="flex gap-3 flex-col sm:flex-row">
<div className="relative flex-1">
<Search size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400" />
<input className="w-full pl-9 pr-3 py-2 text-sm border border-gray-300 rounded-lg focus:border-blue-500 focus:ring-1 focus:ring-blue-500 outline-none" placeholder="Search by make, model, VIN, or owner..." value={search} onChange={(e) => setSearch(e.target.value)} />
</div>
<div className="flex gap-1">
{([['all', 'All'], ['customer', 'Customer'], ['inventory', 'Inventory']] as const).map(([key, label]) => (
<button key={key} onClick={() => setTab(key)}
className={`px-3 py-1.5 text-xs font-medium rounded-lg transition-colors ${tab === key ? 'bg-blue-100 text-blue-700' : 'bg-gray-100 text-gray-600 hover:bg-gray-200'}`}>
{label}
</button>
))}
</div>
</div>
{isLoading ? <p className="text-gray-500 py-4">Loading...</p> : vehicles.length === 0 ? (
<EmptyState icon={Car} title="No vehicles found" description="Add a customer vehicle or inventory vehicle." action={<Button onClick={() => setShowAddModal(true)}>Add Vehicle</Button>} />
) : (
<Card>
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-gray-200 text-left text-xs text-gray-500 uppercase">
<th className="px-4 py-3">Vehicle</th>
<th className="px-4 py-3">Owner</th>
<th className="px-4 py-3">Type</th>
<th className="px-4 py-3">Stock #</th>
<th className="px-4 py-3">Mileage</th>
<th className="px-4 py-3">VIN</th>
<th className="px-4 py-3">Color</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{vehicles.map((v: Vehicle) => (
<tr key={v.id} className="hover:bg-gray-50">
<td className="px-4 py-3 font-medium">
<Link to={`/vehicles/${v.id}`} className="text-blue-600 hover:underline">
{[v.year, v.make, v.model].filter(Boolean).join(' ')}
</Link>
</td>
<td className="px-4 py-3 text-gray-700">
{v.first_name && v.last_name ? `${v.first_name} ${v.last_name}` : <span className="text-gray-400"></span>}
</td>
<td className="px-4 py-3">
{v.is_inventory ? (
<Badge color={invStatusColor[v.inventory_status]}>{invStatusLabel[v.inventory_status] || v.inventory_status}</Badge>
) : (
<span className="text-xs text-gray-500">Customer</span>
)}
</td>
<td className="px-4 py-3 text-xs font-mono text-gray-500">{(v as any).stock_number || '—'}</td>
<td className="px-4 py-3 text-gray-500">{v.mileage ? v.mileage.toLocaleString() : '—'}</td>
<td className="px-4 py-3 text-gray-500 text-xs font-mono">{v.vin || '—'}</td>
<td className="px-4 py-3 text-gray-500">{v.color || '—'}</td>
</tr>
))}
</tbody>
</table>
</div>
</Card>
)}
<AddVehicleModal open={showAddModal} onClose={() => setShowAddModal(false)} onSave={(data) => createMutation.mutate(data)} saving={createMutation.isPending} />
</div>
);
}
function AddVehicleModal({ open, onClose, onSave, saving }: { open: boolean; onClose: () => void; onSave: (data: any) => void; saving: boolean }) {
const [vehicleType, setVehicleType] = useState<'customer' | 'inventory'>('customer');
const [ownerId, setOwnerId] = useState('');
const [year, setYear] = useState('');
const [make, setMake] = useState('');
const [model, setModel] = useState('');
const [vin, setVin] = useState('');
const [color, setColor] = useState('');
const [licensePlate, setLicensePlate] = useState('');
const [mileage, setMileage] = useState('');
const [purchasePrice, setPurchasePrice] = useState('');
const [stockNumber, setStockNumber] = useState('');
const { data: owners = [] } = useQuery({ queryKey: ['owners'], queryFn: () => api.getOwners() });
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
onSave({
ownerId: vehicleType === 'customer' ? Number(ownerId) : null,
year: year ? parseInt(year) : null,
make, model,
vin: vin || null,
color: color || null,
licensePlate: licensePlate || null,
mileage: mileage ? parseInt(mileage) : null,
isInventory: vehicleType === 'inventory',
purchasePrice: purchasePrice ? parseFloat(purchasePrice) : null,
stockNumber: stockNumber || null,
});
};
return (
<Modal open={open} onClose={onClose} title="Add Vehicle">
<form onSubmit={handleSubmit} className="space-y-3">
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Vehicle Type</label>
<div className="flex gap-2">
<button type="button" onClick={() => setVehicleType('customer')}
className={`flex-1 px-3 py-2 text-sm font-medium rounded-lg border transition-colors ${vehicleType === 'customer' ? 'bg-blue-50 border-blue-300 text-blue-700' : 'border-gray-300 text-gray-600 hover:bg-gray-50'}`}>
Customer Vehicle
</button>
<button type="button" onClick={() => setVehicleType('inventory')}
className={`flex-1 px-3 py-2 text-sm font-medium rounded-lg border transition-colors ${vehicleType === 'inventory' ? 'bg-blue-50 border-blue-300 text-blue-700' : 'border-gray-300 text-gray-600 hover:bg-gray-50'}`}>
Inventory / Flip
</button>
</div>
</div>
{vehicleType === 'customer' && (
<div>
<label className="block text-sm font-medium text-gray-700 mb-1">Owner *</label>
<select className="block w-full rounded-lg border border-gray-300 px-3 py-2 text-sm" value={ownerId} onChange={(e) => setOwnerId(e.target.value)} required>
<option value="">Select an owner...</option>
{owners.map((o: Owner) => <option key={o.id} value={o.id}>{o.first_name} {o.last_name}</option>)}
</select>
</div>
)}
<div className="grid grid-cols-3 gap-3">
<Input label="Year" type="number" value={year} onChange={(e) => setYear(e.target.value)} />
<Input label="Make *" value={make} onChange={(e) => setMake(e.target.value)} required />
<Input label="Model *" value={model} onChange={(e) => setModel(e.target.value)} required />
</div>
<Input label="VIN" value={vin} onChange={(e) => setVin(e.target.value)} />
<div className="grid grid-cols-3 gap-3">
<Input label="Color" value={color} onChange={(e) => setColor(e.target.value)} />
<Input label="License Plate" value={licensePlate} onChange={(e) => setLicensePlate(e.target.value)} />
<Input label="Mileage" type="number" value={mileage} onChange={(e) => setMileage(e.target.value)} />
</div>
{vehicleType === 'inventory' && (
<div className="grid grid-cols-2 gap-3">
<Input label="Purchase Price" type="number" step="0.01" value={purchasePrice} onChange={(e) => setPurchasePrice(e.target.value)} />
<Input label="Stock # (auto if blank)" value={stockNumber} onChange={(e) => setStockNumber(e.target.value)} placeholder="STK-0001" />
</div>
)}
<div className="flex justify-end gap-2 pt-2">
<Button variant="secondary" type="button" onClick={onClose}>Cancel</Button>
<Button type="submit" disabled={saving || !make || !model || (vehicleType === 'customer' && !ownerId)}>
{saving ? 'Saving...' : 'Add Vehicle'}
</Button>
</div>
</form>
</Modal>
);
}

28
client/src/store/index.ts Normal file
View File

@@ -0,0 +1,28 @@
import { create } from 'zustand';
import type { User } from '../types';
interface AppState {
token: string | null;
user: User | null;
sidebarOpen: boolean;
login: (token: string, user: User) => void;
logout: () => void;
setUser: (user: User) => void;
toggleSidebar: () => void;
}
export const useStore = create<AppState>((set) => ({
token: localStorage.getItem('autoking_token'),
user: null,
sidebarOpen: false,
login: (token, user) => {
localStorage.setItem('autoking_token', token);
set({ token, user });
},
logout: () => {
localStorage.removeItem('autoking_token');
set({ token: null, user: null });
},
setUser: (user) => set({ user }),
toggleSidebar: () => set((s) => ({ sidebarOpen: !s.sidebarOpen })),
}));

415
client/src/types/index.ts Normal file
View File

@@ -0,0 +1,415 @@
export interface User {
id: number;
username: string;
displayName: string | null;
}
export interface Shop {
id: number;
name: string;
contact_name: string | null;
phone: string | null;
email: string | null;
address: string | null;
city: string | null;
state: string | null;
zip: string | null;
notes: string | null;
default_labor_rate: number;
default_parts_markup: number;
active: number;
created_at: string;
updated_at: string;
jobCount?: number;
outstandingBalance?: number;
}
export interface Owner {
id: number;
first_name: string;
last_name: string;
phone: string | null;
email: string | null;
notes: string | null;
created_at: string;
updated_at: string;
vehicles?: Vehicle[];
}
export interface Vehicle {
id: number;
owner_id: number | null;
year: number | null;
make: string;
model: string;
vin: string | null;
color: string | null;
license_plate: string | null;
mileage: number | null;
is_inventory: number;
inventory_status: InventoryStatus;
purchase_price: number | null;
purchase_date: string | null;
sale_price: number | null;
sale_date: string | null;
notes: string | null;
created_at: string;
updated_at: string;
first_name?: string;
last_name?: string;
}
export type CustomerType = 'owner' | 'shop' | 'internal';
export type JobStatus = 'received' | 'in_progress' | 'completed' | 'invoiced' | 'closed';
export type LaborBillingType = 'hourly' | 'flat_rate';
export type InventoryStatus = 'stock' | 'in_progress' | 'listed' | 'sold';
export interface LaborItem {
id: number;
job_id: number;
description: string;
billing_type: LaborBillingType;
hours: number | null;
rate: number | null;
flat_rate: number | null;
sort_order: number;
created_at: string;
}
export interface PartsItem {
id: number;
job_id: number;
description: string;
part_number: string | null;
quantity: number;
unit_cost: number;
markup: number;
charge_price: number | null;
sort_order: number;
created_at: string;
}
export interface JobNote {
id: number;
job_id: number;
note: string;
created_at: string;
}
export interface Job {
id: number;
job_number: string;
customer_type: CustomerType;
shop_id: number | null;
owner_id: number;
vehicle_id: number;
status: JobStatus;
description: string | null;
received_date: string;
started_date: string | null;
completed_date: string | null;
notes: string | null;
created_at: string;
updated_at: string;
shop_name?: string;
year?: number;
make?: string;
model?: string;
vin?: string;
first_name?: string;
last_name?: string;
default_labor_rate?: number;
default_parts_markup?: number;
laborItems?: LaborItem[];
partsItems?: PartsItem[];
tags?: Tag[];
laborTotal?: number;
partsTotal?: number;
total?: number;
}
export type InvoiceStatus = 'draft' | 'sent' | 'paid' | 'partial' | 'overdue';
export interface Payment {
id: number;
invoice_id: number;
amount: number;
payment_date: string;
method: string | null;
reference: string | null;
notes: string | null;
created_at: string;
}
export interface Invoice {
id: number;
invoice_number: string;
shop_id: number | null;
owner_id: number | null;
status: InvoiceStatus;
invoice_date: string;
due_date: string | null;
subtotal: number;
tax_rate: number;
tax_amount: number;
total: number;
notes: string | null;
created_at: string;
updated_at: string;
shop_name?: string;
owner_first_name?: string;
owner_last_name?: string;
display_name?: string;
amountPaid?: number;
amountDue?: number;
jobs?: Job[];
payments?: Payment[];
}
export type EstimateStatus = 'draft' | 'sent' | 'approved' | 'declined' | 'expired' | 'converted';
export interface EstimateItem {
id: number;
estimate_id: number;
item_type: 'labor' | 'parts';
description: string;
quantity: number;
unit_price: number;
markup: number;
billing_type: LaborBillingType | null;
hours: number | null;
rate: number | null;
flat_rate: number | null;
sort_order: number;
created_at: string;
}
export interface Estimate {
id: number;
estimate_number: string;
customer_type: CustomerType;
shop_id: number | null;
owner_id: number | null;
vehicle_id: number | null;
status: EstimateStatus;
subtotal: number;
tax_rate: number;
tax_amount: number;
total: number;
notes: string | null;
valid_until: string | null;
created_at: string;
updated_at: string;
shop_name?: string;
owner_first_name?: string;
owner_last_name?: string;
year?: number;
make?: string;
model?: string;
items?: EstimateItem[];
}
export interface CannedServiceItem {
id: number;
canned_service_id: number;
item_type: 'labor' | 'parts';
description: string;
billing_type: LaborBillingType | null;
hours: number | null;
rate: number | null;
flat_rate: number | null;
quantity: number;
unit_cost: number;
markup: number;
sort_order: number;
}
export interface CannedService {
id: number;
name: string;
description: string | null;
category: string | null;
is_active: number;
items?: CannedServiceItem[];
created_at: string;
updated_at: string;
}
export interface Technician {
id: number;
name: string;
phone: string | null;
email: string | null;
specialty: string | null;
hourly_rate: number;
is_active: number;
created_at: string;
updated_at: string;
}
export interface TimeEntry {
id: number;
technician_id: number;
job_id: number;
clock_in: string;
clock_out: string | null;
duration_minutes: number | null;
notes: string | null;
technician_name?: string;
created_at: string;
}
export type InspectionCondition = 'good' | 'fair' | 'poor' | 'critical' | 'not_inspected';
export interface InspectionTemplate {
id: number;
name: string;
description: string | null;
is_default: number;
items?: InspectionTemplateItem[];
}
export interface InspectionTemplateItem {
id: number;
template_id: number;
section: string;
label: string;
sort_order: number;
}
export interface InspectionItem {
id: number;
inspection_id: number;
template_item_id: number | null;
section: string;
label: string;
condition: InspectionCondition;
notes: string | null;
sort_order: number;
photos?: FileRecord[];
}
export interface Inspection {
id: number;
job_id: number;
template_id: number | null;
technician_id: number | null;
status: 'in_progress' | 'completed';
created_at: string;
completed_at: string | null;
template_name?: string;
technician_name?: string;
items?: InspectionItem[];
summary?: Record<string, number>;
}
export interface Message {
id: number;
job_id: number | null;
owner_id: number | null;
shop_id: number | null;
direction: 'inbound' | 'outbound';
channel: 'internal' | 'email' | 'sms' | 'portal';
subject: string | null;
body: string;
sent_at: string;
read_at: string | null;
created_at: string;
owner_first_name?: string;
owner_last_name?: string;
shop_name?: string;
job_number?: string;
}
export interface MessageTemplate {
id: number;
name: string;
subject: string | null;
body: string;
channel: string;
trigger_event: string | null;
is_active: number;
created_at: string;
}
export interface Appointment {
id: number;
owner_id: number | null;
shop_id: number | null;
vehicle_id: number | null;
service_description: string | null;
requested_date: string;
requested_time: string | null;
duration_minutes: number;
status: 'pending' | 'confirmed' | 'cancelled' | 'completed';
notes: string | null;
created_at: string;
updated_at: string;
owner_first_name?: string;
owner_last_name?: string;
owner_phone?: string;
year?: number;
make?: string;
model?: string;
}
export interface DeferredService {
id: number;
job_id: number | null;
vehicle_id: number | null;
owner_id: number | null;
description: string;
reason: string | null;
follow_up_date: string | null;
status: string;
created_at: string;
owner_first_name?: string;
owner_last_name?: string;
year?: number;
make?: string;
model?: string;
job_number?: string;
}
export interface Settings {
id: number;
shop_name: string;
shop_address: string | null;
shop_city: string | null;
shop_state: string | null;
shop_zip: string | null;
shop_phone: string | null;
shop_email: string | null;
default_tax_rate: number;
invoice_terms: string | null;
updated_at: string;
}
export interface DashboardStats {
activeJobs: number;
completedThisMonth: number;
outstandingInvoiceCount: number;
outstandingAmount: number;
revenueThisMonth: number;
totalShops: number;
recentJobs: Job[];
outstandingInvoices: Invoice[];
}
export interface Tag {
id: number;
name: string;
color: string;
created_at: string;
}
export interface FileRecord {
id: number;
entity_type: string;
entity_id: number;
filename: string;
original_name: string;
mime_type: string | null;
size: number | null;
created_at: string;
}

28
client/tsconfig.app.json Normal file
View File

@@ -0,0 +1,28 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2023",
"useDefineForClassFields": true,
"lib": ["ES2023", "DOM", "DOM.Iterable"],
"module": "ESNext",
"types": ["vite/client"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["src"]
}

7
client/tsconfig.json Normal file
View File

@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

26
client/tsconfig.node.json Normal file
View File

@@ -0,0 +1,26 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2023",
"lib": ["ES2023"],
"module": "ESNext",
"types": ["node"],
"skipLibCheck": true,
/* Bundler mode */
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
/* Linting */
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"erasableSyntaxOnly": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true
},
"include": ["vite.config.ts"]
}

12
client/vite.config.ts Normal file
View File

@@ -0,0 +1,12 @@
import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite'
export default defineConfig({
plugins: [react(), tailwindcss()],
server: {
proxy: {
'/api': 'http://localhost:3001',
},
},
})