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:
19
.claude/launch.json
Normal file
19
.claude/launch.json
Normal 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
3
.env.example
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
JWT_SECRET=change-me-in-production
|
||||||
|
PORT=3001
|
||||||
|
DATA_DIR=./data
|
||||||
6
.gitignore
vendored
Normal file
6
.gitignore
vendored
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
node_modules/
|
||||||
|
dist/
|
||||||
|
*.db
|
||||||
|
.env
|
||||||
|
.DS_Store
|
||||||
|
data/
|
||||||
179
CLAUDE.md
Normal file
179
CLAUDE.md
Normal 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
28
Dockerfile
Normal 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
24
client/.gitignore
vendored
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
|
||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
dist-ssr
|
||||||
|
*.local
|
||||||
|
|
||||||
|
# Editor directories and files
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/extensions.json
|
||||||
|
.idea
|
||||||
|
.DS_Store
|
||||||
|
*.suo
|
||||||
|
*.ntvs*
|
||||||
|
*.njsproj
|
||||||
|
*.sln
|
||||||
|
*.sw?
|
||||||
73
client/README.md
Normal file
73
client/README.md
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
# React + TypeScript + Vite
|
||||||
|
|
||||||
|
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
|
||||||
|
|
||||||
|
Currently, two official plugins are available:
|
||||||
|
|
||||||
|
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs)
|
||||||
|
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/)
|
||||||
|
|
||||||
|
## React Compiler
|
||||||
|
|
||||||
|
The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation).
|
||||||
|
|
||||||
|
## Expanding the ESLint configuration
|
||||||
|
|
||||||
|
If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules:
|
||||||
|
|
||||||
|
```js
|
||||||
|
export default defineConfig([
|
||||||
|
globalIgnores(['dist']),
|
||||||
|
{
|
||||||
|
files: ['**/*.{ts,tsx}'],
|
||||||
|
extends: [
|
||||||
|
// Other configs...
|
||||||
|
|
||||||
|
// Remove tseslint.configs.recommended and replace with this
|
||||||
|
tseslint.configs.recommendedTypeChecked,
|
||||||
|
// Alternatively, use this for stricter rules
|
||||||
|
tseslint.configs.strictTypeChecked,
|
||||||
|
// Optionally, add this for stylistic rules
|
||||||
|
tseslint.configs.stylisticTypeChecked,
|
||||||
|
|
||||||
|
// Other configs...
|
||||||
|
],
|
||||||
|
languageOptions: {
|
||||||
|
parserOptions: {
|
||||||
|
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||||
|
tsconfigRootDir: import.meta.dirname,
|
||||||
|
},
|
||||||
|
// other options...
|
||||||
|
},
|
||||||
|
},
|
||||||
|
])
|
||||||
|
```
|
||||||
|
|
||||||
|
You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules:
|
||||||
|
|
||||||
|
```js
|
||||||
|
// eslint.config.js
|
||||||
|
import reactX from 'eslint-plugin-react-x'
|
||||||
|
import reactDom from 'eslint-plugin-react-dom'
|
||||||
|
|
||||||
|
export default defineConfig([
|
||||||
|
globalIgnores(['dist']),
|
||||||
|
{
|
||||||
|
files: ['**/*.{ts,tsx}'],
|
||||||
|
extends: [
|
||||||
|
// Other configs...
|
||||||
|
// Enable lint rules for React
|
||||||
|
reactX.configs['recommended-typescript'],
|
||||||
|
// Enable lint rules for React DOM
|
||||||
|
reactDom.configs.recommended,
|
||||||
|
],
|
||||||
|
languageOptions: {
|
||||||
|
parserOptions: {
|
||||||
|
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||||
|
tsconfigRootDir: import.meta.dirname,
|
||||||
|
},
|
||||||
|
// other options...
|
||||||
|
},
|
||||||
|
},
|
||||||
|
])
|
||||||
|
```
|
||||||
23
client/eslint.config.js
Normal file
23
client/eslint.config.js
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import js from '@eslint/js'
|
||||||
|
import globals from 'globals'
|
||||||
|
import reactHooks from 'eslint-plugin-react-hooks'
|
||||||
|
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||||
|
import tseslint from 'typescript-eslint'
|
||||||
|
import { defineConfig, globalIgnores } from 'eslint/config'
|
||||||
|
|
||||||
|
export default defineConfig([
|
||||||
|
globalIgnores(['dist']),
|
||||||
|
{
|
||||||
|
files: ['**/*.{ts,tsx}'],
|
||||||
|
extends: [
|
||||||
|
js.configs.recommended,
|
||||||
|
tseslint.configs.recommended,
|
||||||
|
reactHooks.configs.flat.recommended,
|
||||||
|
reactRefresh.configs.vite,
|
||||||
|
],
|
||||||
|
languageOptions: {
|
||||||
|
ecmaVersion: 2020,
|
||||||
|
globals: globals.browser,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
])
|
||||||
13
client/index.html
Normal file
13
client/index.html
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>client</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
3367
client/package-lock.json
generated
Normal file
3367
client/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
37
client/package.json
Normal file
37
client/package.json
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
{
|
||||||
|
"name": "client",
|
||||||
|
"private": true,
|
||||||
|
"version": "0.0.0",
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "tsc -b && vite build",
|
||||||
|
"lint": "eslint .",
|
||||||
|
"preview": "vite preview"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@tailwindcss/vite": "^4.2.2",
|
||||||
|
"@tanstack/react-query": "^5.96.2",
|
||||||
|
"date-fns": "^4.1.0",
|
||||||
|
"lucide-react": "^1.7.0",
|
||||||
|
"react": "^19.2.4",
|
||||||
|
"react-dom": "^19.2.4",
|
||||||
|
"react-router-dom": "^7.14.0",
|
||||||
|
"tailwindcss": "^4.2.2",
|
||||||
|
"zustand": "^5.0.12"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@eslint/js": "^9.39.4",
|
||||||
|
"@types/node": "^24.12.0",
|
||||||
|
"@types/react": "^19.2.14",
|
||||||
|
"@types/react-dom": "^19.2.3",
|
||||||
|
"@vitejs/plugin-react": "^6.0.1",
|
||||||
|
"eslint": "^9.39.4",
|
||||||
|
"eslint-plugin-react-hooks": "^7.0.1",
|
||||||
|
"eslint-plugin-react-refresh": "^0.5.2",
|
||||||
|
"globals": "^17.4.0",
|
||||||
|
"typescript": "~5.9.3",
|
||||||
|
"typescript-eslint": "^8.57.0",
|
||||||
|
"vite": "^8.0.1"
|
||||||
|
}
|
||||||
|
}
|
||||||
1
client/public/favicon.svg
Normal file
1
client/public/favicon.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 9.3 KiB |
24
client/public/icons.svg
Normal file
24
client/public/icons.svg
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<symbol id="bluesky-icon" viewBox="0 0 16 17">
|
||||||
|
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
|
||||||
|
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="discord-icon" viewBox="0 0 20 19">
|
||||||
|
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="documentation-icon" viewBox="0 0 21 20">
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="github-icon" viewBox="0 0 19 19">
|
||||||
|
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="social-icon" viewBox="0 0 20 20">
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="x-icon" viewBox="0 0 19 19">
|
||||||
|
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
|
||||||
|
</symbol>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 4.9 KiB |
74
client/src/App.tsx
Normal file
74
client/src/App.tsx
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
import { useEffect } from 'react';
|
||||||
|
import { BrowserRouter, Routes, Route, Navigate } from 'react-router-dom';
|
||||||
|
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||||
|
import { useStore } from './store';
|
||||||
|
import { api } from './api/client';
|
||||||
|
import Layout from './components/Layout';
|
||||||
|
import Login from './pages/Login';
|
||||||
|
import Dashboard from './pages/Dashboard';
|
||||||
|
import Shops from './pages/Shops';
|
||||||
|
import ShopDetail from './pages/ShopDetail';
|
||||||
|
import Owners from './pages/Owners';
|
||||||
|
import Vehicles from './pages/Vehicles';
|
||||||
|
import Jobs from './pages/Jobs';
|
||||||
|
import JobDetail from './pages/JobDetail';
|
||||||
|
import Invoices from './pages/Invoices';
|
||||||
|
import InvoiceDetail from './pages/InvoiceDetail';
|
||||||
|
import VehicleDetail from './pages/VehicleDetail';
|
||||||
|
import OwnerDetail from './pages/OwnerDetail';
|
||||||
|
import Estimates from './pages/Estimates';
|
||||||
|
import EstimateDetail from './pages/EstimateDetail';
|
||||||
|
import Technicians from './pages/Technicians';
|
||||||
|
import Appointments from './pages/Appointments';
|
||||||
|
import InventoryPage from './pages/Inventory';
|
||||||
|
import Reports from './pages/Reports';
|
||||||
|
import Settings from './pages/Settings';
|
||||||
|
|
||||||
|
const queryClient = new QueryClient({
|
||||||
|
defaultOptions: { queries: { staleTime: 30000, retry: 1 } },
|
||||||
|
});
|
||||||
|
|
||||||
|
function AuthGuard({ children }: { children: React.ReactNode }) {
|
||||||
|
const { token, setUser } = useStore();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (token) {
|
||||||
|
api.me().then(setUser).catch(() => useStore.getState().logout());
|
||||||
|
}
|
||||||
|
}, [token, setUser]);
|
||||||
|
|
||||||
|
if (!token) return <Navigate to="/login" replace />;
|
||||||
|
return <>{children}</>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function App() {
|
||||||
|
return (
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<BrowserRouter>
|
||||||
|
<Routes>
|
||||||
|
<Route path="/login" element={<Login />} />
|
||||||
|
<Route element={<AuthGuard><Layout /></AuthGuard>}>
|
||||||
|
<Route path="/" element={<Dashboard />} />
|
||||||
|
<Route path="/shops" element={<Shops />} />
|
||||||
|
<Route path="/shops/:id" element={<ShopDetail />} />
|
||||||
|
<Route path="/owners" element={<Owners />} />
|
||||||
|
<Route path="/owners/:id" element={<OwnerDetail />} />
|
||||||
|
<Route path="/vehicles" element={<Vehicles />} />
|
||||||
|
<Route path="/vehicles/:id" element={<VehicleDetail />} />
|
||||||
|
<Route path="/jobs" element={<Jobs />} />
|
||||||
|
<Route path="/jobs/:id" element={<JobDetail />} />
|
||||||
|
<Route path="/estimates" element={<Estimates />} />
|
||||||
|
<Route path="/estimates/:id" element={<EstimateDetail />} />
|
||||||
|
<Route path="/invoices" element={<Invoices />} />
|
||||||
|
<Route path="/invoices/:id" element={<InvoiceDetail />} />
|
||||||
|
<Route path="/technicians" element={<Technicians />} />
|
||||||
|
<Route path="/appointments" element={<Appointments />} />
|
||||||
|
<Route path="/inventory" element={<InventoryPage />} />
|
||||||
|
<Route path="/reports" element={<Reports />} />
|
||||||
|
<Route path="/settings" element={<Settings />} />
|
||||||
|
</Route>
|
||||||
|
</Routes>
|
||||||
|
</BrowserRouter>
|
||||||
|
</QueryClientProvider>
|
||||||
|
);
|
||||||
|
}
|
||||||
353
client/src/api/client.ts
Normal file
353
client/src/api/client.ts
Normal file
@@ -0,0 +1,353 @@
|
|||||||
|
const API_BASE = '/api';
|
||||||
|
|
||||||
|
async function apiFetch<T>(url: string, options?: RequestInit): Promise<T> {
|
||||||
|
const token = localStorage.getItem('autoking_token');
|
||||||
|
const res = await fetch(`${API_BASE}${url}`, {
|
||||||
|
...options,
|
||||||
|
headers: {
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
...(token && { Authorization: `Bearer ${token}` }),
|
||||||
|
...options?.headers,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.status === 401) {
|
||||||
|
localStorage.removeItem('autoking_token');
|
||||||
|
window.location.href = '/login';
|
||||||
|
throw new Error('Unauthorized');
|
||||||
|
}
|
||||||
|
if (!res.ok) {
|
||||||
|
const err = await res.json().catch(() => ({ error: res.statusText }));
|
||||||
|
throw new Error(err.error || res.statusText);
|
||||||
|
}
|
||||||
|
if (res.status === 204) return undefined as T;
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export const api = {
|
||||||
|
// Auth
|
||||||
|
setupRequired: () => apiFetch<{ setupRequired: boolean }>('/auth/setup-required'),
|
||||||
|
register: (data: { username: string; password: string; displayName?: string }) =>
|
||||||
|
apiFetch<{ token: string; user: any }>('/auth/register', { method: 'POST', body: JSON.stringify(data) }),
|
||||||
|
login: (data: { username: string; password: string }) =>
|
||||||
|
apiFetch<{ token: string; user: any }>('/auth/login', { method: 'POST', body: JSON.stringify(data) }),
|
||||||
|
me: () => apiFetch<any>('/auth/me'),
|
||||||
|
|
||||||
|
// Shops
|
||||||
|
getShops: (params?: { search?: string; active?: number }) => {
|
||||||
|
const qs = new URLSearchParams();
|
||||||
|
if (params?.search) qs.set('search', params.search);
|
||||||
|
if (params?.active !== undefined) qs.set('active', String(params.active));
|
||||||
|
return apiFetch<any[]>(`/shops?${qs}`);
|
||||||
|
},
|
||||||
|
getShop: (id: number) => apiFetch<any>(`/shops/${id}`),
|
||||||
|
createShop: (data: any) => apiFetch<any>('/shops', { method: 'POST', body: JSON.stringify(data) }),
|
||||||
|
updateShop: (id: number, data: any) => apiFetch<any>(`/shops/${id}`, { method: 'PUT', body: JSON.stringify(data) }),
|
||||||
|
deleteShop: (id: number) => apiFetch<any>(`/shops/${id}`, { method: 'DELETE' }),
|
||||||
|
getShopJobs: (id: number) => apiFetch<any[]>(`/shops/${id}/jobs`),
|
||||||
|
getShopInvoices: (id: number) => apiFetch<any[]>(`/shops/${id}/invoices`),
|
||||||
|
|
||||||
|
// Owners
|
||||||
|
getOwners: (params?: { search?: string }) => {
|
||||||
|
const qs = new URLSearchParams();
|
||||||
|
if (params?.search) qs.set('search', params.search);
|
||||||
|
return apiFetch<any[]>(`/owners?${qs}`);
|
||||||
|
},
|
||||||
|
getOwner: (id: number) => apiFetch<any>(`/owners/${id}`),
|
||||||
|
createOwner: (data: any) => apiFetch<any>('/owners', { method: 'POST', body: JSON.stringify(data) }),
|
||||||
|
updateOwner: (id: number, data: any) => apiFetch<any>(`/owners/${id}`, { method: 'PUT', body: JSON.stringify(data) }),
|
||||||
|
deleteOwner: (id: number) => apiFetch<any>(`/owners/${id}`, { method: 'DELETE' }),
|
||||||
|
|
||||||
|
// Vehicles
|
||||||
|
getVehicles: (params?: { search?: string; ownerId?: number; inventory?: number }) => {
|
||||||
|
const qs = new URLSearchParams();
|
||||||
|
if (params?.search) qs.set('search', params.search);
|
||||||
|
if (params?.ownerId) qs.set('ownerId', String(params.ownerId));
|
||||||
|
if (params?.inventory !== undefined) qs.set('inventory', String(params.inventory));
|
||||||
|
return apiFetch<any[]>(`/vehicles?${qs}`);
|
||||||
|
},
|
||||||
|
getVehicle: (id: number) => apiFetch<any>(`/vehicles/${id}`),
|
||||||
|
createVehicle: (data: any) => apiFetch<any>('/vehicles', { method: 'POST', body: JSON.stringify(data) }),
|
||||||
|
updateVehicle: (id: number, data: any) => apiFetch<any>(`/vehicles/${id}`, { method: 'PUT', body: JSON.stringify(data) }),
|
||||||
|
deleteVehicle: (id: number) => apiFetch<any>(`/vehicles/${id}`, { method: 'DELETE' }),
|
||||||
|
getVehicleHistory: (id: number) => apiFetch<any>(`/vehicles/${id}/history`),
|
||||||
|
|
||||||
|
// Jobs
|
||||||
|
getJobs: (params?: { status?: string; shopId?: number; search?: string }) => {
|
||||||
|
const qs = new URLSearchParams();
|
||||||
|
if (params?.status) qs.set('status', params.status);
|
||||||
|
if (params?.shopId) qs.set('shopId', String(params.shopId));
|
||||||
|
if (params?.search) qs.set('search', params.search);
|
||||||
|
return apiFetch<any[]>(`/jobs?${qs}`);
|
||||||
|
},
|
||||||
|
getJob: (id: number) => apiFetch<any>(`/jobs/${id}`),
|
||||||
|
createJob: (data: any) => apiFetch<any>('/jobs', { method: 'POST', body: JSON.stringify(data) }),
|
||||||
|
updateJob: (id: number, data: any) => apiFetch<any>(`/jobs/${id}`, { method: 'PUT', body: JSON.stringify(data) }),
|
||||||
|
deleteJob: (id: number) => apiFetch<any>(`/jobs/${id}`, { method: 'DELETE' }),
|
||||||
|
|
||||||
|
// Labor items
|
||||||
|
addLabor: (jobId: number, data: any) => apiFetch<any>(`/jobs/${jobId}/labor`, { method: 'POST', body: JSON.stringify(data) }),
|
||||||
|
updateLabor: (jobId: number, id: number, data: any) => apiFetch<any>(`/jobs/${jobId}/labor/${id}`, { method: 'PUT', body: JSON.stringify(data) }),
|
||||||
|
deleteLabor: (jobId: number, id: number) => apiFetch<any>(`/jobs/${jobId}/labor/${id}`, { method: 'DELETE' }),
|
||||||
|
|
||||||
|
// Parts items
|
||||||
|
addParts: (jobId: number, data: any) => apiFetch<any>(`/jobs/${jobId}/parts`, { method: 'POST', body: JSON.stringify(data) }),
|
||||||
|
updateParts: (jobId: number, id: number, data: any) => apiFetch<any>(`/jobs/${jobId}/parts/${id}`, { method: 'PUT', body: JSON.stringify(data) }),
|
||||||
|
deleteParts: (jobId: number, id: number) => apiFetch<any>(`/jobs/${jobId}/parts/${id}`, { method: 'DELETE' }),
|
||||||
|
|
||||||
|
// Job notes
|
||||||
|
addNote: (jobId: number, note: string, visibility: 'internal' | 'customer' = 'internal') => apiFetch<any>(`/jobs/${jobId}/notes`, { method: 'POST', body: JSON.stringify({ note, visibility }) }),
|
||||||
|
updateNote: (jobId: number, id: number, data: { note: string; visibility: string }) => apiFetch<any>(`/jobs/${jobId}/notes/${id}`, { method: 'PUT', body: JSON.stringify(data) }),
|
||||||
|
deleteNote: (jobId: number, id: number) => apiFetch<any>(`/jobs/${jobId}/notes/${id}`, { method: 'DELETE' }),
|
||||||
|
|
||||||
|
// Misc/Sublet items
|
||||||
|
addMisc: (jobId: number, data: any) => apiFetch<any>(`/jobs/${jobId}/misc`, { method: 'POST', body: JSON.stringify(data) }),
|
||||||
|
updateMisc: (jobId: number, id: number, data: any) => apiFetch<any>(`/jobs/${jobId}/misc/${id}`, { method: 'PUT', body: JSON.stringify(data) }),
|
||||||
|
deleteMisc: (jobId: number, id: number) => apiFetch<any>(`/jobs/${jobId}/misc/${id}`, { method: 'DELETE' }),
|
||||||
|
|
||||||
|
// Recommendations
|
||||||
|
addRecommendation: (jobId: number, data: any) => apiFetch<any>(`/jobs/${jobId}/recommendations`, { method: 'POST', body: JSON.stringify(data) }),
|
||||||
|
updateRecommendation: (jobId: number, id: number, data: any) => apiFetch<any>(`/jobs/${jobId}/recommendations/${id}`, { method: 'PUT', body: JSON.stringify(data) }),
|
||||||
|
deleteRecommendation: (jobId: number, id: number) => apiFetch<any>(`/jobs/${jobId}/recommendations/${id}`, { method: 'DELETE' }),
|
||||||
|
createEstimateFromRec: (jobId: number, recId: number) => apiFetch<any>(`/jobs/${jobId}/recommendations/${recId}/estimate`, { method: 'POST' }),
|
||||||
|
|
||||||
|
// One-click invoice from job
|
||||||
|
invoiceJob: (jobId: number) => apiFetch<any>(`/jobs/${jobId}/invoice`, { method: 'POST' }),
|
||||||
|
|
||||||
|
// Invoices
|
||||||
|
getInvoices: (params?: { status?: string; shopId?: number; ownerId?: number }) => {
|
||||||
|
const qs = new URLSearchParams();
|
||||||
|
if (params?.status) qs.set('status', params.status);
|
||||||
|
if (params?.shopId) qs.set('shopId', String(params.shopId));
|
||||||
|
if (params?.ownerId) qs.set('ownerId', String(params.ownerId));
|
||||||
|
return apiFetch<any[]>(`/invoices?${qs}`);
|
||||||
|
},
|
||||||
|
getInvoice: (id: number) => apiFetch<any>(`/invoices/${id}`),
|
||||||
|
createInvoice: (data: { shopId?: number; ownerId?: number; jobIds: number[]; notes?: string }) =>
|
||||||
|
apiFetch<any>('/invoices', { method: 'POST', body: JSON.stringify(data) }),
|
||||||
|
updateInvoice: (id: number, data: any) => apiFetch<any>(`/invoices/${id}`, { method: 'PUT', body: JSON.stringify(data) }),
|
||||||
|
deleteInvoice: (id: number) => apiFetch<any>(`/invoices/${id}`, { method: 'DELETE' }),
|
||||||
|
getInvoicePdfUrl: (id: number) => {
|
||||||
|
const token = localStorage.getItem('autoking_token');
|
||||||
|
return `${API_BASE}/invoices/${id}/pdf?token=${token}`;
|
||||||
|
},
|
||||||
|
|
||||||
|
// Payments
|
||||||
|
addPayment: (invoiceId: number, data: any) => apiFetch<any>(`/invoices/${invoiceId}/payments`, { method: 'POST', body: JSON.stringify(data) }),
|
||||||
|
deletePayment: (invoiceId: number, id: number) => apiFetch<any>(`/invoices/${invoiceId}/payments/${id}`, { method: 'DELETE' }),
|
||||||
|
|
||||||
|
// Dashboard
|
||||||
|
getDashboardStats: () => apiFetch<any>('/dashboard/stats'),
|
||||||
|
|
||||||
|
// Settings
|
||||||
|
getSettings: () => apiFetch<any>('/settings'),
|
||||||
|
updateSettings: (data: any) => apiFetch<any>('/settings', { method: 'PUT', body: JSON.stringify(data) }),
|
||||||
|
|
||||||
|
// Backup
|
||||||
|
getBackupInfo: () => apiFetch<any>('/backup/info'),
|
||||||
|
downloadBackup: () => {
|
||||||
|
const token = localStorage.getItem('autoking_token');
|
||||||
|
window.location.href = `${API_BASE}/backup/download?token=${token}`;
|
||||||
|
},
|
||||||
|
|
||||||
|
// Tags
|
||||||
|
getTags: () => apiFetch<any[]>('/tags'),
|
||||||
|
createTag: (data: { name: string; color: string }) => apiFetch<any>('/tags', { method: 'POST', body: JSON.stringify(data) }),
|
||||||
|
deleteTag: (id: number) => apiFetch<any>(`/tags/${id}`, { method: 'DELETE' }),
|
||||||
|
addTagToJob: (jobId: number, tagId: number) => apiFetch<any>(`/tags/job/${jobId}`, { method: 'POST', body: JSON.stringify({ tagId }) }),
|
||||||
|
removeTagFromJob: (jobId: number, tagId: number) => apiFetch<any>(`/tags/job/${jobId}/${tagId}`, { method: 'DELETE' }),
|
||||||
|
|
||||||
|
// Estimates
|
||||||
|
getEstimates: (params?: { status?: string; shopId?: number; ownerId?: number }) => {
|
||||||
|
const qs = new URLSearchParams();
|
||||||
|
if (params?.status) qs.set('status', params.status);
|
||||||
|
if (params?.shopId) qs.set('shopId', String(params.shopId));
|
||||||
|
if (params?.ownerId) qs.set('ownerId', String(params.ownerId));
|
||||||
|
return apiFetch<any[]>(`/estimates?${qs}`);
|
||||||
|
},
|
||||||
|
getEstimate: (id: number) => apiFetch<any>(`/estimates/${id}`),
|
||||||
|
createEstimate: (data: any) => apiFetch<any>('/estimates', { method: 'POST', body: JSON.stringify(data) }),
|
||||||
|
updateEstimate: (id: number, data: any) => apiFetch<any>(`/estimates/${id}`, { method: 'PUT', body: JSON.stringify(data) }),
|
||||||
|
deleteEstimate: (id: number) => apiFetch<any>(`/estimates/${id}`, { method: 'DELETE' }),
|
||||||
|
addEstimateItem: (estimateId: number, data: any) => apiFetch<any>(`/estimates/${estimateId}/items`, { method: 'POST', body: JSON.stringify(data) }),
|
||||||
|
updateEstimateItem: (estimateId: number, itemId: number, data: any) => apiFetch<any>(`/estimates/${estimateId}/items/${itemId}`, { method: 'PUT', body: JSON.stringify(data) }),
|
||||||
|
deleteEstimateItem: (estimateId: number, itemId: number) => apiFetch<any>(`/estimates/${estimateId}/items/${itemId}`, { method: 'DELETE' }),
|
||||||
|
convertEstimateToJob: (estimateId: number) => apiFetch<any>(`/estimates/${estimateId}/convert`, { method: 'POST' }),
|
||||||
|
|
||||||
|
// Canned Services
|
||||||
|
getCannedServices: (params?: { active?: number }) => {
|
||||||
|
const qs = new URLSearchParams();
|
||||||
|
if (params?.active !== undefined) qs.set('active', String(params.active));
|
||||||
|
return apiFetch<any[]>(`/canned-services?${qs}`);
|
||||||
|
},
|
||||||
|
getCannedService: (id: number) => apiFetch<any>(`/canned-services/${id}`),
|
||||||
|
createCannedService: (data: any) => apiFetch<any>('/canned-services', { method: 'POST', body: JSON.stringify(data) }),
|
||||||
|
updateCannedService: (id: number, data: any) => apiFetch<any>(`/canned-services/${id}`, { method: 'PUT', body: JSON.stringify(data) }),
|
||||||
|
deleteCannedService: (id: number) => apiFetch<any>(`/canned-services/${id}`, { method: 'DELETE' }),
|
||||||
|
|
||||||
|
// Technicians
|
||||||
|
getTechnicians: (params?: { active?: number }) => {
|
||||||
|
const qs = new URLSearchParams();
|
||||||
|
if (params?.active !== undefined) qs.set('active', String(params.active));
|
||||||
|
return apiFetch<any[]>(`/technicians?${qs}`);
|
||||||
|
},
|
||||||
|
getTechnician: (id: number) => apiFetch<any>(`/technicians/${id}`),
|
||||||
|
createTechnician: (data: any) => apiFetch<any>('/technicians', { method: 'POST', body: JSON.stringify(data) }),
|
||||||
|
updateTechnician: (id: number, data: any) => apiFetch<any>(`/technicians/${id}`, { method: 'PUT', body: JSON.stringify(data) }),
|
||||||
|
deleteTechnician: (id: number) => apiFetch<any>(`/technicians/${id}`, { method: 'DELETE' }),
|
||||||
|
clockIn: (techId: number, jobId: number) => apiFetch<any>(`/technicians/${techId}/clock-in`, { method: 'POST', body: JSON.stringify({ jobId }) }),
|
||||||
|
clockOut: (techId: number) => apiFetch<any>(`/technicians/${techId}/clock-out`, { method: 'POST' }),
|
||||||
|
getTimeEntries: (jobId: number) => apiFetch<any[]>(`/technicians/time-entries/job/${jobId}`),
|
||||||
|
|
||||||
|
// Messages
|
||||||
|
getMessages: (params?: { jobId?: number; ownerId?: number }) => {
|
||||||
|
const qs = new URLSearchParams();
|
||||||
|
if (params?.jobId) qs.set('jobId', String(params.jobId));
|
||||||
|
if (params?.ownerId) qs.set('ownerId', String(params.ownerId));
|
||||||
|
return apiFetch<any[]>(`/messages?${qs}`);
|
||||||
|
},
|
||||||
|
sendMessage: (data: any) => apiFetch<any>('/messages', { method: 'POST', body: JSON.stringify(data) }),
|
||||||
|
deleteMessage: (id: number) => apiFetch<any>(`/messages/${id}`, { method: 'DELETE' }),
|
||||||
|
getMessageTemplates: () => apiFetch<any[]>('/messages/templates'),
|
||||||
|
createMessageTemplate: (data: any) => apiFetch<any>('/messages/templates', { method: 'POST', body: JSON.stringify(data) }),
|
||||||
|
updateMessageTemplate: (id: number, data: any) => apiFetch<any>(`/messages/templates/${id}`, { method: 'PUT', body: JSON.stringify(data) }),
|
||||||
|
deleteMessageTemplate: (id: number) => apiFetch<any>(`/messages/templates/${id}`, { method: 'DELETE' }),
|
||||||
|
getEmailSettings: () => apiFetch<any>('/messages/email-settings'),
|
||||||
|
updateEmailSettings: (data: any) => apiFetch<any>('/messages/email-settings', { method: 'PUT', body: JSON.stringify(data) }),
|
||||||
|
|
||||||
|
// Appointments
|
||||||
|
getAppointments: (params?: { status?: string; date?: string; ownerId?: number }) => {
|
||||||
|
const qs = new URLSearchParams();
|
||||||
|
if (params?.status) qs.set('status', params.status);
|
||||||
|
if (params?.date) qs.set('date', params.date);
|
||||||
|
if (params?.ownerId) qs.set('ownerId', String(params.ownerId));
|
||||||
|
return apiFetch<any[]>(`/appointments?${qs}`);
|
||||||
|
},
|
||||||
|
getAppointment: (id: number) => apiFetch<any>(`/appointments/${id}`),
|
||||||
|
createAppointment: (data: any) => apiFetch<any>('/appointments', { method: 'POST', body: JSON.stringify(data) }),
|
||||||
|
updateAppointment: (id: number, data: any) => apiFetch<any>(`/appointments/${id}`, { method: 'PUT', body: JSON.stringify(data) }),
|
||||||
|
deleteAppointment: (id: number) => apiFetch<any>(`/appointments/${id}`, { method: 'DELETE' }),
|
||||||
|
|
||||||
|
// Deferred Services
|
||||||
|
getDeferredServices: (params?: { status?: string; vehicleId?: number }) => {
|
||||||
|
const qs = new URLSearchParams();
|
||||||
|
if (params?.status) qs.set('status', params.status);
|
||||||
|
if (params?.vehicleId) qs.set('vehicleId', String(params.vehicleId));
|
||||||
|
return apiFetch<any[]>(`/appointments/deferred?${qs}`);
|
||||||
|
},
|
||||||
|
createDeferredService: (data: any) => apiFetch<any>('/appointments/deferred', { method: 'POST', body: JSON.stringify(data) }),
|
||||||
|
updateDeferredService: (id: number, data: any) => apiFetch<any>(`/appointments/deferred/${id}`, { method: 'PUT', body: JSON.stringify(data) }),
|
||||||
|
deleteDeferredService: (id: number) => apiFetch<any>(`/appointments/deferred/${id}`, { method: 'DELETE' }),
|
||||||
|
|
||||||
|
// Inventory
|
||||||
|
getInventoryItems: (params?: { itemType?: string; category?: string; lowStock?: number; search?: string }) => {
|
||||||
|
const qs = new URLSearchParams();
|
||||||
|
if (params?.itemType) qs.set('itemType', params.itemType);
|
||||||
|
if (params?.category) qs.set('category', params.category);
|
||||||
|
if (params?.lowStock) qs.set('lowStock', '1');
|
||||||
|
if (params?.search) qs.set('search', params.search);
|
||||||
|
return apiFetch<any[]>(`/inventory?${qs}`);
|
||||||
|
},
|
||||||
|
getInventoryItem: (id: number) => apiFetch<any>(`/inventory/${id}`),
|
||||||
|
createInventoryItem: (data: any) => apiFetch<any>('/inventory', { method: 'POST', body: JSON.stringify(data) }),
|
||||||
|
updateInventoryItem: (id: number, data: any) => apiFetch<any>(`/inventory/${id}`, { method: 'PUT', body: JSON.stringify(data) }),
|
||||||
|
deleteInventoryItem: (id: number) => apiFetch<any>(`/inventory/${id}`, { method: 'DELETE' }),
|
||||||
|
addInventoryTransaction: (itemId: number, data: any) => apiFetch<any>(`/inventory/${itemId}/transactions`, { method: 'POST', body: JSON.stringify(data) }),
|
||||||
|
|
||||||
|
// Purchase Orders
|
||||||
|
getPurchaseOrders: () => apiFetch<any[]>('/inventory/purchase-orders'),
|
||||||
|
getPurchaseOrder: (id: number) => apiFetch<any>(`/inventory/purchase-orders/${id}`),
|
||||||
|
createPurchaseOrder: (data: any) => apiFetch<any>('/inventory/purchase-orders', { method: 'POST', body: JSON.stringify(data) }),
|
||||||
|
updatePurchaseOrder: (id: number, data: any) => apiFetch<any>(`/inventory/purchase-orders/${id}`, { method: 'PUT', body: JSON.stringify(data) }),
|
||||||
|
|
||||||
|
// Tools
|
||||||
|
getTools: (params?: { category?: string }) => {
|
||||||
|
const qs = new URLSearchParams();
|
||||||
|
if (params?.category) qs.set('category', params.category);
|
||||||
|
return apiFetch<any[]>(`/inventory/tools?${qs}`);
|
||||||
|
},
|
||||||
|
createTool: (data: any) => apiFetch<any>('/inventory/tools', { method: 'POST', body: JSON.stringify(data) }),
|
||||||
|
updateTool: (id: number, data: any) => apiFetch<any>(`/inventory/tools/${id}`, { method: 'PUT', body: JSON.stringify(data) }),
|
||||||
|
deleteTool: (id: number) => apiFetch<any>(`/inventory/tools/${id}`, { method: 'DELETE' }),
|
||||||
|
|
||||||
|
// Reports
|
||||||
|
getRevenueReport: (params?: { startDate?: string; endDate?: string }) => {
|
||||||
|
const qs = new URLSearchParams();
|
||||||
|
if (params?.startDate) qs.set('startDate', params.startDate);
|
||||||
|
if (params?.endDate) qs.set('endDate', params.endDate);
|
||||||
|
return apiFetch<any>(`/reports/revenue?${qs}`);
|
||||||
|
},
|
||||||
|
getProfitabilityReport: (params?: { startDate?: string; endDate?: string }) => {
|
||||||
|
const qs = new URLSearchParams();
|
||||||
|
if (params?.startDate) qs.set('startDate', params.startDate);
|
||||||
|
if (params?.endDate) qs.set('endDate', params.endDate);
|
||||||
|
return apiFetch<any>(`/reports/profitability?${qs}`);
|
||||||
|
},
|
||||||
|
getJobsByStatusReport: () => apiFetch<any[]>('/reports/jobs-by-status'),
|
||||||
|
getRevenueByShopReport: (params?: { startDate?: string; endDate?: string }) => {
|
||||||
|
const qs = new URLSearchParams();
|
||||||
|
if (params?.startDate) qs.set('startDate', params.startDate);
|
||||||
|
if (params?.endDate) qs.set('endDate', params.endDate);
|
||||||
|
return apiFetch<any>(`/reports/revenue-by-shop?${qs}`);
|
||||||
|
},
|
||||||
|
getAgingReport: () => apiFetch<any>('/reports/aging'),
|
||||||
|
getTechProductivityReport: (params?: { startDate?: string; endDate?: string }) => {
|
||||||
|
const qs = new URLSearchParams();
|
||||||
|
if (params?.startDate) qs.set('startDate', params.startDate);
|
||||||
|
if (params?.endDate) qs.set('endDate', params.endDate);
|
||||||
|
return apiFetch<any>(`/reports/technician-productivity?${qs}`);
|
||||||
|
},
|
||||||
|
getTopCustomersReport: (params?: { startDate?: string; endDate?: string }) => {
|
||||||
|
const qs = new URLSearchParams();
|
||||||
|
if (params?.startDate) qs.set('startDate', params.startDate);
|
||||||
|
if (params?.endDate) qs.set('endDate', params.endDate);
|
||||||
|
return apiFetch<any>(`/reports/top-customers?${qs}`);
|
||||||
|
},
|
||||||
|
getInventorySummaryReport: () => apiFetch<any>('/reports/inventory-summary'),
|
||||||
|
|
||||||
|
// Fleets
|
||||||
|
getFleets: () => apiFetch<any[]>('/fleets'),
|
||||||
|
getFleet: (id: number) => apiFetch<any>(`/fleets/${id}`),
|
||||||
|
createFleet: (data: any) => apiFetch<any>('/fleets', { method: 'POST', body: JSON.stringify(data) }),
|
||||||
|
updateFleet: (id: number, data: any) => apiFetch<any>(`/fleets/${id}`, { method: 'PUT', body: JSON.stringify(data) }),
|
||||||
|
deleteFleet: (id: number) => apiFetch<any>(`/fleets/${id}`, { method: 'DELETE' }),
|
||||||
|
addFleetVehicle: (fleetId: number, vehicleId: number) => apiFetch<any>(`/fleets/${fleetId}/vehicles`, { method: 'POST', body: JSON.stringify({ vehicleId }) }),
|
||||||
|
removeFleetVehicle: (fleetId: number, vehicleId: number) => apiFetch<any>(`/fleets/${fleetId}/vehicles/${vehicleId}`, { method: 'DELETE' }),
|
||||||
|
|
||||||
|
// Inspections
|
||||||
|
getInspectionTemplates: () => apiFetch<any[]>('/inspections/templates'),
|
||||||
|
createInspectionTemplate: (data: any) => apiFetch<any>('/inspections/templates', { method: 'POST', body: JSON.stringify(data) }),
|
||||||
|
deleteInspectionTemplate: (id: number) => apiFetch<any>(`/inspections/templates/${id}`, { method: 'DELETE' }),
|
||||||
|
getJobInspections: (jobId: number) => apiFetch<any[]>(`/inspections/job/${jobId}`),
|
||||||
|
getInspection: (id: number) => apiFetch<any>(`/inspections/${id}`),
|
||||||
|
createInspection: (data: { jobId?: number; vehicleId?: number; templateId?: number; technicianId?: number }) =>
|
||||||
|
apiFetch<any>('/inspections', { method: 'POST', body: JSON.stringify(data) }),
|
||||||
|
getVehicleInspections: (vehicleId: number) => apiFetch<any[]>(`/inspections/vehicle/${vehicleId}`),
|
||||||
|
getCannedInspectionNotes: () => apiFetch<any[]>('/inspections/canned-notes'),
|
||||||
|
updateInspectionItem: (inspectionId: number, itemId: number, data: { condition?: string; notes?: string }) =>
|
||||||
|
apiFetch<any>(`/inspections/${inspectionId}/items/${itemId}`, { method: 'PUT', body: JSON.stringify(data) }),
|
||||||
|
addInspectionItem: (inspectionId: number, data: { section?: string; label: string }) =>
|
||||||
|
apiFetch<any>(`/inspections/${inspectionId}/items`, { method: 'POST', body: JSON.stringify(data) }),
|
||||||
|
completeInspection: (id: number) => apiFetch<any>(`/inspections/${id}/complete`, { method: 'POST' }),
|
||||||
|
deleteInspection: (id: number) => apiFetch<any>(`/inspections/${id}`, { method: 'DELETE' }),
|
||||||
|
|
||||||
|
// Files
|
||||||
|
getFiles: (entityType: string, entityId: number) => apiFetch<any[]>(`/files/${entityType}/${entityId}`),
|
||||||
|
uploadFiles: async (entityType: string, entityId: number, files: FileList | File[]) => {
|
||||||
|
const token = localStorage.getItem('autoking_token');
|
||||||
|
const formData = new FormData();
|
||||||
|
Array.from(files).forEach((f) => formData.append('files', f));
|
||||||
|
const res = await fetch(`${API_BASE}/files/${entityType}/${entityId}`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { ...(token && { Authorization: `Bearer ${token}` }) },
|
||||||
|
body: formData,
|
||||||
|
});
|
||||||
|
if (!res.ok) throw new Error((await res.json()).error || 'Upload failed');
|
||||||
|
return res.json();
|
||||||
|
},
|
||||||
|
getFileUrl: (id: number) => {
|
||||||
|
const token = localStorage.getItem('autoking_token');
|
||||||
|
return `${API_BASE}/files/download/${id}?token=${token}`;
|
||||||
|
},
|
||||||
|
deleteFile: (id: number) => apiFetch<any>(`/files/${id}`, { method: 'DELETE' }),
|
||||||
|
};
|
||||||
90
client/src/components/FileUpload.tsx
Normal file
90
client/src/components/FileUpload.tsx
Normal file
@@ -0,0 +1,90 @@
|
|||||||
|
import { useRef } from 'react';
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { Upload, Trash2, FileIcon, Image, File } from 'lucide-react';
|
||||||
|
import { api } from '../api/client';
|
||||||
|
import type { FileRecord } from '../types';
|
||||||
|
|
||||||
|
interface FileUploadProps {
|
||||||
|
entityType: string;
|
||||||
|
entityId: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatSize(bytes: number | null): string {
|
||||||
|
if (!bytes) return '';
|
||||||
|
if (bytes < 1024) return `${bytes}B`;
|
||||||
|
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)}KB`;
|
||||||
|
return `${(bytes / (1024 * 1024)).toFixed(1)}MB`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isImage(mimeType: string | null): boolean {
|
||||||
|
return !!mimeType && mimeType.startsWith('image/');
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function FileUpload({ entityType, entityId }: FileUploadProps) {
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const queryKey = ['files', entityType, entityId];
|
||||||
|
|
||||||
|
const { data: files = [] } = useQuery<FileRecord[]>({
|
||||||
|
queryKey,
|
||||||
|
queryFn: () => api.getFiles(entityType, entityId),
|
||||||
|
});
|
||||||
|
|
||||||
|
const uploadMutation = useMutation({
|
||||||
|
mutationFn: (fileList: FileList) => api.uploadFiles(entityType, entityId, fileList),
|
||||||
|
onSuccess: () => queryClient.invalidateQueries({ queryKey }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const deleteMutation = useMutation({
|
||||||
|
mutationFn: (id: number) => api.deleteFile(id),
|
||||||
|
onSuccess: () => queryClient.invalidateQueries({ queryKey }),
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h3 className="text-sm font-medium text-gray-700">Files</h3>
|
||||||
|
<button onClick={() => inputRef.current?.click()}
|
||||||
|
className="inline-flex items-center gap-1.5 px-2.5 py-1.5 text-xs font-medium text-gray-600 bg-white border border-gray-300 rounded-lg hover:bg-gray-50">
|
||||||
|
<Upload size={14} /> Upload
|
||||||
|
</button>
|
||||||
|
<input ref={inputRef} type="file" multiple className="hidden"
|
||||||
|
onChange={(e) => { if (e.target.files?.length) { uploadMutation.mutate(e.target.files); e.target.value = ''; } }} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{uploadMutation.isPending && <p className="text-xs text-blue-600">Uploading...</p>}
|
||||||
|
|
||||||
|
{files.length > 0 && (
|
||||||
|
<div className="grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-4 gap-2">
|
||||||
|
{files.map((file) => (
|
||||||
|
<div key={file.id} className="group relative border border-gray-200 rounded-lg overflow-hidden">
|
||||||
|
{isImage(file.mime_type) ? (
|
||||||
|
<a href={api.getFileUrl(file.id)} target="_blank" rel="noopener noreferrer">
|
||||||
|
<img src={api.getFileUrl(file.id)} alt={file.original_name}
|
||||||
|
className="w-full h-24 object-cover" />
|
||||||
|
</a>
|
||||||
|
) : (
|
||||||
|
<a href={api.getFileUrl(file.id)} target="_blank" rel="noopener noreferrer"
|
||||||
|
className="flex flex-col items-center justify-center h-24 bg-gray-50 hover:bg-gray-100">
|
||||||
|
<File size={24} className="text-gray-400" />
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
<div className="px-2 py-1.5">
|
||||||
|
<p className="text-xs text-gray-700 truncate" title={file.original_name}>{file.original_name}</p>
|
||||||
|
<p className="text-xs text-gray-400">{formatSize(file.size)}</p>
|
||||||
|
</div>
|
||||||
|
<button onClick={() => deleteMutation.mutate(file.id)}
|
||||||
|
className="absolute top-1 right-1 p-1 bg-white/80 rounded text-gray-400 hover:text-red-500 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||||
|
<Trash2 size={12} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{files.length === 0 && !uploadMutation.isPending && (
|
||||||
|
<p className="text-xs text-gray-400">No files attached.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
227
client/src/components/InspectionPanel.tsx
Normal file
227
client/src/components/InspectionPanel.tsx
Normal file
@@ -0,0 +1,227 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { ClipboardCheck, Plus, CheckCircle, Camera, ChevronDown, ChevronRight } from 'lucide-react';
|
||||||
|
import { api } from '../api/client';
|
||||||
|
import { Card, Button, Badge, Modal } from './ui';
|
||||||
|
import FileUpload from './FileUpload';
|
||||||
|
import type { Inspection, InspectionItem, InspectionTemplate, InspectionCondition } from '../types';
|
||||||
|
|
||||||
|
const conditionColors: Record<string, string> = {
|
||||||
|
good: 'bg-green-500',
|
||||||
|
fair: 'bg-yellow-500',
|
||||||
|
poor: 'bg-orange-500',
|
||||||
|
critical: 'bg-red-500',
|
||||||
|
not_inspected: 'bg-gray-300',
|
||||||
|
};
|
||||||
|
|
||||||
|
const conditionLabels: Record<string, string> = {
|
||||||
|
good: 'Good', fair: 'Fair', poor: 'Poor', critical: 'Critical', not_inspected: 'N/A',
|
||||||
|
};
|
||||||
|
|
||||||
|
interface InspectionPanelProps {
|
||||||
|
jobId: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function InspectionPanel({ jobId }: InspectionPanelProps) {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [showStartModal, setShowStartModal] = useState(false);
|
||||||
|
const [activeInspection, setActiveInspection] = useState<number | null>(null);
|
||||||
|
|
||||||
|
const { data: inspections = [] } = useQuery<Inspection[]>({
|
||||||
|
queryKey: ['inspections', jobId],
|
||||||
|
queryFn: () => api.getJobInspections(jobId),
|
||||||
|
});
|
||||||
|
|
||||||
|
const invalidate = () => queryClient.invalidateQueries({ queryKey: ['inspections', jobId] });
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<div className="p-4 border-b border-gray-200 flex items-center justify-between">
|
||||||
|
<h2 className="font-semibold text-gray-900 flex items-center gap-2">
|
||||||
|
<ClipboardCheck size={18} /> Inspections
|
||||||
|
</h2>
|
||||||
|
<Button variant="ghost" size="sm" onClick={() => setShowStartModal(true)}>
|
||||||
|
<Plus size={14} /> New Inspection
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{inspections.length === 0 ? (
|
||||||
|
<p className="p-4 text-sm text-gray-500">No inspections yet.</p>
|
||||||
|
) : (
|
||||||
|
<div className="divide-y divide-gray-100">
|
||||||
|
{inspections.map((insp) => (
|
||||||
|
<div key={insp.id}>
|
||||||
|
<button
|
||||||
|
onClick={() => setActiveInspection(activeInspection === insp.id ? null : insp.id)}
|
||||||
|
className="w-full flex items-center justify-between p-3 hover:bg-gray-50 text-left"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-gray-900">
|
||||||
|
{insp.template_name || 'Custom Inspection'}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-gray-500">
|
||||||
|
{new Date(insp.created_at).toLocaleDateString()}
|
||||||
|
{insp.technician_name && ` — ${insp.technician_name}`}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Badge color={insp.status === 'completed' ? 'green' : 'yellow'}>
|
||||||
|
{insp.status === 'completed' ? 'Complete' : 'In Progress'}
|
||||||
|
</Badge>
|
||||||
|
{activeInspection === insp.id ? <ChevronDown size={16} /> : <ChevronRight size={16} />}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
{activeInspection === insp.id && (
|
||||||
|
<InspectionDetail inspectionId={insp.id} onUpdate={invalidate} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<StartInspectionModal open={showStartModal} onClose={() => setShowStartModal(false)} jobId={jobId} onDone={invalidate} />
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function InspectionDetail({ inspectionId, onUpdate }: { inspectionId: number; onUpdate: () => void }) {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [expandedItem, setExpandedItem] = useState<number | null>(null);
|
||||||
|
|
||||||
|
const { data: inspection } = useQuery<Inspection>({
|
||||||
|
queryKey: ['inspection', inspectionId],
|
||||||
|
queryFn: () => api.getInspection(inspectionId),
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateItemMutation = useMutation({
|
||||||
|
mutationFn: ({ itemId, data }: { itemId: number; data: any }) =>
|
||||||
|
api.updateInspectionItem(inspectionId, itemId, data),
|
||||||
|
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['inspection', inspectionId] }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const completeMutation = useMutation({
|
||||||
|
mutationFn: () => api.completeInspection(inspectionId),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['inspection', inspectionId] });
|
||||||
|
onUpdate();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!inspection) return <p className="p-3 text-xs text-gray-400">Loading...</p>;
|
||||||
|
|
||||||
|
const items = inspection.items || [];
|
||||||
|
const sections = [...new Set(items.map(i => i.section))];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="px-3 pb-3 space-y-3">
|
||||||
|
{/* Summary bar */}
|
||||||
|
{inspection.summary && (
|
||||||
|
<div className="flex gap-3 text-xs">
|
||||||
|
{Object.entries(inspection.summary).map(([cond, count]) => (
|
||||||
|
<span key={cond} className="flex items-center gap-1">
|
||||||
|
<span className={`w-2.5 h-2.5 rounded-full ${conditionColors[cond]}`} />
|
||||||
|
{conditionLabels[cond]}: {count}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{sections.map(section => (
|
||||||
|
<div key={section}>
|
||||||
|
<h4 className="text-xs font-medium text-gray-500 uppercase mb-1">{section}</h4>
|
||||||
|
<div className="space-y-1">
|
||||||
|
{items.filter(i => i.section === section).map(item => (
|
||||||
|
<div key={item.id} className="bg-gray-50 rounded-lg">
|
||||||
|
<div className="flex items-center justify-between px-3 py-2">
|
||||||
|
<button
|
||||||
|
onClick={() => setExpandedItem(expandedItem === item.id ? null : item.id)}
|
||||||
|
className="flex-1 text-left text-sm text-gray-700"
|
||||||
|
>
|
||||||
|
{item.label}
|
||||||
|
</button>
|
||||||
|
<div className="flex gap-1">
|
||||||
|
{(['good', 'fair', 'poor', 'critical'] as InspectionCondition[]).map(cond => (
|
||||||
|
<button
|
||||||
|
key={cond}
|
||||||
|
onClick={() => updateItemMutation.mutate({ itemId: item.id, data: { condition: cond } })}
|
||||||
|
className={`w-6 h-6 rounded-full border-2 transition-all ${
|
||||||
|
item.condition === cond
|
||||||
|
? `${conditionColors[cond]} border-gray-700 ring-2 ring-offset-1 ring-gray-400`
|
||||||
|
: `${conditionColors[cond]} border-transparent opacity-40 hover:opacity-70`
|
||||||
|
}`}
|
||||||
|
title={conditionLabels[cond]}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{expandedItem === item.id && (
|
||||||
|
<div className="px-3 pb-2 space-y-2">
|
||||||
|
<textarea
|
||||||
|
className="w-full text-xs border border-gray-200 rounded px-2 py-1 resize-none"
|
||||||
|
placeholder="Notes..."
|
||||||
|
rows={2}
|
||||||
|
defaultValue={item.notes || ''}
|
||||||
|
onBlur={(e) => {
|
||||||
|
if (e.target.value !== (item.notes || '')) {
|
||||||
|
updateItemMutation.mutate({ itemId: item.id, data: { notes: e.target.value } });
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<FileUpload entityType="inspection_item" entityId={item.id} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{inspection.status !== 'completed' && (
|
||||||
|
<Button size="sm" onClick={() => completeMutation.mutate()} disabled={completeMutation.isPending}>
|
||||||
|
<CheckCircle size={14} /> {completeMutation.isPending ? 'Completing...' : 'Complete Inspection'}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function StartInspectionModal({ open, onClose, jobId, onDone }: {
|
||||||
|
open: boolean; onClose: () => void; jobId: number; onDone: () => void;
|
||||||
|
}) {
|
||||||
|
const [templateId, setTemplateId] = useState('');
|
||||||
|
|
||||||
|
const { data: templates = [] } = useQuery<InspectionTemplate[]>({
|
||||||
|
queryKey: ['inspection-templates'],
|
||||||
|
queryFn: () => api.getInspectionTemplates(),
|
||||||
|
enabled: open,
|
||||||
|
});
|
||||||
|
|
||||||
|
const createMutation = useMutation({
|
||||||
|
mutationFn: () => api.createInspection({ jobId, templateId: templateId ? Number(templateId) : undefined }),
|
||||||
|
onSuccess: () => { onDone(); onClose(); },
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal open={open} onClose={onClose} title="Start Inspection">
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Template</label>
|
||||||
|
<select className="block w-full rounded-lg border border-gray-300 px-3 py-2 text-sm"
|
||||||
|
value={templateId} onChange={(e) => setTemplateId(e.target.value)}>
|
||||||
|
<option value="">No template (blank)</option>
|
||||||
|
{templates.map(t => (
|
||||||
|
<option key={t.id} value={t.id}>{t.name} ({t.items?.length || 0} items)</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<Button variant="secondary" onClick={onClose}>Cancel</Button>
|
||||||
|
<Button onClick={() => createMutation.mutate()} disabled={createMutation.isPending}>
|
||||||
|
{createMutation.isPending ? 'Starting...' : 'Start Inspection'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
95
client/src/components/KanbanBoard.tsx
Normal file
95
client/src/components/KanbanBoard.tsx
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { api } from '../api/client';
|
||||||
|
import { Badge } from './ui';
|
||||||
|
import type { Job, Tag } from '../types';
|
||||||
|
|
||||||
|
const columns: { status: string; label: string; color: string }[] = [
|
||||||
|
{ status: 'received', label: 'Received', color: 'border-t-blue-400' },
|
||||||
|
{ status: 'in_progress', label: 'In Progress', color: 'border-t-yellow-400' },
|
||||||
|
{ status: 'completed', label: 'Completed', color: 'border-t-green-400' },
|
||||||
|
{ status: 'invoiced', label: 'Invoiced', color: 'border-t-purple-400' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const tagColorClasses: Record<string, string> = {
|
||||||
|
gray: 'bg-gray-100 text-gray-600',
|
||||||
|
red: 'bg-red-100 text-red-600',
|
||||||
|
yellow: 'bg-yellow-100 text-yellow-600',
|
||||||
|
green: 'bg-green-100 text-green-600',
|
||||||
|
blue: 'bg-blue-100 text-blue-600',
|
||||||
|
purple: 'bg-purple-100 text-purple-600',
|
||||||
|
};
|
||||||
|
|
||||||
|
interface KanbanBoardProps {
|
||||||
|
jobs: Job[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function KanbanBoard({ jobs }: KanbanBoardProps) {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const statusMutation = useMutation({
|
||||||
|
mutationFn: ({ id, status }: { id: number; status: string }) => api.updateJob(id, { status }),
|
||||||
|
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['jobs'] }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleDrop = (e: React.DragEvent, status: string) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const jobId = parseInt(e.dataTransfer.getData('jobId'));
|
||||||
|
if (jobId) statusMutation.mutate({ id: jobId, status });
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex gap-3 overflow-x-auto pb-4" style={{ minHeight: '60vh' }}>
|
||||||
|
{columns.map((col) => {
|
||||||
|
const colJobs = jobs.filter((j) => j.status === col.status);
|
||||||
|
return (
|
||||||
|
<div key={col.status}
|
||||||
|
className={`flex-shrink-0 w-72 bg-gray-50 rounded-lg border-t-4 ${col.color}`}
|
||||||
|
onDragOver={(e) => e.preventDefault()}
|
||||||
|
onDrop={(e) => handleDrop(e, col.status)}
|
||||||
|
>
|
||||||
|
<div className="px-3 py-2.5 flex items-center justify-between">
|
||||||
|
<h3 className="text-sm font-semibold text-gray-700">{col.label}</h3>
|
||||||
|
<span className="text-xs bg-gray-200 text-gray-600 px-1.5 py-0.5 rounded-full">{colJobs.length}</span>
|
||||||
|
</div>
|
||||||
|
<div className="px-2 pb-2 space-y-2">
|
||||||
|
{colJobs.map((job) => (
|
||||||
|
<div key={job.id} draggable
|
||||||
|
onDragStart={(e) => e.dataTransfer.setData('jobId', String(job.id))}
|
||||||
|
className="bg-white rounded-lg border border-gray-200 p-3 shadow-sm hover:shadow-md transition-shadow cursor-grab active:cursor-grabbing"
|
||||||
|
>
|
||||||
|
<Link to={`/jobs/${job.id}`} className="block">
|
||||||
|
<div className="flex items-center justify-between mb-1">
|
||||||
|
<span className="font-mono text-xs font-semibold text-gray-900">{job.job_number}</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-gray-700 leading-tight">
|
||||||
|
{[job.year, job.make, job.model].filter(Boolean).join(' ')}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-gray-500 mt-0.5">
|
||||||
|
{job.first_name && job.last_name ? `${job.first_name} ${job.last_name}` : ''}
|
||||||
|
{job.shop_name ? (job.first_name ? ` — ${job.shop_name}` : job.shop_name) : ''}
|
||||||
|
</p>
|
||||||
|
{job.description && <p className="text-xs text-gray-400 mt-1 line-clamp-2">{job.description}</p>}
|
||||||
|
</Link>
|
||||||
|
{job.tags && job.tags.length > 0 && (
|
||||||
|
<div className="flex flex-wrap gap-1 mt-2">
|
||||||
|
{job.tags.map((tag: Tag) => (
|
||||||
|
<span key={tag.id} className={`px-1.5 py-0.5 rounded text-[10px] font-medium ${tagColorClasses[tag.color] || tagColorClasses.gray}`}>
|
||||||
|
{tag.name}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{colJobs.length === 0 && (
|
||||||
|
<div className="text-center py-8 text-xs text-gray-400">
|
||||||
|
Drop jobs here
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
125
client/src/components/Layout.tsx
Normal file
125
client/src/components/Layout.tsx
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
import { Link, useLocation, Outlet } from 'react-router-dom';
|
||||||
|
import { LayoutDashboard, Wrench, Building2, FileText, Settings, Menu, X, Users, Car, LogOut, ClipboardList, HardHat, Calendar, Package, BarChart3 } from 'lucide-react';
|
||||||
|
import { useStore } from '../store';
|
||||||
|
|
||||||
|
const navItems = [
|
||||||
|
{ to: '/', icon: LayoutDashboard, label: 'Dashboard' },
|
||||||
|
{ to: '/jobs', icon: Wrench, label: 'Jobs' },
|
||||||
|
{ to: '/estimates', icon: ClipboardList, label: 'Estimates' },
|
||||||
|
{ to: '/shops', icon: Building2, label: 'Shops' },
|
||||||
|
{ to: '/owners', icon: Users, label: 'Owners' },
|
||||||
|
{ to: '/vehicles', icon: Car, label: 'Vehicles' },
|
||||||
|
{ to: '/invoices', icon: FileText, label: 'Invoices' },
|
||||||
|
{ to: '/technicians', icon: HardHat, label: 'Technicians' },
|
||||||
|
{ to: '/appointments', icon: Calendar, label: 'Appointments' },
|
||||||
|
{ to: '/inventory', icon: Package, label: 'Inventory' },
|
||||||
|
{ to: '/reports', icon: BarChart3, label: 'Reports' },
|
||||||
|
{ to: '/settings', icon: Settings, label: 'Settings' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function Layout() {
|
||||||
|
const location = useLocation();
|
||||||
|
const { sidebarOpen, toggleSidebar, logout, user } = useStore();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-gray-50 flex">
|
||||||
|
{/* Desktop Sidebar */}
|
||||||
|
<aside className="hidden md:flex flex-col w-60 bg-white border-r border-gray-200 fixed h-full">
|
||||||
|
<div className="p-4 border-b border-gray-200">
|
||||||
|
<h1 className="text-xl font-bold text-gray-900">Shop Manager</h1>
|
||||||
|
{user && <p className="text-sm text-gray-500 mt-0.5">{user.displayName || user.username}</p>}
|
||||||
|
</div>
|
||||||
|
<nav className="flex-1 p-3 space-y-1">
|
||||||
|
{navItems.map(({ to, icon: Icon, label }) => {
|
||||||
|
const active = to === '/' ? location.pathname === '/' : location.pathname.startsWith(to);
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={to}
|
||||||
|
to={to}
|
||||||
|
className={`flex items-center gap-3 px-3 py-2 rounded-lg text-sm font-medium transition-colors ${
|
||||||
|
active
|
||||||
|
? 'bg-blue-50 text-blue-700'
|
||||||
|
: 'text-gray-600 hover:bg-gray-100 hover:text-gray-900'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Icon size={18} />
|
||||||
|
{label}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</nav>
|
||||||
|
<button
|
||||||
|
onClick={() => { logout(); window.location.href = '/login'; }}
|
||||||
|
className="flex items-center gap-3 px-6 py-3 text-sm text-gray-500 hover:text-gray-700 border-t border-gray-200"
|
||||||
|
>
|
||||||
|
<LogOut size={18} />
|
||||||
|
Sign out
|
||||||
|
</button>
|
||||||
|
</aside>
|
||||||
|
|
||||||
|
{/* Mobile Header */}
|
||||||
|
<div className="md:hidden fixed top-0 left-0 right-0 bg-white border-b border-gray-200 z-30 flex items-center justify-between px-4 h-14">
|
||||||
|
<h1 className="text-lg font-bold text-gray-900">Shop Manager</h1>
|
||||||
|
<button onClick={toggleSidebar} className="p-2 text-gray-600">
|
||||||
|
{sidebarOpen ? <X size={22} /> : <Menu size={22} />}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Mobile Sidebar Overlay */}
|
||||||
|
{sidebarOpen && (
|
||||||
|
<div className="md:hidden fixed inset-0 z-40">
|
||||||
|
<div className="absolute inset-0 bg-black/30" onClick={toggleSidebar} />
|
||||||
|
<aside className="absolute left-0 top-0 bottom-0 w-64 bg-white shadow-xl">
|
||||||
|
<div className="p-4 border-b border-gray-200">
|
||||||
|
<h1 className="text-xl font-bold text-gray-900">Shop Manager</h1>
|
||||||
|
</div>
|
||||||
|
<nav className="p-3 space-y-1">
|
||||||
|
{navItems.map(({ to, icon: Icon, label }) => {
|
||||||
|
const active = to === '/' ? location.pathname === '/' : location.pathname.startsWith(to);
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={to}
|
||||||
|
to={to}
|
||||||
|
onClick={toggleSidebar}
|
||||||
|
className={`flex items-center gap-3 px-3 py-2.5 rounded-lg text-sm font-medium ${
|
||||||
|
active ? 'bg-blue-50 text-blue-700' : 'text-gray-600 hover:bg-gray-100'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Icon size={18} />
|
||||||
|
{label}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</nav>
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Main Content */}
|
||||||
|
<main className="flex-1 md:ml-60 pt-14 md:pt-0">
|
||||||
|
<div className="p-4 md:p-6 max-w-6xl">
|
||||||
|
<Outlet />
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
{/* Mobile Bottom Nav */}
|
||||||
|
<nav className="md:hidden fixed bottom-0 left-0 right-0 bg-white border-t border-gray-200 z-30 flex">
|
||||||
|
{navItems.slice(0, 5).map(({ to, icon: Icon, label }) => {
|
||||||
|
const active = to === '/' ? location.pathname === '/' : location.pathname.startsWith(to);
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={to}
|
||||||
|
to={to}
|
||||||
|
className={`flex-1 flex flex-col items-center py-2 text-xs ${
|
||||||
|
active ? 'text-blue-600' : 'text-gray-500'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<Icon size={20} />
|
||||||
|
<span className="mt-0.5">{label}</span>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
104
client/src/components/TagPicker.tsx
Normal file
104
client/src/components/TagPicker.tsx
Normal file
@@ -0,0 +1,104 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { X, Plus, Tag as TagIcon } from 'lucide-react';
|
||||||
|
import { api } from '../api/client';
|
||||||
|
import type { Tag } from '../types';
|
||||||
|
|
||||||
|
const TAG_COLORS = ['gray', 'red', 'yellow', 'green', 'blue', 'purple'];
|
||||||
|
|
||||||
|
interface TagPickerProps {
|
||||||
|
jobId: number;
|
||||||
|
tags: Tag[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function TagPicker({ jobId, tags }: TagPickerProps) {
|
||||||
|
const [showPicker, setShowPicker] = useState(false);
|
||||||
|
const [newTagName, setNewTagName] = useState('');
|
||||||
|
const [newTagColor, setNewTagColor] = useState('gray');
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const { data: allTags = [] } = useQuery<Tag[]>({ queryKey: ['tags'], queryFn: api.getTags });
|
||||||
|
|
||||||
|
const invalidate = () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['jobs', String(jobId)] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['jobs'] });
|
||||||
|
};
|
||||||
|
|
||||||
|
const addMutation = useMutation({ mutationFn: (tagId: number) => api.addTagToJob(jobId, tagId), onSuccess: invalidate });
|
||||||
|
const removeMutation = useMutation({ mutationFn: (tagId: number) => api.removeTagFromJob(jobId, tagId), onSuccess: invalidate });
|
||||||
|
const createMutation = useMutation({
|
||||||
|
mutationFn: api.createTag,
|
||||||
|
onSuccess: (tag: Tag) => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['tags'] });
|
||||||
|
addMutation.mutate(tag.id);
|
||||||
|
setNewTagName('');
|
||||||
|
setNewTagColor('gray');
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const availableTags = allTags.filter((t) => !tags.some((jt) => jt.id === t.id));
|
||||||
|
|
||||||
|
const colorClasses: Record<string, string> = {
|
||||||
|
gray: 'bg-gray-100 text-gray-700 border-gray-200',
|
||||||
|
red: 'bg-red-100 text-red-700 border-red-200',
|
||||||
|
yellow: 'bg-yellow-100 text-yellow-700 border-yellow-200',
|
||||||
|
green: 'bg-green-100 text-green-700 border-green-200',
|
||||||
|
blue: 'bg-blue-100 text-blue-700 border-blue-200',
|
||||||
|
purple: 'bg-purple-100 text-purple-700 border-purple-200',
|
||||||
|
};
|
||||||
|
|
||||||
|
const dotClasses: Record<string, string> = {
|
||||||
|
gray: 'bg-gray-400', red: 'bg-red-500', yellow: 'bg-yellow-500', green: 'bg-green-500', blue: 'bg-blue-500', purple: 'bg-purple-500',
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div className="flex flex-wrap gap-1.5 items-center">
|
||||||
|
{tags.map((tag) => (
|
||||||
|
<span key={tag.id} className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium border ${colorClasses[tag.color] || colorClasses.gray}`}>
|
||||||
|
{tag.name}
|
||||||
|
<button onClick={() => removeMutation.mutate(tag.id)} className="hover:opacity-70">
|
||||||
|
<X size={12} />
|
||||||
|
</button>
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
<button onClick={() => setShowPicker(!showPicker)} className="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium border border-dashed border-gray-300 text-gray-500 hover:border-blue-400 hover:text-blue-600">
|
||||||
|
<Plus size={12} /> Tag
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showPicker && (
|
||||||
|
<div className="mt-2 border border-gray-200 rounded-lg p-3 bg-white shadow-sm space-y-2">
|
||||||
|
{availableTags.length > 0 && (
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
{availableTags.map((tag) => (
|
||||||
|
<button key={tag.id} onClick={() => { addMutation.mutate(tag.id); setShowPicker(false); }}
|
||||||
|
className={`inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium border hover:opacity-80 ${colorClasses[tag.color] || colorClasses.gray}`}>
|
||||||
|
<Plus size={10} /> {tag.name}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex gap-2 items-end">
|
||||||
|
<div className="flex-1">
|
||||||
|
<input className="block w-full border border-gray-300 rounded px-2 py-1 text-xs focus:border-blue-500 outline-none"
|
||||||
|
placeholder="New tag name..." value={newTagName} onChange={(e) => setNewTagName(e.target.value)}
|
||||||
|
onKeyDown={(e) => { if (e.key === 'Enter' && newTagName.trim()) { e.preventDefault(); createMutation.mutate({ name: newTagName.trim(), color: newTagColor }); } }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-1">
|
||||||
|
{TAG_COLORS.map((c) => (
|
||||||
|
<button key={c} onClick={() => setNewTagColor(c)}
|
||||||
|
className={`w-5 h-5 rounded-full ${dotClasses[c]} ${newTagColor === c ? 'ring-2 ring-offset-1 ring-blue-400' : ''}`} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<button onClick={() => { if (newTagName.trim()) createMutation.mutate({ name: newTagName.trim(), color: newTagColor }); }}
|
||||||
|
disabled={!newTagName.trim()} className="px-2 py-1 text-xs font-medium bg-blue-600 text-white rounded hover:bg-blue-700 disabled:opacity-50">
|
||||||
|
Add
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
448
client/src/components/VehicleInspectionPanel.tsx
Normal file
448
client/src/components/VehicleInspectionPanel.tsx
Normal file
@@ -0,0 +1,448 @@
|
|||||||
|
import { useState, useRef } from 'react';
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { ClipboardCheck, Plus, CheckCircle, ChevronLeft, ChevronRight, Camera, MessageSquare, X, AlertTriangle, Check, Trash2 } from 'lucide-react';
|
||||||
|
import { api } from '../api/client';
|
||||||
|
import { Card, Button, Badge, Modal } from './ui';
|
||||||
|
import type { InspectionTemplate } from '../types';
|
||||||
|
|
||||||
|
const conditionConfig = [
|
||||||
|
{ value: 'good', label: 'Good', bg: 'bg-green-500 text-white', outline: 'border-green-400 text-green-700 hover:bg-green-50', icon: '✓' },
|
||||||
|
{ value: 'fair', label: 'Attention', bg: 'bg-yellow-500 text-white', outline: 'border-yellow-400 text-yellow-700 hover:bg-yellow-50', icon: '~' },
|
||||||
|
{ value: 'poor', label: 'Needs Work', bg: 'bg-red-500 text-white', outline: 'border-red-400 text-red-700 hover:bg-red-50', icon: '!' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const conditionBorder: Record<string, string> = {
|
||||||
|
good: 'border-green-300 bg-green-50/50', fair: 'border-yellow-300 bg-yellow-50/50',
|
||||||
|
poor: 'border-red-300 bg-red-50/50', critical: 'border-red-500 bg-red-100',
|
||||||
|
not_inspected: 'border-gray-200 bg-white',
|
||||||
|
};
|
||||||
|
|
||||||
|
const conditionDot: Record<string, string> = {
|
||||||
|
good: 'bg-green-500', fair: 'bg-yellow-500', poor: 'bg-red-500', critical: 'bg-red-700', not_inspected: 'bg-gray-300',
|
||||||
|
};
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
vehicleId: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function VehicleInspectionPanel({ vehicleId }: Props) {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [showStartModal, setShowStartModal] = useState(false);
|
||||||
|
const [activeInspectionId, setActiveInspectionId] = useState<number | null>(null);
|
||||||
|
|
||||||
|
const { data: inspections = [] } = useQuery<any[]>({
|
||||||
|
queryKey: ['vehicle-inspections', vehicleId],
|
||||||
|
queryFn: () => api.getVehicleInspections(vehicleId),
|
||||||
|
});
|
||||||
|
|
||||||
|
const invalidate = () => queryClient.invalidateQueries({ queryKey: ['vehicle-inspections', vehicleId] });
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Card>
|
||||||
|
<div className="p-4 border-b border-gray-200 flex items-center justify-between">
|
||||||
|
<h2 className="font-semibold text-gray-900 flex items-center gap-2">
|
||||||
|
<ClipboardCheck size={18} /> Inspections
|
||||||
|
</h2>
|
||||||
|
<Button variant="ghost" size="sm" onClick={() => setShowStartModal(true)}>
|
||||||
|
<Plus size={14} /> New Inspection
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{inspections.length === 0 ? (
|
||||||
|
<div className="p-6 text-center">
|
||||||
|
<ClipboardCheck size={32} className="mx-auto text-gray-300 mb-2" />
|
||||||
|
<p className="text-sm text-gray-500">No inspections yet.</p>
|
||||||
|
<Button size="sm" className="mt-3" onClick={() => setShowStartModal(true)}>Start Inspection</Button>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="divide-y divide-gray-100">
|
||||||
|
{inspections.map((insp: any) => (
|
||||||
|
<button key={insp.id} onClick={() => setActiveInspectionId(insp.id)}
|
||||||
|
className="w-full flex items-center justify-between p-3 hover:bg-gray-50 text-left">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-gray-900">{insp.template_name || 'Inspection'}</p>
|
||||||
|
<p className="text-xs text-gray-500">
|
||||||
|
{new Date(insp.created_at).toLocaleDateString()}
|
||||||
|
{insp.technician_name && ` — ${insp.technician_name}`}
|
||||||
|
{insp.job_number && ` — ${insp.job_number}`}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Badge color={insp.status === 'completed' ? 'green' : 'yellow'}>
|
||||||
|
{insp.status === 'completed' ? 'Complete' : 'In Progress'}
|
||||||
|
</Badge>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<StartModal open={showStartModal} onClose={() => setShowStartModal(false)} vehicleId={vehicleId}
|
||||||
|
onDone={(id) => { invalidate(); setActiveInspectionId(id); }} />
|
||||||
|
|
||||||
|
{activeInspectionId && (
|
||||||
|
<MobileInspection inspectionId={activeInspectionId}
|
||||||
|
onClose={() => { setActiveInspectionId(null); invalidate(); }} />
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function StartModal({ open, onClose, vehicleId, onDone }: {
|
||||||
|
open: boolean; onClose: () => void; vehicleId: number; onDone: (id: number) => void;
|
||||||
|
}) {
|
||||||
|
const [templateId, setTemplateId] = useState('');
|
||||||
|
const { data: templates = [] } = useQuery<InspectionTemplate[]>({
|
||||||
|
queryKey: ['inspection-templates'],
|
||||||
|
queryFn: () => api.getInspectionTemplates(),
|
||||||
|
enabled: open,
|
||||||
|
});
|
||||||
|
|
||||||
|
const createMutation = useMutation({
|
||||||
|
mutationFn: () => api.createInspection({ vehicleId, templateId: templateId ? Number(templateId) : undefined }),
|
||||||
|
onSuccess: (data: any) => { onDone(data.id); onClose(); },
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal open={open} onClose={onClose} title="Start Inspection">
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-2">Choose Template</label>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{templates.map(t => (
|
||||||
|
<button key={t.id} onClick={() => setTemplateId(String(t.id))}
|
||||||
|
className={`w-full text-left p-3 rounded-lg border-2 transition-colors ${
|
||||||
|
templateId === String(t.id) ? 'border-blue-500 bg-blue-50' : 'border-gray-200 hover:border-gray-300'
|
||||||
|
}`}>
|
||||||
|
<p className="text-sm font-medium text-gray-900">{t.name}</p>
|
||||||
|
{(t as any).description && <p className="text-xs text-gray-500 mt-0.5">{(t as any).description}</p>}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
<button onClick={() => setTemplateId('')}
|
||||||
|
className={`w-full text-left p-3 rounded-lg border-2 transition-colors ${
|
||||||
|
templateId === '' ? 'border-blue-500 bg-blue-50' : 'border-gray-200 hover:border-gray-300'
|
||||||
|
}`}>
|
||||||
|
<p className="text-sm font-medium text-gray-900">Blank Inspection</p>
|
||||||
|
<p className="text-xs text-gray-500 mt-0.5">Start from scratch</p>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<Button variant="secondary" onClick={onClose}>Cancel</Button>
|
||||||
|
<Button onClick={() => createMutation.mutate()} disabled={createMutation.isPending}>
|
||||||
|
{createMutation.isPending ? 'Starting...' : 'Start Inspection'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function MobileInspection({ inspectionId, onClose }: { inspectionId: number; onClose: () => void }) {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [currentSection, setCurrentSection] = useState(0);
|
||||||
|
const [expandedItem, setExpandedItem] = useState<number | null>(null);
|
||||||
|
const [showSummary, setShowSummary] = useState(false);
|
||||||
|
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const [photoItemId, setPhotoItemId] = useState<number | null>(null);
|
||||||
|
|
||||||
|
const { data: inspection } = useQuery<any>({
|
||||||
|
queryKey: ['inspection', inspectionId],
|
||||||
|
queryFn: () => api.getInspection(inspectionId),
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: cannedNotes = [] } = useQuery<any[]>({
|
||||||
|
queryKey: ['canned-inspection-notes'],
|
||||||
|
queryFn: () => api.getCannedInspectionNotes(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateItemMutation = useMutation({
|
||||||
|
mutationFn: ({ itemId, data }: { itemId: number; data: any }) =>
|
||||||
|
api.updateInspectionItem(inspectionId, itemId, data),
|
||||||
|
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['inspection', inspectionId] }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const completeMutation = useMutation({
|
||||||
|
mutationFn: () => api.completeInspection(inspectionId),
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['inspection', inspectionId] });
|
||||||
|
onClose();
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const uploadMutation = useMutation({
|
||||||
|
mutationFn: ({ itemId, files }: { itemId: number; files: FileList }) =>
|
||||||
|
api.uploadFiles('inspection_item', itemId, files),
|
||||||
|
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['inspection', inspectionId] }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!inspection) return null;
|
||||||
|
|
||||||
|
const items: any[] = inspection.items || [];
|
||||||
|
const sections = [...new Set(items.map((i: any) => i.section))];
|
||||||
|
const sectionItems = showSummary ? items : items.filter((i: any) => i.section === sections[currentSection]);
|
||||||
|
|
||||||
|
const totalItems = items.length;
|
||||||
|
const inspectedItems = items.filter((i: any) => i.condition !== 'not_inspected').length;
|
||||||
|
const progress = totalItems > 0 ? Math.round((inspectedItems / totalItems) * 100) : 0;
|
||||||
|
|
||||||
|
const redItems = items.filter((i: any) => i.condition === 'poor' || i.condition === 'critical');
|
||||||
|
const yellowItems = items.filter((i: any) => i.condition === 'fair');
|
||||||
|
const greenItems = items.filter((i: any) => i.condition === 'good');
|
||||||
|
const unratedItems = items.filter((i: any) => i.condition === 'not_inspected');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 bg-white flex flex-col">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="bg-gray-900 text-white px-4 py-3 flex items-center justify-between safe-area-top">
|
||||||
|
<button onClick={onClose} className="p-1"><X size={24} /></button>
|
||||||
|
<div className="text-center">
|
||||||
|
<p className="text-sm font-semibold">{inspection.template_name || 'Inspection'}</p>
|
||||||
|
<p className="text-xs text-gray-400">{inspectedItems}/{totalItems} — {progress}%</p>
|
||||||
|
</div>
|
||||||
|
<button onClick={() => setShowSummary(!showSummary)}
|
||||||
|
className={`text-xs px-2 py-1 rounded ${showSummary ? 'bg-white text-gray-900' : 'bg-gray-700 text-white'}`}>
|
||||||
|
{showSummary ? 'Items' : 'Summary'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Progress bar */}
|
||||||
|
<div className="h-1.5 bg-gray-200">
|
||||||
|
<div className="h-full bg-green-500 transition-all duration-300" style={{ width: `${progress}%` }} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{showSummary ? (
|
||||||
|
/* Summary View */
|
||||||
|
<div className="flex-1 overflow-y-auto p-4 space-y-4">
|
||||||
|
{redItems.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-semibold text-red-700 flex items-center gap-1.5 mb-2">
|
||||||
|
<span className="w-3 h-3 rounded-full bg-red-500" /> Needs Work ({redItems.length})
|
||||||
|
</h3>
|
||||||
|
{redItems.map((item: any) => (
|
||||||
|
<div key={item.id} className="bg-red-50 rounded-lg p-3 mb-2 border border-red-200">
|
||||||
|
<p className="text-sm font-medium text-gray-900">{item.label}</p>
|
||||||
|
{item.notes && <p className="text-xs text-gray-600 mt-0.5">{item.notes}</p>}
|
||||||
|
{item.measurement && <p className="text-xs text-gray-500 mt-0.5">Measured: {item.measurement}{item.measurement_unit || ''}</p>}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{yellowItems.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-semibold text-yellow-700 flex items-center gap-1.5 mb-2">
|
||||||
|
<span className="w-3 h-3 rounded-full bg-yellow-500" /> Attention ({yellowItems.length})
|
||||||
|
</h3>
|
||||||
|
{yellowItems.map((item: any) => (
|
||||||
|
<div key={item.id} className="bg-yellow-50 rounded-lg p-3 mb-2 border border-yellow-200">
|
||||||
|
<p className="text-sm font-medium text-gray-900">{item.label}</p>
|
||||||
|
{item.notes && <p className="text-xs text-gray-600 mt-0.5">{item.notes}</p>}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{greenItems.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-semibold text-green-700 flex items-center gap-1.5 mb-2">
|
||||||
|
<span className="w-3 h-3 rounded-full bg-green-500" /> Good ({greenItems.length})
|
||||||
|
</h3>
|
||||||
|
<div className="bg-green-50 rounded-lg p-3 border border-green-200">
|
||||||
|
<p className="text-xs text-gray-600">{greenItems.map((i: any) => i.label).join(', ')}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{unratedItems.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-semibold text-gray-500 flex items-center gap-1.5 mb-2">
|
||||||
|
<span className="w-3 h-3 rounded-full bg-gray-300" /> Not Inspected ({unratedItems.length})
|
||||||
|
</h3>
|
||||||
|
<div className="bg-gray-50 rounded-lg p-3 border border-gray-200">
|
||||||
|
<p className="text-xs text-gray-500">{unratedItems.map((i: any) => i.label).join(', ')}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
{/* Section tabs */}
|
||||||
|
<div className="flex border-b border-gray-200 overflow-x-auto">
|
||||||
|
{sections.map((section, idx) => {
|
||||||
|
const sItems = items.filter((i: any) => i.section === section);
|
||||||
|
const done = sItems.filter((i: any) => i.condition !== 'not_inspected').length;
|
||||||
|
const allDone = done === sItems.length;
|
||||||
|
return (
|
||||||
|
<button key={section} onClick={() => setCurrentSection(idx)}
|
||||||
|
className={`flex-shrink-0 px-4 py-2.5 text-sm font-medium border-b-2 transition-colors ${
|
||||||
|
currentSection === idx ? 'border-blue-500 text-blue-600' : 'border-transparent text-gray-500'
|
||||||
|
}`}>
|
||||||
|
{allDone && <Check size={12} className="inline mr-1 text-green-500" />}
|
||||||
|
{section} <span className="text-xs text-gray-400 ml-1">{done}/{sItems.length}</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Items */}
|
||||||
|
<div className="flex-1 overflow-y-auto">
|
||||||
|
<div className="p-3 space-y-3">
|
||||||
|
{sectionItems.map((item: any) => (
|
||||||
|
<div key={item.id} className={`rounded-xl border-2 p-4 transition-all ${conditionBorder[item.condition] || conditionBorder.not_inspected}`}>
|
||||||
|
<p className="text-sm font-semibold text-gray-900 mb-3">{item.label}</p>
|
||||||
|
|
||||||
|
{/* 3 big condition buttons */}
|
||||||
|
<div className="grid grid-cols-3 gap-2">
|
||||||
|
{conditionConfig.map(c => (
|
||||||
|
<button key={c.value}
|
||||||
|
onClick={() => updateItemMutation.mutate({ itemId: item.id, data: { condition: c.value } })}
|
||||||
|
className={`py-3.5 rounded-xl text-sm font-bold border-2 transition-all ${
|
||||||
|
item.condition === c.value ? c.bg : `bg-white ${c.outline}`
|
||||||
|
}`}>
|
||||||
|
{c.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Critical safety flag — shows when red */}
|
||||||
|
{(item.condition === 'poor' || item.condition === 'critical') && (
|
||||||
|
<button onClick={() => updateItemMutation.mutate({ itemId: item.id, data: { condition: item.condition === 'critical' ? 'poor' : 'critical' } })}
|
||||||
|
className={`mt-2 w-full py-2 rounded-lg text-xs font-bold border-2 transition-all flex items-center justify-center gap-1.5 ${
|
||||||
|
item.condition === 'critical'
|
||||||
|
? 'bg-red-700 border-red-700 text-white'
|
||||||
|
: 'border-red-300 text-red-600 hover:bg-red-50'
|
||||||
|
}`}>
|
||||||
|
<AlertTriangle size={14} />
|
||||||
|
{item.condition === 'critical' ? 'CRITICAL SAFETY ISSUE' : 'Flag as Critical Safety Issue'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Measurement — for brake/tire items */}
|
||||||
|
{item.measurement_type && (
|
||||||
|
<div className="mt-3 flex items-center gap-2">
|
||||||
|
<span className="text-xs text-gray-500">
|
||||||
|
{item.measurement_type === 'brake_mm' ? 'Pad thickness:' : 'Tread depth:'}
|
||||||
|
</span>
|
||||||
|
<input
|
||||||
|
className="w-16 border border-gray-300 rounded-lg px-2 py-1.5 text-sm text-center focus:border-blue-500 outline-none"
|
||||||
|
type="number" step="0.5" min="0"
|
||||||
|
defaultValue={item.measurement || ''}
|
||||||
|
onBlur={(e) => {
|
||||||
|
if (e.target.value !== (item.measurement || '')) {
|
||||||
|
updateItemMutation.mutate({
|
||||||
|
itemId: item.id,
|
||||||
|
data: {
|
||||||
|
measurement: e.target.value || null,
|
||||||
|
measurementUnit: item.measurement_type === 'brake_mm' ? 'mm' : '/32"',
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span className="text-xs text-gray-400">{item.measurement_type === 'brake_mm' ? 'mm' : '/32"'}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Action row */}
|
||||||
|
<div className="flex gap-2 mt-3">
|
||||||
|
<button onClick={() => setExpandedItem(expandedItem === item.id ? null : item.id)}
|
||||||
|
className={`flex items-center gap-1.5 px-3 py-2 rounded-lg text-xs font-medium transition-colors ${
|
||||||
|
item.notes ? 'bg-blue-100 text-blue-700' : 'bg-gray-100 text-gray-600 hover:bg-gray-200'
|
||||||
|
}`}>
|
||||||
|
<MessageSquare size={14} /> {item.notes ? 'Notes' : 'Add Note'}
|
||||||
|
</button>
|
||||||
|
<button onClick={() => { setPhotoItemId(item.id); fileInputRef.current?.click(); }}
|
||||||
|
className="flex items-center gap-1.5 px-3 py-2 rounded-lg text-xs font-medium bg-gray-100 text-gray-600 hover:bg-gray-200">
|
||||||
|
<Camera size={14} /> Photo
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Expanded: notes + canned notes */}
|
||||||
|
{expandedItem === item.id && (
|
||||||
|
<div className="mt-3 space-y-2">
|
||||||
|
{/* Canned notes chips */}
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
{cannedNotes.map((cn: any) => {
|
||||||
|
const isApplied = item.notes?.includes(cn.note_text);
|
||||||
|
return (
|
||||||
|
<button key={cn.id}
|
||||||
|
onClick={() => {
|
||||||
|
const newNotes = isApplied
|
||||||
|
? (item.notes || '').replace(cn.note_text, '').replace(/\n+/g, '\n').trim()
|
||||||
|
: [item.notes, cn.note_text].filter(Boolean).join('\n');
|
||||||
|
updateItemMutation.mutate({ itemId: item.id, data: { notes: newNotes } });
|
||||||
|
}}
|
||||||
|
className={`px-2.5 py-1.5 rounded-lg text-xs font-medium border transition-colors ${
|
||||||
|
isApplied ? 'bg-blue-100 border-blue-300 text-blue-700' : 'bg-white border-gray-200 text-gray-600 hover:bg-gray-50'
|
||||||
|
}`}>
|
||||||
|
{cn.label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
{/* Custom note */}
|
||||||
|
<textarea
|
||||||
|
className="w-full border border-gray-200 rounded-lg px-3 py-2 text-sm resize-none focus:border-blue-500 outline-none"
|
||||||
|
placeholder="Custom notes..."
|
||||||
|
rows={2}
|
||||||
|
defaultValue={item.notes || ''}
|
||||||
|
onBlur={(e) => {
|
||||||
|
if (e.target.value !== (item.notes || '')) {
|
||||||
|
updateItemMutation.mutate({ itemId: item.id, data: { notes: e.target.value } });
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Hidden file input */}
|
||||||
|
<input ref={fileInputRef} type="file" accept="image/*" capture="environment" className="hidden"
|
||||||
|
onChange={(e) => {
|
||||||
|
if (e.target.files?.length && photoItemId) {
|
||||||
|
uploadMutation.mutate({ itemId: photoItemId, files: e.target.files });
|
||||||
|
e.target.value = '';
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Bottom nav */}
|
||||||
|
<div className="border-t border-gray-200 bg-white px-4 py-3 flex items-center justify-between safe-area-bottom">
|
||||||
|
{showSummary ? (
|
||||||
|
<>
|
||||||
|
<button onClick={() => setShowSummary(false)} className="flex items-center gap-1 px-4 py-2.5 rounded-lg text-sm font-medium text-gray-600 hover:bg-gray-100">
|
||||||
|
<ChevronLeft size={18} /> Back to Items
|
||||||
|
</button>
|
||||||
|
<button onClick={() => completeMutation.mutate()} disabled={completeMutation.isPending}
|
||||||
|
className="flex items-center gap-1.5 px-5 py-2.5 rounded-xl text-sm font-bold bg-green-600 text-white hover:bg-green-700 disabled:opacity-50">
|
||||||
|
<CheckCircle size={18} /> {completeMutation.isPending ? 'Saving...' : 'Complete Inspection'}
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<button onClick={() => setCurrentSection(Math.max(0, currentSection - 1))}
|
||||||
|
disabled={currentSection === 0}
|
||||||
|
className="flex items-center gap-1 px-4 py-2.5 rounded-lg text-sm font-medium disabled:text-gray-300 text-gray-600 hover:bg-gray-100">
|
||||||
|
<ChevronLeft size={18} /> Prev
|
||||||
|
</button>
|
||||||
|
<span className="text-sm text-gray-500">{sections[currentSection]}</span>
|
||||||
|
{currentSection === sections.length - 1 ? (
|
||||||
|
<button onClick={() => setShowSummary(true)}
|
||||||
|
className="flex items-center gap-1 px-4 py-2.5 rounded-xl text-sm font-bold bg-blue-600 text-white hover:bg-blue-700">
|
||||||
|
Review <ChevronRight size={18} />
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<button onClick={() => setCurrentSection(currentSection + 1)}
|
||||||
|
className="flex items-center gap-1 px-4 py-2.5 rounded-lg text-sm font-medium text-blue-600 hover:bg-blue-50">
|
||||||
|
Next <ChevronRight size={18} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
36
client/src/components/ui/Badge.tsx
Normal file
36
client/src/components/ui/Badge.tsx
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
const colors: Record<string, string> = {
|
||||||
|
blue: 'bg-blue-100 text-blue-700',
|
||||||
|
green: 'bg-green-100 text-green-700',
|
||||||
|
yellow: 'bg-yellow-100 text-yellow-700',
|
||||||
|
red: 'bg-red-100 text-red-700',
|
||||||
|
gray: 'bg-gray-100 text-gray-700',
|
||||||
|
purple: 'bg-purple-100 text-purple-700',
|
||||||
|
};
|
||||||
|
|
||||||
|
const statusColors: Record<string, string> = {
|
||||||
|
received: 'blue',
|
||||||
|
in_progress: 'yellow',
|
||||||
|
completed: 'green',
|
||||||
|
invoiced: 'purple',
|
||||||
|
closed: 'gray',
|
||||||
|
draft: 'gray',
|
||||||
|
sent: 'blue',
|
||||||
|
paid: 'green',
|
||||||
|
partial: 'yellow',
|
||||||
|
overdue: 'red',
|
||||||
|
};
|
||||||
|
|
||||||
|
interface BadgeProps {
|
||||||
|
children: React.ReactNode;
|
||||||
|
color?: string;
|
||||||
|
status?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Badge({ children, color, status }: BadgeProps) {
|
||||||
|
const c = color || (status ? statusColors[status] : 'gray') || 'gray';
|
||||||
|
return (
|
||||||
|
<span className={`inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium ${colors[c]}`}>
|
||||||
|
{children}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
28
client/src/components/ui/Button.tsx
Normal file
28
client/src/components/ui/Button.tsx
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import type { ButtonHTMLAttributes } from 'react';
|
||||||
|
|
||||||
|
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
|
||||||
|
variant?: 'primary' | 'secondary' | 'danger' | 'ghost';
|
||||||
|
size?: 'sm' | 'md' | 'lg';
|
||||||
|
}
|
||||||
|
|
||||||
|
const variants = {
|
||||||
|
primary: 'bg-blue-600 text-white hover:bg-blue-700 shadow-sm',
|
||||||
|
secondary: 'bg-white text-gray-700 border border-gray-300 hover:bg-gray-50 shadow-sm',
|
||||||
|
danger: 'bg-red-600 text-white hover:bg-red-700 shadow-sm',
|
||||||
|
ghost: 'text-gray-600 hover:bg-gray-100 hover:text-gray-900',
|
||||||
|
};
|
||||||
|
|
||||||
|
const sizes = {
|
||||||
|
sm: 'px-2.5 py-1.5 text-xs',
|
||||||
|
md: 'px-3.5 py-2 text-sm',
|
||||||
|
lg: 'px-5 py-2.5 text-base',
|
||||||
|
};
|
||||||
|
|
||||||
|
export function Button({ variant = 'primary', size = 'md', className = '', ...props }: ButtonProps) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
className={`inline-flex items-center justify-center gap-2 font-medium rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed ${variants[variant]} ${sizes[size]} ${className}`}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
9
client/src/components/ui/Card.tsx
Normal file
9
client/src/components/ui/Card.tsx
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
import type { HTMLAttributes } from 'react';
|
||||||
|
|
||||||
|
export function Card({ className = '', children, ...props }: HTMLAttributes<HTMLDivElement>) {
|
||||||
|
return (
|
||||||
|
<div className={`bg-white rounded-lg border border-gray-200 shadow-sm ${className}`} {...props}>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
19
client/src/components/ui/EmptyState.tsx
Normal file
19
client/src/components/ui/EmptyState.tsx
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
import type { LucideIcon } from 'lucide-react';
|
||||||
|
|
||||||
|
interface EmptyStateProps {
|
||||||
|
icon: LucideIcon;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
action?: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EmptyState({ icon: Icon, title, description, action }: EmptyStateProps) {
|
||||||
|
return (
|
||||||
|
<div className="text-center py-12">
|
||||||
|
<Icon size={40} className="mx-auto text-gray-300 mb-3" />
|
||||||
|
<h3 className="text-sm font-medium text-gray-900">{title}</h3>
|
||||||
|
<p className="text-sm text-gray-500 mt-1">{description}</p>
|
||||||
|
{action && <div className="mt-4">{action}</div>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
47
client/src/components/ui/Input.tsx
Normal file
47
client/src/components/ui/Input.tsx
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
import type { InputHTMLAttributes, TextareaHTMLAttributes, SelectHTMLAttributes } from 'react';
|
||||||
|
|
||||||
|
const inputBase = 'block w-full rounded-lg border border-gray-300 px-3 py-2 text-sm shadow-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 outline-none disabled:bg-gray-50';
|
||||||
|
|
||||||
|
interface InputProps extends InputHTMLAttributes<HTMLInputElement> {
|
||||||
|
label?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Input({ label, className = '', ...props }: InputProps) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{label && <label className="block text-sm font-medium text-gray-700 mb-1">{label}</label>}
|
||||||
|
<input className={`${inputBase} ${className}`} {...props} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TextAreaProps extends TextareaHTMLAttributes<HTMLTextAreaElement> {
|
||||||
|
label?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function TextArea({ label, className = '', ...props }: TextAreaProps) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{label && <label className="block text-sm font-medium text-gray-700 mb-1">{label}</label>}
|
||||||
|
<textarea className={`${inputBase} ${className}`} rows={3} {...props} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
interface SelectProps extends SelectHTMLAttributes<HTMLSelectElement> {
|
||||||
|
label?: string;
|
||||||
|
options: { value: string | number; label: string }[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Select({ label, options, className = '', ...props }: SelectProps) {
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{label && <label className="block text-sm font-medium text-gray-700 mb-1">{label}</label>}
|
||||||
|
<select className={`${inputBase} ${className}`} {...props}>
|
||||||
|
{options.map((o) => (
|
||||||
|
<option key={o.value} value={o.value}>{o.label}</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
33
client/src/components/ui/Modal.tsx
Normal file
33
client/src/components/ui/Modal.tsx
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import { useEffect } from 'react';
|
||||||
|
import { X } from 'lucide-react';
|
||||||
|
|
||||||
|
interface ModalProps {
|
||||||
|
open: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
title: string;
|
||||||
|
children: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function Modal({ open, onClose, title, children }: ModalProps) {
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) document.body.style.overflow = 'hidden';
|
||||||
|
return () => { document.body.style.overflow = ''; };
|
||||||
|
}, [open]);
|
||||||
|
|
||||||
|
if (!open) return null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||||
|
<div className="absolute inset-0 bg-black/40" onClick={onClose} />
|
||||||
|
<div className="relative bg-white rounded-xl shadow-xl w-full max-w-lg max-h-[90vh] overflow-y-auto">
|
||||||
|
<div className="flex items-center justify-between p-4 border-b border-gray-200">
|
||||||
|
<h2 className="text-lg font-semibold text-gray-900">{title}</h2>
|
||||||
|
<button onClick={onClose} className="p-1 text-gray-400 hover:text-gray-600 rounded">
|
||||||
|
<X size={20} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div className="p-4">{children}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
6
client/src/components/ui/index.ts
Normal file
6
client/src/components/ui/index.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
export { Badge } from './Badge';
|
||||||
|
export { Button } from './Button';
|
||||||
|
export { Card } from './Card';
|
||||||
|
export { EmptyState } from './EmptyState';
|
||||||
|
export { Input, TextArea, Select } from './Input';
|
||||||
|
export { Modal } from './Modal';
|
||||||
13
client/src/index.css
Normal file
13
client/src/index.css
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
@import "tailwindcss";
|
||||||
|
|
||||||
|
@theme {
|
||||||
|
--color-primary: #2563eb;
|
||||||
|
--color-primary-dark: #1d4ed8;
|
||||||
|
--color-primary-light: #dbeafe;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
margin: 0;
|
||||||
|
font-family: system-ui, -apple-system, sans-serif;
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
}
|
||||||
10
client/src/main.tsx
Normal file
10
client/src/main.tsx
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
import { StrictMode } from 'react'
|
||||||
|
import { createRoot } from 'react-dom/client'
|
||||||
|
import './index.css'
|
||||||
|
import App from './App'
|
||||||
|
|
||||||
|
createRoot(document.getElementById('root')!).render(
|
||||||
|
<StrictMode>
|
||||||
|
<App />
|
||||||
|
</StrictMode>,
|
||||||
|
)
|
||||||
161
client/src/pages/Appointments.tsx
Normal file
161
client/src/pages/Appointments.tsx
Normal file
@@ -0,0 +1,161 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { Calendar, Plus, Clock } from 'lucide-react';
|
||||||
|
import { api } from '../api/client';
|
||||||
|
import { Card, Button, Badge, Input, Modal, EmptyState } from '../components/ui';
|
||||||
|
import type { Appointment, Owner, Vehicle } from '../types';
|
||||||
|
|
||||||
|
const statusLabel: Record<string, string> = {
|
||||||
|
pending: 'Pending', confirmed: 'Confirmed', cancelled: 'Cancelled', completed: 'Completed',
|
||||||
|
};
|
||||||
|
|
||||||
|
const statusColors: Record<string, string> = {
|
||||||
|
pending: 'yellow', confirmed: 'blue', cancelled: 'gray', completed: 'green',
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function Appointments() {
|
||||||
|
const [showModal, setShowModal] = useState(false);
|
||||||
|
const [dateFilter, setDateFilter] = useState('');
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const { data: appointments = [], isLoading } = useQuery({
|
||||||
|
queryKey: ['appointments', dateFilter],
|
||||||
|
queryFn: () => api.getAppointments({ date: dateFilter || undefined }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const createMutation = useMutation({
|
||||||
|
mutationFn: api.createAppointment,
|
||||||
|
onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['appointments'] }); setShowModal(false); },
|
||||||
|
});
|
||||||
|
|
||||||
|
const statusMutation = useMutation({
|
||||||
|
mutationFn: ({ id, status }: { id: number; status: string }) => api.updateAppointment(id, { status }),
|
||||||
|
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['appointments'] }),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Group appointments by date
|
||||||
|
const grouped = appointments.reduce((acc: Record<string, Appointment[]>, appt: Appointment) => {
|
||||||
|
const date = appt.requested_date;
|
||||||
|
if (!acc[date]) acc[date] = [];
|
||||||
|
acc[date].push(appt);
|
||||||
|
return acc;
|
||||||
|
}, {});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900">Appointments</h1>
|
||||||
|
<Button onClick={() => setShowModal(true)}><Plus size={16} /> New Appointment</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-3 items-center">
|
||||||
|
<Input type="date" value={dateFilter} onChange={(e) => setDateFilter(e.target.value)} />
|
||||||
|
{dateFilter && <button onClick={() => setDateFilter('')} className="text-sm text-gray-500 hover:text-gray-700">Clear</button>}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isLoading ? <p className="text-gray-500 py-4">Loading...</p> : appointments.length === 0 ? (
|
||||||
|
<EmptyState icon={Calendar} title="No appointments" description="Schedule appointments for customers." action={<Button onClick={() => setShowModal(true)}>New Appointment</Button>} />
|
||||||
|
) : (
|
||||||
|
Object.entries(grouped).map(([date, appts]) => (
|
||||||
|
<div key={date} className="space-y-2">
|
||||||
|
<h3 className="text-sm font-medium text-gray-500">{new Date(date + 'T00:00').toLocaleDateString(undefined, { weekday: 'long', month: 'long', day: 'numeric' })}</h3>
|
||||||
|
{(appts as Appointment[]).map((appt) => (
|
||||||
|
<Card key={appt.id} className="p-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Clock size={14} className="text-gray-400" />
|
||||||
|
<span className="text-sm font-medium text-gray-900">{appt.requested_time || 'No time set'}</span>
|
||||||
|
<Badge color={statusColors[appt.status]}>{statusLabel[appt.status]}</Badge>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-gray-700 mt-1">
|
||||||
|
{appt.owner_first_name && `${appt.owner_first_name} ${appt.owner_last_name}`}
|
||||||
|
{appt.year && ` — ${[appt.year, appt.make, appt.model].filter(Boolean).join(' ')}`}
|
||||||
|
</p>
|
||||||
|
{appt.service_description && <p className="text-xs text-gray-500 mt-0.5">{appt.service_description}</p>}
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-1">
|
||||||
|
{appt.status === 'pending' && (
|
||||||
|
<Button variant="ghost" size="sm" onClick={() => statusMutation.mutate({ id: appt.id, status: 'confirmed' })}>Confirm</Button>
|
||||||
|
)}
|
||||||
|
{(appt.status === 'pending' || appt.status === 'confirmed') && (
|
||||||
|
<Button variant="ghost" size="sm" onClick={() => statusMutation.mutate({ id: appt.id, status: 'cancelled' })}>Cancel</Button>
|
||||||
|
)}
|
||||||
|
{appt.status === 'confirmed' && (
|
||||||
|
<Button variant="ghost" size="sm" onClick={() => statusMutation.mutate({ id: appt.id, status: 'completed' })}>Complete</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
|
||||||
|
<NewAppointmentModal open={showModal} onClose={() => setShowModal(false)} onSave={(data) => createMutation.mutate(data)} saving={createMutation.isPending} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function NewAppointmentModal({ open, onClose, onSave, saving }: {
|
||||||
|
open: boolean; onClose: () => void; onSave: (data: any) => void; saving: boolean;
|
||||||
|
}) {
|
||||||
|
const [ownerId, setOwnerId] = useState('');
|
||||||
|
const [vehicleId, setVehicleId] = useState('');
|
||||||
|
const [requestedDate, setRequestedDate] = useState('');
|
||||||
|
const [requestedTime, setRequestedTime] = useState('');
|
||||||
|
const [serviceDescription, setServiceDescription] = useState('');
|
||||||
|
const [notes, setNotes] = useState('');
|
||||||
|
|
||||||
|
const { data: owners = [] } = useQuery({ queryKey: ['owners'], queryFn: () => api.getOwners() });
|
||||||
|
const { data: vehicles = [] } = useQuery({
|
||||||
|
queryKey: ['vehicles', ownerId],
|
||||||
|
queryFn: () => api.getVehicles(ownerId ? { ownerId: Number(ownerId) } : {}),
|
||||||
|
enabled: !!ownerId,
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal open={open} onClose={onClose} title="New Appointment">
|
||||||
|
<form onSubmit={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
onSave({
|
||||||
|
ownerId: ownerId ? Number(ownerId) : undefined,
|
||||||
|
vehicleId: vehicleId ? Number(vehicleId) : undefined,
|
||||||
|
requestedDate,
|
||||||
|
requestedTime: requestedTime || undefined,
|
||||||
|
serviceDescription: serviceDescription || undefined,
|
||||||
|
notes: notes || undefined,
|
||||||
|
});
|
||||||
|
}} className="space-y-3">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Customer</label>
|
||||||
|
<select className="block w-full rounded-lg border border-gray-300 px-3 py-2 text-sm" value={ownerId} onChange={(e) => { setOwnerId(e.target.value); setVehicleId(''); }}>
|
||||||
|
<option value="">Select customer...</option>
|
||||||
|
{owners.map((o: Owner) => <option key={o.id} value={o.id}>{o.first_name} {o.last_name}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
{ownerId && (
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Vehicle</label>
|
||||||
|
<select className="block w-full rounded-lg border border-gray-300 px-3 py-2 text-sm" value={vehicleId} onChange={(e) => setVehicleId(e.target.value)}>
|
||||||
|
<option value="">Select vehicle...</option>
|
||||||
|
{vehicles.map((v: Vehicle) => <option key={v.id} value={v.id}>{[v.year, v.make, v.model].filter(Boolean).join(' ')}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<Input label="Date *" type="date" value={requestedDate} onChange={(e) => setRequestedDate(e.target.value)} required />
|
||||||
|
<Input label="Time" type="time" value={requestedTime} onChange={(e) => setRequestedTime(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Service Description</label>
|
||||||
|
<textarea className="block w-full rounded-lg border border-gray-300 px-3 py-2 text-sm" rows={2} value={serviceDescription} onChange={(e) => setServiceDescription(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-end gap-2 pt-2">
|
||||||
|
<Button variant="secondary" type="button" onClick={onClose}>Cancel</Button>
|
||||||
|
<Button type="submit" disabled={saving}>{saving ? 'Saving...' : 'Save'}</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
159
client/src/pages/Dashboard.tsx
Normal file
159
client/src/pages/Dashboard.tsx
Normal file
@@ -0,0 +1,159 @@
|
|||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import { Wrench, FileText, DollarSign, AlertTriangle } from 'lucide-react';
|
||||||
|
import { api } from '../api/client';
|
||||||
|
import { Card, Badge } from '../components/ui';
|
||||||
|
import type { DashboardStats } from '../types';
|
||||||
|
|
||||||
|
const fmt = (n: number) => '$' + n.toFixed(2);
|
||||||
|
|
||||||
|
const statusLabel: Record<string, string> = {
|
||||||
|
received: 'Received',
|
||||||
|
in_progress: 'In Progress',
|
||||||
|
completed: 'Completed',
|
||||||
|
invoiced: 'Invoiced',
|
||||||
|
closed: 'Closed',
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function Dashboard() {
|
||||||
|
const { data: stats, isLoading } = useQuery<DashboardStats>({
|
||||||
|
queryKey: ['dashboard'],
|
||||||
|
queryFn: api.getDashboardStats,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (isLoading || !stats) return <p className="text-gray-500 py-8">Loading dashboard...</p>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900">Dashboard</h1>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 lg:grid-cols-4 gap-4">
|
||||||
|
{[
|
||||||
|
{ label: 'Active Jobs', value: stats.activeJobs, icon: Wrench, color: 'text-blue-600 bg-blue-50' },
|
||||||
|
{ label: 'Completed This Month', value: stats.completedThisMonth, icon: Wrench, color: 'text-green-600 bg-green-50' },
|
||||||
|
{ label: 'Outstanding', value: fmt(stats.outstandingAmount), icon: FileText, color: 'text-yellow-600 bg-yellow-50' },
|
||||||
|
{ label: 'Revenue This Month', value: fmt(stats.revenueThisMonth), icon: DollarSign, color: 'text-green-600 bg-green-50' },
|
||||||
|
].map((stat) => (
|
||||||
|
<Card key={stat.label} className="p-4">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className={`p-2 rounded-lg ${stat.color}`}>
|
||||||
|
<stat.icon size={20} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="text-2xl font-bold text-gray-900">{stat.value}</p>
|
||||||
|
<p className="text-xs text-gray-500">{stat.label}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Recommendations to communicate */}
|
||||||
|
{(stats as any).urgentRecs?.length > 0 && (
|
||||||
|
<Card>
|
||||||
|
<div className="p-4 border-b border-gray-200 flex items-center gap-2">
|
||||||
|
<AlertTriangle size={16} className="text-orange-500" />
|
||||||
|
<h2 className="font-semibold text-gray-900">Recommend to Customer</h2>
|
||||||
|
<span className="text-xs bg-orange-100 text-orange-700 px-1.5 py-0.5 rounded-full">{(stats as any).urgentRecs.length}</span>
|
||||||
|
</div>
|
||||||
|
<div className="divide-y divide-gray-100">
|
||||||
|
{(stats as any).urgentRecs.map((rec: any) => (
|
||||||
|
<Link key={rec.id} to={`/jobs/${rec.job_id}`} className="block p-3 hover:bg-gray-50 transition-colors">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-gray-900">{rec.description}</p>
|
||||||
|
<p className="text-xs text-gray-500 mt-0.5">
|
||||||
|
{rec.first_name && <span className="font-medium">{rec.first_name} {rec.last_name}'s </span>}
|
||||||
|
{[rec.year, rec.make, rec.model].filter(Boolean).join(' ')}
|
||||||
|
<span className="text-gray-400"> — {rec.job_number}</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{rec.estimated_cost && <span className="text-sm font-medium text-gray-700">~{fmt(rec.estimated_cost)}</span>}
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{(stats as any).soonRecs?.length > 0 && (
|
||||||
|
<Card>
|
||||||
|
<div className="p-4 border-b border-gray-200 flex items-center gap-2">
|
||||||
|
<h2 className="font-semibold text-gray-900">Follow-Up Recommendations</h2>
|
||||||
|
<span className="text-xs bg-gray-100 text-gray-600 px-1.5 py-0.5 rounded-full">{(stats as any).soonRecs.length}</span>
|
||||||
|
</div>
|
||||||
|
<div className="divide-y divide-gray-100">
|
||||||
|
{(stats as any).soonRecs.map((rec: any) => (
|
||||||
|
<Link key={rec.id} to={`/jobs/${rec.job_id}`} className="block p-3 hover:bg-gray-50 transition-colors">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-gray-900">{rec.description}</p>
|
||||||
|
<p className="text-xs text-gray-500 mt-0.5">
|
||||||
|
{rec.first_name && <span className="font-medium">{rec.first_name} {rec.last_name}'s </span>}
|
||||||
|
{[rec.year, rec.make, rec.model].filter(Boolean).join(' ')}
|
||||||
|
<span className="text-gray-400"> — {rec.job_number}</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{rec.estimated_cost && <span className="text-sm text-gray-500">~{fmt(rec.estimated_cost)}</span>}
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="grid lg:grid-cols-2 gap-6">
|
||||||
|
<Card>
|
||||||
|
<div className="p-4 border-b border-gray-200">
|
||||||
|
<h2 className="font-semibold text-gray-900">Recent Jobs</h2>
|
||||||
|
</div>
|
||||||
|
<div className="divide-y divide-gray-100">
|
||||||
|
{stats.recentJobs.length === 0 ? (
|
||||||
|
<p className="p-4 text-sm text-gray-500">No jobs yet</p>
|
||||||
|
) : (
|
||||||
|
stats.recentJobs.map((job: any) => (
|
||||||
|
<Link key={job.id} to={`/jobs/${job.id}`} className="block p-3 hover:bg-gray-50 transition-colors">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-gray-900">{job.job_number}</p>
|
||||||
|
<p className="text-xs text-gray-500">
|
||||||
|
{[job.year, job.make, job.model].filter(Boolean).join(' ')} — {job.shop_name}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Badge status={job.status}>{statusLabel[job.status] || job.status}</Badge>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<div className="p-4 border-b border-gray-200">
|
||||||
|
<h2 className="font-semibold text-gray-900">Outstanding Invoices</h2>
|
||||||
|
</div>
|
||||||
|
<div className="divide-y divide-gray-100">
|
||||||
|
{stats.outstandingInvoices.length === 0 ? (
|
||||||
|
<p className="p-4 text-sm text-gray-500">All caught up!</p>
|
||||||
|
) : (
|
||||||
|
stats.outstandingInvoices.map((inv: any) => (
|
||||||
|
<Link key={inv.id} to={`/invoices/${inv.id}`} className="block p-3 hover:bg-gray-50 transition-colors">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-gray-900">{inv.invoice_number}</p>
|
||||||
|
<p className="text-xs text-gray-500">{inv.shop_name}</p>
|
||||||
|
</div>
|
||||||
|
<div className="text-right">
|
||||||
|
<p className="text-sm font-medium text-gray-900">{fmt(inv.total)}</p>
|
||||||
|
<Badge status={inv.status}>{inv.status}</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
363
client/src/pages/EstimateDetail.tsx
Normal file
363
client/src/pages/EstimateDetail.tsx
Normal file
@@ -0,0 +1,363 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { useParams, Link, useNavigate } from 'react-router-dom';
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { ArrowLeft, Plus, Trash2, Send, CheckCircle, XCircle, Wrench, Pencil } from 'lucide-react';
|
||||||
|
import { api } from '../api/client';
|
||||||
|
import { Card, Button, Badge, Input, Modal } from '../components/ui';
|
||||||
|
import type { Estimate, EstimateItem, CannedService } from '../types';
|
||||||
|
|
||||||
|
const fmt = (n: number) => '$' + n.toFixed(2);
|
||||||
|
|
||||||
|
const statusLabel: Record<string, string> = {
|
||||||
|
draft: 'Draft', sent: 'Sent', approved: 'Approved', declined: 'Declined', expired: 'Expired', converted: 'Converted',
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function EstimateDetail() {
|
||||||
|
const { id } = useParams();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [showItemModal, setShowItemModal] = useState(false);
|
||||||
|
const [editItem, setEditItem] = useState<EstimateItem | null>(null);
|
||||||
|
const [showCannedModal, setShowCannedModal] = useState(false);
|
||||||
|
|
||||||
|
const { data: estimate, isLoading } = useQuery<Estimate>({
|
||||||
|
queryKey: ['estimates', id],
|
||||||
|
queryFn: () => api.getEstimate(Number(id)),
|
||||||
|
});
|
||||||
|
|
||||||
|
const invalidate = () => queryClient.invalidateQueries({ queryKey: ['estimates', id] });
|
||||||
|
|
||||||
|
const statusMutation = useMutation({
|
||||||
|
mutationFn: (status: string) => api.updateEstimate(Number(id), { status }),
|
||||||
|
onSuccess: invalidate,
|
||||||
|
});
|
||||||
|
|
||||||
|
const deleteItemMutation = useMutation({
|
||||||
|
mutationFn: (itemId: number) => api.deleteEstimateItem(Number(id), itemId),
|
||||||
|
onSuccess: invalidate,
|
||||||
|
});
|
||||||
|
|
||||||
|
const convertMutation = useMutation({
|
||||||
|
mutationFn: () => api.convertEstimateToJob(Number(id)),
|
||||||
|
onSuccess: (job: any) => {
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['estimates'] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['jobs'] });
|
||||||
|
navigate(`/jobs/${job.id}`);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const deleteMutation = useMutation({
|
||||||
|
mutationFn: () => api.deleteEstimate(Number(id)),
|
||||||
|
onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['estimates'] }); navigate('/estimates'); },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (isLoading || !estimate) return <p className="text-gray-500 py-4">Loading...</p>;
|
||||||
|
|
||||||
|
const items = estimate.items || [];
|
||||||
|
const laborItems = items.filter(i => i.item_type === 'labor');
|
||||||
|
const partsItems = items.filter(i => i.item_type === 'parts');
|
||||||
|
const vehicleStr = [estimate.year, estimate.make, estimate.model].filter(Boolean).join(' ');
|
||||||
|
const customerName = estimate.shop_name || (estimate.owner_first_name ? `${estimate.owner_first_name} ${estimate.owner_last_name}` : '—');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<Link to="/estimates" className="inline-flex items-center gap-1.5 text-sm text-gray-500 hover:text-gray-700">
|
||||||
|
<ArrowLeft size={16} /> Back to Estimates
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900">{estimate.estimate_number}</h1>
|
||||||
|
<Badge status={estimate.status}>{statusLabel[estimate.status]}</Badge>
|
||||||
|
</div>
|
||||||
|
<p className="text-gray-700 mt-1">{customerName} {vehicleStr && `— ${vehicleStr}`}</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2 flex-wrap">
|
||||||
|
{estimate.status === 'draft' && (
|
||||||
|
<Button onClick={() => statusMutation.mutate('sent')}><Send size={16} /> Mark Sent</Button>
|
||||||
|
)}
|
||||||
|
{estimate.status === 'sent' && (
|
||||||
|
<>
|
||||||
|
<Button onClick={() => statusMutation.mutate('approved')}><CheckCircle size={16} /> Approved</Button>
|
||||||
|
<Button variant="secondary" onClick={() => statusMutation.mutate('declined')}><XCircle size={16} /> Declined</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
{estimate.status === 'approved' && estimate.vehicle_id && (
|
||||||
|
<Button onClick={() => convertMutation.mutate()} disabled={convertMutation.isPending}>
|
||||||
|
<Wrench size={16} /> {convertMutation.isPending ? 'Converting...' : 'Convert to Job'}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
<Button variant="danger" onClick={() => { if (confirm('Delete this estimate?')) deleteMutation.mutate(); }}>
|
||||||
|
<Trash2 size={16} /> Delete
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Labor Items */}
|
||||||
|
<Card>
|
||||||
|
<div className="p-4 border-b border-gray-200 flex items-center justify-between">
|
||||||
|
<h2 className="font-semibold text-gray-900">Labor</h2>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button variant="ghost" size="sm" onClick={() => setShowCannedModal(true)}>
|
||||||
|
<Plus size={14} /> Canned Service
|
||||||
|
</Button>
|
||||||
|
<Button variant="ghost" size="sm" onClick={() => { setEditItem(null); setShowItemModal(true); }}>
|
||||||
|
<Plus size={14} /> Add Labor
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{laborItems.length > 0 ? (
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<tbody className="divide-y divide-gray-100">
|
||||||
|
{laborItems.map((item) => {
|
||||||
|
const lineTotal = item.billing_type === 'flat_rate' ? (item.flat_rate || 0) : (item.hours || 0) * (item.rate || 0);
|
||||||
|
return (
|
||||||
|
<tr key={item.id} className="hover:bg-gray-50">
|
||||||
|
<td className="px-4 py-2 text-gray-700">{item.description}</td>
|
||||||
|
<td className="px-4 py-2 text-right text-gray-500">
|
||||||
|
{item.billing_type === 'flat_rate' ? 'Flat rate' : `${item.hours}h @ ${fmt(item.rate || 0)}/hr`}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2 text-right font-medium w-24">{fmt(lineTotal)}</td>
|
||||||
|
<td className="px-4 py-2 w-16">
|
||||||
|
<div className="flex gap-1">
|
||||||
|
<button onClick={() => { setEditItem(item); setShowItemModal(true); }} className="text-gray-400 hover:text-blue-500"><Pencil size={14} /></button>
|
||||||
|
<button onClick={() => deleteItemMutation.mutate(item.id)} className="text-gray-400 hover:text-red-500"><Trash2 size={14} /></button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="p-4 text-sm text-gray-500">No labor items.</p>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Parts Items */}
|
||||||
|
<Card>
|
||||||
|
<div className="p-4 border-b border-gray-200 flex items-center justify-between">
|
||||||
|
<h2 className="font-semibold text-gray-900">Parts</h2>
|
||||||
|
<Button variant="ghost" size="sm" onClick={() => { setEditItem({ item_type: 'parts' } as any); setShowItemModal(true); }}>
|
||||||
|
<Plus size={14} /> Add Parts
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{partsItems.length > 0 ? (
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<tbody className="divide-y divide-gray-100">
|
||||||
|
{partsItems.map((item) => {
|
||||||
|
const lineTotal = item.quantity * item.unit_price * (1 + item.markup);
|
||||||
|
return (
|
||||||
|
<tr key={item.id} className="hover:bg-gray-50">
|
||||||
|
<td className="px-4 py-2 text-gray-700">{item.description}</td>
|
||||||
|
<td className="px-4 py-2 text-right text-gray-500">{item.quantity} x {fmt(item.unit_price * (1 + item.markup))}</td>
|
||||||
|
<td className="px-4 py-2 text-right font-medium w-24">{fmt(lineTotal)}</td>
|
||||||
|
<td className="px-4 py-2 w-16">
|
||||||
|
<div className="flex gap-1">
|
||||||
|
<button onClick={() => { setEditItem(item); setShowItemModal(true); }} className="text-gray-400 hover:text-blue-500"><Pencil size={14} /></button>
|
||||||
|
<button onClick={() => deleteItemMutation.mutate(item.id)} className="text-gray-400 hover:text-red-500"><Trash2 size={14} /></button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="p-4 text-sm text-gray-500">No parts items.</p>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Totals */}
|
||||||
|
<Card className="p-4 space-y-2">
|
||||||
|
<div className="flex justify-between text-sm">
|
||||||
|
<span className="text-gray-600">Subtotal</span>
|
||||||
|
<span className="font-medium">{fmt(estimate.subtotal)}</span>
|
||||||
|
</div>
|
||||||
|
{estimate.tax_rate > 0 && (
|
||||||
|
<div className="flex justify-between text-sm">
|
||||||
|
<span className="text-gray-600">Tax ({(estimate.tax_rate * 100).toFixed(1)}%)</span>
|
||||||
|
<span className="font-medium">{fmt(estimate.tax_amount)}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex justify-between text-lg font-bold border-t border-gray-200 pt-2">
|
||||||
|
<span>Total</span>
|
||||||
|
<span>{fmt(estimate.total)}</span>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{estimate.notes && (
|
||||||
|
<Card className="p-4">
|
||||||
|
<h3 className="text-xs font-medium text-gray-500 uppercase mb-1">Notes</h3>
|
||||||
|
<p className="text-sm text-gray-700">{estimate.notes}</p>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<EstimateItemModal
|
||||||
|
open={showItemModal}
|
||||||
|
onClose={() => { setShowItemModal(false); setEditItem(null); }}
|
||||||
|
estimateId={Number(id)}
|
||||||
|
item={editItem}
|
||||||
|
onDone={invalidate}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<CannedServicePickerModal
|
||||||
|
open={showCannedModal}
|
||||||
|
onClose={() => setShowCannedModal(false)}
|
||||||
|
estimateId={Number(id)}
|
||||||
|
onDone={invalidate}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function EstimateItemModal({ open, onClose, estimateId, item, onDone }: {
|
||||||
|
open: boolean; onClose: () => void; estimateId: number; item: EstimateItem | null; onDone: () => void;
|
||||||
|
}) {
|
||||||
|
const isEditing = item?.id !== undefined;
|
||||||
|
const isPartsMode = item?.item_type === 'parts';
|
||||||
|
|
||||||
|
const [itemType, setItemType] = useState<'labor' | 'parts'>(item?.item_type || 'labor');
|
||||||
|
const [description, setDescription] = useState(item?.description || '');
|
||||||
|
const [billingType, setBillingType] = useState(item?.billing_type || 'hourly');
|
||||||
|
const [hours, setHours] = useState(item?.hours?.toString() || '');
|
||||||
|
const [rate, setRate] = useState(item?.rate?.toString() || '75');
|
||||||
|
const [flatRate, setFlatRate] = useState(item?.flat_rate?.toString() || '');
|
||||||
|
const [quantity, setQuantity] = useState(item?.quantity?.toString() || '1');
|
||||||
|
const [unitPrice, setUnitPrice] = useState(item?.unit_price?.toString() || '');
|
||||||
|
const [markup, setMarkup] = useState(item ? String(item.markup * 100) : '30');
|
||||||
|
|
||||||
|
const mutation = useMutation({
|
||||||
|
mutationFn: (data: any) => isEditing
|
||||||
|
? api.updateEstimateItem(estimateId, item!.id, data)
|
||||||
|
: api.addEstimateItem(estimateId, data),
|
||||||
|
onSuccess: () => { onDone(); onClose(); },
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (itemType === 'labor') {
|
||||||
|
mutation.mutate({
|
||||||
|
itemType: 'labor', description, billingType,
|
||||||
|
hours: billingType === 'hourly' ? parseFloat(hours) : null,
|
||||||
|
rate: billingType === 'hourly' ? parseFloat(rate) : null,
|
||||||
|
flatRate: billingType === 'flat_rate' ? parseFloat(flatRate) : null,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
mutation.mutate({
|
||||||
|
itemType: 'parts', description,
|
||||||
|
quantity: parseFloat(quantity),
|
||||||
|
unitPrice: parseFloat(unitPrice),
|
||||||
|
markup: parseFloat(markup) / 100,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal open={open} onClose={onClose} title={isEditing ? 'Edit Item' : 'Add Item'}>
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-3">
|
||||||
|
{!isEditing && !isPartsMode && (
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button type="button" onClick={() => setItemType('labor')}
|
||||||
|
className={`flex-1 px-3 py-2 text-sm font-medium rounded-lg border ${itemType === 'labor' ? 'bg-blue-50 border-blue-300 text-blue-700' : 'border-gray-300 text-gray-600'}`}>
|
||||||
|
Labor
|
||||||
|
</button>
|
||||||
|
<button type="button" onClick={() => setItemType('parts')}
|
||||||
|
className={`flex-1 px-3 py-2 text-sm font-medium rounded-lg border ${itemType === 'parts' ? 'bg-blue-50 border-blue-300 text-blue-700' : 'border-gray-300 text-gray-600'}`}>
|
||||||
|
Parts
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Input label="Description *" value={description} onChange={(e) => setDescription(e.target.value)} required />
|
||||||
|
|
||||||
|
{itemType === 'labor' ? (
|
||||||
|
<>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button type="button" onClick={() => setBillingType('hourly')}
|
||||||
|
className={`flex-1 px-3 py-2 text-sm rounded-lg border ${billingType === 'hourly' ? 'bg-blue-50 border-blue-300 text-blue-700' : 'border-gray-300 text-gray-600'}`}>
|
||||||
|
Hourly
|
||||||
|
</button>
|
||||||
|
<button type="button" onClick={() => setBillingType('flat_rate')}
|
||||||
|
className={`flex-1 px-3 py-2 text-sm rounded-lg border ${billingType === 'flat_rate' ? 'bg-blue-50 border-blue-300 text-blue-700' : 'border-gray-300 text-gray-600'}`}>
|
||||||
|
Flat Rate
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{billingType === 'hourly' ? (
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<Input label="Hours *" type="number" step="0.1" value={hours} onChange={(e) => setHours(e.target.value)} required />
|
||||||
|
<Input label="Rate ($/hr) *" type="number" step="0.01" value={rate} onChange={(e) => setRate(e.target.value)} required />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Input label="Flat Rate *" type="number" step="0.01" value={flatRate} onChange={(e) => setFlatRate(e.target.value)} required />
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-3 gap-3">
|
||||||
|
<Input label="Qty *" type="number" step="1" value={quantity} onChange={(e) => setQuantity(e.target.value)} required />
|
||||||
|
<Input label="Unit Cost *" type="number" step="0.01" value={unitPrice} onChange={(e) => setUnitPrice(e.target.value)} required />
|
||||||
|
<Input label="Markup %" type="number" step="1" value={markup} onChange={(e) => setMarkup(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-2 pt-2">
|
||||||
|
<Button variant="secondary" type="button" onClick={onClose}>Cancel</Button>
|
||||||
|
<Button type="submit" disabled={mutation.isPending}>{mutation.isPending ? 'Saving...' : 'Save'}</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function CannedServicePickerModal({ open, onClose, estimateId, onDone }: {
|
||||||
|
open: boolean; onClose: () => void; estimateId: number; onDone: () => void;
|
||||||
|
}) {
|
||||||
|
const { data: services = [] } = useQuery({
|
||||||
|
queryKey: ['canned-services'],
|
||||||
|
queryFn: () => api.getCannedServices({ active: 1 }),
|
||||||
|
enabled: open,
|
||||||
|
});
|
||||||
|
|
||||||
|
const addItems = useMutation({
|
||||||
|
mutationFn: async (service: CannedService) => {
|
||||||
|
for (const item of service.items || []) {
|
||||||
|
await api.addEstimateItem(estimateId, {
|
||||||
|
itemType: item.item_type,
|
||||||
|
description: item.description,
|
||||||
|
billingType: item.billing_type || 'hourly',
|
||||||
|
hours: item.hours,
|
||||||
|
rate: item.rate,
|
||||||
|
flatRate: item.flat_rate,
|
||||||
|
quantity: item.quantity,
|
||||||
|
unitPrice: item.unit_cost,
|
||||||
|
markup: item.markup,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onSuccess: () => { onDone(); onClose(); },
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal open={open} onClose={onClose} title="Add Canned Service">
|
||||||
|
{services.length === 0 ? (
|
||||||
|
<p className="text-sm text-gray-500 py-4">No canned services available. Create them in Settings.</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2 max-h-80 overflow-y-auto">
|
||||||
|
{services.map((svc: CannedService) => (
|
||||||
|
<button key={svc.id} onClick={() => addItems.mutate(svc)} disabled={addItems.isPending}
|
||||||
|
className="w-full text-left p-3 border border-gray-200 rounded-lg hover:bg-gray-50 transition-colors">
|
||||||
|
<p className="font-medium text-gray-900">{svc.name}</p>
|
||||||
|
{svc.description && <p className="text-xs text-gray-500 mt-0.5">{svc.description}</p>}
|
||||||
|
<p className="text-xs text-gray-400 mt-1">{(svc.items?.length || 0)} items {svc.category && `— ${svc.category}`}</p>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
178
client/src/pages/Estimates.tsx
Normal file
178
client/src/pages/Estimates.tsx
Normal file
@@ -0,0 +1,178 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { FileText, Plus } from 'lucide-react';
|
||||||
|
import { api } from '../api/client';
|
||||||
|
import { Card, Button, Badge, Modal, EmptyState } from '../components/ui';
|
||||||
|
import type { Estimate, Shop, Owner, Vehicle } from '../types';
|
||||||
|
|
||||||
|
const fmt = (n: number) => '$' + n.toFixed(2);
|
||||||
|
|
||||||
|
const statusLabel: Record<string, string> = {
|
||||||
|
draft: 'Draft', sent: 'Sent', approved: 'Approved', declined: 'Declined', expired: 'Expired', converted: 'Converted',
|
||||||
|
};
|
||||||
|
|
||||||
|
const statusFilters = [
|
||||||
|
{ value: '', label: 'All' },
|
||||||
|
{ value: 'draft', label: 'Draft' },
|
||||||
|
{ value: 'sent', label: 'Sent' },
|
||||||
|
{ value: 'approved', label: 'Approved' },
|
||||||
|
{ value: 'declined', label: 'Declined' },
|
||||||
|
{ value: 'converted', label: 'Converted' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function Estimates() {
|
||||||
|
const [statusFilter, setStatusFilter] = useState('');
|
||||||
|
const [showNewEstimate, setShowNewEstimate] = useState(false);
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const { data: estimates = [], isLoading } = useQuery({
|
||||||
|
queryKey: ['estimates', statusFilter],
|
||||||
|
queryFn: () => api.getEstimates({ status: statusFilter || undefined }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const createMutation = useMutation({
|
||||||
|
mutationFn: api.createEstimate,
|
||||||
|
onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['estimates'] }); setShowNewEstimate(false); },
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900">Estimates</h1>
|
||||||
|
<Button onClick={() => setShowNewEstimate(true)}><Plus size={16} /> New Estimate</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-1 flex-wrap">
|
||||||
|
{statusFilters.map((s) => (
|
||||||
|
<button key={s.value} onClick={() => setStatusFilter(s.value)}
|
||||||
|
className={`px-3 py-1.5 text-xs font-medium rounded-lg transition-colors ${statusFilter === s.value ? 'bg-blue-100 text-blue-700' : 'bg-gray-100 text-gray-600 hover:bg-gray-200'}`}>
|
||||||
|
{s.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isLoading ? <p className="text-gray-500 py-4">Loading...</p> : estimates.length === 0 ? (
|
||||||
|
<EmptyState icon={FileText} title="No estimates" description="Create an estimate to quote work for customers." action={<Button onClick={() => setShowNewEstimate(true)}>New Estimate</Button>} />
|
||||||
|
) : (
|
||||||
|
<Card>
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-gray-200 text-left text-xs text-gray-500 uppercase">
|
||||||
|
<th className="px-4 py-3">Estimate</th>
|
||||||
|
<th className="px-4 py-3">Customer</th>
|
||||||
|
<th className="px-4 py-3">Vehicle</th>
|
||||||
|
<th className="px-4 py-3">Date</th>
|
||||||
|
<th className="px-4 py-3 text-right">Total</th>
|
||||||
|
<th className="px-4 py-3">Status</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-gray-100">
|
||||||
|
{estimates.map((est: Estimate) => (
|
||||||
|
<tr key={est.id} className="hover:bg-gray-50">
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<Link to={`/estimates/${est.id}`} className="font-medium text-blue-600 hover:underline">{est.estimate_number}</Link>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-gray-700">
|
||||||
|
{est.shop_name || (est.owner_first_name ? `${est.owner_first_name} ${est.owner_last_name}` : '—')}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-gray-500">
|
||||||
|
{[est.year, est.make, est.model].filter(Boolean).join(' ') || '—'}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-gray-500">{new Date(est.created_at).toLocaleDateString()}</td>
|
||||||
|
<td className="px-4 py-3 text-right font-medium">{fmt(est.total)}</td>
|
||||||
|
<td className="px-4 py-3"><Badge status={est.status}>{statusLabel[est.status]}</Badge></td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<NewEstimateModal open={showNewEstimate} onClose={() => setShowNewEstimate(false)} onSave={(data) => createMutation.mutate(data)} saving={createMutation.isPending} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function NewEstimateModal({ open, onClose, onSave, saving }: { open: boolean; onClose: () => void; onSave: (data: any) => void; saving: boolean }) {
|
||||||
|
const [customerType, setCustomerType] = useState<'shop' | 'owner'>('owner');
|
||||||
|
const [shopId, setShopId] = useState('');
|
||||||
|
const [ownerId, setOwnerId] = useState('');
|
||||||
|
const [vehicleId, setVehicleId] = useState('');
|
||||||
|
const [notes, setNotes] = useState('');
|
||||||
|
|
||||||
|
const { data: shops = [] } = useQuery({ queryKey: ['shops'], queryFn: () => api.getShops({ active: 1 }) });
|
||||||
|
const { data: owners = [] } = useQuery({ queryKey: ['owners'], queryFn: () => api.getOwners() });
|
||||||
|
const { data: vehicles = [] } = useQuery({
|
||||||
|
queryKey: ['vehicles', ownerId],
|
||||||
|
queryFn: () => api.getVehicles(ownerId ? { ownerId: Number(ownerId) } : {}),
|
||||||
|
enabled: customerType === 'owner' ? !!ownerId : true,
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal open={open} onClose={onClose} title="New Estimate">
|
||||||
|
<form onSubmit={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
onSave({
|
||||||
|
customerType: customerType === 'shop' ? 'shop' : 'owner',
|
||||||
|
shopId: customerType === 'shop' ? Number(shopId) : undefined,
|
||||||
|
ownerId: customerType === 'owner' ? Number(ownerId) : undefined,
|
||||||
|
vehicleId: vehicleId ? Number(vehicleId) : undefined,
|
||||||
|
notes: notes || undefined,
|
||||||
|
});
|
||||||
|
}} className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Customer Type</label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button type="button" onClick={() => { setCustomerType('owner'); setShopId(''); }}
|
||||||
|
className={`flex-1 px-3 py-2 text-sm font-medium rounded-lg border transition-colors ${customerType === 'owner' ? 'bg-blue-50 border-blue-300 text-blue-700' : 'border-gray-300 text-gray-600 hover:bg-gray-50'}`}>
|
||||||
|
Customer
|
||||||
|
</button>
|
||||||
|
<button type="button" onClick={() => { setCustomerType('shop'); setOwnerId(''); }}
|
||||||
|
className={`flex-1 px-3 py-2 text-sm font-medium rounded-lg border transition-colors ${customerType === 'shop' ? 'bg-blue-50 border-blue-300 text-blue-700' : 'border-gray-300 text-gray-600 hover:bg-gray-50'}`}>
|
||||||
|
Shop
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{customerType === 'shop' ? (
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Shop *</label>
|
||||||
|
<select className="block w-full rounded-lg border border-gray-300 px-3 py-2 text-sm" value={shopId} onChange={(e) => setShopId(e.target.value)} required>
|
||||||
|
<option value="">Select a shop...</option>
|
||||||
|
{shops.map((s: Shop) => <option key={s.id} value={s.id}>{s.name}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Customer *</label>
|
||||||
|
<select className="block w-full rounded-lg border border-gray-300 px-3 py-2 text-sm" value={ownerId} onChange={(e) => { setOwnerId(e.target.value); setVehicleId(''); }} required>
|
||||||
|
<option value="">Select a customer...</option>
|
||||||
|
{owners.map((o: Owner) => <option key={o.id} value={o.id}>{o.first_name} {o.last_name}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Vehicle</label>
|
||||||
|
<select className="block w-full rounded-lg border border-gray-300 px-3 py-2 text-sm" value={vehicleId} onChange={(e) => setVehicleId(e.target.value)}>
|
||||||
|
<option value="">Select a vehicle...</option>
|
||||||
|
{vehicles.map((v: Vehicle) => <option key={v.id} value={v.id}>{[v.year, v.make, v.model].filter(Boolean).join(' ')}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Notes</label>
|
||||||
|
<textarea className="block w-full rounded-lg border border-gray-300 px-3 py-2 text-sm" rows={2} value={notes} onChange={(e) => setNotes(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-2 pt-2">
|
||||||
|
<Button variant="secondary" type="button" onClick={onClose}>Cancel</Button>
|
||||||
|
<Button type="submit" disabled={saving}>{saving ? 'Creating...' : 'Create Estimate'}</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
154
client/src/pages/Inventory.tsx
Normal file
154
client/src/pages/Inventory.tsx
Normal file
@@ -0,0 +1,154 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { Package, Plus, Search, AlertTriangle } from 'lucide-react';
|
||||||
|
import { api } from '../api/client';
|
||||||
|
import { Card, Button, Badge, Input, Modal, EmptyState } from '../components/ui';
|
||||||
|
|
||||||
|
const fmt = (n: number) => '$' + (n || 0).toFixed(2);
|
||||||
|
|
||||||
|
const typeFilters = [
|
||||||
|
{ value: '', label: 'All' },
|
||||||
|
{ value: 'parts', label: 'Parts' },
|
||||||
|
{ value: 'supply', label: 'Supplies' },
|
||||||
|
{ value: 'consumable', label: 'Consumables' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function InventoryPage() {
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
|
const [typeFilter, setTypeFilter] = useState('');
|
||||||
|
const [lowStockOnly, setLowStockOnly] = useState(false);
|
||||||
|
const [showModal, setShowModal] = useState(false);
|
||||||
|
const [editItem, setEditItem] = useState<any>(null);
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const { data: items = [], isLoading } = useQuery({
|
||||||
|
queryKey: ['inventory', search, typeFilter, lowStockOnly],
|
||||||
|
queryFn: () => api.getInventoryItems({
|
||||||
|
search: search || undefined,
|
||||||
|
itemType: typeFilter || undefined,
|
||||||
|
lowStock: lowStockOnly ? 1 : undefined,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const saveMutation = useMutation({
|
||||||
|
mutationFn: (data: any) => editItem ? api.updateInventoryItem(editItem.id, data) : api.createInventoryItem(data),
|
||||||
|
onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['inventory'] }); setShowModal(false); setEditItem(null); },
|
||||||
|
});
|
||||||
|
|
||||||
|
const lowStockCount = items.filter((i: any) => i.quantity_on_hand <= i.reorder_point && i.reorder_point > 0).length;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900">Inventory</h1>
|
||||||
|
<Button onClick={() => { setEditItem(null); setShowModal(true); }}><Plus size={16} /> Add Item</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-3 flex-col sm:flex-row">
|
||||||
|
<div className="relative flex-1">
|
||||||
|
<Search size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400" />
|
||||||
|
<input className="w-full pl-9 pr-3 py-2 text-sm border border-gray-300 rounded-lg focus:border-blue-500 focus:ring-1 focus:ring-blue-500 outline-none" placeholder="Search inventory..." value={search} onChange={(e) => setSearch(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-1">
|
||||||
|
{typeFilters.map(f => (
|
||||||
|
<button key={f.value} onClick={() => setTypeFilter(f.value)}
|
||||||
|
className={`px-3 py-1.5 text-xs font-medium rounded-lg ${typeFilter === f.value ? 'bg-blue-100 text-blue-700' : 'bg-gray-100 text-gray-600 hover:bg-gray-200'}`}>
|
||||||
|
{f.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
<button onClick={() => setLowStockOnly(!lowStockOnly)}
|
||||||
|
className={`px-3 py-1.5 text-xs font-medium rounded-lg flex items-center gap-1 ${lowStockOnly ? 'bg-red-100 text-red-700' : 'bg-gray-100 text-gray-600 hover:bg-gray-200'}`}>
|
||||||
|
<AlertTriangle size={12} /> Low ({lowStockCount})
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isLoading ? <p className="text-gray-500 py-4">Loading...</p> : items.length === 0 ? (
|
||||||
|
<EmptyState icon={Package} title="No inventory items" description="Add parts, supplies, and consumables to track your inventory." action={<Button onClick={() => setShowModal(true)}>Add Item</Button>} />
|
||||||
|
) : (
|
||||||
|
<Card>
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-gray-200 text-left text-xs text-gray-500 uppercase">
|
||||||
|
<th className="px-4 py-3">Item</th>
|
||||||
|
<th className="px-4 py-3">Part #</th>
|
||||||
|
<th className="px-4 py-3">Type</th>
|
||||||
|
<th className="px-4 py-3 text-right">On Hand</th>
|
||||||
|
<th className="px-4 py-3 text-right">Reorder Pt</th>
|
||||||
|
<th className="px-4 py-3 text-right">Unit Cost</th>
|
||||||
|
<th className="px-4 py-3">Supplier</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-gray-100">
|
||||||
|
{items.map((item: any) => {
|
||||||
|
const isLow = item.quantity_on_hand <= item.reorder_point && item.reorder_point > 0;
|
||||||
|
return (
|
||||||
|
<tr key={item.id} className={`hover:bg-gray-50 ${isLow ? 'bg-red-50' : ''}`}>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<button onClick={() => { setEditItem(item); setShowModal(true); }} className="font-medium text-blue-600 hover:underline">{item.name}</button>
|
||||||
|
{item.category && <p className="text-xs text-gray-400">{item.category}</p>}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-gray-500 font-mono text-xs">{item.part_number || '—'}</td>
|
||||||
|
<td className="px-4 py-3"><Badge color="gray">{item.item_type}</Badge></td>
|
||||||
|
<td className={`px-4 py-3 text-right font-medium ${isLow ? 'text-red-600' : ''}`}>{item.quantity_on_hand}</td>
|
||||||
|
<td className="px-4 py-3 text-right text-gray-500">{item.reorder_point}</td>
|
||||||
|
<td className="px-4 py-3 text-right">{fmt(item.unit_cost)}</td>
|
||||||
|
<td className="px-4 py-3 text-gray-500">{item.supplier || '—'}</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<InventoryItemModal open={showModal} onClose={() => { setShowModal(false); setEditItem(null); }}
|
||||||
|
item={editItem} onSave={(data) => saveMutation.mutate(data)} saving={saveMutation.isPending} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function InventoryItemModal({ open, onClose, item, onSave, saving }: {
|
||||||
|
open: boolean; onClose: () => void; item: any; onSave: (data: any) => void; saving: boolean;
|
||||||
|
}) {
|
||||||
|
const [form, setForm] = useState({
|
||||||
|
name: item?.name || '', partNumber: item?.part_number || '', description: item?.description || '',
|
||||||
|
category: item?.category || '', itemType: item?.item_type || 'parts',
|
||||||
|
quantityOnHand: item?.quantity_on_hand?.toString() || '0', reorderPoint: item?.reorder_point?.toString() || '0',
|
||||||
|
unitCost: item?.unit_cost?.toString() || '0', supplier: item?.supplier || '', location: item?.location || '',
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal open={open} onClose={onClose} title={item ? 'Edit Item' : 'Add Inventory Item'}>
|
||||||
|
<form onSubmit={(e) => { e.preventDefault(); onSave({ ...form, quantityOnHand: parseFloat(form.quantityOnHand), reorderPoint: parseFloat(form.reorderPoint), unitCost: parseFloat(form.unitCost) }); }} className="space-y-3">
|
||||||
|
<Input label="Name *" value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} required />
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<Input label="Part Number" value={form.partNumber} onChange={(e) => setForm({ ...form, partNumber: e.target.value })} />
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Type</label>
|
||||||
|
<select className="block w-full rounded-lg border border-gray-300 px-3 py-2 text-sm" value={form.itemType} onChange={(e) => setForm({ ...form, itemType: e.target.value })}>
|
||||||
|
<option value="parts">Parts</option>
|
||||||
|
<option value="supply">Supply</option>
|
||||||
|
<option value="consumable">Consumable</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-3 gap-3">
|
||||||
|
<Input label="Qty On Hand" type="number" value={form.quantityOnHand} onChange={(e) => setForm({ ...form, quantityOnHand: e.target.value })} />
|
||||||
|
<Input label="Reorder Point" type="number" value={form.reorderPoint} onChange={(e) => setForm({ ...form, reorderPoint: e.target.value })} />
|
||||||
|
<Input label="Unit Cost" type="number" step="0.01" value={form.unitCost} onChange={(e) => setForm({ ...form, unitCost: e.target.value })} />
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<Input label="Supplier" value={form.supplier} onChange={(e) => setForm({ ...form, supplier: e.target.value })} />
|
||||||
|
<Input label="Category" value={form.category} onChange={(e) => setForm({ ...form, category: e.target.value })} />
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-end gap-2 pt-2">
|
||||||
|
<Button variant="secondary" type="button" onClick={onClose}>Cancel</Button>
|
||||||
|
<Button type="submit" disabled={saving}>{saving ? 'Saving...' : 'Save'}</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
312
client/src/pages/InvoiceDetail.tsx
Normal file
312
client/src/pages/InvoiceDetail.tsx
Normal file
@@ -0,0 +1,312 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { useParams, Link, useNavigate } from 'react-router-dom';
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { ArrowLeft, Download, Send, Trash2, DollarSign } from 'lucide-react';
|
||||||
|
import { api } from '../api/client';
|
||||||
|
import { Card, Button, Badge, Modal, Input } from '../components/ui';
|
||||||
|
import FileUpload from '../components/FileUpload';
|
||||||
|
import type { Invoice, Payment, LaborItem, PartsItem } from '../types';
|
||||||
|
|
||||||
|
const fmt = (n: number) => '$' + n.toFixed(2);
|
||||||
|
|
||||||
|
const statusLabel: Record<string, string> = {
|
||||||
|
draft: 'Draft', sent: 'Sent', paid: 'Paid', partial: 'Partial', overdue: 'Overdue',
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function InvoiceDetail() {
|
||||||
|
const { id } = useParams();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [showPaymentModal, setShowPaymentModal] = useState(false);
|
||||||
|
|
||||||
|
const { data: invoice, isLoading } = useQuery<Invoice>({
|
||||||
|
queryKey: ['invoices', id],
|
||||||
|
queryFn: () => api.getInvoice(Number(id)),
|
||||||
|
});
|
||||||
|
|
||||||
|
const invalidate = () => { queryClient.invalidateQueries({ queryKey: ['invoices', id] }); queryClient.invalidateQueries({ queryKey: ['invoices'] }); };
|
||||||
|
|
||||||
|
const statusMutation = useMutation({
|
||||||
|
mutationFn: (status: string) => api.updateInvoice(Number(id), { status }),
|
||||||
|
onSuccess: invalidate,
|
||||||
|
});
|
||||||
|
|
||||||
|
const deletePaymentMutation = useMutation({
|
||||||
|
mutationFn: (pid: number) => api.deletePayment(Number(id), pid),
|
||||||
|
onSuccess: invalidate,
|
||||||
|
});
|
||||||
|
|
||||||
|
const paymentMutation = useMutation({
|
||||||
|
mutationFn: (data: any) => api.addPayment(Number(id), data),
|
||||||
|
onSuccess: () => { invalidate(); setShowPaymentModal(false); },
|
||||||
|
});
|
||||||
|
|
||||||
|
const deleteMutation = useMutation({
|
||||||
|
mutationFn: () => api.deleteInvoice(Number(id)),
|
||||||
|
onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['invoices'] }); navigate('/invoices'); },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (isLoading || !invoice) return <p className="text-gray-500 py-4">Loading...</p>;
|
||||||
|
|
||||||
|
const openPdf = () => {
|
||||||
|
const token = localStorage.getItem('autoking_token');
|
||||||
|
window.open(`/api/invoices/${id}/pdf?token=${token}`, '_blank');
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<Link to="/invoices" className="inline-flex items-center gap-1.5 text-sm text-gray-500 hover:text-gray-700">
|
||||||
|
<ArrowLeft size={16} /> Back to Invoices
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900">{invoice.invoice_number}</h1>
|
||||||
|
<Badge status={invoice.status}>{statusLabel[invoice.status]}</Badge>
|
||||||
|
</div>
|
||||||
|
{invoice.shop_id ? (
|
||||||
|
<Link to={`/shops/${invoice.shop_id}`} className="text-sm text-blue-600 hover:underline">{(invoice as any).shop_name}</Link>
|
||||||
|
) : invoice.owner_id ? (
|
||||||
|
<Link to={`/owners/${invoice.owner_id}`} className="text-sm text-blue-600 hover:underline">{(invoice as any).owner_first_name} {(invoice as any).owner_last_name}</Link>
|
||||||
|
) : null}
|
||||||
|
<p className="text-sm text-gray-500 mt-0.5">
|
||||||
|
Issued: {new Date(invoice.invoice_date).toLocaleDateString()}
|
||||||
|
{invoice.due_date && ` — Due: ${new Date(invoice.due_date).toLocaleDateString()}`}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2 flex-wrap">
|
||||||
|
<Button variant="secondary" onClick={openPdf}><Download size={16} /> PDF</Button>
|
||||||
|
{invoice.status === 'draft' && (
|
||||||
|
<Button onClick={() => statusMutation.mutate('sent')}><Send size={16} /> Mark Sent</Button>
|
||||||
|
)}
|
||||||
|
<Button variant="secondary" onClick={() => setShowPaymentModal(true)}><DollarSign size={16} /> Record Payment</Button>
|
||||||
|
<Button variant="danger" onClick={() => { if (confirm('Delete this invoice? Jobs will revert to completed.')) deleteMutation.mutate(); }}>
|
||||||
|
<Trash2 size={16} /> Delete
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Bill To */}
|
||||||
|
<Card className="p-4">
|
||||||
|
<h3 className="text-xs font-medium text-gray-500 uppercase mb-2">Bill To</h3>
|
||||||
|
{invoice.shop_id ? (
|
||||||
|
<>
|
||||||
|
<p className="font-medium text-gray-900">{(invoice as any).shop_name}</p>
|
||||||
|
{(invoice as any).shop_contact && <p className="text-sm text-gray-700">Attn: {(invoice as any).shop_contact}</p>}
|
||||||
|
{(invoice as any).shop_address && <p className="text-sm text-gray-500">{(invoice as any).shop_address}</p>}
|
||||||
|
{((invoice as any).shop_city || (invoice as any).shop_state) && (
|
||||||
|
<p className="text-sm text-gray-500">{[(invoice as any).shop_city, (invoice as any).shop_state, (invoice as any).shop_zip].filter(Boolean).join(', ')}</p>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : invoice.owner_id ? (
|
||||||
|
<>
|
||||||
|
<p className="font-medium text-gray-900">{(invoice as any).owner_first_name} {(invoice as any).owner_last_name}</p>
|
||||||
|
{(invoice as any).owner_phone && <p className="text-sm text-gray-500">{(invoice as any).owner_phone}</p>}
|
||||||
|
{(invoice as any).owner_email && <p className="text-sm text-gray-500">{(invoice as any).owner_email}</p>}
|
||||||
|
</>
|
||||||
|
) : null}
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Jobs & Line Items */}
|
||||||
|
{invoice.jobs?.map((job: any) => (
|
||||||
|
<Card key={job.id}>
|
||||||
|
<div className="p-4 border-b border-gray-200">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<Link to={`/jobs/${job.id}`} className="font-medium text-blue-600 hover:underline">{job.job_number}</Link>
|
||||||
|
<p className="text-sm text-gray-500">{[job.year, job.make, job.model].filter(Boolean).join(' ')} — {job.first_name} {job.last_name}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{job.description && <p className="text-sm text-gray-600 mt-1">{job.description}</p>}
|
||||||
|
</div>
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<tbody className="divide-y divide-gray-100">
|
||||||
|
{job.laborItems?.map((item: LaborItem) => {
|
||||||
|
const lineTotal = item.billing_type === 'flat_rate' ? (item.flat_rate || 0) : (item.hours || 0) * (item.rate || 0);
|
||||||
|
return (
|
||||||
|
<tr key={`l-${item.id}`}>
|
||||||
|
<td className="px-4 py-2 text-gray-700">{item.description}</td>
|
||||||
|
<td className="px-4 py-2 text-gray-500 text-right">{item.billing_type === 'flat_rate' ? 'Flat rate' : `${item.hours}h @ ${fmt(item.rate || 0)}/hr`}</td>
|
||||||
|
<td className="px-4 py-2 text-right font-medium w-24">{fmt(lineTotal)}</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{job.partsItems?.map((item: PartsItem) => (
|
||||||
|
<tr key={`p-${item.id}`}>
|
||||||
|
<td className="px-4 py-2 text-gray-700">{item.description} {item.part_number && <span className="text-gray-400 text-xs">({item.part_number})</span>}</td>
|
||||||
|
<td className="px-4 py-2 text-gray-500 text-right">{item.quantity} x {fmt(item.unit_cost * (1 + item.markup))}</td>
|
||||||
|
<td className="px-4 py-2 text-right font-medium w-24">{fmt(item.quantity * item.unit_cost * (1 + item.markup))}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{/* Totals */}
|
||||||
|
<Card className="p-4 space-y-2">
|
||||||
|
<div className="flex justify-between text-sm">
|
||||||
|
<span className="text-gray-600">Subtotal</span>
|
||||||
|
<span className="font-medium">{fmt(invoice.subtotal)}</span>
|
||||||
|
</div>
|
||||||
|
{invoice.tax_rate > 0 && (
|
||||||
|
<div className="flex justify-between text-sm">
|
||||||
|
<span className="text-gray-600">Tax ({(invoice.tax_rate * 100).toFixed(1)}%)</span>
|
||||||
|
<span className="font-medium">{fmt(invoice.tax_amount)}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex justify-between text-lg font-bold border-t border-gray-200 pt-2">
|
||||||
|
<span>Total</span>
|
||||||
|
<span>{fmt(invoice.total)}</span>
|
||||||
|
</div>
|
||||||
|
{(invoice.amountPaid ?? 0) > 0 && (
|
||||||
|
<>
|
||||||
|
<div className="flex justify-between text-sm text-green-600">
|
||||||
|
<span>Paid</span>
|
||||||
|
<span>-{fmt(invoice.amountPaid!)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between text-lg font-bold text-yellow-600">
|
||||||
|
<span>Amount Due</span>
|
||||||
|
<span>{fmt(invoice.amountDue || 0)}</span>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Payments */}
|
||||||
|
{invoice.payments && invoice.payments.length > 0 && (
|
||||||
|
<Card>
|
||||||
|
<div className="p-4 border-b border-gray-200">
|
||||||
|
<h2 className="font-semibold text-gray-900">Payments</h2>
|
||||||
|
</div>
|
||||||
|
<div className="divide-y divide-gray-100">
|
||||||
|
{invoice.payments.map((p: Payment) => (
|
||||||
|
<div key={p.id} className="flex items-center justify-between p-3">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-gray-900">{fmt(p.amount)}</p>
|
||||||
|
<p className="text-xs text-gray-500">
|
||||||
|
{new Date(p.payment_date).toLocaleDateString()} — {p.method || 'Unknown'}
|
||||||
|
{p.reference && ` (${p.reference})`}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<button onClick={() => deletePaymentMutation.mutate(p.id)} className="text-gray-400 hover:text-red-500">
|
||||||
|
<Trash2 size={14} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Files */}
|
||||||
|
<Card className="p-4">
|
||||||
|
<FileUpload entityType="invoice" entityId={Number(id)} />
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<InvoiceNotes invoiceId={Number(id)} notes={invoice.notes || ''} onUpdate={invalidate} />
|
||||||
|
|
||||||
|
{/* Version History */}
|
||||||
|
{(invoice as any).versions?.length > 0 && (
|
||||||
|
<Card>
|
||||||
|
<div className="p-4 border-b border-gray-200">
|
||||||
|
<h2 className="font-semibold text-gray-900">Revision History</h2>
|
||||||
|
</div>
|
||||||
|
<div className="divide-y divide-gray-100">
|
||||||
|
{(invoice as any).versions.map((v: any) => (
|
||||||
|
<div key={v.id} className="p-3 text-sm">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-gray-500">v{v.version_number} — {new Date(v.created_at).toLocaleString()}</span>
|
||||||
|
<span className="font-medium text-gray-700">{fmt(v.total)}</span>
|
||||||
|
</div>
|
||||||
|
{v.reason && <p className="text-xs text-gray-400 mt-0.5">{v.reason}</p>}
|
||||||
|
{v.total !== (invoice as any).versions[0]?.total && (
|
||||||
|
<p className="text-xs text-gray-400">
|
||||||
|
Subtotal: {fmt(v.subtotal)}{v.tax_amount > 0 && ` + tax: ${fmt(v.tax_amount)}`}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<PaymentModal open={showPaymentModal} onClose={() => setShowPaymentModal(false)} onSave={(data) => paymentMutation.mutate(data)} saving={paymentMutation.isPending} balanceDue={invoice.amountDue || invoice.total} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function InvoiceNotes({ invoiceId, notes, onUpdate }: { invoiceId: number; notes: string; onUpdate: () => void }) {
|
||||||
|
const [editing, setEditing] = useState(false);
|
||||||
|
const [value, setValue] = useState(notes);
|
||||||
|
|
||||||
|
const mutation = useMutation({
|
||||||
|
mutationFn: (newNotes: string) => api.updateInvoice(invoiceId, { notes: newNotes }),
|
||||||
|
onSuccess: () => { onUpdate(); setEditing(false); },
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="p-4">
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<h3 className="text-xs font-medium text-gray-500 uppercase">Invoice Notes</h3>
|
||||||
|
{!editing && (
|
||||||
|
<button onClick={() => { setValue(notes); setEditing(true); }} className="text-xs text-blue-600 hover:text-blue-800">
|
||||||
|
{notes ? 'Edit' : 'Add notes'}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{editing ? (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<textarea
|
||||||
|
className="block w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 outline-none"
|
||||||
|
rows={4} value={value} onChange={(e) => setValue(e.target.value)}
|
||||||
|
placeholder="Describe what was done, process notes, etc. This prints on the PDF."
|
||||||
|
/>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button size="sm" onClick={() => mutation.mutate(value)} disabled={mutation.isPending}>
|
||||||
|
{mutation.isPending ? 'Saving...' : 'Save'}
|
||||||
|
</Button>
|
||||||
|
<Button size="sm" variant="ghost" onClick={() => setEditing(false)}>Cancel</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : notes ? (
|
||||||
|
<p className="text-sm text-gray-700 whitespace-pre-wrap">{notes}</p>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-gray-400">No notes yet. Click "Add notes" to describe your process.</p>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PaymentModal({ open, onClose, onSave, saving, balanceDue }: { open: boolean; onClose: () => void; onSave: (data: any) => void; saving: boolean; balanceDue: number }) {
|
||||||
|
const [amount, setAmount] = useState(String(balanceDue));
|
||||||
|
const [paymentDate, setPaymentDate] = useState(new Date().toISOString().split('T')[0]);
|
||||||
|
const [method, setMethod] = useState('check');
|
||||||
|
const [reference, setReference] = useState('');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal open={open} onClose={onClose} title="Record Payment">
|
||||||
|
<form onSubmit={(e) => { e.preventDefault(); onSave({ amount: parseFloat(amount), paymentDate, method, reference: reference || null }); }} className="space-y-3">
|
||||||
|
<Input label="Amount *" type="number" step="0.01" value={amount} onChange={(e) => setAmount(e.target.value)} required />
|
||||||
|
<Input label="Date *" type="date" value={paymentDate} onChange={(e) => setPaymentDate(e.target.value)} required />
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Method</label>
|
||||||
|
<select className="block w-full rounded-lg border border-gray-300 px-3 py-2 text-sm" value={method} onChange={(e) => setMethod(e.target.value)}>
|
||||||
|
<option value="check">Check</option>
|
||||||
|
<option value="cash">Cash</option>
|
||||||
|
<option value="card">Card</option>
|
||||||
|
<option value="ach">ACH</option>
|
||||||
|
<option value="other">Other</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<Input label="Reference (check #, etc.)" value={reference} onChange={(e) => setReference(e.target.value)} />
|
||||||
|
<div className="flex justify-end gap-2 pt-2">
|
||||||
|
<Button variant="secondary" type="button" onClick={onClose}>Cancel</Button>
|
||||||
|
<Button type="submit" disabled={saving}>{saving ? 'Saving...' : 'Record Payment'}</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
209
client/src/pages/Invoices.tsx
Normal file
209
client/src/pages/Invoices.tsx
Normal file
@@ -0,0 +1,209 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { FileText, Plus } from 'lucide-react';
|
||||||
|
import { api } from '../api/client';
|
||||||
|
import { Card, Button, Badge, Modal, EmptyState } from '../components/ui';
|
||||||
|
import type { Invoice, Shop, Owner, Job } from '../types';
|
||||||
|
|
||||||
|
const fmt = (n: number) => '$' + n.toFixed(2);
|
||||||
|
|
||||||
|
const statusLabel: Record<string, string> = {
|
||||||
|
draft: 'Draft', sent: 'Sent', paid: 'Paid', partial: 'Partial', overdue: 'Overdue',
|
||||||
|
};
|
||||||
|
|
||||||
|
const statusFilters = [
|
||||||
|
{ value: '', label: 'All' },
|
||||||
|
{ value: 'draft', label: 'Draft' },
|
||||||
|
{ value: 'sent', label: 'Sent' },
|
||||||
|
{ value: 'partial', label: 'Partial' },
|
||||||
|
{ value: 'overdue', label: 'Overdue' },
|
||||||
|
{ value: 'paid', label: 'Paid' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function Invoices() {
|
||||||
|
const [statusFilter, setStatusFilter] = useState('');
|
||||||
|
const [showNewInvoice, setShowNewInvoice] = useState(false);
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const { data: invoices = [], isLoading } = useQuery({
|
||||||
|
queryKey: ['invoices', statusFilter],
|
||||||
|
queryFn: () => api.getInvoices({ status: statusFilter || undefined }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const createMutation = useMutation({
|
||||||
|
mutationFn: api.createInvoice,
|
||||||
|
onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['invoices'] }); queryClient.invalidateQueries({ queryKey: ['jobs'] }); setShowNewInvoice(false); },
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900">Invoices</h1>
|
||||||
|
<Button onClick={() => setShowNewInvoice(true)}><Plus size={16} /> New Invoice</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-1 flex-wrap">
|
||||||
|
{statusFilters.map((s) => (
|
||||||
|
<button
|
||||||
|
key={s.value}
|
||||||
|
onClick={() => setStatusFilter(s.value)}
|
||||||
|
className={`px-3 py-1.5 text-xs font-medium rounded-lg transition-colors ${
|
||||||
|
statusFilter === s.value ? 'bg-blue-100 text-blue-700' : 'bg-gray-100 text-gray-600 hover:bg-gray-200'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{s.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isLoading ? <p className="text-gray-500 py-4">Loading...</p> : invoices.length === 0 ? (
|
||||||
|
<EmptyState icon={FileText} title="No invoices" description="Create an invoice from completed jobs." action={<Button onClick={() => setShowNewInvoice(true)}>New Invoice</Button>} />
|
||||||
|
) : (
|
||||||
|
<Card>
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-gray-200 text-left text-xs text-gray-500 uppercase">
|
||||||
|
<th className="px-4 py-3">Invoice</th>
|
||||||
|
<th className="px-4 py-3">Bill To</th>
|
||||||
|
<th className="px-4 py-3">Date</th>
|
||||||
|
<th className="px-4 py-3 text-right">Total</th>
|
||||||
|
<th className="px-4 py-3 text-right">Due</th>
|
||||||
|
<th className="px-4 py-3">Status</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-gray-100">
|
||||||
|
{invoices.map((inv: Invoice) => (
|
||||||
|
<tr key={inv.id} className="hover:bg-gray-50">
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<Link to={`/invoices/${inv.id}`} className="font-medium text-blue-600 hover:underline">{inv.invoice_number}</Link>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-gray-700">
|
||||||
|
{inv.display_name || inv.shop_name || (inv.owner_first_name ? `${inv.owner_first_name} ${inv.owner_last_name}` : '—')}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-gray-500">{new Date(inv.invoice_date).toLocaleDateString()}</td>
|
||||||
|
<td className="px-4 py-3 text-right font-medium">{fmt(inv.total)}</td>
|
||||||
|
<td className="px-4 py-3 text-right font-medium text-yellow-600">{fmt(inv.amountDue || 0)}</td>
|
||||||
|
<td className="px-4 py-3"><Badge status={inv.status}>{statusLabel[inv.status]}</Badge></td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<NewInvoiceModal open={showNewInvoice} onClose={() => setShowNewInvoice(false)} onSave={(data) => createMutation.mutate(data)} saving={createMutation.isPending} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function NewInvoiceModal({ open, onClose, onSave, saving }: { open: boolean; onClose: () => void; onSave: (data: any) => void; saving: boolean }) {
|
||||||
|
const [billType, setBillType] = useState<'shop' | 'owner'>('shop');
|
||||||
|
const [shopId, setShopId] = useState('');
|
||||||
|
const [ownerId, setOwnerId] = useState('');
|
||||||
|
const [selectedJobs, setSelectedJobs] = useState<number[]>([]);
|
||||||
|
const [notes, setNotes] = useState('');
|
||||||
|
|
||||||
|
const { data: shops = [] } = useQuery({ queryKey: ['shops'], queryFn: () => api.getShops({ active: 1 }) });
|
||||||
|
const { data: owners = [] } = useQuery({ queryKey: ['owners'], queryFn: () => api.getOwners() });
|
||||||
|
|
||||||
|
const selectedId = billType === 'shop' ? shopId : ownerId;
|
||||||
|
const jobQueryParams = billType === 'shop'
|
||||||
|
? { status: 'completed', shopId: Number(shopId) }
|
||||||
|
: { status: 'completed' };
|
||||||
|
|
||||||
|
const { data: allJobs = [] } = useQuery({
|
||||||
|
queryKey: ['jobs', 'completed', billType, selectedId],
|
||||||
|
queryFn: () => api.getJobs(jobQueryParams),
|
||||||
|
enabled: billType === 'shop' ? !!shopId : !!ownerId,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Filter owner jobs client-side (jobs where owner_id matches and customer_type is 'owner')
|
||||||
|
const jobs = billType === 'owner'
|
||||||
|
? allJobs.filter((j: Job) => j.owner_id === Number(ownerId) && j.customer_type === 'owner')
|
||||||
|
: allJobs;
|
||||||
|
|
||||||
|
const toggleJob = (id: number) => {
|
||||||
|
setSelectedJobs((prev) => prev.includes(id) ? prev.filter((j) => j !== id) : [...prev, id]);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const data: any = { jobIds: selectedJobs, notes: notes || undefined };
|
||||||
|
if (billType === 'shop') data.shopId = Number(shopId);
|
||||||
|
else data.ownerId = Number(ownerId);
|
||||||
|
onSave(data);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal open={open} onClose={onClose} title="Create Invoice">
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
{/* Bill type toggle */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Invoice Type</label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button type="button" onClick={() => { setBillType('shop'); setSelectedJobs([]); setOwnerId(''); }}
|
||||||
|
className={`flex-1 px-3 py-2 text-sm font-medium rounded-lg border transition-colors ${billType === 'shop' ? 'bg-blue-50 border-blue-300 text-blue-700' : 'border-gray-300 text-gray-600 hover:bg-gray-50'}`}>
|
||||||
|
Shop
|
||||||
|
</button>
|
||||||
|
<button type="button" onClick={() => { setBillType('owner'); setSelectedJobs([]); setShopId(''); }}
|
||||||
|
className={`flex-1 px-3 py-2 text-sm font-medium rounded-lg border transition-colors ${billType === 'owner' ? 'bg-blue-50 border-blue-300 text-blue-700' : 'border-gray-300 text-gray-600 hover:bg-gray-50'}`}>
|
||||||
|
Customer
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{billType === 'shop' ? (
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Shop *</label>
|
||||||
|
<select className="block w-full rounded-lg border border-gray-300 px-3 py-2 text-sm" value={shopId} onChange={(e) => { setShopId(e.target.value); setSelectedJobs([]); }} required>
|
||||||
|
<option value="">Select a shop...</option>
|
||||||
|
{shops.map((s: Shop) => <option key={s.id} value={s.id}>{s.name}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Customer *</label>
|
||||||
|
<select className="block w-full rounded-lg border border-gray-300 px-3 py-2 text-sm" value={ownerId} onChange={(e) => { setOwnerId(e.target.value); setSelectedJobs([]); }} required>
|
||||||
|
<option value="">Select a customer...</option>
|
||||||
|
{owners.map((o: Owner) => <option key={o.id} value={o.id}>{o.first_name} {o.last_name}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{(billType === 'shop' ? shopId : ownerId) && (
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Completed Jobs *</label>
|
||||||
|
{jobs.length === 0 ? (
|
||||||
|
<p className="text-sm text-gray-500">No completed jobs for this {billType === 'shop' ? 'shop' : 'customer'}.</p>
|
||||||
|
) : (
|
||||||
|
<div className="border border-gray-200 rounded-lg divide-y divide-gray-100 max-h-60 overflow-y-auto">
|
||||||
|
{jobs.map((job: Job) => (
|
||||||
|
<label key={job.id} className="flex items-center gap-3 p-3 hover:bg-gray-50 cursor-pointer">
|
||||||
|
<input type="checkbox" checked={selectedJobs.includes(job.id)} onChange={() => toggleJob(job.id)} className="rounded" />
|
||||||
|
<div className="flex-1">
|
||||||
|
<p className="text-sm font-medium text-gray-900">{job.job_number}</p>
|
||||||
|
<p className="text-xs text-gray-500">{[job.year, job.make, job.model].filter(Boolean).join(' ')} — {job.first_name} {job.last_name}</p>
|
||||||
|
</div>
|
||||||
|
</label>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Notes</label>
|
||||||
|
<textarea className="block w-full rounded-lg border border-gray-300 px-3 py-2 text-sm" rows={2} value={notes} onChange={(e) => setNotes(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-2 pt-2">
|
||||||
|
<Button variant="secondary" type="button" onClick={onClose}>Cancel</Button>
|
||||||
|
<Button type="submit" disabled={saving || selectedJobs.length === 0}>{saving ? 'Creating...' : 'Create Invoice'}</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
972
client/src/pages/JobDetail.tsx
Normal file
972
client/src/pages/JobDetail.tsx
Normal file
@@ -0,0 +1,972 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { useParams, Link, useNavigate } from 'react-router-dom';
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { ArrowLeft, Plus, Trash2, FileText, AlertTriangle, Pencil, Eye, EyeOff } from 'lucide-react';
|
||||||
|
import { api } from '../api/client';
|
||||||
|
import { Card, Button, Input, Modal } from '../components/ui';
|
||||||
|
import TagPicker from '../components/TagPicker';
|
||||||
|
import FileUpload from '../components/FileUpload';
|
||||||
|
import InspectionPanel from '../components/InspectionPanel';
|
||||||
|
import type { Job, LaborItem, PartsItem, JobNote, JobStatus } from '../types';
|
||||||
|
|
||||||
|
const fmt = (n: number) => '$' + n.toFixed(2);
|
||||||
|
|
||||||
|
const statusLabel: Record<string, string> = {
|
||||||
|
received: 'Received', in_progress: 'In Progress', completed: 'Completed', invoiced: 'Invoiced', closed: 'Closed',
|
||||||
|
};
|
||||||
|
|
||||||
|
const statusSteps: { status: JobStatus; label: string; color: string; activeColor: string }[] = [
|
||||||
|
{ status: 'received', label: 'Received', color: 'bg-gray-200', activeColor: 'bg-blue-500' },
|
||||||
|
{ status: 'in_progress', label: 'In Progress', color: 'bg-gray-200', activeColor: 'bg-yellow-500' },
|
||||||
|
{ status: 'completed', label: 'Completed', color: 'bg-gray-200', activeColor: 'bg-green-500' },
|
||||||
|
{ status: 'invoiced', label: 'Invoiced', color: 'bg-gray-200', activeColor: 'bg-purple-500' },
|
||||||
|
{ status: 'closed', label: 'Closed', color: 'bg-gray-200', activeColor: 'bg-gray-500' },
|
||||||
|
];
|
||||||
|
|
||||||
|
function StatusTracker({ currentStatus, onChangeStatus, disabled }: { currentStatus: string; onChangeStatus: (s: string) => void; disabled: boolean }) {
|
||||||
|
const currentIdx = statusSteps.findIndex((s) => s.status === currentStatus);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex rounded-xl overflow-hidden border border-gray-200 shadow-sm">
|
||||||
|
{statusSteps.map((step, idx) => {
|
||||||
|
const isActive = idx <= currentIdx;
|
||||||
|
const isCurrent = idx === currentIdx;
|
||||||
|
const bgColor = isActive
|
||||||
|
? step.status === 'received' ? 'bg-blue-500'
|
||||||
|
: step.status === 'in_progress' ? 'bg-yellow-500'
|
||||||
|
: step.status === 'completed' ? 'bg-green-500'
|
||||||
|
: step.status === 'invoiced' ? 'bg-purple-500'
|
||||||
|
: 'bg-gray-500'
|
||||||
|
: 'bg-gray-100';
|
||||||
|
const textColor = isActive ? 'text-white' : 'text-gray-400';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={step.status}
|
||||||
|
onClick={() => { if (!disabled && step.status !== currentStatus) onChangeStatus(step.status); }}
|
||||||
|
disabled={disabled}
|
||||||
|
className={`flex-1 py-2.5 px-2 text-center text-xs font-semibold transition-all relative ${bgColor} ${textColor} ${
|
||||||
|
disabled ? 'cursor-wait' : 'cursor-pointer hover:brightness-110'
|
||||||
|
} ${isCurrent ? 'ring-2 ring-inset ring-white/40' : ''}`}
|
||||||
|
>
|
||||||
|
{step.label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function MileageField({ label, value, onSave }: { label: string; value: number | null; onSave: (v: number | null) => void }) {
|
||||||
|
const [editing, setEditing] = useState(false);
|
||||||
|
const [input, setInput] = useState(value?.toString() || '');
|
||||||
|
|
||||||
|
if (editing) {
|
||||||
|
return (
|
||||||
|
<span className="inline-flex items-center gap-1">
|
||||||
|
{label}:
|
||||||
|
<input className="w-20 border border-gray-300 rounded px-1.5 py-0.5 text-xs focus:border-blue-500 outline-none"
|
||||||
|
type="number" value={input} onChange={(e) => setInput(e.target.value)} autoFocus
|
||||||
|
onBlur={() => { onSave(input ? parseInt(input) : null); setEditing(false); }}
|
||||||
|
onKeyDown={(e) => { if (e.key === 'Enter') { onSave(input ? parseInt(input) : null); setEditing(false); } if (e.key === 'Escape') setEditing(false); }}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span className="cursor-pointer hover:text-blue-600" onClick={() => { setInput(value?.toString() || ''); setEditing(true); }}>
|
||||||
|
{label}: <span className="font-medium">{value ? value.toLocaleString() : 'click to set'}</span>
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function JobDetail() {
|
||||||
|
const { id } = useParams();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [showLaborModal, setShowLaborModal] = useState(false);
|
||||||
|
const [showPartsModal, setShowPartsModal] = useState(false);
|
||||||
|
const [editLabor, setEditLabor] = useState<LaborItem | null>(null);
|
||||||
|
const [editParts, setEditParts] = useState<PartsItem | null>(null);
|
||||||
|
const [newNote, setNewNote] = useState('');
|
||||||
|
const [noteVisibility, setNoteVisibility] = useState<'internal' | 'customer'>('internal');
|
||||||
|
const [noteTab, setNoteTab] = useState<'all' | 'internal' | 'customer'>('all');
|
||||||
|
const [editingNoteId, setEditingNoteId] = useState<number | null>(null);
|
||||||
|
const [editNoteText, setEditNoteText] = useState('');
|
||||||
|
const [editNoteVis, setEditNoteVis] = useState<'internal' | 'customer'>('internal');
|
||||||
|
const [newRec, setNewRec] = useState('');
|
||||||
|
const [newRecUrgency, setNewRecUrgency] = useState('soon');
|
||||||
|
const [newRecCost, setNewRecCost] = useState('');
|
||||||
|
const [newRecNotes, setNewRecNotes] = useState('');
|
||||||
|
const [showEditRecModal, setShowEditRecModal] = useState(false);
|
||||||
|
const [editRecData, setEditRecData] = useState<any>(null);
|
||||||
|
|
||||||
|
const { data: job, isLoading } = useQuery<Job>({
|
||||||
|
queryKey: ['jobs', id],
|
||||||
|
queryFn: () => api.getJob(Number(id)),
|
||||||
|
});
|
||||||
|
|
||||||
|
const invalidate = () => queryClient.invalidateQueries({ queryKey: ['jobs', id] });
|
||||||
|
|
||||||
|
const statusMutation = useMutation({
|
||||||
|
mutationFn: (status: string) => api.updateJob(Number(id), { status }),
|
||||||
|
onSuccess: invalidate,
|
||||||
|
});
|
||||||
|
|
||||||
|
const [showMiscModal, setShowMiscModal] = useState(false);
|
||||||
|
const [editMisc, setEditMisc] = useState<any>(null);
|
||||||
|
|
||||||
|
const deleteLaborMutation = useMutation({
|
||||||
|
mutationFn: (lid: number) => api.deleteLabor(Number(id), lid),
|
||||||
|
onSuccess: invalidate,
|
||||||
|
});
|
||||||
|
|
||||||
|
const deletePartsMutation = useMutation({
|
||||||
|
mutationFn: (pid: number) => api.deleteParts(Number(id), pid),
|
||||||
|
onSuccess: invalidate,
|
||||||
|
});
|
||||||
|
|
||||||
|
const deleteMiscMutation = useMutation({
|
||||||
|
mutationFn: (mid: number) => api.deleteMisc(Number(id), mid),
|
||||||
|
onSuccess: invalidate,
|
||||||
|
});
|
||||||
|
|
||||||
|
const toggleHidden = (type: 'labor' | 'parts' | 'misc', itemId: number, item: any) => {
|
||||||
|
const newHidden = item.hidden ? 0 : 1;
|
||||||
|
const fn = type === 'labor' ? api.updateLabor(Number(id), itemId, { ...item, billingType: item.billing_type, flatRate: item.flat_rate, hidden: newHidden })
|
||||||
|
: type === 'parts' ? api.updateParts(Number(id), itemId, { ...item, partNumber: item.part_number, unitCost: item.unit_cost, chargePrice: item.charge_price, hidden: newHidden })
|
||||||
|
: api.updateMisc(Number(id), itemId, { ...item, yourCost: item.your_cost, chargePrice: item.charge_price, hidden: newHidden });
|
||||||
|
fn.then(invalidate);
|
||||||
|
};
|
||||||
|
|
||||||
|
const addNoteMutation = useMutation({
|
||||||
|
mutationFn: ({ note, visibility }: { note: string; visibility: 'internal' | 'customer' }) => api.addNote(Number(id), note, visibility),
|
||||||
|
onSuccess: () => { invalidate(); setNewNote(''); },
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateNoteMutation = useMutation({
|
||||||
|
mutationFn: ({ nid, data }: { nid: number; data: { note: string; visibility: string } }) => api.updateNote(Number(id), nid, data),
|
||||||
|
onSuccess: () => { invalidate(); setEditingNoteId(null); },
|
||||||
|
});
|
||||||
|
|
||||||
|
const deleteNoteMutation = useMutation({
|
||||||
|
mutationFn: (nid: number) => api.deleteNote(Number(id), nid),
|
||||||
|
onSuccess: invalidate,
|
||||||
|
});
|
||||||
|
|
||||||
|
const addRecMutation = useMutation({
|
||||||
|
mutationFn: (data: any) => api.addRecommendation(Number(id), data),
|
||||||
|
onSuccess: () => { invalidate(); setNewRec(''); setNewRecCost(''); setNewRecUrgency('soon'); },
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateRecMutation = useMutation({
|
||||||
|
mutationFn: ({ rid, data }: { rid: number; data: any }) => api.updateRecommendation(Number(id), rid, data),
|
||||||
|
onSuccess: () => { invalidate(); setShowEditRecModal(false); setEditRecData(null); },
|
||||||
|
});
|
||||||
|
|
||||||
|
const deleteRecMutation = useMutation({
|
||||||
|
mutationFn: (rid: number) => api.deleteRecommendation(Number(id), rid),
|
||||||
|
onSuccess: invalidate,
|
||||||
|
});
|
||||||
|
|
||||||
|
const createEstimateFromRecMutation = useMutation({
|
||||||
|
mutationFn: (rid: number) => api.createEstimateFromRec(Number(id), rid),
|
||||||
|
onSuccess: (data: any) => { invalidate(); if (!data.existing) navigate(`/estimates/${data.estimateId}`); },
|
||||||
|
});
|
||||||
|
|
||||||
|
const invoiceJobMutation = useMutation({
|
||||||
|
mutationFn: () => api.invoiceJob(Number(id)),
|
||||||
|
onSuccess: (invoice: any) => { invalidate(); navigate(`/invoices/${invoice.id}`); },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (isLoading || !job) return <p className="text-gray-500 py-4">Loading...</p>;
|
||||||
|
|
||||||
|
const vehicleStr = [job.year, job.make, job.model].filter(Boolean).join(' ');
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<Link to="/jobs" className="inline-flex items-center gap-1.5 text-sm text-gray-500 hover:text-gray-700">
|
||||||
|
<ArrowLeft size={16} /> Back to Jobs
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<div className="flex flex-col sm:flex-row sm:items-start justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900">{job.job_number}</h1>
|
||||||
|
<p className="text-gray-700 mt-1">{vehicleStr} — {job.first_name} {job.last_name}</p>
|
||||||
|
{job.shop_id && job.shop_name ? (
|
||||||
|
<Link to={`/shops/${job.shop_id}`} className="text-sm text-blue-600 hover:underline">{job.shop_name}</Link>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-gray-500">Direct customer</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{(job as any).invoiceId ? (
|
||||||
|
<>
|
||||||
|
<Button variant="secondary" onClick={() => invoiceJobMutation.mutate()} disabled={invoiceJobMutation.isPending}>
|
||||||
|
<FileText size={16} /> {invoiceJobMutation.isPending ? 'Updating...' : 'Update Invoice'}
|
||||||
|
</Button>
|
||||||
|
<Link to={`/invoices/${(job as any).invoiceId}`}>
|
||||||
|
<Button variant="ghost" size="sm">View Invoice</Button>
|
||||||
|
</Link>
|
||||||
|
</>
|
||||||
|
) : job.status === 'completed' ? (
|
||||||
|
<Button variant="secondary" onClick={() => invoiceJobMutation.mutate()} disabled={invoiceJobMutation.isPending}>
|
||||||
|
<FileText size={16} /> {invoiceJobMutation.isPending ? 'Creating...' : 'Invoice This Job'}
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Status Tracker */}
|
||||||
|
<StatusTracker currentStatus={job.status} onChangeStatus={(s) => statusMutation.mutate(s)} disabled={statusMutation.isPending} />
|
||||||
|
|
||||||
|
{/* Tags */}
|
||||||
|
<TagPicker jobId={Number(id)} tags={job.tags || []} />
|
||||||
|
|
||||||
|
{job.description && (
|
||||||
|
<Card className="p-4">
|
||||||
|
<h3 className="text-xs font-medium text-gray-500 uppercase mb-1">Description</h3>
|
||||||
|
<p className="text-sm text-gray-700">{job.description}</p>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Vehicle Info + Mileage */}
|
||||||
|
<div className="flex flex-wrap gap-4 text-xs text-gray-500">
|
||||||
|
{job.vin && <span>VIN: <span className="font-mono">{job.vin}</span></span>}
|
||||||
|
<MileageField label="Miles In" value={(job as any).mileage_in} onSave={(v) => api.updateJob(Number(id), { mileageIn: v }).then(invalidate)} />
|
||||||
|
<MileageField label="Miles Out" value={(job as any).mileage_out} onSave={(v) => api.updateJob(Number(id), { mileageOut: v }).then(invalidate)} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Labor Items */}
|
||||||
|
<Card>
|
||||||
|
<div className="p-4 border-b border-gray-200 flex items-center justify-between">
|
||||||
|
<h2 className="font-semibold text-gray-900">Labor</h2>
|
||||||
|
<Button variant="ghost" size="sm" onClick={() => { setEditLabor(null); setShowLaborModal(true); }}>
|
||||||
|
<Plus size={14} /> Add Labor
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{job.laborItems && job.laborItems.length > 0 ? (
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-gray-200 text-left text-xs text-gray-500">
|
||||||
|
<th className="px-4 py-2">Description</th>
|
||||||
|
<th className="px-4 py-2 text-right">Details</th>
|
||||||
|
<th className="px-4 py-2 text-right">Total</th>
|
||||||
|
<th className="px-4 py-2 w-10"></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-gray-100">
|
||||||
|
{job.laborItems.map((item: LaborItem) => {
|
||||||
|
const lineTotal = item.billing_type === 'flat_rate' ? (item.flat_rate || 0) : (item.hours || 0) * (item.rate || 0);
|
||||||
|
const isHidden = !!(item as any).hidden;
|
||||||
|
return (
|
||||||
|
<tr key={item.id} className={`hover:bg-gray-50 ${isHidden ? 'opacity-40' : ''}`}>
|
||||||
|
<td className="px-4 py-2 text-gray-700">{item.description} {isHidden && <span className="text-[10px] text-gray-400">(hidden from invoice)</span>}</td>
|
||||||
|
<td className="px-4 py-2 text-right text-gray-700">
|
||||||
|
{item.billing_type === 'flat_rate' ? <span className="text-xs text-gray-500">Flat rate</span> : `${item.hours}h @ ${fmt(item.rate || 0)}/hr`}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2 text-right font-medium">{fmt(lineTotal)}</td>
|
||||||
|
<td className="px-4 py-2">
|
||||||
|
<div className="flex gap-1">
|
||||||
|
<button onClick={() => toggleHidden('labor', item.id, item)} className={isHidden ? 'text-gray-300 hover:text-gray-500' : 'text-gray-400 hover:text-gray-600'} title={isHidden ? 'Show on invoice' : 'Hide from invoice'}>
|
||||||
|
{isHidden ? <EyeOff size={14} /> : <Eye size={14} />}
|
||||||
|
</button>
|
||||||
|
<button onClick={() => { setEditLabor(item); setShowLaborModal(true); }} className="text-gray-400 hover:text-blue-500">
|
||||||
|
<Pencil size={14} />
|
||||||
|
</button>
|
||||||
|
<button onClick={() => deleteLaborMutation.mutate(item.id)} className="text-gray-400 hover:text-red-500">
|
||||||
|
<Trash2 size={14} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
<tfoot>
|
||||||
|
<tr className="border-t border-gray-200 font-medium">
|
||||||
|
<td colSpan={3} className="px-4 py-2 text-right text-gray-700">Labor Total</td>
|
||||||
|
<td className="px-4 py-2 text-right">{fmt(job.laborTotal || 0)}</td>
|
||||||
|
<td></td>
|
||||||
|
</tr>
|
||||||
|
</tfoot>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="p-4 text-sm text-gray-500">No labor items added yet.</p>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Parts Items */}
|
||||||
|
<Card>
|
||||||
|
<div className="p-4 border-b border-gray-200 flex items-center justify-between">
|
||||||
|
<h2 className="font-semibold text-gray-900">Parts</h2>
|
||||||
|
<Button variant="ghost" size="sm" onClick={() => { setEditParts(null); setShowPartsModal(true); }}>
|
||||||
|
<Plus size={14} /> Add Parts
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{job.partsItems && job.partsItems.length > 0 ? (
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-gray-200 text-left text-xs text-gray-500">
|
||||||
|
<th className="px-4 py-2">Description</th>
|
||||||
|
<th className="px-4 py-2">Part #</th>
|
||||||
|
<th className="px-4 py-2 text-right">Qty</th>
|
||||||
|
<th className="px-4 py-2 text-right">Cost</th>
|
||||||
|
<th className="px-4 py-2 text-right">Markup</th>
|
||||||
|
<th className="px-4 py-2 text-right">Total</th>
|
||||||
|
<th className="px-4 py-2 w-10"></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-gray-100">
|
||||||
|
{job.partsItems.map((item: PartsItem) => {
|
||||||
|
const calcPrice = item.quantity * item.unit_cost * (1 + item.markup);
|
||||||
|
const chargedPrice = item.charge_price !== null && item.charge_price !== undefined ? item.charge_price : calcPrice;
|
||||||
|
const isOverridden = item.charge_price !== null && item.charge_price !== undefined;
|
||||||
|
const isHidden = !!(item as any).hidden;
|
||||||
|
return (
|
||||||
|
<tr key={item.id} className={`hover:bg-gray-50 ${isHidden ? 'opacity-40' : ''}`}>
|
||||||
|
<td className="px-4 py-2 text-gray-700">{item.description} {isHidden && <span className="text-[10px] text-gray-400">(hidden)</span>}</td>
|
||||||
|
<td className="px-4 py-2 text-gray-500 text-xs font-mono">{item.part_number || '—'}</td>
|
||||||
|
<td className="px-4 py-2 text-right text-gray-700">{item.quantity}</td>
|
||||||
|
<td className="px-4 py-2 text-right text-gray-700">{fmt(item.unit_cost)}</td>
|
||||||
|
<td className="px-4 py-2 text-right text-gray-700">{(item.markup * 100).toFixed(0)}%</td>
|
||||||
|
<td className="px-4 py-2 text-right font-medium">
|
||||||
|
{fmt(chargedPrice)}
|
||||||
|
{isOverridden && <span className="block text-[10px] text-gray-400 line-through">{fmt(calcPrice)}</span>}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2">
|
||||||
|
<div className="flex gap-1">
|
||||||
|
<button onClick={() => toggleHidden('parts', item.id, item)} className={isHidden ? 'text-gray-300 hover:text-gray-500' : 'text-gray-400 hover:text-gray-600'} title={isHidden ? 'Show on invoice' : 'Hide from invoice'}>
|
||||||
|
{isHidden ? <EyeOff size={14} /> : <Eye size={14} />}
|
||||||
|
</button>
|
||||||
|
<button onClick={() => { setEditParts(item); setShowPartsModal(true); }} className="text-gray-400 hover:text-blue-500">
|
||||||
|
<Pencil size={14} />
|
||||||
|
</button>
|
||||||
|
<button onClick={() => deletePartsMutation.mutate(item.id)} className="text-gray-400 hover:text-red-500">
|
||||||
|
<Trash2 size={14} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
<tfoot>
|
||||||
|
<tr className="border-t border-gray-200 font-medium">
|
||||||
|
<td colSpan={5} className="px-4 py-2 text-right text-gray-700">Parts Total</td>
|
||||||
|
<td className="px-4 py-2 text-right">{fmt(job.partsTotal || 0)}</td>
|
||||||
|
<td></td>
|
||||||
|
</tr>
|
||||||
|
</tfoot>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="p-4 text-sm text-gray-500">No parts items added yet.</p>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Misc/Sublet Items */}
|
||||||
|
<Card>
|
||||||
|
<div className="p-4 border-b border-gray-200 flex items-center justify-between">
|
||||||
|
<h2 className="font-semibold text-gray-900">Sublet / Misc</h2>
|
||||||
|
<Button variant="ghost" size="sm" onClick={() => { setEditMisc(null); setShowMiscModal(true); }}>
|
||||||
|
<Plus size={14} /> Add
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{(job as any).miscItems && (job as any).miscItems.length > 0 ? (
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-gray-200 text-left text-xs text-gray-500">
|
||||||
|
<th className="px-4 py-2">Description</th>
|
||||||
|
<th className="px-4 py-2">Category</th>
|
||||||
|
<th className="px-4 py-2 text-right">Your Cost</th>
|
||||||
|
<th className="px-4 py-2 text-right">Charged</th>
|
||||||
|
<th className="px-4 py-2 w-16"></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-gray-100">
|
||||||
|
{(job as any).miscItems.map((item: any) => (
|
||||||
|
<tr key={item.id} className={`hover:bg-gray-50 ${item.hidden ? 'opacity-40' : ''}`}>
|
||||||
|
<td className="px-4 py-2 text-gray-700">{item.description} {!!item.hidden && <span className="text-[10px] text-gray-400">(hidden)</span>}</td>
|
||||||
|
<td className="px-4 py-2 text-gray-500 text-xs capitalize">{item.category}</td>
|
||||||
|
<td className="px-4 py-2 text-right text-gray-500">{fmt(item.your_cost)}</td>
|
||||||
|
<td className="px-4 py-2 text-right font-medium">{fmt(item.charge_price)}</td>
|
||||||
|
<td className="px-4 py-2">
|
||||||
|
<div className="flex gap-1">
|
||||||
|
<button onClick={() => toggleHidden('misc', item.id, item)} className={item.hidden ? 'text-gray-300 hover:text-gray-500' : 'text-gray-400 hover:text-gray-600'} title={item.hidden ? 'Show on invoice' : 'Hide from invoice'}>
|
||||||
|
{item.hidden ? <EyeOff size={14} /> : <Eye size={14} />}
|
||||||
|
</button>
|
||||||
|
<button onClick={() => { setEditMisc(item); setShowMiscModal(true); }} className="text-gray-400 hover:text-blue-500">
|
||||||
|
<Pencil size={14} />
|
||||||
|
</button>
|
||||||
|
<button onClick={() => deleteMiscMutation.mutate(item.id)} className="text-gray-400 hover:text-red-500">
|
||||||
|
<Trash2 size={14} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
<tfoot>
|
||||||
|
<tr className="border-t border-gray-200 font-medium">
|
||||||
|
<td colSpan={2} className="px-4 py-2 text-right text-gray-500 text-xs">Cost: {fmt((job as any).miscCost || 0)}</td>
|
||||||
|
<td className="px-4 py-2"></td>
|
||||||
|
<td className="px-4 py-2 text-right">{fmt((job as any).miscTotal || 0)}</td>
|
||||||
|
<td></td>
|
||||||
|
</tr>
|
||||||
|
</tfoot>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="p-4 text-sm text-gray-500">No sublet or misc costs. Tows, outside work, etc.</p>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Job Total */}
|
||||||
|
<Card className="p-4">
|
||||||
|
<div className="flex justify-between items-center">
|
||||||
|
<span className="text-lg font-semibold text-gray-900">Job Total</span>
|
||||||
|
<span className="text-2xl font-bold text-gray-900">{fmt(job.total || 0)}</span>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Recommendations */}
|
||||||
|
<Card>
|
||||||
|
<div className="p-4 border-b border-gray-200 flex items-center justify-between">
|
||||||
|
<h2 className="font-semibold text-gray-900 flex items-center gap-2"><AlertTriangle size={16} /> Recommendations</h2>
|
||||||
|
</div>
|
||||||
|
<div className="p-4 space-y-3">
|
||||||
|
{/* Add form */}
|
||||||
|
<form onSubmit={(e) => { e.preventDefault(); if (newRec.trim()) addRecMutation.mutate({ description: newRec.trim(), urgency: newRecUrgency, estimatedCost: newRecCost ? parseFloat(newRecCost) : null, notes: newRecNotes.trim() || null }); setNewRecNotes(''); }} className="space-y-2">
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<input className="flex-1 border border-gray-300 rounded-lg px-3 py-2 text-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 outline-none" placeholder="e.g. CV boots torn, recommend replacing..." value={newRec} onChange={(e) => setNewRec(e.target.value)} />
|
||||||
|
<Button type="submit" size="sm" disabled={!newRec.trim()}>Add</Button>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<div className="flex gap-1">
|
||||||
|
{(['now', 'soon', 'monitor'] as const).map((u) => (
|
||||||
|
<button key={u} type="button" onClick={() => setNewRecUrgency(u)}
|
||||||
|
className={`px-2 py-1 text-xs rounded-lg border transition-colors ${newRecUrgency === u
|
||||||
|
? u === 'now' ? 'bg-red-50 border-red-300 text-red-700'
|
||||||
|
: u === 'soon' ? 'bg-yellow-50 border-yellow-300 text-yellow-700'
|
||||||
|
: 'bg-blue-50 border-blue-300 text-blue-700'
|
||||||
|
: 'border-gray-300 text-gray-500 hover:bg-gray-50'}`}>
|
||||||
|
{u === 'now' ? 'Needs Now' : u === 'soon' ? 'Soon' : 'Monitor'}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<input className="w-28 border border-gray-300 rounded-lg px-2 py-1 text-xs focus:border-blue-500 outline-none" type="number" step="0.01" placeholder="Est. cost" value={newRecCost} onChange={(e) => setNewRecCost(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
<input className="w-full border border-gray-300 rounded-lg px-3 py-1.5 text-xs focus:border-blue-500 outline-none" placeholder="Notes (optional)..." value={newRecNotes} onChange={(e) => setNewRecNotes(e.target.value)} />
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{/* List */}
|
||||||
|
{(job as any).recommendations?.length > 0 ? (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{(job as any).recommendations.map((rec: any) => (
|
||||||
|
<div key={rec.id} className={`rounded-lg p-3 ${
|
||||||
|
rec.urgency === 'now' ? 'bg-red-50' : rec.urgency === 'soon' ? 'bg-yellow-50' : 'bg-blue-50'
|
||||||
|
}`}>
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div className="flex-1">
|
||||||
|
<p className="text-sm text-gray-700">{rec.description}</p>
|
||||||
|
<div className="flex items-center gap-2 mt-1 flex-wrap">
|
||||||
|
<span className={`text-xs font-medium ${
|
||||||
|
rec.urgency === 'now' ? 'text-red-600' : rec.urgency === 'soon' ? 'text-yellow-600' : 'text-blue-600'
|
||||||
|
}`}>
|
||||||
|
{rec.urgency === 'now' ? 'Needs Now' : rec.urgency === 'soon' ? 'Soon' : 'Monitor'}
|
||||||
|
</span>
|
||||||
|
{rec.estimate_total != null ? (
|
||||||
|
<span className="text-xs font-medium text-gray-700">{fmt(rec.estimate_total)} <span className="text-gray-400">({rec.estimate_number} — {rec.estimate_status})</span></span>
|
||||||
|
) : rec.estimated_cost ? (
|
||||||
|
<span className="text-xs text-gray-500">~{fmt(rec.estimated_cost)}</span>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-1 ml-2 mt-0.5">
|
||||||
|
<button onClick={() => { setEditRecData(rec); setShowEditRecModal(true); }} className="text-gray-400 hover:text-blue-500">
|
||||||
|
<Pencil size={14} />
|
||||||
|
</button>
|
||||||
|
<button onClick={() => deleteRecMutation.mutate(rec.id)} className="text-gray-400 hover:text-red-500">
|
||||||
|
<Trash2 size={14} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{rec.notes && <p className="text-xs text-gray-500 mt-1">{rec.notes}</p>}
|
||||||
|
<div className="mt-2 flex items-center gap-3">
|
||||||
|
{rec.estimate_id ? (
|
||||||
|
<Link to={`/estimates/${rec.estimate_id}`} className="text-xs text-blue-600 hover:underline font-medium">
|
||||||
|
View Estimate
|
||||||
|
</Link>
|
||||||
|
) : rec.status === 'info_only' ? (
|
||||||
|
<span className="text-xs text-gray-400 italic">For customer awareness — not a service we offer</span>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<button
|
||||||
|
onClick={() => createEstimateFromRecMutation.mutate(rec.id)}
|
||||||
|
disabled={createEstimateFromRecMutation.isPending}
|
||||||
|
className="text-xs text-blue-600 hover:text-blue-800 font-medium"
|
||||||
|
>
|
||||||
|
{createEstimateFromRecMutation.isPending ? 'Creating...' : 'Create Estimate'}
|
||||||
|
</button>
|
||||||
|
<span className="text-gray-300">|</span>
|
||||||
|
<button
|
||||||
|
onClick={() => updateRecMutation.mutate({ rid: rec.id, data: { description: rec.description, urgency: rec.urgency, estimatedCost: rec.estimated_cost, notes: rec.notes, status: 'info_only' } })}
|
||||||
|
className="text-xs text-gray-400 hover:text-gray-600"
|
||||||
|
>
|
||||||
|
Not our service
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-gray-400">No recommendations yet.</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Inspections */}
|
||||||
|
<InspectionPanel jobId={Number(id)} />
|
||||||
|
|
||||||
|
{/* Files */}
|
||||||
|
<Card className="p-4">
|
||||||
|
<FileUpload entityType="job" entityId={Number(id)} />
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Notes */}
|
||||||
|
<Card>
|
||||||
|
<div className="p-4 border-b border-gray-200">
|
||||||
|
<h2 className="font-semibold text-gray-900">Notes</h2>
|
||||||
|
</div>
|
||||||
|
<div className="p-4">
|
||||||
|
{/* Add note form */}
|
||||||
|
<form onSubmit={(e) => { e.preventDefault(); if (newNote.trim()) addNoteMutation.mutate({ note: newNote.trim(), visibility: noteVisibility }); }} className="space-y-2 mb-4">
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<input className="flex-1 border border-gray-300 rounded-lg px-3 py-2 text-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 outline-none" placeholder="Add a note..." value={newNote} onChange={(e) => setNewNote(e.target.value)} />
|
||||||
|
<Button type="submit" size="sm" disabled={!newNote.trim()}>Add</Button>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button type="button" onClick={() => setNoteVisibility('internal')}
|
||||||
|
className={`px-2.5 py-1 text-xs rounded-lg border transition-colors ${noteVisibility === 'internal' ? 'bg-gray-700 border-gray-700 text-white' : 'border-gray-300 text-gray-500 hover:bg-gray-50'}`}>
|
||||||
|
Internal
|
||||||
|
</button>
|
||||||
|
<button type="button" onClick={() => setNoteVisibility('customer')}
|
||||||
|
className={`px-2.5 py-1 text-xs rounded-lg border transition-colors ${noteVisibility === 'customer' ? 'bg-blue-500 border-blue-500 text-white' : 'border-gray-300 text-gray-500 hover:bg-gray-50'}`}>
|
||||||
|
Customer-facing
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
{/* Filter tabs */}
|
||||||
|
<div className="flex gap-1 mb-3">
|
||||||
|
{([['all', 'All'], ['internal', 'Internal'], ['customer', 'Customer']] as const).map(([key, label]) => (
|
||||||
|
<button key={key} onClick={() => setNoteTab(key)}
|
||||||
|
className={`px-2.5 py-1 text-xs font-medium rounded-lg transition-colors ${noteTab === key ? 'bg-blue-100 text-blue-700' : 'bg-gray-100 text-gray-600 hover:bg-gray-200'}`}>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Notes list */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
{((job as any).notes as JobNote[])?.filter?.((note: any) => noteTab === 'all' || note.visibility === noteTab)?.map?.((note: any) => (
|
||||||
|
<div key={note.id} className={`rounded-lg p-3 ${
|
||||||
|
note.visibility === 'customer' ? 'bg-blue-50 border border-blue-100' : 'bg-gray-50'
|
||||||
|
}`}>
|
||||||
|
{editingNoteId === note.id ? (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<textarea className="w-full border border-gray-300 rounded-lg px-3 py-2 text-sm focus:border-blue-500 outline-none"
|
||||||
|
rows={2} value={editNoteText} onChange={(e) => setEditNoteText(e.target.value)} autoFocus />
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button type="button" onClick={() => setEditNoteVis('internal')}
|
||||||
|
className={`px-2 py-1 text-xs rounded border ${editNoteVis === 'internal' ? 'bg-gray-700 border-gray-700 text-white' : 'border-gray-300 text-gray-500'}`}>
|
||||||
|
Internal
|
||||||
|
</button>
|
||||||
|
<button type="button" onClick={() => setEditNoteVis('customer')}
|
||||||
|
className={`px-2 py-1 text-xs rounded border ${editNoteVis === 'customer' ? 'bg-blue-500 border-blue-500 text-white' : 'border-gray-300 text-gray-500'}`}>
|
||||||
|
Customer
|
||||||
|
</button>
|
||||||
|
<div className="flex-1" />
|
||||||
|
<Button size="sm" onClick={() => updateNoteMutation.mutate({ nid: note.id, data: { note: editNoteText, visibility: editNoteVis } })}
|
||||||
|
disabled={updateNoteMutation.isPending}>Save</Button>
|
||||||
|
<Button size="sm" variant="ghost" onClick={() => setEditingNoteId(null)}>Cancel</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-2 mb-0.5">
|
||||||
|
<span className={`text-[10px] font-medium px-1.5 py-0.5 rounded ${
|
||||||
|
note.visibility === 'customer' ? 'bg-blue-100 text-blue-700' : 'bg-gray-200 text-gray-600'
|
||||||
|
}`}>
|
||||||
|
{note.visibility === 'customer' ? 'Customer' : 'Internal'}
|
||||||
|
</span>
|
||||||
|
<span className="text-xs text-gray-400">{new Date(note.created_at).toLocaleString()}</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-gray-700">{note.note}</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-1 ml-2 mt-0.5">
|
||||||
|
<button onClick={() => { setEditingNoteId(note.id); setEditNoteText(note.note); setEditNoteVis(note.visibility || 'internal'); }} className="text-gray-400 hover:text-blue-500">
|
||||||
|
<Pencil size={14} />
|
||||||
|
</button>
|
||||||
|
<button onClick={() => deleteNoteMutation.mutate(note.id)} className="text-gray-400 hover:text-red-500">
|
||||||
|
<Trash2 size={14} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)) || <p className="text-sm text-gray-500">No notes yet.</p>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<LaborModal open={showLaborModal} onClose={() => { setShowLaborModal(false); setEditLabor(null); }} jobId={Number(id)} item={editLabor} defaultRate={job.default_labor_rate || 75} onDone={invalidate} />
|
||||||
|
<PartsModal open={showPartsModal} onClose={() => { setShowPartsModal(false); setEditParts(null); }} jobId={Number(id)} item={editParts} defaultMarkup={job.default_parts_markup || 0.3} onDone={invalidate} />
|
||||||
|
<MiscModal open={showMiscModal} onClose={() => { setShowMiscModal(false); setEditMisc(null); }} jobId={Number(id)} item={editMisc} onDone={invalidate} />
|
||||||
|
{showEditRecModal && editRecData && (
|
||||||
|
<EditRecModal open={showEditRecModal} onClose={() => { setShowEditRecModal(false); setEditRecData(null); }} rec={editRecData}
|
||||||
|
onSave={(data) => updateRecMutation.mutate({ rid: editRecData.id, data })} saving={updateRecMutation.isPending} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function LaborModal({ open, onClose, jobId, item, defaultRate, onDone }: { open: boolean; onClose: () => void; jobId: number; item: LaborItem | null; defaultRate: number; onDone: () => void }) {
|
||||||
|
const [billingType, setBillingType] = useState<'hourly' | 'flat_rate'>(item?.billing_type || 'hourly');
|
||||||
|
const [description, setDescription] = useState(item?.description || '');
|
||||||
|
const [hours, setHours] = useState(item?.hours?.toString() || '');
|
||||||
|
const [rate, setRate] = useState(item?.rate?.toString() || String(defaultRate));
|
||||||
|
const [flatRate, setFlatRate] = useState(item?.flat_rate?.toString() || '');
|
||||||
|
|
||||||
|
useState(() => {
|
||||||
|
setBillingType(item?.billing_type || 'hourly');
|
||||||
|
setDescription(item?.description || '');
|
||||||
|
setHours(item?.hours?.toString() || '');
|
||||||
|
setRate(item?.rate?.toString() || String(defaultRate));
|
||||||
|
setFlatRate(item?.flat_rate?.toString() || '');
|
||||||
|
});
|
||||||
|
|
||||||
|
const mutation = useMutation({
|
||||||
|
mutationFn: (data: any) => item ? api.updateLabor(jobId, item.id, data) : api.addLabor(jobId, data),
|
||||||
|
onSuccess: () => { onDone(); onClose(); },
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (billingType === 'flat_rate') {
|
||||||
|
mutation.mutate({ description, billingType: 'flat_rate', flatRate: parseFloat(flatRate) });
|
||||||
|
} else {
|
||||||
|
mutation.mutate({ description, billingType: 'hourly', hours: parseFloat(hours), rate: parseFloat(rate) });
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const total = billingType === 'flat_rate'
|
||||||
|
? (flatRate ? parseFloat(flatRate) : 0)
|
||||||
|
: (hours && rate ? parseFloat(hours) * parseFloat(rate) : 0);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal open={open} onClose={onClose} title={item ? 'Edit Labor' : 'Add Labor'}>
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-3">
|
||||||
|
<Input label="Description *" value={description} onChange={(e) => setDescription(e.target.value)} required placeholder="e.g. Brake pad replacement" />
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Billing Type</label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button type="button" onClick={() => setBillingType('hourly')}
|
||||||
|
className={`flex-1 px-3 py-2 text-sm font-medium rounded-lg border transition-colors ${billingType === 'hourly' ? 'bg-blue-50 border-blue-300 text-blue-700' : 'border-gray-300 text-gray-600 hover:bg-gray-50'}`}>
|
||||||
|
Hourly
|
||||||
|
</button>
|
||||||
|
<button type="button" onClick={() => setBillingType('flat_rate')}
|
||||||
|
className={`flex-1 px-3 py-2 text-sm font-medium rounded-lg border transition-colors ${billingType === 'flat_rate' ? 'bg-blue-50 border-blue-300 text-blue-700' : 'border-gray-300 text-gray-600 hover:bg-gray-50'}`}>
|
||||||
|
Flat Rate
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{billingType === 'hourly' ? (
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<Input label="Hours *" type="number" step="0.1" min="0" value={hours} onChange={(e) => setHours(e.target.value)} required />
|
||||||
|
<Input label="Rate ($/hr) *" type="number" step="0.01" min="0" value={rate} onChange={(e) => setRate(e.target.value)} required />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Input label="Flat Rate Amount *" type="number" step="0.01" min="0" value={flatRate} onChange={(e) => setFlatRate(e.target.value)} required placeholder="e.g. 350.00" />
|
||||||
|
)}
|
||||||
|
{total > 0 && <p className="text-sm text-gray-500">Total: <span className="font-medium">${total.toFixed(2)}</span></p>}
|
||||||
|
<div className="flex justify-end gap-2 pt-2">
|
||||||
|
<Button variant="secondary" type="button" onClick={onClose}>Cancel</Button>
|
||||||
|
<Button type="submit" disabled={mutation.isPending}>{mutation.isPending ? 'Saving...' : 'Save'}</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PartsModal({ open, onClose, jobId, item, defaultMarkup, onDone }: { open: boolean; onClose: () => void; jobId: number; item: PartsItem | null; defaultMarkup: number; onDone: () => void }) {
|
||||||
|
const [description, setDescription] = useState(item?.description || '');
|
||||||
|
const [partNumber, setPartNumber] = useState(item?.part_number || '');
|
||||||
|
const [quantity, setQuantity] = useState(item?.quantity?.toString() || '1');
|
||||||
|
const [unitCost, setUnitCost] = useState(item?.unit_cost?.toString() || '');
|
||||||
|
const [markup, setMarkup] = useState(item ? String(item.markup * 100) : String(defaultMarkup * 100));
|
||||||
|
const [overridePrice, setOverridePrice] = useState(item?.charge_price !== null && item?.charge_price !== undefined);
|
||||||
|
const [chargePrice, setChargePrice] = useState(item?.charge_price?.toString() || '');
|
||||||
|
const [inventoryItemId, setInventoryItemId] = useState<number | null>(null);
|
||||||
|
|
||||||
|
useState(() => {
|
||||||
|
setDescription(item?.description || '');
|
||||||
|
setPartNumber(item?.part_number || '');
|
||||||
|
setQuantity(item?.quantity?.toString() || '1');
|
||||||
|
setUnitCost(item?.unit_cost?.toString() || '');
|
||||||
|
setMarkup(item ? String(item.markup * 100) : String(defaultMarkup * 100));
|
||||||
|
setOverridePrice(item?.charge_price !== null && item?.charge_price !== undefined);
|
||||||
|
setChargePrice(item?.charge_price?.toString() || '');
|
||||||
|
setInventoryItemId(null);
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: inventoryItems = [] } = useQuery({
|
||||||
|
queryKey: ['inventory-parts'],
|
||||||
|
queryFn: () => api.getInventoryItems({}),
|
||||||
|
enabled: open && !item,
|
||||||
|
});
|
||||||
|
|
||||||
|
const selectFromInventory = (invItem: any) => {
|
||||||
|
setDescription(invItem.name);
|
||||||
|
setPartNumber(invItem.part_number || '');
|
||||||
|
setUnitCost(String(invItem.unit_cost || 0));
|
||||||
|
setInventoryItemId(invItem.id);
|
||||||
|
};
|
||||||
|
|
||||||
|
const mutation = useMutation({
|
||||||
|
mutationFn: (data: any) => item ? api.updateParts(jobId, item.id, data) : api.addParts(jobId, data),
|
||||||
|
onSuccess: () => { onDone(); onClose(); },
|
||||||
|
});
|
||||||
|
|
||||||
|
const calculatedPrice = quantity && unitCost ? parseFloat(quantity) * parseFloat(unitCost) * (1 + parseFloat(markup || '0') / 100) : 0;
|
||||||
|
const yourCost = quantity && unitCost ? parseFloat(quantity) * parseFloat(unitCost) : 0;
|
||||||
|
const customerPrice = overridePrice && chargePrice !== '' ? parseFloat(chargePrice) : calculatedPrice;
|
||||||
|
|
||||||
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
mutation.mutate({
|
||||||
|
description,
|
||||||
|
partNumber: partNumber || null,
|
||||||
|
quantity: parseFloat(quantity),
|
||||||
|
unitCost: parseFloat(unitCost),
|
||||||
|
markup: parseFloat(markup) / 100,
|
||||||
|
chargePrice: overridePrice ? parseFloat(chargePrice || '0') : null,
|
||||||
|
inventoryItemId,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal open={open} onClose={onClose} title={item ? 'Edit Part' : 'Add Part'}>
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-3">
|
||||||
|
{/* Inventory picker — only on new parts */}
|
||||||
|
{!item && inventoryItems.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">From Inventory</label>
|
||||||
|
<select
|
||||||
|
className="block w-full rounded-lg border border-gray-300 px-3 py-2 text-sm"
|
||||||
|
value={inventoryItemId || ''}
|
||||||
|
onChange={(e) => {
|
||||||
|
const id = e.target.value ? Number(e.target.value) : null;
|
||||||
|
if (id) {
|
||||||
|
const inv = inventoryItems.find((i: any) => i.id === id);
|
||||||
|
if (inv) selectFromInventory(inv);
|
||||||
|
} else {
|
||||||
|
setInventoryItemId(null);
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<option value="">Manual entry...</option>
|
||||||
|
{inventoryItems.map((i: any) => (
|
||||||
|
<option key={i.id} value={i.id}>
|
||||||
|
[{i.item_type}] {i.name}{i.part_number ? ` (${i.part_number})` : ''} — {i.quantity_on_hand} in stock @ ${(i.unit_cost || 0).toFixed(2)}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
{inventoryItemId && <p className="text-xs text-blue-600 mt-1">Will deduct from inventory on save</p>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<Input label="Description *" value={description} onChange={(e) => setDescription(e.target.value)} required placeholder="e.g. Brake pads (front)" />
|
||||||
|
<Input label="Part Number" value={partNumber} onChange={(e) => setPartNumber(e.target.value)} />
|
||||||
|
<div className="grid grid-cols-3 gap-3">
|
||||||
|
<Input label="Quantity *" type="number" step="0.1" min="0.1" value={quantity} onChange={(e) => setQuantity(e.target.value)} required />
|
||||||
|
<Input label="Your Cost *" type="number" step="0.01" min="0" value={unitCost} onChange={(e) => setUnitCost(e.target.value)} required />
|
||||||
|
<Input label="Markup (%)" type="number" step="1" value={markup} onChange={(e) => setMarkup(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Price summary */}
|
||||||
|
{yourCost > 0 && (
|
||||||
|
<div className="bg-gray-50 rounded-lg p-3 text-sm space-y-1">
|
||||||
|
<div className="flex justify-between text-gray-600">
|
||||||
|
<span>Your cost</span>
|
||||||
|
<span>{fmt(yourCost)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between text-gray-600">
|
||||||
|
<span>Calculated price ({markup}% markup)</span>
|
||||||
|
<span>{fmt(calculatedPrice)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between font-medium text-gray-900 border-t border-gray-200 pt-1">
|
||||||
|
<span>Customer pays</span>
|
||||||
|
<span>{fmt(customerPrice)}</span>
|
||||||
|
</div>
|
||||||
|
{yourCost > 0 && <div className="flex justify-between text-xs text-green-600">
|
||||||
|
<span>Profit</span>
|
||||||
|
<span>{fmt(customerPrice - yourCost)}</span>
|
||||||
|
</div>}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Override toggle */}
|
||||||
|
<label className="flex items-center gap-2 cursor-pointer">
|
||||||
|
<input type="checkbox" checked={overridePrice} onChange={(e) => { setOverridePrice(e.target.checked); if (e.target.checked && !chargePrice) setChargePrice(calculatedPrice.toFixed(2)); }} className="rounded" />
|
||||||
|
<span className="text-sm text-gray-700">Override customer price</span>
|
||||||
|
</label>
|
||||||
|
{overridePrice && (
|
||||||
|
<Input label="Charge Price" type="number" step="0.01" min="0" value={chargePrice} onChange={(e) => setChargePrice(e.target.value)} placeholder="0.00 to not charge" />
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-2 pt-2">
|
||||||
|
<Button variant="secondary" type="button" onClick={onClose}>Cancel</Button>
|
||||||
|
<Button type="submit" disabled={mutation.isPending}>{mutation.isPending ? 'Saving...' : 'Save'}</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function MiscModal({ open, onClose, jobId, item, onDone }: { open: boolean; onClose: () => void; jobId: number; item: any; onDone: () => void }) {
|
||||||
|
const [description, setDescription] = useState(item?.description || '');
|
||||||
|
const [category, setCategory] = useState(item?.category || 'sublet');
|
||||||
|
const [yourCost, setYourCost] = useState(item?.your_cost?.toString() || '');
|
||||||
|
const [chargePrice, setChargePrice] = useState(item?.charge_price?.toString() || '0');
|
||||||
|
|
||||||
|
useState(() => {
|
||||||
|
setDescription(item?.description || '');
|
||||||
|
setCategory(item?.category || 'sublet');
|
||||||
|
setYourCost(item?.your_cost?.toString() || '');
|
||||||
|
setChargePrice(item?.charge_price?.toString() || '0');
|
||||||
|
});
|
||||||
|
|
||||||
|
const mutation = useMutation({
|
||||||
|
mutationFn: (data: any) => item ? api.updateMisc(jobId, item.id, data) : api.addMisc(jobId, data),
|
||||||
|
onSuccess: () => { onDone(); onClose(); },
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal open={open} onClose={onClose} title={item ? 'Edit Sublet/Misc' : 'Add Sublet/Misc'}>
|
||||||
|
<form onSubmit={(e) => { e.preventDefault(); mutation.mutate({ description, category, yourCost: parseFloat(yourCost || '0'), chargePrice: parseFloat(chargePrice || '0') }); }} className="space-y-3">
|
||||||
|
<Input label="Description *" value={description} onChange={(e) => setDescription(e.target.value)} required placeholder="e.g. Tow to shop, Machine work, etc." />
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Category</label>
|
||||||
|
<select className="block w-full rounded-lg border border-gray-300 px-3 py-2 text-sm" value={category} onChange={(e) => setCategory(e.target.value)}>
|
||||||
|
<option value="sublet">Sublet</option>
|
||||||
|
<option value="tow">Tow</option>
|
||||||
|
<option value="machine_work">Machine Work</option>
|
||||||
|
<option value="disposal">Disposal Fee</option>
|
||||||
|
<option value="other">Other</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<Input label="Your Cost" type="number" step="0.01" min="0" value={yourCost} onChange={(e) => setYourCost(e.target.value)} placeholder="What it cost you" />
|
||||||
|
<Input label="Charge Customer" type="number" step="0.01" min="0" value={chargePrice} onChange={(e) => setChargePrice(e.target.value)} placeholder="0 = absorbed" />
|
||||||
|
</div>
|
||||||
|
{yourCost && (
|
||||||
|
<div className="bg-gray-50 rounded-lg p-3 text-sm space-y-1">
|
||||||
|
<div className="flex justify-between text-gray-600">
|
||||||
|
<span>Your cost</span><span>{fmt(parseFloat(yourCost || '0'))}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between text-gray-600">
|
||||||
|
<span>Customer pays</span><span>{fmt(parseFloat(chargePrice || '0'))}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between text-xs text-green-600 border-t border-gray-200 pt-1">
|
||||||
|
<span>{parseFloat(chargePrice || '0') >= parseFloat(yourCost || '0') ? 'Profit' : 'Absorbed'}</span>
|
||||||
|
<span>{fmt(parseFloat(chargePrice || '0') - parseFloat(yourCost || '0'))}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex justify-end gap-2 pt-2">
|
||||||
|
<Button variant="secondary" type="button" onClick={onClose}>Cancel</Button>
|
||||||
|
<Button type="submit" disabled={mutation.isPending}>{mutation.isPending ? 'Saving...' : 'Save'}</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function EditRecModal({ open, onClose, rec, onSave, saving }: { open: boolean; onClose: () => void; rec: any; onSave: (data: any) => void; saving: boolean }) {
|
||||||
|
const [description, setDescription] = useState(rec?.description || '');
|
||||||
|
const [urgency, setUrgency] = useState(rec?.urgency || 'soon');
|
||||||
|
const [estimatedCost, setEstimatedCost] = useState(rec?.estimated_cost?.toString() || '');
|
||||||
|
const [notes, setNotes] = useState(rec?.notes || '');
|
||||||
|
const [status, setStatus] = useState(rec?.status || 'open');
|
||||||
|
|
||||||
|
useState(() => {
|
||||||
|
setDescription(rec?.description || '');
|
||||||
|
setUrgency(rec?.urgency || 'soon');
|
||||||
|
setEstimatedCost(rec?.estimated_cost?.toString() || '');
|
||||||
|
setNotes(rec?.notes || '');
|
||||||
|
setStatus(rec?.status || 'open');
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal open={open} onClose={onClose} title="Edit Recommendation">
|
||||||
|
<form onSubmit={(e) => { e.preventDefault(); onSave({ description, urgency, estimatedCost: estimatedCost ? parseFloat(estimatedCost) : null, notes: notes || null, status }); }} className="space-y-3">
|
||||||
|
<Input label="Description *" value={description} onChange={(e) => setDescription(e.target.value)} required />
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Urgency</label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{(['now', 'soon', 'monitor'] as const).map((u) => (
|
||||||
|
<button key={u} type="button" onClick={() => setUrgency(u)}
|
||||||
|
className={`flex-1 px-3 py-2 text-sm font-medium rounded-lg border transition-colors ${urgency === u
|
||||||
|
? u === 'now' ? 'bg-red-50 border-red-300 text-red-700'
|
||||||
|
: u === 'soon' ? 'bg-yellow-50 border-yellow-300 text-yellow-700'
|
||||||
|
: 'bg-blue-50 border-blue-300 text-blue-700'
|
||||||
|
: 'border-gray-300 text-gray-500 hover:bg-gray-50'}`}>
|
||||||
|
{u === 'now' ? 'Needs Now' : u === 'soon' ? 'Soon' : 'Monitor'}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Input label="Estimated Cost" type="number" step="0.01" value={estimatedCost} onChange={(e) => setEstimatedCost(e.target.value)} />
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Notes</label>
|
||||||
|
<textarea className="block w-full rounded-lg border border-gray-300 px-3 py-2 text-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 outline-none"
|
||||||
|
rows={3} value={notes} onChange={(e) => setNotes(e.target.value)} placeholder="Additional details..." />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Status</label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button type="button" onClick={() => setStatus('open')}
|
||||||
|
className={`flex-1 px-3 py-2 text-sm font-medium rounded-lg border transition-colors ${status === 'open' ? 'bg-blue-50 border-blue-300 text-blue-700' : 'border-gray-300 text-gray-500 hover:bg-gray-50'}`}>
|
||||||
|
Open
|
||||||
|
</button>
|
||||||
|
<button type="button" onClick={() => setStatus('info_only')}
|
||||||
|
className={`flex-1 px-3 py-2 text-sm font-medium rounded-lg border transition-colors ${status === 'info_only' ? 'bg-orange-50 border-orange-300 text-orange-700' : 'border-gray-300 text-gray-500 hover:bg-gray-50'}`}>
|
||||||
|
Info Only
|
||||||
|
</button>
|
||||||
|
<button type="button" onClick={() => setStatus('dismissed')}
|
||||||
|
className={`flex-1 px-3 py-2 text-sm font-medium rounded-lg border transition-colors ${status === 'dismissed' ? 'bg-gray-200 border-gray-400 text-gray-700' : 'border-gray-300 text-gray-500 hover:bg-gray-50'}`}>
|
||||||
|
Dismissed
|
||||||
|
</button>
|
||||||
|
<button type="button" onClick={() => setStatus('completed')}
|
||||||
|
className={`flex-1 px-3 py-2 text-sm font-medium rounded-lg border transition-colors ${status === 'completed' ? 'bg-green-50 border-green-300 text-green-700' : 'border-gray-300 text-gray-500 hover:bg-gray-50'}`}>
|
||||||
|
Done
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-end gap-2 pt-2">
|
||||||
|
<Button variant="secondary" type="button" onClick={onClose}>Cancel</Button>
|
||||||
|
<Button type="submit" disabled={saving}>{saving ? 'Saving...' : 'Save'}</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
343
client/src/pages/Jobs.tsx
Normal file
343
client/src/pages/Jobs.tsx
Normal file
@@ -0,0 +1,343 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { Link, useSearchParams } from 'react-router-dom';
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { Wrench, Plus, Search, LayoutGrid, List } from 'lucide-react';
|
||||||
|
import { api } from '../api/client';
|
||||||
|
import { Card, Button, Badge, Modal, Input, EmptyState } from '../components/ui';
|
||||||
|
import KanbanBoard from '../components/KanbanBoard';
|
||||||
|
import type { Job, Tag, Shop, Owner, Vehicle } from '../types';
|
||||||
|
|
||||||
|
const statuses: { value: string; label: string }[] = [
|
||||||
|
{ value: '', label: 'All' },
|
||||||
|
{ value: 'received', label: 'Received' },
|
||||||
|
{ value: 'in_progress', label: 'In Progress' },
|
||||||
|
{ value: 'completed', label: 'Completed' },
|
||||||
|
{ value: 'invoiced', label: 'Invoiced' },
|
||||||
|
{ value: 'closed', label: 'Closed' },
|
||||||
|
];
|
||||||
|
|
||||||
|
const statusLabel: Record<string, string> = {
|
||||||
|
received: 'Received', in_progress: 'In Progress', completed: 'Completed', invoiced: 'Invoiced', closed: 'Closed',
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function Jobs() {
|
||||||
|
const [searchParams] = useSearchParams();
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
|
const [statusFilter, setStatusFilter] = useState(searchParams.get('status') || '');
|
||||||
|
const [showNewJob, setShowNewJob] = useState(false);
|
||||||
|
const [viewMode, setViewMode] = useState<'list' | 'kanban'>('list');
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const { data: jobs = [], isLoading } = useQuery({
|
||||||
|
queryKey: ['jobs', statusFilter, search],
|
||||||
|
queryFn: () => api.getJobs({ status: statusFilter || undefined, search: search || undefined }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const createMutation = useMutation({
|
||||||
|
mutationFn: api.createJob,
|
||||||
|
onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['jobs'] }); setShowNewJob(false); },
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900">Jobs</h1>
|
||||||
|
<div className="flex border border-gray-300 rounded-lg overflow-hidden">
|
||||||
|
<button onClick={() => setViewMode('list')}
|
||||||
|
className={`p-1.5 ${viewMode === 'list' ? 'bg-blue-50 text-blue-600' : 'text-gray-400 hover:bg-gray-50'}`}>
|
||||||
|
<List size={18} />
|
||||||
|
</button>
|
||||||
|
<button onClick={() => setViewMode('kanban')}
|
||||||
|
className={`p-1.5 ${viewMode === 'kanban' ? 'bg-blue-50 text-blue-600' : 'text-gray-400 hover:bg-gray-50'}`}>
|
||||||
|
<LayoutGrid size={18} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button onClick={() => setShowNewJob(true)}><Plus size={16} /> New Job</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col sm:flex-row gap-3">
|
||||||
|
<div className="relative flex-1">
|
||||||
|
<Search size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400" />
|
||||||
|
<input className="w-full pl-9 pr-3 py-2 text-sm border border-gray-300 rounded-lg focus:border-blue-500 focus:ring-1 focus:ring-blue-500 outline-none" placeholder="Search jobs..." value={search} onChange={(e) => setSearch(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-1 flex-wrap">
|
||||||
|
{statuses.map((s) => (
|
||||||
|
<button
|
||||||
|
key={s.value}
|
||||||
|
onClick={() => setStatusFilter(s.value)}
|
||||||
|
className={`px-3 py-1.5 text-xs font-medium rounded-lg transition-colors ${
|
||||||
|
statusFilter === s.value ? 'bg-blue-100 text-blue-700' : 'bg-gray-100 text-gray-600 hover:bg-gray-200'
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{s.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isLoading ? <p className="text-gray-500 py-4">Loading...</p> : jobs.length === 0 ? (
|
||||||
|
<EmptyState icon={Wrench} title="No jobs found" description="Create your first job to start tracking work." action={<Button onClick={() => setShowNewJob(true)}>New Job</Button>} />
|
||||||
|
) : viewMode === 'kanban' ? (
|
||||||
|
<KanbanBoard jobs={jobs} />
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{jobs.map((job: Job) => (
|
||||||
|
<Link key={job.id} to={`/jobs/${job.id}`}>
|
||||||
|
<Card className="p-4 hover:border-blue-300 transition-colors">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="font-mono text-sm font-semibold text-gray-900">{job.job_number}</span>
|
||||||
|
<Badge status={job.status}>{statusLabel[job.status]}</Badge>
|
||||||
|
{job.tags && job.tags.map((tag: Tag) => (
|
||||||
|
<span key={tag.id} className={`px-1.5 py-0.5 rounded text-[10px] font-medium ${
|
||||||
|
tag.color === 'red' ? 'bg-red-100 text-red-600' :
|
||||||
|
tag.color === 'yellow' ? 'bg-yellow-100 text-yellow-600' :
|
||||||
|
tag.color === 'green' ? 'bg-green-100 text-green-600' :
|
||||||
|
tag.color === 'blue' ? 'bg-blue-100 text-blue-600' :
|
||||||
|
tag.color === 'purple' ? 'bg-purple-100 text-purple-600' :
|
||||||
|
'bg-gray-100 text-gray-600'
|
||||||
|
}`}>{tag.name}</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-gray-700 mt-1">
|
||||||
|
{[job.year, job.make, job.model].filter(Boolean).join(' ')}
|
||||||
|
<span className="text-gray-400"> — </span>
|
||||||
|
{job.first_name} {job.last_name}
|
||||||
|
</p>
|
||||||
|
{job.shop_name && <p className="text-xs text-gray-500 mt-0.5">{job.shop_name}</p>}
|
||||||
|
</div>
|
||||||
|
<div className="text-right text-xs text-gray-500">
|
||||||
|
{new Date(job.received_date).toLocaleDateString()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{job.description && <p className="text-sm text-gray-500 mt-2 line-clamp-1">{job.description}</p>}
|
||||||
|
</Card>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<NewJobModal open={showNewJob} onClose={() => setShowNewJob(false)} onSave={(data) => createMutation.mutate(data)} saving={createMutation.isPending} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function InlineQuickAdd({ label, onSave, fields, saving }: { label: string; onSave: (data: any) => void; fields: { key: string; label: string; required?: boolean; type?: string }[]; saving: boolean }) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [form, setForm] = useState<Record<string, string>>({});
|
||||||
|
|
||||||
|
if (!open) {
|
||||||
|
return (
|
||||||
|
<button type="button" onClick={() => setOpen(true)} className="text-xs text-blue-600 hover:text-blue-800 font-medium flex items-center gap-1">
|
||||||
|
<Plus size={12} /> {label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="border border-blue-200 bg-blue-50/50 rounded-lg p-3 space-y-2">
|
||||||
|
<p className="text-xs font-medium text-blue-700">{label}</p>
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
{fields.map((f) => (
|
||||||
|
<input
|
||||||
|
key={f.key}
|
||||||
|
className="block w-full rounded border border-gray-300 px-2 py-1.5 text-sm focus:border-blue-500 focus:ring-1 focus:ring-blue-500 outline-none"
|
||||||
|
placeholder={f.label + (f.required ? ' *' : '')}
|
||||||
|
type={f.type || 'text'}
|
||||||
|
value={form[f.key] || ''}
|
||||||
|
onChange={(e) => setForm({ ...form, [f.key]: e.target.value })}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button type="button" onClick={() => { onSave(form); setForm({}); setOpen(false); }} disabled={saving || !fields.filter((f) => f.required).every((f) => form[f.key]?.trim())}
|
||||||
|
className="px-2.5 py-1 text-xs font-medium bg-blue-600 text-white rounded hover:bg-blue-700 disabled:opacity-50">
|
||||||
|
{saving ? 'Saving...' : 'Add'}
|
||||||
|
</button>
|
||||||
|
<button type="button" onClick={() => { setOpen(false); setForm({}); }} className="px-2.5 py-1 text-xs text-gray-600 hover:text-gray-800">Cancel</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function NewJobModal({ open, onClose, onSave, saving }: { open: boolean; onClose: () => void; onSave: (data: any) => void; saving: boolean }) {
|
||||||
|
const [customerType, setCustomerType] = useState<'owner' | 'shop' | 'internal'>('owner');
|
||||||
|
const [shopId, setShopId] = useState('');
|
||||||
|
const [ownerId, setOwnerId] = useState('');
|
||||||
|
const [vehicleId, setVehicleId] = useState('');
|
||||||
|
const [description, setDescription] = useState('');
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const { data: shops = [] } = useQuery({ queryKey: ['shops'], queryFn: () => api.getShops({ active: 1 }) });
|
||||||
|
const { data: owners = [] } = useQuery({ queryKey: ['owners'], queryFn: () => api.getOwners() });
|
||||||
|
const { data: vehicles = [] } = useQuery({
|
||||||
|
queryKey: ['vehicles', { ownerId }],
|
||||||
|
queryFn: () => api.getVehicles({ ownerId: ownerId ? Number(ownerId) : undefined }),
|
||||||
|
enabled: customerType !== 'internal' && !!ownerId,
|
||||||
|
});
|
||||||
|
const { data: inventoryVehicles = [] } = useQuery({
|
||||||
|
queryKey: ['vehicles', 'inventory'],
|
||||||
|
queryFn: () => api.getVehicles({ inventory: 1 } as any),
|
||||||
|
enabled: customerType === 'internal',
|
||||||
|
});
|
||||||
|
|
||||||
|
const createShopMutation = useMutation({
|
||||||
|
mutationFn: api.createShop,
|
||||||
|
onSuccess: (data: any) => { queryClient.invalidateQueries({ queryKey: ['shops'] }); setShopId(String(data.id)); },
|
||||||
|
});
|
||||||
|
|
||||||
|
const createOwnerMutation = useMutation({
|
||||||
|
mutationFn: api.createOwner,
|
||||||
|
onSuccess: (data: any) => { queryClient.invalidateQueries({ queryKey: ['owners'] }); setOwnerId(String(data.id)); setVehicleId(''); },
|
||||||
|
});
|
||||||
|
|
||||||
|
const createVehicleMutation = useMutation({
|
||||||
|
mutationFn: api.createVehicle,
|
||||||
|
onSuccess: (data: any) => { queryClient.invalidateQueries({ queryKey: ['vehicles'] }); setVehicleId(String(data.id)); },
|
||||||
|
});
|
||||||
|
|
||||||
|
const canSubmit = vehicleId && (
|
||||||
|
customerType === 'internal' ||
|
||||||
|
(customerType === 'owner' && ownerId) ||
|
||||||
|
(customerType === 'shop' && shopId && ownerId)
|
||||||
|
);
|
||||||
|
|
||||||
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
onSave({
|
||||||
|
customerType,
|
||||||
|
shopId: customerType === 'shop' ? Number(shopId) : null,
|
||||||
|
ownerId: ownerId ? Number(ownerId) : null,
|
||||||
|
vehicleId: Number(vehicleId),
|
||||||
|
description: description || null,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const typeBtn = (type: 'owner' | 'shop' | 'internal', label: string) => (
|
||||||
|
<button type="button" onClick={() => { setCustomerType(type); setShopId(''); setVehicleId(''); }}
|
||||||
|
className={`flex-1 px-3 py-2 text-sm font-medium rounded-lg border transition-colors ${customerType === type ? 'bg-blue-50 border-blue-300 text-blue-700' : 'border-gray-300 text-gray-600 hover:bg-gray-50'}`}>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal open={open} onClose={onClose} title="New Job">
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Customer Type *</label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
{typeBtn('owner', 'Vehicle Owner')}
|
||||||
|
{typeBtn('shop', 'Referring Shop')}
|
||||||
|
{typeBtn('internal', 'Internal / Flip')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{customerType === 'shop' && (
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center justify-between mb-1">
|
||||||
|
<label className="block text-sm font-medium text-gray-700">Referring Shop *</label>
|
||||||
|
<InlineQuickAdd
|
||||||
|
label="New Shop"
|
||||||
|
saving={createShopMutation.isPending}
|
||||||
|
fields={[
|
||||||
|
{ key: 'name', label: 'Shop Name', required: true },
|
||||||
|
{ key: 'contactName', label: 'Contact Name', required: true },
|
||||||
|
{ key: 'phone', label: 'Phone' },
|
||||||
|
{ key: 'email', label: 'Email' },
|
||||||
|
]}
|
||||||
|
onSave={(data) => createShopMutation.mutate(data)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<select className="block w-full rounded-lg border border-gray-300 px-3 py-2 text-sm" value={shopId} onChange={(e) => setShopId(e.target.value)} required>
|
||||||
|
<option value="">Select a shop...</option>
|
||||||
|
{shops.map((s: Shop) => <option key={s.id} value={s.id}>{s.name}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{customerType === 'internal' ? (
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center justify-between mb-1">
|
||||||
|
<label className="block text-sm font-medium text-gray-700">Inventory Vehicle *</label>
|
||||||
|
<InlineQuickAdd
|
||||||
|
label="New Inventory Vehicle"
|
||||||
|
saving={createVehicleMutation.isPending}
|
||||||
|
fields={[
|
||||||
|
{ key: 'year', label: 'Year', type: 'number' },
|
||||||
|
{ key: 'make', label: 'Make', required: true },
|
||||||
|
{ key: 'model', label: 'Model', required: true },
|
||||||
|
{ key: 'vin', label: 'VIN' },
|
||||||
|
{ key: 'color', label: 'Color' },
|
||||||
|
{ key: 'mileage', label: 'Mileage', type: 'number' },
|
||||||
|
{ key: 'purchasePrice', label: 'Purchase Price', type: 'number' },
|
||||||
|
]}
|
||||||
|
onSave={(data) => createVehicleMutation.mutate({ ...data, isInventory: true, year: data.year ? parseInt(data.year) : null, mileage: data.mileage ? parseInt(data.mileage) : null, purchasePrice: data.purchasePrice ? parseFloat(data.purchasePrice) : null })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<select className="block w-full rounded-lg border border-gray-300 px-3 py-2 text-sm" value={vehicleId} onChange={(e) => setVehicleId(e.target.value)} required>
|
||||||
|
<option value="">Select an inventory vehicle...</option>
|
||||||
|
{inventoryVehicles.map((v: Vehicle) => <option key={v.id} value={v.id}>{[v.year, v.make, v.model].filter(Boolean).join(' ')}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center justify-between mb-1">
|
||||||
|
<label className="block text-sm font-medium text-gray-700">Vehicle Owner *</label>
|
||||||
|
<InlineQuickAdd
|
||||||
|
label="New Owner"
|
||||||
|
saving={createOwnerMutation.isPending}
|
||||||
|
fields={[
|
||||||
|
{ key: 'firstName', label: 'First Name', required: true },
|
||||||
|
{ key: 'lastName', label: 'Last Name', required: true },
|
||||||
|
{ key: 'phone', label: 'Phone' },
|
||||||
|
{ key: 'email', label: 'Email' },
|
||||||
|
]}
|
||||||
|
onSave={(data) => createOwnerMutation.mutate(data)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<select className="block w-full rounded-lg border border-gray-300 px-3 py-2 text-sm" value={ownerId} onChange={(e) => { setOwnerId(e.target.value); setVehicleId(''); }} required>
|
||||||
|
<option value="">Select an owner...</option>
|
||||||
|
{owners.map((o: Owner) => <option key={o.id} value={o.id}>{o.first_name} {o.last_name}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{ownerId && (
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center justify-between mb-1">
|
||||||
|
<label className="block text-sm font-medium text-gray-700">Vehicle *</label>
|
||||||
|
<InlineQuickAdd
|
||||||
|
label="New Vehicle"
|
||||||
|
saving={createVehicleMutation.isPending}
|
||||||
|
fields={[
|
||||||
|
{ key: 'year', label: 'Year', type: 'number' },
|
||||||
|
{ key: 'make', label: 'Make', required: true },
|
||||||
|
{ key: 'model', label: 'Model', required: true },
|
||||||
|
{ key: 'vin', label: 'VIN' },
|
||||||
|
{ key: 'color', label: 'Color' },
|
||||||
|
{ key: 'licensePlate', label: 'License Plate' },
|
||||||
|
]}
|
||||||
|
onSave={(data) => createVehicleMutation.mutate({ ...data, ownerId: Number(ownerId), year: data.year ? parseInt(data.year) : null })}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<select className="block w-full rounded-lg border border-gray-300 px-3 py-2 text-sm" value={vehicleId} onChange={(e) => setVehicleId(e.target.value)} required>
|
||||||
|
<option value="">Select a vehicle...</option>
|
||||||
|
{vehicles.map((v: Vehicle) => <option key={v.id} value={v.id}>{[v.year, v.make, v.model].filter(Boolean).join(' ')}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Input label="Description / Chief Complaint" value={description} onChange={(e) => setDescription(e.target.value)} placeholder="What needs to be done?" />
|
||||||
|
<div className="flex justify-end gap-2 pt-2">
|
||||||
|
<Button variant="secondary" type="button" onClick={onClose}>Cancel</Button>
|
||||||
|
<Button type="submit" disabled={saving || !canSubmit}>{saving ? 'Creating...' : 'Create Job'}</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
61
client/src/pages/Login.tsx
Normal file
61
client/src/pages/Login.tsx
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import type { FormEvent } from 'react';
|
||||||
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { api } from '../api/client';
|
||||||
|
import { useStore } from '../store';
|
||||||
|
import { Button, Input } from '../components/ui';
|
||||||
|
|
||||||
|
export default function Login() {
|
||||||
|
const [username, setUsername] = useState('');
|
||||||
|
const [password, setPassword] = useState('');
|
||||||
|
const [displayName, setDisplayName] = useState('');
|
||||||
|
const [isSetup, setIsSetup] = useState(false);
|
||||||
|
const [error, setError] = useState('');
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const { login, token } = useStore();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (token) { navigate('/'); return; }
|
||||||
|
api.setupRequired().then(({ setupRequired }) => {
|
||||||
|
setIsSetup(setupRequired);
|
||||||
|
setLoading(false);
|
||||||
|
});
|
||||||
|
}, [token, navigate]);
|
||||||
|
|
||||||
|
const handleSubmit = async (e: FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setError('');
|
||||||
|
try {
|
||||||
|
const result = isSetup
|
||||||
|
? await api.register({ username, password, displayName })
|
||||||
|
: await api.login({ username, password });
|
||||||
|
login(result.token, result.user);
|
||||||
|
navigate('/');
|
||||||
|
} catch (err: any) {
|
||||||
|
setError(err.message);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) return <div className="min-h-screen flex items-center justify-center"><p className="text-gray-500">Loading...</p></div>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen flex items-center justify-center bg-gray-50 px-4">
|
||||||
|
<div className="w-full max-w-sm">
|
||||||
|
<h1 className="text-2xl font-bold text-center text-gray-900 mb-1">Shop Manager</h1>
|
||||||
|
<p className="text-center text-sm text-gray-500 mb-6">
|
||||||
|
{isSetup ? 'Create your admin account' : 'Sign in to your account'}
|
||||||
|
</p>
|
||||||
|
<form onSubmit={handleSubmit} className="bg-white rounded-xl border border-gray-200 shadow-sm p-6 space-y-4">
|
||||||
|
{isSetup && (
|
||||||
|
<Input label="Display Name" value={displayName} onChange={(e) => setDisplayName(e.target.value)} placeholder="Tony" />
|
||||||
|
)}
|
||||||
|
<Input label="Username" value={username} onChange={(e) => setUsername(e.target.value)} required autoFocus />
|
||||||
|
<Input label="Password" type="password" value={password} onChange={(e) => setPassword(e.target.value)} required />
|
||||||
|
{error && <p className="text-sm text-red-600">{error}</p>}
|
||||||
|
<Button type="submit" className="w-full">{isSetup ? 'Create Account' : 'Sign In'}</Button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
190
client/src/pages/OwnerDetail.tsx
Normal file
190
client/src/pages/OwnerDetail.tsx
Normal file
@@ -0,0 +1,190 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { useParams, Link } from 'react-router-dom';
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { ArrowLeft, Phone, Mail, Car, Edit2, Plus } from 'lucide-react';
|
||||||
|
import { api } from '../api/client';
|
||||||
|
import { Card, Button, Badge, Input, Modal } from '../components/ui';
|
||||||
|
import type { Owner, Vehicle, Job } from '../types';
|
||||||
|
|
||||||
|
const statusLabel: Record<string, string> = {
|
||||||
|
received: 'Received', in_progress: 'In Progress', completed: 'Completed', invoiced: 'Invoiced', closed: 'Closed',
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function OwnerDetail() {
|
||||||
|
const { id } = useParams();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [showEditModal, setShowEditModal] = useState(false);
|
||||||
|
|
||||||
|
const { data: owner, isLoading } = useQuery<Owner & { vehicles: Vehicle[] }>({
|
||||||
|
queryKey: ['owners', id],
|
||||||
|
queryFn: () => api.getOwner(Number(id)),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get all jobs for this owner
|
||||||
|
const { data: jobs = [] } = useQuery({
|
||||||
|
queryKey: ['jobs', { ownerId: id }],
|
||||||
|
queryFn: async () => {
|
||||||
|
const allJobs = await api.getJobs({});
|
||||||
|
return allJobs.filter((j: Job) => j.owner_id === Number(id));
|
||||||
|
},
|
||||||
|
enabled: !!id,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Get invoices for this owner
|
||||||
|
const { data: invoices = [] } = useQuery({
|
||||||
|
queryKey: ['invoices', { ownerId: id }],
|
||||||
|
queryFn: () => api.getInvoices({ ownerId: Number(id) }),
|
||||||
|
enabled: !!id,
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateMutation = useMutation({
|
||||||
|
mutationFn: (data: any) => api.updateOwner(Number(id), data),
|
||||||
|
onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['owners', id] }); setShowEditModal(false); },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (isLoading || !owner) return <p className="text-gray-500 py-4">Loading...</p>;
|
||||||
|
|
||||||
|
const vehicles = owner.vehicles || [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<Link to="/owners" className="inline-flex items-center gap-1.5 text-sm text-gray-500 hover:text-gray-700">
|
||||||
|
<ArrowLeft size={16} /> Back to Owners
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900">{owner.first_name} {owner.last_name}</h1>
|
||||||
|
<div className="flex gap-4 mt-1 text-sm text-gray-500">
|
||||||
|
{owner.phone && <span className="flex items-center gap-1"><Phone size={14} /> {owner.phone}</span>}
|
||||||
|
{owner.email && <span className="flex items-center gap-1"><Mail size={14} /> {owner.email}</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Button variant="secondary" onClick={() => setShowEditModal(true)}>
|
||||||
|
<Edit2 size={16} /> Edit
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{owner.notes && (
|
||||||
|
<Card className="p-4">
|
||||||
|
<h3 className="text-xs font-medium text-gray-500 uppercase mb-1">Notes</h3>
|
||||||
|
<p className="text-sm text-gray-700">{owner.notes}</p>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Vehicles */}
|
||||||
|
<Card>
|
||||||
|
<div className="p-4 border-b border-gray-200 flex items-center justify-between">
|
||||||
|
<h2 className="font-semibold text-gray-900">Vehicles ({vehicles.length})</h2>
|
||||||
|
</div>
|
||||||
|
{vehicles.length === 0 ? (
|
||||||
|
<p className="p-4 text-sm text-gray-500">No vehicles registered.</p>
|
||||||
|
) : (
|
||||||
|
<div className="divide-y divide-gray-100">
|
||||||
|
{vehicles.map((v: Vehicle) => (
|
||||||
|
<Link key={v.id} to={`/vehicles/${v.id}`} className="flex items-center gap-3 p-3 hover:bg-gray-50">
|
||||||
|
<Car size={16} className="text-gray-400" />
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-gray-900">{[v.year, v.make, v.model].filter(Boolean).join(' ')}</p>
|
||||||
|
<p className="text-xs text-gray-500">
|
||||||
|
{[v.color, v.vin && `VIN: ${v.vin}`, v.license_plate && `Plate: ${v.license_plate}`].filter(Boolean).join(' — ')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Job History */}
|
||||||
|
<Card>
|
||||||
|
<div className="p-4 border-b border-gray-200">
|
||||||
|
<h2 className="font-semibold text-gray-900">Job History ({jobs.length})</h2>
|
||||||
|
</div>
|
||||||
|
{jobs.length === 0 ? (
|
||||||
|
<p className="p-4 text-sm text-gray-500">No jobs yet.</p>
|
||||||
|
) : (
|
||||||
|
<div className="divide-y divide-gray-100">
|
||||||
|
{jobs.map((job: Job) => (
|
||||||
|
<div key={job.id} className="flex items-center justify-between p-3 hover:bg-gray-50">
|
||||||
|
<div>
|
||||||
|
<Link to={`/jobs/${job.id}`} className="text-sm font-medium text-blue-600 hover:underline">{job.job_number}</Link>
|
||||||
|
<p className="text-xs text-gray-500">
|
||||||
|
{[job.year, job.make, job.model].filter(Boolean).join(' ')}
|
||||||
|
{job.description && ` — ${job.description.slice(0, 50)}${job.description.length > 50 ? '...' : ''}`}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Badge status={job.status}>{statusLabel[job.status]}</Badge>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Invoices */}
|
||||||
|
{invoices.length > 0 && (
|
||||||
|
<Card>
|
||||||
|
<div className="p-4 border-b border-gray-200">
|
||||||
|
<h2 className="font-semibold text-gray-900">Invoices ({invoices.length})</h2>
|
||||||
|
</div>
|
||||||
|
<div className="divide-y divide-gray-100">
|
||||||
|
{invoices.map((inv: any) => (
|
||||||
|
<div key={inv.id} className="flex items-center justify-between p-3 hover:bg-gray-50">
|
||||||
|
<div>
|
||||||
|
<Link to={`/invoices/${inv.id}`} className="text-sm font-medium text-blue-600 hover:underline">{inv.invoice_number}</Link>
|
||||||
|
<p className="text-xs text-gray-500">{new Date(inv.invoice_date).toLocaleDateString()} — ${inv.total.toFixed(2)}</p>
|
||||||
|
</div>
|
||||||
|
<Badge status={inv.status}>{inv.status}</Badge>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{showEditModal && (
|
||||||
|
<EditOwnerModal
|
||||||
|
owner={owner}
|
||||||
|
open={showEditModal}
|
||||||
|
onClose={() => setShowEditModal(false)}
|
||||||
|
onSave={(data) => updateMutation.mutate(data)}
|
||||||
|
saving={updateMutation.isPending}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function EditOwnerModal({ owner, open, onClose, onSave, saving }: {
|
||||||
|
owner: Owner; open: boolean; onClose: () => void; onSave: (data: any) => void; saving: boolean;
|
||||||
|
}) {
|
||||||
|
const [form, setForm] = useState({
|
||||||
|
firstName: owner.first_name,
|
||||||
|
lastName: owner.last_name,
|
||||||
|
phone: owner.phone || '',
|
||||||
|
email: owner.email || '',
|
||||||
|
notes: owner.notes || '',
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal open={open} onClose={onClose} title="Edit Owner">
|
||||||
|
<form onSubmit={(e) => { e.preventDefault(); onSave(form); }} className="space-y-3">
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<Input label="First Name *" value={form.firstName} onChange={(e) => setForm({ ...form, firstName: e.target.value })} required />
|
||||||
|
<Input label="Last Name *" value={form.lastName} onChange={(e) => setForm({ ...form, lastName: e.target.value })} required />
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<Input label="Phone" value={form.phone} onChange={(e) => setForm({ ...form, phone: e.target.value })} />
|
||||||
|
<Input label="Email" value={form.email} onChange={(e) => setForm({ ...form, email: e.target.value })} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Notes</label>
|
||||||
|
<textarea className="block w-full rounded-lg border border-gray-300 px-3 py-2 text-sm" rows={2} value={form.notes} onChange={(e) => setForm({ ...form, notes: e.target.value })} />
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-end gap-2 pt-2">
|
||||||
|
<Button variant="secondary" type="button" onClick={onClose}>Cancel</Button>
|
||||||
|
<Button type="submit" disabled={saving}>{saving ? 'Saving...' : 'Save'}</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
157
client/src/pages/Owners.tsx
Normal file
157
client/src/pages/Owners.tsx
Normal file
@@ -0,0 +1,157 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { Users, Plus, Search, Phone, Mail, Car } from 'lucide-react';
|
||||||
|
import { api } from '../api/client';
|
||||||
|
import { Card, Button, Input, Modal, EmptyState } from '../components/ui';
|
||||||
|
import type { Owner, Vehicle } from '../types';
|
||||||
|
|
||||||
|
export default function Owners() {
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
|
const [showOwnerModal, setShowOwnerModal] = useState(false);
|
||||||
|
const [showVehicleModal, setShowVehicleModal] = useState(false);
|
||||||
|
const [editOwner, setEditOwner] = useState<Owner | null>(null);
|
||||||
|
const [selectedOwnerId, setSelectedOwnerId] = useState<number | null>(null);
|
||||||
|
const [expandedOwner, setExpandedOwner] = useState<number | null>(null);
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const { data: owners = [], isLoading } = useQuery({
|
||||||
|
queryKey: ['owners', search],
|
||||||
|
queryFn: () => api.getOwners({ search: search || undefined }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const saveMutation = useMutation({
|
||||||
|
mutationFn: (data: any) => editOwner ? api.updateOwner(editOwner.id, data) : api.createOwner(data),
|
||||||
|
onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['owners'] }); setShowOwnerModal(false); setEditOwner(null); },
|
||||||
|
});
|
||||||
|
|
||||||
|
const saveVehicleMutation = useMutation({
|
||||||
|
mutationFn: (data: any) => api.createVehicle(data),
|
||||||
|
onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['owners'] }); queryClient.invalidateQueries({ queryKey: ['vehicles'] }); setShowVehicleModal(false); },
|
||||||
|
});
|
||||||
|
|
||||||
|
const toggleExpand = (id: number) => {
|
||||||
|
setExpandedOwner(expandedOwner === id ? null : id);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900">Vehicle Owners</h1>
|
||||||
|
<Button onClick={() => { setEditOwner(null); setShowOwnerModal(true); }}><Plus size={16} /> Add Owner</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="relative">
|
||||||
|
<Search size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400" />
|
||||||
|
<input className="w-full pl-9 pr-3 py-2 text-sm border border-gray-300 rounded-lg focus:border-blue-500 focus:ring-1 focus:ring-blue-500 outline-none" placeholder="Search owners..." value={search} onChange={(e) => setSearch(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isLoading ? <p className="text-gray-500 py-4">Loading...</p> : owners.length === 0 ? (
|
||||||
|
<EmptyState icon={Users} title="No owners yet" description="Add vehicle owners to track whose cars you're working on." action={<Button onClick={() => setShowOwnerModal(true)}>Add Owner</Button>} />
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{owners.map((owner: Owner) => (
|
||||||
|
<Card key={owner.id}>
|
||||||
|
<div className="p-4 cursor-pointer hover:bg-gray-50 transition-colors" onClick={() => toggleExpand(owner.id)}>
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<Link to={`/owners/${owner.id}`} className="font-medium text-blue-600 hover:underline" onClick={(e) => e.stopPropagation()}>{owner.first_name} {owner.last_name}</Link>
|
||||||
|
<div className="flex gap-3 mt-1 text-xs text-gray-500">
|
||||||
|
{owner.phone && <span className="flex items-center gap-1"><Phone size={11} />{owner.phone}</span>}
|
||||||
|
{owner.email && <span className="flex items-center gap-1"><Mail size={11} />{owner.email}</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button variant="ghost" size="sm" onClick={(e) => { e.stopPropagation(); setEditOwner(owner); setShowOwnerModal(true); }}>Edit</Button>
|
||||||
|
<Button variant="ghost" size="sm" onClick={(e) => { e.stopPropagation(); setSelectedOwnerId(owner.id); setShowVehicleModal(true); }}>
|
||||||
|
<Car size={14} /> Add Vehicle
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{expandedOwner === owner.id && <OwnerVehicles ownerId={owner.id} />}
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<OwnerModal open={showOwnerModal} onClose={() => { setShowOwnerModal(false); setEditOwner(null); }} owner={editOwner} onSave={(data) => saveMutation.mutate(data)} saving={saveMutation.isPending} />
|
||||||
|
<VehicleModal open={showVehicleModal} onClose={() => setShowVehicleModal(false)} ownerId={selectedOwnerId} onSave={(data) => saveVehicleMutation.mutate(data)} saving={saveVehicleMutation.isPending} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function OwnerVehicles({ ownerId }: { ownerId: number }) {
|
||||||
|
const { data: vehicles = [] } = useQuery({
|
||||||
|
queryKey: ['vehicles', { ownerId }],
|
||||||
|
queryFn: () => api.getVehicles({ ownerId }),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (vehicles.length === 0) return <p className="px-4 pb-3 text-sm text-gray-500">No vehicles registered.</p>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="px-4 pb-3 space-y-2">
|
||||||
|
{vehicles.map((v: Vehicle) => (
|
||||||
|
<Link key={v.id} to={`/vehicles/${v.id}`} className="flex items-center gap-2 text-sm bg-gray-50 rounded-lg px-3 py-2 hover:bg-gray-100">
|
||||||
|
<Car size={14} className="text-gray-400" />
|
||||||
|
<span className="font-medium text-blue-600">{[v.year, v.make, v.model].filter(Boolean).join(' ')}</span>
|
||||||
|
{v.vin && <span className="text-gray-400 text-xs">VIN: {v.vin}</span>}
|
||||||
|
{v.color && <span className="text-gray-400 text-xs">({v.color})</span>}
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function OwnerModal({ open, onClose, owner, onSave, saving }: { open: boolean; onClose: () => void; owner: Owner | null; onSave: (data: any) => void; saving: boolean }) {
|
||||||
|
const [form, setForm] = useState({ firstName: '', lastName: '', phone: '', email: '', notes: '' });
|
||||||
|
|
||||||
|
useState(() => {
|
||||||
|
if (owner) setForm({ firstName: owner.first_name, lastName: owner.last_name, phone: owner.phone || '', email: owner.email || '', notes: owner.notes || '' });
|
||||||
|
else setForm({ firstName: '', lastName: '', phone: '', email: '', notes: '' });
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal open={open} onClose={onClose} title={owner ? 'Edit Owner' : 'Add Owner'}>
|
||||||
|
<form onSubmit={(e) => { e.preventDefault(); onSave(form); }} className="space-y-3">
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<Input label="First Name *" value={form.firstName} onChange={(e) => setForm({ ...form, firstName: e.target.value })} required />
|
||||||
|
<Input label="Last Name *" value={form.lastName} onChange={(e) => setForm({ ...form, lastName: e.target.value })} required />
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<Input label="Phone" value={form.phone} onChange={(e) => setForm({ ...form, phone: e.target.value })} />
|
||||||
|
<Input label="Email" value={form.email} onChange={(e) => setForm({ ...form, email: e.target.value })} />
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-end gap-2 pt-2">
|
||||||
|
<Button variant="secondary" type="button" onClick={onClose}>Cancel</Button>
|
||||||
|
<Button type="submit" disabled={saving}>{saving ? 'Saving...' : 'Save'}</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function VehicleModal({ open, onClose, ownerId, onSave, saving }: { open: boolean; onClose: () => void; ownerId: number | null; onSave: (data: any) => void; saving: boolean }) {
|
||||||
|
const [form, setForm] = useState({ year: '', make: '', model: '', vin: '', color: '', licensePlate: '' });
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal open={open} onClose={onClose} title="Add Vehicle">
|
||||||
|
<form onSubmit={(e) => { e.preventDefault(); onSave({ ...form, ownerId, year: form.year ? parseInt(form.year) : null }); }} className="space-y-3">
|
||||||
|
<div className="grid grid-cols-3 gap-3">
|
||||||
|
<Input label="Year" type="number" value={form.year} onChange={(e) => setForm({ ...form, year: e.target.value })} />
|
||||||
|
<Input label="Make *" value={form.make} onChange={(e) => setForm({ ...form, make: e.target.value })} required />
|
||||||
|
<Input label="Model *" value={form.model} onChange={(e) => setForm({ ...form, model: e.target.value })} required />
|
||||||
|
</div>
|
||||||
|
<Input label="VIN" value={form.vin} onChange={(e) => setForm({ ...form, vin: e.target.value })} />
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<Input label="Color" value={form.color} onChange={(e) => setForm({ ...form, color: e.target.value })} />
|
||||||
|
<Input label="License Plate" value={form.licensePlate} onChange={(e) => setForm({ ...form, licensePlate: e.target.value })} />
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-end gap-2 pt-2">
|
||||||
|
<Button variant="secondary" type="button" onClick={onClose}>Cancel</Button>
|
||||||
|
<Button type="submit" disabled={saving}>{saving ? 'Saving...' : 'Save'}</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
251
client/src/pages/Reports.tsx
Normal file
251
client/src/pages/Reports.tsx
Normal file
@@ -0,0 +1,251 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { BarChart3 } from 'lucide-react';
|
||||||
|
import { api } from '../api/client';
|
||||||
|
import { Card, Input } from '../components/ui';
|
||||||
|
|
||||||
|
const fmt = (n: number) => '$' + (n || 0).toFixed(2);
|
||||||
|
|
||||||
|
const reports = [
|
||||||
|
{ id: 'revenue', label: 'Revenue Summary' },
|
||||||
|
{ id: 'profitability', label: 'Job Profitability' },
|
||||||
|
{ id: 'by-shop', label: 'Revenue by Shop' },
|
||||||
|
{ id: 'aging', label: 'Receivables Aging' },
|
||||||
|
{ id: 'tech-productivity', label: 'Technician Productivity' },
|
||||||
|
{ id: 'top-customers', label: 'Top Customers' },
|
||||||
|
{ id: 'jobs-status', label: 'Jobs by Status' },
|
||||||
|
{ id: 'inventory', label: 'Inventory Summary' },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function Reports() {
|
||||||
|
const [activeReport, setActiveReport] = useState('revenue');
|
||||||
|
const now = new Date();
|
||||||
|
const [startDate, setStartDate] = useState(new Date(now.getFullYear(), 0, 1).toISOString().split('T')[0]);
|
||||||
|
const [endDate, setEndDate] = useState(now.toISOString().split('T')[0]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900">Reports</h1>
|
||||||
|
|
||||||
|
<div className="flex gap-3 flex-col sm:flex-row">
|
||||||
|
<div className="flex gap-1 flex-wrap flex-1">
|
||||||
|
{reports.map(r => (
|
||||||
|
<button key={r.id} onClick={() => setActiveReport(r.id)}
|
||||||
|
className={`px-3 py-1.5 text-xs font-medium rounded-lg transition-colors ${activeReport === r.id ? 'bg-blue-100 text-blue-700' : 'bg-gray-100 text-gray-600 hover:bg-gray-200'}`}>
|
||||||
|
{r.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2 items-center">
|
||||||
|
<Input type="date" value={startDate} onChange={(e) => setStartDate(e.target.value)} />
|
||||||
|
<span className="text-gray-400">to</span>
|
||||||
|
<Input type="date" value={endDate} onChange={(e) => setEndDate(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{activeReport === 'revenue' && <RevenueReport startDate={startDate} endDate={endDate} />}
|
||||||
|
{activeReport === 'profitability' && <ProfitabilityReport startDate={startDate} endDate={endDate} />}
|
||||||
|
{activeReport === 'by-shop' && <RevenueByShopReport startDate={startDate} endDate={endDate} />}
|
||||||
|
{activeReport === 'aging' && <AgingReport />}
|
||||||
|
{activeReport === 'tech-productivity' && <TechProductivityReport startDate={startDate} endDate={endDate} />}
|
||||||
|
{activeReport === 'top-customers' && <TopCustomersReport startDate={startDate} endDate={endDate} />}
|
||||||
|
{activeReport === 'jobs-status' && <JobsByStatusReport />}
|
||||||
|
{activeReport === 'inventory' && <InventorySummaryReport />}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function RevenueReport({ startDate, endDate }: { startDate: string; endDate: string }) {
|
||||||
|
const { data } = useQuery({ queryKey: ['report-revenue', startDate, endDate], queryFn: () => api.getRevenueReport({ startDate, endDate }) });
|
||||||
|
if (!data) return <p className="text-gray-500">Loading...</p>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
|
||||||
|
<Card className="p-4"><p className="text-xs text-gray-500">Total Revenue</p><p className="text-2xl font-bold text-gray-900">{fmt(data.total)}</p></Card>
|
||||||
|
<Card className="p-4"><p className="text-xs text-gray-500">Invoices</p><p className="text-2xl font-bold text-gray-900">{data.invoiceCount}</p></Card>
|
||||||
|
<Card className="p-4"><p className="text-xs text-gray-500">Avg Repair Order</p><p className="text-2xl font-bold text-gray-900">{fmt(data.averageRepairOrder)}</p></Card>
|
||||||
|
<Card className="p-4"><p className="text-xs text-gray-500">Payment Methods</p><p className="text-2xl font-bold text-gray-900">{data.byMethod?.length || 0}</p></Card>
|
||||||
|
</div>
|
||||||
|
<Card><div className="p-4"><h3 className="font-medium text-gray-900 mb-3">Monthly Revenue</h3>
|
||||||
|
<div className="space-y-2">{data.monthly?.map((m: any) => (
|
||||||
|
<div key={m.month} className="flex items-center justify-between text-sm">
|
||||||
|
<span className="text-gray-600">{m.month}</span>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-48 bg-gray-100 rounded-full h-2"><div className="bg-blue-500 h-2 rounded-full" style={{ width: `${Math.min(100, (m.total / (data.total || 1)) * 100 * data.monthly.length)}%` }} /></div>
|
||||||
|
<span className="font-medium w-24 text-right">{fmt(m.total)}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}</div></div>
|
||||||
|
</Card>
|
||||||
|
<Card><div className="p-4"><h3 className="font-medium text-gray-900 mb-3">By Payment Method</h3>
|
||||||
|
<div className="space-y-2">{data.byMethod?.map((m: any) => (
|
||||||
|
<div key={m.method} className="flex justify-between text-sm"><span className="text-gray-600 capitalize">{m.method}</span><span className="font-medium">{fmt(m.total)} ({m.count})</span></div>
|
||||||
|
))}</div></div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ProfitabilityReport({ startDate, endDate }: { startDate: string; endDate: string }) {
|
||||||
|
const { data } = useQuery({ queryKey: ['report-profit', startDate, endDate], queryFn: () => api.getProfitabilityReport({ startDate, endDate }) });
|
||||||
|
if (!data) return <p className="text-gray-500">Loading...</p>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
|
||||||
|
<Card className="p-4"><p className="text-xs text-gray-500">Total Billed</p><p className="text-2xl font-bold">{fmt(data.summary?.totalBilled)}</p></Card>
|
||||||
|
<Card className="p-4"><p className="text-xs text-gray-500">Labor Revenue</p><p className="text-2xl font-bold">{fmt(data.summary?.totalLabor)}</p></Card>
|
||||||
|
<Card className="p-4"><p className="text-xs text-gray-500">Parts Margin</p><p className="text-2xl font-bold text-green-600">{fmt(data.summary?.totalPartsMargin)}</p></Card>
|
||||||
|
<Card className="p-4"><p className="text-xs text-gray-500">Jobs</p><p className="text-2xl font-bold">{data.summary?.jobCount}</p></Card>
|
||||||
|
</div>
|
||||||
|
<Card><div className="overflow-x-auto"><table className="w-full text-sm">
|
||||||
|
<thead><tr className="border-b text-left text-xs text-gray-500 uppercase">
|
||||||
|
<th className="px-4 py-3">Job</th><th className="px-4 py-3">Vehicle</th><th className="px-4 py-3 text-right">Labor</th>
|
||||||
|
<th className="px-4 py-3 text-right">Parts Cost</th><th className="px-4 py-3 text-right">Parts Billed</th>
|
||||||
|
<th className="px-4 py-3 text-right">Margin</th><th className="px-4 py-3 text-right">Total</th>
|
||||||
|
</tr></thead>
|
||||||
|
<tbody className="divide-y divide-gray-100">{data.jobs?.slice(0, 50).map((j: any) => (
|
||||||
|
<tr key={j.id}><td className="px-4 py-2 font-medium text-gray-900">{j.job_number}</td>
|
||||||
|
<td className="px-4 py-2 text-gray-500">{[j.year, j.make, j.model].filter(Boolean).join(' ')}</td>
|
||||||
|
<td className="px-4 py-2 text-right">{fmt(j.labor_total)}</td>
|
||||||
|
<td className="px-4 py-2 text-right text-gray-500">{fmt(j.parts_cost)}</td>
|
||||||
|
<td className="px-4 py-2 text-right">{fmt(j.parts_billed)}</td>
|
||||||
|
<td className="px-4 py-2 text-right text-green-600">{fmt(j.parts_margin)}</td>
|
||||||
|
<td className="px-4 py-2 text-right font-medium">{fmt(j.total_billed)}</td></tr>
|
||||||
|
))}</tbody></table></div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function RevenueByShopReport({ startDate, endDate }: { startDate: string; endDate: string }) {
|
||||||
|
const { data = [] } = useQuery({ queryKey: ['report-by-shop', startDate, endDate], queryFn: () => api.getRevenueByShopReport({ startDate, endDate }) });
|
||||||
|
return (
|
||||||
|
<Card><div className="overflow-x-auto"><table className="w-full text-sm">
|
||||||
|
<thead><tr className="border-b text-left text-xs text-gray-500 uppercase">
|
||||||
|
<th className="px-4 py-3">Shop</th><th className="px-4 py-3 text-right">Invoices</th>
|
||||||
|
<th className="px-4 py-3 text-right">Invoiced</th><th className="px-4 py-3 text-right">Paid</th>
|
||||||
|
<th className="px-4 py-3 text-right">Outstanding</th>
|
||||||
|
</tr></thead>
|
||||||
|
<tbody className="divide-y divide-gray-100">{(data as any[]).map((s: any) => (
|
||||||
|
<tr key={s.shop_id}><td className="px-4 py-2 font-medium">{s.shop_name}</td>
|
||||||
|
<td className="px-4 py-2 text-right">{s.invoice_count}</td>
|
||||||
|
<td className="px-4 py-2 text-right">{fmt(s.total_invoiced)}</td>
|
||||||
|
<td className="px-4 py-2 text-right text-green-600">{fmt(s.total_paid)}</td>
|
||||||
|
<td className="px-4 py-2 text-right text-yellow-600">{fmt(s.total_invoiced - s.total_paid)}</td></tr>
|
||||||
|
))}</tbody></table></div>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AgingReport() {
|
||||||
|
const { data } = useQuery({ queryKey: ['report-aging'], queryFn: () => api.getAgingReport() });
|
||||||
|
if (!data) return <p className="text-gray-500">Loading...</p>;
|
||||||
|
const buckets = [
|
||||||
|
{ label: 'Current', items: data.aging?.current || [], total: data.totals?.current || 0, color: 'green' },
|
||||||
|
{ label: '1-30 Days', items: data.aging?.over30 || [], total: data.totals?.over30 || 0, color: 'yellow' },
|
||||||
|
{ label: '31-60 Days', items: data.aging?.over60 || [], total: data.totals?.over60 || 0, color: 'orange' },
|
||||||
|
{ label: '90+ Days', items: data.aging?.over90 || [], total: data.totals?.over90 || 0, color: 'red' },
|
||||||
|
];
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4">
|
||||||
|
{buckets.map(b => (
|
||||||
|
<Card key={b.label} className="p-4"><p className="text-xs text-gray-500">{b.label}</p>
|
||||||
|
<p className="text-2xl font-bold">{fmt(b.total)}</p><p className="text-xs text-gray-400">{b.items.length} invoices</p></Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<Card className="p-4"><p className="text-lg font-bold">Total Outstanding: {fmt(data.total || 0)}</p></Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TechProductivityReport({ startDate, endDate }: { startDate: string; endDate: string }) {
|
||||||
|
const { data = [] } = useQuery({ queryKey: ['report-tech', startDate, endDate], queryFn: () => api.getTechProductivityReport({ startDate, endDate }) });
|
||||||
|
return (
|
||||||
|
<Card><div className="overflow-x-auto"><table className="w-full text-sm">
|
||||||
|
<thead><tr className="border-b text-left text-xs text-gray-500 uppercase">
|
||||||
|
<th className="px-4 py-3">Technician</th><th className="px-4 py-3 text-right">Hours</th>
|
||||||
|
<th className="px-4 py-3 text-right">Jobs</th><th className="px-4 py-3 text-right">Clock Entries</th>
|
||||||
|
<th className="px-4 py-3 text-right">Labor Cost</th>
|
||||||
|
</tr></thead>
|
||||||
|
<tbody className="divide-y divide-gray-100">{(data as any[]).map((t: any) => (
|
||||||
|
<tr key={t.id}><td className="px-4 py-2 font-medium">{t.name}</td>
|
||||||
|
<td className="px-4 py-2 text-right">{t.total_hours}h</td>
|
||||||
|
<td className="px-4 py-2 text-right">{t.jobs_worked}</td>
|
||||||
|
<td className="px-4 py-2 text-right">{t.clock_entries}</td>
|
||||||
|
<td className="px-4 py-2 text-right">{fmt(t.labor_cost)}</td></tr>
|
||||||
|
))}</tbody></table></div>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TopCustomersReport({ startDate, endDate }: { startDate: string; endDate: string }) {
|
||||||
|
const { data } = useQuery({ queryKey: ['report-customers', startDate, endDate], queryFn: () => api.getTopCustomersReport({ startDate, endDate }) });
|
||||||
|
if (!data) return <p className="text-gray-500">Loading...</p>;
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<Card><div className="p-4 border-b"><h3 className="font-medium">Top Shops</h3></div><div className="overflow-x-auto"><table className="w-full text-sm">
|
||||||
|
<tbody className="divide-y divide-gray-100">{data.shops?.map((s: any) => (
|
||||||
|
<tr key={s.id}><td className="px-4 py-2 font-medium">{s.name}</td>
|
||||||
|
<td className="px-4 py-2 text-right text-gray-500">{s.job_count} jobs</td>
|
||||||
|
<td className="px-4 py-2 text-right font-medium">{fmt(s.total_invoiced)}</td></tr>
|
||||||
|
))}</tbody></table></div>
|
||||||
|
</Card>
|
||||||
|
<Card><div className="p-4 border-b"><h3 className="font-medium">Top Customers</h3></div><div className="overflow-x-auto"><table className="w-full text-sm">
|
||||||
|
<tbody className="divide-y divide-gray-100">{data.owners?.map((o: any) => (
|
||||||
|
<tr key={o.id}><td className="px-4 py-2 font-medium">{o.first_name} {o.last_name}</td>
|
||||||
|
<td className="px-4 py-2 text-right text-gray-500">{o.job_count} jobs</td>
|
||||||
|
<td className="px-4 py-2 text-right font-medium">{fmt(o.total_invoiced)}</td></tr>
|
||||||
|
))}</tbody></table></div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function JobsByStatusReport() {
|
||||||
|
const { data = [] } = useQuery({ queryKey: ['report-jobs-status'], queryFn: () => api.getJobsByStatusReport() });
|
||||||
|
const total = (data as any[]).reduce((s: number, j: any) => s + j.count, 0);
|
||||||
|
return (
|
||||||
|
<Card className="p-4"><h3 className="font-medium mb-3">Jobs by Status</h3>
|
||||||
|
<div className="space-y-3">{(data as any[]).map((j: any) => (
|
||||||
|
<div key={j.status} className="flex items-center justify-between">
|
||||||
|
<span className="text-sm capitalize text-gray-700">{j.status.replace('_', ' ')}</span>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="w-48 bg-gray-100 rounded-full h-2"><div className="bg-blue-500 h-2 rounded-full" style={{ width: `${(j.count / (total || 1)) * 100}%` }} /></div>
|
||||||
|
<span className="text-sm font-medium w-8 text-right">{j.count}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}</div>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function InventorySummaryReport() {
|
||||||
|
const { data } = useQuery({ queryKey: ['report-inventory'], queryFn: () => api.getInventorySummaryReport() });
|
||||||
|
if (!data) return <p className="text-gray-500">Loading...</p>;
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<Card className="p-4"><p className="text-xs text-gray-500">Total Inventory Value</p><p className="text-2xl font-bold">{fmt(data.totalValue)}</p></Card>
|
||||||
|
{data.lowStock?.length > 0 && (
|
||||||
|
<Card><div className="p-4 border-b"><h3 className="font-medium text-red-600">Low Stock Items ({data.lowStock.length})</h3></div>
|
||||||
|
<div className="divide-y divide-gray-100">{data.lowStock.map((i: any) => (
|
||||||
|
<div key={i.id} className="flex justify-between p-3 text-sm">
|
||||||
|
<span className="font-medium">{i.name} {i.part_number && <span className="text-gray-400">({i.part_number})</span>}</span>
|
||||||
|
<span className="text-red-600">{i.quantity_on_hand} / {i.reorder_point} min</span>
|
||||||
|
</div>
|
||||||
|
))}</div>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
<Card><div className="p-4"><h3 className="font-medium mb-3">By Category</h3>
|
||||||
|
<div className="space-y-2">{data.byCategory?.map((c: any) => (
|
||||||
|
<div key={c.category} className="flex justify-between text-sm">
|
||||||
|
<span className="text-gray-600">{c.category || 'Uncategorized'}</span>
|
||||||
|
<span className="font-medium">{c.item_count} items — {fmt(c.value)}</span>
|
||||||
|
</div>
|
||||||
|
))}</div></div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
109
client/src/pages/Settings.tsx
Normal file
109
client/src/pages/Settings.tsx
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { Download, Database } from 'lucide-react';
|
||||||
|
import { api } from '../api/client';
|
||||||
|
import { Card, Button, Input } from '../components/ui';
|
||||||
|
import type { Settings as SettingsType } from '../types';
|
||||||
|
|
||||||
|
export default function Settings() {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const { data: settings, isLoading } = useQuery<SettingsType>({ queryKey: ['settings'], queryFn: api.getSettings });
|
||||||
|
const [form, setForm] = useState({
|
||||||
|
shopName: '', shopAddress: '', shopCity: '', shopState: '', shopZip: '', shopPhone: '', shopEmail: '', defaultTaxRate: '0', invoiceTerms: '',
|
||||||
|
});
|
||||||
|
const [saved, setSaved] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (settings) {
|
||||||
|
setForm({
|
||||||
|
shopName: settings.shop_name || '',
|
||||||
|
shopAddress: settings.shop_address || '',
|
||||||
|
shopCity: settings.shop_city || '',
|
||||||
|
shopState: settings.shop_state || '',
|
||||||
|
shopZip: settings.shop_zip || '',
|
||||||
|
shopPhone: settings.shop_phone || '',
|
||||||
|
shopEmail: settings.shop_email || '',
|
||||||
|
defaultTaxRate: String((settings.default_tax_rate || 0) * 100),
|
||||||
|
invoiceTerms: settings.invoice_terms || '',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}, [settings]);
|
||||||
|
|
||||||
|
const mutation = useMutation({
|
||||||
|
mutationFn: (data: any) => api.updateSettings(data),
|
||||||
|
onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['settings'] }); setSaved(true); setTimeout(() => setSaved(false), 2000); },
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
mutation.mutate({ ...form, defaultTaxRate: parseFloat(form.defaultTaxRate) / 100 });
|
||||||
|
};
|
||||||
|
|
||||||
|
if (isLoading) return <p className="text-gray-500 py-4">Loading...</p>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900">Settings</h1>
|
||||||
|
|
||||||
|
<Card className="p-6">
|
||||||
|
<h2 className="font-semibold text-gray-900 mb-4">Business Information</h2>
|
||||||
|
<p className="text-sm text-gray-500 mb-4">This info appears on your invoices.</p>
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4 max-w-lg">
|
||||||
|
<Input label="Shop Name" value={form.shopName} onChange={(e) => setForm({ ...form, shopName: e.target.value })} />
|
||||||
|
<Input label="Address" value={form.shopAddress} onChange={(e) => setForm({ ...form, shopAddress: e.target.value })} />
|
||||||
|
<div className="grid grid-cols-3 gap-3">
|
||||||
|
<Input label="City" value={form.shopCity} onChange={(e) => setForm({ ...form, shopCity: e.target.value })} />
|
||||||
|
<Input label="State" value={form.shopState} onChange={(e) => setForm({ ...form, shopState: e.target.value })} />
|
||||||
|
<Input label="ZIP" value={form.shopZip} onChange={(e) => setForm({ ...form, shopZip: e.target.value })} />
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<Input label="Phone" value={form.shopPhone} onChange={(e) => setForm({ ...form, shopPhone: e.target.value })} />
|
||||||
|
<Input label="Email" value={form.shopEmail} onChange={(e) => setForm({ ...form, shopEmail: e.target.value })} />
|
||||||
|
</div>
|
||||||
|
<Input label="Default Tax Rate (%)" type="number" step="0.1" value={form.defaultTaxRate} onChange={(e) => setForm({ ...form, defaultTaxRate: e.target.value })} />
|
||||||
|
<Input label="Invoice Terms" value={form.invoiceTerms} onChange={(e) => setForm({ ...form, invoiceTerms: e.target.value })} placeholder="e.g. Net 30" />
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Button type="submit" disabled={mutation.isPending}>{mutation.isPending ? 'Saving...' : 'Save Settings'}</Button>
|
||||||
|
{saved && <span className="text-sm text-green-600">Saved!</span>}
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<BackupSection />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function BackupSection() {
|
||||||
|
const { data: info } = useQuery({ queryKey: ['backup-info'], queryFn: api.getBackupInfo });
|
||||||
|
|
||||||
|
const formatSize = (bytes: number) => {
|
||||||
|
if (bytes < 1024) return `${bytes} B`;
|
||||||
|
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||||
|
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Card className="p-6">
|
||||||
|
<h2 className="font-semibold text-gray-900 mb-4 flex items-center gap-2"><Database size={18} /> Backup & Restore</h2>
|
||||||
|
<div className="space-y-4 max-w-lg">
|
||||||
|
{info?.exists && (
|
||||||
|
<div className="text-sm text-gray-600 space-y-1">
|
||||||
|
<p>Database size: <span className="font-medium">{formatSize(info.size)}</span></p>
|
||||||
|
<p>Tables: <span className="font-medium">{info.tables}</span></p>
|
||||||
|
<p>Jobs: <span className="font-medium">{info.jobs}</span></p>
|
||||||
|
<p>Last modified: <span className="font-medium">{new Date(info.lastModified).toLocaleString()}</span></p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<Button variant="secondary" onClick={() => api.downloadBackup()}>
|
||||||
|
<Download size={16} /> Download Backup
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-gray-400">
|
||||||
|
To restore: stop the server, replace <code className="bg-gray-100 px-1 rounded">data/autoking.db</code> with your backup file, restart the server.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
109
client/src/pages/ShopDetail.tsx
Normal file
109
client/src/pages/ShopDetail.tsx
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
import { useParams, Link } from 'react-router-dom';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { ArrowLeft, Phone, Mail, MapPin } from 'lucide-react';
|
||||||
|
import { api } from '../api/client';
|
||||||
|
import { Card, Badge, Button } from '../components/ui';
|
||||||
|
|
||||||
|
const fmt = (n: number) => '$' + n.toFixed(2);
|
||||||
|
|
||||||
|
const statusLabel: Record<string, string> = {
|
||||||
|
received: 'Received', in_progress: 'In Progress', completed: 'Completed',
|
||||||
|
invoiced: 'Invoiced', closed: 'Closed', draft: 'Draft', sent: 'Sent',
|
||||||
|
paid: 'Paid', partial: 'Partial', overdue: 'Overdue',
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function ShopDetail() {
|
||||||
|
const { id } = useParams();
|
||||||
|
const { data: shop, isLoading } = useQuery({ queryKey: ['shops', id], queryFn: () => api.getShop(Number(id)) });
|
||||||
|
const { data: jobs = [] } = useQuery({ queryKey: ['shops', id, 'jobs'], queryFn: () => api.getShopJobs(Number(id)) });
|
||||||
|
const { data: invoices = [] } = useQuery({ queryKey: ['shops', id, 'invoices'], queryFn: () => api.getShopInvoices(Number(id)) });
|
||||||
|
|
||||||
|
if (isLoading || !shop) return <p className="text-gray-500 py-4">Loading...</p>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<Link to="/shops" className="inline-flex items-center gap-1.5 text-sm text-gray-500 hover:text-gray-700">
|
||||||
|
<ArrowLeft size={16} /> Back to Shops
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900">{shop.name}</h1>
|
||||||
|
{shop.contact_name && <p className="text-gray-500">{shop.contact_name}</p>}
|
||||||
|
</div>
|
||||||
|
<div className="text-right">
|
||||||
|
<p className="text-sm text-gray-500">{shop.jobCount} jobs</p>
|
||||||
|
{shop.outstandingBalance > 0 && (
|
||||||
|
<p className="text-sm font-medium text-yellow-600">Outstanding: {fmt(shop.outstandingBalance)}</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid sm:grid-cols-3 gap-4">
|
||||||
|
<Card className="p-4">
|
||||||
|
<h3 className="text-xs font-medium text-gray-500 uppercase mb-2">Contact</h3>
|
||||||
|
<div className="space-y-1.5 text-sm">
|
||||||
|
{shop.phone && <div className="flex items-center gap-2 text-gray-700"><Phone size={14} />{shop.phone}</div>}
|
||||||
|
{shop.email && <div className="flex items-center gap-2 text-gray-700"><Mail size={14} />{shop.email}</div>}
|
||||||
|
{shop.address && <div className="flex items-center gap-2 text-gray-700"><MapPin size={14} />{shop.address}, {[shop.city, shop.state, shop.zip].filter(Boolean).join(', ')}</div>}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
<Card className="p-4">
|
||||||
|
<h3 className="text-xs font-medium text-gray-500 uppercase mb-2">Rates</h3>
|
||||||
|
<p className="text-sm text-gray-700">Labor: <span className="font-medium">${shop.default_labor_rate}/hr</span></p>
|
||||||
|
<p className="text-sm text-gray-700">Parts Markup: <span className="font-medium">{(shop.default_parts_markup * 100).toFixed(0)}%</span></p>
|
||||||
|
</Card>
|
||||||
|
<Card className="p-4">
|
||||||
|
<h3 className="text-xs font-medium text-gray-500 uppercase mb-2">Notes</h3>
|
||||||
|
<p className="text-sm text-gray-700">{shop.notes || 'No notes'}</p>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<div className="p-4 border-b border-gray-200 flex items-center justify-between">
|
||||||
|
<h2 className="font-semibold text-gray-900">Jobs</h2>
|
||||||
|
<Link to={`/jobs?shopId=${shop.id}`}><Button variant="ghost" size="sm">View All</Button></Link>
|
||||||
|
</div>
|
||||||
|
<div className="divide-y divide-gray-100">
|
||||||
|
{jobs.length === 0 ? (
|
||||||
|
<p className="p-4 text-sm text-gray-500">No jobs for this shop yet.</p>
|
||||||
|
) : (
|
||||||
|
jobs.slice(0, 10).map((job: any) => (
|
||||||
|
<Link key={job.id} to={`/jobs/${job.id}`} className="flex items-center justify-between p-3 hover:bg-gray-50">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-gray-900">{job.job_number}</p>
|
||||||
|
<p className="text-xs text-gray-500">{[job.year, job.make, job.model].filter(Boolean).join(' ')} — {job.first_name} {job.last_name}</p>
|
||||||
|
</div>
|
||||||
|
<Badge status={job.status}>{statusLabel[job.status]}</Badge>
|
||||||
|
</Link>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Card>
|
||||||
|
<div className="p-4 border-b border-gray-200">
|
||||||
|
<h2 className="font-semibold text-gray-900">Invoices</h2>
|
||||||
|
</div>
|
||||||
|
<div className="divide-y divide-gray-100">
|
||||||
|
{invoices.length === 0 ? (
|
||||||
|
<p className="p-4 text-sm text-gray-500">No invoices yet.</p>
|
||||||
|
) : (
|
||||||
|
invoices.slice(0, 10).map((inv: any) => (
|
||||||
|
<Link key={inv.id} to={`/invoices/${inv.id}`} className="flex items-center justify-between p-3 hover:bg-gray-50">
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-medium text-gray-900">{inv.invoice_number}</p>
|
||||||
|
<p className="text-xs text-gray-500">{new Date(inv.invoice_date).toLocaleDateString()}</p>
|
||||||
|
</div>
|
||||||
|
<div className="text-right">
|
||||||
|
<p className="text-sm font-medium">{fmt(inv.total)}</p>
|
||||||
|
<Badge status={inv.status}>{statusLabel[inv.status]}</Badge>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
122
client/src/pages/Shops.tsx
Normal file
122
client/src/pages/Shops.tsx
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { Building2, Plus, Search, Phone, Mail } from 'lucide-react';
|
||||||
|
import { api } from '../api/client';
|
||||||
|
import { Card, Button, Input, Modal, EmptyState } from '../components/ui';
|
||||||
|
import type { Shop } from '../types';
|
||||||
|
|
||||||
|
export default function Shops() {
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
|
const [showModal, setShowModal] = useState(false);
|
||||||
|
const [editShop, setEditShop] = useState<Shop | null>(null);
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const { data: shops = [], isLoading } = useQuery({
|
||||||
|
queryKey: ['shops', search],
|
||||||
|
queryFn: () => api.getShops({ search: search || undefined, active: 1 }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const saveMutation = useMutation({
|
||||||
|
mutationFn: (data: any) => editShop ? api.updateShop(editShop.id, data) : api.createShop(data),
|
||||||
|
onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['shops'] }); setShowModal(false); setEditShop(null); },
|
||||||
|
});
|
||||||
|
|
||||||
|
const openNew = () => { setEditShop(null); setShowModal(true); };
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900">Referring Shops</h1>
|
||||||
|
<Button onClick={openNew}><Plus size={16} /> Add Shop</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="relative">
|
||||||
|
<Search size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400" />
|
||||||
|
<input
|
||||||
|
className="w-full pl-9 pr-3 py-2 text-sm border border-gray-300 rounded-lg focus:border-blue-500 focus:ring-1 focus:ring-blue-500 outline-none"
|
||||||
|
placeholder="Search shops..."
|
||||||
|
value={search}
|
||||||
|
onChange={(e) => setSearch(e.target.value)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isLoading ? (
|
||||||
|
<p className="text-gray-500 py-4">Loading...</p>
|
||||||
|
) : shops.length === 0 ? (
|
||||||
|
<EmptyState icon={Building2} title="No shops yet" description="Add your first referring shop to get started." action={<Button onClick={openNew}>Add Shop</Button>} />
|
||||||
|
) : (
|
||||||
|
<div className="grid sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
|
{shops.map((shop: Shop) => (
|
||||||
|
<Link key={shop.id} to={`/shops/${shop.id}`}>
|
||||||
|
<Card className="p-4 hover:border-blue-300 transition-colors h-full">
|
||||||
|
<h3 className="font-semibold text-gray-900">{shop.name}</h3>
|
||||||
|
{shop.contact_name && <p className="text-sm text-gray-500 mt-0.5">{shop.contact_name}</p>}
|
||||||
|
<div className="mt-2 space-y-1 text-xs text-gray-500">
|
||||||
|
{shop.phone && <div className="flex items-center gap-1.5"><Phone size={12} />{shop.phone}</div>}
|
||||||
|
{shop.email && <div className="flex items-center gap-1.5"><Mail size={12} />{shop.email}</div>}
|
||||||
|
</div>
|
||||||
|
<div className="mt-3 flex gap-3 text-xs text-gray-500">
|
||||||
|
<span>Rate: ${shop.default_labor_rate}/hr</span>
|
||||||
|
<span>Markup: {(shop.default_parts_markup * 100).toFixed(0)}%</span>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<ShopModal open={showModal} onClose={() => { setShowModal(false); setEditShop(null); }} shop={editShop} onSave={(data) => saveMutation.mutate(data)} saving={saveMutation.isPending} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ShopModal({ open, onClose, shop, onSave, saving }: { open: boolean; onClose: () => void; shop: Shop | null; onSave: (data: any) => void; saving: boolean }) {
|
||||||
|
const [form, setForm] = useState({
|
||||||
|
name: '', contactName: '', phone: '', email: '', address: '', city: '', state: '', zip: '', notes: '', defaultLaborRate: '75', defaultPartsMarkup: '30',
|
||||||
|
});
|
||||||
|
|
||||||
|
useState(() => {
|
||||||
|
if (shop) {
|
||||||
|
setForm({
|
||||||
|
name: shop.name, contactName: shop.contact_name || '', phone: shop.phone || '', email: shop.email || '',
|
||||||
|
address: shop.address || '', city: shop.city || '', state: shop.state || '', zip: shop.zip || '',
|
||||||
|
notes: shop.notes || '', defaultLaborRate: String(shop.default_labor_rate), defaultPartsMarkup: String(shop.default_parts_markup * 100),
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
setForm({ name: '', contactName: '', phone: '', email: '', address: '', city: '', state: '', zip: '', notes: '', defaultLaborRate: '75', defaultPartsMarkup: '30' });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
onSave({ ...form, defaultLaborRate: parseFloat(form.defaultLaborRate), defaultPartsMarkup: parseFloat(form.defaultPartsMarkup) / 100 });
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal open={open} onClose={onClose} title={shop ? 'Edit Shop' : 'Add Shop'}>
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-3">
|
||||||
|
<Input label="Shop Name *" value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} required />
|
||||||
|
<Input label="Contact Name" value={form.contactName} onChange={(e) => setForm({ ...form, contactName: e.target.value })} />
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<Input label="Phone" value={form.phone} onChange={(e) => setForm({ ...form, phone: e.target.value })} />
|
||||||
|
<Input label="Email" value={form.email} onChange={(e) => setForm({ ...form, email: e.target.value })} />
|
||||||
|
</div>
|
||||||
|
<Input label="Address" value={form.address} onChange={(e) => setForm({ ...form, address: e.target.value })} />
|
||||||
|
<div className="grid grid-cols-3 gap-3">
|
||||||
|
<Input label="City" value={form.city} onChange={(e) => setForm({ ...form, city: e.target.value })} />
|
||||||
|
<Input label="State" value={form.state} onChange={(e) => setForm({ ...form, state: e.target.value })} />
|
||||||
|
<Input label="ZIP" value={form.zip} onChange={(e) => setForm({ ...form, zip: e.target.value })} />
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<Input label="Labor Rate ($/hr)" type="number" step="0.01" value={form.defaultLaborRate} onChange={(e) => setForm({ ...form, defaultLaborRate: e.target.value })} />
|
||||||
|
<Input label="Parts Markup (%)" type="number" step="1" value={form.defaultPartsMarkup} onChange={(e) => setForm({ ...form, defaultPartsMarkup: e.target.value })} />
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-end gap-2 pt-2">
|
||||||
|
<Button variant="secondary" type="button" onClick={onClose}>Cancel</Button>
|
||||||
|
<Button type="submit" disabled={saving}>{saving ? 'Saving...' : 'Save'}</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
94
client/src/pages/Technicians.tsx
Normal file
94
client/src/pages/Technicians.tsx
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { HardHat, Plus, Phone, Mail } from 'lucide-react';
|
||||||
|
import { api } from '../api/client';
|
||||||
|
import { Card, Button, Badge, Input, Modal, EmptyState } from '../components/ui';
|
||||||
|
import type { Technician } from '../types';
|
||||||
|
|
||||||
|
const fmt = (n: number) => '$' + n.toFixed(2);
|
||||||
|
|
||||||
|
export default function Technicians() {
|
||||||
|
const [showModal, setShowModal] = useState(false);
|
||||||
|
const [editTech, setEditTech] = useState<Technician | null>(null);
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const { data: technicians = [], isLoading } = useQuery({
|
||||||
|
queryKey: ['technicians'],
|
||||||
|
queryFn: () => api.getTechnicians(),
|
||||||
|
});
|
||||||
|
|
||||||
|
const saveMutation = useMutation({
|
||||||
|
mutationFn: (data: any) => editTech ? api.updateTechnician(editTech.id, data) : api.createTechnician(data),
|
||||||
|
onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['technicians'] }); setShowModal(false); setEditTech(null); },
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900">Technicians</h1>
|
||||||
|
<Button onClick={() => { setEditTech(null); setShowModal(true); }}><Plus size={16} /> Add Technician</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isLoading ? <p className="text-gray-500 py-4">Loading...</p> : technicians.length === 0 ? (
|
||||||
|
<EmptyState icon={HardHat} title="No technicians" description="Add technicians to assign them to jobs and track time." action={<Button onClick={() => setShowModal(true)}>Add Technician</Button>} />
|
||||||
|
) : (
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
|
||||||
|
{technicians.map((tech: Technician) => (
|
||||||
|
<Card key={tech.id} className="p-4">
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div>
|
||||||
|
<Link to={`/technicians/${tech.id}`} className="font-medium text-blue-600 hover:underline">{tech.name}</Link>
|
||||||
|
{tech.specialty && <p className="text-xs text-gray-500 mt-0.5">{tech.specialty}</p>}
|
||||||
|
<div className="flex flex-col gap-1 mt-2 text-xs text-gray-500">
|
||||||
|
{tech.phone && <span className="flex items-center gap-1"><Phone size={11} /> {tech.phone}</span>}
|
||||||
|
{tech.email && <span className="flex items-center gap-1"><Mail size={11} /> {tech.email}</span>}
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-gray-400 mt-2">Rate: {fmt(tech.hourly_rate)}/hr</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Badge color={tech.is_active ? 'green' : 'gray'}>{tech.is_active ? 'Active' : 'Inactive'}</Badge>
|
||||||
|
<Button variant="ghost" size="sm" onClick={() => { setEditTech(tech); setShowModal(true); }}>Edit</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<TechnicianModal open={showModal} onClose={() => { setShowModal(false); setEditTech(null); }} tech={editTech} onSave={(data) => saveMutation.mutate(data)} saving={saveMutation.isPending} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function TechnicianModal({ open, onClose, tech, onSave, saving }: {
|
||||||
|
open: boolean; onClose: () => void; tech: Technician | null; onSave: (data: any) => void; saving: boolean;
|
||||||
|
}) {
|
||||||
|
const [form, setForm] = useState({
|
||||||
|
name: tech?.name || '',
|
||||||
|
phone: tech?.phone || '',
|
||||||
|
email: tech?.email || '',
|
||||||
|
specialty: tech?.specialty || '',
|
||||||
|
hourlyRate: tech?.hourly_rate?.toString() || '0',
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal open={open} onClose={onClose} title={tech ? 'Edit Technician' : 'Add Technician'}>
|
||||||
|
<form onSubmit={(e) => { e.preventDefault(); onSave({ ...form, hourlyRate: parseFloat(form.hourlyRate) || 0 }); }} className="space-y-3">
|
||||||
|
<Input label="Name *" value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} required />
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<Input label="Phone" value={form.phone} onChange={(e) => setForm({ ...form, phone: e.target.value })} />
|
||||||
|
<Input label="Email" value={form.email} onChange={(e) => setForm({ ...form, email: e.target.value })} />
|
||||||
|
</div>
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<Input label="Specialty" value={form.specialty} onChange={(e) => setForm({ ...form, specialty: e.target.value })} placeholder="e.g. Brakes, Engine" />
|
||||||
|
<Input label="Hourly Rate" type="number" step="0.01" value={form.hourlyRate} onChange={(e) => setForm({ ...form, hourlyRate: e.target.value })} />
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-end gap-2 pt-2">
|
||||||
|
<Button variant="secondary" type="button" onClick={onClose}>Cancel</Button>
|
||||||
|
<Button type="submit" disabled={saving}>{saving ? 'Saving...' : 'Save'}</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
287
client/src/pages/VehicleDetail.tsx
Normal file
287
client/src/pages/VehicleDetail.tsx
Normal file
@@ -0,0 +1,287 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { useParams, Link } from 'react-router-dom';
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { ArrowLeft, Wrench, Edit2, FileText, ClipboardCheck } from 'lucide-react';
|
||||||
|
import { api } from '../api/client';
|
||||||
|
import { Card, Button, Badge, Input, Modal } from '../components/ui';
|
||||||
|
import FileUpload from '../components/FileUpload';
|
||||||
|
import VehicleInspectionPanel from '../components/VehicleInspectionPanel';
|
||||||
|
import type { Vehicle, Job } from '../types';
|
||||||
|
|
||||||
|
const fmt = (n: number) => '$' + n.toFixed(2);
|
||||||
|
|
||||||
|
const statusLabel: Record<string, string> = {
|
||||||
|
received: 'Received', in_progress: 'In Progress', completed: 'Completed', invoiced: 'Invoiced', closed: 'Closed',
|
||||||
|
};
|
||||||
|
|
||||||
|
const invStatusLabel: Record<string, string> = {
|
||||||
|
stock: 'In Stock', in_progress: 'In Progress', listed: 'Listed', sold: 'Sold',
|
||||||
|
};
|
||||||
|
const invStatusColor: Record<string, string> = {
|
||||||
|
stock: 'blue', in_progress: 'yellow', listed: 'purple', sold: 'green',
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function VehicleDetail() {
|
||||||
|
const { id } = useParams();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [showEditModal, setShowEditModal] = useState(false);
|
||||||
|
|
||||||
|
const [showHistory, setShowHistory] = useState(false);
|
||||||
|
|
||||||
|
const { data: vehicle, isLoading } = useQuery<Vehicle & { jobs: Job[] }>({
|
||||||
|
queryKey: ['vehicles', id],
|
||||||
|
queryFn: () => api.getVehicle(Number(id)),
|
||||||
|
});
|
||||||
|
|
||||||
|
const { data: history } = useQuery({
|
||||||
|
queryKey: ['vehicles', id, 'history'],
|
||||||
|
queryFn: () => api.getVehicleHistory(Number(id)),
|
||||||
|
enabled: showHistory,
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateMutation = useMutation({
|
||||||
|
mutationFn: (data: any) => api.updateVehicle(Number(id), data),
|
||||||
|
onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['vehicles', id] }); setShowEditModal(false); },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (isLoading || !vehicle) return <p className="text-gray-500 py-4">Loading...</p>;
|
||||||
|
|
||||||
|
const vehicleStr = [vehicle.year, vehicle.make, vehicle.model].filter(Boolean).join(' ');
|
||||||
|
const jobs = (vehicle as any).jobs || [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<Link to="/vehicles" className="inline-flex items-center gap-1.5 text-sm text-gray-500 hover:text-gray-700">
|
||||||
|
<ArrowLeft size={16} /> Back to Vehicles
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900">{vehicleStr}</h1>
|
||||||
|
{vehicle.is_inventory ? (
|
||||||
|
<Badge color={invStatusColor[vehicle.inventory_status]}>{invStatusLabel[vehicle.inventory_status]}</Badge>
|
||||||
|
) : (
|
||||||
|
<Badge color="gray">Customer</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{vehicle.owner_id && vehicle.first_name && (
|
||||||
|
<Link to={`/owners/${vehicle.owner_id}`} className="text-sm text-blue-600 hover:underline">
|
||||||
|
{vehicle.first_name} {vehicle.last_name}
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button variant="secondary" onClick={() => setShowHistory(!showHistory)}>
|
||||||
|
<FileText size={16} /> {showHistory ? 'Hide' : 'Service'} History
|
||||||
|
</Button>
|
||||||
|
<Button variant="secondary" onClick={() => setShowEditModal(true)}>
|
||||||
|
<Edit2 size={16} /> Edit
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Vehicle Info */}
|
||||||
|
<Card className="p-4">
|
||||||
|
<div className="grid grid-cols-2 sm:grid-cols-3 gap-4 text-sm">
|
||||||
|
{vehicle.vin && (
|
||||||
|
<div>
|
||||||
|
<span className="text-xs text-gray-500 uppercase">VIN</span>
|
||||||
|
<p className="font-mono text-gray-900">{vehicle.vin}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{vehicle.license_plate && (
|
||||||
|
<div>
|
||||||
|
<span className="text-xs text-gray-500 uppercase">License Plate</span>
|
||||||
|
<p className="text-gray-900">{vehicle.license_plate}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{vehicle.color && (
|
||||||
|
<div>
|
||||||
|
<span className="text-xs text-gray-500 uppercase">Color</span>
|
||||||
|
<p className="text-gray-900">{vehicle.color}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{vehicle.mileage && (
|
||||||
|
<div>
|
||||||
|
<span className="text-xs text-gray-500 uppercase">Mileage</span>
|
||||||
|
<p className="text-gray-900">{vehicle.mileage.toLocaleString()}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{vehicle.is_inventory === 1 && vehicle.purchase_price != null && (
|
||||||
|
<div>
|
||||||
|
<span className="text-xs text-gray-500 uppercase">Purchase Price</span>
|
||||||
|
<p className="text-gray-900">{fmt(vehicle.purchase_price)}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{vehicle.is_inventory === 1 && vehicle.sale_price != null && (
|
||||||
|
<div>
|
||||||
|
<span className="text-xs text-gray-500 uppercase">Sale Price</span>
|
||||||
|
<p className="text-gray-900">{fmt(vehicle.sale_price)}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{vehicle.notes && (
|
||||||
|
<div className="mt-4 pt-4 border-t border-gray-200">
|
||||||
|
<span className="text-xs text-gray-500 uppercase">Notes</span>
|
||||||
|
<p className="text-sm text-gray-700 mt-1">{vehicle.notes}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Inspections */}
|
||||||
|
<VehicleInspectionPanel vehicleId={Number(id)} />
|
||||||
|
|
||||||
|
{/* Service History Report */}
|
||||||
|
{showHistory && history && (
|
||||||
|
<Card>
|
||||||
|
<div className="p-4 border-b border-gray-200">
|
||||||
|
<h2 className="font-semibold text-gray-900">Complete Service History</h2>
|
||||||
|
<div className="flex gap-4 mt-2 text-sm text-gray-600">
|
||||||
|
<span><span className="font-medium">{history.summary.totalJobs}</span> jobs</span>
|
||||||
|
<span><span className="font-medium">{fmt(history.summary.totalSpent)}</span> total</span>
|
||||||
|
{history.summary.firstService && <span>Since {new Date(history.summary.firstService).toLocaleDateString()}</span>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="divide-y divide-gray-100">
|
||||||
|
{history.jobs.map((job: any) => (
|
||||||
|
<div key={job.id} className="p-4">
|
||||||
|
<div className="flex items-center justify-between mb-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Link to={`/jobs/${job.id}`} className="font-mono text-sm font-semibold text-blue-600 hover:underline">{job.job_number}</Link>
|
||||||
|
<Badge status={job.status}>{statusLabel[job.status]}</Badge>
|
||||||
|
</div>
|
||||||
|
<div className="text-right">
|
||||||
|
<p className="text-sm font-medium">{fmt(job.total)}</p>
|
||||||
|
<p className="text-xs text-gray-500">{new Date(job.received_date).toLocaleDateString()}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{job.description && <p className="text-sm text-gray-600 mb-2">{job.description}</p>}
|
||||||
|
{job.shop_name && <p className="text-xs text-gray-500 mb-2">Via: {job.shop_name}</p>}
|
||||||
|
|
||||||
|
{job.laborItems.length > 0 && (
|
||||||
|
<div className="ml-3 mb-1">
|
||||||
|
{job.laborItems.map((item: any) => (
|
||||||
|
<p key={item.id} className="text-xs text-gray-600">
|
||||||
|
<span className="text-gray-400">Labor:</span> {item.description}
|
||||||
|
{item.billing_type === 'flat_rate' ? ` — ${fmt(item.flat_rate || 0)}` : ` — ${item.hours}h @ ${fmt(item.rate || 0)}/hr`}
|
||||||
|
</p>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{job.partsItems.length > 0 && (
|
||||||
|
<div className="ml-3 mb-1">
|
||||||
|
{job.partsItems.map((item: any) => (
|
||||||
|
<p key={item.id} className="text-xs text-gray-600">
|
||||||
|
<span className="text-gray-400">Parts:</span> {item.description}
|
||||||
|
{item.part_number && <span className="text-gray-400"> ({item.part_number})</span>}
|
||||||
|
{' — '}{fmt(item.quantity * item.unit_cost * (1 + item.markup))}
|
||||||
|
</p>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{job.recommendations.length > 0 && (
|
||||||
|
<div className="ml-3 mt-1">
|
||||||
|
{job.recommendations.map((rec: any) => (
|
||||||
|
<p key={rec.id} className={`text-xs ${rec.urgency === 'now' ? 'text-red-600' : rec.urgency === 'soon' ? 'text-yellow-600' : 'text-blue-600'}`}>
|
||||||
|
Rec: {rec.description} {rec.estimated_cost && `(~${fmt(rec.estimated_cost)})`}
|
||||||
|
</p>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Files */}
|
||||||
|
<Card className="p-4">
|
||||||
|
<FileUpload entityType="vehicle" entityId={Number(id)} />
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Job History */}
|
||||||
|
<Card>
|
||||||
|
<div className="p-4 border-b border-gray-200">
|
||||||
|
<h2 className="font-semibold text-gray-900">Service History ({jobs.length})</h2>
|
||||||
|
</div>
|
||||||
|
{jobs.length === 0 ? (
|
||||||
|
<p className="p-4 text-sm text-gray-500">No jobs for this vehicle yet.</p>
|
||||||
|
) : (
|
||||||
|
<div className="divide-y divide-gray-100">
|
||||||
|
{jobs.map((job: any) => (
|
||||||
|
<div key={job.id} className="flex items-center justify-between p-3 hover:bg-gray-50">
|
||||||
|
<div>
|
||||||
|
<Link to={`/jobs/${job.id}`} className="text-sm font-medium text-blue-600 hover:underline">{job.job_number}</Link>
|
||||||
|
<p className="text-xs text-gray-500">
|
||||||
|
{job.description?.slice(0, 60)}{job.description?.length > 60 ? '...' : ''}
|
||||||
|
{job.shop_name && ` — ${job.shop_name}`}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Badge status={job.status}>{statusLabel[job.status]}</Badge>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{showEditModal && (
|
||||||
|
<EditVehicleModal
|
||||||
|
vehicle={vehicle}
|
||||||
|
open={showEditModal}
|
||||||
|
onClose={() => setShowEditModal(false)}
|
||||||
|
onSave={(data) => updateMutation.mutate(data)}
|
||||||
|
saving={updateMutation.isPending}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function EditVehicleModal({ vehicle, open, onClose, onSave, saving }: {
|
||||||
|
vehicle: Vehicle; open: boolean; onClose: () => void; onSave: (data: any) => void; saving: boolean;
|
||||||
|
}) {
|
||||||
|
const [form, setForm] = useState({
|
||||||
|
year: vehicle.year?.toString() || '',
|
||||||
|
make: vehicle.make,
|
||||||
|
model: vehicle.model,
|
||||||
|
vin: vehicle.vin || '',
|
||||||
|
color: vehicle.color || '',
|
||||||
|
licensePlate: vehicle.license_plate || '',
|
||||||
|
mileage: vehicle.mileage?.toString() || '',
|
||||||
|
notes: vehicle.notes || '',
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal open={open} onClose={onClose} title="Edit Vehicle">
|
||||||
|
<form onSubmit={(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
onSave({
|
||||||
|
...form,
|
||||||
|
year: form.year ? parseInt(form.year) : null,
|
||||||
|
mileage: form.mileage ? parseInt(form.mileage) : null,
|
||||||
|
});
|
||||||
|
}} className="space-y-3">
|
||||||
|
<div className="grid grid-cols-3 gap-3">
|
||||||
|
<Input label="Year" type="number" value={form.year} onChange={(e) => setForm({ ...form, year: e.target.value })} />
|
||||||
|
<Input label="Make *" value={form.make} onChange={(e) => setForm({ ...form, make: e.target.value })} required />
|
||||||
|
<Input label="Model *" value={form.model} onChange={(e) => setForm({ ...form, model: e.target.value })} required />
|
||||||
|
</div>
|
||||||
|
<Input label="VIN" value={form.vin} onChange={(e) => setForm({ ...form, vin: e.target.value })} />
|
||||||
|
<div className="grid grid-cols-3 gap-3">
|
||||||
|
<Input label="Color" value={form.color} onChange={(e) => setForm({ ...form, color: e.target.value })} />
|
||||||
|
<Input label="License Plate" value={form.licensePlate} onChange={(e) => setForm({ ...form, licensePlate: e.target.value })} />
|
||||||
|
<Input label="Mileage" type="number" value={form.mileage} onChange={(e) => setForm({ ...form, mileage: e.target.value })} />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Notes</label>
|
||||||
|
<textarea className="block w-full rounded-lg border border-gray-300 px-3 py-2 text-sm" rows={2} value={form.notes} onChange={(e) => setForm({ ...form, notes: e.target.value })} />
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-end gap-2 pt-2">
|
||||||
|
<Button variant="secondary" type="button" onClick={onClose}>Cancel</Button>
|
||||||
|
<Button type="submit" disabled={saving}>{saving ? 'Saving...' : 'Save'}</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
195
client/src/pages/Vehicles.tsx
Normal file
195
client/src/pages/Vehicles.tsx
Normal file
@@ -0,0 +1,195 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { Car, Search, Plus } from 'lucide-react';
|
||||||
|
import { api } from '../api/client';
|
||||||
|
import { Card, Badge, Button, Input, Modal, EmptyState } from '../components/ui';
|
||||||
|
import type { Vehicle, Owner } from '../types';
|
||||||
|
|
||||||
|
const invStatusLabel: Record<string, string> = {
|
||||||
|
stock: 'In Stock', in_progress: 'In Progress', listed: 'Listed', sold: 'Sold',
|
||||||
|
};
|
||||||
|
const invStatusColor: Record<string, string> = {
|
||||||
|
stock: 'blue', in_progress: 'yellow', listed: 'purple', sold: 'green',
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function Vehicles() {
|
||||||
|
const [search, setSearch] = useState('');
|
||||||
|
const [tab, setTab] = useState<'all' | 'customer' | 'inventory'>('all');
|
||||||
|
const [showAddModal, setShowAddModal] = useState(false);
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const { data: vehicles = [], isLoading } = useQuery({
|
||||||
|
queryKey: ['vehicles', search, tab],
|
||||||
|
queryFn: () => api.getVehicles({
|
||||||
|
search: search || undefined,
|
||||||
|
...(tab === 'inventory' ? { inventory: 1 } : tab === 'customer' ? { inventory: 0 } : {}),
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
const createMutation = useMutation({
|
||||||
|
mutationFn: api.createVehicle,
|
||||||
|
onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['vehicles'] }); setShowAddModal(false); },
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h1 className="text-2xl font-bold text-gray-900">Vehicles</h1>
|
||||||
|
<Button onClick={() => setShowAddModal(true)}><Plus size={16} /> Add Vehicle</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-3 flex-col sm:flex-row">
|
||||||
|
<div className="relative flex-1">
|
||||||
|
<Search size={16} className="absolute left-3 top-1/2 -translate-y-1/2 text-gray-400" />
|
||||||
|
<input className="w-full pl-9 pr-3 py-2 text-sm border border-gray-300 rounded-lg focus:border-blue-500 focus:ring-1 focus:ring-blue-500 outline-none" placeholder="Search by make, model, VIN, or owner..." value={search} onChange={(e) => setSearch(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-1">
|
||||||
|
{([['all', 'All'], ['customer', 'Customer'], ['inventory', 'Inventory']] as const).map(([key, label]) => (
|
||||||
|
<button key={key} onClick={() => setTab(key)}
|
||||||
|
className={`px-3 py-1.5 text-xs font-medium rounded-lg transition-colors ${tab === key ? 'bg-blue-100 text-blue-700' : 'bg-gray-100 text-gray-600 hover:bg-gray-200'}`}>
|
||||||
|
{label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{isLoading ? <p className="text-gray-500 py-4">Loading...</p> : vehicles.length === 0 ? (
|
||||||
|
<EmptyState icon={Car} title="No vehicles found" description="Add a customer vehicle or inventory vehicle." action={<Button onClick={() => setShowAddModal(true)}>Add Vehicle</Button>} />
|
||||||
|
) : (
|
||||||
|
<Card>
|
||||||
|
<div className="overflow-x-auto">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-gray-200 text-left text-xs text-gray-500 uppercase">
|
||||||
|
<th className="px-4 py-3">Vehicle</th>
|
||||||
|
<th className="px-4 py-3">Owner</th>
|
||||||
|
<th className="px-4 py-3">Type</th>
|
||||||
|
<th className="px-4 py-3">Stock #</th>
|
||||||
|
<th className="px-4 py-3">Mileage</th>
|
||||||
|
<th className="px-4 py-3">VIN</th>
|
||||||
|
<th className="px-4 py-3">Color</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-gray-100">
|
||||||
|
{vehicles.map((v: Vehicle) => (
|
||||||
|
<tr key={v.id} className="hover:bg-gray-50">
|
||||||
|
<td className="px-4 py-3 font-medium">
|
||||||
|
<Link to={`/vehicles/${v.id}`} className="text-blue-600 hover:underline">
|
||||||
|
{[v.year, v.make, v.model].filter(Boolean).join(' ')}
|
||||||
|
</Link>
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-gray-700">
|
||||||
|
{v.first_name && v.last_name ? `${v.first_name} ${v.last_name}` : <span className="text-gray-400">—</span>}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
{v.is_inventory ? (
|
||||||
|
<Badge color={invStatusColor[v.inventory_status]}>{invStatusLabel[v.inventory_status] || v.inventory_status}</Badge>
|
||||||
|
) : (
|
||||||
|
<span className="text-xs text-gray-500">Customer</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-3 text-xs font-mono text-gray-500">{(v as any).stock_number || '—'}</td>
|
||||||
|
<td className="px-4 py-3 text-gray-500">{v.mileage ? v.mileage.toLocaleString() : '—'}</td>
|
||||||
|
<td className="px-4 py-3 text-gray-500 text-xs font-mono">{v.vin || '—'}</td>
|
||||||
|
<td className="px-4 py-3 text-gray-500">{v.color || '—'}</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<AddVehicleModal open={showAddModal} onClose={() => setShowAddModal(false)} onSave={(data) => createMutation.mutate(data)} saving={createMutation.isPending} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function AddVehicleModal({ open, onClose, onSave, saving }: { open: boolean; onClose: () => void; onSave: (data: any) => void; saving: boolean }) {
|
||||||
|
const [vehicleType, setVehicleType] = useState<'customer' | 'inventory'>('customer');
|
||||||
|
const [ownerId, setOwnerId] = useState('');
|
||||||
|
const [year, setYear] = useState('');
|
||||||
|
const [make, setMake] = useState('');
|
||||||
|
const [model, setModel] = useState('');
|
||||||
|
const [vin, setVin] = useState('');
|
||||||
|
const [color, setColor] = useState('');
|
||||||
|
const [licensePlate, setLicensePlate] = useState('');
|
||||||
|
const [mileage, setMileage] = useState('');
|
||||||
|
const [purchasePrice, setPurchasePrice] = useState('');
|
||||||
|
const [stockNumber, setStockNumber] = useState('');
|
||||||
|
|
||||||
|
const { data: owners = [] } = useQuery({ queryKey: ['owners'], queryFn: () => api.getOwners() });
|
||||||
|
|
||||||
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
onSave({
|
||||||
|
ownerId: vehicleType === 'customer' ? Number(ownerId) : null,
|
||||||
|
year: year ? parseInt(year) : null,
|
||||||
|
make, model,
|
||||||
|
vin: vin || null,
|
||||||
|
color: color || null,
|
||||||
|
licensePlate: licensePlate || null,
|
||||||
|
mileage: mileage ? parseInt(mileage) : null,
|
||||||
|
isInventory: vehicleType === 'inventory',
|
||||||
|
purchasePrice: purchasePrice ? parseFloat(purchasePrice) : null,
|
||||||
|
stockNumber: stockNumber || null,
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal open={open} onClose={onClose} title="Add Vehicle">
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-3">
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Vehicle Type</label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button type="button" onClick={() => setVehicleType('customer')}
|
||||||
|
className={`flex-1 px-3 py-2 text-sm font-medium rounded-lg border transition-colors ${vehicleType === 'customer' ? 'bg-blue-50 border-blue-300 text-blue-700' : 'border-gray-300 text-gray-600 hover:bg-gray-50'}`}>
|
||||||
|
Customer Vehicle
|
||||||
|
</button>
|
||||||
|
<button type="button" onClick={() => setVehicleType('inventory')}
|
||||||
|
className={`flex-1 px-3 py-2 text-sm font-medium rounded-lg border transition-colors ${vehicleType === 'inventory' ? 'bg-blue-50 border-blue-300 text-blue-700' : 'border-gray-300 text-gray-600 hover:bg-gray-50'}`}>
|
||||||
|
Inventory / Flip
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{vehicleType === 'customer' && (
|
||||||
|
<div>
|
||||||
|
<label className="block text-sm font-medium text-gray-700 mb-1">Owner *</label>
|
||||||
|
<select className="block w-full rounded-lg border border-gray-300 px-3 py-2 text-sm" value={ownerId} onChange={(e) => setOwnerId(e.target.value)} required>
|
||||||
|
<option value="">Select an owner...</option>
|
||||||
|
{owners.map((o: Owner) => <option key={o.id} value={o.id}>{o.first_name} {o.last_name}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="grid grid-cols-3 gap-3">
|
||||||
|
<Input label="Year" type="number" value={year} onChange={(e) => setYear(e.target.value)} />
|
||||||
|
<Input label="Make *" value={make} onChange={(e) => setMake(e.target.value)} required />
|
||||||
|
<Input label="Model *" value={model} onChange={(e) => setModel(e.target.value)} required />
|
||||||
|
</div>
|
||||||
|
<Input label="VIN" value={vin} onChange={(e) => setVin(e.target.value)} />
|
||||||
|
<div className="grid grid-cols-3 gap-3">
|
||||||
|
<Input label="Color" value={color} onChange={(e) => setColor(e.target.value)} />
|
||||||
|
<Input label="License Plate" value={licensePlate} onChange={(e) => setLicensePlate(e.target.value)} />
|
||||||
|
<Input label="Mileage" type="number" value={mileage} onChange={(e) => setMileage(e.target.value)} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{vehicleType === 'inventory' && (
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<Input label="Purchase Price" type="number" step="0.01" value={purchasePrice} onChange={(e) => setPurchasePrice(e.target.value)} />
|
||||||
|
<Input label="Stock # (auto if blank)" value={stockNumber} onChange={(e) => setStockNumber(e.target.value)} placeholder="STK-0001" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-2 pt-2">
|
||||||
|
<Button variant="secondary" type="button" onClick={onClose}>Cancel</Button>
|
||||||
|
<Button type="submit" disabled={saving || !make || !model || (vehicleType === 'customer' && !ownerId)}>
|
||||||
|
{saving ? 'Saving...' : 'Add Vehicle'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
}
|
||||||
28
client/src/store/index.ts
Normal file
28
client/src/store/index.ts
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import { create } from 'zustand';
|
||||||
|
import type { User } from '../types';
|
||||||
|
|
||||||
|
interface AppState {
|
||||||
|
token: string | null;
|
||||||
|
user: User | null;
|
||||||
|
sidebarOpen: boolean;
|
||||||
|
login: (token: string, user: User) => void;
|
||||||
|
logout: () => void;
|
||||||
|
setUser: (user: User) => void;
|
||||||
|
toggleSidebar: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useStore = create<AppState>((set) => ({
|
||||||
|
token: localStorage.getItem('autoking_token'),
|
||||||
|
user: null,
|
||||||
|
sidebarOpen: false,
|
||||||
|
login: (token, user) => {
|
||||||
|
localStorage.setItem('autoking_token', token);
|
||||||
|
set({ token, user });
|
||||||
|
},
|
||||||
|
logout: () => {
|
||||||
|
localStorage.removeItem('autoking_token');
|
||||||
|
set({ token: null, user: null });
|
||||||
|
},
|
||||||
|
setUser: (user) => set({ user }),
|
||||||
|
toggleSidebar: () => set((s) => ({ sidebarOpen: !s.sidebarOpen })),
|
||||||
|
}));
|
||||||
415
client/src/types/index.ts
Normal file
415
client/src/types/index.ts
Normal file
@@ -0,0 +1,415 @@
|
|||||||
|
export interface User {
|
||||||
|
id: number;
|
||||||
|
username: string;
|
||||||
|
displayName: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Shop {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
contact_name: string | null;
|
||||||
|
phone: string | null;
|
||||||
|
email: string | null;
|
||||||
|
address: string | null;
|
||||||
|
city: string | null;
|
||||||
|
state: string | null;
|
||||||
|
zip: string | null;
|
||||||
|
notes: string | null;
|
||||||
|
default_labor_rate: number;
|
||||||
|
default_parts_markup: number;
|
||||||
|
active: number;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
jobCount?: number;
|
||||||
|
outstandingBalance?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Owner {
|
||||||
|
id: number;
|
||||||
|
first_name: string;
|
||||||
|
last_name: string;
|
||||||
|
phone: string | null;
|
||||||
|
email: string | null;
|
||||||
|
notes: string | null;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
vehicles?: Vehicle[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Vehicle {
|
||||||
|
id: number;
|
||||||
|
owner_id: number | null;
|
||||||
|
year: number | null;
|
||||||
|
make: string;
|
||||||
|
model: string;
|
||||||
|
vin: string | null;
|
||||||
|
color: string | null;
|
||||||
|
license_plate: string | null;
|
||||||
|
mileage: number | null;
|
||||||
|
is_inventory: number;
|
||||||
|
inventory_status: InventoryStatus;
|
||||||
|
purchase_price: number | null;
|
||||||
|
purchase_date: string | null;
|
||||||
|
sale_price: number | null;
|
||||||
|
sale_date: string | null;
|
||||||
|
notes: string | null;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
first_name?: string;
|
||||||
|
last_name?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type CustomerType = 'owner' | 'shop' | 'internal';
|
||||||
|
export type JobStatus = 'received' | 'in_progress' | 'completed' | 'invoiced' | 'closed';
|
||||||
|
export type LaborBillingType = 'hourly' | 'flat_rate';
|
||||||
|
export type InventoryStatus = 'stock' | 'in_progress' | 'listed' | 'sold';
|
||||||
|
|
||||||
|
export interface LaborItem {
|
||||||
|
id: number;
|
||||||
|
job_id: number;
|
||||||
|
description: string;
|
||||||
|
billing_type: LaborBillingType;
|
||||||
|
hours: number | null;
|
||||||
|
rate: number | null;
|
||||||
|
flat_rate: number | null;
|
||||||
|
sort_order: number;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PartsItem {
|
||||||
|
id: number;
|
||||||
|
job_id: number;
|
||||||
|
description: string;
|
||||||
|
part_number: string | null;
|
||||||
|
quantity: number;
|
||||||
|
unit_cost: number;
|
||||||
|
markup: number;
|
||||||
|
charge_price: number | null;
|
||||||
|
sort_order: number;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface JobNote {
|
||||||
|
id: number;
|
||||||
|
job_id: number;
|
||||||
|
note: string;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Job {
|
||||||
|
id: number;
|
||||||
|
job_number: string;
|
||||||
|
customer_type: CustomerType;
|
||||||
|
shop_id: number | null;
|
||||||
|
owner_id: number;
|
||||||
|
vehicle_id: number;
|
||||||
|
status: JobStatus;
|
||||||
|
description: string | null;
|
||||||
|
received_date: string;
|
||||||
|
started_date: string | null;
|
||||||
|
completed_date: string | null;
|
||||||
|
notes: string | null;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
shop_name?: string;
|
||||||
|
year?: number;
|
||||||
|
make?: string;
|
||||||
|
model?: string;
|
||||||
|
vin?: string;
|
||||||
|
first_name?: string;
|
||||||
|
last_name?: string;
|
||||||
|
default_labor_rate?: number;
|
||||||
|
default_parts_markup?: number;
|
||||||
|
laborItems?: LaborItem[];
|
||||||
|
partsItems?: PartsItem[];
|
||||||
|
tags?: Tag[];
|
||||||
|
laborTotal?: number;
|
||||||
|
partsTotal?: number;
|
||||||
|
total?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type InvoiceStatus = 'draft' | 'sent' | 'paid' | 'partial' | 'overdue';
|
||||||
|
|
||||||
|
export interface Payment {
|
||||||
|
id: number;
|
||||||
|
invoice_id: number;
|
||||||
|
amount: number;
|
||||||
|
payment_date: string;
|
||||||
|
method: string | null;
|
||||||
|
reference: string | null;
|
||||||
|
notes: string | null;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Invoice {
|
||||||
|
id: number;
|
||||||
|
invoice_number: string;
|
||||||
|
shop_id: number | null;
|
||||||
|
owner_id: number | null;
|
||||||
|
status: InvoiceStatus;
|
||||||
|
invoice_date: string;
|
||||||
|
due_date: string | null;
|
||||||
|
subtotal: number;
|
||||||
|
tax_rate: number;
|
||||||
|
tax_amount: number;
|
||||||
|
total: number;
|
||||||
|
notes: string | null;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
shop_name?: string;
|
||||||
|
owner_first_name?: string;
|
||||||
|
owner_last_name?: string;
|
||||||
|
display_name?: string;
|
||||||
|
amountPaid?: number;
|
||||||
|
amountDue?: number;
|
||||||
|
jobs?: Job[];
|
||||||
|
payments?: Payment[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export type EstimateStatus = 'draft' | 'sent' | 'approved' | 'declined' | 'expired' | 'converted';
|
||||||
|
|
||||||
|
export interface EstimateItem {
|
||||||
|
id: number;
|
||||||
|
estimate_id: number;
|
||||||
|
item_type: 'labor' | 'parts';
|
||||||
|
description: string;
|
||||||
|
quantity: number;
|
||||||
|
unit_price: number;
|
||||||
|
markup: number;
|
||||||
|
billing_type: LaborBillingType | null;
|
||||||
|
hours: number | null;
|
||||||
|
rate: number | null;
|
||||||
|
flat_rate: number | null;
|
||||||
|
sort_order: number;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Estimate {
|
||||||
|
id: number;
|
||||||
|
estimate_number: string;
|
||||||
|
customer_type: CustomerType;
|
||||||
|
shop_id: number | null;
|
||||||
|
owner_id: number | null;
|
||||||
|
vehicle_id: number | null;
|
||||||
|
status: EstimateStatus;
|
||||||
|
subtotal: number;
|
||||||
|
tax_rate: number;
|
||||||
|
tax_amount: number;
|
||||||
|
total: number;
|
||||||
|
notes: string | null;
|
||||||
|
valid_until: string | null;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
shop_name?: string;
|
||||||
|
owner_first_name?: string;
|
||||||
|
owner_last_name?: string;
|
||||||
|
year?: number;
|
||||||
|
make?: string;
|
||||||
|
model?: string;
|
||||||
|
items?: EstimateItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CannedServiceItem {
|
||||||
|
id: number;
|
||||||
|
canned_service_id: number;
|
||||||
|
item_type: 'labor' | 'parts';
|
||||||
|
description: string;
|
||||||
|
billing_type: LaborBillingType | null;
|
||||||
|
hours: number | null;
|
||||||
|
rate: number | null;
|
||||||
|
flat_rate: number | null;
|
||||||
|
quantity: number;
|
||||||
|
unit_cost: number;
|
||||||
|
markup: number;
|
||||||
|
sort_order: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CannedService {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
description: string | null;
|
||||||
|
category: string | null;
|
||||||
|
is_active: number;
|
||||||
|
items?: CannedServiceItem[];
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Technician {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
phone: string | null;
|
||||||
|
email: string | null;
|
||||||
|
specialty: string | null;
|
||||||
|
hourly_rate: number;
|
||||||
|
is_active: number;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TimeEntry {
|
||||||
|
id: number;
|
||||||
|
technician_id: number;
|
||||||
|
job_id: number;
|
||||||
|
clock_in: string;
|
||||||
|
clock_out: string | null;
|
||||||
|
duration_minutes: number | null;
|
||||||
|
notes: string | null;
|
||||||
|
technician_name?: string;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type InspectionCondition = 'good' | 'fair' | 'poor' | 'critical' | 'not_inspected';
|
||||||
|
|
||||||
|
export interface InspectionTemplate {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
description: string | null;
|
||||||
|
is_default: number;
|
||||||
|
items?: InspectionTemplateItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface InspectionTemplateItem {
|
||||||
|
id: number;
|
||||||
|
template_id: number;
|
||||||
|
section: string;
|
||||||
|
label: string;
|
||||||
|
sort_order: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface InspectionItem {
|
||||||
|
id: number;
|
||||||
|
inspection_id: number;
|
||||||
|
template_item_id: number | null;
|
||||||
|
section: string;
|
||||||
|
label: string;
|
||||||
|
condition: InspectionCondition;
|
||||||
|
notes: string | null;
|
||||||
|
sort_order: number;
|
||||||
|
photos?: FileRecord[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Inspection {
|
||||||
|
id: number;
|
||||||
|
job_id: number;
|
||||||
|
template_id: number | null;
|
||||||
|
technician_id: number | null;
|
||||||
|
status: 'in_progress' | 'completed';
|
||||||
|
created_at: string;
|
||||||
|
completed_at: string | null;
|
||||||
|
template_name?: string;
|
||||||
|
technician_name?: string;
|
||||||
|
items?: InspectionItem[];
|
||||||
|
summary?: Record<string, number>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Message {
|
||||||
|
id: number;
|
||||||
|
job_id: number | null;
|
||||||
|
owner_id: number | null;
|
||||||
|
shop_id: number | null;
|
||||||
|
direction: 'inbound' | 'outbound';
|
||||||
|
channel: 'internal' | 'email' | 'sms' | 'portal';
|
||||||
|
subject: string | null;
|
||||||
|
body: string;
|
||||||
|
sent_at: string;
|
||||||
|
read_at: string | null;
|
||||||
|
created_at: string;
|
||||||
|
owner_first_name?: string;
|
||||||
|
owner_last_name?: string;
|
||||||
|
shop_name?: string;
|
||||||
|
job_number?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface MessageTemplate {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
subject: string | null;
|
||||||
|
body: string;
|
||||||
|
channel: string;
|
||||||
|
trigger_event: string | null;
|
||||||
|
is_active: number;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Appointment {
|
||||||
|
id: number;
|
||||||
|
owner_id: number | null;
|
||||||
|
shop_id: number | null;
|
||||||
|
vehicle_id: number | null;
|
||||||
|
service_description: string | null;
|
||||||
|
requested_date: string;
|
||||||
|
requested_time: string | null;
|
||||||
|
duration_minutes: number;
|
||||||
|
status: 'pending' | 'confirmed' | 'cancelled' | 'completed';
|
||||||
|
notes: string | null;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
owner_first_name?: string;
|
||||||
|
owner_last_name?: string;
|
||||||
|
owner_phone?: string;
|
||||||
|
year?: number;
|
||||||
|
make?: string;
|
||||||
|
model?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DeferredService {
|
||||||
|
id: number;
|
||||||
|
job_id: number | null;
|
||||||
|
vehicle_id: number | null;
|
||||||
|
owner_id: number | null;
|
||||||
|
description: string;
|
||||||
|
reason: string | null;
|
||||||
|
follow_up_date: string | null;
|
||||||
|
status: string;
|
||||||
|
created_at: string;
|
||||||
|
owner_first_name?: string;
|
||||||
|
owner_last_name?: string;
|
||||||
|
year?: number;
|
||||||
|
make?: string;
|
||||||
|
model?: string;
|
||||||
|
job_number?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Settings {
|
||||||
|
id: number;
|
||||||
|
shop_name: string;
|
||||||
|
shop_address: string | null;
|
||||||
|
shop_city: string | null;
|
||||||
|
shop_state: string | null;
|
||||||
|
shop_zip: string | null;
|
||||||
|
shop_phone: string | null;
|
||||||
|
shop_email: string | null;
|
||||||
|
default_tax_rate: number;
|
||||||
|
invoice_terms: string | null;
|
||||||
|
updated_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DashboardStats {
|
||||||
|
activeJobs: number;
|
||||||
|
completedThisMonth: number;
|
||||||
|
outstandingInvoiceCount: number;
|
||||||
|
outstandingAmount: number;
|
||||||
|
revenueThisMonth: number;
|
||||||
|
totalShops: number;
|
||||||
|
recentJobs: Job[];
|
||||||
|
outstandingInvoices: Invoice[];
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface Tag {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
color: string;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FileRecord {
|
||||||
|
id: number;
|
||||||
|
entity_type: string;
|
||||||
|
entity_id: number;
|
||||||
|
filename: string;
|
||||||
|
original_name: string;
|
||||||
|
mime_type: string | null;
|
||||||
|
size: number | null;
|
||||||
|
created_at: string;
|
||||||
|
}
|
||||||
28
client/tsconfig.app.json
Normal file
28
client/tsconfig.app.json
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||||
|
"target": "ES2023",
|
||||||
|
"useDefineForClassFields": true,
|
||||||
|
"lib": ["ES2023", "DOM", "DOM.Iterable"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"types": ["vite/client"],
|
||||||
|
"skipLibCheck": true,
|
||||||
|
|
||||||
|
/* Bundler mode */
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
"jsx": "react-jsx",
|
||||||
|
|
||||||
|
/* Linting */
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"erasableSyntaxOnly": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"noUncheckedSideEffectImports": true
|
||||||
|
},
|
||||||
|
"include": ["src"]
|
||||||
|
}
|
||||||
7
client/tsconfig.json
Normal file
7
client/tsconfig.json
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"files": [],
|
||||||
|
"references": [
|
||||||
|
{ "path": "./tsconfig.app.json" },
|
||||||
|
{ "path": "./tsconfig.node.json" }
|
||||||
|
]
|
||||||
|
}
|
||||||
26
client/tsconfig.node.json
Normal file
26
client/tsconfig.node.json
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||||
|
"target": "ES2023",
|
||||||
|
"lib": ["ES2023"],
|
||||||
|
"module": "ESNext",
|
||||||
|
"types": ["node"],
|
||||||
|
"skipLibCheck": true,
|
||||||
|
|
||||||
|
/* Bundler mode */
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"allowImportingTsExtensions": true,
|
||||||
|
"verbatimModuleSyntax": true,
|
||||||
|
"moduleDetection": "force",
|
||||||
|
"noEmit": true,
|
||||||
|
|
||||||
|
/* Linting */
|
||||||
|
"strict": true,
|
||||||
|
"noUnusedLocals": true,
|
||||||
|
"noUnusedParameters": true,
|
||||||
|
"erasableSyntaxOnly": true,
|
||||||
|
"noFallthroughCasesInSwitch": true,
|
||||||
|
"noUncheckedSideEffectImports": true
|
||||||
|
},
|
||||||
|
"include": ["vite.config.ts"]
|
||||||
|
}
|
||||||
12
client/vite.config.ts
Normal file
12
client/vite.config.ts
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import react from '@vitejs/plugin-react'
|
||||||
|
import tailwindcss from '@tailwindcss/vite'
|
||||||
|
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [react(), tailwindcss()],
|
||||||
|
server: {
|
||||||
|
proxy: {
|
||||||
|
'/api': 'http://localhost:3001',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
15
docker-compose.yml
Normal file
15
docker-compose.yml
Normal 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
2990
server/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
31
server/package.json
Normal file
31
server/package.json
Normal 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
19
server/src/database.ts
Normal 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
71
server/src/index.ts
Normal 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}`);
|
||||||
|
});
|
||||||
36
server/src/middleware/auth.ts
Normal file
36
server/src/middleware/auth.ts
Normal 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' });
|
||||||
|
}
|
||||||
|
}
|
||||||
615
server/src/migrations/001_initial_schema.ts
Normal file
615
server/src/migrations/001_initial_schema.ts
Normal 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);
|
||||||
|
`;
|
||||||
6
server/src/migrations/002_parts_charge_price.ts
Normal file
6
server/src/migrations/002_parts_charge_price.ts
Normal 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;
|
||||||
|
`;
|
||||||
16
server/src/migrations/003_misc_items.ts
Normal file
16
server/src/migrations/003_misc_items.ts
Normal 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);
|
||||||
|
`;
|
||||||
17
server/src/migrations/004_invoice_versions.ts
Normal file
17
server/src/migrations/004_invoice_versions.ts
Normal 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);
|
||||||
|
`;
|
||||||
4
server/src/migrations/005_vehicle_stock_number.ts
Normal file
4
server/src/migrations/005_vehicle_stock_number.ts
Normal 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;
|
||||||
|
`;
|
||||||
4
server/src/migrations/006_clear_default_terms.ts
Normal file
4
server/src/migrations/006_clear_default_terms.ts
Normal 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';
|
||||||
|
`;
|
||||||
5
server/src/migrations/007_job_mileage.ts
Normal file
5
server/src/migrations/007_job_mileage.ts
Normal 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;
|
||||||
|
`;
|
||||||
6
server/src/migrations/008_line_item_hidden.ts
Normal file
6
server/src/migrations/008_line_item_hidden.ts
Normal 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;
|
||||||
|
`;
|
||||||
4
server/src/migrations/009_rec_estimate_link.ts
Normal file
4
server/src/migrations/009_rec_estimate_link.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
// Link recommendations to estimates
|
||||||
|
export const up = `
|
||||||
|
ALTER TABLE recommendations ADD COLUMN estimate_id INTEGER REFERENCES estimates(id);
|
||||||
|
`;
|
||||||
4
server/src/migrations/010_rec_notes.ts
Normal file
4
server/src/migrations/010_rec_notes.ts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
// Add notes field to recommendations
|
||||||
|
export const up = `
|
||||||
|
ALTER TABLE recommendations ADD COLUMN notes TEXT;
|
||||||
|
`;
|
||||||
4
server/src/migrations/011_vehicle_inspections.ts
Normal file
4
server/src/migrations/011_vehicle_inspections.ts
Normal 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);
|
||||||
|
`;
|
||||||
27
server/src/migrations/012_fix_inspection_nullable_job.ts
Normal file
27
server/src/migrations/012_fix_inspection_nullable_job.ts
Normal 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);
|
||||||
|
`;
|
||||||
4
server/src/migrations/013_note_visibility.ts
Normal file
4
server/src/migrations/013_note_visibility.ts
Normal 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';
|
||||||
|
`;
|
||||||
35
server/src/migrations/014_inspection_enhancements.ts
Normal file
35
server/src/migrations/014_inspection_enhancements.ts
Normal 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;
|
||||||
|
`;
|
||||||
50
server/src/migrations/015_new_templates.ts
Normal file
50
server/src/migrations/015_new_templates.ts
Normal 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);
|
||||||
|
`;
|
||||||
78
server/src/migrations/runner.ts
Normal file
78
server/src/migrations/runner.ts
Normal 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.');
|
||||||
|
}
|
||||||
179
server/src/routes/appointments.ts
Normal file
179
server/src/routes/appointments.ts
Normal 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
71
server/src/routes/auth.ts
Normal 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;
|
||||||
51
server/src/routes/backup.ts
Normal file
51
server/src/routes/backup.ts
Normal 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;
|
||||||
127
server/src/routes/canned-services.ts
Normal file
127
server/src/routes/canned-services.ts
Normal 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;
|
||||||
83
server/src/routes/dashboard.ts
Normal file
83
server/src/routes/dashboard.ts
Normal 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;
|
||||||
296
server/src/routes/estimates.ts
Normal file
296
server/src/routes/estimates.ts
Normal 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;
|
||||||
96
server/src/routes/files.ts
Normal file
96
server/src/routes/files.ts
Normal 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
105
server/src/routes/fleets.ts
Normal 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;
|
||||||
254
server/src/routes/inspections.ts
Normal file
254
server/src/routes/inspections.ts
Normal 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;
|
||||||
299
server/src/routes/inventory.ts
Normal file
299
server/src/routes/inventory.ts
Normal 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;
|
||||||
305
server/src/routes/invoices.ts
Normal file
305
server/src/routes/invoices.ts
Normal 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
556
server/src/routes/jobs.ts
Normal 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;
|
||||||
154
server/src/routes/messages.ts
Normal file
154
server/src/routes/messages.ts
Normal 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;
|
||||||
82
server/src/routes/owners.ts
Normal file
82
server/src/routes/owners.ts
Normal 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;
|
||||||
259
server/src/routes/reports.ts
Normal file
259
server/src/routes/reports.ts
Normal 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;
|
||||||
32
server/src/routes/settings.ts
Normal file
32
server/src/routes/settings.ts
Normal 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
131
server/src/routes/shops.ts
Normal 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
82
server/src/routes/tags.ts
Normal 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;
|
||||||
161
server/src/routes/technicians.ts
Normal file
161
server/src/routes/technicians.ts
Normal 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;
|
||||||
177
server/src/routes/vehicles.ts
Normal file
177
server/src/routes/vehicles.ts
Normal 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
247
server/src/services/pdf.ts
Normal 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
249
server/src/types.ts
Normal 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
17
server/tsconfig.json
Normal 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"]
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user