Initial commit — Shop Manager for Tony Moon

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

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

2990
server/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

31
server/package.json Normal file
View File

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

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

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

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

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

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

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

View File

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

View File

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

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

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

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

@@ -0,0 +1,249 @@
export interface User {
id: number;
username: string;
password_hash: string;
display_name: string | null;
created_at: string;
updated_at: string;
}
export interface Shop {
id: number;
name: string;
contact_name: string | null;
phone: string | null;
email: string | null;
address: string | null;
city: string | null;
state: string | null;
zip: string | null;
notes: string | null;
default_labor_rate: number;
default_parts_markup: number;
active: number;
created_at: string;
updated_at: string;
}
export interface Owner {
id: number;
first_name: string;
last_name: string;
phone: string | null;
email: string | null;
notes: string | null;
created_at: string;
updated_at: string;
}
export type InventoryStatus = 'stock' | 'in_progress' | 'listed' | 'sold';
export interface Vehicle {
id: number;
owner_id: number | null;
year: number | null;
make: string;
model: string;
vin: string | null;
color: string | null;
license_plate: string | null;
mileage: number | null;
is_inventory: number;
inventory_status: InventoryStatus;
purchase_price: number | null;
purchase_date: string | null;
sale_price: number | null;
sale_date: string | null;
notes: string | null;
created_at: string;
updated_at: string;
}
export type JobStatus = 'received' | 'in_progress' | 'completed' | 'invoiced' | 'closed';
export type CustomerType = 'owner' | 'shop' | 'internal';
export interface Job {
id: number;
job_number: string;
customer_type: CustomerType;
shop_id: number | null;
owner_id: number;
vehicle_id: number;
status: JobStatus;
description: string | null;
received_date: string;
started_date: string | null;
completed_date: string | null;
notes: string | null;
created_at: string;
updated_at: string;
}
export type LaborBillingType = 'hourly' | 'flat_rate';
export interface LaborItem {
id: number;
job_id: number;
description: string;
billing_type: LaborBillingType;
hours: number | null;
rate: number | null;
flat_rate: number | null;
sort_order: number;
created_at: string;
}
export interface PartsItem {
id: number;
job_id: number;
description: string;
part_number: string | null;
quantity: number;
unit_cost: number;
markup: number;
sort_order: number;
created_at: string;
}
export interface JobNote {
id: number;
job_id: number;
note: string;
created_at: string;
}
export type InvoiceStatus = 'draft' | 'sent' | 'paid' | 'partial' | 'overdue';
export interface Invoice {
id: number;
invoice_number: string;
shop_id: number | null;
owner_id: number | null;
status: InvoiceStatus;
invoice_date: string;
due_date: string | null;
subtotal: number;
tax_rate: number;
tax_amount: number;
total: number;
notes: string | null;
created_at: string;
updated_at: string;
}
export interface InvoiceJob {
id: number;
invoice_id: number;
job_id: number;
}
export interface Payment {
id: number;
invoice_id: number;
amount: number;
payment_date: string;
method: string | null;
reference: string | null;
notes: string | null;
created_at: string;
}
export type EstimateStatus = 'draft' | 'sent' | 'approved' | 'declined' | 'expired' | 'converted';
export interface Estimate {
id: number;
estimate_number: string;
customer_type: CustomerType;
shop_id: number | null;
owner_id: number | null;
vehicle_id: number | null;
status: EstimateStatus;
subtotal: number;
tax_rate: number;
tax_amount: number;
total: number;
notes: string | null;
valid_until: string | null;
approval_token: string | null;
created_at: string;
updated_at: string;
}
export interface EstimateItem {
id: number;
estimate_id: number;
item_type: 'labor' | 'parts';
description: string;
quantity: number;
unit_price: number;
markup: number;
billing_type: LaborBillingType | null;
hours: number | null;
rate: number | null;
flat_rate: number | null;
sort_order: number;
created_at: string;
}
export interface CannedService {
id: number;
name: string;
description: string | null;
category: string | null;
is_active: number;
created_at: string;
updated_at: string;
}
export interface CannedServiceItem {
id: number;
canned_service_id: number;
item_type: 'labor' | 'parts';
description: string;
billing_type: LaborBillingType | null;
hours: number | null;
rate: number | null;
flat_rate: number | null;
quantity: number;
unit_cost: number;
markup: number;
sort_order: number;
}
export interface Technician {
id: number;
name: string;
phone: string | null;
email: string | null;
specialty: string | null;
hourly_rate: number;
is_active: number;
created_at: string;
updated_at: string;
}
export interface TimeEntry {
id: number;
technician_id: number;
job_id: number;
clock_in: string;
clock_out: string | null;
duration_minutes: number | null;
notes: string | null;
created_at: string;
}
export interface Settings {
id: number;
shop_name: string;
shop_address: string | null;
shop_city: string | null;
shop_state: string | null;
shop_zip: string | null;
shop_phone: string | null;
shop_email: string | null;
default_tax_rate: number;
invoice_terms: string | null;
updated_at: string;
}

17
server/tsconfig.json Normal file
View File

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