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:
24
client/.gitignore
vendored
Normal file
24
client/.gitignore
vendored
Normal 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
73
client/README.md
Normal 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
23
client/eslint.config.js
Normal 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
13
client/index.html
Normal 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
3367
client/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
37
client/package.json
Normal file
37
client/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
1
client/public/favicon.svg
Normal file
1
client/public/favicon.svg
Normal file
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
24
client/public/icons.svg
Normal 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
74
client/src/App.tsx
Normal 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
353
client/src/api/client.ts
Normal 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' }),
|
||||
};
|
||||
90
client/src/components/FileUpload.tsx
Normal file
90
client/src/components/FileUpload.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
227
client/src/components/InspectionPanel.tsx
Normal file
227
client/src/components/InspectionPanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
95
client/src/components/KanbanBoard.tsx
Normal file
95
client/src/components/KanbanBoard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
125
client/src/components/Layout.tsx
Normal file
125
client/src/components/Layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
104
client/src/components/TagPicker.tsx
Normal file
104
client/src/components/TagPicker.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
448
client/src/components/VehicleInspectionPanel.tsx
Normal file
448
client/src/components/VehicleInspectionPanel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
36
client/src/components/ui/Badge.tsx
Normal file
36
client/src/components/ui/Badge.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
28
client/src/components/ui/Button.tsx
Normal file
28
client/src/components/ui/Button.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
9
client/src/components/ui/Card.tsx
Normal file
9
client/src/components/ui/Card.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
19
client/src/components/ui/EmptyState.tsx
Normal file
19
client/src/components/ui/EmptyState.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
47
client/src/components/ui/Input.tsx
Normal file
47
client/src/components/ui/Input.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
33
client/src/components/ui/Modal.tsx
Normal file
33
client/src/components/ui/Modal.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
6
client/src/components/ui/index.ts
Normal file
6
client/src/components/ui/index.ts
Normal 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
13
client/src/index.css
Normal 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
10
client/src/main.tsx
Normal 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>,
|
||||
)
|
||||
161
client/src/pages/Appointments.tsx
Normal file
161
client/src/pages/Appointments.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
159
client/src/pages/Dashboard.tsx
Normal file
159
client/src/pages/Dashboard.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
363
client/src/pages/EstimateDetail.tsx
Normal file
363
client/src/pages/EstimateDetail.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
178
client/src/pages/Estimates.tsx
Normal file
178
client/src/pages/Estimates.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
154
client/src/pages/Inventory.tsx
Normal file
154
client/src/pages/Inventory.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
312
client/src/pages/InvoiceDetail.tsx
Normal file
312
client/src/pages/InvoiceDetail.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
209
client/src/pages/Invoices.tsx
Normal file
209
client/src/pages/Invoices.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
972
client/src/pages/JobDetail.tsx
Normal file
972
client/src/pages/JobDetail.tsx
Normal 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
343
client/src/pages/Jobs.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
61
client/src/pages/Login.tsx
Normal file
61
client/src/pages/Login.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
190
client/src/pages/OwnerDetail.tsx
Normal file
190
client/src/pages/OwnerDetail.tsx
Normal 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
157
client/src/pages/Owners.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
251
client/src/pages/Reports.tsx
Normal file
251
client/src/pages/Reports.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
109
client/src/pages/Settings.tsx
Normal file
109
client/src/pages/Settings.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
109
client/src/pages/ShopDetail.tsx
Normal file
109
client/src/pages/ShopDetail.tsx
Normal 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
122
client/src/pages/Shops.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
94
client/src/pages/Technicians.tsx
Normal file
94
client/src/pages/Technicians.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
287
client/src/pages/VehicleDetail.tsx
Normal file
287
client/src/pages/VehicleDetail.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
195
client/src/pages/Vehicles.tsx
Normal file
195
client/src/pages/Vehicles.tsx
Normal 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
28
client/src/store/index.ts
Normal 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
415
client/src/types/index.ts
Normal 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
28
client/tsconfig.app.json
Normal 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
7
client/tsconfig.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
26
client/tsconfig.node.json
Normal file
26
client/tsconfig.node.json
Normal 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
12
client/vite.config.ts
Normal 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',
|
||||
},
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user