Initial commit — Shop Manager for Tony Moon

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

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

19
.claude/launch.json Normal file
View File

@@ -0,0 +1,19 @@
{
"version": "0.0.1",
"configurations": [
{
"name": "server",
"runtimeExecutable": "npm",
"runtimeArgs": ["run", "dev"],
"cwd": "server",
"port": 3001
},
{
"name": "client",
"runtimeExecutable": "npm",
"runtimeArgs": ["run", "dev"],
"cwd": "client",
"port": 5173
}
]
}

3
.env.example Normal file
View File

@@ -0,0 +1,3 @@
JWT_SECRET=change-me-in-production
PORT=3001
DATA_DIR=./data

6
.gitignore vendored Normal file
View File

@@ -0,0 +1,6 @@
node_modules/
dist/
*.db
.env
.DS_Store
data/

179
CLAUDE.md Normal file
View File

@@ -0,0 +1,179 @@
# Shop Manager (autoking)
Auto mechanic shop management tool for Tony Moon. Specializes in sublet work from other shops, direct customer jobs, and internal flip car work.
## Quick Start
```bash
# Terminal 1 — Server
cd server && npm run dev
# Terminal 2 — Client
cd client && npm run dev
```
App runs at http://localhost:5173 (Vite proxies API to :3001).
First run requires account creation — no default credentials.
## Tech Stack
- **Frontend**: React 19 + TypeScript + Vite + Tailwind CSS v4
- **State**: Zustand (auth/UI only), TanStack React Query (server data)
- **Icons**: Lucide React
- **Backend**: Node.js + Express 4 + TypeScript (via tsx)
- **Database**: SQLite (better-sqlite3) at `data/autoking.db`
- **PDF**: pdfkit for invoice generation
- **File uploads**: multer, stored in `data/uploads/`
- **Auth**: JWT + bcryptjs, token in localStorage as `autoking_token`
- **Deploy**: Docker + Docker Compose, ready for Unraid
## Project Structure
```
server/src/
index.ts — Express entry, route registration
database.ts — SQLite schema (all CREATE TABLE statements)
middleware/auth.ts — JWT auth middleware (supports header + query param for PDF)
routes/
auth.ts — login, register (first-run only), me, setup-required
shops.ts — referring shops CRUD + their jobs/invoices
owners.ts — vehicle owners CRUD
vehicles.ts — vehicles CRUD (supports inventory vehicles)
jobs.ts — work orders + labor/parts/notes sub-routes
invoices.ts — invoice CRUD + PDF generation + payments (shop + owner)
estimates.ts — estimates/quotes CRUD + items + convert-to-job
canned-services.ts — saved service templates CRUD
technicians.ts — technician CRUD + clock in/out + time entries
inspections.ts — DVI templates + inspections + items + complete
messages.ts — internal messaging + templates + email settings
appointments.ts — scheduling + deferred services
inventory.ts — parts/supplies/tools inventory + purchase orders
reports.ts — 8 report endpoints (revenue, profitability, aging, etc.)
fleets.ts — fleet management + vehicle assignments
dashboard.ts — aggregated stats
settings.ts — business info for invoice headers
tags.ts — tag CRUD + job-tag associations
files.ts — file upload/download/delete (multer)
services/pdf.ts — PDF invoice generation with pdfkit
types.ts — all server-side TypeScript interfaces
client/src/
App.tsx — React Router setup, AuthGuard, QueryClient
api/client.ts — fetch wrapper with JWT, all API methods
store/index.ts — Zustand store (token, user, sidebar)
types/index.ts — all client-side TypeScript interfaces
components/
Layout.tsx — sidebar + mobile nav
KanbanBoard.tsx — drag-and-drop job kanban
TagPicker.tsx — tag management on jobs
FileUpload.tsx — file upload/display component
ui/ — Button, Card, Badge, Input, Modal, EmptyState
pages/
Login.tsx, Dashboard.tsx, Shops.tsx, ShopDetail.tsx,
Owners.tsx, OwnerDetail.tsx, Vehicles.tsx, VehicleDetail.tsx,
Jobs.tsx, JobDetail.tsx, Estimates.tsx, EstimateDetail.tsx,
Invoices.tsx, InvoiceDetail.tsx, Technicians.tsx,
Appointments.tsx, Inventory.tsx, Reports.tsx, Settings.tsx
```
## Database Schema (key tables)
- `shops` — referring shops with per-shop labor rate + parts markup
- `owners` — vehicle owners (nullable on jobs for internal work)
- `vehicles` — customer cars + inventory/flip cars (is_inventory flag, purchase_price, inventory_status)
- `jobs` — work orders with customer_type (owner/shop/internal), technician_id, estimate_id; status: received → in_progress → completed → invoiced → closed
- `labor_items` — hourly OR flat_rate billing
- `parts_items` — quantity × unit_cost × (1 + markup)
- `job_notes` — activity log per job
- `tags` / `job_tags` — freeform color-coded tags on jobs, ships with presets
- `invoices` / `invoice_jobs` / `payments` — invoice bundling + partial payments (shop OR owner)
- `estimates` / `estimate_items` — quotes with approval workflow + convert to job
- `canned_services` / `canned_service_items` — reusable service templates
- `technicians` — shop techs with rates + specialties
- `time_entries` — clock in/out per technician per job
- `inspection_templates` / `inspection_template_items` — DVI templates
- `inspections` / `inspection_items` — per-job inspections with condition ratings
- `messages` / `message_templates` — internal messaging + templates
- `email_settings` — SMTP + Twilio config
- `appointments` — scheduling with status management
- `deferred_services` — declined work tracking
- `inventory_items` — parts, supplies, consumables with stock levels
- `inventory_transactions` — stock movement log
- `purchase_orders` / `purchase_order_items` — PO management
- `tools` — tool inventory with assignment tracking
- `fleets` / `fleet_vehicles` — fleet account management
- `files` — file attachments (entity_type + entity_id pattern, works for jobs/vehicles/invoices/inspection_items)
- `settings` — single row, business info for invoice headers
- `users` — auth (first-run registration only)
## Job Numbers
Format: `TM-{year}-{seq}` (e.g., TM-2026-0001). Auto-generated server-side.
## Invoice Numbers
Format: `INV-{year}-{seq}`. Auto-generated when creating invoice from completed jobs.
## Key Patterns
- Schema changes require deleting the SQLite db file (no migrations yet)
- Vite tsconfig has `verbatimModuleSyntax` — use `import type` for type-only imports
- `noUnusedLocals` / `noUnusedParameters` enforced in client
- LEFT JOIN for shops and owners (both nullable on jobs)
- File upload uses multer with `files` field name, max 10 files, 25MB each
- PDF endpoint accepts `?token=` query param (for opening in new browser tab)
## What's Built
- [x] Dashboard with stats
- [x] Shops (referring shops) CRUD with per-shop rates
- [x] Owners + Vehicles CRUD
- [x] Vehicle inventory tracking (flip cars with purchase price, status)
- [x] Jobs with 3 customer types: Vehicle Owner, Referring Shop, Internal/Flip
- [x] Labor items: hourly + flat rate billing
- [x] Parts items with markup
- [x] Job notes
- [x] Job tags (freeform + presets, color-coded)
- [x] Kanban board (drag-and-drop status changes)
- [x] List ↔ Kanban toggle on Jobs page
- [x] Inline quick-add for owners/vehicles/shops from job creation modal
- [x] Invoices from completed jobs (multi-job bundling)
- [x] PDF invoice generation
- [x] Payment tracking (partial payments, auto-status update)
- [x] File uploads on jobs, vehicles, invoices (local disk storage)
- [x] Settings (business info for invoices)
- [x] Docker + Docker Compose
- [x] Vehicle Detail page + Owner Detail page
- [x] Owner/direct customer invoicing (not just shops)
- [x] Estimates/Quotes system (draft → sent → approved → convert to job)
- [x] Canned/Saved Services (templates for common repairs)
- [x] Technician management (CRUD, assignment, hourly rates)
- [x] Technician time tracking (clock in/out per job)
- [x] Digital Vehicle Inspections (DVI) with templates, color-coded conditions, per-item photos/notes
- [x] Default multi-point inspection template (30 items across 5 sections)
- [x] Internal messaging system (log communications per job/customer)
- [x] Message templates (5 defaults: job started, job complete, estimate sent, invoice sent, appointment reminder)
- [x] Email/SMS settings (SMTP + Twilio config)
- [x] Appointment scheduling with status management
- [x] Deferred services tracking (declined work follow-ups)
- [x] Parts inventory management (stock tracking, low stock alerts, categories)
- [x] Shop supplies + consumables inventory
- [x] Inventory transactions (received, used, adjusted, returned)
- [x] Purchase orders (PO number generation, auto-receive to inventory)
- [x] Tool inventory (serial numbers, assigned technician, condition tracking)
- [x] Fleet management (fleet accounts, vehicle grouping)
- [x] Reports: Revenue summary, Job profitability, Revenue by shop, Receivables aging, Technician productivity, Top customers, Jobs by status, Inventory summary
## What's Next (TODO)
- [ ] Customer portal (customers view status, approve estimates, view inspections, pay online)
- [ ] S3-compatible file storage option (for SaaS)
- [ ] Database migrations (currently requires db delete on schema changes)
- [ ] Multi-location support
- [ ] QuickBooks Online integration
- [ ] Stripe payment processing
- [ ] NHTSA VIN decoder integration
- [ ] Automated email/SMS sending (nodemailer + twilio)
- [ ] Google Business review link integration
- [ ] Embeddable booking widget
## Competitors to Study
AutoLeap, ShopMonkey, ShopView — for feature parity and UX inspiration.
## Docker
```bash
docker compose up -d # builds and runs on port 3001
```
Data persists in `autoking_data` volume. For Unraid, map to `/mnt/user/appdata/autoking/`.

28
Dockerfile Normal file
View File

@@ -0,0 +1,28 @@
FROM node:22-alpine AS client-build
WORKDIR /app/client
COPY client/package*.json ./
RUN npm ci
COPY client/ .
RUN npm run build
FROM node:22-alpine AS server-build
WORKDIR /app/server
COPY server/package*.json ./
RUN npm ci
COPY server/ .
RUN npx tsc
FROM node:22-alpine
WORKDIR /app
RUN apk add --no-cache python3 make g++
COPY server/package*.json ./
RUN npm ci --omit=dev && apk del python3 make g++
COPY --from=server-build /app/server/dist ./dist
COPY --from=client-build /app/client/dist ./public
ENV NODE_ENV=production
ENV PORT=3001
ENV DATA_DIR=/data
EXPOSE 3001
CMD ["node", "dist/index.js"]

24
client/.gitignore vendored Normal file
View File

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

73
client/README.md Normal file
View File

@@ -0,0 +1,73 @@
# React + TypeScript + Vite
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
Currently, two official plugins are available:
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs)
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
## React Compiler
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
## Expanding the ESLint configuration
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
```js
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Remove tseslint.configs.recommended and replace with this
tseslint.configs.recommendedTypeChecked,
// Alternatively, use this for stricter rules
tseslint.configs.strictTypeChecked,
// Optionally, add this for stylistic rules
tseslint.configs.stylisticTypeChecked,
// Other configs...
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
```js
// eslint.config.js
import reactX from 'eslint-plugin-react-x'
import reactDom from 'eslint-plugin-react-dom'
export default defineConfig([
globalIgnores(['dist']),
{
files: ['**/*.{ts,tsx}'],
extends: [
// Other configs...
// Enable lint rules for React
reactX.configs['recommended-typescript'],
// Enable lint rules for React DOM
reactDom.configs.recommended,
],
languageOptions: {
parserOptions: {
project: ['./tsconfig.node.json', './tsconfig.app.json'],
tsconfigRootDir: import.meta.dirname,
},
// other options...
},
},
])
```

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

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

13
client/index.html Normal file
View File

@@ -0,0 +1,13 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>client</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

3367
client/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

37
client/package.json Normal file
View File

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

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 9.3 KiB

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

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

After

Width:  |  Height:  |  Size: 4.9 KiB

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

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

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

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

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

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

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

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

7
client/tsconfig.json Normal file
View File

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

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

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

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

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

15
docker-compose.yml Normal file
View File

@@ -0,0 +1,15 @@
services:
autoking:
build: .
container_name: autoking
restart: unless-stopped
ports:
- "3001:3001"
environment:
- JWT_SECRET=change-me-in-production
- DATA_DIR=/data
volumes:
- autoking_data:/data
volumes:
autoking_data:

2990
server/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

31
server/package.json Normal file
View File

@@ -0,0 +1,31 @@
{
"name": "autoking-server",
"version": "1.0.0",
"description": "AutoKing shop management backend",
"main": "dist/index.js",
"scripts": {
"dev": "tsx watch src/index.ts",
"build": "tsc",
"start": "node dist/index.js"
},
"dependencies": {
"@types/multer": "^2.1.0",
"bcryptjs": "^2.4.3",
"better-sqlite3": "^11.6.0",
"cors": "^2.8.5",
"express": "^4.21.2",
"jsonwebtoken": "^9.0.2",
"multer": "^2.1.1",
"pdfkit": "^0.15.0"
},
"devDependencies": {
"@types/bcryptjs": "^2.4.6",
"@types/better-sqlite3": "^7.6.8",
"@types/cors": "^2.8.17",
"@types/express": "^4.17.21",
"@types/jsonwebtoken": "^9.0.7",
"@types/pdfkit": "^0.13.8",
"tsx": "^4.19.0",
"typescript": "~5.8.3"
}
}

19
server/src/database.ts Normal file
View File

@@ -0,0 +1,19 @@
import Database, { Database as DatabaseType } from 'better-sqlite3';
import path from 'path';
import fs from 'fs';
import { runMigrations } from './migrations/runner';
const dataDir = process.env.DATA_DIR || path.join(__dirname, '../../data');
if (!fs.existsSync(dataDir)) {
fs.mkdirSync(dataDir, { recursive: true });
}
const db: DatabaseType = new Database(path.join(dataDir, 'autoking.db'));
db.pragma('journal_mode = WAL');
db.pragma('foreign_keys = ON');
export function initDatabase() {
runMigrations(db);
}
export default db;

71
server/src/index.ts Normal file
View File

@@ -0,0 +1,71 @@
import express from 'express';
import cors from 'cors';
import path from 'path';
import { initDatabase } from './database';
import { authMiddleware } from './middleware/auth';
import authRoutes from './routes/auth';
import shopRoutes from './routes/shops';
import ownerRoutes from './routes/owners';
import vehicleRoutes from './routes/vehicles';
import jobRoutes from './routes/jobs';
import invoiceRoutes from './routes/invoices';
import dashboardRoutes from './routes/dashboard';
import settingsRoutes from './routes/settings';
import tagRoutes from './routes/tags';
import fileRoutes from './routes/files';
import estimateRoutes from './routes/estimates';
import cannedServiceRoutes from './routes/canned-services';
import technicianRoutes from './routes/technicians';
import inspectionRoutes from './routes/inspections';
import messageRoutes from './routes/messages';
import appointmentRoutes from './routes/appointments';
import inventoryRoutes from './routes/inventory';
import reportRoutes from './routes/reports';
import fleetRoutes from './routes/fleets';
import backupRoutes from './routes/backup';
const app = express();
const PORT = process.env.PORT || 3001;
app.use(cors());
app.use(express.json());
// Initialize database
initDatabase();
// Public routes
app.use('/api/auth', authRoutes);
// Protected routes
app.use('/api/shops', authMiddleware, shopRoutes);
app.use('/api/owners', authMiddleware, ownerRoutes);
app.use('/api/vehicles', authMiddleware, vehicleRoutes);
app.use('/api/jobs', authMiddleware, jobRoutes);
app.use('/api/invoices', authMiddleware, invoiceRoutes);
app.use('/api/dashboard', authMiddleware, dashboardRoutes);
app.use('/api/settings', authMiddleware, settingsRoutes);
app.use('/api/tags', authMiddleware, tagRoutes);
app.use('/api/files', authMiddleware, fileRoutes);
app.use('/api/estimates', authMiddleware, estimateRoutes);
app.use('/api/canned-services', authMiddleware, cannedServiceRoutes);
app.use('/api/technicians', authMiddleware, technicianRoutes);
app.use('/api/inspections', authMiddleware, inspectionRoutes);
app.use('/api/messages', authMiddleware, messageRoutes);
app.use('/api/appointments', authMiddleware, appointmentRoutes);
app.use('/api/inventory', authMiddleware, inventoryRoutes);
app.use('/api/reports', authMiddleware, reportRoutes);
app.use('/api/fleets', authMiddleware, fleetRoutes);
app.use('/api/backup', authMiddleware, backupRoutes);
// Serve frontend in production
if (process.env.NODE_ENV === 'production') {
const publicPath = path.join(__dirname, '../public');
app.use(express.static(publicPath));
app.get('*', (_req, res) => {
res.sendFile(path.join(publicPath, 'index.html'));
});
}
app.listen(PORT, () => {
console.log(`AutoKing server running on port ${PORT}`);
});

View File

@@ -0,0 +1,36 @@
import { Request, Response, NextFunction } from 'express';
import jwt from 'jsonwebtoken';
const JWT_SECRET = process.env.JWT_SECRET || 'autoking-dev-secret';
export interface AuthRequest extends Request {
userId?: number;
}
export function generateToken(userId: number): string {
return jwt.sign({ userId }, JWT_SECRET, { expiresIn: '30d' });
}
export function authMiddleware(req: AuthRequest, res: Response, next: NextFunction) {
let token: string | undefined;
const header = req.headers.authorization;
if (header && header.startsWith('Bearer ')) {
token = header.slice(7);
} else if (req.query.token) {
// Support token as query param for PDF downloads in new tabs
token = req.query.token as string;
}
if (!token) {
return res.status(401).json({ error: 'No token provided' });
}
try {
const payload = jwt.verify(token, JWT_SECRET) as { userId: number };
req.userId = payload.userId;
next();
} catch {
return res.status(401).json({ error: 'Invalid token' });
}
}

View File

@@ -0,0 +1,615 @@
// Full initial schema — everything that exists as of this migration.
// For existing databases, this gets marked as "already applied" since all
// tables use CREATE TABLE IF NOT EXISTS.
export const up = `
CREATE TABLE IF NOT EXISTS users (
id INTEGER PRIMARY KEY AUTOINCREMENT,
username TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
display_name TEXT,
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS settings (
id INTEGER PRIMARY KEY CHECK (id = 1),
shop_name TEXT NOT NULL DEFAULT 'Tony Moon',
shop_address TEXT,
shop_city TEXT,
shop_state TEXT,
shop_zip TEXT,
shop_phone TEXT,
shop_email TEXT,
default_tax_rate REAL DEFAULT 0,
invoice_terms TEXT DEFAULT 'Net 30',
updated_at TEXT DEFAULT (datetime('now'))
);
INSERT OR IGNORE INTO settings (id, shop_name) VALUES (1, 'Tony Moon');
CREATE TABLE IF NOT EXISTS shops (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
contact_name TEXT,
phone TEXT,
email TEXT,
address TEXT,
city TEXT,
state TEXT,
zip TEXT,
notes TEXT,
default_labor_rate REAL DEFAULT 75.00,
default_parts_markup REAL DEFAULT 0.30,
active INTEGER DEFAULT 1,
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS owners (
id INTEGER PRIMARY KEY AUTOINCREMENT,
first_name TEXT NOT NULL,
last_name TEXT NOT NULL,
phone TEXT,
email TEXT,
notes TEXT,
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS vehicles (
id INTEGER PRIMARY KEY AUTOINCREMENT,
owner_id INTEGER,
year INTEGER,
make TEXT NOT NULL,
model TEXT NOT NULL,
vin TEXT,
color TEXT,
license_plate TEXT,
mileage INTEGER,
is_inventory INTEGER DEFAULT 0,
inventory_status TEXT DEFAULT 'stock',
purchase_price REAL,
purchase_date TEXT,
sale_price REAL,
sale_date TEXT,
notes TEXT,
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now')),
FOREIGN KEY (owner_id) REFERENCES owners(id)
);
CREATE TABLE IF NOT EXISTS jobs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
job_number TEXT NOT NULL UNIQUE,
customer_type TEXT NOT NULL DEFAULT 'owner',
shop_id INTEGER,
owner_id INTEGER,
vehicle_id INTEGER NOT NULL,
technician_id INTEGER,
estimate_id INTEGER,
status TEXT NOT NULL DEFAULT 'received',
description TEXT,
received_date TEXT DEFAULT (datetime('now')),
started_date TEXT,
completed_date TEXT,
notes TEXT,
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now')),
FOREIGN KEY (shop_id) REFERENCES shops(id),
FOREIGN KEY (owner_id) REFERENCES owners(id),
FOREIGN KEY (vehicle_id) REFERENCES vehicles(id),
FOREIGN KEY (technician_id) REFERENCES technicians(id),
FOREIGN KEY (estimate_id) REFERENCES estimates(id)
);
CREATE TABLE IF NOT EXISTS labor_items (
id INTEGER PRIMARY KEY AUTOINCREMENT,
job_id INTEGER NOT NULL,
description TEXT NOT NULL,
billing_type TEXT NOT NULL DEFAULT 'hourly',
hours REAL,
rate REAL,
flat_rate REAL,
sort_order INTEGER DEFAULT 0,
created_at TEXT DEFAULT (datetime('now')),
FOREIGN KEY (job_id) REFERENCES jobs(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS parts_items (
id INTEGER PRIMARY KEY AUTOINCREMENT,
job_id INTEGER NOT NULL,
description TEXT NOT NULL,
part_number TEXT,
quantity REAL NOT NULL DEFAULT 1,
unit_cost REAL NOT NULL,
markup REAL NOT NULL DEFAULT 0.30,
sort_order INTEGER DEFAULT 0,
created_at TEXT DEFAULT (datetime('now')),
FOREIGN KEY (job_id) REFERENCES jobs(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS job_notes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
job_id INTEGER NOT NULL,
note TEXT NOT NULL,
created_at TEXT DEFAULT (datetime('now')),
FOREIGN KEY (job_id) REFERENCES jobs(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS invoices (
id INTEGER PRIMARY KEY AUTOINCREMENT,
invoice_number TEXT NOT NULL UNIQUE,
shop_id INTEGER,
owner_id INTEGER,
status TEXT NOT NULL DEFAULT 'draft',
invoice_date TEXT DEFAULT (datetime('now')),
due_date TEXT,
subtotal REAL NOT NULL DEFAULT 0,
tax_rate REAL DEFAULT 0,
tax_amount REAL DEFAULT 0,
total REAL NOT NULL DEFAULT 0,
notes TEXT,
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now')),
FOREIGN KEY (shop_id) REFERENCES shops(id),
FOREIGN KEY (owner_id) REFERENCES owners(id)
);
CREATE TABLE IF NOT EXISTS invoice_jobs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
invoice_id INTEGER NOT NULL,
job_id INTEGER NOT NULL,
FOREIGN KEY (invoice_id) REFERENCES invoices(id) ON DELETE CASCADE,
FOREIGN KEY (job_id) REFERENCES jobs(id),
UNIQUE(invoice_id, job_id)
);
CREATE TABLE IF NOT EXISTS payments (
id INTEGER PRIMARY KEY AUTOINCREMENT,
invoice_id INTEGER NOT NULL,
amount REAL NOT NULL,
payment_date TEXT NOT NULL,
method TEXT,
reference TEXT,
notes TEXT,
created_at TEXT DEFAULT (datetime('now')),
FOREIGN KEY (invoice_id) REFERENCES invoices(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS estimates (
id INTEGER PRIMARY KEY AUTOINCREMENT,
estimate_number TEXT NOT NULL UNIQUE,
customer_type TEXT NOT NULL DEFAULT 'owner',
shop_id INTEGER,
owner_id INTEGER,
vehicle_id INTEGER,
status TEXT NOT NULL DEFAULT 'draft',
subtotal REAL NOT NULL DEFAULT 0,
tax_rate REAL DEFAULT 0,
tax_amount REAL DEFAULT 0,
total REAL NOT NULL DEFAULT 0,
notes TEXT,
valid_until TEXT,
approval_token TEXT,
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now')),
FOREIGN KEY (shop_id) REFERENCES shops(id),
FOREIGN KEY (owner_id) REFERENCES owners(id),
FOREIGN KEY (vehicle_id) REFERENCES vehicles(id)
);
CREATE TABLE IF NOT EXISTS estimate_items (
id INTEGER PRIMARY KEY AUTOINCREMENT,
estimate_id INTEGER NOT NULL,
item_type TEXT NOT NULL DEFAULT 'labor',
description TEXT NOT NULL,
quantity REAL NOT NULL DEFAULT 1,
unit_price REAL NOT NULL DEFAULT 0,
markup REAL NOT NULL DEFAULT 0,
billing_type TEXT DEFAULT 'hourly',
hours REAL,
rate REAL,
flat_rate REAL,
sort_order INTEGER DEFAULT 0,
created_at TEXT DEFAULT (datetime('now')),
FOREIGN KEY (estimate_id) REFERENCES estimates(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS canned_services (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
description TEXT,
category TEXT,
is_active INTEGER DEFAULT 1,
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS canned_service_items (
id INTEGER PRIMARY KEY AUTOINCREMENT,
canned_service_id INTEGER NOT NULL,
item_type TEXT NOT NULL DEFAULT 'labor',
description TEXT NOT NULL,
billing_type TEXT DEFAULT 'hourly',
hours REAL,
rate REAL,
flat_rate REAL,
quantity REAL DEFAULT 1,
unit_cost REAL DEFAULT 0,
markup REAL DEFAULT 0,
sort_order INTEGER DEFAULT 0,
FOREIGN KEY (canned_service_id) REFERENCES canned_services(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS technicians (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
phone TEXT,
email TEXT,
specialty TEXT,
hourly_rate REAL DEFAULT 0,
is_active INTEGER DEFAULT 1,
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS time_entries (
id INTEGER PRIMARY KEY AUTOINCREMENT,
technician_id INTEGER NOT NULL,
job_id INTEGER NOT NULL,
clock_in TEXT NOT NULL,
clock_out TEXT,
duration_minutes REAL,
notes TEXT,
created_at TEXT DEFAULT (datetime('now')),
FOREIGN KEY (technician_id) REFERENCES technicians(id),
FOREIGN KEY (job_id) REFERENCES jobs(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS inspection_templates (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
description TEXT,
is_default INTEGER DEFAULT 0,
is_active INTEGER DEFAULT 1,
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS inspection_template_items (
id INTEGER PRIMARY KEY AUTOINCREMENT,
template_id INTEGER NOT NULL,
section TEXT NOT NULL DEFAULT 'General',
label TEXT NOT NULL,
sort_order INTEGER DEFAULT 0,
FOREIGN KEY (template_id) REFERENCES inspection_templates(id) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS inspections (
id INTEGER PRIMARY KEY AUTOINCREMENT,
job_id INTEGER NOT NULL,
template_id INTEGER,
technician_id INTEGER,
status TEXT NOT NULL DEFAULT 'in_progress',
created_at TEXT DEFAULT (datetime('now')),
completed_at TEXT,
FOREIGN KEY (job_id) REFERENCES jobs(id) ON DELETE CASCADE,
FOREIGN KEY (template_id) REFERENCES inspection_templates(id),
FOREIGN KEY (technician_id) REFERENCES technicians(id)
);
CREATE TABLE IF NOT EXISTS inspection_items (
id INTEGER PRIMARY KEY AUTOINCREMENT,
inspection_id INTEGER NOT NULL,
template_item_id INTEGER,
section TEXT NOT NULL DEFAULT 'General',
label TEXT NOT NULL,
condition TEXT DEFAULT 'not_inspected',
notes TEXT,
sort_order INTEGER DEFAULT 0,
FOREIGN KEY (inspection_id) REFERENCES inspections(id) ON DELETE CASCADE,
FOREIGN KEY (template_item_id) REFERENCES inspection_template_items(id)
);
CREATE TABLE IF NOT EXISTS messages (
id INTEGER PRIMARY KEY AUTOINCREMENT,
job_id INTEGER,
owner_id INTEGER,
shop_id INTEGER,
direction TEXT NOT NULL DEFAULT 'outbound',
channel TEXT NOT NULL DEFAULT 'internal',
subject TEXT,
body TEXT NOT NULL,
sent_at TEXT DEFAULT (datetime('now')),
read_at TEXT,
created_at TEXT DEFAULT (datetime('now')),
FOREIGN KEY (job_id) REFERENCES jobs(id),
FOREIGN KEY (owner_id) REFERENCES owners(id),
FOREIGN KEY (shop_id) REFERENCES shops(id)
);
CREATE TABLE IF NOT EXISTS message_templates (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
subject TEXT,
body TEXT NOT NULL,
channel TEXT DEFAULT 'email',
trigger_event TEXT,
is_active INTEGER DEFAULT 1,
created_at TEXT DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS appointments (
id INTEGER PRIMARY KEY AUTOINCREMENT,
owner_id INTEGER,
shop_id INTEGER,
vehicle_id INTEGER,
service_description TEXT,
requested_date TEXT NOT NULL,
requested_time TEXT,
duration_minutes INTEGER DEFAULT 60,
status TEXT NOT NULL DEFAULT 'pending',
notes TEXT,
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now')),
FOREIGN KEY (owner_id) REFERENCES owners(id),
FOREIGN KEY (shop_id) REFERENCES shops(id),
FOREIGN KEY (vehicle_id) REFERENCES vehicles(id)
);
CREATE TABLE IF NOT EXISTS deferred_services (
id INTEGER PRIMARY KEY AUTOINCREMENT,
job_id INTEGER,
vehicle_id INTEGER,
owner_id INTEGER,
description TEXT NOT NULL,
reason TEXT,
follow_up_date TEXT,
status TEXT DEFAULT 'pending',
created_at TEXT DEFAULT (datetime('now')),
FOREIGN KEY (job_id) REFERENCES jobs(id),
FOREIGN KEY (vehicle_id) REFERENCES vehicles(id),
FOREIGN KEY (owner_id) REFERENCES owners(id)
);
CREATE TABLE IF NOT EXISTS email_settings (
id INTEGER PRIMARY KEY CHECK (id = 1),
smtp_host TEXT,
smtp_port INTEGER DEFAULT 587,
smtp_user TEXT,
smtp_pass TEXT,
smtp_from TEXT,
smtp_secure INTEGER DEFAULT 0,
twilio_sid TEXT,
twilio_token TEXT,
twilio_phone TEXT,
updated_at TEXT DEFAULT (datetime('now'))
);
INSERT OR IGNORE INTO email_settings (id) VALUES (1);
CREATE TABLE IF NOT EXISTS inventory_items (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
part_number TEXT,
description TEXT,
category TEXT,
item_type TEXT NOT NULL DEFAULT 'parts',
quantity_on_hand REAL NOT NULL DEFAULT 0,
reorder_point REAL DEFAULT 0,
unit_cost REAL DEFAULT 0,
supplier TEXT,
location TEXT,
is_active INTEGER DEFAULT 1,
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS inventory_transactions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
item_id INTEGER NOT NULL,
transaction_type TEXT NOT NULL,
quantity REAL NOT NULL,
job_id INTEGER,
reference TEXT,
notes TEXT,
created_at TEXT DEFAULT (datetime('now')),
FOREIGN KEY (item_id) REFERENCES inventory_items(id),
FOREIGN KEY (job_id) REFERENCES jobs(id)
);
CREATE TABLE IF NOT EXISTS purchase_orders (
id INTEGER PRIMARY KEY AUTOINCREMENT,
po_number TEXT NOT NULL UNIQUE,
supplier TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'draft',
total REAL DEFAULT 0,
notes TEXT,
ordered_date TEXT,
received_date TEXT,
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS purchase_order_items (
id INTEGER PRIMARY KEY AUTOINCREMENT,
po_id INTEGER NOT NULL,
inventory_item_id INTEGER,
description TEXT NOT NULL,
quantity REAL NOT NULL DEFAULT 1,
unit_cost REAL NOT NULL DEFAULT 0,
FOREIGN KEY (po_id) REFERENCES purchase_orders(id) ON DELETE CASCADE,
FOREIGN KEY (inventory_item_id) REFERENCES inventory_items(id)
);
CREATE TABLE IF NOT EXISTS tools (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
description TEXT,
serial_number TEXT,
category TEXT,
purchase_date TEXT,
purchase_price REAL,
condition TEXT DEFAULT 'good',
assigned_to INTEGER,
location TEXT,
notes TEXT,
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now')),
FOREIGN KEY (assigned_to) REFERENCES technicians(id)
);
CREATE TABLE IF NOT EXISTS fleets (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
contact_name TEXT,
phone TEXT,
email TEXT,
billing_address TEXT,
payment_terms TEXT DEFAULT 'Net 30',
notes TEXT,
created_at TEXT DEFAULT (datetime('now')),
updated_at TEXT DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS fleet_vehicles (
id INTEGER PRIMARY KEY AUTOINCREMENT,
fleet_id INTEGER NOT NULL,
vehicle_id INTEGER NOT NULL,
FOREIGN KEY (fleet_id) REFERENCES fleets(id) ON DELETE CASCADE,
FOREIGN KEY (vehicle_id) REFERENCES vehicles(id),
UNIQUE(fleet_id, vehicle_id)
);
CREATE TABLE IF NOT EXISTS tags (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL UNIQUE,
color TEXT NOT NULL DEFAULT 'gray',
created_at TEXT DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS job_tags (
id INTEGER PRIMARY KEY AUTOINCREMENT,
job_id INTEGER NOT NULL,
tag_id INTEGER NOT NULL,
FOREIGN KEY (job_id) REFERENCES jobs(id) ON DELETE CASCADE,
FOREIGN KEY (tag_id) REFERENCES tags(id) ON DELETE CASCADE,
UNIQUE(job_id, tag_id)
);
INSERT OR IGNORE INTO tags (name, color) VALUES ('Waiting on Parts', 'yellow');
INSERT OR IGNORE INTO tags (name, color) VALUES ('Needs Diagnosis', 'purple');
INSERT OR IGNORE INTO tags (name, color) VALUES ('Customer Callback', 'blue');
INSERT OR IGNORE INTO tags (name, color) VALUES ('Hold', 'red');
INSERT OR IGNORE INTO tags (name, color) VALUES ('Priority', 'red');
INSERT OR IGNORE INTO tags (name, color) VALUES ('Warranty', 'green');
CREATE TABLE IF NOT EXISTS files (
id INTEGER PRIMARY KEY AUTOINCREMENT,
entity_type TEXT NOT NULL,
entity_id INTEGER NOT NULL,
filename TEXT NOT NULL,
original_name TEXT NOT NULL,
mime_type TEXT,
size INTEGER,
created_at TEXT DEFAULT (datetime('now'))
);
CREATE TABLE IF NOT EXISTS recommendations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
job_id INTEGER NOT NULL,
inspection_item_id INTEGER,
description TEXT NOT NULL,
urgency TEXT NOT NULL DEFAULT 'soon',
estimated_cost REAL,
status TEXT NOT NULL DEFAULT 'open',
created_at TEXT DEFAULT (datetime('now')),
FOREIGN KEY (job_id) REFERENCES jobs(id) ON DELETE CASCADE,
FOREIGN KEY (inspection_item_id) REFERENCES inspection_items(id) ON DELETE SET NULL
);
-- Default inspection template
INSERT OR IGNORE INTO inspection_templates (id, name, description, is_default) VALUES (1, 'Multi-Point Inspection', 'Standard vehicle inspection', 1);
INSERT OR IGNORE INTO inspection_template_items (template_id, section, label, sort_order) VALUES
(1, 'Exterior', 'Body condition', 1),
(1, 'Exterior', 'Paint / clear coat', 2),
(1, 'Exterior', 'Windshield / glass', 3),
(1, 'Exterior', 'Wipers', 4),
(1, 'Exterior', 'Headlights', 5),
(1, 'Exterior', 'Taillights / signals', 6),
(1, 'Exterior', 'Tires — tread depth', 7),
(1, 'Exterior', 'Tires — condition', 8),
(1, 'Under Hood', 'Engine oil level / condition', 10),
(1, 'Under Hood', 'Coolant level / condition', 11),
(1, 'Under Hood', 'Brake fluid level', 12),
(1, 'Under Hood', 'Power steering fluid', 13),
(1, 'Under Hood', 'Transmission fluid', 14),
(1, 'Under Hood', 'Belts', 15),
(1, 'Under Hood', 'Hoses', 16),
(1, 'Under Hood', 'Battery / terminals', 17),
(1, 'Under Hood', 'Air filter', 18),
(1, 'Brakes', 'Front brake pads', 20),
(1, 'Brakes', 'Rear brake pads', 21),
(1, 'Brakes', 'Rotors / drums', 22),
(1, 'Brakes', 'Brake lines / hoses', 23),
(1, 'Undercarriage', 'Exhaust system', 25),
(1, 'Undercarriage', 'Suspension components', 26),
(1, 'Undercarriage', 'CV joints / boots', 27),
(1, 'Undercarriage', 'Oil leaks', 28),
(1, 'Undercarriage', 'Coolant leaks', 29),
(1, 'Interior', 'A/C / heater operation', 31),
(1, 'Interior', 'Dashboard warning lights', 32),
(1, 'Interior', 'Horn', 33),
(1, 'Interior', 'Seat belts', 34);
-- Default message templates
INSERT OR IGNORE INTO message_templates (id, name, subject, body, channel, trigger_event) VALUES
(1, 'Job Started', 'Work Started on Your Vehicle', 'Hi {{customer_name}}, work has begun on your {{vehicle}}. Job #{{job_number}}. We''ll keep you updated!', 'email', 'job_started'),
(2, 'Job Complete', 'Your Vehicle is Ready', 'Hi {{customer_name}}, your {{vehicle}} is ready for pickup! Job #{{job_number}}. Total: {{total}}', 'email', 'job_completed'),
(3, 'Estimate Sent', 'Estimate for Your Vehicle', 'Hi {{customer_name}}, here''s your estimate for {{vehicle}}. Estimate #{{estimate_number}}. Total: {{total}}', 'email', 'estimate_sent'),
(4, 'Invoice Sent', 'Invoice from {{shop_name}}', 'Hi {{customer_name}}, your invoice #{{invoice_number}} for {{total}} is ready.', 'email', 'invoice_sent'),
(5, 'Appointment Reminder', 'Appointment Reminder', 'Hi {{customer_name}}, reminder: you have an appointment on {{date}} at {{time}}.', 'email', 'appointment_reminder');
-- All indexes
CREATE INDEX IF NOT EXISTS idx_recommendations_job ON recommendations(job_id);
CREATE INDEX IF NOT EXISTS idx_files_entity ON files(entity_type, entity_id);
CREATE INDEX IF NOT EXISTS idx_job_tags_job ON job_tags(job_id);
CREATE INDEX IF NOT EXISTS idx_job_tags_tag ON job_tags(tag_id);
CREATE INDEX IF NOT EXISTS idx_vehicles_owner ON vehicles(owner_id);
CREATE INDEX IF NOT EXISTS idx_jobs_shop ON jobs(shop_id);
CREATE INDEX IF NOT EXISTS idx_jobs_vehicle ON jobs(vehicle_id);
CREATE INDEX IF NOT EXISTS idx_jobs_status ON jobs(status);
CREATE INDEX IF NOT EXISTS idx_jobs_number ON jobs(job_number);
CREATE INDEX IF NOT EXISTS idx_labor_job ON labor_items(job_id);
CREATE INDEX IF NOT EXISTS idx_parts_job ON parts_items(job_id);
CREATE INDEX IF NOT EXISTS idx_notes_job ON job_notes(job_id);
CREATE INDEX IF NOT EXISTS idx_invoices_shop ON invoices(shop_id);
CREATE INDEX IF NOT EXISTS idx_invoices_owner ON invoices(owner_id);
CREATE INDEX IF NOT EXISTS idx_invoices_status ON invoices(status);
CREATE INDEX IF NOT EXISTS idx_invoice_jobs_invoice ON invoice_jobs(invoice_id);
CREATE INDEX IF NOT EXISTS idx_invoice_jobs_job ON invoice_jobs(job_id);
CREATE INDEX IF NOT EXISTS idx_payments_invoice ON payments(invoice_id);
CREATE INDEX IF NOT EXISTS idx_estimates_shop ON estimates(shop_id);
CREATE INDEX IF NOT EXISTS idx_estimates_owner ON estimates(owner_id);
CREATE INDEX IF NOT EXISTS idx_estimates_status ON estimates(status);
CREATE INDEX IF NOT EXISTS idx_estimates_token ON estimates(approval_token);
CREATE INDEX IF NOT EXISTS idx_estimate_items ON estimate_items(estimate_id);
CREATE INDEX IF NOT EXISTS idx_canned_service_items ON canned_service_items(canned_service_id);
CREATE INDEX IF NOT EXISTS idx_time_entries_tech ON time_entries(technician_id);
CREATE INDEX IF NOT EXISTS idx_time_entries_job ON time_entries(job_id);
CREATE INDEX IF NOT EXISTS idx_template_items ON inspection_template_items(template_id);
CREATE INDEX IF NOT EXISTS idx_inspections_job ON inspections(job_id);
CREATE INDEX IF NOT EXISTS idx_inspection_items ON inspection_items(inspection_id);
CREATE INDEX IF NOT EXISTS idx_messages_job ON messages(job_id);
CREATE INDEX IF NOT EXISTS idx_messages_owner ON messages(owner_id);
CREATE INDEX IF NOT EXISTS idx_appointments_date ON appointments(requested_date);
CREATE INDEX IF NOT EXISTS idx_appointments_status ON appointments(status);
CREATE INDEX IF NOT EXISTS idx_deferred_vehicle ON deferred_services(vehicle_id);
CREATE INDEX IF NOT EXISTS idx_deferred_status ON deferred_services(status);
CREATE INDEX IF NOT EXISTS idx_inventory_items_type ON inventory_items(item_type);
CREATE INDEX IF NOT EXISTS idx_inventory_items_part ON inventory_items(part_number);
CREATE INDEX IF NOT EXISTS idx_inventory_txn_item ON inventory_transactions(item_id);
CREATE INDEX IF NOT EXISTS idx_po_items ON purchase_order_items(po_id);
CREATE INDEX IF NOT EXISTS idx_fleet_vehicles_fleet ON fleet_vehicles(fleet_id);
`;

View File

@@ -0,0 +1,6 @@
// Add charge_price override column to parts_items
// When set, this overrides the calculated price (unit_cost * (1 + markup) * quantity)
// Allows tracking actual cost separately from what's charged to customer
export const up = `
ALTER TABLE parts_items ADD COLUMN charge_price REAL;
`;

View File

@@ -0,0 +1,16 @@
// Sublet/Misc cost line items on jobs — tows, outside machine work, sublet labor, etc.
export const up = `
CREATE TABLE IF NOT EXISTS misc_items (
id INTEGER PRIMARY KEY AUTOINCREMENT,
job_id INTEGER NOT NULL,
description TEXT NOT NULL,
category TEXT DEFAULT 'sublet',
your_cost REAL NOT NULL DEFAULT 0,
charge_price REAL NOT NULL DEFAULT 0,
sort_order INTEGER DEFAULT 0,
created_at TEXT DEFAULT (datetime('now')),
FOREIGN KEY (job_id) REFERENCES jobs(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_misc_job ON misc_items(job_id);
`;

View File

@@ -0,0 +1,17 @@
// Track invoice revisions — stores a snapshot each time an invoice is updated
export const up = `
CREATE TABLE IF NOT EXISTS invoice_versions (
id INTEGER PRIMARY KEY AUTOINCREMENT,
invoice_id INTEGER NOT NULL,
version_number INTEGER NOT NULL,
subtotal REAL NOT NULL,
tax_rate REAL NOT NULL,
tax_amount REAL NOT NULL,
total REAL NOT NULL,
reason TEXT,
created_at TEXT DEFAULT (datetime('now')),
FOREIGN KEY (invoice_id) REFERENCES invoices(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_invoice_versions ON invoice_versions(invoice_id);
`;

View File

@@ -0,0 +1,4 @@
// Add stock_number to vehicles for inventory/flip cars
export const up = `
ALTER TABLE vehicles ADD COLUMN stock_number TEXT;
`;

View File

@@ -0,0 +1,4 @@
// Clear the default invoice terms — user can set them in settings if they want
export const up = `
UPDATE settings SET invoice_terms = NULL WHERE id = 1 AND invoice_terms = 'Net 30';
`;

View File

@@ -0,0 +1,5 @@
// Track mileage in/out per job
export const up = `
ALTER TABLE jobs ADD COLUMN mileage_in INTEGER;
ALTER TABLE jobs ADD COLUMN mileage_out INTEGER;
`;

View File

@@ -0,0 +1,6 @@
// Add hidden flag to line items so they can be excluded from invoices/PDFs
export const up = `
ALTER TABLE parts_items ADD COLUMN hidden INTEGER DEFAULT 0;
ALTER TABLE misc_items ADD COLUMN hidden INTEGER DEFAULT 0;
ALTER TABLE labor_items ADD COLUMN hidden INTEGER DEFAULT 0;
`;

View File

@@ -0,0 +1,4 @@
// Link recommendations to estimates
export const up = `
ALTER TABLE recommendations ADD COLUMN estimate_id INTEGER REFERENCES estimates(id);
`;

View File

@@ -0,0 +1,4 @@
// Add notes field to recommendations
export const up = `
ALTER TABLE recommendations ADD COLUMN notes TEXT;
`;

View File

@@ -0,0 +1,4 @@
// Allow inspections to be linked to a vehicle directly (without a job)
export const up = `
ALTER TABLE inspections ADD COLUMN vehicle_id INTEGER REFERENCES vehicles(id);
`;

View File

@@ -0,0 +1,27 @@
// Fix inspections table to allow nullable job_id (for vehicle-only inspections)
// SQLite doesn't support ALTER COLUMN, so we recreate the table
export const up = `
CREATE TABLE inspections_new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
job_id INTEGER,
vehicle_id INTEGER,
template_id INTEGER,
technician_id INTEGER,
status TEXT NOT NULL DEFAULT 'in_progress',
created_at TEXT DEFAULT (datetime('now')),
completed_at TEXT,
FOREIGN KEY (job_id) REFERENCES jobs(id) ON DELETE CASCADE,
FOREIGN KEY (vehicle_id) REFERENCES vehicles(id),
FOREIGN KEY (template_id) REFERENCES inspection_templates(id),
FOREIGN KEY (technician_id) REFERENCES technicians(id)
);
INSERT INTO inspections_new (id, job_id, vehicle_id, template_id, technician_id, status, created_at, completed_at)
SELECT id, job_id, vehicle_id, template_id, technician_id, status, created_at, completed_at FROM inspections;
DROP TABLE inspections;
ALTER TABLE inspections_new RENAME TO inspections;
CREATE INDEX IF NOT EXISTS idx_inspections_job ON inspections(job_id);
CREATE INDEX IF NOT EXISTS idx_inspections_vehicle ON inspections(vehicle_id);
`;

View File

@@ -0,0 +1,4 @@
// Add visibility flag to job notes — 'internal' or 'customer'
export const up = `
ALTER TABLE job_notes ADD COLUMN visibility TEXT NOT NULL DEFAULT 'internal';
`;

View File

@@ -0,0 +1,35 @@
// Enhance inspections: simplify to 3-tier (green/yellow/red), add canned notes,
// add sharing tokens, and add video support flag
export const up = `
-- Canned inspection notes for quick entry
CREATE TABLE IF NOT EXISTS canned_inspection_notes (
id INTEGER PRIMARY KEY AUTOINCREMENT,
label TEXT NOT NULL,
note_text TEXT NOT NULL,
category TEXT,
sort_order INTEGER DEFAULT 0,
created_at TEXT DEFAULT (datetime('now'))
);
-- Default canned notes
INSERT OR IGNORE INTO canned_inspection_notes (id, label, note_text, category) VALUES
(1, 'Worn thin', 'Worn to minimum specification, replacement recommended', 'Brakes'),
(2, 'Leaking', 'Active leak detected, repair needed', 'Fluids'),
(3, 'Cracked', 'Cracking observed, monitor or replace', 'General'),
(4, 'Corroded', 'Corrosion present, may affect function', 'General'),
(5, 'Missing', 'Component missing or not present', 'General'),
(6, 'Noise', 'Abnormal noise detected during operation', 'General'),
(7, 'Low fluid', 'Fluid level below recommended', 'Fluids'),
(8, 'Torn/damaged', 'Torn or physically damaged, replacement needed', 'General'),
(9, 'Within spec', 'Measured and within manufacturer specification', 'General'),
(10, 'Customer declined', 'Brought to customer attention, declined at this time', 'General');
-- Sharing token on inspections for customer access
ALTER TABLE inspections ADD COLUMN share_token TEXT;
ALTER TABLE inspections ADD COLUMN shared_at TEXT;
ALTER TABLE inspections ADD COLUMN customer_viewed_at TEXT;
-- Measurement values on inspection items (e.g. brake pad mm, tread depth)
ALTER TABLE inspection_items ADD COLUMN measurement TEXT;
ALTER TABLE inspection_items ADD COLUMN measurement_unit TEXT;
`;

View File

@@ -0,0 +1,50 @@
// Add Courtesy Check and Pre-Purchase inspection templates
// Also add measurement_type to template items so we know which items show measurement fields
export const up = `
ALTER TABLE inspection_template_items ADD COLUMN measurement_type TEXT;
ALTER TABLE inspection_items ADD COLUMN measurement_type TEXT;
-- Courtesy Check template (the money maker)
INSERT INTO inspection_templates (id, name, description, is_default, is_active)
VALUES (2, 'Courtesy Check', 'Quick check while vehicle is in — surfaces work naturally', 0, 1)
ON CONFLICT(id) DO NOTHING;
INSERT OR IGNORE INTO inspection_template_items (template_id, section, label, measurement_type, sort_order) VALUES
(2, 'Brakes', 'Front brake pads', 'brake_mm', 1),
(2, 'Brakes', 'Rear brake pads', 'brake_mm', 2),
(2, 'Tires', 'Tire tread depth & condition', 'tread_32', 3),
(2, 'Safety', 'Wipers', NULL, 4),
(2, 'Safety', 'Headlights / taillights / signals', NULL, 5),
(2, 'Under Hood', 'Battery & terminals', NULL, 6),
(2, 'Under Hood', 'Belts', NULL, 7),
(2, 'Under Hood', 'Air filter', NULL, 8),
(2, 'Fluids', 'Oil / coolant / brake fluid levels', NULL, 9),
(2, 'Under Vehicle', 'Leaks (oil, coolant, trans)', NULL, 10);
-- Pre-Purchase template
INSERT INTO inspection_templates (id, name, description, is_default, is_active)
VALUES (3, 'Pre-Purchase Inspection', 'Thorough inspection for used vehicle buyers', 0, 1)
ON CONFLICT(id) DO NOTHING;
INSERT OR IGNORE INTO inspection_template_items (template_id, section, label, measurement_type, sort_order) VALUES
(3, 'Structure', 'Frame / structural integrity', NULL, 1),
(3, 'Structure', 'Accident signs (panel gaps, paint mismatch, overspray)', NULL, 2),
(3, 'Structure', 'Undercarriage rust / damage', NULL, 3),
(3, 'Mechanical', 'Engine start / idle / rev', NULL, 4),
(3, 'Mechanical', 'Transmission shift quality', NULL, 5),
(3, 'Mechanical', 'Steering play', NULL, 6),
(3, 'Mechanical', 'Suspension components & play', NULL, 7),
(3, 'Mechanical', 'Exhaust system', NULL, 8),
(3, 'Brakes', 'Front brakes', 'brake_mm', 9),
(3, 'Brakes', 'Rear brakes', 'brake_mm', 10),
(3, 'Tires', 'Tire tread & condition', 'tread_32', 11),
(3, 'Fluids', 'All fluid levels & condition', NULL, 12),
(3, 'Electrical', 'All lights', NULL, 13),
(3, 'Electrical', 'A/C & heater', NULL, 14),
(3, 'Electrical', 'Windows, locks, gauges', NULL, 15),
(3, 'Interior', 'Interior condition', NULL, 16),
(3, 'Interior', 'Odometer vs condition consistency', NULL, 17),
(3, 'Diagnostics', 'OBD codes check', NULL, 18),
(3, 'Test Drive', 'Test drive notes', NULL, 19),
(3, 'Overall', 'Recommendation (buy / negotiate / walk)', NULL, 20);
`;

View File

@@ -0,0 +1,78 @@
import { Database as DatabaseType } from 'better-sqlite3';
interface Migration {
id: string;
up: string;
}
// Import all migrations in order
import { up as up001 } from './001_initial_schema';
import { up as up002 } from './002_parts_charge_price';
import { up as up003 } from './003_misc_items';
import { up as up004 } from './004_invoice_versions';
import { up as up005 } from './005_vehicle_stock_number';
import { up as up006 } from './006_clear_default_terms';
import { up as up007 } from './007_job_mileage';
import { up as up008 } from './008_line_item_hidden';
import { up as up009 } from './009_rec_estimate_link';
import { up as up010 } from './010_rec_notes';
import { up as up011 } from './011_vehicle_inspections';
import { up as up012 } from './012_fix_inspection_nullable_job';
import { up as up013 } from './013_note_visibility';
import { up as up014 } from './014_inspection_enhancements';
import { up as up015 } from './015_new_templates';
const migrations: Migration[] = [
{ id: '001_initial_schema', up: up001 },
{ id: '002_parts_charge_price', up: up002 },
{ id: '003_misc_items', up: up003 },
{ id: '004_invoice_versions', up: up004 },
{ id: '005_inventory_stock_number', up: up005 },
{ id: '006_clear_default_terms', up: up006 },
{ id: '007_job_mileage', up: up007 },
{ id: '008_line_item_hidden', up: up008 },
{ id: '009_rec_estimate_link', up: up009 },
{ id: '010_rec_notes', up: up010 },
{ id: '011_vehicle_inspections', up: up011 },
{ id: '012_fix_inspection_nullable_job', up: up012 },
{ id: '013_note_visibility', up: up013 },
{ id: '014_inspection_enhancements', up: up014 },
{ id: '015_new_templates', up: up015 },
];
export function runMigrations(db: DatabaseType) {
// Create migrations tracking table
db.exec(`
CREATE TABLE IF NOT EXISTS _migrations (
id TEXT PRIMARY KEY,
applied_at TEXT DEFAULT (datetime('now'))
);
`);
const applied = new Set(
(db.prepare('SELECT id FROM _migrations').all() as { id: string }[]).map((r) => r.id)
);
const pending = migrations.filter((m) => !applied.has(m.id));
if (pending.length === 0) {
return;
}
console.log(`Running ${pending.length} migration(s)...`);
for (const migration of pending) {
try {
db.transaction(() => {
db.exec(migration.up);
db.prepare('INSERT INTO _migrations (id) VALUES (?)').run(migration.id);
})();
console.log(`${migration.id}`);
} catch (error: any) {
console.error(`${migration.id}: ${error.message}`);
throw error;
}
}
console.log('Migrations complete.');
}

View File

@@ -0,0 +1,179 @@
import { Router, Response } from 'express';
import db from '../database';
import { AuthRequest } from '../middleware/auth';
const router = Router();
// List appointments
router.get('/', (req: AuthRequest, res: Response) => {
try {
const { status, date, ownerId } = req.query;
let query = `
SELECT a.*, o.first_name as owner_first_name, o.last_name as owner_last_name,
o.phone as owner_phone, v.year, v.make, v.model
FROM appointments a
LEFT JOIN owners o ON a.owner_id = o.id
LEFT JOIN vehicles v ON a.vehicle_id = v.id
`;
const conditions: string[] = [];
const params: any[] = [];
if (status) { conditions.push('a.status = ?'); params.push(status); }
if (date) { conditions.push('a.requested_date = ?'); params.push(date); }
if (ownerId) { conditions.push('a.owner_id = ?'); params.push(Number(ownerId)); }
if (conditions.length > 0) query += ' WHERE ' + conditions.join(' AND ');
query += ' ORDER BY a.requested_date ASC, a.requested_time ASC';
const appointments = db.prepare(query).all(...params);
res.json(appointments);
} catch (error: any) {
res.status(500).json({ error: error.message });
}
});
// Get appointment
router.get('/:id', (req: AuthRequest, res: Response) => {
try {
const appointment = db.prepare(`
SELECT a.*, o.first_name as owner_first_name, o.last_name as owner_last_name,
o.phone as owner_phone, o.email as owner_email, v.year, v.make, v.model
FROM appointments a
LEFT JOIN owners o ON a.owner_id = o.id
LEFT JOIN vehicles v ON a.vehicle_id = v.id
WHERE a.id = ?
`).get(req.params.id);
if (!appointment) return res.status(404).json({ error: 'Appointment not found' });
res.json(appointment);
} catch (error: any) {
res.status(500).json({ error: error.message });
}
});
// Create appointment
router.post('/', (req: AuthRequest, res: Response) => {
try {
const { ownerId, shopId, vehicleId, serviceDescription, requestedDate, requestedTime, durationMinutes, notes } = req.body;
if (!requestedDate) return res.status(400).json({ error: 'Requested date required' });
const result = db.prepare(`
INSERT INTO appointments (owner_id, shop_id, vehicle_id, service_description, requested_date, requested_time, duration_minutes, notes)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`).run(ownerId || null, shopId || null, vehicleId || null, serviceDescription || null,
requestedDate, requestedTime || null, durationMinutes || 60, notes || null);
const appointment = db.prepare('SELECT * FROM appointments WHERE id = ?').get(result.lastInsertRowid);
res.status(201).json(appointment);
} catch (error: any) {
res.status(500).json({ error: error.message });
}
});
// Update appointment
router.put('/:id', (req: AuthRequest, res: Response) => {
try {
const appt = db.prepare('SELECT * FROM appointments WHERE id = ?').get(req.params.id) as any;
if (!appt) return res.status(404).json({ error: 'Appointment not found' });
const { ownerId, vehicleId, serviceDescription, requestedDate, requestedTime, durationMinutes, status, notes } = req.body;
db.prepare(`
UPDATE appointments SET owner_id = ?, vehicle_id = ?, service_description = ?,
requested_date = ?, requested_time = ?, duration_minutes = ?, status = ?, notes = ?,
updated_at = datetime('now') WHERE id = ?
`).run(
ownerId !== undefined ? (ownerId || null) : appt.owner_id,
vehicleId !== undefined ? (vehicleId || null) : appt.vehicle_id,
serviceDescription ?? appt.service_description,
requestedDate ?? appt.requested_date,
requestedTime ?? appt.requested_time,
durationMinutes ?? appt.duration_minutes,
status ?? appt.status,
notes ?? appt.notes,
appt.id,
);
const updated = db.prepare('SELECT * FROM appointments WHERE id = ?').get(appt.id);
res.json(updated);
} catch (error: any) {
res.status(500).json({ error: error.message });
}
});
// Delete appointment
router.delete('/:id', (req: AuthRequest, res: Response) => {
try {
db.prepare('DELETE FROM appointments WHERE id = ?').run(req.params.id);
res.json({ success: true });
} catch (error: any) {
res.status(500).json({ error: error.message });
}
});
// --- Deferred Services ---
router.get('/deferred', (req: AuthRequest, res: Response) => {
try {
const { status, vehicleId } = req.query;
let query = `
SELECT d.*, o.first_name as owner_first_name, o.last_name as owner_last_name,
v.year, v.make, v.model, j.job_number
FROM deferred_services d
LEFT JOIN owners o ON d.owner_id = o.id
LEFT JOIN vehicles v ON d.vehicle_id = v.id
LEFT JOIN jobs j ON d.job_id = j.id
`;
const conditions: string[] = [];
const params: any[] = [];
if (status) { conditions.push('d.status = ?'); params.push(status); }
if (vehicleId) { conditions.push('d.vehicle_id = ?'); params.push(Number(vehicleId)); }
if (conditions.length > 0) query += ' WHERE ' + conditions.join(' AND ');
query += ' ORDER BY d.follow_up_date ASC';
const deferred = db.prepare(query).all(...params);
res.json(deferred);
} catch (error: any) {
res.status(500).json({ error: error.message });
}
});
router.post('/deferred', (req: AuthRequest, res: Response) => {
try {
const { jobId, vehicleId, ownerId, description, reason, followUpDate } = req.body;
if (!description) return res.status(400).json({ error: 'Description required' });
const result = db.prepare(`
INSERT INTO deferred_services (job_id, vehicle_id, owner_id, description, reason, follow_up_date)
VALUES (?, ?, ?, ?, ?, ?)
`).run(jobId || null, vehicleId || null, ownerId || null, description, reason || null, followUpDate || null);
const deferred = db.prepare('SELECT * FROM deferred_services WHERE id = ?').get(result.lastInsertRowid);
res.status(201).json(deferred);
} catch (error: any) {
res.status(500).json({ error: error.message });
}
});
router.put('/deferred/:id', (req: AuthRequest, res: Response) => {
try {
const existing = db.prepare('SELECT * FROM deferred_services WHERE id = ?').get(req.params.id) as any;
if (!existing) return res.status(404).json({ error: 'Deferred service not found' });
const { status, followUpDate } = req.body;
db.prepare('UPDATE deferred_services SET status = ?, follow_up_date = ? WHERE id = ?')
.run(status ?? existing.status, followUpDate ?? existing.follow_up_date, existing.id);
const updated = db.prepare('SELECT * FROM deferred_services WHERE id = ?').get(existing.id);
res.json(updated);
} catch (error: any) {
res.status(500).json({ error: error.message });
}
});
router.delete('/deferred/:id', (req: AuthRequest, res: Response) => {
try {
db.prepare('DELETE FROM deferred_services WHERE id = ?').run(req.params.id);
res.json({ success: true });
} catch (error: any) {
res.status(500).json({ error: error.message });
}
});
export default router;

71
server/src/routes/auth.ts Normal file
View File

@@ -0,0 +1,71 @@
import { Router, Response } from 'express';
import bcrypt from 'bcryptjs';
import db from '../database';
import { AuthRequest, generateToken, authMiddleware } from '../middleware/auth';
import { User } from '../types';
const router = Router();
router.post('/register', (req: AuthRequest, res: Response) => {
try {
const { username, password, displayName } = req.body;
if (!username || !password) {
return res.status(400).json({ error: 'Username and password required' });
}
// Only allow registration if no users exist (first-run setup)
const userCount = db.prepare('SELECT COUNT(*) as count FROM users').get() as { count: number };
if (userCount.count > 0) {
return res.status(403).json({ error: 'Registration disabled. User already exists.' });
}
const hash = bcrypt.hashSync(password, 10);
const result = db.prepare(
'INSERT INTO users (username, password_hash, display_name) VALUES (?, ?, ?)'
).run(username, hash, displayName || null);
const token = generateToken(result.lastInsertRowid as number);
res.json({ token, user: { id: result.lastInsertRowid, username, displayName: displayName || null } });
} catch (error: any) {
if (error.code === 'SQLITE_CONSTRAINT_UNIQUE') {
return res.status(409).json({ error: 'Username already exists' });
}
res.status(500).json({ error: error.message });
}
});
router.post('/login', (req: AuthRequest, res: Response) => {
try {
const { username, password } = req.body;
if (!username || !password) {
return res.status(400).json({ error: 'Username and password required' });
}
const user = db.prepare('SELECT * FROM users WHERE username = ?').get(username) as User | undefined;
if (!user || !bcrypt.compareSync(password, user.password_hash)) {
return res.status(401).json({ error: 'Invalid credentials' });
}
const token = generateToken(user.id);
res.json({ token, user: { id: user.id, username: user.username, displayName: user.display_name } });
} catch (error: any) {
res.status(500).json({ error: error.message });
}
});
router.get('/me', authMiddleware, (req: AuthRequest, res: Response) => {
try {
const user = db.prepare('SELECT id, username, display_name FROM users WHERE id = ?').get(req.userId) as User | undefined;
if (!user) return res.status(404).json({ error: 'User not found' });
res.json({ id: user.id, username: user.username, displayName: user.display_name });
} catch (error: any) {
res.status(500).json({ error: error.message });
}
});
router.get('/setup-required', (_req: AuthRequest, res: Response) => {
const userCount = db.prepare('SELECT COUNT(*) as count FROM users').get() as { count: number };
res.json({ setupRequired: userCount.count === 0 });
});
export default router;

View File

@@ -0,0 +1,51 @@
import { Router, Response } from 'express';
import path from 'path';
import fs from 'fs';
import db from '../database';
import { AuthRequest } from '../middleware/auth';
const dataDir = process.env.DATA_DIR || path.join(__dirname, '../../../data');
const router = Router();
// Download a backup of the database
router.get('/download', (req: AuthRequest, res: Response) => {
try {
const dbPath = path.join(dataDir, 'autoking.db');
if (!fs.existsSync(dbPath)) return res.status(404).json({ error: 'Database not found' });
// Force WAL checkpoint so the backup file is complete
db.pragma('wal_checkpoint(TRUNCATE)');
const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
res.setHeader('Content-Type', 'application/octet-stream');
res.setHeader('Content-Disposition', `attachment; filename="shopmanager-backup-${timestamp}.db"`);
fs.createReadStream(dbPath).pipe(res);
} catch (error: any) {
res.status(500).json({ error: error.message });
}
});
// Get backup info (last modified, size)
router.get('/info', (_req: AuthRequest, res: Response) => {
try {
const dbPath = path.join(dataDir, 'autoking.db');
if (!fs.existsSync(dbPath)) return res.json({ exists: false });
const stats = fs.statSync(dbPath);
const tableCount = db.prepare("SELECT COUNT(*) as count FROM sqlite_master WHERE type='table' AND name NOT LIKE '_migrations' AND name NOT LIKE 'sqlite_%'").get() as { count: number };
const jobCount = db.prepare('SELECT COUNT(*) as count FROM jobs').get() as { count: number };
res.json({
exists: true,
size: stats.size,
lastModified: stats.mtime.toISOString(),
tables: tableCount.count,
jobs: jobCount.count,
});
} catch (error: any) {
res.status(500).json({ error: error.message });
}
});
export default router;

View File

@@ -0,0 +1,127 @@
import { Router, Response } from 'express';
import db from '../database';
import { AuthRequest } from '../middleware/auth';
const router = Router();
// List canned services
router.get('/', (req: AuthRequest, res: Response) => {
try {
const { active } = req.query;
let query = 'SELECT * FROM canned_services';
const params: any[] = [];
if (active !== undefined) { query += ' WHERE is_active = ?'; params.push(Number(active)); }
query += ' ORDER BY category, name';
const services = db.prepare(query).all(...params);
// Attach items to each service
const result = services.map((svc: any) => {
const items = db.prepare('SELECT * FROM canned_service_items WHERE canned_service_id = ? ORDER BY sort_order, id').all(svc.id);
return { ...svc, items };
});
res.json(result);
} catch (error: any) {
res.status(500).json({ error: error.message });
}
});
// Get single canned service
router.get('/:id', (req: AuthRequest, res: Response) => {
try {
const service = db.prepare('SELECT * FROM canned_services WHERE id = ?').get(req.params.id);
if (!service) return res.status(404).json({ error: 'Canned service not found' });
const items = db.prepare('SELECT * FROM canned_service_items WHERE canned_service_id = ? ORDER BY sort_order, id').all(req.params.id);
res.json({ ...service, items });
} catch (error: any) {
res.status(500).json({ error: error.message });
}
});
// Create canned service
router.post('/', (req: AuthRequest, res: Response) => {
try {
const { name, description, category, items } = req.body;
if (!name) return res.status(400).json({ error: 'Name required' });
const create = db.transaction(() => {
const result = db.prepare(
'INSERT INTO canned_services (name, description, category) VALUES (?, ?, ?)'
).run(name, description || null, category || null);
const serviceId = result.lastInsertRowid;
if (items?.length) {
const insertItem = db.prepare(`
INSERT INTO canned_service_items (canned_service_id, item_type, description, billing_type, hours, rate, flat_rate, quantity, unit_cost, markup, sort_order)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`);
for (const item of items) {
insertItem.run(serviceId, item.itemType || 'labor', item.description, item.billingType || 'hourly',
item.hours || null, item.rate || null, item.flatRate || null,
item.quantity || 1, item.unitCost || 0, item.markup || 0, item.sortOrder || 0);
}
}
return serviceId;
});
const serviceId = create();
const service = db.prepare('SELECT * FROM canned_services WHERE id = ?').get(serviceId);
const serviceItems = db.prepare('SELECT * FROM canned_service_items WHERE canned_service_id = ?').all(serviceId);
res.status(201).json({ ...(service as any), items: serviceItems });
} catch (error: any) {
res.status(500).json({ error: error.message });
}
});
// Update canned service
router.put('/:id', (req: AuthRequest, res: Response) => {
try {
const service = db.prepare('SELECT * FROM canned_services WHERE id = ?').get(req.params.id) as any;
if (!service) return res.status(404).json({ error: 'Canned service not found' });
const { name, description, category, isActive, items } = req.body;
const update = db.transaction(() => {
db.prepare(`
UPDATE canned_services SET name = ?, description = ?, category = ?, is_active = ?, updated_at = datetime('now') WHERE id = ?
`).run(name ?? service.name, description ?? service.description, category ?? service.category, isActive ?? service.is_active, service.id);
// Replace items if provided
if (items !== undefined) {
db.prepare('DELETE FROM canned_service_items WHERE canned_service_id = ?').run(service.id);
const insertItem = db.prepare(`
INSERT INTO canned_service_items (canned_service_id, item_type, description, billing_type, hours, rate, flat_rate, quantity, unit_cost, markup, sort_order)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`);
for (const item of items) {
insertItem.run(service.id, item.itemType || 'labor', item.description, item.billingType || 'hourly',
item.hours || null, item.rate || null, item.flatRate || null,
item.quantity || 1, item.unitCost || 0, item.markup || 0, item.sortOrder || 0);
}
}
});
update();
const updated = db.prepare('SELECT * FROM canned_services WHERE id = ?').get(service.id);
const updatedItems = db.prepare('SELECT * FROM canned_service_items WHERE canned_service_id = ?').all(service.id);
res.json({ ...(updated as any), items: updatedItems });
} catch (error: any) {
res.status(500).json({ error: error.message });
}
});
// Delete canned service
router.delete('/:id', (req: AuthRequest, res: Response) => {
try {
db.prepare('DELETE FROM canned_services WHERE id = ?').run(req.params.id);
res.json({ success: true });
} catch (error: any) {
res.status(500).json({ error: error.message });
}
});
export default router;

View File

@@ -0,0 +1,83 @@
import { Router, Response } from 'express';
import db from '../database';
import { AuthRequest } from '../middleware/auth';
const router = Router();
router.get('/stats', (_req: AuthRequest, res: Response) => {
try {
const now = new Date();
const startOfMonth = new Date(now.getFullYear(), now.getMonth(), 1).toISOString();
const endOfMonth = new Date(now.getFullYear(), now.getMonth() + 1, 0, 23, 59, 59).toISOString();
const activeJobs = db.prepare("SELECT COUNT(*) as count FROM jobs WHERE status IN ('received', 'in_progress')").get() as { count: number };
const completedThisMonth = db.prepare("SELECT COUNT(*) as count FROM jobs WHERE status IN ('completed', 'invoiced', 'closed') AND completed_date >= ? AND completed_date <= ?").get(startOfMonth, endOfMonth) as { count: number };
const outstandingInvoices = db.prepare("SELECT COUNT(*) as count, COALESCE(SUM(total), 0) as total FROM invoices WHERE status IN ('draft', 'sent', 'partial', 'overdue')").get() as { count: number; total: number };
const paidPayments = db.prepare('SELECT COALESCE(SUM(amount), 0) as total FROM payments WHERE invoice_id IN (SELECT id FROM invoices WHERE status IN (\'draft\', \'sent\', \'partial\', \'overdue\'))').get() as { total: number };
const revenueThisMonth = db.prepare("SELECT COALESCE(SUM(p.amount), 0) as total FROM payments p WHERE p.payment_date >= ? AND p.payment_date <= ?").get(startOfMonth, endOfMonth) as { total: number };
const totalShops = db.prepare("SELECT COUNT(*) as count FROM shops WHERE active = 1").get() as { count: number };
const recentJobs = db.prepare(`
SELECT j.*, s.name as shop_name, v.year, v.make, v.model, o.first_name, o.last_name
FROM jobs j
LEFT JOIN shops s ON j.shop_id = s.id
LEFT JOIN vehicles v ON j.vehicle_id = v.id
LEFT JOIN owners o ON j.owner_id = o.id
ORDER BY j.created_at DESC LIMIT 5
`).all();
const outstandingInvoiceList = db.prepare(`
SELECT i.*, s.name as shop_name, o.first_name as owner_first_name, o.last_name as owner_last_name
FROM invoices i
LEFT JOIN shops s ON i.shop_id = s.id
LEFT JOIN owners o ON i.owner_id = o.id
WHERE i.status IN ('sent', 'partial', 'overdue')
ORDER BY i.due_date ASC LIMIT 5
`).all();
const urgentRecs = db.prepare(`
SELECT r.*, j.job_number, j.id as job_id, v.year, v.make, v.model,
o.first_name, o.last_name, s.name as shop_name
FROM recommendations r
JOIN jobs j ON r.job_id = j.id
LEFT JOIN vehicles v ON j.vehicle_id = v.id
LEFT JOIN owners o ON j.owner_id = o.id
LEFT JOIN shops s ON j.shop_id = s.id
WHERE r.status = 'open' AND r.urgency = 'now'
ORDER BY r.created_at DESC
`).all();
const soonRecs = db.prepare(`
SELECT r.*, j.job_number, j.id as job_id, v.year, v.make, v.model,
o.first_name, o.last_name, s.name as shop_name
FROM recommendations r
JOIN jobs j ON r.job_id = j.id
LEFT JOIN vehicles v ON j.vehicle_id = v.id
LEFT JOIN owners o ON j.owner_id = o.id
LEFT JOIN shops s ON j.shop_id = s.id
WHERE r.status = 'open' AND r.urgency = 'soon'
ORDER BY r.created_at DESC LIMIT 10
`).all();
res.json({
activeJobs: activeJobs.count,
completedThisMonth: completedThisMonth.count,
outstandingInvoiceCount: outstandingInvoices.count,
outstandingAmount: outstandingInvoices.total - paidPayments.total,
revenueThisMonth: revenueThisMonth.total,
totalShops: totalShops.count,
recentJobs,
outstandingInvoices: outstandingInvoiceList,
urgentRecs,
soonRecs,
});
} catch (error: any) {
res.status(500).json({ error: error.message });
}
});
export default router;

View File

@@ -0,0 +1,296 @@
import { Router, Response } from 'express';
import crypto from 'crypto';
import db from '../database';
import { AuthRequest } from '../middleware/auth';
import type { Estimate, Settings } from '../types';
const router = Router();
function generateEstimateNumber(): string {
const year = new Date().getFullYear();
const last = db.prepare(
"SELECT estimate_number FROM estimates WHERE estimate_number LIKE ? ORDER BY id DESC LIMIT 1"
).get(`EST-${year}-%`) as { estimate_number: string } | undefined;
let seq = 1;
if (last) {
const parts = last.estimate_number.split('-');
seq = parseInt(parts[2], 10) + 1;
}
return `EST-${year}-${String(seq).padStart(4, '0')}`;
}
function recalcTotals(estimateId: number) {
const items = db.prepare('SELECT * FROM estimate_items WHERE estimate_id = ?').all(estimateId) as any[];
let subtotal = 0;
for (const item of items) {
if (item.item_type === 'labor') {
if (item.billing_type === 'flat_rate') subtotal += item.flat_rate || 0;
else subtotal += (item.hours || 0) * (item.rate || 0);
} else {
subtotal += item.quantity * item.unit_price * (1 + item.markup);
}
}
const est = db.prepare('SELECT tax_rate FROM estimates WHERE id = ?').get(estimateId) as any;
const taxRate = est?.tax_rate || 0;
const taxAmount = subtotal * taxRate;
const total = subtotal + taxAmount;
db.prepare("UPDATE estimates SET subtotal = ?, tax_amount = ?, total = ?, updated_at = datetime('now') WHERE id = ?")
.run(subtotal, taxAmount, total, estimateId);
}
// List estimates
router.get('/', (req: AuthRequest, res: Response) => {
try {
const { status, shopId, ownerId } = req.query;
let query = `
SELECT e.*, s.name as shop_name, o.first_name as owner_first_name, o.last_name as owner_last_name,
v.year, v.make, v.model
FROM estimates e
LEFT JOIN shops s ON e.shop_id = s.id
LEFT JOIN owners o ON e.owner_id = o.id
LEFT JOIN vehicles v ON e.vehicle_id = v.id
`;
const conditions: string[] = [];
const params: any[] = [];
if (status) { conditions.push('e.status = ?'); params.push(status); }
if (shopId) { conditions.push('e.shop_id = ?'); params.push(Number(shopId)); }
if (ownerId) { conditions.push('e.owner_id = ?'); params.push(Number(ownerId)); }
if (conditions.length > 0) query += ' WHERE ' + conditions.join(' AND ');
query += ' ORDER BY e.created_at DESC';
const estimates = db.prepare(query).all(...params);
res.json(estimates);
} catch (error: any) {
res.status(500).json({ error: error.message });
}
});
// Get estimate detail
router.get('/:id', (req: AuthRequest, res: Response) => {
try {
const estimate = db.prepare(`
SELECT e.*, s.name as shop_name, s.contact_name as shop_contact,
o.first_name as owner_first_name, o.last_name as owner_last_name, o.phone as owner_phone, o.email as owner_email,
v.year, v.make, v.model, v.vin
FROM estimates e
LEFT JOIN shops s ON e.shop_id = s.id
LEFT JOIN owners o ON e.owner_id = o.id
LEFT JOIN vehicles v ON e.vehicle_id = v.id
WHERE e.id = ?
`).get(req.params.id);
if (!estimate) return res.status(404).json({ error: 'Estimate not found' });
const items = db.prepare('SELECT * FROM estimate_items WHERE estimate_id = ? ORDER BY sort_order, id').all(req.params.id);
res.json({ ...estimate, items });
} catch (error: any) {
res.status(500).json({ error: error.message });
}
});
// Create estimate
router.post('/', (req: AuthRequest, res: Response) => {
try {
const { customerType, shopId, ownerId, vehicleId, notes, taxRate } = req.body;
const settings = db.prepare('SELECT * FROM settings WHERE id = 1').get() as Settings;
const estimateNumber = generateEstimateNumber();
const approvalToken = crypto.randomBytes(32).toString('hex');
const validUntil = new Date();
validUntil.setDate(validUntil.getDate() + 30);
const result = db.prepare(`
INSERT INTO estimates (estimate_number, customer_type, shop_id, owner_id, vehicle_id, status, tax_rate, notes, valid_until, approval_token)
VALUES (?, ?, ?, ?, ?, 'draft', ?, ?, ?, ?)
`).run(
estimateNumber,
customerType || 'owner',
shopId || null,
ownerId || null,
vehicleId || null,
taxRate ?? settings.default_tax_rate ?? 0,
notes || null,
validUntil.toISOString(),
approvalToken,
);
const estimate = db.prepare('SELECT * FROM estimates WHERE id = ?').get(result.lastInsertRowid);
res.status(201).json(estimate);
} catch (error: any) {
res.status(500).json({ error: error.message });
}
});
// Update estimate
router.put('/:id', (req: AuthRequest, res: Response) => {
try {
const estimate = db.prepare('SELECT * FROM estimates WHERE id = ?').get(req.params.id) as Estimate | undefined;
if (!estimate) return res.status(404).json({ error: 'Estimate not found' });
const { status, notes, taxRate, validUntil, customerType, shopId, ownerId, vehicleId } = req.body;
db.prepare(`
UPDATE estimates SET status = ?, notes = ?, tax_rate = ?, valid_until = ?,
customer_type = ?, shop_id = ?, owner_id = ?, vehicle_id = ?,
updated_at = datetime('now') WHERE id = ?
`).run(
status ?? estimate.status,
notes ?? estimate.notes,
taxRate ?? estimate.tax_rate,
validUntil ?? estimate.valid_until,
customerType ?? estimate.customer_type,
shopId !== undefined ? (shopId || null) : estimate.shop_id,
ownerId !== undefined ? (ownerId || null) : estimate.owner_id,
vehicleId !== undefined ? (vehicleId || null) : estimate.vehicle_id,
estimate.id,
);
if (taxRate !== undefined) recalcTotals(estimate.id);
const updated = db.prepare('SELECT * FROM estimates WHERE id = ?').get(estimate.id);
res.json(updated);
} catch (error: any) {
res.status(500).json({ error: error.message });
}
});
// Delete estimate
router.delete('/:id', (req: AuthRequest, res: Response) => {
try {
const estimate = db.prepare('SELECT * FROM estimates WHERE id = ?').get(req.params.id) as Estimate | undefined;
if (!estimate) return res.status(404).json({ error: 'Estimate not found' });
db.prepare('DELETE FROM estimates WHERE id = ?').run(estimate.id);
res.json({ success: true });
} catch (error: any) {
res.status(500).json({ error: error.message });
}
});
// Add item to estimate
router.post('/:id/items', (req: AuthRequest, res: Response) => {
try {
const estimate = db.prepare('SELECT * FROM estimates WHERE id = ?').get(req.params.id);
if (!estimate) return res.status(404).json({ error: 'Estimate not found' });
const { itemType, description, quantity, unitPrice, markup, billingType, hours, rate, flatRate, sortOrder } = req.body;
const result = db.prepare(`
INSERT INTO estimate_items (estimate_id, item_type, description, quantity, unit_price, markup, billing_type, hours, rate, flat_rate, sort_order)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(
req.params.id,
itemType || 'labor',
description,
quantity || 1,
unitPrice || 0,
markup || 0,
billingType || 'hourly',
hours || null,
rate || null,
flatRate || null,
sortOrder || 0,
);
recalcTotals(Number(req.params.id));
const item = db.prepare('SELECT * FROM estimate_items WHERE id = ?').get(result.lastInsertRowid);
res.status(201).json(item);
} catch (error: any) {
res.status(500).json({ error: error.message });
}
});
// Update estimate item
router.put('/:id/items/:itemId', (req: AuthRequest, res: Response) => {
try {
const item = db.prepare('SELECT * FROM estimate_items WHERE id = ? AND estimate_id = ?').get(req.params.itemId, req.params.id) as any;
if (!item) return res.status(404).json({ error: 'Item not found' });
const { description, quantity, unitPrice, markup, billingType, hours, rate, flatRate, sortOrder } = req.body;
db.prepare(`
UPDATE estimate_items SET description = ?, quantity = ?, unit_price = ?, markup = ?,
billing_type = ?, hours = ?, rate = ?, flat_rate = ?, sort_order = ?
WHERE id = ?
`).run(
description ?? item.description,
quantity ?? item.quantity,
unitPrice ?? item.unit_price,
markup ?? item.markup,
billingType ?? item.billing_type,
hours !== undefined ? hours : item.hours,
rate !== undefined ? rate : item.rate,
flatRate !== undefined ? flatRate : item.flat_rate,
sortOrder ?? item.sort_order,
item.id,
);
recalcTotals(Number(req.params.id));
const updated = db.prepare('SELECT * FROM estimate_items WHERE id = ?').get(item.id);
res.json(updated);
} catch (error: any) {
res.status(500).json({ error: error.message });
}
});
// Delete estimate item
router.delete('/:id/items/:itemId', (req: AuthRequest, res: Response) => {
try {
db.prepare('DELETE FROM estimate_items WHERE id = ? AND estimate_id = ?').run(req.params.itemId, req.params.id);
recalcTotals(Number(req.params.id));
res.json({ success: true });
} catch (error: any) {
res.status(500).json({ error: error.message });
}
});
// Convert estimate to job
router.post('/:id/convert', (req: AuthRequest, res: Response) => {
try {
const estimate = db.prepare('SELECT * FROM estimates WHERE id = ?').get(req.params.id) as Estimate | undefined;
if (!estimate) return res.status(404).json({ error: 'Estimate not found' });
if (!estimate.vehicle_id) return res.status(400).json({ error: 'Estimate must have a vehicle to convert to a job' });
const items = db.prepare('SELECT * FROM estimate_items WHERE estimate_id = ? ORDER BY sort_order, id').all(estimate.id) as any[];
// Generate job number
const year = new Date().getFullYear();
const last = db.prepare("SELECT job_number FROM jobs WHERE job_number LIKE ? ORDER BY id DESC LIMIT 1")
.get(`TM-${year}-%`) as { job_number: string } | undefined;
let seq = 1;
if (last) { seq = parseInt(last.job_number.split('-')[2], 10) + 1; }
const jobNumber = `TM-${year}-${String(seq).padStart(4, '0')}`;
const convert = db.transaction(() => {
const jobResult = db.prepare(`
INSERT INTO jobs (job_number, customer_type, shop_id, owner_id, vehicle_id, estimate_id, status, description)
VALUES (?, ?, ?, ?, ?, ?, 'received', ?)
`).run(jobNumber, estimate.customer_type, estimate.shop_id, estimate.owner_id, estimate.vehicle_id, estimate.id, estimate.notes);
const jobId = jobResult.lastInsertRowid;
// Convert items to labor/parts
for (const item of items) {
if (item.item_type === 'labor') {
db.prepare(`
INSERT INTO labor_items (job_id, description, billing_type, hours, rate, flat_rate, sort_order)
VALUES (?, ?, ?, ?, ?, ?, ?)
`).run(jobId, item.description, item.billing_type || 'hourly', item.hours, item.rate, item.flat_rate, item.sort_order);
} else {
db.prepare(`
INSERT INTO parts_items (job_id, description, quantity, unit_cost, markup, sort_order)
VALUES (?, ?, ?, ?, ?, ?)
`).run(jobId, item.description, item.quantity, item.unit_price, item.markup, item.sort_order);
}
}
// Mark estimate as converted
db.prepare("UPDATE estimates SET status = 'converted', updated_at = datetime('now') WHERE id = ?").run(estimate.id);
return jobId;
});
const jobId = convert();
const job = db.prepare('SELECT * FROM jobs WHERE id = ?').get(jobId);
res.status(201).json(job);
} catch (error: any) {
res.status(500).json({ error: error.message });
}
});
export default router;

View File

@@ -0,0 +1,96 @@
import { Router, Response } from 'express';
import multer from 'multer';
import path from 'path';
import fs from 'fs';
import crypto from 'crypto';
import db from '../database';
import { AuthRequest } from '../middleware/auth';
const dataDir = process.env.DATA_DIR || path.join(__dirname, '../../../data');
const uploadsDir = path.join(dataDir, 'uploads');
if (!fs.existsSync(uploadsDir)) fs.mkdirSync(uploadsDir, { recursive: true });
const storage = multer.diskStorage({
destination: (_req, _file, cb) => cb(null, uploadsDir),
filename: (_req, file, cb) => {
const ext = path.extname(file.originalname);
cb(null, `${Date.now()}-${crypto.randomBytes(8).toString('hex')}${ext}`);
},
});
const upload = multer({
storage,
limits: { fileSize: 25 * 1024 * 1024 }, // 25MB
});
const router = Router();
// Get files for an entity
router.get('/:entityType/:entityId', (req: AuthRequest, res: Response) => {
try {
const { entityType, entityId } = req.params;
const files = db.prepare('SELECT * FROM files WHERE entity_type = ? AND entity_id = ? ORDER BY created_at DESC').all(entityType, entityId);
res.json(files);
} catch (error: any) {
res.status(500).json({ error: error.message });
}
});
// Upload file(s)
router.post('/:entityType/:entityId', upload.array('files', 10), (req: AuthRequest, res: Response) => {
try {
const { entityType, entityId } = req.params;
const uploadedFiles = req.files as Express.Multer.File[];
if (!uploadedFiles || uploadedFiles.length === 0) {
return res.status(400).json({ error: 'No files uploaded' });
}
const insertStmt = db.prepare(
'INSERT INTO files (entity_type, entity_id, filename, original_name, mime_type, size) VALUES (?, ?, ?, ?, ?, ?)'
);
const results = uploadedFiles.map((file) => {
const result = insertStmt.run(entityType, entityId, file.filename, file.originalname, file.mimetype, file.size);
return db.prepare('SELECT * FROM files WHERE id = ?').get(result.lastInsertRowid);
});
res.status(201).json(results);
} catch (error: any) {
res.status(500).json({ error: error.message });
}
});
// Download/serve a file
router.get('/download/:id', (req: AuthRequest, res: Response) => {
try {
const file = db.prepare('SELECT * FROM files WHERE id = ?').get(req.params.id) as any;
if (!file) return res.status(404).json({ error: 'File not found' });
const filePath = path.join(uploadsDir, file.filename);
if (!fs.existsSync(filePath)) return res.status(404).json({ error: 'File missing from disk' });
res.setHeader('Content-Type', file.mime_type || 'application/octet-stream');
res.setHeader('Content-Disposition', `inline; filename="${file.original_name}"`);
fs.createReadStream(filePath).pipe(res);
} catch (error: any) {
res.status(500).json({ error: error.message });
}
});
// Delete a file
router.delete('/:id', (req: AuthRequest, res: Response) => {
try {
const file = db.prepare('SELECT * FROM files WHERE id = ?').get(req.params.id) as any;
if (!file) return res.status(404).json({ error: 'File not found' });
const filePath = path.join(uploadsDir, file.filename);
if (fs.existsSync(filePath)) fs.unlinkSync(filePath);
db.prepare('DELETE FROM files WHERE id = ?').run(file.id);
res.json({ success: true });
} catch (error: any) {
res.status(500).json({ error: error.message });
}
});
export default router;

105
server/src/routes/fleets.ts Normal file
View File

@@ -0,0 +1,105 @@
import { Router, Response } from 'express';
import db from '../database';
import { AuthRequest } from '../middleware/auth';
const router = Router();
router.get('/', (_req: AuthRequest, res: Response) => {
try {
const fleets = db.prepare('SELECT * FROM fleets ORDER BY name').all() as any[];
const result = fleets.map(f => {
const vehicleCount = db.prepare('SELECT COUNT(*) as count FROM fleet_vehicles WHERE fleet_id = ?').get(f.id) as { count: number };
return { ...f, vehicleCount: vehicleCount.count };
});
res.json(result);
} catch (error: any) {
res.status(500).json({ error: error.message });
}
});
router.get('/:id', (req: AuthRequest, res: Response) => {
try {
const fleet = db.prepare('SELECT * FROM fleets WHERE id = ?').get(req.params.id);
if (!fleet) return res.status(404).json({ error: 'Fleet not found' });
const vehicles = db.prepare(`
SELECT v.*, o.first_name, o.last_name FROM fleet_vehicles fv
JOIN vehicles v ON fv.vehicle_id = v.id
LEFT JOIN owners o ON v.owner_id = o.id
WHERE fv.fleet_id = ?
`).all(req.params.id);
res.json({ ...(fleet as any), vehicles });
} catch (error: any) {
res.status(500).json({ error: error.message });
}
});
router.post('/', (req: AuthRequest, res: Response) => {
try {
const { name, contactName, phone, email, billingAddress, paymentTerms, notes } = req.body;
if (!name) return res.status(400).json({ error: 'Name required' });
const result = db.prepare(
'INSERT INTO fleets (name, contact_name, phone, email, billing_address, payment_terms, notes) VALUES (?, ?, ?, ?, ?, ?, ?)'
).run(name, contactName || null, phone || null, email || null, billingAddress || null, paymentTerms || 'Net 30', notes || null);
const fleet = db.prepare('SELECT * FROM fleets WHERE id = ?').get(result.lastInsertRowid);
res.status(201).json(fleet);
} catch (error: any) {
res.status(500).json({ error: error.message });
}
});
router.put('/:id', (req: AuthRequest, res: Response) => {
try {
const fleet = db.prepare('SELECT * FROM fleets WHERE id = ?').get(req.params.id) as any;
if (!fleet) return res.status(404).json({ error: 'Fleet not found' });
const { name, contactName, phone, email, billingAddress, paymentTerms, notes } = req.body;
db.prepare(`
UPDATE fleets SET name = ?, contact_name = ?, phone = ?, email = ?, billing_address = ?,
payment_terms = ?, notes = ?, updated_at = datetime('now') WHERE id = ?
`).run(name ?? fleet.name, contactName ?? fleet.contact_name, phone ?? fleet.phone,
email ?? fleet.email, billingAddress ?? fleet.billing_address, paymentTerms ?? fleet.payment_terms,
notes ?? fleet.notes, fleet.id);
const updated = db.prepare('SELECT * FROM fleets WHERE id = ?').get(fleet.id);
res.json(updated);
} catch (error: any) {
res.status(500).json({ error: error.message });
}
});
router.delete('/:id', (req: AuthRequest, res: Response) => {
try {
db.prepare('DELETE FROM fleets WHERE id = ?').run(req.params.id);
res.json({ success: true });
} catch (error: any) {
res.status(500).json({ error: error.message });
}
});
// Add vehicle to fleet
router.post('/:id/vehicles', (req: AuthRequest, res: Response) => {
try {
const { vehicleId } = req.body;
if (!vehicleId) return res.status(400).json({ error: 'Vehicle ID required' });
db.prepare('INSERT OR IGNORE INTO fleet_vehicles (fleet_id, vehicle_id) VALUES (?, ?)').run(req.params.id, vehicleId);
res.status(201).json({ success: true });
} catch (error: any) {
res.status(500).json({ error: error.message });
}
});
// Remove vehicle from fleet
router.delete('/:id/vehicles/:vehicleId', (req: AuthRequest, res: Response) => {
try {
db.prepare('DELETE FROM fleet_vehicles WHERE fleet_id = ? AND vehicle_id = ?').run(req.params.id, req.params.vehicleId);
res.json({ success: true });
} catch (error: any) {
res.status(500).json({ error: error.message });
}
});
export default router;

View File

@@ -0,0 +1,254 @@
import { Router, Response } from 'express';
import db from '../database';
import { AuthRequest } from '../middleware/auth';
const router = Router();
// --- Templates ---
router.get('/templates', (req: AuthRequest, res: Response) => {
try {
const templates = db.prepare('SELECT * FROM inspection_templates WHERE is_active = 1 ORDER BY is_default DESC, name').all();
const result = templates.map((t: any) => {
const items = db.prepare('SELECT * FROM inspection_template_items WHERE template_id = ? ORDER BY sort_order, id').all(t.id);
return { ...(t as any), items };
});
res.json(result);
} catch (error: any) {
res.status(500).json({ error: error.message });
}
});
router.post('/templates', (req: AuthRequest, res: Response) => {
try {
const { name, description, items } = req.body;
if (!name) return res.status(400).json({ error: 'Name required' });
const create = db.transaction(() => {
const result = db.prepare('INSERT INTO inspection_templates (name, description) VALUES (?, ?)').run(name, description || null);
const templateId = result.lastInsertRowid;
if (items?.length) {
const insert = db.prepare('INSERT INTO inspection_template_items (template_id, section, label, sort_order) VALUES (?, ?, ?, ?)');
for (const item of items) {
insert.run(templateId, item.section || 'General', item.label, item.sortOrder || 0);
}
}
return templateId;
});
const templateId = create();
const template = db.prepare('SELECT * FROM inspection_templates WHERE id = ?').get(templateId);
const templateItems = db.prepare('SELECT * FROM inspection_template_items WHERE template_id = ?').all(templateId);
res.status(201).json({ ...(template as any), items: templateItems });
} catch (error: any) {
res.status(500).json({ error: error.message });
}
});
router.delete('/templates/:id', (req: AuthRequest, res: Response) => {
try {
db.prepare("UPDATE inspection_templates SET is_active = 0, updated_at = datetime('now') WHERE id = ?").run(req.params.id);
res.json({ success: true });
} catch (error: any) {
res.status(500).json({ error: error.message });
}
});
// --- Inspections ---
// Get inspections for a job
router.get('/job/:jobId', (req: AuthRequest, res: Response) => {
try {
const inspections = db.prepare(`
SELECT i.*, t.name as template_name, tech.name as technician_name
FROM inspections i
LEFT JOIN inspection_templates t ON i.template_id = t.id
LEFT JOIN technicians tech ON i.technician_id = tech.id
WHERE i.job_id = ?
ORDER BY i.created_at DESC
`).all(req.params.jobId);
res.json(inspections);
} catch (error: any) {
res.status(500).json({ error: error.message });
}
});
// Get inspections by vehicle
router.get('/vehicle/:vehicleId', (req: AuthRequest, res: Response) => {
try {
const inspections = db.prepare(`
SELECT i.*, t.name as template_name, tech.name as technician_name,
j.job_number
FROM inspections i
LEFT JOIN inspection_templates t ON i.template_id = t.id
LEFT JOIN technicians tech ON i.technician_id = tech.id
LEFT JOIN jobs j ON i.job_id = j.id
WHERE i.vehicle_id = ? OR i.job_id IN (SELECT id FROM jobs WHERE vehicle_id = ?)
ORDER BY i.created_at DESC
`).all(req.params.vehicleId, req.params.vehicleId);
res.json(inspections);
} catch (error: any) {
res.status(500).json({ error: error.message });
}
});
// Get inspection detail
router.get('/:id', (req: AuthRequest, res: Response) => {
try {
const inspection = db.prepare(`
SELECT i.*, t.name as template_name, tech.name as technician_name
FROM inspections i
LEFT JOIN inspection_templates t ON i.template_id = t.id
LEFT JOIN technicians tech ON i.technician_id = tech.id
WHERE i.id = ?
`).get(req.params.id);
if (!inspection) return res.status(404).json({ error: 'Inspection not found' });
const items = db.prepare('SELECT * FROM inspection_items WHERE inspection_id = ? ORDER BY sort_order, id').all(req.params.id);
// Get photos for each item
const itemsWithPhotos = items.map((item: any) => {
const photos = db.prepare("SELECT * FROM files WHERE entity_type = 'inspection_item' AND entity_id = ?").all(item.id);
return { ...item, photos };
});
// Compute summary
const conditions = itemsWithPhotos.reduce((acc: any, item: any) => {
acc[item.condition] = (acc[item.condition] || 0) + 1;
return acc;
}, {});
res.json({ ...inspection, items: itemsWithPhotos, summary: conditions });
} catch (error: any) {
res.status(500).json({ error: error.message });
}
});
// Start inspection from template
router.post('/', (req: AuthRequest, res: Response) => {
try {
const { jobId, vehicleId, templateId, technicianId } = req.body;
if (!jobId && !vehicleId) return res.status(400).json({ error: 'Job ID or Vehicle ID required' });
const create = db.transaction(() => {
const result = db.prepare(
'INSERT INTO inspections (job_id, vehicle_id, template_id, technician_id) VALUES (?, ?, ?, ?)'
).run(jobId || null, vehicleId || null, templateId || null, technicianId || null);
const inspectionId = result.lastInsertRowid;
// Copy template items if template provided
if (templateId) {
const templateItems = db.prepare('SELECT * FROM inspection_template_items WHERE template_id = ? ORDER BY sort_order, id').all(templateId) as any[];
const insert = db.prepare(
'INSERT INTO inspection_items (inspection_id, template_item_id, section, label, measurement_type, sort_order) VALUES (?, ?, ?, ?, ?, ?)'
);
for (const item of templateItems) {
insert.run(inspectionId, item.id, item.section, item.label, item.measurement_type || null, item.sort_order);
}
}
return inspectionId;
});
const inspectionId = create();
const inspection = db.prepare('SELECT * FROM inspections WHERE id = ?').get(inspectionId);
const items = db.prepare('SELECT * FROM inspection_items WHERE inspection_id = ?').all(inspectionId);
res.status(201).json({ ...(inspection as any), items });
} catch (error: any) {
res.status(500).json({ error: error.message });
}
});
// Update inspection item (set condition/notes)
router.put('/:id/items/:itemId', (req: AuthRequest, res: Response) => {
try {
const { condition, notes, measurement, measurementUnit, isCritical } = req.body;
const item = db.prepare('SELECT * FROM inspection_items WHERE id = ? AND inspection_id = ?').get(req.params.itemId, req.params.id) as any;
if (!item) return res.status(404).json({ error: 'Item not found' });
db.prepare('UPDATE inspection_items SET condition = ?, notes = ?, measurement = ?, measurement_unit = ? WHERE id = ?')
.run(condition ?? item.condition, notes !== undefined ? notes : item.notes,
measurement !== undefined ? measurement : item.measurement,
measurementUnit !== undefined ? measurementUnit : item.measurement_unit, item.id);
const updated = db.prepare('SELECT * FROM inspection_items WHERE id = ?').get(item.id);
res.json(updated);
} catch (error: any) {
res.status(500).json({ error: error.message });
}
});
// Add custom inspection item
router.post('/:id/items', (req: AuthRequest, res: Response) => {
try {
const { section, label } = req.body;
if (!label) return res.status(400).json({ error: 'Label required' });
const maxOrder = db.prepare('SELECT MAX(sort_order) as max_order FROM inspection_items WHERE inspection_id = ?').get(req.params.id) as any;
const result = db.prepare(
'INSERT INTO inspection_items (inspection_id, section, label, sort_order) VALUES (?, ?, ?, ?)'
).run(req.params.id, section || 'Custom', label, (maxOrder?.max_order || 0) + 1);
const item = db.prepare('SELECT * FROM inspection_items WHERE id = ?').get(result.lastInsertRowid);
res.status(201).json(item);
} catch (error: any) {
res.status(500).json({ error: error.message });
}
});
// Complete inspection
router.post('/:id/complete', (req: AuthRequest, res: Response) => {
try {
db.prepare("UPDATE inspections SET status = 'completed', completed_at = datetime('now') WHERE id = ?").run(req.params.id);
const inspection = db.prepare('SELECT * FROM inspections WHERE id = ?').get(req.params.id);
res.json(inspection);
} catch (error: any) {
res.status(500).json({ error: error.message });
}
});
// Delete inspection
router.delete('/:id', (req: AuthRequest, res: Response) => {
try {
db.prepare('DELETE FROM inspections WHERE id = ?').run(req.params.id);
res.json({ success: true });
} catch (error: any) {
res.status(500).json({ error: error.message });
}
});
// --- Canned Notes ---
router.get('/canned-notes', (_req: AuthRequest, res: Response) => {
try {
const notes = db.prepare('SELECT * FROM canned_inspection_notes ORDER BY sort_order, label').all();
res.json(notes);
} catch (error: any) {
res.status(500).json({ error: error.message });
}
});
router.post('/canned-notes', (req: AuthRequest, res: Response) => {
try {
const { label, noteText, category } = req.body;
if (!label || !noteText) return res.status(400).json({ error: 'Label and note text required' });
const result = db.prepare('INSERT INTO canned_inspection_notes (label, note_text, category) VALUES (?, ?, ?)').run(label, noteText, category || null);
const note = db.prepare('SELECT * FROM canned_inspection_notes WHERE id = ?').get(result.lastInsertRowid);
res.status(201).json(note);
} catch (error: any) {
res.status(500).json({ error: error.message });
}
});
router.delete('/canned-notes/:id', (req: AuthRequest, res: Response) => {
try {
db.prepare('DELETE FROM canned_inspection_notes WHERE id = ?').run(req.params.id);
res.json({ success: true });
} catch (error: any) {
res.status(500).json({ error: error.message });
}
});
export default router;

View File

@@ -0,0 +1,299 @@
import { Router, Response } from 'express';
import db from '../database';
import { AuthRequest } from '../middleware/auth';
const router = Router();
// --- Inventory Items ---
router.get('/', (req: AuthRequest, res: Response) => {
try {
const { itemType, category, lowStock, search } = req.query;
let query = 'SELECT * FROM inventory_items';
const conditions: string[] = ['is_active = 1'];
const params: any[] = [];
if (itemType) { conditions.push('item_type = ?'); params.push(itemType); }
if (category) { conditions.push('category = ?'); params.push(category); }
if (lowStock === '1') { conditions.push('quantity_on_hand <= reorder_point'); }
if (search) {
conditions.push('(name LIKE ? OR part_number LIKE ? OR description LIKE ?)');
params.push(`%${search}%`, `%${search}%`, `%${search}%`);
}
query += ' WHERE ' + conditions.join(' AND ');
query += ' ORDER BY name';
const items = db.prepare(query).all(...params);
res.json(items);
} catch (error: any) {
res.status(500).json({ error: error.message });
}
});
router.get('/:id', (req: AuthRequest, res: Response) => {
try {
const item = db.prepare('SELECT * FROM inventory_items WHERE id = ?').get(req.params.id);
if (!item) return res.status(404).json({ error: 'Item not found' });
const transactions = db.prepare(`
SELECT t.*, j.job_number FROM inventory_transactions t
LEFT JOIN jobs j ON t.job_id = j.id
WHERE t.item_id = ? ORDER BY t.created_at DESC LIMIT 50
`).all(req.params.id);
res.json({ ...(item as any), transactions });
} catch (error: any) {
res.status(500).json({ error: error.message });
}
});
router.post('/', (req: AuthRequest, res: Response) => {
try {
const { name, partNumber, description, category, itemType, quantityOnHand, reorderPoint, unitCost, supplier, location } = req.body;
if (!name) return res.status(400).json({ error: 'Name required' });
const result = db.prepare(`
INSERT INTO inventory_items (name, part_number, description, category, item_type, quantity_on_hand, reorder_point, unit_cost, supplier, location)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(name, partNumber || null, description || null, category || null, itemType || 'parts',
quantityOnHand || 0, reorderPoint || 0, unitCost || 0, supplier || null, location || null);
const item = db.prepare('SELECT * FROM inventory_items WHERE id = ?').get(result.lastInsertRowid);
res.status(201).json(item);
} catch (error: any) {
res.status(500).json({ error: error.message });
}
});
router.put('/:id', (req: AuthRequest, res: Response) => {
try {
const item = db.prepare('SELECT * FROM inventory_items WHERE id = ?').get(req.params.id) as any;
if (!item) return res.status(404).json({ error: 'Item not found' });
const { name, partNumber, description, category, itemType, quantityOnHand, reorderPoint, unitCost, supplier, location, isActive } = req.body;
db.prepare(`
UPDATE inventory_items SET name = ?, part_number = ?, description = ?, category = ?, item_type = ?,
quantity_on_hand = ?, reorder_point = ?, unit_cost = ?, supplier = ?, location = ?, is_active = ?,
updated_at = datetime('now') WHERE id = ?
`).run(name ?? item.name, partNumber ?? item.part_number, description ?? item.description,
category ?? item.category, itemType ?? item.item_type, quantityOnHand ?? item.quantity_on_hand,
reorderPoint ?? item.reorder_point, unitCost ?? item.unit_cost, supplier ?? item.supplier,
location ?? item.location, isActive ?? item.is_active, item.id);
const updated = db.prepare('SELECT * FROM inventory_items WHERE id = ?').get(item.id);
res.json(updated);
} catch (error: any) {
res.status(500).json({ error: error.message });
}
});
router.delete('/:id', (req: AuthRequest, res: Response) => {
try {
db.prepare("UPDATE inventory_items SET is_active = 0, updated_at = datetime('now') WHERE id = ?").run(req.params.id);
res.json({ success: true });
} catch (error: any) {
res.status(500).json({ error: error.message });
}
});
// --- Inventory Transactions ---
router.post('/:id/transactions', (req: AuthRequest, res: Response) => {
try {
const { transactionType, quantity, jobId, reference, notes } = req.body;
if (!transactionType || quantity === undefined) return res.status(400).json({ error: 'Type and quantity required' });
const txn = db.transaction(() => {
const result = db.prepare(`
INSERT INTO inventory_transactions (item_id, transaction_type, quantity, job_id, reference, notes)
VALUES (?, ?, ?, ?, ?, ?)
`).run(req.params.id, transactionType, quantity, jobId || null, reference || null, notes || null);
// Update quantity on hand
const multiplier = ['received', 'returned', 'adjusted_up'].includes(transactionType) ? 1 : -1;
db.prepare(`
UPDATE inventory_items SET quantity_on_hand = quantity_on_hand + ?, updated_at = datetime('now') WHERE id = ?
`).run(quantity * multiplier, req.params.id);
return result.lastInsertRowid;
});
const txnId = txn();
const transaction = db.prepare('SELECT * FROM inventory_transactions WHERE id = ?').get(txnId);
res.status(201).json(transaction);
} catch (error: any) {
res.status(500).json({ error: error.message });
}
});
// --- Purchase Orders ---
router.get('/purchase-orders', (_req: AuthRequest, res: Response) => {
try {
const pos = db.prepare('SELECT * FROM purchase_orders ORDER BY created_at DESC').all();
res.json(pos);
} catch (error: any) {
res.status(500).json({ error: error.message });
}
});
router.get('/purchase-orders/:id', (req: AuthRequest, res: Response) => {
try {
const po = db.prepare('SELECT * FROM purchase_orders WHERE id = ?').get(req.params.id);
if (!po) return res.status(404).json({ error: 'PO not found' });
const items = db.prepare(`
SELECT poi.*, ii.name as item_name, ii.part_number
FROM purchase_order_items poi
LEFT JOIN inventory_items ii ON poi.inventory_item_id = ii.id
WHERE poi.po_id = ?
`).all(req.params.id);
res.json({ ...(po as any), items });
} catch (error: any) {
res.status(500).json({ error: error.message });
}
});
router.post('/purchase-orders', (req: AuthRequest, res: Response) => {
try {
const { supplier, notes, items } = req.body;
if (!supplier) return res.status(400).json({ error: 'Supplier required' });
// Generate PO number
const year = new Date().getFullYear();
const last = db.prepare("SELECT po_number FROM purchase_orders WHERE po_number LIKE ? ORDER BY id DESC LIMIT 1")
.get(`PO-${year}-%`) as { po_number: string } | undefined;
let seq = 1;
if (last) { seq = parseInt(last.po_number.split('-')[2], 10) + 1; }
const poNumber = `PO-${year}-${String(seq).padStart(4, '0')}`;
const create = db.transaction(() => {
let total = 0;
const result = db.prepare(
'INSERT INTO purchase_orders (po_number, supplier, notes) VALUES (?, ?, ?)'
).run(poNumber, supplier, notes || null);
const poId = result.lastInsertRowid;
if (items?.length) {
const insert = db.prepare(
'INSERT INTO purchase_order_items (po_id, inventory_item_id, description, quantity, unit_cost) VALUES (?, ?, ?, ?, ?)'
);
for (const item of items) {
insert.run(poId, item.inventoryItemId || null, item.description, item.quantity || 1, item.unitCost || 0);
total += (item.quantity || 1) * (item.unitCost || 0);
}
}
db.prepare('UPDATE purchase_orders SET total = ? WHERE id = ?').run(total, poId);
return poId;
});
const poId = create();
const po = db.prepare('SELECT * FROM purchase_orders WHERE id = ?').get(poId);
res.status(201).json(po);
} catch (error: any) {
res.status(500).json({ error: error.message });
}
});
router.put('/purchase-orders/:id', (req: AuthRequest, res: Response) => {
try {
const po = db.prepare('SELECT * FROM purchase_orders WHERE id = ?').get(req.params.id) as any;
if (!po) return res.status(404).json({ error: 'PO not found' });
const { status, orderedDate, receivedDate, notes } = req.body;
db.prepare(`
UPDATE purchase_orders SET status = ?, ordered_date = ?, received_date = ?, notes = ?, updated_at = datetime('now') WHERE id = ?
`).run(status ?? po.status, orderedDate ?? po.ordered_date, receivedDate ?? po.received_date, notes ?? po.notes, po.id);
// If received, add inventory
if (status === 'received' && po.status !== 'received') {
const items = db.prepare('SELECT * FROM purchase_order_items WHERE po_id = ?').all(po.id) as any[];
for (const item of items) {
if (item.inventory_item_id) {
db.prepare(`
INSERT INTO inventory_transactions (item_id, transaction_type, quantity, reference) VALUES (?, 'received', ?, ?)
`).run(item.inventory_item_id, item.quantity, `PO: ${po.po_number}`);
db.prepare("UPDATE inventory_items SET quantity_on_hand = quantity_on_hand + ?, updated_at = datetime('now') WHERE id = ?")
.run(item.quantity, item.inventory_item_id);
}
}
}
const updated = db.prepare('SELECT * FROM purchase_orders WHERE id = ?').get(po.id);
res.json(updated);
} catch (error: any) {
res.status(500).json({ error: error.message });
}
});
// --- Tools ---
router.get('/tools', (req: AuthRequest, res: Response) => {
try {
const { category, assignedTo } = req.query;
let query = `SELECT t.*, tech.name as assigned_to_name FROM tools t LEFT JOIN technicians tech ON t.assigned_to = tech.id`;
const conditions: string[] = [];
const params: any[] = [];
if (category) { conditions.push('t.category = ?'); params.push(category); }
if (assignedTo) { conditions.push('t.assigned_to = ?'); params.push(Number(assignedTo)); }
if (conditions.length > 0) query += ' WHERE ' + conditions.join(' AND ');
query += ' ORDER BY t.name';
const tools = db.prepare(query).all(...params);
res.json(tools);
} catch (error: any) {
res.status(500).json({ error: error.message });
}
});
router.post('/tools', (req: AuthRequest, res: Response) => {
try {
const { name, description, serialNumber, category, purchaseDate, purchasePrice, condition, assignedTo, location, notes } = req.body;
if (!name) return res.status(400).json({ error: 'Name required' });
const result = db.prepare(`
INSERT INTO tools (name, description, serial_number, category, purchase_date, purchase_price, condition, assigned_to, location, notes)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(name, description || null, serialNumber || null, category || null, purchaseDate || null,
purchasePrice || null, condition || 'good', assignedTo || null, location || null, notes || null);
const tool = db.prepare('SELECT * FROM tools WHERE id = ?').get(result.lastInsertRowid);
res.status(201).json(tool);
} catch (error: any) {
res.status(500).json({ error: error.message });
}
});
router.put('/tools/:id', (req: AuthRequest, res: Response) => {
try {
const tool = db.prepare('SELECT * FROM tools WHERE id = ?').get(req.params.id) as any;
if (!tool) return res.status(404).json({ error: 'Tool not found' });
const { name, description, serialNumber, category, purchaseDate, purchasePrice, condition, assignedTo, location, notes } = req.body;
db.prepare(`
UPDATE tools SET name = ?, description = ?, serial_number = ?, category = ?, purchase_date = ?,
purchase_price = ?, condition = ?, assigned_to = ?, location = ?, notes = ?, updated_at = datetime('now') WHERE id = ?
`).run(name ?? tool.name, description ?? tool.description, serialNumber ?? tool.serial_number,
category ?? tool.category, purchaseDate ?? tool.purchase_date, purchasePrice ?? tool.purchase_price,
condition ?? tool.condition, assignedTo !== undefined ? (assignedTo || null) : tool.assigned_to,
location ?? tool.location, notes ?? tool.notes, tool.id);
const updated = db.prepare('SELECT * FROM tools WHERE id = ?').get(tool.id);
res.json(updated);
} catch (error: any) {
res.status(500).json({ error: error.message });
}
});
router.delete('/tools/:id', (req: AuthRequest, res: Response) => {
try {
db.prepare('DELETE FROM tools WHERE id = ?').run(req.params.id);
res.json({ success: true });
} catch (error: any) {
res.status(500).json({ error: error.message });
}
});
export default router;

View File

@@ -0,0 +1,305 @@
import { Router, Response } from 'express';
import db from '../database';
import { AuthRequest } from '../middleware/auth';
import { Invoice, Settings } from '../types';
import { generateInvoicePdf } from '../services/pdf';
const router = Router();
function generateInvoiceNumber(): string {
const year = new Date().getFullYear();
const last = db.prepare(
"SELECT invoice_number FROM invoices WHERE invoice_number LIKE ? ORDER BY id DESC LIMIT 1"
).get(`INV-${year}-%`) as { invoice_number: string } | undefined;
let seq = 1;
if (last) {
const parts = last.invoice_number.split('-');
seq = parseInt(parts[2], 10) + 1;
}
return `INV-${year}-${String(seq).padStart(4, '0')}`;
}
router.get('/', (req: AuthRequest, res: Response) => {
try {
const { status, shopId, ownerId } = req.query;
let query = `
SELECT i.*, s.name as shop_name, o.first_name as owner_first_name, o.last_name as owner_last_name
FROM invoices i
LEFT JOIN shops s ON i.shop_id = s.id
LEFT JOIN owners o ON i.owner_id = o.id
`;
const conditions: string[] = [];
const params: any[] = [];
if (status) { conditions.push('i.status = ?'); params.push(status); }
if (shopId) { conditions.push('i.shop_id = ?'); params.push(Number(shopId)); }
if (ownerId) { conditions.push('i.owner_id = ?'); params.push(Number(ownerId)); }
if (conditions.length > 0) query += ' WHERE ' + conditions.join(' AND ');
query += ' ORDER BY i.invoice_date DESC';
const invoices = db.prepare(query).all(...params);
// Add payment totals
const result = invoices.map((inv: any) => {
const paid = db.prepare('SELECT COALESCE(SUM(amount), 0) as total FROM payments WHERE invoice_id = ?').get(inv.id) as { total: number };
return {
...inv,
display_name: inv.shop_name || (inv.owner_first_name ? `${inv.owner_first_name} ${inv.owner_last_name}` : 'Unknown'),
amountPaid: paid.total,
amountDue: inv.total - paid.total,
};
});
res.json(result);
} catch (error: any) {
res.status(500).json({ error: error.message });
}
});
router.get('/:id', (req: AuthRequest, res: Response) => {
try {
const invoice = db.prepare(`
SELECT i.*,
s.name as shop_name, s.contact_name as shop_contact, s.address as shop_address,
s.city as shop_city, s.state as shop_state, s.zip as shop_zip, s.phone as shop_phone, s.email as shop_email,
o.first_name as owner_first_name, o.last_name as owner_last_name, o.phone as owner_phone, o.email as owner_email
FROM invoices i
LEFT JOIN shops s ON i.shop_id = s.id
LEFT JOIN owners o ON i.owner_id = o.id
WHERE i.id = ?
`).get(req.params.id);
if (!invoice) return res.status(404).json({ error: 'Invoice not found' });
const jobs = db.prepare(`
SELECT j.*, v.year, v.make, v.model, o.first_name, o.last_name
FROM invoice_jobs ij
JOIN jobs j ON ij.job_id = j.id
JOIN vehicles v ON j.vehicle_id = v.id
LEFT JOIN owners o ON j.owner_id = o.id
WHERE ij.invoice_id = ?
`).all(req.params.id);
// Get line items for each job
const jobsWithItems = jobs.map((job: any) => {
const laborItems = db.prepare('SELECT * FROM labor_items WHERE job_id = ? ORDER BY sort_order, id').all(job.id);
const partsItems = db.prepare('SELECT * FROM parts_items WHERE job_id = ? ORDER BY sort_order, id').all(job.id);
return { ...job, laborItems, partsItems };
});
const payments = db.prepare('SELECT * FROM payments WHERE invoice_id = ? ORDER BY payment_date DESC').all(req.params.id);
const totalPaid = payments.reduce((sum: number, p: any) => sum + p.amount, 0);
const versions = db.prepare('SELECT * FROM invoice_versions WHERE invoice_id = ? ORDER BY version_number DESC').all(req.params.id);
res.json({ ...invoice, jobs: jobsWithItems, payments, versions, amountPaid: totalPaid, amountDue: (invoice as any).total - totalPaid });
} catch (error: any) {
res.status(500).json({ error: error.message });
}
});
router.post('/', (req: AuthRequest, res: Response) => {
try {
const { shopId, ownerId, jobIds, notes } = req.body;
if (!jobIds || !jobIds.length) {
return res.status(400).json({ error: 'At least one job required' });
}
if (!shopId && !ownerId) {
return res.status(400).json({ error: 'Shop or owner required' });
}
const settings = db.prepare('SELECT * FROM settings WHERE id = 1').get() as Settings;
// Calculate totals from jobs
let subtotal = 0;
for (const jobId of jobIds) {
const labor = db.prepare("SELECT COALESCE(SUM(CASE WHEN billing_type = 'flat_rate' THEN flat_rate ELSE hours * rate END), 0) as total FROM labor_items WHERE job_id = ?").get(jobId) as { total: number };
const parts = db.prepare('SELECT COALESCE(SUM(CASE WHEN charge_price IS NOT NULL THEN charge_price ELSE quantity * unit_cost * (1 + markup) END), 0) as total FROM parts_items WHERE job_id = ?').get(jobId) as { total: number };
const misc = db.prepare('SELECT COALESCE(SUM(charge_price), 0) as total FROM misc_items WHERE job_id = ?').get(jobId) as { total: number };
subtotal += labor.total + parts.total + misc.total;
}
const taxRate = settings.default_tax_rate || 0;
const taxAmount = subtotal * taxRate;
const total = subtotal + taxAmount;
const invoiceNumber = generateInvoiceNumber();
const createInvoice = db.transaction(() => {
const result = db.prepare(`
INSERT INTO invoices (invoice_number, shop_id, owner_id, status, subtotal, tax_rate, tax_amount, total, notes)
VALUES (?, ?, ?, 'draft', ?, ?, ?, ?, ?)
`).run(invoiceNumber, shopId || null, ownerId || null, subtotal, taxRate, taxAmount, total, notes || null);
const invoiceId = result.lastInsertRowid;
const insertJob = db.prepare('INSERT INTO invoice_jobs (invoice_id, job_id) VALUES (?, ?)');
const updateJob = db.prepare("UPDATE jobs SET status = 'invoiced', updated_at = datetime('now') WHERE id = ?");
for (const jobId of jobIds) {
insertJob.run(invoiceId, jobId);
updateJob.run(jobId);
}
return invoiceId;
});
const invoiceId = createInvoice();
const invoice = db.prepare('SELECT * FROM invoices WHERE id = ?').get(invoiceId);
res.status(201).json(invoice);
} catch (error: any) {
res.status(500).json({ error: error.message });
}
});
router.put('/:id', (req: AuthRequest, res: Response) => {
try {
const invoice = db.prepare('SELECT * FROM invoices WHERE id = ?').get(req.params.id) as Invoice | undefined;
if (!invoice) return res.status(404).json({ error: 'Invoice not found' });
const { status, dueDate, taxRate, notes } = req.body;
// Recalculate if tax rate changed
let newTaxRate = taxRate ?? invoice.tax_rate;
let newTaxAmount = invoice.subtotal * newTaxRate;
let newTotal = invoice.subtotal + newTaxAmount;
db.prepare(`
UPDATE invoices SET status = ?, due_date = ?, tax_rate = ?, tax_amount = ?, total = ?, notes = ?, updated_at = datetime('now')
WHERE id = ?
`).run(status ?? invoice.status, dueDate ?? invoice.due_date, newTaxRate, newTaxAmount, newTotal, notes ?? invoice.notes, invoice.id);
const updated = db.prepare('SELECT * FROM invoices WHERE id = ?').get(invoice.id);
res.json(updated);
} catch (error: any) {
res.status(500).json({ error: error.message });
}
});
router.delete('/:id', (req: AuthRequest, res: Response) => {
try {
const invoice = db.prepare('SELECT * FROM invoices WHERE id = ?').get(req.params.id) as Invoice | undefined;
if (!invoice) return res.status(404).json({ error: 'Invoice not found' });
// Revert job statuses back to completed
const jobIds = db.prepare('SELECT job_id FROM invoice_jobs WHERE invoice_id = ?').all(invoice.id) as { job_id: number }[];
const revertJob = db.prepare("UPDATE jobs SET status = 'completed', updated_at = datetime('now') WHERE id = ?");
for (const { job_id } of jobIds) {
revertJob.run(job_id);
}
db.prepare('DELETE FROM invoices WHERE id = ?').run(invoice.id);
res.json({ success: true });
} catch (error: any) {
res.status(500).json({ error: error.message });
}
});
// PDF generation
router.get('/:id/pdf', (req: AuthRequest, res: Response) => {
try {
const invoice = db.prepare(`
SELECT i.*,
s.name as shop_name, s.contact_name as shop_contact, s.address as shop_address,
s.city as shop_city, s.state as shop_state, s.zip as shop_zip, s.phone as shop_phone,
o.first_name as owner_first_name, o.last_name as owner_last_name, o.phone as owner_phone, o.email as owner_email
FROM invoices i
LEFT JOIN shops s ON i.shop_id = s.id
LEFT JOIN owners o ON i.owner_id = o.id
WHERE i.id = ?
`).get(req.params.id) as any;
if (!invoice) return res.status(404).json({ error: 'Invoice not found' });
const settings = db.prepare('SELECT * FROM settings WHERE id = 1').get() as Settings;
const jobs = db.prepare(`
SELECT j.*, v.year, v.make, v.model, v.vin, o.first_name, o.last_name
FROM invoice_jobs ij
JOIN jobs j ON ij.job_id = j.id
JOIN vehicles v ON j.vehicle_id = v.id
LEFT JOIN owners o ON j.owner_id = o.id
WHERE ij.invoice_id = ?
`).all(invoice.id);
const jobsWithItems = jobs.map((job: any) => ({
...job,
laborItems: db.prepare('SELECT * FROM labor_items WHERE job_id = ? ORDER BY sort_order, id').all(job.id),
partsItems: db.prepare('SELECT * FROM parts_items WHERE job_id = ? ORDER BY sort_order, id').all(job.id),
miscItems: db.prepare('SELECT * FROM misc_items WHERE job_id = ? ORDER BY sort_order, id').all(job.id),
}));
const payments = db.prepare('SELECT * FROM payments WHERE invoice_id = ? ORDER BY payment_date').all(invoice.id);
res.setHeader('Content-Type', 'application/pdf');
res.setHeader('Content-Disposition', `inline; filename="${invoice.invoice_number}.pdf"`);
// Gather open recommendations across all jobs on this invoice
const recommendations: any[] = [];
const customerNotes: any[] = [];
for (const job of jobsWithItems) {
const recs = db.prepare("SELECT * FROM recommendations WHERE job_id = ? AND status IN ('open', 'info_only')").all(job.id);
recs.forEach((r: any) => recommendations.push({ ...r, job }));
const notes = db.prepare("SELECT * FROM job_notes WHERE job_id = ? AND visibility = 'customer' ORDER BY created_at").all(job.id);
notes.forEach((n: any) => customerNotes.push(n));
}
generateInvoicePdf({ invoice, settings, jobs: jobsWithItems, payments, recommendations, customerNotes }, res);
} catch (error: any) {
res.status(500).json({ error: error.message });
}
});
// Payments
router.post('/:id/payments', (req: AuthRequest, res: Response) => {
try {
const invoice = db.prepare('SELECT * FROM invoices WHERE id = ?').get(req.params.id) as Invoice | undefined;
if (!invoice) return res.status(404).json({ error: 'Invoice not found' });
const { amount, paymentDate, method, reference, notes } = req.body;
if (!amount || !paymentDate) return res.status(400).json({ error: 'Amount and payment date required' });
const result = db.prepare(
'INSERT INTO payments (invoice_id, amount, payment_date, method, reference, notes) VALUES (?, ?, ?, ?, ?, ?)'
).run(invoice.id, amount, paymentDate, method || null, reference || null, notes || null);
// Auto-update invoice status
const totalPaid = db.prepare('SELECT COALESCE(SUM(amount), 0) as total FROM payments WHERE invoice_id = ?').get(invoice.id) as { total: number };
let newStatus = invoice.status;
if (totalPaid.total >= invoice.total) {
newStatus = 'paid';
} else if (totalPaid.total > 0) {
newStatus = 'partial';
}
if (newStatus !== invoice.status) {
db.prepare("UPDATE invoices SET status = ?, updated_at = datetime('now') WHERE id = ?").run(newStatus, invoice.id);
}
const payment = db.prepare('SELECT * FROM payments WHERE id = ?').get(result.lastInsertRowid);
res.status(201).json(payment);
} catch (error: any) {
res.status(500).json({ error: error.message });
}
});
router.delete('/:id/payments/:pid', (req: AuthRequest, res: Response) => {
try {
db.prepare('DELETE FROM payments WHERE id = ? AND invoice_id = ?').run(req.params.pid, req.params.id);
// Recalculate invoice status
const invoice = db.prepare('SELECT * FROM invoices WHERE id = ?').get(req.params.id) as Invoice;
const totalPaid = db.prepare('SELECT COALESCE(SUM(amount), 0) as total FROM payments WHERE invoice_id = ?').get(invoice.id) as { total: number };
let newStatus: string;
if (totalPaid.total >= invoice.total) {
newStatus = 'paid';
} else if (totalPaid.total > 0) {
newStatus = 'partial';
} else {
newStatus = 'sent';
}
db.prepare("UPDATE invoices SET status = ?, updated_at = datetime('now') WHERE id = ?").run(newStatus, invoice.id);
res.json({ success: true });
} catch (error: any) {
res.status(500).json({ error: error.message });
}
});
export default router;

556
server/src/routes/jobs.ts Normal file
View File

@@ -0,0 +1,556 @@
import { Router, Response } from 'express';
import db from '../database';
import { AuthRequest } from '../middleware/auth';
import { Job, Shop } from '../types';
const router = Router();
function generateJobNumber(): string {
const year = new Date().getFullYear();
const last = db.prepare(
"SELECT job_number FROM jobs WHERE job_number LIKE ? ORDER BY id DESC LIMIT 1"
).get(`TM-${year}-%`) as { job_number: string } | undefined;
let seq = 1;
if (last) {
const parts = last.job_number.split('-');
seq = parseInt(parts[2], 10) + 1;
}
return `TM-${year}-${String(seq).padStart(4, '0')}`;
}
router.get('/', (req: AuthRequest, res: Response) => {
try {
const { status, shopId, search } = req.query;
let query = `
SELECT j.*, s.name as shop_name, v.year, v.make, v.model, o.first_name, o.last_name
FROM jobs j
LEFT JOIN shops s ON j.shop_id = s.id
JOIN vehicles v ON j.vehicle_id = v.id
LEFT JOIN owners o ON j.owner_id = o.id
`;
const conditions: string[] = [];
const params: any[] = [];
if (status) {
conditions.push('j.status = ?');
params.push(status);
}
if (shopId) {
conditions.push('j.shop_id = ?');
params.push(Number(shopId));
}
if (search) {
conditions.push('(j.job_number LIKE ? OR j.description LIKE ? OR COALESCE(s.name, \'\') LIKE ? OR v.make LIKE ? OR v.model LIKE ?)');
params.push(`%${search}%`, `%${search}%`, `%${search}%`, `%${search}%`, `%${search}%`);
}
if (conditions.length > 0) {
query += ' WHERE ' + conditions.join(' AND ');
}
query += ' ORDER BY j.created_at DESC';
const jobs = db.prepare(query).all(...params);
// Attach tags to each job
const getTagsStmt = db.prepare('SELECT t.* FROM tags t JOIN job_tags jt ON t.id = jt.tag_id WHERE jt.job_id = ?');
const jobsWithTags = jobs.map((job: any) => ({
...job,
tags: getTagsStmt.all(job.id),
}));
res.json(jobsWithTags);
} catch (error: any) {
res.status(500).json({ error: error.message });
}
});
router.get('/:id', (req: AuthRequest, res: Response) => {
try {
const job = db.prepare(`
SELECT j.*, s.name as shop_name, s.default_labor_rate, s.default_parts_markup,
v.year, v.make, v.model, v.vin, o.first_name, o.last_name
FROM jobs j
LEFT JOIN shops s ON j.shop_id = s.id
JOIN vehicles v ON j.vehicle_id = v.id
LEFT JOIN owners o ON j.owner_id = o.id
WHERE j.id = ?
`).get(req.params.id);
if (!job) return res.status(404).json({ error: 'Job not found' });
const laborItems = db.prepare('SELECT * FROM labor_items WHERE job_id = ? ORDER BY sort_order, id').all(req.params.id);
const partsItems = db.prepare('SELECT * FROM parts_items WHERE job_id = ? ORDER BY sort_order, id').all(req.params.id);
const miscItems = db.prepare('SELECT * FROM misc_items WHERE job_id = ? ORDER BY sort_order, id').all(req.params.id);
const notes = db.prepare('SELECT * FROM job_notes WHERE job_id = ? ORDER BY created_at DESC').all(req.params.id);
const laborTotal = laborItems.reduce((sum: number, item: any) => {
if (item.billing_type === 'flat_rate') return sum + (item.flat_rate || 0);
return sum + (item.hours || 0) * (item.rate || 0);
}, 0);
const partsTotal = partsItems.reduce((sum: number, item: any) => {
if (item.charge_price !== null && item.charge_price !== undefined) return sum + item.charge_price;
return sum + item.quantity * item.unit_cost * (1 + item.markup);
}, 0);
const miscTotal = miscItems.reduce((sum: number, item: any) => sum + (item.charge_price || 0), 0);
const miscCost = miscItems.reduce((sum: number, item: any) => sum + (item.your_cost || 0), 0);
const tags = db.prepare('SELECT t.* FROM tags t JOIN job_tags jt ON t.id = jt.tag_id WHERE jt.job_id = ?').all(req.params.id);
const recommendations = db.prepare(`
SELECT r.*, e.total as estimate_total, e.status as estimate_status, e.estimate_number
FROM recommendations r
LEFT JOIN estimates e ON r.estimate_id = e.id
WHERE r.job_id = ?
ORDER BY r.created_at DESC
`).all(req.params.id);
const invoiceLink = db.prepare('SELECT invoice_id FROM invoice_jobs WHERE job_id = ?').get(req.params.id) as { invoice_id: number } | undefined;
res.json({ ...(job as any), laborItems, partsItems, miscItems, notes, tags, recommendations, invoiceId: invoiceLink?.invoice_id || null, laborTotal, partsTotal, miscTotal, miscCost, total: laborTotal + partsTotal + miscTotal });
} catch (error: any) {
res.status(500).json({ error: error.message });
}
});
router.post('/', (req: AuthRequest, res: Response) => {
try {
const { customerType, shopId, ownerId, vehicleId, description } = req.body;
const type = customerType || (shopId ? 'shop' : 'owner');
if (!vehicleId) {
return res.status(400).json({ error: 'Vehicle is required' });
}
if (type !== 'internal' && !ownerId) {
return res.status(400).json({ error: 'Owner is required for non-internal jobs' });
}
if (type === 'shop' && !shopId) {
return res.status(400).json({ error: 'Referring shop is required when customer type is shop' });
}
const jobNumber = generateJobNumber();
const result = db.prepare(`
INSERT INTO jobs (job_number, customer_type, shop_id, owner_id, vehicle_id, description)
VALUES (?, ?, ?, ?, ?, ?)
`).run(jobNumber, type, shopId || null, ownerId || null, vehicleId, description || null);
const job = db.prepare('SELECT * FROM jobs WHERE id = ?').get(result.lastInsertRowid);
res.status(201).json(job);
} catch (error: any) {
res.status(500).json({ error: error.message });
}
});
router.put('/:id', (req: AuthRequest, res: Response) => {
try {
const job = db.prepare('SELECT * FROM jobs WHERE id = ?').get(req.params.id) as Job | undefined;
if (!job) return res.status(404).json({ error: 'Job not found' });
const { status, description, notes, mileageIn, mileageOut } = req.body;
// Auto-set date fields on status changes
let startedDate = job.started_date;
let completedDate = job.completed_date;
if (status === 'in_progress' && !job.started_date) {
startedDate = new Date().toISOString();
}
if (status === 'completed' && !job.completed_date) {
completedDate = new Date().toISOString();
}
db.prepare(`
UPDATE jobs SET status = ?, description = ?, notes = ?, started_date = ?, completed_date = ?,
mileage_in = ?, mileage_out = ?, updated_at = datetime('now')
WHERE id = ?
`).run(status ?? job.status, description ?? job.description, notes ?? job.notes, startedDate, completedDate,
mileageIn !== undefined ? mileageIn : (job as any).mileage_in,
mileageOut !== undefined ? mileageOut : (job as any).mileage_out, job.id);
const updated = db.prepare('SELECT * FROM jobs WHERE id = ?').get(job.id);
res.json(updated);
} catch (error: any) {
res.status(500).json({ error: error.message });
}
});
router.delete('/:id', (req: AuthRequest, res: Response) => {
try {
db.prepare('DELETE FROM jobs WHERE id = ?').run(req.params.id);
res.json({ success: true });
} catch (error: any) {
res.status(500).json({ error: error.message });
}
});
// Labor items
router.post('/:id/labor', (req: AuthRequest, res: Response) => {
try {
const job = db.prepare('SELECT * FROM jobs WHERE id = ?').get(req.params.id) as Job | undefined;
if (!job) return res.status(404).json({ error: 'Job not found' });
const { description, billingType, hours, rate, flatRate } = req.body;
const type = billingType || 'hourly';
if (!description) return res.status(400).json({ error: 'Description required' });
if (type === 'hourly' && (hours === undefined || rate === undefined)) {
return res.status(400).json({ error: 'Hours and rate required for hourly billing' });
}
if (type === 'flat_rate' && flatRate === undefined) {
return res.status(400).json({ error: 'Flat rate amount required' });
}
const maxOrder = db.prepare('SELECT MAX(sort_order) as max FROM labor_items WHERE job_id = ?').get(job.id) as { max: number | null };
const result = db.prepare(
'INSERT INTO labor_items (job_id, description, billing_type, hours, rate, flat_rate, sort_order) VALUES (?, ?, ?, ?, ?, ?, ?)'
).run(job.id, description, type, hours ?? null, rate ?? null, flatRate ?? null, (maxOrder.max ?? -1) + 1);
const item = db.prepare('SELECT * FROM labor_items WHERE id = ?').get(result.lastInsertRowid);
res.status(201).json(item);
} catch (error: any) {
res.status(500).json({ error: error.message });
}
});
router.put('/:id/labor/:lid', (req: AuthRequest, res: Response) => {
try {
const { description, billingType, hours, rate, flatRate, hidden } = req.body;
db.prepare('UPDATE labor_items SET description = ?, billing_type = ?, hours = ?, rate = ?, flat_rate = ?, hidden = ? WHERE id = ? AND job_id = ?')
.run(description, billingType || 'hourly', hours ?? null, rate ?? null, flatRate ?? null, hidden ?? 0, req.params.lid, req.params.id);
const item = db.prepare('SELECT * FROM labor_items WHERE id = ?').get(req.params.lid);
res.json(item);
} catch (error: any) {
res.status(500).json({ error: error.message });
}
});
router.delete('/:id/labor/:lid', (req: AuthRequest, res: Response) => {
try {
db.prepare('DELETE FROM labor_items WHERE id = ? AND job_id = ?').run(req.params.lid, req.params.id);
res.json({ success: true });
} catch (error: any) {
res.status(500).json({ error: error.message });
}
});
// Parts items
router.post('/:id/parts', (req: AuthRequest, res: Response) => {
try {
const job = db.prepare('SELECT * FROM jobs WHERE id = ?').get(req.params.id) as Job | undefined;
if (!job) return res.status(404).json({ error: 'Job not found' });
const { description, partNumber, quantity, unitCost, markup, chargePrice, inventoryItemId } = req.body;
if (!description || unitCost === undefined) {
return res.status(400).json({ error: 'Description and unit cost required' });
}
// Default markup from shop if not provided
let effectiveMarkup = markup;
if (effectiveMarkup === undefined) {
const shop = db.prepare('SELECT default_parts_markup FROM shops WHERE id = ?').get(job.shop_id) as Shop | undefined;
effectiveMarkup = shop?.default_parts_markup ?? 0.30;
}
const maxOrder = db.prepare('SELECT MAX(sort_order) as max FROM parts_items WHERE job_id = ?').get(job.id) as { max: number | null };
const addPart = db.transaction(() => {
const result = db.prepare(
'INSERT INTO parts_items (job_id, description, part_number, quantity, unit_cost, markup, charge_price, sort_order) VALUES (?, ?, ?, ?, ?, ?, ?, ?)'
).run(job.id, description, partNumber || null, quantity ?? 1, unitCost, effectiveMarkup, chargePrice !== undefined ? chargePrice : null, (maxOrder.max ?? -1) + 1);
// Deduct from inventory if sourced from stock
if (inventoryItemId) {
const qty = quantity ?? 1;
db.prepare('UPDATE inventory_items SET quantity_on_hand = quantity_on_hand - ?, updated_at = datetime(\'now\') WHERE id = ?').run(qty, inventoryItemId);
db.prepare('INSERT INTO inventory_transactions (item_id, transaction_type, quantity, job_id, notes) VALUES (?, ?, ?, ?, ?)').run(inventoryItemId, 'used', -qty, job.id, `Used on job ${job.job_number}`);
}
return result;
});
const result = addPart();
const item = db.prepare('SELECT * FROM parts_items WHERE id = ?').get(result.lastInsertRowid);
res.status(201).json(item);
} catch (error: any) {
res.status(500).json({ error: error.message });
}
});
router.put('/:id/parts/:pid', (req: AuthRequest, res: Response) => {
try {
const { description, partNumber, quantity, unitCost, markup, chargePrice, hidden } = req.body;
db.prepare(
'UPDATE parts_items SET description = ?, part_number = ?, quantity = ?, unit_cost = ?, markup = ?, charge_price = ?, hidden = ? WHERE id = ? AND job_id = ?'
).run(description, partNumber || null, quantity, unitCost, markup, chargePrice !== undefined ? chargePrice : null, hidden ?? 0, req.params.pid, req.params.id);
const item = db.prepare('SELECT * FROM parts_items WHERE id = ?').get(req.params.pid);
res.json(item);
} catch (error: any) {
res.status(500).json({ error: error.message });
}
});
router.delete('/:id/parts/:pid', (req: AuthRequest, res: Response) => {
try {
db.prepare('DELETE FROM parts_items WHERE id = ? AND job_id = ?').run(req.params.pid, req.params.id);
res.json({ success: true });
} catch (error: any) {
res.status(500).json({ error: error.message });
}
});
// Job notes
router.post('/:id/notes', (req: AuthRequest, res: Response) => {
try {
const { note, visibility } = req.body;
if (!note) return res.status(400).json({ error: 'Note text required' });
const result = db.prepare('INSERT INTO job_notes (job_id, note, visibility) VALUES (?, ?, ?)').run(req.params.id, note, visibility || 'internal');
const jobNote = db.prepare('SELECT * FROM job_notes WHERE id = ?').get(result.lastInsertRowid);
res.status(201).json(jobNote);
} catch (error: any) {
res.status(500).json({ error: error.message });
}
});
router.put('/:id/notes/:nid', (req: AuthRequest, res: Response) => {
try {
const { note, visibility } = req.body;
db.prepare('UPDATE job_notes SET note = ?, visibility = ? WHERE id = ? AND job_id = ?')
.run(note, visibility || 'internal', req.params.nid, req.params.id);
const updated = db.prepare('SELECT * FROM job_notes WHERE id = ?').get(req.params.nid);
res.json(updated);
} catch (error: any) {
res.status(500).json({ error: error.message });
}
});
router.delete('/:id/notes/:nid', (req: AuthRequest, res: Response) => {
try {
db.prepare('DELETE FROM job_notes WHERE id = ? AND job_id = ?').run(req.params.nid, req.params.id);
res.json({ success: true });
} catch (error: any) {
res.status(500).json({ error: error.message });
}
});
// Misc/Sublet items
router.post('/:id/misc', (req: AuthRequest, res: Response) => {
try {
const job = db.prepare('SELECT * FROM jobs WHERE id = ?').get(req.params.id) as Job | undefined;
if (!job) return res.status(404).json({ error: 'Job not found' });
const { description, category, yourCost, chargePrice } = req.body;
if (!description) return res.status(400).json({ error: 'Description required' });
const maxOrder = db.prepare('SELECT MAX(sort_order) as max FROM misc_items WHERE job_id = ?').get(job.id) as { max: number | null };
const result = db.prepare(
'INSERT INTO misc_items (job_id, description, category, your_cost, charge_price, sort_order) VALUES (?, ?, ?, ?, ?, ?)'
).run(job.id, description, category || 'sublet', yourCost || 0, chargePrice || 0, (maxOrder.max ?? -1) + 1);
const item = db.prepare('SELECT * FROM misc_items WHERE id = ?').get(result.lastInsertRowid);
res.status(201).json(item);
} catch (error: any) {
res.status(500).json({ error: error.message });
}
});
router.put('/:id/misc/:mid', (req: AuthRequest, res: Response) => {
try {
const { description, category, yourCost, chargePrice, hidden } = req.body;
db.prepare(
'UPDATE misc_items SET description = ?, category = ?, your_cost = ?, charge_price = ?, hidden = ? WHERE id = ? AND job_id = ?'
).run(description, category || 'sublet', yourCost || 0, chargePrice || 0, hidden ?? 0, req.params.mid, req.params.id);
const item = db.prepare('SELECT * FROM misc_items WHERE id = ?').get(req.params.mid);
res.json(item);
} catch (error: any) {
res.status(500).json({ error: error.message });
}
});
router.delete('/:id/misc/:mid', (req: AuthRequest, res: Response) => {
try {
db.prepare('DELETE FROM misc_items WHERE id = ? AND job_id = ?').run(req.params.mid, req.params.id);
res.json({ success: true });
} catch (error: any) {
res.status(500).json({ error: error.message });
}
});
// Recommendations
router.post('/:id/recommendations', (req: AuthRequest, res: Response) => {
try {
const { description, urgency, estimatedCost, inspectionItemId, notes } = req.body;
if (!description) return res.status(400).json({ error: 'Description required' });
const result = db.prepare(
'INSERT INTO recommendations (job_id, inspection_item_id, description, urgency, estimated_cost, notes) VALUES (?, ?, ?, ?, ?, ?)'
).run(req.params.id, inspectionItemId || null, description, urgency || 'soon', estimatedCost || null, notes || null);
const rec = db.prepare('SELECT * FROM recommendations WHERE id = ?').get(result.lastInsertRowid);
res.status(201).json(rec);
} catch (error: any) {
res.status(500).json({ error: error.message });
}
});
router.put('/:id/recommendations/:rid', (req: AuthRequest, res: Response) => {
try {
const { description, urgency, estimatedCost, status, notes } = req.body;
db.prepare(
'UPDATE recommendations SET description = ?, urgency = ?, estimated_cost = ?, status = ?, notes = ? WHERE id = ? AND job_id = ?'
).run(description, urgency, estimatedCost || null, status || 'open', notes !== undefined ? notes : null, req.params.rid, req.params.id);
const rec = db.prepare('SELECT * FROM recommendations WHERE id = ?').get(req.params.rid);
res.json(rec);
} catch (error: any) {
res.status(500).json({ error: error.message });
}
});
router.delete('/:id/recommendations/:rid', (req: AuthRequest, res: Response) => {
try {
db.prepare('DELETE FROM recommendations WHERE id = ? AND job_id = ?').run(req.params.rid, req.params.id);
res.json({ success: true });
} catch (error: any) {
res.status(500).json({ error: error.message });
}
});
// Create estimate from recommendation
router.post('/:id/recommendations/:rid/estimate', (req: AuthRequest, res: Response) => {
try {
const job = db.prepare('SELECT * FROM jobs WHERE id = ?').get(req.params.id) as Job | undefined;
if (!job) return res.status(404).json({ error: 'Job not found' });
const rec = db.prepare('SELECT * FROM recommendations WHERE id = ? AND job_id = ?').get(req.params.rid, req.params.id) as any;
if (!rec) return res.status(404).json({ error: 'Recommendation not found' });
if (rec.estimate_id) {
return res.json({ estimateId: rec.estimate_id, existing: true });
}
const settings = db.prepare('SELECT * FROM settings WHERE id = 1').get() as any;
// Generate estimate number
const year = new Date().getFullYear();
const last = db.prepare("SELECT estimate_number FROM estimates WHERE estimate_number LIKE ? ORDER BY id DESC LIMIT 1").get(`EST-${year}-%`) as { estimate_number: string } | undefined;
let seq = 1;
if (last) { seq = parseInt(last.estimate_number.split('-')[2], 10) + 1; }
const estimateNumber = `EST-${year}-${String(seq).padStart(4, '0')}`;
const estimatedCost = rec.estimated_cost || 0;
const taxRate = settings?.default_tax_rate || 0;
const taxAmount = estimatedCost * taxRate;
const total = estimatedCost + taxAmount;
const create = db.transaction(() => {
const estimateNotes = [rec.description, rec.notes].filter(Boolean).join('\n');
const result = db.prepare(`
INSERT INTO estimates (estimate_number, customer_type, shop_id, owner_id, vehicle_id, status, subtotal, tax_rate, tax_amount, total, notes)
VALUES (?, ?, ?, ?, ?, 'draft', ?, ?, ?, ?, ?)
`).run(estimateNumber, job.customer_type, job.shop_id || null, job.owner_id || null, job.vehicle_id, estimatedCost, taxRate, taxAmount, total, estimateNotes);
const estimateId = result.lastInsertRowid;
// Add the recommendation as a labor line item — use notes as the description if available for more detail
const itemDescription = rec.notes ? `${rec.description}${rec.notes}` : rec.description;
db.prepare(
"INSERT INTO estimate_items (estimate_id, item_type, description, quantity, unit_price, billing_type, flat_rate, sort_order) VALUES (?, 'labor', ?, 1, ?, 'flat_rate', ?, 0)"
).run(estimateId, itemDescription, estimatedCost, estimatedCost);
// Link recommendation to estimate
db.prepare('UPDATE recommendations SET estimate_id = ? WHERE id = ?').run(estimateId, rec.id);
return estimateId;
});
const estimateId = create();
res.status(201).json({ estimateId, existing: false });
} catch (error: any) {
res.status(500).json({ error: error.message });
}
});
// Create or update invoice from job
router.post('/:id/invoice', (req: AuthRequest, res: Response) => {
try {
const job = db.prepare('SELECT * FROM jobs WHERE id = ?').get(req.params.id) as Job | undefined;
if (!job) return res.status(404).json({ error: 'Job not found' });
const settings = db.prepare('SELECT * FROM settings WHERE id = 1').get() as any;
// Calculate current totals
const labor = db.prepare("SELECT COALESCE(SUM(CASE WHEN billing_type = 'flat_rate' THEN flat_rate ELSE hours * rate END), 0) as total FROM labor_items WHERE job_id = ?").get(job.id) as { total: number };
const parts = db.prepare('SELECT COALESCE(SUM(CASE WHEN charge_price IS NOT NULL THEN charge_price ELSE quantity * unit_cost * (1 + markup) END), 0) as total FROM parts_items WHERE job_id = ?').get(job.id) as { total: number };
const misc = db.prepare('SELECT COALESCE(SUM(charge_price), 0) as total FROM misc_items WHERE job_id = ?').get(job.id) as { total: number };
const subtotal = labor.total + parts.total + misc.total;
const taxRate = settings?.default_tax_rate || 0;
const taxAmount = subtotal * taxRate;
const total = subtotal + taxAmount;
// Check if job already has an invoice
const existingLink = db.prepare('SELECT invoice_id FROM invoice_jobs WHERE job_id = ?').get(job.id) as { invoice_id: number } | undefined;
if (existingLink) {
// UPDATE existing invoice
const invoice = db.prepare('SELECT * FROM invoices WHERE id = ?').get(existingLink.invoice_id) as any;
if (!invoice) return res.status(404).json({ error: 'Linked invoice not found' });
const updateInvoice = db.transaction(() => {
// Save version snapshot of current state
const versionCount = db.prepare('SELECT COUNT(*) as count FROM invoice_versions WHERE invoice_id = ?').get(invoice.id) as { count: number };
db.prepare(
'INSERT INTO invoice_versions (invoice_id, version_number, subtotal, tax_rate, tax_amount, total, reason) VALUES (?, ?, ?, ?, ?, ?, ?)'
).run(invoice.id, versionCount.count + 1, invoice.subtotal, invoice.tax_rate, invoice.tax_amount, invoice.total, 'Updated from job');
// Recalculate — may include other jobs on this invoice too
let invoiceSubtotal = 0;
const allJobIds = db.prepare('SELECT job_id FROM invoice_jobs WHERE invoice_id = ?').all(invoice.id) as { job_id: number }[];
for (const { job_id } of allJobIds) {
const jLabor = db.prepare("SELECT COALESCE(SUM(CASE WHEN billing_type = 'flat_rate' THEN flat_rate ELSE hours * rate END), 0) as total FROM labor_items WHERE job_id = ?").get(job_id) as { total: number };
const jParts = db.prepare('SELECT COALESCE(SUM(CASE WHEN charge_price IS NOT NULL THEN charge_price ELSE quantity * unit_cost * (1 + markup) END), 0) as total FROM parts_items WHERE job_id = ?').get(job_id) as { total: number };
const jMisc = db.prepare('SELECT COALESCE(SUM(charge_price), 0) as total FROM misc_items WHERE job_id = ?').get(job_id) as { total: number };
invoiceSubtotal += jLabor.total + jParts.total + jMisc.total;
}
const invTaxAmount = invoiceSubtotal * invoice.tax_rate;
const invTotal = invoiceSubtotal + invTaxAmount;
db.prepare(
"UPDATE invoices SET subtotal = ?, tax_amount = ?, total = ?, updated_at = datetime('now') WHERE id = ?"
).run(invoiceSubtotal, invTaxAmount, invTotal, invoice.id);
db.prepare("UPDATE jobs SET status = 'invoiced', updated_at = datetime('now') WHERE id = ?").run(job.id);
return invoice.id;
});
const invoiceId = updateInvoice();
const updated = db.prepare('SELECT * FROM invoices WHERE id = ?').get(invoiceId);
res.json(updated);
} else {
// CREATE new invoice
if (job.status !== 'completed') return res.status(400).json({ error: 'Job must be completed to create a new invoice' });
const year = new Date().getFullYear();
const last = db.prepare("SELECT invoice_number FROM invoices WHERE invoice_number LIKE ? ORDER BY id DESC LIMIT 1").get(`INV-${year}-%`) as { invoice_number: string } | undefined;
let seq = 1;
if (last) { seq = parseInt(last.invoice_number.split('-')[2], 10) + 1; }
const invoiceNumber = `INV-${year}-${String(seq).padStart(4, '0')}`;
const createInvoice = db.transaction(() => {
const result = db.prepare(`
INSERT INTO invoices (invoice_number, shop_id, owner_id, status, subtotal, tax_rate, tax_amount, total)
VALUES (?, ?, ?, 'draft', ?, ?, ?, ?)
`).run(invoiceNumber, job.shop_id || null, job.customer_type === 'owner' ? job.owner_id : null, subtotal, taxRate, taxAmount, total);
const invoiceId = result.lastInsertRowid;
db.prepare('INSERT INTO invoice_jobs (invoice_id, job_id) VALUES (?, ?)').run(invoiceId, job.id);
db.prepare("UPDATE jobs SET status = 'invoiced', updated_at = datetime('now') WHERE id = ?").run(job.id);
return invoiceId;
});
const invoiceId = createInvoice();
const invoice = db.prepare('SELECT * FROM invoices WHERE id = ?').get(invoiceId);
res.status(201).json(invoice);
}
} catch (error: any) {
res.status(500).json({ error: error.message });
}
});
export default router;

View File

@@ -0,0 +1,154 @@
import { Router, Response } from 'express';
import db from '../database';
import { AuthRequest } from '../middleware/auth';
const router = Router();
// List messages (optionally filtered by job, owner, shop)
router.get('/', (req: AuthRequest, res: Response) => {
try {
const { jobId, ownerId, shopId } = req.query;
let query = `
SELECT m.*, o.first_name as owner_first_name, o.last_name as owner_last_name,
s.name as shop_name, j.job_number
FROM messages m
LEFT JOIN owners o ON m.owner_id = o.id
LEFT JOIN shops s ON m.shop_id = s.id
LEFT JOIN jobs j ON m.job_id = j.id
`;
const conditions: string[] = [];
const params: any[] = [];
if (jobId) { conditions.push('m.job_id = ?'); params.push(Number(jobId)); }
if (ownerId) { conditions.push('m.owner_id = ?'); params.push(Number(ownerId)); }
if (shopId) { conditions.push('m.shop_id = ?'); params.push(Number(shopId)); }
if (conditions.length > 0) query += ' WHERE ' + conditions.join(' AND ');
query += ' ORDER BY m.created_at DESC';
const messages = db.prepare(query).all(...params);
res.json(messages);
} catch (error: any) {
res.status(500).json({ error: error.message });
}
});
// Send a message (internal / log)
router.post('/', (req: AuthRequest, res: Response) => {
try {
const { jobId, ownerId, shopId, direction, channel, subject, body } = req.body;
if (!body) return res.status(400).json({ error: 'Message body required' });
const result = db.prepare(`
INSERT INTO messages (job_id, owner_id, shop_id, direction, channel, subject, body)
VALUES (?, ?, ?, ?, ?, ?, ?)
`).run(jobId || null, ownerId || null, shopId || null, direction || 'outbound', channel || 'internal', subject || null, body);
const message = db.prepare('SELECT * FROM messages WHERE id = ?').get(result.lastInsertRowid);
res.status(201).json(message);
} catch (error: any) {
res.status(500).json({ error: error.message });
}
});
// Mark message as read
router.put('/:id/read', (req: AuthRequest, res: Response) => {
try {
db.prepare("UPDATE messages SET read_at = datetime('now') WHERE id = ?").run(req.params.id);
res.json({ success: true });
} catch (error: any) {
res.status(500).json({ error: error.message });
}
});
// Delete message
router.delete('/:id', (req: AuthRequest, res: Response) => {
try {
db.prepare('DELETE FROM messages WHERE id = ?').run(req.params.id);
res.json({ success: true });
} catch (error: any) {
res.status(500).json({ error: error.message });
}
});
// --- Message Templates ---
router.get('/templates', (_req: AuthRequest, res: Response) => {
try {
const templates = db.prepare('SELECT * FROM message_templates ORDER BY name').all();
res.json(templates);
} catch (error: any) {
res.status(500).json({ error: error.message });
}
});
router.post('/templates', (req: AuthRequest, res: Response) => {
try {
const { name, subject, body, channel, triggerEvent, isActive } = req.body;
if (!name || !body) return res.status(400).json({ error: 'Name and body required' });
const result = db.prepare(
'INSERT INTO message_templates (name, subject, body, channel, trigger_event, is_active) VALUES (?, ?, ?, ?, ?, ?)'
).run(name, subject || null, body, channel || 'email', triggerEvent || null, isActive ?? 1);
const template = db.prepare('SELECT * FROM message_templates WHERE id = ?').get(result.lastInsertRowid);
res.status(201).json(template);
} catch (error: any) {
res.status(500).json({ error: error.message });
}
});
router.put('/templates/:id', (req: AuthRequest, res: Response) => {
try {
const existing = db.prepare('SELECT * FROM message_templates WHERE id = ?').get(req.params.id) as any;
if (!existing) return res.status(404).json({ error: 'Template not found' });
const { name, subject, body, channel, triggerEvent, isActive } = req.body;
db.prepare(`
UPDATE message_templates SET name = ?, subject = ?, body = ?, channel = ?, trigger_event = ?, is_active = ?
WHERE id = ?
`).run(name ?? existing.name, subject ?? existing.subject, body ?? existing.body,
channel ?? existing.channel, triggerEvent ?? existing.trigger_event, isActive ?? existing.is_active, existing.id);
const updated = db.prepare('SELECT * FROM message_templates WHERE id = ?').get(existing.id);
res.json(updated);
} catch (error: any) {
res.status(500).json({ error: error.message });
}
});
router.delete('/templates/:id', (req: AuthRequest, res: Response) => {
try {
db.prepare('DELETE FROM message_templates WHERE id = ?').run(req.params.id);
res.json({ success: true });
} catch (error: any) {
res.status(500).json({ error: error.message });
}
});
// --- Email Settings ---
router.get('/email-settings', (_req: AuthRequest, res: Response) => {
try {
const settings = db.prepare('SELECT * FROM email_settings WHERE id = 1').get();
res.json(settings);
} catch (error: any) {
res.status(500).json({ error: error.message });
}
});
router.put('/email-settings', (req: AuthRequest, res: Response) => {
try {
const { smtpHost, smtpPort, smtpUser, smtpPass, smtpFrom, smtpSecure, twilioSid, twilioToken, twilioPhone } = req.body;
db.prepare(`
UPDATE email_settings SET smtp_host = ?, smtp_port = ?, smtp_user = ?, smtp_pass = ?, smtp_from = ?,
smtp_secure = ?, twilio_sid = ?, twilio_token = ?, twilio_phone = ?, updated_at = datetime('now')
WHERE id = 1
`).run(smtpHost || null, smtpPort || 587, smtpUser || null, smtpPass || null, smtpFrom || null,
smtpSecure ? 1 : 0, twilioSid || null, twilioToken || null, twilioPhone || null);
const settings = db.prepare('SELECT * FROM email_settings WHERE id = 1').get();
res.json(settings);
} catch (error: any) {
res.status(500).json({ error: error.message });
}
});
export default router;

View File

@@ -0,0 +1,82 @@
import { Router, Response } from 'express';
import db from '../database';
import { AuthRequest } from '../middleware/auth';
import { Owner } from '../types';
const router = Router();
router.get('/', (req: AuthRequest, res: Response) => {
try {
const { search } = req.query;
let query = 'SELECT * FROM owners';
const params: any[] = [];
if (search) {
query += ' WHERE first_name LIKE ? OR last_name LIKE ? OR (first_name || \' \' || last_name) LIKE ?';
params.push(`%${search}%`, `%${search}%`, `%${search}%`);
}
query += ' ORDER BY last_name, first_name';
const owners = db.prepare(query).all(...params);
res.json(owners);
} catch (error: any) {
res.status(500).json({ error: error.message });
}
});
router.get('/:id', (req: AuthRequest, res: Response) => {
try {
const owner = db.prepare('SELECT * FROM owners WHERE id = ?').get(req.params.id) as Owner | undefined;
if (!owner) return res.status(404).json({ error: 'Owner not found' });
const vehicles = db.prepare('SELECT * FROM vehicles WHERE owner_id = ? ORDER BY year DESC').all(owner.id);
res.json({ ...owner, vehicles });
} catch (error: any) {
res.status(500).json({ error: error.message });
}
});
router.post('/', (req: AuthRequest, res: Response) => {
try {
const { firstName, lastName, phone, email, notes } = req.body;
if (!firstName || !lastName) return res.status(400).json({ error: 'First and last name required' });
const result = db.prepare(
'INSERT INTO owners (first_name, last_name, phone, email, notes) VALUES (?, ?, ?, ?, ?)'
).run(firstName, lastName, phone || null, email || null, notes || null);
const owner = db.prepare('SELECT * FROM owners WHERE id = ?').get(result.lastInsertRowid);
res.status(201).json(owner);
} catch (error: any) {
res.status(500).json({ error: error.message });
}
});
router.put('/:id', (req: AuthRequest, res: Response) => {
try {
const owner = db.prepare('SELECT * FROM owners WHERE id = ?').get(req.params.id) as Owner | undefined;
if (!owner) return res.status(404).json({ error: 'Owner not found' });
const { firstName, lastName, phone, email, notes } = req.body;
db.prepare(`
UPDATE owners SET first_name = ?, last_name = ?, phone = ?, email = ?, notes = ?, updated_at = datetime('now')
WHERE id = ?
`).run(firstName ?? owner.first_name, lastName ?? owner.last_name, phone ?? owner.phone, email ?? owner.email, notes ?? owner.notes, owner.id);
const updated = db.prepare('SELECT * FROM owners WHERE id = ?').get(owner.id);
res.json(updated);
} catch (error: any) {
res.status(500).json({ error: error.message });
}
});
router.delete('/:id', (req: AuthRequest, res: Response) => {
try {
db.prepare('DELETE FROM owners WHERE id = ?').run(req.params.id);
res.json({ success: true });
} catch (error: any) {
res.status(500).json({ error: error.message });
}
});
export default router;

View File

@@ -0,0 +1,259 @@
import { Router, Response } from 'express';
import db from '../database';
import { AuthRequest } from '../middleware/auth';
const router = Router();
// Revenue summary
router.get('/revenue', (req: AuthRequest, res: Response) => {
try {
const { startDate, endDate, groupBy } = req.query;
const start = startDate || new Date(new Date().getFullYear(), 0, 1).toISOString();
const end = endDate || new Date().toISOString();
// Total revenue (payments)
const revenue = db.prepare(
'SELECT COALESCE(SUM(amount), 0) as total FROM payments WHERE payment_date >= ? AND payment_date <= ?'
).get(start, end) as { total: number };
// Revenue by month
const monthly = db.prepare(`
SELECT strftime('%Y-%m', payment_date) as month, SUM(amount) as total, COUNT(*) as count
FROM payments WHERE payment_date >= ? AND payment_date <= ?
GROUP BY strftime('%Y-%m', payment_date)
ORDER BY month
`).all(start, end);
// Revenue by payment method
const byMethod = db.prepare(`
SELECT COALESCE(method, 'Unknown') as method, SUM(amount) as total, COUNT(*) as count
FROM payments WHERE payment_date >= ? AND payment_date <= ?
GROUP BY method ORDER BY total DESC
`).all(start, end);
// Average repair order
const aro = db.prepare(`
SELECT AVG(total) as avg_total, COUNT(*) as count
FROM invoices WHERE invoice_date >= ? AND invoice_date <= ? AND status != 'draft'
`).get(start, end) as { avg_total: number; count: number };
res.json({ total: revenue.total, monthly, byMethod, averageRepairOrder: aro.avg_total || 0, invoiceCount: aro.count });
} catch (error: any) {
res.status(500).json({ error: error.message });
}
});
// Profitability
router.get('/profitability', (req: AuthRequest, res: Response) => {
try {
const { startDate, endDate } = req.query;
const start = startDate || new Date(new Date().getFullYear(), 0, 1).toISOString();
const end = endDate || new Date().toISOString();
// Jobs with their revenue
const jobs = db.prepare(`
SELECT j.id, j.job_number, j.description, j.status, j.created_at,
s.name as shop_name,
v.year, v.make, v.model,
COALESCE((SELECT SUM(CASE WHEN billing_type = 'flat_rate' THEN flat_rate ELSE hours * rate END) FROM labor_items WHERE job_id = j.id), 0) as labor_total,
COALESCE((SELECT SUM(quantity * unit_cost * (1 + markup)) FROM parts_items WHERE job_id = j.id), 0) as parts_billed,
COALESCE((SELECT SUM(quantity * unit_cost) FROM parts_items WHERE job_id = j.id), 0) as parts_cost
FROM jobs j
LEFT JOIN shops s ON j.shop_id = s.id
LEFT JOIN vehicles v ON j.vehicle_id = v.id
WHERE j.created_at >= ? AND j.created_at <= ? AND j.status IN ('completed', 'invoiced', 'closed')
ORDER BY j.created_at DESC
`).all(start, end) as any[];
const result = jobs.map(j => ({
...j,
total_billed: j.labor_total + j.parts_billed,
parts_margin: j.parts_billed - j.parts_cost,
parts_margin_pct: j.parts_cost > 0 ? ((j.parts_billed - j.parts_cost) / j.parts_cost * 100) : 0,
}));
const summary = {
totalBilled: result.reduce((s, j) => s + j.total_billed, 0),
totalPartsCost: result.reduce((s, j) => s + j.parts_cost, 0),
totalPartsMargin: result.reduce((s, j) => s + j.parts_margin, 0),
totalLabor: result.reduce((s, j) => s + j.labor_total, 0),
jobCount: result.length,
};
res.json({ jobs: result, summary });
} catch (error: any) {
res.status(500).json({ error: error.message });
}
});
// Jobs by status
router.get('/jobs-by-status', (_req: AuthRequest, res: Response) => {
try {
const result = db.prepare(`
SELECT status, COUNT(*) as count FROM jobs GROUP BY status ORDER BY
CASE status WHEN 'received' THEN 1 WHEN 'in_progress' THEN 2 WHEN 'completed' THEN 3
WHEN 'invoiced' THEN 4 WHEN 'closed' THEN 5 END
`).all();
res.json(result);
} catch (error: any) {
res.status(500).json({ error: error.message });
}
});
// Revenue by shop
router.get('/revenue-by-shop', (req: AuthRequest, res: Response) => {
try {
const { startDate, endDate } = req.query;
const start = startDate || new Date(new Date().getFullYear(), 0, 1).toISOString();
const end = endDate || new Date().toISOString();
const result = db.prepare(`
SELECT s.name as shop_name, s.id as shop_id,
COUNT(DISTINCT i.id) as invoice_count,
COALESCE(SUM(i.total), 0) as total_invoiced,
COALESCE(SUM(p_total.paid), 0) as total_paid
FROM shops s
LEFT JOIN invoices i ON s.id = i.shop_id AND i.invoice_date >= ? AND i.invoice_date <= ?
LEFT JOIN (SELECT invoice_id, SUM(amount) as paid FROM payments GROUP BY invoice_id) p_total ON i.id = p_total.invoice_id
WHERE s.active = 1
GROUP BY s.id ORDER BY total_invoiced DESC
`).all(start, end);
res.json(result);
} catch (error: any) {
res.status(500).json({ error: error.message });
}
});
// Outstanding receivables aging
router.get('/aging', (_req: AuthRequest, res: Response) => {
try {
const now = new Date();
const invoices = db.prepare(`
SELECT i.*, s.name as shop_name, o.first_name as owner_first_name, o.last_name as owner_last_name,
COALESCE((SELECT SUM(amount) FROM payments WHERE invoice_id = i.id), 0) as paid
FROM invoices i
LEFT JOIN shops s ON i.shop_id = s.id
LEFT JOIN owners o ON i.owner_id = o.id
WHERE i.status IN ('sent', 'partial', 'overdue')
`).all() as any[];
const aging = { current: [] as any[], over30: [] as any[], over60: [] as any[], over90: [] as any[] };
for (const inv of invoices) {
const due = inv.due_date ? new Date(inv.due_date) : new Date(inv.invoice_date);
const daysOverdue = Math.floor((now.getTime() - due.getTime()) / 86400000);
const balance = inv.total - inv.paid;
const entry = { ...inv, balance, daysOverdue };
if (daysOverdue <= 0) aging.current.push(entry);
else if (daysOverdue <= 30) aging.over30.push(entry);
else if (daysOverdue <= 60) aging.over60.push(entry);
else aging.over90.push(entry);
}
const totals = {
current: aging.current.reduce((s, i) => s + i.balance, 0),
over30: aging.over30.reduce((s, i) => s + i.balance, 0),
over60: aging.over60.reduce((s, i) => s + i.balance, 0),
over90: aging.over90.reduce((s, i) => s + i.balance, 0),
};
res.json({ aging, totals, total: totals.current + totals.over30 + totals.over60 + totals.over90 });
} catch (error: any) {
res.status(500).json({ error: error.message });
}
});
// Technician productivity
router.get('/technician-productivity', (req: AuthRequest, res: Response) => {
try {
const { startDate, endDate } = req.query;
const start = startDate || new Date(new Date().getFullYear(), new Date().getMonth(), 1).toISOString();
const end = endDate || new Date().toISOString();
const result = db.prepare(`
SELECT t.id, t.name, t.hourly_rate,
COALESCE(SUM(te.duration_minutes), 0) as total_minutes,
COUNT(DISTINCT te.job_id) as jobs_worked,
COUNT(te.id) as clock_entries
FROM technicians t
LEFT JOIN time_entries te ON t.id = te.technician_id AND te.clock_in >= ? AND te.clock_in <= ?
WHERE t.is_active = 1
GROUP BY t.id ORDER BY total_minutes DESC
`).all(start, end) as any[];
const productivity = result.map(r => ({
...r,
total_hours: (r.total_minutes / 60).toFixed(1),
labor_cost: (r.total_minutes / 60) * r.hourly_rate,
}));
res.json(productivity);
} catch (error: any) {
res.status(500).json({ error: error.message });
}
});
// Top customers
router.get('/top-customers', (req: AuthRequest, res: Response) => {
try {
const { startDate, endDate, limit } = req.query;
const start = startDate || new Date(new Date().getFullYear(), 0, 1).toISOString();
const end = endDate || new Date().toISOString();
const lim = Number(limit) || 10;
// By owner
const owners = db.prepare(`
SELECT o.id, o.first_name, o.last_name,
COUNT(DISTINCT j.id) as job_count,
COALESCE(SUM(DISTINCT i.total), 0) as total_invoiced
FROM owners o
JOIN jobs j ON o.id = j.owner_id AND j.created_at >= ? AND j.created_at <= ?
LEFT JOIN invoice_jobs ij ON j.id = ij.job_id
LEFT JOIN invoices i ON ij.invoice_id = i.id
GROUP BY o.id ORDER BY total_invoiced DESC LIMIT ?
`).all(start, end, lim);
// By shop
const shops = db.prepare(`
SELECT s.id, s.name,
COUNT(DISTINCT j.id) as job_count,
COALESCE(SUM(DISTINCT i.total), 0) as total_invoiced
FROM shops s
JOIN jobs j ON s.id = j.shop_id AND j.created_at >= ? AND j.created_at <= ?
LEFT JOIN invoice_jobs ij ON j.id = ij.job_id
LEFT JOIN invoices i ON ij.invoice_id = i.id
WHERE s.active = 1
GROUP BY s.id ORDER BY total_invoiced DESC LIMIT ?
`).all(start, end, lim);
res.json({ owners, shops });
} catch (error: any) {
res.status(500).json({ error: error.message });
}
});
// Inventory summary
router.get('/inventory-summary', (_req: AuthRequest, res: Response) => {
try {
const lowStock = db.prepare(
'SELECT * FROM inventory_items WHERE is_active = 1 AND quantity_on_hand <= reorder_point AND reorder_point > 0 ORDER BY name'
).all();
const totalValue = db.prepare(
'SELECT COALESCE(SUM(quantity_on_hand * unit_cost), 0) as total FROM inventory_items WHERE is_active = 1'
).get() as { total: number };
const byCategory = db.prepare(`
SELECT category, COUNT(*) as item_count, SUM(quantity_on_hand * unit_cost) as value
FROM inventory_items WHERE is_active = 1 GROUP BY category ORDER BY value DESC
`).all();
res.json({ lowStock, totalValue: totalValue.total, byCategory });
} catch (error: any) {
res.status(500).json({ error: error.message });
}
});
export default router;

View File

@@ -0,0 +1,32 @@
import { Router, Response } from 'express';
import db from '../database';
import { AuthRequest } from '../middleware/auth';
const router = Router();
router.get('/', (_req: AuthRequest, res: Response) => {
try {
const settings = db.prepare('SELECT * FROM settings WHERE id = 1').get();
res.json(settings);
} catch (error: any) {
res.status(500).json({ error: error.message });
}
});
router.put('/', (req: AuthRequest, res: Response) => {
try {
const { shopName, shopAddress, shopCity, shopState, shopZip, shopPhone, shopEmail, defaultTaxRate, invoiceTerms } = req.body;
db.prepare(`
UPDATE settings SET shop_name = ?, shop_address = ?, shop_city = ?, shop_state = ?, shop_zip = ?,
shop_phone = ?, shop_email = ?, default_tax_rate = ?, invoice_terms = ?, updated_at = datetime('now')
WHERE id = 1
`).run(shopName, shopAddress || null, shopCity || null, shopState || null, shopZip || null, shopPhone || null, shopEmail || null, defaultTaxRate ?? 0, invoiceTerms || null);
const settings = db.prepare('SELECT * FROM settings WHERE id = 1').get();
res.json(settings);
} catch (error: any) {
res.status(500).json({ error: error.message });
}
});
export default router;

131
server/src/routes/shops.ts Normal file
View File

@@ -0,0 +1,131 @@
import { Router, Response } from 'express';
import db from '../database';
import { AuthRequest } from '../middleware/auth';
import { Shop } from '../types';
const router = Router();
router.get('/', (req: AuthRequest, res: Response) => {
try {
const { search, active } = req.query;
let query = 'SELECT * FROM shops';
const conditions: string[] = [];
const params: any[] = [];
if (active !== undefined) {
conditions.push('active = ?');
params.push(Number(active));
}
if (search) {
conditions.push('(name LIKE ? OR contact_name LIKE ?)');
params.push(`%${search}%`, `%${search}%`);
}
if (conditions.length > 0) {
query += ' WHERE ' + conditions.join(' AND ');
}
query += ' ORDER BY name';
const shops = db.prepare(query).all(...params);
res.json(shops);
} catch (error: any) {
res.status(500).json({ error: error.message });
}
});
router.get('/:id', (req: AuthRequest, res: Response) => {
try {
const shop = db.prepare('SELECT * FROM shops WHERE id = ?').get(req.params.id) as Shop | undefined;
if (!shop) return res.status(404).json({ error: 'Shop not found' });
const jobCount = db.prepare('SELECT COUNT(*) as count FROM jobs WHERE shop_id = ?').get(shop.id) as { count: number };
const outstanding = db.prepare(`
SELECT COALESCE(SUM(i.total), 0) - COALESCE((
SELECT SUM(p.amount) FROM payments p
JOIN invoices inv ON p.invoice_id = inv.id
WHERE inv.shop_id = ?
), 0) as balance
FROM invoices i WHERE i.shop_id = ? AND i.status != 'paid'
`).get(shop.id, shop.id) as { balance: number };
res.json({ ...shop, jobCount: jobCount.count, outstandingBalance: outstanding.balance });
} catch (error: any) {
res.status(500).json({ error: error.message });
}
});
router.post('/', (req: AuthRequest, res: Response) => {
try {
const { name, contactName, phone, email, address, city, state, zip, notes, defaultLaborRate, defaultPartsMarkup } = req.body;
if (!name) return res.status(400).json({ error: 'Shop name is required' });
const result = db.prepare(`
INSERT INTO shops (name, contact_name, phone, email, address, city, state, zip, notes, default_labor_rate, default_parts_markup)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(name, contactName || null, phone || null, email || null, address || null, city || null, state || null, zip || null, notes || null, defaultLaborRate ?? 75.00, defaultPartsMarkup ?? 0.30);
const shop = db.prepare('SELECT * FROM shops WHERE id = ?').get(result.lastInsertRowid);
res.status(201).json(shop);
} catch (error: any) {
res.status(500).json({ error: error.message });
}
});
router.put('/:id', (req: AuthRequest, res: Response) => {
try {
const shop = db.prepare('SELECT * FROM shops WHERE id = ?').get(req.params.id) as Shop | undefined;
if (!shop) return res.status(404).json({ error: 'Shop not found' });
const { name, contactName, phone, email, address, city, state, zip, notes, defaultLaborRate, defaultPartsMarkup, active } = req.body;
db.prepare(`
UPDATE shops SET name = ?, contact_name = ?, phone = ?, email = ?, address = ?, city = ?, state = ?, zip = ?,
notes = ?, default_labor_rate = ?, default_parts_markup = ?, active = ?, updated_at = datetime('now')
WHERE id = ?
`).run(
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 ?? shop.default_labor_rate, defaultPartsMarkup ?? shop.default_parts_markup,
active ?? shop.active, shop.id
);
const updated = db.prepare('SELECT * FROM shops WHERE id = ?').get(shop.id);
res.json(updated);
} catch (error: any) {
res.status(500).json({ error: error.message });
}
});
router.delete('/:id', (req: AuthRequest, res: Response) => {
try {
db.prepare('UPDATE shops SET active = 0, updated_at = datetime(\'now\') WHERE id = ?').run(req.params.id);
res.json({ success: true });
} catch (error: any) {
res.status(500).json({ error: error.message });
}
});
router.get('/:id/jobs', (req: AuthRequest, res: Response) => {
try {
const jobs = db.prepare(`
SELECT j.*, v.year, v.make, v.model, o.first_name, o.last_name
FROM jobs j
JOIN vehicles v ON j.vehicle_id = v.id
LEFT JOIN owners o ON j.owner_id = o.id
WHERE j.shop_id = ?
ORDER BY j.created_at DESC
`).all(req.params.id);
res.json(jobs);
} catch (error: any) {
res.status(500).json({ error: error.message });
}
});
router.get('/:id/invoices', (req: AuthRequest, res: Response) => {
try {
const invoices = db.prepare('SELECT * FROM invoices WHERE shop_id = ? ORDER BY invoice_date DESC').all(req.params.id);
res.json(invoices);
} catch (error: any) {
res.status(500).json({ error: error.message });
}
});
export default router;

82
server/src/routes/tags.ts Normal file
View File

@@ -0,0 +1,82 @@
import { Router, Response } from 'express';
import db from '../database';
import { AuthRequest } from '../middleware/auth';
const router = Router();
// Get all tags
router.get('/', (_req: AuthRequest, res: Response) => {
try {
const tags = db.prepare('SELECT * FROM tags ORDER BY name').all();
res.json(tags);
} catch (error: any) {
res.status(500).json({ error: error.message });
}
});
// Create tag
router.post('/', (req: AuthRequest, res: Response) => {
try {
const { name, color } = req.body;
if (!name) return res.status(400).json({ error: 'Tag name required' });
const result = db.prepare('INSERT INTO tags (name, color) VALUES (?, ?)').run(name, color || 'gray');
const tag = db.prepare('SELECT * FROM tags WHERE id = ?').get(result.lastInsertRowid);
res.status(201).json(tag);
} catch (error: any) {
if (error.code === 'SQLITE_CONSTRAINT_UNIQUE') {
return res.status(409).json({ error: 'Tag already exists' });
}
res.status(500).json({ error: error.message });
}
});
// Delete tag
router.delete('/:id', (req: AuthRequest, res: Response) => {
try {
db.prepare('DELETE FROM tags WHERE id = ?').run(req.params.id);
res.json({ success: true });
} catch (error: any) {
res.status(500).json({ error: error.message });
}
});
// Get tags for a job
router.get('/job/:jobId', (req: AuthRequest, res: Response) => {
try {
const tags = db.prepare(`
SELECT t.* FROM tags t
JOIN job_tags jt ON t.id = jt.tag_id
WHERE jt.job_id = ?
ORDER BY t.name
`).all(req.params.jobId);
res.json(tags);
} catch (error: any) {
res.status(500).json({ error: error.message });
}
});
// Add tag to job
router.post('/job/:jobId', (req: AuthRequest, res: Response) => {
try {
const { tagId } = req.body;
if (!tagId) return res.status(400).json({ error: 'Tag ID required' });
db.prepare('INSERT OR IGNORE INTO job_tags (job_id, tag_id) VALUES (?, ?)').run(req.params.jobId, tagId);
res.json({ success: true });
} catch (error: any) {
res.status(500).json({ error: error.message });
}
});
// Remove tag from job
router.delete('/job/:jobId/:tagId', (req: AuthRequest, res: Response) => {
try {
db.prepare('DELETE FROM job_tags WHERE job_id = ? AND tag_id = ?').run(req.params.jobId, req.params.tagId);
res.json({ success: true });
} catch (error: any) {
res.status(500).json({ error: error.message });
}
});
export default router;

View File

@@ -0,0 +1,161 @@
import { Router, Response } from 'express';
import db from '../database';
import { AuthRequest } from '../middleware/auth';
import type { Technician } from '../types';
const router = Router();
// List technicians
router.get('/', (req: AuthRequest, res: Response) => {
try {
const { active } = req.query;
let query = 'SELECT * FROM technicians';
const params: any[] = [];
if (active !== undefined) { query += ' WHERE is_active = ?'; params.push(Number(active)); }
query += ' ORDER BY name';
const technicians = db.prepare(query).all(...params);
res.json(technicians);
} catch (error: any) {
res.status(500).json({ error: error.message });
}
});
// Get technician detail
router.get('/:id', (req: AuthRequest, res: Response) => {
try {
const tech = db.prepare('SELECT * FROM technicians WHERE id = ?').get(req.params.id);
if (!tech) return res.status(404).json({ error: 'Technician not found' });
// Get active time entries
const activeEntry = db.prepare("SELECT * FROM time_entries WHERE technician_id = ? AND clock_out IS NULL ORDER BY clock_in DESC LIMIT 1")
.get(req.params.id);
// Get recent jobs
const jobs = db.prepare(`
SELECT j.*, v.year, v.make, v.model FROM jobs j
LEFT JOIN vehicles v ON j.vehicle_id = v.id
WHERE j.technician_id = ?
ORDER BY j.created_at DESC LIMIT 10
`).all(req.params.id);
// Get time summary (this month)
const now = new Date();
const startOfMonth = new Date(now.getFullYear(), now.getMonth(), 1).toISOString();
const timeSummary = db.prepare(`
SELECT COALESCE(SUM(duration_minutes), 0) as total_minutes, COUNT(*) as entry_count
FROM time_entries WHERE technician_id = ? AND clock_in >= ?
`).get(req.params.id, startOfMonth) as { total_minutes: number; entry_count: number };
res.json({ ...tech, activeEntry, jobs, timeSummary });
} catch (error: any) {
res.status(500).json({ error: error.message });
}
});
// Create technician
router.post('/', (req: AuthRequest, res: Response) => {
try {
const { name, phone, email, specialty, hourlyRate } = req.body;
if (!name) return res.status(400).json({ error: 'Name required' });
const result = db.prepare(
'INSERT INTO technicians (name, phone, email, specialty, hourly_rate) VALUES (?, ?, ?, ?, ?)'
).run(name, phone || null, email || null, specialty || null, hourlyRate || 0);
const tech = db.prepare('SELECT * FROM technicians WHERE id = ?').get(result.lastInsertRowid);
res.status(201).json(tech);
} catch (error: any) {
res.status(500).json({ error: error.message });
}
});
// Update technician
router.put('/:id', (req: AuthRequest, res: Response) => {
try {
const tech = db.prepare('SELECT * FROM technicians WHERE id = ?').get(req.params.id) as Technician | undefined;
if (!tech) return res.status(404).json({ error: 'Technician not found' });
const { name, phone, email, specialty, hourlyRate, isActive } = req.body;
db.prepare(`
UPDATE technicians SET name = ?, phone = ?, email = ?, specialty = ?, hourly_rate = ?, is_active = ?, updated_at = datetime('now')
WHERE id = ?
`).run(name ?? tech.name, phone ?? tech.phone, email ?? tech.email, specialty ?? tech.specialty,
hourlyRate ?? tech.hourly_rate, isActive ?? tech.is_active, tech.id);
const updated = db.prepare('SELECT * FROM technicians WHERE id = ?').get(tech.id);
res.json(updated);
} catch (error: any) {
res.status(500).json({ error: error.message });
}
});
// Delete technician
router.delete('/:id', (req: AuthRequest, res: Response) => {
try {
db.prepare("UPDATE technicians SET is_active = 0, updated_at = datetime('now') WHERE id = ?").run(req.params.id);
res.json({ success: true });
} catch (error: any) {
res.status(500).json({ error: error.message });
}
});
// Clock in
router.post('/:id/clock-in', (req: AuthRequest, res: Response) => {
try {
const { jobId } = req.body;
if (!jobId) return res.status(400).json({ error: 'Job ID required' });
// Check for existing open entry
const existing = db.prepare("SELECT * FROM time_entries WHERE technician_id = ? AND clock_out IS NULL").get(req.params.id);
if (existing) return res.status(400).json({ error: 'Already clocked in. Clock out first.' });
const result = db.prepare(
'INSERT INTO time_entries (technician_id, job_id, clock_in) VALUES (?, ?, datetime(\'now\'))'
).run(req.params.id, jobId);
const entry = db.prepare('SELECT * FROM time_entries WHERE id = ?').get(result.lastInsertRowid);
res.status(201).json(entry);
} catch (error: any) {
res.status(500).json({ error: error.message });
}
});
// Clock out
router.post('/:id/clock-out', (req: AuthRequest, res: Response) => {
try {
const entry = db.prepare("SELECT * FROM time_entries WHERE technician_id = ? AND clock_out IS NULL ORDER BY clock_in DESC LIMIT 1")
.get(req.params.id) as any;
if (!entry) return res.status(400).json({ error: 'No active clock-in found' });
const clockIn = new Date(entry.clock_in);
const clockOut = new Date();
const durationMinutes = Math.round((clockOut.getTime() - clockIn.getTime()) / 60000);
db.prepare("UPDATE time_entries SET clock_out = datetime('now'), duration_minutes = ? WHERE id = ?")
.run(durationMinutes, entry.id);
const updated = db.prepare('SELECT * FROM time_entries WHERE id = ?').get(entry.id);
res.json(updated);
} catch (error: any) {
res.status(500).json({ error: error.message });
}
});
// Get time entries for a job
router.get('/time-entries/job/:jobId', (req: AuthRequest, res: Response) => {
try {
const entries = db.prepare(`
SELECT te.*, t.name as technician_name
FROM time_entries te
JOIN technicians t ON te.technician_id = t.id
WHERE te.job_id = ?
ORDER BY te.clock_in DESC
`).all(req.params.jobId);
res.json(entries);
} catch (error: any) {
res.status(500).json({ error: error.message });
}
});
export default router;

View File

@@ -0,0 +1,177 @@
import { Router, Response } from 'express';
import db from '../database';
import { AuthRequest } from '../middleware/auth';
import { Vehicle } from '../types';
const router = Router();
router.get('/', (req: AuthRequest, res: Response) => {
try {
const { search, ownerId, inventory } = req.query;
let query = `
SELECT v.*, o.first_name, o.last_name
FROM vehicles v
LEFT JOIN owners o ON v.owner_id = o.id
`;
const conditions: string[] = [];
const params: any[] = [];
if (ownerId) {
conditions.push('v.owner_id = ?');
params.push(Number(ownerId));
}
if (inventory !== undefined) {
conditions.push('v.is_inventory = ?');
params.push(Number(inventory));
}
if (search) {
conditions.push("(v.make LIKE ? OR v.model LIKE ? OR v.vin LIKE ? OR COALESCE(o.first_name || ' ' || o.last_name, '') LIKE ?)");
params.push(`%${search}%`, `%${search}%`, `%${search}%`, `%${search}%`);
}
if (conditions.length > 0) {
query += ' WHERE ' + conditions.join(' AND ');
}
query += ' ORDER BY v.year DESC, v.make, v.model';
const vehicles = db.prepare(query).all(...params);
res.json(vehicles);
} catch (error: any) {
res.status(500).json({ error: error.message });
}
});
router.get('/:id', (req: AuthRequest, res: Response) => {
try {
const vehicle = db.prepare(`
SELECT v.*, o.first_name, o.last_name
FROM vehicles v LEFT JOIN owners o ON v.owner_id = o.id
WHERE v.id = ?
`).get(req.params.id) as (Vehicle & { first_name: string; last_name: string }) | undefined;
if (!vehicle) return res.status(404).json({ error: 'Vehicle not found' });
const jobs = db.prepare(`
SELECT j.*, s.name as shop_name FROM jobs j
LEFT JOIN shops s ON j.shop_id = s.id
WHERE j.vehicle_id = ? ORDER BY j.created_at DESC
`).all(vehicle.id);
res.json({ ...vehicle, jobs });
} catch (error: any) {
res.status(500).json({ error: error.message });
}
});
router.post('/', (req: AuthRequest, res: Response) => {
try {
const { ownerId, year, make, model, vin, color, licensePlate, mileage, isInventory, inventoryStatus, purchasePrice, purchaseDate, stockNumber, notes } = req.body;
if (!make || !model) return res.status(400).json({ error: 'Make and model required' });
// Auto-generate stock number for inventory vehicles
let effectiveStockNumber = stockNumber || null;
if (isInventory && !effectiveStockNumber) {
const last = db.prepare("SELECT stock_number FROM vehicles WHERE stock_number LIKE 'STK-%' ORDER BY id DESC LIMIT 1").get() as { stock_number: string } | undefined;
let seq = 1;
if (last) { seq = parseInt(last.stock_number.split('-')[1], 10) + 1; }
effectiveStockNumber = `STK-${String(seq).padStart(4, '0')}`;
}
const result = db.prepare(`
INSERT INTO vehicles (owner_id, year, make, model, vin, color, license_plate, mileage, is_inventory, inventory_status, purchase_price, purchase_date, stock_number, notes)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(ownerId || null, year || null, make, model, vin || null, color || null, licensePlate || null, mileage || null, isInventory ? 1 : 0, inventoryStatus || 'stock', purchasePrice || null, purchaseDate || null, effectiveStockNumber, notes || null);
const vehicle = db.prepare('SELECT * FROM vehicles WHERE id = ?').get(result.lastInsertRowid);
res.status(201).json(vehicle);
} catch (error: any) {
res.status(500).json({ error: error.message });
}
});
router.put('/:id', (req: AuthRequest, res: Response) => {
try {
const vehicle = db.prepare('SELECT * FROM vehicles WHERE id = ?').get(req.params.id) as Vehicle | undefined;
if (!vehicle) return res.status(404).json({ error: 'Vehicle not found' });
const { ownerId, year, make, model, vin, color, licensePlate, mileage, isInventory, inventoryStatus, purchasePrice, purchaseDate, salePrice, saleDate, stockNumber, notes } = req.body;
db.prepare(`
UPDATE vehicles SET owner_id = ?, year = ?, make = ?, model = ?, vin = ?, color = ?, license_plate = ?, mileage = ?,
is_inventory = ?, inventory_status = ?, purchase_price = ?, purchase_date = ?, sale_price = ?, sale_date = ?,
stock_number = ?, notes = ?, updated_at = datetime('now')
WHERE id = ?
`).run(
ownerId !== undefined ? (ownerId || null) : vehicle.owner_id,
year ?? vehicle.year, make ?? vehicle.make, model ?? vehicle.model, vin ?? vehicle.vin,
color ?? vehicle.color, licensePlate ?? vehicle.license_plate, mileage ?? vehicle.mileage,
isInventory !== undefined ? (isInventory ? 1 : 0) : vehicle.is_inventory,
inventoryStatus ?? vehicle.inventory_status, purchasePrice ?? vehicle.purchase_price,
purchaseDate ?? vehicle.purchase_date, salePrice ?? vehicle.sale_price,
saleDate ?? vehicle.sale_date, stockNumber ?? (vehicle as any).stock_number, notes ?? vehicle.notes, vehicle.id
);
const updated = db.prepare('SELECT * FROM vehicles WHERE id = ?').get(vehicle.id);
res.json(updated);
} catch (error: any) {
res.status(500).json({ error: error.message });
}
});
router.delete('/:id', (req: AuthRequest, res: Response) => {
try {
db.prepare('DELETE FROM vehicles WHERE id = ?').run(req.params.id);
res.json({ success: true });
} catch (error: any) {
res.status(500).json({ error: error.message });
}
});
// Full service history report for a vehicle
router.get('/:id/history', (req: AuthRequest, res: Response) => {
try {
const vehicle = db.prepare(`
SELECT v.*, o.first_name, o.last_name
FROM vehicles v LEFT JOIN owners o ON v.owner_id = o.id
WHERE v.id = ?
`).get(req.params.id) as any;
if (!vehicle) return res.status(404).json({ error: 'Vehicle not found' });
const jobs = db.prepare(`
SELECT j.*, s.name as shop_name, t.name as technician_name
FROM jobs j
LEFT JOIN shops s ON j.shop_id = s.id
LEFT JOIN technicians t ON j.technician_id = t.id
WHERE j.vehicle_id = ?
ORDER BY j.received_date DESC
`).all(vehicle.id);
const jobsWithDetails = jobs.map((job: any) => {
const laborItems = db.prepare('SELECT * FROM labor_items WHERE job_id = ? ORDER BY sort_order, id').all(job.id);
const partsItems = db.prepare('SELECT * FROM parts_items WHERE job_id = ? ORDER BY sort_order, id').all(job.id);
const recommendations = db.prepare('SELECT * FROM recommendations WHERE job_id = ?').all(job.id);
const laborTotal = laborItems.reduce((sum: number, item: any) => {
if (item.billing_type === 'flat_rate') return sum + (item.flat_rate || 0);
return sum + (item.hours || 0) * (item.rate || 0);
}, 0);
const partsTotal = partsItems.reduce((sum: number, item: any) => sum + item.quantity * item.unit_cost * (1 + item.markup), 0);
return { ...job, laborItems, partsItems, recommendations, laborTotal, partsTotal, total: laborTotal + partsTotal };
});
const totalSpent = jobsWithDetails.reduce((sum: number, j: any) => sum + j.total, 0);
res.json({
vehicle,
jobs: jobsWithDetails,
summary: {
totalJobs: jobs.length,
totalSpent,
firstService: jobs.length > 0 ? (jobs[jobs.length - 1] as any).received_date : null,
lastService: jobs.length > 0 ? (jobs[0] as any).received_date : null,
},
});
} catch (error: any) {
res.status(500).json({ error: error.message });
}
});
export default router;

247
server/src/services/pdf.ts Normal file
View File

@@ -0,0 +1,247 @@
import PDFDocument from 'pdfkit';
import { Response } from 'express';
import { Settings } from '../types';
interface PdfData {
invoice: any;
settings: Settings;
jobs: any[];
payments: any[];
recommendations?: any[];
customerNotes?: any[];
}
export function generateInvoicePdf(data: PdfData, res: Response) {
const { invoice, settings, jobs, payments } = data;
const doc = new PDFDocument({ margin: 50, size: 'LETTER' });
doc.pipe(res);
const fmt = (n: number) => '$' + n.toFixed(2);
// Header
doc.fontSize(24).font('Helvetica-Bold').text(settings.shop_name || 'Shop Manager', 50, 50);
doc.fontSize(10).font('Helvetica');
let headerY = 78;
if (settings.shop_address) { doc.text(settings.shop_address, 50, headerY); headerY += 14; }
const cityLine = [settings.shop_city, settings.shop_state, settings.shop_zip].filter(Boolean).join(', ');
if (cityLine) { doc.text(cityLine, 50, headerY); headerY += 14; }
if (settings.shop_phone) { doc.text(settings.shop_phone, 50, headerY); headerY += 14; }
if (settings.shop_email) { doc.text(settings.shop_email, 50, headerY); headerY += 14; }
// Invoice info (right side)
doc.fontSize(18).font('Helvetica-Bold').text('INVOICE', 400, 50, { align: 'right' });
doc.fontSize(10).font('Helvetica');
doc.text(`Invoice #: ${invoice.invoice_number}`, 400, 75, { align: 'right' });
doc.text(`Date: ${new Date(invoice.invoice_date).toLocaleDateString()}`, 400, 89, { align: 'right' });
if (invoice.due_date) {
doc.text(`Due: ${new Date(invoice.due_date).toLocaleDateString()}`, 400, 103, { align: 'right' });
} else {
doc.text('Due: On Receipt', 400, 103, { align: 'right' });
}
if (settings.invoice_terms) {
doc.text(`Terms: ${settings.invoice_terms}`, 400, 117, { align: 'right' });
}
// Bill To
let y = Math.max(headerY, 140) + 10;
doc.moveTo(50, y).lineTo(562, y).stroke();
y += 10;
doc.fontSize(11).font('Helvetica-Bold').text('BILL TO:', 50, y);
y += 16;
doc.fontSize(10).font('Helvetica');
if (invoice.shop_id && invoice.shop_name) {
doc.text(invoice.shop_name, 50, y); y += 14;
if (invoice.shop_contact) { doc.text(`Attn: ${invoice.shop_contact}`, 50, y); y += 14; }
if (invoice.shop_address) { doc.text(invoice.shop_address, 50, y); y += 14; }
const shopCityLine = [invoice.shop_city, invoice.shop_state, invoice.shop_zip].filter(Boolean).join(', ');
if (shopCityLine) { doc.text(shopCityLine, 50, y); y += 14; }
if (invoice.shop_phone) { doc.text(invoice.shop_phone, 50, y); y += 14; }
} else if (invoice.owner_id && invoice.owner_first_name) {
doc.text(`${invoice.owner_first_name} ${invoice.owner_last_name}`, 50, y); y += 14;
if (invoice.owner_phone) { doc.text(invoice.owner_phone, 50, y); y += 14; }
if (invoice.owner_email) { doc.text(invoice.owner_email, 50, y); y += 14; }
}
y += 10;
// Jobs and line items
for (const job of jobs) {
if (y > 680) { doc.addPage(); y = 50; }
doc.moveTo(50, y).lineTo(562, y).stroke();
y += 8;
const vehicleStr = [job.year, job.make, job.model].filter(Boolean).join(' ');
const ownerStr = job.first_name ? `${job.first_name} ${job.last_name}` : '';
doc.fontSize(10).font('Helvetica-Bold').text(`${job.job_number}${vehicleStr}${ownerStr ? ` (${ownerStr})` : ''}`, 50, y);
y += 14;
// Mileage line — miles out defaults to miles in if not set
const milesOut = job.mileage_out || job.mileage_in;
const infoLine = [
job.mileage_in ? `Miles In: ${Number(job.mileage_in).toLocaleString()}` : null,
milesOut ? `Miles Out: ${Number(milesOut).toLocaleString()}` : null,
].filter(Boolean).join(' | ');
if (infoLine) {
doc.font('Helvetica').fontSize(9).text(infoLine, 50, y); y += 12;
}
doc.fontSize(10);
if (job.description) {
doc.font('Helvetica').text(job.description, 50, y, { width: 512 });
y += doc.heightOfString(job.description, { width: 512 }) + 4;
}
y += 4;
// Labor
const visibleLabor = (job.laborItems || []).filter((i: any) => !i.hidden);
if (visibleLabor.length > 0) {
doc.font('Helvetica-Bold').text('Labor', 60, y); y += 14;
doc.font('Helvetica');
for (const item of visibleLabor) {
if (item.billing_type === 'flat_rate') {
doc.text(item.description, 70, y, { width: 300 });
doc.text('Flat rate', 380, y);
doc.text(fmt(item.flat_rate || 0), 500, y, { align: 'right', width: 62 });
} else {
const lineTotal = (item.hours || 0) * (item.rate || 0);
doc.text(item.description, 70, y, { width: 300 });
doc.text(`${item.hours}h @ ${fmt(item.rate || 0)}/hr`, 380, y);
doc.text(fmt(lineTotal), 500, y, { align: 'right', width: 62 });
}
y += 14;
}
y += 4;
}
// Parts — only skip hidden
const visibleParts = (job.partsItems || []).filter((i: any) => !i.hidden);
if (visibleParts.length > 0) {
doc.font('Helvetica-Bold').text('Parts', 60, y); y += 14;
doc.font('Helvetica');
for (const item of visibleParts) {
const hasOverride = item.charge_price !== null && item.charge_price !== undefined;
const lineTotal = hasOverride ? item.charge_price : item.quantity * item.unit_cost * (1 + item.markup);
doc.text(item.description, 70, y, { width: 280 });
if (!hasOverride) {
doc.text(`${item.quantity} × ${fmt(item.unit_cost * (1 + item.markup))}`, 360, y);
}
doc.text(fmt(lineTotal), 500, y, { align: 'right', width: 62 });
y += 14;
}
y += 4;
}
// Misc / Sublet — only skip hidden
const visibleMisc = (job.miscItems || []).filter((m: any) => !m.hidden);
if (visibleMisc.length > 0) {
doc.font('Helvetica-Bold').text('Other', 60, y); y += 14;
doc.font('Helvetica');
for (const item of visibleMisc) {
doc.text(item.description, 70, y, { width: 380 });
doc.text(fmt(item.charge_price), 500, y, { align: 'right', width: 62 });
y += 14;
}
y += 4;
}
y += 6;
}
// Totals
if (y > 660) { doc.addPage(); y = 50; }
y += 4;
doc.moveTo(350, y).lineTo(562, y).stroke();
y += 8;
doc.font('Helvetica').text('Subtotal:', 380, y);
doc.text(fmt(invoice.subtotal), 500, y, { align: 'right', width: 62 });
y += 16;
if (invoice.tax_rate > 0) {
doc.text(`Tax (${(invoice.tax_rate * 100).toFixed(1)}%):`, 380, y);
doc.text(fmt(invoice.tax_amount), 500, y, { align: 'right', width: 62 });
y += 16;
}
doc.font('Helvetica-Bold').fontSize(12).text('Total:', 380, y);
doc.text(fmt(invoice.total), 490, y, { align: 'right', width: 72 });
y += 20;
// Payments
if (payments.length > 0) {
doc.fontSize(10).font('Helvetica');
doc.moveTo(350, y).lineTo(562, y).stroke();
y += 8;
for (const p of payments) {
doc.text(`Payment ${new Date(p.payment_date).toLocaleDateString()} (${p.method || 'N/A'}):`, 370, y);
doc.text(`-${fmt(p.amount)}`, 500, y, { align: 'right', width: 62 });
y += 14;
}
const totalPaid = payments.reduce((s: number, p: any) => s + p.amount, 0);
const amountDue = invoice.total - totalPaid;
y += 4;
doc.font('Helvetica-Bold').fontSize(12).text('Amount Due:', 370, y);
doc.text(fmt(amountDue), 490, y, { align: 'right', width: 72 });
}
// Customer-facing job notes
const customerNotes = data.customerNotes || [];
if (customerNotes.length > 0) {
y += 30;
if (y > 660) { doc.addPage(); y = 50; }
doc.moveTo(50, y).lineTo(562, y).stroke();
y += 10;
doc.fontSize(11).font('Helvetica-Bold').text('Work Performed', 50, y);
y += 16;
doc.fontSize(10).font('Helvetica');
for (const note of customerNotes) {
if (y > 700) { doc.addPage(); y = 50; }
doc.text(note.note, 50, y, { width: 512 });
y += doc.heightOfString(note.note, { width: 512 }) + 6;
}
}
// Invoice notes
if (invoice.notes) {
y += 20;
if (y > 700) { doc.addPage(); y = 50; }
doc.fontSize(10).font('Helvetica-Bold').text('Notes:', 50, y); y += 14;
doc.font('Helvetica').text(invoice.notes, 50, y, { width: 512 });
y += doc.heightOfString(invoice.notes, { width: 512 }) + 4;
}
// Recommendations
const recs = data.recommendations || [];
if (recs.length > 0) {
y += 20;
if (y > 660) { doc.addPage(); y = 50; }
doc.moveTo(50, y).lineTo(562, y).stroke();
y += 10;
doc.fontSize(11).font('Helvetica-Bold').text('Recommended Services', 50, y);
y += 16;
doc.fontSize(9).font('Helvetica').text(
'During our inspection we noticed the following items that may need attention:',
50, y, { width: 512 }
);
y += 16;
doc.fontSize(10);
for (const rec of recs) {
if (y > 720) { doc.addPage(); y = 50; }
const isInfoOnly = rec.status === 'info_only';
const urgencyLabel = isInfoOnly ? 'FYI' : rec.urgency === 'now' ? 'URGENT' : rec.urgency === 'soon' ? 'Soon' : 'Monitor';
const urgencyColor = isInfoOnly ? '#6b7280' : rec.urgency === 'now' ? '#dc2626' : rec.urgency === 'soon' ? '#ca8a04' : '#2563eb';
doc.font('Helvetica-Bold').fillColor(urgencyColor).text(`[${urgencyLabel}]`, 60, y, { continued: true });
doc.fillColor('#000000').font('Helvetica').text(` ${rec.description}`, { continued: false });
y += 14;
if (rec.estimated_cost) {
doc.fontSize(9).text(`Estimated cost: ${fmt(rec.estimated_cost)}`, 70, y);
y += 12;
}
if (rec.notes) {
doc.fontSize(9).text(rec.notes, 70, y, { width: 480 });
y += doc.heightOfString(rec.notes, { width: 480 }) + 4;
}
y += 2;
}
}
doc.end();
}

249
server/src/types.ts Normal file
View File

@@ -0,0 +1,249 @@
export interface User {
id: number;
username: string;
password_hash: string;
display_name: string | null;
created_at: string;
updated_at: string;
}
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;
}
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;
}
export type InventoryStatus = 'stock' | 'in_progress' | 'listed' | 'sold';
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;
}
export type JobStatus = 'received' | 'in_progress' | 'completed' | 'invoiced' | 'closed';
export type CustomerType = 'owner' | 'shop' | 'internal';
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;
}
export type LaborBillingType = 'hourly' | 'flat_rate';
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;
sort_order: number;
created_at: string;
}
export interface JobNote {
id: number;
job_id: number;
note: string;
created_at: string;
}
export type InvoiceStatus = 'draft' | 'sent' | 'paid' | 'partial' | 'overdue';
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;
}
export interface InvoiceJob {
id: number;
invoice_id: number;
job_id: number;
}
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 type EstimateStatus = 'draft' | 'sent' | 'approved' | 'declined' | 'expired' | 'converted';
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;
approval_token: string | null;
created_at: string;
updated_at: string;
}
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 CannedService {
id: number;
name: string;
description: string | null;
category: string | null;
is_active: number;
created_at: string;
updated_at: string;
}
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 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;
created_at: 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;
}

17
server/tsconfig.json Normal file
View File

@@ -0,0 +1,17 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "commonjs",
"lib": ["ES2022"],
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"resolveJsonModule": true,
"declaration": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}