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:
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