?php
declare(strict_types=1);
require_once __DIR__ . '/app/bootstrap.php';
$pdo = db();
$route = $_GET['route'] ?? 'dashboard';
if ($route === 'logout') {
session_destroy();
header('Location: ?route=login');
exit;
}
if ($route === 'login') {
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
verify_csrf();
$stmt = $pdo->prepare('SELECT users.*, roles.name AS role FROM users JOIN roles ON roles.id=users.role_id WHERE email=? AND active=1');
$stmt->execute([post('email')]);
$user = $stmt->fetch();
if ($user && password_verify(post('password'), $user['password_hash'])) {
$_SESSION['user_id'] = (int)$user['id'];
$pdo->prepare('UPDATE users SET last_login_at=CURRENT_TIMESTAMP WHERE id=?')->execute([(int)$user['id']]);
redirect($user['role'] === 'Technician' ? 'tech' : 'dashboard');
}
$error = 'Invalid email or password.';
}
echo '
Login - V.I.T.A.L. ' . head_branding_tags() . ' ';
echo '';
exit;
}
if ($route === 'photo') {
require_login();
$stmt = $pdo->prepare('SELECT * FROM job_photos WHERE id=?');
$stmt->execute([(int)($_GET['id'] ?? 0)]);
$photo = $stmt->fetch();
if (!$photo) {
http_response_code(404);
exit('Photo not found.');
}
$path = STORAGE_DIR . '/' . str_replace(['..', '\\'], ['', '/'], $photo['file_path']);
if (!is_file($path)) {
http_response_code(404);
exit('File not found.');
}
header('Content-Type: ' . $photo['mime_type']);
header('Content-Length: ' . filesize($path));
readfile($path);
exit;
}
$user = require_login();
verify_csrf();
if ($_SERVER['REQUEST_METHOD'] === 'POST') {
handle_post($user);
}
if ($route === 'dashboard' && $user['role'] === 'Technician') {
redirect('tech');
}
switch ($route) {
case 'dashboard': page_dashboard($user); break;
case 'users': page_users($user); break;
case 'teams': page_teams($user); break;
case 'customers': page_customers($user); break;
case 'vehicles': page_vehicles($user); break;
case 'jobs': page_jobs($user); break;
case 'job_form': page_job_form($user); break;
case 'job_review': page_job_review($user); break;
case 'tech_preview': page_tech_preview($user); break;
case 'proof_packet': page_proof_packet($user); break;
case 'schedule': page_schedule($user); break;
case 'tech': page_tech($user); break;
case 'tech_job': page_tech_job($user); break;
case 'checklists': page_checklists($user); break;
case 'photosets': page_photosets($user); break;
case 'templates': page_templates($user); break;
case 'reports': page_reports($user); break;
case 'settings': page_settings($user); break;
case 'avail': route_avail(); break;
default: redirect(can_manage($user) ? 'dashboard' : 'tech');
}
function handle_post(array $user): void
{
$action = post('action');
if ($action === '') return;
try {
switch ($action) {
case 'save_user': action_save_user($user); break;
case 'save_team': action_save_team($user); break;
case 'save_customer': action_save_customer($user); break;
case 'save_vehicle': action_save_vehicle($user); break;
case 'save_job': action_save_job($user); break;
case 'set_job_status': action_set_job_status($user); break;
case 'claim_job': action_claim_job($user); break;
case 'save_vehicle_info': action_save_vehicle_info($user); break;
case 'save_dash_lights': action_save_dash_lights($user); break;
case 'confirm_vehicle': action_confirm_vehicle($user); break;
case 'save_checklist_response': action_save_checklist_response($user); break;
case 'upload_photo': action_upload_photo($user); break;
case 'timer_event': action_timer_event($user); break;
case 'add_issue': action_add_issue($user); break;
case 'submit_job': action_submit_job($user); break;
case 'save_checklist_template': action_save_checklist_template($user); break;
case 'save_photo_template': action_save_photo_template($user); break;
case 'save_template': action_save_template($user); break;
case 'save_settings': action_save_settings($user); break;
case 'override_job': action_override_job($user); break;
default: return; // silently ignore unrecognised actions
}
} catch (Throwable $e) {
flash($e->getMessage(), 'bad');
redirect($_GET['route'] ?? 'dashboard', $_GET);
}
}
function page_dashboard(array $user): void
{
require_role(['Admin', 'Manager']);
layout_header('Dashboard', $user);
$today = date('Y-m-d');
$cards = [
['Jobs Today', scalar('SELECT COUNT(*) FROM jobs WHERE scheduled_date=?', [$today]), 'Scheduled work', '?route=jobs&date=' . $today],
['Active Work', scalar("SELECT COUNT(*) FROM jobs WHERE status IN ('Work Started','Intake Started','Outtake Started')"), 'In the bay', '?route=jobs&status=Work+Started'],
['Waiting Review', scalar("SELECT COUNT(*) FROM jobs WHERE status='Ready For Review'"), 'Manager action', '?route=jobs&status=Ready+For+Review'],
['Over Estimate', count_over_estimate_jobs(), 'Labor warning', '?route=reports'],
['Missing Photos', count_jobs_missing_photos(), 'Proof gaps', '?route=jobs&missing_photos=1'],
['Paused Jobs', scalar("SELECT COUNT(*) FROM jobs WHERE status='Paused'"), 'Blocked work', '?route=jobs&status=Paused'],
];
echo '';
echo 'Shop Command Center Action queues for today, review, proof gaps, and blocked work.
';
echo 'Needs Action Now ';
$jobs = query("SELECT j.*, c.first_name, c.last_name, v.year, v.make, v.model, u.name AS tech_name, t.name AS team_name FROM jobs j JOIN customers c ON c.id=j.customer_id JOIN vehicles v ON v.id=j.vehicle_id LEFT JOIN users u ON u.id=j.assigned_technician_id LEFT JOIN teams t ON t.id=j.assigned_team_id WHERE j.status IN ('Ready For Review','Paused','Assigned','Available') ORDER BY j.scheduled_date, j.scheduled_start_time LIMIT 12");
if ($jobs) dashboard_job_cards($jobs); else empty_state('No jobs need action.', 'Create a job or assign work from the schedule.', '?route=job_form', 'Create Job');
echo 'Today / Upcoming ';
$upcoming = query("SELECT j.*, c.first_name, c.last_name, v.year, v.make, v.model, u.name AS tech_name, t.name AS team_name FROM jobs j JOIN customers c ON c.id=j.customer_id JOIN vehicles v ON v.id=j.vehicle_id LEFT JOIN users u ON u.id=j.assigned_technician_id LEFT JOIN teams t ON t.id=j.assigned_team_id WHERE date(j.scheduled_date) >= date('now') ORDER BY j.scheduled_date, j.scheduled_start_time LIMIT 8");
if ($upcoming) dashboard_job_cards($upcoming); else empty_state('No upcoming jobs.', 'Schedule work to see the shop load here.', '?route=job_form', 'Schedule Job');
echo 'Proof Gaps ';
$miss = grouped_proof_gaps(array_slice(jobs_with_missing_requirements(), 0, 16));
if (!$miss) echo 'No proof gaps right now. Required photos and checklist records are complete.
';
foreach ($miss as $jobId => $gap) {
echo '' . e($gap['title']) . ' Missing: ' . e(implode(', ', $gap['gaps'])) . '
Open Job ';
}
echo 'Open Issues ';
$issues = query("SELECT i.*, j.job_title, v.year, v.make, v.model FROM job_issues i JOIN jobs j ON j.id=i.job_id JOIN vehicles v ON v.id=j.vehicle_id WHERE i.resolved=0 ORDER BY i.created_at DESC LIMIT 12");
if (!$issues) echo 'No open issues right now. Damage, exceptions, and approval flags will show here.
';
foreach ($issues as $issue) echo '' . e($issue['issue_type']) . ' ' . e($issue['year'].' '.$issue['make'].' '.$issue['model'].' - '.$issue['job_title']) . ' ' . e($issue['note']) . '
';
echo ' ';
layout_footer();
}
function page_users(array $user): void
{
require_role(['Admin']);
layout_header('Users', $user);
$edit = row('SELECT * FROM users WHERE id=?', [(int)($_GET['edit'] ?? 0)]);
$roles = query('SELECT * FROM roles ORDER BY id');
echo 'Role guide Admins manage everything. Managers schedule and review work. Technicians only see bay workflow screens.
Manage Teams ';
echo '' . ($edit ? 'Edit User' : 'Create User') . ' All Users ';
$users = query('SELECT users.*, roles.name AS role FROM users JOIN roles ON roles.id=users.role_id ORDER BY users.name');
echo 'Name Email Role Status Last Login ';
foreach ($users as $u) echo '' . e($u['name']) . ' ' . e($u['email']) . ' ' . e($u['role']) . ' ' . ((int)$u['active'] ? 'Active' : 'Inactive') . ' ' . e($u['last_login_at'] ?: 'Never') . ' Edit / Reset ';
echo '
';
layout_footer();
}
function page_teams(array $user): void
{
require_role(['Admin', 'Manager']);
layout_header('Technicians & Teams', $user);
$edit = row('SELECT * FROM teams WHERE id=?', [(int)($_GET['edit'] ?? 0)]);
$managers = query("SELECT users.* FROM users JOIN roles ON roles.id=users.role_id WHERE roles.name IN ('Admin','Manager') AND active=1 ORDER BY name");
$techs = query("SELECT users.* FROM users JOIN roles ON roles.id=users.role_id WHERE roles.name='Technician' AND active=1 ORDER BY name");
$selected = $edit ? array_column(query('SELECT user_id FROM team_members WHERE team_id=?', [$edit['id']]), 'user_id') : [];
echo '' . ($edit ? 'Edit Team' : 'Create Team') . ' Teams ';
$teams = query('SELECT t.*, u.name AS manager FROM teams t LEFT JOIN users u ON u.id=t.manager_id ORDER BY archived, name');
echo 'Name Manager Status ';
foreach ($teams as $team) echo '' . e($team['name']) . ' ' . e($team['manager']) . ' ' . ((int)$team['archived'] ? 'Archived' : 'Active') . ' Edit ';
echo '
';
layout_footer();
}
function page_customers(array $user): void
{
require_role(['Admin', 'Manager']);
layout_header('Customers', $user);
$edit = row('SELECT * FROM customers WHERE id=?', [(int)($_GET['edit'] ?? 0)]);
$view = row('SELECT * FROM customers WHERE id=?', [(int)($_GET['view'] ?? 0)]);
$showForm = isset($_GET['new']) || $edit;
echo 'Customer Database Find customers, vehicles, and job history fast.
Add New Customer ';
echo '';
if ($showForm) {
echo '' . ($edit ? 'Edit Customer' : 'Add New Customer') . ' ';
echo ' ';
}
if ($view && !$showForm) {
$custJobs = query("SELECT j.*, v.year, v.make, v.model, v.trim FROM jobs j JOIN vehicles v ON v.id=j.vehicle_id WHERE j.customer_id=? ORDER BY j.scheduled_date DESC, j.id DESC", [(int)$view['id']]);
$custVehicles = query('SELECT * FROM vehicles WHERE customer_id=? ORDER BY updated_at DESC', [(int)$view['id']]);
echo '';
echo '' . e(trim($view['first_name'].' '.$view['last_name'])) . ' ';
if ($view['company_name']) echo '
' . e($view['company_name']) . ' ';
echo '
' . e($view['phone']) . ($view['email'] ? ' | ' . e($view['email']) : '') . ' ';
if ($view['notes']) echo '
' . e($view['notes']) . '
';
echo '
';
// Vehicles
if ($custVehicles) {
echo 'Vehicles ';
}
// Job history
if ($custJobs) {
echo 'Job History ';
} else {
echo 'No job history yet.
';
}
echo ' ';
}
$where = ['1=1']; $params = [];
if (!empty($_GET['q'])) {
$q = '%' . $_GET['q'] . '%';
$where[] = "(c.first_name LIKE ? OR c.last_name LIKE ? OR c.company_name LIKE ? OR c.phone LIKE ? OR c.email LIKE ? OR v.make LIKE ? OR v.model LIKE ? OR v.vin LIKE ?)";
$params = [$q,$q,$q,$q,$q,$q,$q,$q];
}
$customers = query("SELECT c.*, COUNT(DISTINCT v.id) AS vehicles, COUNT(DISTINCT CASE WHEN j.status NOT IN ('Completed','Cancelled') THEN j.id END) AS open_jobs, MAX(COALESCE(j.updated_at, v.updated_at, c.updated_at)) AS last_activity FROM customers c LEFT JOIN vehicles v ON v.customer_id=c.id LEFT JOIN jobs j ON j.customer_id=c.id WHERE " . implode(' AND ', $where) . " GROUP BY c.id ORDER BY last_activity DESC, c.last_name", $params);
echo 'Customer Database ';
if (!$customers) {
empty_state('No customers found.', 'Add a new customer to get started.', '?route=customers&new=1', 'Add New Customer');
} else {
echo 'Customer Contact Vehicles Open Jobs Last Activity Actions ';
foreach ($customers as $c) echo '' . e(trim($c['first_name'].' '.$c['last_name'])) . ' ' . e($c['company_name']) . ' ' . e($c['phone']) . '' . e($c['email']) . ' ' . (int)$c['vehicles'] . ' ' . (int)$c['open_jobs'] . ' ' . e($c['last_activity'] ? date('M j, Y', strtotime($c['last_activity'])) : 'None') . ' View Add Vehicle Edit ';
echo '
';
}
echo ' ';
layout_footer();
}
function page_vehicles(array $user): void
{
require_role(['Admin', 'Manager']);
layout_header('Vehicles', $user);
$edit = row('SELECT * FROM vehicles WHERE id=?', [(int)($_GET['edit'] ?? 0)]);
$view = row('SELECT v.*, c.first_name, c.last_name, c.company_name FROM vehicles v JOIN customers c ON c.id=v.customer_id WHERE v.id=?', [(int)($_GET['view'] ?? 0)]);
$showForm = isset($_GET['new']) || $edit;
$customers = query("SELECT id, first_name || ' ' || last_name || COALESCE(' - ' || company_name, '') AS name FROM customers ORDER BY last_name, first_name");
echo 'Vehicle Database Find vehicles by customer, VIN, plate, or model.
Add New Vehicle ';
echo 'Search Clear ';
if ($showForm) {
echo '';
}
if ($view && !$showForm) {
$history = query('SELECT * FROM jobs WHERE vehicle_id=? ORDER BY created_at DESC LIMIT 8', [$view['id']]);
echo 'Vehicle Detail ' . e(vehicle_label($view)) . ' ' . e($view['first_name'].' '.$view['last_name']) . ' VIN: ' . e($view['vin']) . ' | Plate: ' . e($view['license_plate']) . ' Mileage: ' . e((string)$view['mileage']) . ' | Color: ' . e($view['color']) . ' ' . e($view['notes']) . '
';
if ($history) {
echo 'Job History ';
}
echo ' ';
}
$where = ['1=1']; $params = [];
if (!empty($_GET['q'])) {
$q = '%' . $_GET['q'] . '%';
$where[] = "(v.year LIKE ? OR v.make LIKE ? OR v.model LIKE ? OR v.trim LIKE ? OR v.vin LIKE ? OR v.license_plate LIKE ? OR c.first_name LIKE ? OR c.last_name LIKE ?)";
$params = [$q,$q,$q,$q,$q,$q,$q,$q];
}
$vehicles = query("SELECT v.*, c.first_name, c.last_name, COUNT(DISTINCT CASE WHEN j.status NOT IN ('Completed','Cancelled') THEN j.id END) AS open_jobs, MAX(COALESCE(j.updated_at, v.updated_at)) AS last_activity FROM vehicles v JOIN customers c ON c.id=v.customer_id LEFT JOIN jobs j ON j.vehicle_id=v.id WHERE " . implode(' AND ', $where) . " GROUP BY v.id ORDER BY last_activity DESC LIMIT 300", $params);
echo 'Vehicle Database ';
if (!$vehicles) {
empty_state('No vehicles found.', 'Add a vehicle before creating shop work.', '?route=vehicles&new=1', 'Add New Vehicle');
} else {
echo 'Vehicle Customer VIN / Plate Mileage Open Jobs Last Activity Actions ';
foreach ($vehicles as $v) echo '' . e(vehicle_label($v)) . ' ' . e($v['first_name'].' '.$v['last_name']) . ' ' . e($v['vin']) . '' . e($v['license_plate']) . ' ' . e((string)$v['mileage']) . ' ' . (int)$v['open_jobs'] . ' ' . e($v['last_activity'] ? date('M j, Y', strtotime($v['last_activity'])) : 'None') . ' View Create Job Edit ';
echo '
';
}
echo ' ';
layout_footer();
}
function page_jobs(array $user): void
{
require_role(['Admin', 'Manager']);
layout_header('Jobs', $user);
echo '';
$techs = query("SELECT users.* FROM users JOIN roles ON roles.id=users.role_id WHERE roles.name='Technician' AND active=1 ORDER BY name");
echo 'Job Command Center Search, filter, assign, and review shop work.
Create Job ';
echo 'All statuses ';
foreach (job_statuses() as $s) echo '' . e($s) . ' ';
echo 'All techs ';
foreach ($techs as $tech) echo '' . e($tech['name']) . ' ';
echo 'All job types ';
foreach (job_types() as $type) echo '' . e($type) . ' ';
echo ' ';
$where = ['1=1']; $params = [];
if (!empty($_GET['status'])) { $where[] = 'j.status=?'; $params[] = $_GET['status']; }
if (!empty($_GET['date'])) { $where[] = 'j.scheduled_date=?'; $params[] = $_GET['date']; }
if (!empty($_GET['technician'])) { $where[] = 'j.assigned_technician_id=?'; $params[] = (int)$_GET['technician']; }
if (!empty($_GET['job_type'])) { $where[] = 'j.job_type=?'; $params[] = $_GET['job_type']; }
if (!empty($_GET['q'])) { $where[] = "(j.job_title LIKE ? OR v.make LIKE ? OR v.model LIKE ? OR v.vin LIKE ? OR c.last_name LIKE ?)"; $q = '%' . $_GET['q'] . '%'; $params = array_merge($params, [$q,$q,$q,$q,$q]); }
if (!empty($_GET['missing_photos'])) { $where[] = "j.id IN (SELECT DISTINCT job_id FROM job_photos WHERE 1=0 UNION SELECT j2.id FROM jobs j2 WHERE j2.id NOT IN (SELECT DISTINCT job_id FROM job_photos WHERE stage IN ('intake','outtake')))"; }
$jobs = query("SELECT j.*, c.first_name, c.last_name, v.year, v.make, v.model, v.trim, u.name AS tech_name, t.name AS team_name FROM jobs j JOIN customers c ON c.id=j.customer_id JOIN vehicles v ON v.id=j.vehicle_id LEFT JOIN users u ON u.id=j.assigned_technician_id LEFT JOIN teams t ON t.id=j.assigned_team_id WHERE " . implode(' AND ', $where) . " ORDER BY COALESCE(j.scheduled_date,'9999-12-31'), j.scheduled_start_time LIMIT 300", $params);
if ($jobs) job_table($jobs, true); else empty_state('No jobs match this view.', 'Clear filters or create the next scheduled job.', '?route=job_form', 'Create Job');
layout_footer();
}
function page_job_form(array $user): void
{
require_role(['Admin', 'Manager']);
$id = (int)($_GET['id'] ?? 0);
$job = $id ? row('SELECT * FROM jobs WHERE id=?', [$id]) : null;
$vehicleId = (int)($job['vehicle_id'] ?? ($_GET['vehicle_id'] ?? 0));
$vehicle = $vehicleId ? row('SELECT v.*, c.first_name, c.last_name, c.id AS customer_id FROM vehicles v JOIN customers c ON c.id=v.customer_id WHERE v.id=?', [$vehicleId]) : null;
$template = !empty($_GET['template_id']) ? row('SELECT * FROM job_templates WHERE id=?', [(int)$_GET['template_id']]) : null;
$jobType = $_GET['job_type'] ?? ($job['job_type'] ?? ($template['job_type'] ?? ''));
$matches = [];
if ($vehicle && $jobType) {
$matches = query("SELECT * FROM job_templates WHERE active=1 AND lower(make)=lower(?) AND lower(model)=lower(?) AND ? BETWEEN year_start AND year_end AND job_type=? ORDER BY updated_at DESC", [$vehicle['make'], $vehicle['model'], $vehicle['year'], $jobType]);
}
layout_header($job ? 'Edit Job' : 'Create Job', $user);
if ($vehicle && $matches && !$template && !$job) {
echo 'Similar Template Found ';
foreach ($matches as $m) echo '';
echo ' ';
}
$customerId = (int)($job['customer_id'] ?? ($vehicle['customer_id'] ?? ($_GET['customer_id'] ?? 0)));
$customers = query("SELECT id, first_name, last_name, company_name, phone, email FROM customers ORDER BY last_name, first_name");
$vehicles = query("SELECT v.*, c.first_name, c.last_name FROM vehicles v JOIN customers c ON c.id=v.customer_id ORDER BY c.last_name, c.first_name, v.year DESC, v.make, v.model");
$techs = query("SELECT users.* FROM users JOIN roles ON roles.id=users.role_id WHERE roles.name='Technician' AND active=1 ORDER BY name");
$teams = query('SELECT * FROM teams WHERE archived=0 ORDER BY name');
$intakes = query("SELECT * FROM checklist_templates WHERE stage='intake' AND active=1 ORDER BY name");
$outtakes = query("SELECT * FROM checklist_templates WHERE stage='outtake' AND active=1 ORDER BY name");
$photosets = query("SELECT * FROM photo_requirement_templates WHERE active=1 ORDER BY name");
// Build customer JSON for JS lookup
$customerJson = json_encode(array_values(array_map(fn($c) => [
'id' => (int)$c['id'],
'label' => trim($c['first_name'] . ' ' . $c['last_name']) . ($c['company_name'] ? ' - ' . $c['company_name'] : ''),
'first_name' => $c['first_name'], 'last_name' => $c['last_name'],
'company' => $c['company_name'] ?? '', 'phone' => $c['phone'] ?? '', 'email' => $c['email'] ?? '',
], $customers)));
// Build vehicle JSON for JS lookup
$vehicleJson = json_encode(array_values(array_map(fn($v) => [
'id' => (int)$v['id'], 'customer_id' => (int)$v['customer_id'],
'label' => vehicle_label($v),
'year' => $v['year'], 'make' => $v['make'], 'model' => $v['model'],
'trim' => $v['trim'] ?? '', 'vin' => $v['vin'] ?? '',
'plate' => $v['license_plate'] ?? '', 'color' => $v['color'] ?? '',
'mileage' => $v['mileage'] ?? '', 'drivetrain' => $v['drivetrain'] ?? '',
], $vehicles)));
echo '' . csrf_field() . ' ';
echo '';
// ── Customer fieldset ──────────────────────────────────────────────────
echo '1. Customer ';
echo '';
echo '
';
echo '
';
echo '
';
echo ' ';
// Existing customer info card (shown when a customer is selected)
$selC = $customerId ? array_values(array_filter($customers, fn($c) => (int)$c['id'] === $customerId))[0] ?? null : null;
echo '';
echo '
Name ' . e($selC ? trim($selC['first_name'].' '.$selC['last_name']) : '') . '
';
echo '
Company ' . e($selC['company_name'] ?? '') . '
';
echo '
Phone ' . e($selC['phone'] ?? '') . '
';
echo '
Email ' . e($selC['email'] ?? '') . '
';
echo '
Change Customer
';
echo '
';
echo ' ';
// ── New Customer modal ─────────────────────────────────────────────────
echo '';
echo '
';
echo '
New Customer ';
echo '
';
field('new_customer_first_name', 'First Name', '', false);
field('new_customer_last_name', 'Last Name', '', false);
field('new_customer_company_name', 'Company', '', false);
field('new_customer_phone', 'Phone', '', false);
field('new_customer_email', 'Email', '', false, 'email');
echo '
Notes ';
echo '
Add Customer Cancel
';
echo '
';
// ── Vehicle fieldset ───────────────────────────────────────────────────
echo '2. Vehicle ';
echo '';
echo '
';
echo '
';
echo '
';
echo ' ';
$selV = $vehicleId ? array_values(array_filter($vehicles, fn($v) => (int)$v['id'] === $vehicleId))[0] ?? null : null;
echo '';
echo '
Vehicle ' . e($selV ? vehicle_label($selV) : '') . '
';
echo '
VIN ' . e($selV['vin'] ?? '') . '
';
echo '
Plate ' . e($selV['license_plate'] ?? '') . '
';
echo '
Color ' . e($selV['color'] ?? '') . '
';
echo '
Mileage ' . e($selV['mileage'] ?? '') . '
';
echo '
Change Vehicle ';
echo '
';
echo ' ';
// ── New Vehicle modal ──────────────────────────────────────────────────
echo '';
echo '
';
echo '
New Vehicle ';
echo '
';
field('new_vehicle_year', 'Year', '', false, 'number');
field('new_vehicle_make', 'Make', '', false);
field('new_vehicle_model', 'Model', '', false);
field('new_vehicle_trim', 'Trim', '', false);
field('new_vehicle_vin', 'VIN', '', false);
field('new_vehicle_license_plate', 'License Plate', '', false);
field('new_vehicle_color', 'Color', '', false);
field('new_vehicle_mileage', 'Mileage', '', false, 'number');
field('new_vehicle_drivetrain', 'Drivetrain', '', false);
echo '
Notes ';
echo '
Add Vehicle Cancel
';
echo '
';
echo '3. Job Type Job TypeChoose type ';
foreach (job_types() as $type) echo '' . e($type) . ' ';
echo ' ';
echo ' ';
echo '4. Estimated Duration ';
echo 'Estimated Minutes ';
echo '5. Technician ';
echo 'TechnicianNone / Open Pool ';
foreach ($techs as $t) echo '' . e($t['name']) . ' ';
echo ' ';
echo ' ';
echo ' ';
echo '6. Schedule ';
$scheduledDate = $job['scheduled_date'] ?? '';
$scheduledTime = $job['scheduled_start_time'] ?? setting('workday_start', '08:00');
echo 'Scheduled Date';
echo '';
echo ' ';
echo 'Next Available ';
echo '
';
echo ' ';
echo '';
echo '
';
echo ' ';
echo '';
echo 'Start Time ';
echo 'Proof Requirements Intake Checklist';
foreach ($intakes as $tpl) echo '' . e($tpl['name']) . ' ';
echo ' Outtake Checklist';
foreach ($outtakes as $tpl) echo '' . e($tpl['name']) . ' ';
echo ' Photo Requirements';
foreach ($photosets as $tpl) echo '' . e($tpl['name']) . ' ';
echo '
';
echo 'Notes Parts Notes' . e($job['parts_notes'] ?? $template['common_parts_notes'] ?? '') . ' Internal Notes' . e($job['internal_notes'] ?? $template['common_internal_notes'] ?? '') . ' Customer Notes' . e($job['customer_notes'] ?? '') . ' ';
echo ' ';
layout_footer();
}
function page_job_review(array $user): void
{
require_role(['Admin', 'Manager']);
$job = full_job((int)($_GET['id'] ?? 0));
if (!$job) redirect('jobs');
layout_header('Job Review', $user);
job_summary($job);
compact_status_progress($job['status']);
echo '';
$active = active_time_seconds((int)$job['id']);
$variance = (int)round($active / 60) - (int)$job['estimated_duration_minutes'];
echo '' . fmt_minutes($active) . ' Actual Active Time
' . (int)$job['estimated_duration_minutes'] . 'm Estimated
' . ($variance >= 0 ? '+' : '') . $variance . 'm Variance
' . fmt_minutes(elapsed_time_seconds((int)$job['id'])) . ' Total Elapsed
';
echo 'Before / After Photos ';
$reqs = requirements_for_job((int)$job['id'], 'intake');
foreach ($reqs as $req) {
$before = last_photo((int)$job['id'], 'intake', $req['requirement_key']);
$after = last_photo((int)$job['id'], 'outtake', $req['requirement_key']);
echo '
' . e($req['name']) . ' Before ' . thumb($before) . '
After ' . thumb($after) . '
';
}
echo '
Checklist Completion ';
checklist_review((int)$job['id'], 'intake');
checklist_review((int)$job['id'], 'outtake');
echo 'Issues & History ';
foreach (query('SELECT * FROM job_issues WHERE job_id=? ORDER BY created_at DESC', [$job['id']]) as $issue) echo '' . e($issue['issue_type']) . ' ' . e($issue['note']) . ' ' . e($issue['created_at']) . '
';
echo 'Status History ';
foreach (query('SELECT h.*, u.name FROM job_status_history h LEFT JOIN users u ON u.id=h.changed_by WHERE job_id=? ORDER BY h.created_at DESC', [$job['id']]) as $h) echo '' . e(($h['old_status'] ?: 'New') . ' -> ' . $h['new_status']) . ' ' . e($h['reason']) . ' ' . e($h['created_at'] . ' by ' . ($h['name'] ?: 'system')) . '
';
echo ' ';
layout_footer();
}
function page_proof_packet(array $user): void
{
require_role(['Admin', 'Manager']);
$job = full_job((int)($_GET['id'] ?? 0));
if (!$job) redirect('jobs');
echo 'Proof Packet - V.I.T.A.L. ' . head_branding_tags() . '';
echo '';
job_summary($job);
echo 'Proof Summary Customer ' . e($job['first_name'].' '.$job['last_name']) . '
Vehicle ' . e(vehicle_label($job)) . '
VIN ' . e($job['vin']) . '
Plate ' . e($job['license_plate']) . '
Estimate ' . (int)$job['estimated_duration_minutes'] . 'm
Actual Active ' . e(fmt_minutes(active_time_seconds((int)$job['id']))) . '
';
echo 'Before / After Photo Proof ';
foreach (requirements_for_job((int)$job['id'], 'intake') as $req) {
echo '
' . e($req['name']) . ' Before ' . thumb(last_photo((int)$job['id'], 'intake', $req['requirement_key'])) . '
After ' . thumb(last_photo((int)$job['id'], 'outtake', $req['requirement_key'])) . '
';
}
echo '
Checklist Record ';
checklist_review((int)$job['id'], 'intake');
checklist_review((int)$job['id'], 'outtake');
echo 'Status / Audit Trail ';
foreach (query('SELECT h.*, u.name FROM job_status_history h LEFT JOIN users u ON u.id=h.changed_by WHERE job_id=? ORDER BY h.created_at', [$job['id']]) as $h) {
echo '' . e(($h['old_status'] ?: 'New') . ' -> ' . $h['new_status']) . ' ' . e($h['reason']) . ' ' . e($h['created_at'] . ' by ' . ($h['name'] ?: 'system')) . '
';
}
echo ' ';
}
function page_schedule(array $user): void
{
require_role(['Admin', 'Manager']);
layout_header('Schedule', $user);
$date = $_GET['date'] ?? date('Y-m-d');
$prev = date('Y-m-d', strtotime($date . ' -7 days'));
$next = date('Y-m-d', strtotime($date . ' +7 days'));
echo 'Dispatch Board Week view for scheduled work and open pool jobs.
Quick Create Job ';
echo '';
echo 'All teams ';
foreach (query('SELECT * FROM teams WHERE archived=0 ORDER BY name') as $team) echo '' . e($team['name']) . ' ';
echo 'All statuses ';
foreach (job_statuses() as $s) echo '' . e($s) . ' ';
echo 'Filter ';
for ($i = 0; $i < 7; $i++) {
$d = date('Y-m-d', strtotime($date . " +{$i} day"));
$where = ['j.scheduled_date=?']; $params = [$d];
if (!empty($_GET['team'])) { $where[] = 'j.assigned_team_id=?'; $params[] = (int)$_GET['team']; }
if (!empty($_GET['status'])) { $where[] = 'j.status=?'; $params[] = $_GET['status']; }
$jobs = query("SELECT j.*, c.first_name, c.last_name, v.year, v.make, v.model, u.name AS tech_name, t.name AS team_name FROM jobs j JOIN customers c ON c.id=j.customer_id JOIN vehicles v ON v.id=j.vehicle_id LEFT JOIN users u ON u.id=j.assigned_technician_id LEFT JOIN teams t ON t.id=j.assigned_team_id WHERE " . implode(' AND ', $where) . " ORDER BY j.scheduled_start_time", $params);
$totalEstimate = array_sum(array_map(fn($job) => (int)$job['estimated_duration_minutes'], $jobs));
echo '
' . e(date('D M j', strtotime($d))) . ' ' . count($jobs) . ' jobs' . ($totalEstimate ? ' | ' . $totalEstimate . 'm est' : '') . ' ';
if (!$jobs) echo 'No jobs scheduled. Quick create or assign from open pool.
';
foreach ($jobs as $job) echo schedule_job_card($job);
echo '';
}
echo '
Unscheduled / Open Pool ';
$open = query("SELECT j.*, c.first_name, c.last_name, v.year, v.make, v.model, u.name AS tech_name, t.name AS team_name FROM jobs j JOIN customers c ON c.id=j.customer_id JOIN vehicles v ON v.id=j.vehicle_id LEFT JOIN users u ON u.id=j.assigned_technician_id LEFT JOIN teams t ON t.id=j.assigned_team_id WHERE j.assignment_mode='pool' OR j.scheduled_date IS NULL ORDER BY j.created_at DESC LIMIT 12");
if ($open) job_table($open, true); else empty_state('No unscheduled work.', 'Open pool jobs and unscheduled work will show here.', '?route=job_form', 'Create Job');
echo ' ';
layout_footer();
}
function page_tech(array $user): void
{
require_role(['Technician']);
layout_header('My Jobs', $user);
$my = tech_jobs($user, 'my');
$team = tech_jobs($user, 'team');
$pool = tech_jobs($user, 'pool');
echo '';
tech_section('my', 'My Jobs', $my, $user);
tech_section('team', 'Team Jobs', $team, $user);
tech_section('pool', 'Open Pool', $pool, $user);
layout_footer();
}
function page_tech_preview(array $user): void
{
require_role(['Admin', 'Manager']);
$job = full_job((int)($_GET['id'] ?? 0));
if (!$job) redirect('settings');
echo ' ';
echo 'Viewing As Technician - V.I.T.A.L. ' . head_branding_tags() . ' ';
echo '';
foreach (flashes() as $flash) {
echo '' . e($flash['message']) . '
';
}
$step = $_GET['step'] ?? next_tech_step($job, $user);
job_summary($job);
tech_step_banner($job, $step);
echo '';
switch ($step) {
case 'dash-lights':
dash_lights_panel($job);
break;
case 'intake-checklist':
tech_checklist_panel($job, 'intake', $user);
break;
case 'intake-photos':
tech_photos_panel($job, 'intake', $user);
issue_panel($job, $user);
break;
case 'timer':
timer_panel($job, $user);
break;
case 'outtake-checklist':
tech_checklist_panel($job, 'outtake', $user);
break;
case 'outtake-photos':
tech_photos_panel($job, 'outtake', $user);
break;
case 'submit':
submit_panel($job);
break;
case 'confirm':
default:
vehicle_info_panel($job);
break;
}
echo ' ';
echo ' ';
}
function page_tech_job(array $user): void
{
require_role(['Technician']);
$job = full_job((int)($_GET['id'] ?? 0));
if (!$job || !tech_can_view($user, $job)) redirect('tech');
layout_header('Job Detail', $user);
job_summary($job);
$step = $_GET['step'] ?? next_tech_step($job, $user);
if (!$job['claimed_by'] && in_array($job['assignment_mode'], ['team','pool'], true)) {
echo '' . csrf_field() . 'Claim Job ';
layout_footer();
return;
} elseif ((int)$job['assigned_technician_id'] === (int)$user['id'] && $job['status'] === 'Assigned') {
echo '' . csrf_field() . 'Accept Job ';
layout_footer();
return;
}
tech_step_banner($job, $step);
echo '';
switch ($step) {
case 'dash-lights':
dash_lights_panel($job);
break;
case 'intake-checklist':
tech_checklist_panel($job, 'intake', $user);
break;
case 'intake-photos':
tech_photos_panel($job, 'intake', $user);
issue_panel($job, $user);
break;
case 'timer':
timer_panel($job, $user);
break;
case 'outtake-checklist':
tech_checklist_panel($job, 'outtake', $user);
break;
case 'outtake-photos':
tech_photos_panel($job, 'outtake', $user);
break;
case 'marketing':
tech_photos_panel($job, 'marketing', $user);
issue_panel($job, $user);
break;
case 'submit':
submit_panel($job);
break;
case 'confirm':
default:
vehicle_info_panel($job);
break;
}
echo ' ';
layout_footer();
}
function page_checklists(array $user): void
{
require_role(['Admin', 'Manager']);
layout_header('Checklists', $user);
echo 'Checklist Templates ';
foreach (query('SELECT * FROM checklist_templates ORDER BY stage, name') as $tpl) {
$items = query('SELECT * FROM checklist_items WHERE template_id=? ORDER BY sort_order', [$tpl['id']]);
echo '' . e($tpl['name']) . ' ' . e(ucfirst($tpl['stage'])) . ' ' . count($items) . ' items
';
foreach ($items as $item) echo '
' . e(((int)$item['is_required'] ? 'Required' : 'Optional') . ': ' . $item['label']) . ' ';
echo '
';
}
echo ' ';
layout_footer();
}
function page_photosets(array $user): void
{
require_role(['Admin', 'Manager']);
layout_header('Photo Requirements', $user);
echo 'Photo Sets ';
foreach (query('SELECT * FROM photo_requirement_templates ORDER BY name') as $tpl) {
$items = query('SELECT * FROM photo_requirement_items WHERE template_id=? ORDER BY stage, sort_order', [$tpl['id']]);
echo '' . e($tpl['name']) . ' ' . count($items) . ' prompts
';
foreach ($items as $item) echo '' . e(ucfirst($item['stage'])) . ' ' . e($item['name']) . ' ';
echo '
';
echo '
';
}
echo ' ';
layout_footer();
}
function page_templates(array $user): void
{
require_role(['Admin', 'Manager']);
layout_header('Job Templates', $user);
$edit = row('SELECT * FROM job_templates WHERE id=?', [(int)($_GET['edit'] ?? 0)]);
$teams = query('SELECT * FROM teams WHERE archived=0 ORDER BY name');
echo '' . ($edit ? 'Edit Template' : 'Create Template') . ' ' . csrf_field() . ' ';
foreach (['template_name'=>'Template Name','make'=>'Make','model'=>'Model','trim'=>'Trim'] as $k => $label) field($k, $label, $edit[$k] ?? '', $k !== 'trim');
echo ''; field('year_start','Year Start',(string)($edit['year_start'] ?? ''),true,'number'); field('year_end','Year End',(string)($edit['year_end'] ?? ''),true,'number'); echo 'Job Type';
foreach (job_types() as $type) echo '' . e($type) . ' ';
echo '
'; field('estimated_duration_minutes','Estimated Minutes',(string)($edit['estimated_duration_minutes'] ?? 120),true,'number');
echo 'Default TeamNone ';
foreach ($teams as $t) echo '' . e($t['name']) . ' ';
echo ' ';
echo template_selects($edit);
echo 'Common Internal Notes' . e($edit['common_internal_notes'] ?? '') . ' Common Parts Notes' . e($edit['common_parts_notes'] ?? '') . ' ActiveSave Template Template Library ';
echo 'Template Vehicle Type Estimate ';
foreach (query('SELECT * FROM job_templates ORDER BY active DESC, make, model, job_type') as $tpl) echo '' . e($tpl['template_name']) . ' ' . e($tpl['year_start'].'-'.$tpl['year_end'].' '.$tpl['make'].' '.$tpl['model']) . ' ' . e($tpl['job_type']) . ' ' . (int)$tpl['estimated_duration_minutes'] . 'm Edit ';
echo '
';
layout_footer();
}
function page_reports(array $user): void
{
require_role(['Admin', 'Manager']);
layout_header('Reports', $user);
$completed = scalar("SELECT COUNT(*) FROM jobs WHERE status='Completed'");
$overEstimate = count_over_estimate_jobs();
$missingPhotos = count_jobs_missing_photos();
$activeSeconds = 0;
foreach (query('SELECT id FROM jobs') as $j) $activeSeconds += active_time_seconds((int)$j['id']);
echo '' . (int)$completed . ' Jobs Completed Closed proof records
' . (int)$overEstimate . ' Jobs Over Estimate Labor variance
' . (int)$missingPhotos . ' Missing Required Photos Proof gaps
' . e(fmt_minutes($activeSeconds)) . ' Active Labor Total tracked time
';
echo 'Technician Performance Technician Jobs Completed Total Active Avg Active Pauses ';
foreach (query("SELECT u.id, u.name, COUNT(DISTINCT CASE WHEN j.status='Completed' THEN j.id END) AS jobs, COUNT(CASE WHEN l.event_type='pause' THEN 1 END) AS pauses FROM users u JOIN roles r ON r.id=u.role_id LEFT JOIN job_time_logs l ON l.user_id=u.id LEFT JOIN jobs j ON j.id=l.job_id WHERE r.name='Technician' GROUP BY u.id ORDER BY u.name") as $tech) {
$total = 0; $completed = query("SELECT DISTINCT j.id FROM jobs j JOIN job_time_logs l ON l.job_id=j.id WHERE l.user_id=? AND j.status='Completed'", [$tech['id']]);
foreach ($completed as $j) $total += active_time_seconds((int)$j['id'], (int)$tech['id']);
echo '' . e($tech['name']) . ' ' . (int)$tech['jobs'] . ' ' . fmt_minutes($total) . ' ' . fmt_minutes((int)$tech['jobs'] ? (int)($total / (int)$tech['jobs']) : 0) . ' ' . (int)$tech['pauses'] . ' ';
}
echo '
Job Type Timing Job Type Jobs Avg Estimate Avg Actual ';
foreach (query('SELECT job_type, COUNT(*) AS jobs, AVG(estimated_duration_minutes) AS est FROM jobs GROUP BY job_type ORDER BY jobs DESC') as $r) {
$ids = query('SELECT id FROM jobs WHERE job_type=?', [$r['job_type']]); $total = 0; foreach ($ids as $id) $total += active_time_seconds((int)$id['id']);
echo '' . e($r['job_type']) . ' ' . (int)$r['jobs'] . ' ' . (int)$r['est'] . 'm ' . fmt_minutes((int)$r['jobs'] ? (int)($total / (int)$r['jobs']) : 0) . ' ';
}
echo '
Vehicle Timing Vehicle Jobs Avg Active ';
foreach (query('SELECT v.year, v.make, v.model, COUNT(j.id) AS jobs, GROUP_CONCAT(j.id) AS ids FROM vehicles v JOIN jobs j ON j.vehicle_id=v.id GROUP BY v.year, v.make, v.model ORDER BY jobs DESC LIMIT 50') as $r) {
$total = 0; foreach (explode(',', (string)$r['ids']) as $id) $total += active_time_seconds((int)$id);
echo '' . e($r['year'].' '.$r['make'].' '.$r['model']) . ' ' . (int)$r['jobs'] . ' ' . fmt_minutes((int)($total / max(1, (int)$r['jobs']))) . ' ';
}
echo '
Template Accuracy Template Estimate Avg Actual Jobs Recommendation ';
foreach (query('SELECT t.*, COUNT(j.id) AS jobs, GROUP_CONCAT(j.id) AS ids FROM job_templates t LEFT JOIN jobs j ON j.selected_job_template_id=t.id GROUP BY t.id ORDER BY t.template_name') as $t) {
$total = 0; foreach (array_filter(explode(',', (string)$t['ids'])) as $id) $total += active_time_seconds((int)$id);
$avg = (int)$t['jobs'] ? (int)round($total / (int)$t['jobs'] / 60) : 0;
$variance = $avg - (int)$t['estimated_duration_minutes'];
$rec = abs($variance) <= 15 ? 'Estimate accurate' : ($variance > 0 ? 'Estimate low' : 'Estimate high');
echo '' . e($t['template_name']) . ' ' . (int)$t['estimated_duration_minutes'] . 'm ' . $avg . 'm ' . (int)$t['jobs'] . ' ' . e($rec) . ' ';
}
echo '
Checklist & Photo Gaps ';
$miss = jobs_with_missing_requirements();
if (!$miss) echo 'No missing required checklist items or photos found.
';
foreach ($miss as $m) echo '';
echo 'Marketing Photo Library ';
foreach (query("SELECT p.*, j.job_title FROM job_photos p JOIN jobs j ON j.id=p.job_id WHERE p.stage='marketing' ORDER BY p.created_at DESC LIMIT 60") as $p) echo '
' . thumb($p) . '' . e($p['job_title']) . ' ';
echo '
';
layout_footer();
}
function page_settings(array $user): void
{
require_role(['Admin']);
layout_header('Settings', $user);
echo '';
$previewJobs = query("SELECT j.*, c.first_name, c.last_name, v.year, v.make, v.model FROM jobs j JOIN customers c ON c.id=j.customer_id JOIN vehicles v ON v.id=j.vehicle_id WHERE j.status NOT IN ('Completed','Cancelled') ORDER BY j.updated_at DESC LIMIT 6");
echo 'View As Technician Open a live technician workflow for testing. Actions here can update the selected job.
';
echo '' . csrf_field() . ' ';
echo 'Shop Profile '; field('shop_name','Shop Name',setting('shop_name',''),true); field('timezone','Timezone',setting('timezone',''),true); echo ' ';
echo 'Branding '; field('logo_text','Logo Text',setting('logo_text',''),true); echo 'Logo files are loaded from the site root: vital-logo.webp and favicon package.
';
echo 'Time & Workday '; field('workday_start','Default Workday Start',setting('workday_start',''),true); field('workday_end','Default Workday End',setting('workday_end',''),true); field('upload_max_mb','Upload Max MB',setting('upload_max_mb',''),true); echo '
';
$bufVal = setting('job_buffer_minutes', '15');
echo 'Buffer Between Jobs (minutes)No buffer 15 minutes 30 minutes 45 minutes 60 minutes ';
echo ' ';
echo 'Job Rules Team jobs can be claimed by team members Open pool jobs can be claimed by any technician ';
echo 'Photo Rules Marketing photos required before submission ';
echo 'Job Types Editable Job Type List' . e(setting('job_types', '')) . ' Save Settings ';
layout_footer();
}
function action_save_user(array $user): void
{
require_role(['Admin']);
$id = (int)post('id');
$password = post('password');
if ($id) {
$params = [(int)post('role_id'), post('name'), post('email'), isset($_POST['active']) ? 1 : 0, $id];
db()->prepare('UPDATE users SET role_id=?, name=?, email=?, active=?, force_password_change=0, updated_at=CURRENT_TIMESTAMP WHERE id=?')->execute($params);
if ($password !== '') db()->prepare('UPDATE users SET password_hash=? WHERE id=?')->execute([password_hash($password, PASSWORD_DEFAULT), $id]);
} else {
if ($password === '') $password = 'password';
db()->prepare('INSERT INTO users(role_id, name, email, password_hash, active, force_password_change) VALUES(?,?,?,?,?,?)')->execute([(int)post('role_id'), post('name'), post('email'), password_hash($password, PASSWORD_DEFAULT), isset($_POST['active']) ? 1 : 0, 1]);
}
flash('User saved.');
redirect('users');
}
function action_save_team(array $user): void
{
require_role(['Admin', 'Manager']);
$id = (int)post('id');
$params = [post('name'), post('manager_id') ?: null, isset($_POST['archived']) ? 1 : 0];
if ($id) {
db()->prepare('UPDATE teams SET name=?, manager_id=?, archived=?, updated_at=CURRENT_TIMESTAMP WHERE id=?')->execute([...$params, $id]);
} else {
db()->prepare('INSERT INTO teams(name, manager_id, archived) VALUES(?,?,?)')->execute($params);
$id = (int)db()->lastInsertId();
}
db()->prepare('DELETE FROM team_members WHERE team_id=?')->execute([$id]);
foreach ($_POST['members'] ?? [] as $member) db()->prepare('INSERT OR IGNORE INTO team_members(team_id, user_id) VALUES(?,?)')->execute([$id, (int)$member]);
flash('Team saved.');
redirect('teams');
}
function action_save_customer(array $user): void
{
require_role(['Admin', 'Manager']);
$fields = ['first_name','last_name','company_name','phone','email','address','city','state','zip','notes'];
save_table('customers', $fields, 'customers');
}
function action_save_vehicle(array $user): void
{
require_role(['Admin', 'Manager']);
$fields = ['customer_id','year','make','model','trim','vin','license_plate','color','mileage','drivetrain','notes'];
save_table('vehicles', $fields, 'vehicles');
}
function action_save_job(array $user): void
{
require_role(['Admin', 'Manager']);
$customerId = resolve_job_customer_id();
$vehicleId = resolve_job_vehicle_id($customerId);
$vehicle = row('SELECT * FROM vehicles WHERE id=?', [$vehicleId]);
if (!$vehicle) throw new RuntimeException('Choose or create a valid vehicle.');
$mode = post('assignment_mode');
$status = $mode === 'pool' ? 'Available' : 'Assigned';
$fields = ['customer_id','vehicle_id','job_title','job_type','scheduled_date','scheduled_start_time','estimated_duration_minutes','assignment_mode','assigned_technician_id','assigned_team_id','status','parts_notes','internal_notes','customer_notes','selected_job_template_id','intake_checklist_template_id','outtake_checklist_template_id','photo_requirement_template_id','created_by'];
$id = (int)post('id');
$jobTitle = $id ? generated_job_title($id, post('job_type')) : post('job_type');
$values = [
$customerId, $vehicleId, $jobTitle, post('job_type'), post('scheduled_date') ?: null, post('scheduled_start_time') ?: null, (int)post('estimated_duration_minutes'),
$mode, post('assigned_technician_id') ?: null, post('assigned_team_id') ?: null, $status, post('parts_notes'), post('internal_notes'), post('customer_notes'), post('selected_job_template_id') ?: null,
(int)post('intake_checklist_template_id'), (int)post('outtake_checklist_template_id'), (int)post('photo_requirement_template_id'), (int)$user['id']
];
if ($id) {
$oldStatus = row('SELECT status FROM jobs WHERE id=?', [$id])['status'] ?? null;
$set = implode('=?, ', array_slice($fields, 0, -1)) . '=?';
db()->prepare("UPDATE jobs SET {$set}, updated_at=CURRENT_TIMESTAMP WHERE id=?")->execute([...array_slice($values, 0, -1), $id]);
if ($oldStatus !== $status) {
db()->prepare('INSERT INTO job_status_history(job_id, old_status, new_status, changed_by, reason) VALUES(?,?,?,?,?)')->execute([$id, $oldStatus, $status, (int)$user['id'], 'Assignment updated']);
}
} else {
db()->prepare('INSERT INTO jobs(' . implode(',', $fields) . ') VALUES(' . implode(',', array_fill(0, count($fields), '?')) . ')')->execute($values);
$id = (int)db()->lastInsertId();
db()->prepare('UPDATE jobs SET job_title=? WHERE id=?')->execute([generated_job_title($id, post('job_type')), $id]);
audit_new_job($id, (int)$user['id']);
if ($status !== 'Scheduled') job_status($id, $status, (int)$user['id'], 'Initial assignment');
}
flash('Job saved.');
redirect('job_review', ['id' => $id]);
}
function resolve_job_customer_id(): int
{
if (post('customer_id') !== 'new' && (int)post('customer_id') > 0) {
return (int)post('customer_id');
}
$first = post('new_customer_first_name');
$last = post('new_customer_last_name');
if ($first === '' || $last === '') {
throw new RuntimeException('Enter first and last name for the new customer.');
}
db()->prepare('INSERT INTO customers(first_name,last_name,company_name,phone,email,notes) VALUES(?,?,?,?,?,?)')
->execute([$first, $last, post('new_customer_company_name') ?: null, post('new_customer_phone') ?: null, post('new_customer_email') ?: null, post('new_customer_notes') ?: null]);
return (int)db()->lastInsertId();
}
function resolve_job_vehicle_id(int $customerId): int
{
if (post('vehicle_id') !== 'new' && (int)post('vehicle_id') > 0) {
$vehicle = row('SELECT id, customer_id FROM vehicles WHERE id=?', [(int)post('vehicle_id')]);
if (!$vehicle || (int)$vehicle['customer_id'] !== $customerId) {
throw new RuntimeException('Choose a vehicle that belongs to the selected customer.');
}
return (int)$vehicle['id'];
}
$year = (int)post('new_vehicle_year');
$make = post('new_vehicle_make');
$model = post('new_vehicle_model');
if (!$year || $make === '' || $model === '') {
throw new RuntimeException('Enter year, make, and model for the new vehicle.');
}
db()->prepare('INSERT INTO vehicles(customer_id,year,make,model,trim,vin,license_plate,color,mileage,drivetrain,notes) VALUES(?,?,?,?,?,?,?,?,?,?,?)')
->execute([$customerId, $year, $make, $model, post('new_vehicle_trim') ?: null, post('new_vehicle_vin') ?: null, post('new_vehicle_license_plate') ?: null, post('new_vehicle_color') ?: null, post('new_vehicle_mileage') !== '' ? (int)post('new_vehicle_mileage') : null, post('new_vehicle_drivetrain') ?: null, post('new_vehicle_notes') ?: null]);
return (int)db()->lastInsertId();
}
function generated_job_title(int $id, string $jobType): string
{
return 'Job #' . $id . ($jobType !== '' ? ' - ' . $jobType : '');
}
function action_set_job_status(array $user): void
{
require_role(['Admin', 'Manager']);
job_status((int)post('job_id'), post('status'), (int)$user['id'], post('reason'));
flash('Job status updated.');
redirect('job_review', ['id' => (int)post('job_id')]);
}
function action_claim_job(array $user): void
{
require_role(['Technician']);
$job = full_job((int)post('job_id'));
if (!$job || !tech_can_view($user, $job)) throw new RuntimeException('Job is not available.');
if ($job['assignment_mode'] === 'team' && setting('team_claim_enabled', '1') !== '1') throw new RuntimeException('Team claiming is disabled.');
if ($job['assignment_mode'] === 'pool' && setting('pool_claim_enabled', '1') !== '1') throw new RuntimeException('Open pool claiming is disabled.');
db()->prepare('UPDATE jobs SET assigned_technician_id=?, claimed_by=?, claimed_at=CURRENT_TIMESTAMP, status=?, updated_at=CURRENT_TIMESTAMP WHERE id=?')->execute([(int)$user['id'], (int)$user['id'], 'Accepted', (int)$job['id']]);
db()->prepare('INSERT OR IGNORE INTO job_participants(job_id, user_id) VALUES(?,?)')->execute([(int)$job['id'], (int)$user['id']]);
db()->prepare('INSERT INTO job_status_history(job_id, old_status, new_status, changed_by, reason) VALUES(?,?,?,?,?)')->execute([(int)$job['id'], $job['status'], 'Accepted', (int)$user['id'], 'Job claimed/accepted']);
flash('Job accepted.');
redirect('tech_job', ['id' => (int)$job['id']]);
}
function action_save_vehicle_info(array $user): void
{
require_role(['Technician', 'Admin', 'Manager']);
$job = full_job((int)post('job_id'));
if (!$job || !tech_can_view($user, $job)) throw new RuntimeException('Job not found.');
$vin = post('vin_value');
if ($vin !== '') {
db()->prepare('UPDATE vehicles SET vin=?, mileage=?, updated_at=CURRENT_TIMESTAMP WHERE id=?')->execute([$vin, (int)post('mileage'), (int)$job['vehicle_id']]);
} else {
db()->prepare('UPDATE vehicles SET mileage=?, updated_at=CURRENT_TIMESTAMP WHERE id=?')->execute([(int)post('mileage'), (int)$job['vehicle_id']]);
}
db()->prepare('UPDATE jobs SET vehicle_vin_confirmed=1, vehicle_plate_confirmed=1, vehicle_ymm_confirmed=1, vehicle_mileage_confirmed=1, vehicle_confirmed_by=?, vehicle_confirmed_at=CURRENT_TIMESTAMP, updated_at=CURRENT_TIMESTAMP WHERE id=?')
->execute([(int)$user['id'], (int)$job['id']]);
auto_complete_vehicle_info_items((int)$job['id'], (int)$user['id']);
flash('Vehicle info saved.');
redirect($user['role'] === 'Technician' ? 'tech_job' : 'tech_preview', ['id' => (int)$job['id'], 'step' => 'dash-lights']);
}
function action_save_dash_lights(array $user): void
{
require_role(['Technician', 'Admin', 'Manager']);
$job = full_job((int)post('job_id'));
if (!$job || !tech_can_view($user, $job)) throw new RuntimeException('Job not found.');
if (isset($_POST['dash_notify_answer'])) {
$dash = $_SESSION['pending_dash_lights'][(int)$job['id']] ?? null;
if (!$dash) throw new RuntimeException('Dash light selection expired. Select dash lights again.');
unset($_SESSION['pending_dash_lights'][(int)$job['id']]);
$dash['dash_customer_notified'] = post('dash_notify_answer') === 'yes' ? 1 : 0;
} else {
$dash = parse_dash_light_post(false);
if ($dash['dash_other'] && $dash['dash_other_note'] === '') {
throw new RuntimeException('Describe the other dash light before continuing.');
}
if ($dash['lights_present']) {
$_SESSION['pending_dash_lights'][(int)$job['id']] = $dash;
redirect($user['role'] === 'Technician' ? 'tech_job' : 'tech_preview', ['id' => (int)$job['id'], 'step' => 'dash-lights', 'notify' => 1]);
}
}
save_dash_light_state((int)$job['id'], (int)$user['id'], $dash);
auto_complete_dash_light_items((int)$job['id'], (int)$user['id']);
job_status((int)$job['id'], 'Intake Started', (int)$user['id'], 'Dash lights checked');
flash('Dash lights saved.');
redirect($user['role'] === 'Technician' ? 'tech_job' : 'tech_preview', ['id' => (int)$job['id'], 'step' => 'intake-checklist']);
}
function action_confirm_vehicle(array $user): void
{
require_role(['Technician', 'Admin', 'Manager']);
$job = full_job((int)post('job_id'));
if (!$job || !tech_can_view($user, $job)) throw new RuntimeException('Job not found.');
$dash = parse_dash_light_post();
if ($dash['dash_other'] && $dash['dash_other_note'] === '') {
throw new RuntimeException('Describe the other dash light before continuing.');
}
if ($dash['lights_present'] && !$dash['dash_customer_notified']) {
throw new RuntimeException('Notify the customer about dash lights before work begins.');
}
$vin = post('vin_value');
if ($vin !== '') {
db()->prepare('UPDATE vehicles SET vin=?, mileage=?, updated_at=CURRENT_TIMESTAMP WHERE id=?')->execute([$vin, (int)post('mileage'), (int)$job['vehicle_id']]);
} else {
db()->prepare('UPDATE vehicles SET mileage=?, updated_at=CURRENT_TIMESTAMP WHERE id=?')->execute([(int)post('mileage'), (int)$job['vehicle_id']]);
}
db()->prepare('UPDATE jobs SET vehicle_vin_confirmed=?, vehicle_plate_confirmed=?, vehicle_ymm_confirmed=?, vehicle_mileage_confirmed=?, dash_lights_checked=1, dash_no_lights=?, dash_airbag=?, dash_abs=?, dash_check_engine=?, dash_tpms=?, dash_other=?, dash_other_note=?, dash_customer_notified=?, vehicle_confirmed_by=?, vehicle_confirmed_at=CURRENT_TIMESTAMP, updated_at=CURRENT_TIMESTAMP WHERE id=?')
->execute([isset($_POST['vin']) ? 1 : 0, isset($_POST['plate']) ? 1 : 0, isset($_POST['ymm']) ? 1 : 0, isset($_POST['mileage_confirmed']) ? 1 : 0, $dash['dash_no_lights'], $dash['dash_airbag'], $dash['dash_abs'], $dash['dash_check_engine'], $dash['dash_tpms'], $dash['dash_other'], $dash['dash_other_note'], $dash['dash_customer_notified'], (int)$user['id'], (int)$job['id']]);
flag_dash_light_issue((int)$job['id'], (int)$user['id'], $dash);
auto_complete_vehicle_checklist_items((int)$job['id'], (int)$user['id']);
job_status((int)$job['id'], 'Intake Started', (int)$user['id'], 'Vehicle confirmation updated');
flash('Vehicle confirmation saved.');
redirect($user['role'] === 'Technician' ? 'tech_job' : 'tech_preview', ['id' => (int)$job['id'], 'step' => 'intake-checklist']);
}
function save_dash_light_state(int $jobId, int $userId, array $dash): void
{
db()->prepare('UPDATE jobs SET dash_lights_checked=1, dash_no_lights=?, dash_airbag=?, dash_abs=?, dash_check_engine=?, dash_tpms=?, dash_other=?, dash_other_note=?, dash_customer_notified=?, updated_at=CURRENT_TIMESTAMP WHERE id=?')
->execute([$dash['dash_no_lights'], $dash['dash_airbag'], $dash['dash_abs'], $dash['dash_check_engine'], $dash['dash_tpms'], $dash['dash_other'], $dash['dash_other_note'], $dash['dash_customer_notified'], $jobId]);
flag_dash_light_issue($jobId, $userId, $dash);
}
function parse_dash_light_post(bool $requireNotification = true): array
{
$lights = [
'dash_airbag' => isset($_POST['dash_airbag']) ? 1 : 0,
'dash_abs' => isset($_POST['dash_abs']) ? 1 : 0,
'dash_check_engine' => isset($_POST['dash_check_engine']) ? 1 : 0,
'dash_tpms' => isset($_POST['dash_tpms']) ? 1 : 0,
'dash_other' => isset($_POST['dash_other']) ? 1 : 0,
];
$present = array_sum($lights) > 0;
$noLights = isset($_POST['dash_no_lights']) && !$present ? 1 : 0;
if (!$present && !$noLights) {
throw new RuntimeException('Select no dash lights or choose which dash lights are on.');
}
return array_merge($lights, [
'dash_no_lights' => $noLights,
'dash_other_note' => post('dash_other_note'),
'dash_customer_notified' => $requireNotification && isset($_POST['dash_customer_notified']) ? 1 : 0,
'lights_present' => $present,
]);
}
function auto_complete_vehicle_info_items(int $jobId, int $userId): void
{
foreach (checklist_for_job($jobId, 'intake') as $item) {
$label = strtolower(trim($item['label']));
if (in_array($label, ['confirm vehicle identity', 'record mileage'], true)) {
db()->prepare('INSERT INTO job_checklist_responses(job_id, checklist_item_id, user_id, checked, exception_note) VALUES(?,?,?,?,?) ON CONFLICT(job_id, checklist_item_id) DO UPDATE SET user_id=excluded.user_id, checked=1, exception_note=NULL, updated_at=CURRENT_TIMESTAMP')
->execute([$jobId, (int)$item['id'], $userId, 1, null]);
}
}
}
function auto_complete_dash_light_items(int $jobId, int $userId): void
{
foreach (checklist_for_job($jobId, 'intake') as $item) {
if (strtolower(trim($item['label'])) === 'check dash lights') {
db()->prepare('INSERT INTO job_checklist_responses(job_id, checklist_item_id, user_id, checked, exception_note) VALUES(?,?,?,?,?) ON CONFLICT(job_id, checklist_item_id) DO UPDATE SET user_id=excluded.user_id, checked=1, exception_note=NULL, updated_at=CURRENT_TIMESTAMP')
->execute([$jobId, (int)$item['id'], $userId, 1, null]);
}
}
}
function flag_dash_light_issue(int $jobId, int $userId, array $dash): void
{
if (!$dash['lights_present']) {
return;
}
$note = 'Dash lights on at intake: ' . implode(', ', dash_light_labels($dash)) . '. Customer ' . ($dash['dash_customer_notified'] ? 'notified before work.' : 'not notified before work.');
$exists = row('SELECT id FROM job_issues WHERE job_id=? AND issue_type=? AND note LIKE ?', [$jobId, 'Vehicle condition issue', 'Dash lights on at intake:%']);
if ($exists) {
db()->prepare('UPDATE job_issues SET user_id=?, note=?, resolved=0 WHERE id=?')->execute([$userId, $note, (int)$exists['id']]);
} else {
db()->prepare('INSERT INTO job_issues(job_id, user_id, issue_type, note) VALUES(?,?,?,?)')->execute([$jobId, $userId, 'Vehicle condition issue', $note]);
}
}
function dash_light_labels(array $dash): array
{
$labels = [];
if ($dash['dash_airbag']) $labels[] = 'Airbag';
if ($dash['dash_abs']) $labels[] = 'ABS';
if ($dash['dash_check_engine']) $labels[] = 'Check engine';
if ($dash['dash_tpms']) $labels[] = 'TPMS';
if ($dash['dash_other']) $labels[] = 'Other' . ($dash['dash_other_note'] ? ': ' . $dash['dash_other_note'] : '');
return $labels;
}
function auto_complete_vehicle_checklist_items(int $jobId, int $userId): void
{
foreach (checklist_for_job($jobId, 'intake') as $item) {
if (!is_auto_vehicle_item($item['label'])) {
continue;
}
db()->prepare('INSERT INTO job_checklist_responses(job_id, checklist_item_id, user_id, checked, exception_note) VALUES(?,?,?,?,?) ON CONFLICT(job_id, checklist_item_id) DO UPDATE SET user_id=excluded.user_id, checked=1, exception_note=NULL, updated_at=CURRENT_TIMESTAMP')
->execute([$jobId, (int)$item['id'], $userId, 1, null]);
}
}
function is_auto_vehicle_item(string $label): bool
{
$label = strtolower(trim($label));
return in_array($label, ['confirm vehicle identity', 'record mileage', 'check dash lights', 'take required intake photos', 'final photos taken'], true);
}
function action_save_checklist_response(array $user): void
{
require_role(['Technician', 'Admin', 'Manager']);
$jobId = (int)post('job_id');
$stage = post('stage');
foreach (checklist_for_job($jobId, $stage) as $item) {
$checked = isset($_POST['item'][$item['id']]) ? 1 : 0;
$note = trim((string)($_POST['note'][$item['id']] ?? ''));
db()->prepare('INSERT INTO job_checklist_responses(job_id, checklist_item_id, user_id, checked, exception_note) VALUES(?,?,?,?,?) ON CONFLICT(job_id, checklist_item_id) DO UPDATE SET user_id=excluded.user_id, checked=excluded.checked, exception_note=excluded.exception_note, updated_at=CURRENT_TIMESTAMP')
->execute([$jobId, (int)$item['id'], (int)$user['id'], $checked, $note]);
}
if ($stage === 'intake' && checklist_complete($jobId, 'intake')) job_status($jobId, 'Intake Complete', (int)$user['id'], 'Intake checklist complete');
if ($stage === 'outtake') {
job_status($jobId, 'Outtake Started', (int)$user['id'], 'Outtake checklist updated');
if (checklist_complete($jobId, 'outtake')) job_status($jobId, 'Outtake Complete', (int)$user['id'], 'Outtake checklist complete');
}
flash(ucfirst($stage) . ' checklist saved.');
redirect($user['role'] === 'Technician' ? 'tech_job' : 'tech_preview', ['id' => $jobId, 'step' => $stage === 'intake' ? 'intake-photos' : 'outtake-photos']);
}
function action_upload_photo(array $user): void
{
require_role(['Technician', 'Admin', 'Manager']);
$job = full_job((int)post('job_id'));
if (!$job || !tech_can_view($user, $job)) throw new RuntimeException('Job not found.');
save_uploaded_photo((int)$job['id'], (int)$user['id'], post('stage'), post('requirement_key'), post('requirement_name'));
auto_complete_photo_checklist_items((int)$job['id'], (int)$user['id'], post('stage'));
if (post('stage') === 'outtake') job_status((int)$job['id'], 'Outtake Started', (int)$user['id'], 'Outtake photo uploaded');
flash('Photo saved.');
$step = photo_redirect_step((int)$job['id'], post('stage'));
redirect($user['role'] === 'Technician' ? 'tech_job' : 'tech_preview', ['id' => (int)$job['id'], 'step' => $step]);
}
function photo_redirect_step(int $jobId, string $stage): string
{
if (!photos_complete($jobId, $stage)) {
return $stage === 'intake' ? 'intake-photos' : ($stage === 'marketing' ? 'marketing' : 'outtake-photos');
}
if ($stage === 'intake') {
return 'timer';
}
if ($stage === 'marketing') {
return 'submit';
}
return setting('marketing_required', '0') === '1' && !photos_complete($jobId, 'marketing') ? 'marketing' : 'submit';
}
function auto_complete_photo_checklist_items(int $jobId, int $userId, string $stage): void
{
if (!photos_complete($jobId, $stage)) {
return;
}
foreach (checklist_for_job($jobId, $stage) as $item) {
$label = strtolower(trim($item['label']));
if (($stage === 'intake' && $label === 'take required intake photos') || ($stage === 'outtake' && $label === 'final photos taken')) {
db()->prepare('INSERT INTO job_checklist_responses(job_id, checklist_item_id, user_id, checked, exception_note) VALUES(?,?,?,?,?) ON CONFLICT(job_id, checklist_item_id) DO UPDATE SET user_id=excluded.user_id, checked=1, exception_note=NULL, updated_at=CURRENT_TIMESTAMP')
->execute([$jobId, (int)$item['id'], $userId, 1, null]);
}
}
}
function action_timer_event(array $user): void
{
require_role(['Technician', 'Admin', 'Manager']);
$job = full_job((int)post('job_id'));
$event = post('event_type');
if (!$job || !tech_can_view($user, $job)) throw new RuntimeException('Job not found.');
if ($event === 'start' && !job_ready_for_work($job)) throw new RuntimeException('Complete vehicle confirmation, intake checklist, and required intake photos before starting work.');
if ($event === 'pause' && post('pause_reason') === '') throw new RuntimeException('Pause reason is required.');
db()->prepare('INSERT INTO job_time_logs(job_id, user_id, event_type, pause_reason, note) VALUES(?,?,?,?,?)')->execute([(int)$job['id'], (int)$user['id'], $event, post('pause_reason') ?: null, post('note') ?: null]);
db()->prepare('INSERT OR IGNORE INTO job_participants(job_id, user_id) VALUES(?,?)')->execute([(int)$job['id'], (int)$user['id']]);
$map = ['start' => 'Work Started', 'pause' => 'Paused', 'resume' => 'Work Started', 'complete' => 'Work Complete'];
job_status((int)$job['id'], $map[$event], (int)$user['id'], ucfirst($event) . ' work');
flash('Timer updated.');
redirect($user['role'] === 'Technician' ? 'tech_job' : 'tech_preview', ['id' => (int)$job['id'], 'step' => $event === 'complete' ? 'outtake-checklist' : 'timer']);
}
function action_add_issue(array $user): void
{
require_role(['Technician', 'Admin', 'Manager']);
$job = full_job((int)post('job_id'));
if (!$job || !tech_can_view($user, $job)) throw new RuntimeException('Job not found.');
$type = row('SELECT * FROM issue_types WHERE name=?', [post('issue_type')]);
if ($type && (int)$type['photo_required'] && empty($_FILES['photo']['tmp_name'])) {
throw new RuntimeException('This issue type requires a photo.');
}
$photoId = null;
if (!empty($_FILES['photo']['tmp_name'])) $photoId = save_uploaded_photo((int)$job['id'], (int)$user['id'], 'issue', slug(post('issue_type')), post('issue_type'));
db()->prepare('INSERT INTO job_issues(job_id, user_id, issue_type, note, photo_id) VALUES(?,?,?,?,?)')->execute([(int)$job['id'], (int)$user['id'], post('issue_type'), post('note'), $photoId]);
flash('Issue added.');
redirect($user['role'] === 'Technician' ? 'tech_job' : 'tech_preview', ['id' => (int)$job['id'], 'step' => 'intake-photos']);
}
function action_submit_job(array $user): void
{
require_role(['Technician', 'Admin', 'Manager']);
$job = full_job((int)post('job_id'));
if (!$job || !tech_can_view($user, $job)) throw new RuntimeException('Job not found.');
if (!job_ready_to_submit($job)) throw new RuntimeException('Complete required outtake checklist and photos before submitting.');
job_status((int)$job['id'], 'Ready For Review', (int)$user['id'], 'Submitted by technician');
flash('Job submitted for review.');
redirect($user['role'] === 'Technician' ? 'tech' : 'jobs');
}
function action_save_checklist_template(array $user): void
{
require_role(['Admin', 'Manager']);
db()->prepare('INSERT INTO checklist_templates(name, stage) VALUES(?,?)')->execute([post('name'), post('stage')]);
$id = (int)db()->lastInsertId();
$items = [];
foreach (preg_split('/\R/', post('items')) as $line) {
$line = trim($line); if ($line === '') continue;
$required = substr($line, 0, 1) !== '?';
$noteLine = ltrim($line, '?');
$note = substr($noteLine, 0, 1) === '!';
$line = ltrim($line, '?! ');
$items[] = [$line, $required ? 1 : 0, $note ? 1 : 0];
}
insert_checklist_items(db(), $id, $items);
flash('Checklist template saved.');
redirect('checklists');
}
function action_save_photo_template(array $user): void
{
require_role(['Admin', 'Manager']);
db()->prepare('INSERT INTO photo_requirement_templates(name) VALUES(?)')->execute([post('name')]);
$id = (int)db()->lastInsertId();
foreach (['intake' => 1, 'outtake' => 1, 'marketing' => 0] as $stage => $required) {
$names = array_values(array_filter(array_map('trim', preg_split('/\R/', post($stage)))));
insert_photo_items(db(), $id, $stage, $names, $required);
}
flash('Photo requirement set saved.');
redirect('photosets');
}
function action_save_template(array $user): void
{
require_role(['Admin', 'Manager']);
$fields = ['template_name','make','model','year_start','year_end','trim','job_type','estimated_duration_minutes','default_team_id','intake_checklist_template_id','outtake_checklist_template_id','photo_requirement_template_id','common_internal_notes','common_parts_notes','active'];
$values = [post('template_name'),post('make'),post('model'),(int)post('year_start'),(int)post('year_end'),post('trim'),post('job_type'),(int)post('estimated_duration_minutes'),post('default_team_id') ?: null,(int)post('intake_checklist_template_id'),(int)post('outtake_checklist_template_id'),(int)post('photo_requirement_template_id'),post('common_internal_notes'),post('common_parts_notes'),isset($_POST['active']) ? 1 : 0];
$id = (int)post('id');
if ($id) db()->prepare('UPDATE job_templates SET ' . implode('=?, ', $fields) . '=?, updated_at=CURRENT_TIMESTAMP WHERE id=?')->execute([...$values, $id]);
else db()->prepare('INSERT INTO job_templates(' . implode(',', $fields) . ') VALUES(' . implode(',', array_fill(0, count($fields), '?')) . ')')->execute($values);
flash('Job template saved.');
redirect('templates');
}
function action_save_settings(array $user): void
{
require_role(['Admin']);
foreach (['shop_name','logo_text','timezone','workday_start','workday_end','upload_max_mb','job_types','job_buffer_minutes'] as $k) db()->prepare('INSERT INTO settings(key,value) VALUES(?,?) ON CONFLICT(key) DO UPDATE SET value=excluded.value')->execute([$k, post($k)]);
foreach (['marketing_required','team_claim_enabled','pool_claim_enabled'] as $k) db()->prepare('INSERT INTO settings(key,value) VALUES(?,?) ON CONFLICT(key) DO UPDATE SET value=excluded.value')->execute([$k, isset($_POST[$k]) ? '1' : '0']);
flash('Settings saved.');
redirect('settings');
}
function route_avail(): void
{
header('Content-Type: application/json');
$techId = (int)($_GET['tech_id'] ?? 0);
$duration = max(15, (int)($_GET['duration_minutes'] ?? 60));
$requestedDate = $_GET['date'] ?? '';
$wStart = setting('workday_start', '08:00');
$wEnd = setting('workday_end', '17:00');
$buffer = (int)setting('job_buffer_minutes', '15');
$wsMin = time_to_minutes($wStart);
$weMin = time_to_minutes($wEnd);
$today = date('Y-m-d');
$slots = [];
$next = null;
$daysToScan = $requestedDate ? 1 : 14;
$startDate = $requestedDate ?: $today;
for ($di = 0; $di < $daysToScan; $di++) {
$d = date('Y-m-d', strtotime($startDate . " +{$di} day"));
$booked = [];
if ($techId > 0) {
$jobs = query("SELECT scheduled_start_time, estimated_duration_minutes FROM jobs WHERE assigned_technician_id=? AND scheduled_date=? AND status NOT IN ('Completed','Cancelled') AND scheduled_start_time IS NOT NULL AND scheduled_start_time != ''", [$techId, $d]);
foreach ($jobs as $j) {
$jStart = time_to_minutes((string)$j['scheduled_start_time']);
$jDur = max(15, (int)($j['estimated_duration_minutes'] ?? 60));
$booked[] = [$jStart, $jStart + $jDur + $buffer];
}
}
usort($booked, fn($a, $b) => $a[0] - $b[0]);
$daySlots = [];
$cursor = $wsMin;
while ($cursor + $duration <= $weMin) {
$conflict = false;
foreach ($booked as $b) {
if ($cursor < $b[1] && $cursor + $duration > $b[0]) {
$conflict = true;
$cursor = $b[1];
break;
}
}
if (!$conflict) {
$label = ($d === $today ? 'Today' : date('D M j', strtotime($d))) . ' ' . minutes_to_time($cursor);
$daySlots[] = ['date' => $d, 'time' => minutes_to_time($cursor), 'label' => $label];
if (!$next) $next = end($daySlots);
$cursor += $duration + $buffer;
if (count($daySlots) >= 6 && $requestedDate) break;
}
}
if ($requestedDate) { $slots = $daySlots; break; }
if (!$next && $daySlots) $next = $daySlots[0];
if ($next && !$requestedDate && count($slots) === 0) $slots = $daySlots;
}
echo json_encode(['slots' => $slots, 'next' => $next]);
exit;
}
function time_to_minutes(string $t): int
{
$parts = explode(':', $t);
return (int)($parts[0] ?? 0) * 60 + (int)($parts[1] ?? 0);
}
function minutes_to_time(int $m): string
{
return str_pad((string)floor($m / 60), 2, '0', STR_PAD_LEFT) . ':' . str_pad((string)($m % 60), 2, '0', STR_PAD_LEFT);
}
function action_override_job(array $user): void
{
require_role(['Admin', 'Manager']);
db()->prepare('UPDATE jobs SET override_reason=?, updated_at=CURRENT_TIMESTAMP WHERE id=?')->execute([post('reason'), (int)post('job_id')]);
db()->prepare('INSERT INTO job_status_history(job_id, old_status, new_status, changed_by, reason) SELECT id, status, status, ?, ? FROM jobs WHERE id=?')->execute([(int)$user['id'], 'Validation override: ' . post('reason'), (int)post('job_id')]);
flash('Override logged.');
redirect('job_review', ['id' => (int)post('job_id')]);
}
function scalar(string $sql, array $params = []): int
{
$stmt = db()->prepare($sql); $stmt->execute($params); return (int)$stmt->fetchColumn();
}
function count_over_estimate_jobs(): int
{
$count = 0;
foreach (query("SELECT id, estimated_duration_minutes FROM jobs WHERE status NOT IN ('Scheduled','Assigned','Available','Cancelled')") as $job) {
if (active_time_seconds((int)$job['id']) > ((int)$job['estimated_duration_minutes'] * 60)) {
$count++;
}
}
return $count;
}
function count_jobs_missing_photos(): int
{
$ids = [];
foreach (jobs_with_missing_requirements() as $gap) {
if (stripos($gap['gap'], 'photo') !== false) {
$ids[(int)$gap['id']] = true;
}
}
return count($ids);
}
function query(string $sql, array $params = []): array
{
$stmt = db()->prepare($sql); $stmt->execute($params); return $stmt->fetchAll();
}
function row(string $sql, array $params = []): ?array
{
$rows = query($sql, $params); return $rows[0] ?? null;
}
function empty_state(string $title, string $copy, string $href, string $action): void
{
echo '';
}
function selected($a, $b): string { return (string)$a === (string)$b ? ' selected' : ''; }
function checked($a, $b): string { return (string)$a === (string)$b ? ' checked' : ''; }
function field(string $name, string $label, ?string $value = '', bool $required = false, string $type = 'text'): void
{
echo '' . e($label) . ' ';
}
function save_table(string $table, array $fields, string $redirect): void
{
$id = (int)post('id');
$values = array_map(fn($f) => post($f) === '' ? null : post($f), $fields);
if ($id) {
db()->prepare("UPDATE {$table} SET " . implode('=?, ', $fields) . '=?, updated_at=CURRENT_TIMESTAMP WHERE id=?')->execute([...$values, $id]);
} else {
db()->prepare("INSERT INTO {$table}(" . implode(',', $fields) . ') VALUES(' . implode(',', array_fill(0, count($fields), '?')) . ')')->execute($values);
}
flash(ucfirst(rtrim($table, 's')) . ' saved.');
redirect($redirect);
}
function vehicle_label(array $v): string
{
return trim((string)$v['year'] . ' ' . $v['make'] . ' ' . $v['model'] . ' ' . ($v['trim'] ?? ''));
}
function job_statuses(): array
{
return ['Scheduled','Available','Assigned','Accepted','Intake Started','Intake Complete','Work Started','Paused','Work Complete','Outtake Started','Outtake Complete','Ready For Review','Ready For Pickup','Completed','Cancelled'];
}
function full_job(int $id): ?array
{
return row("SELECT j.*, c.first_name, c.last_name, c.company_name, c.phone, c.email, v.year, v.make, v.model, v.trim, v.vin, v.license_plate, v.color, v.mileage, v.drivetrain FROM jobs j JOIN customers c ON c.id=j.customer_id JOIN vehicles v ON v.id=j.vehicle_id WHERE j.id=?", [$id]);
}
function job_table(array $jobs, bool $actions = false): void
{
if (!$jobs) { echo 'No jobs found.
'; return; }
echo '';
foreach ($jobs as $j) {
mobile_job_card($j, $actions);
}
echo '
';
echo 'Job Vehicle Scheduled Status Estimate / Actual ' . ($actions ? 'Actions ' : '') . ' ';
foreach ($jobs as $j) {
$assigned = ($j['tech_name'] ?? '') ?: (($j['team_name'] ?? '') ?: 'Unassigned');
echo '' . e($j['job_title']) . ' ' . e($j['first_name'].' '.$j['last_name']) . ' | ' . e($assigned) . ' ' . e(short_vehicle_label($j)) . ' ' . e(compact_datetime($j['scheduled_date'] ?? '', $j['scheduled_start_time'] ?? '')) . ' ' . status_badge($j['status'], true) . ' ' . (int)$j['estimated_duration_minutes'] . 'm / ' . fmt_minutes(active_time_seconds((int)$j['id'])) . ' ';
if ($actions) echo '' . primary_job_action($j) . 'Edit ';
echo ' ';
}
echo '
';
}
function mobile_job_card(array $job, bool $actions): void
{
$assigned = ($job['tech_name'] ?? '') ?: (($job['team_name'] ?? '') ?: 'Unassigned');
$actual = fmt_minutes(active_time_seconds((int)$job['id']));
echo '';
echo '' . e($job['job_title']) . ' ' . e($job['first_name'] . ' ' . $job['last_name']) . '
' . status_badge($job['status'], true) . '
';
echo 'Vehicle ' . e(short_vehicle_label($job)) . '
';
echo 'Scheduled ' . e(compact_datetime($job['scheduled_date'] ?? '', $job['scheduled_start_time'] ?? '')) . '
';
echo 'Assigned ' . e($assigned) . '
';
echo 'Time ' . (int)$job['estimated_duration_minutes'] . 'm est / ' . e($actual) . ' actual
';
if ($actions) {
echo '' . primary_job_action($job) . '
Edit ';
}
echo ' ';
}
function primary_job_action(array $job): string
{
$label = in_array($job['status'], ['Ready For Review','Ready For Pickup'], true) ? 'Review' : ($job['status'] === 'Available' ? 'Assign' : 'Open');
return '' . e($label) . ' ';
}
function dashboard_job_cards(array $jobs): void
{
echo '';
foreach ($jobs as $job) {
$actual = fmt_minutes(active_time_seconds((int)$job['id']));
$assigned = $job['tech_name'] ?: ($job['team_name'] ?: 'Unassigned');
echo '
';
echo '#' . (int)$job['id'] . ' ' . e(short_job_title($job['job_title'])) . ' ' . e(short_vehicle_label($job)) . ' | ' . e($job['first_name'] . ' ' . $job['last_name']) . '
' . status_badge($job['status'], true) . '
';
echo '' . e(compact_datetime($job['scheduled_date'] ?? '', $job['scheduled_start_time'] ?? '')) . ' Tech: ' . e($assigned) . ' Time: ' . (int)$job['estimated_duration_minutes'] . 'm / ' . e($actual) . '
';
echo workflow_mini((int)$job['id'], $job['status']);
echo '';
echo ' ';
}
echo '
';
}
function schedule_job_card(array $job): string
{
$assigned = ($job['tech_name'] ?? '') ?: (($job['team_name'] ?? '') ?: 'Unassigned');
$actual = fmt_minutes(active_time_seconds((int)$job['id']));
return '' . e(date('g:i A', strtotime($job['scheduled_start_time'] ?: '00:00')) . ' ' . short_job_title($job['job_title'])) . ' ' . status_badge($job['status'], true) . '
' . e(short_vehicle_label($job)) . ' ' . e($assigned) . ' | ' . (int)$job['estimated_duration_minutes'] . 'm / ' . e($actual) . ' ';
}
function short_job_title(string $title): string
{
return str_replace([' Install', ' Check'], ['', ''], $title);
}
function short_vehicle_label(array $job): string
{
return trim((string)$job['year'] . ' ' . $job['make'] . ' ' . $job['model']);
}
function compact_datetime(?string $date, ?string $time): string
{
if (!$date) return 'Unscheduled';
$ts = strtotime(trim($date . ' ' . ($time ?: '00:00')));
if (!$ts) return trim($date . ' ' . (string)$time);
if ($date === date('Y-m-d')) return 'Today, ' . date('g:i A', $ts);
if ($date === date('Y-m-d', strtotime('+1 day'))) return 'Tomorrow, ' . date('g:i A', $ts);
return date('M j, g:i A', $ts);
}
function workflow_mini(int $jobId, string $status): string
{
$steps = [
'Intake' => checklist_complete($jobId, 'intake'),
'Photos' => photos_complete($jobId, 'intake'),
'Work' => in_array($status, ['Work Complete','Outtake Started','Outtake Complete','Ready For Review','Ready For Pickup','Completed'], true),
'Outtake' => checklist_complete($jobId, 'outtake') && photos_complete($jobId, 'outtake'),
'Review' => in_array($status, ['Ready For Review','Ready For Pickup','Completed'], true),
];
$html = '';
foreach ($steps as $label => $done) {
$html .= '' . e($label) . ' ';
}
return $html . '
';
}
function grouped_proof_gaps(array $gaps): array
{
$out = [];
foreach ($gaps as $gap) {
$id = (int)$gap['id'];
if (!isset($out[$id])) {
$out[$id] = ['title' => $gap['job_title'], 'gaps' => []];
}
$out[$id]['gaps'][] = str_replace([' missing required items', ' required photos missing'], ['', ' photos'], $gap['gap']);
}
return $out;
}
function status_badge(string $status, bool $short = false): string
{
return '' . e($short ? short_status($status) : $status) . ' ';
}
function short_status(string $status): string
{
$map = ['Ready For Review' => 'Review', 'Ready For Pickup' => 'Pickup', 'Work Started' => 'Work', 'Intake Started' => 'Intake', 'Outtake Started' => 'Outtake'];
return $map[$status] ?? $status;
}
function status_class(string $status): string
{
$map = [
'Scheduled' => 'badge-scheduled',
'Assigned' => 'badge-assigned',
'Available' => 'badge-assigned',
'Accepted' => 'badge-active',
'Intake Started' => 'badge-alert',
'Intake Complete' => 'badge-active',
'Work Started' => 'badge-red',
'Paused' => 'badge-warn',
'Work Complete' => 'badge-active',
'Outtake Started' => 'badge-alert',
'Outtake Complete' => 'badge-active',
'Ready For Review' => 'badge-review',
'Ready For Pickup' => 'badge-review',
'Completed' => 'badge-complete',
'Cancelled' => 'badge-cancelled',
];
return $map[$status] ?? 'badge-scheduled';
}
function tech_jobs(array $user, string $type): array
{
if ($type === 'my') {
return query("SELECT j.*, c.first_name, c.last_name, v.year, v.make, v.model FROM jobs j JOIN customers c ON c.id=j.customer_id JOIN vehicles v ON v.id=j.vehicle_id WHERE j.assigned_technician_id=? AND j.status NOT IN ('Completed','Cancelled') ORDER BY j.scheduled_date, j.scheduled_start_time", [(int)$user['id']]);
}
if ($type === 'team') {
return query("SELECT DISTINCT j.*, c.first_name, c.last_name, v.year, v.make, v.model FROM jobs j JOIN customers c ON c.id=j.customer_id JOIN vehicles v ON v.id=j.vehicle_id JOIN team_members tm ON tm.team_id=j.assigned_team_id WHERE tm.user_id=? AND j.assignment_mode='team' AND j.status NOT IN ('Completed','Cancelled','Ready For Review') ORDER BY j.scheduled_date, j.scheduled_start_time", [(int)$user['id']]);
}
return query("SELECT j.*, c.first_name, c.last_name, v.year, v.make, v.model FROM jobs j JOIN customers c ON c.id=j.customer_id JOIN vehicles v ON v.id=j.vehicle_id WHERE j.assignment_mode='pool' AND j.status IN ('Available','Scheduled') ORDER BY j.scheduled_date, j.scheduled_start_time");
}
function tech_can_view(array $user, array $job): bool
{
if (has_role($user, ['Admin', 'Manager'])) return true;
if ((int)$job['assigned_technician_id'] === (int)$user['id'] || (int)$job['claimed_by'] === (int)$user['id']) return true;
if ($job['assignment_mode'] === 'pool' && in_array($job['status'], ['Available','Scheduled'], true)) return true;
if ($job['assignment_mode'] === 'team') return scalar('SELECT COUNT(*) FROM team_members WHERE team_id=? AND user_id=?', [(int)$job['assigned_team_id'], (int)$user['id']]) > 0;
return false;
}
function tech_job_cta_label(string $status): string
{
$map = [
'Available' => 'ACCEPT JOB',
'Accepted' => 'START INTAKE',
'Intake Started' => 'CONTINUE INTAKE',
'Intake Complete' => 'START WORK',
'Work Started' => 'CONTINUE WORK',
'Work Complete' => 'START OUTTAKE',
'Outtake Started' => 'CONTINUE OUTTAKE',
'Outtake Complete' => 'SUBMIT JOB',
'Ready For Review' => 'SUBMIT JOB',
];
return $map[$status] ?? 'VIEW JOB';
}
function tech_section(string $id, string $title, array $jobs, array $user): void
{
echo '';
}
function job_summary(array $job): void
{
echo '#' . (int)$job['id'] . ' ' . e($job['job_title']) . ' ' . e($job['first_name'].' '.$job['last_name']) . ' - ' . e(vehicle_label($job)) . '
VIN: ' . e($job['vin']) . ' | Plate: ' . e($job['license_plate']) . ' | Color: ' . e($job['color']) . '
' . dash_light_summary($job) . '
' . status_badge($job['status']) . '
' . e($job['job_type']) . '
Scheduled: ' . e($job['scheduled_date'].' '.$job['scheduled_start_time']) . '
';
}
function dash_light_summary(array $job): string
{
if (!(int)($job['dash_lights_checked'] ?? 0)) {
return 'Dash lights not checked
';
}
if (!dash_lights_present($job)) {
return 'No dash lights on at intake
';
}
$labels = dash_light_labels([
'dash_airbag' => (int)($job['dash_airbag'] ?? 0),
'dash_abs' => (int)($job['dash_abs'] ?? 0),
'dash_check_engine' => (int)($job['dash_check_engine'] ?? 0),
'dash_tpms' => (int)($job['dash_tpms'] ?? 0),
'dash_other' => (int)($job['dash_other'] ?? 0),
'dash_other_note' => (string)($job['dash_other_note'] ?? ''),
]);
return 'Dash lights on: ' . e(implode(', ', $labels)) . ((int)($job['dash_customer_notified'] ?? 0) ? ' | Customer notified' : ' | Customer not notified') . '
';
}
function tech_progress(array $job): void
{
$steps = [
'Vehicle' => vehicle_confirmed($job),
'Intake Tasks' => checklist_complete((int)$job['id'], 'intake'),
'Intake Photos' => photos_complete((int)$job['id'], 'intake'),
'Work' => scalar('SELECT COUNT(*) FROM job_time_logs WHERE job_id=? AND event_type="complete"', [(int)$job['id']]) > 0,
'Outtake Tasks' => checklist_complete((int)$job['id'], 'outtake'),
'Final Photos' => photos_complete((int)$job['id'], 'outtake'),
'Submit' => $job['status'] === 'Ready For Review',
];
echo '';
foreach ($steps as $label => $done) echo '' . e($label) . ' ';
echo '
';
}
function lifecycle_bar(string $currentStatus): void
{
$statuses = ['Scheduled','Assigned','Accepted','Intake Started','Intake Complete','Work Started','Paused','Work Complete','Outtake Complete','Ready For Review','Completed'];
$currentIndex = array_search($currentStatus, $statuses, true);
if ($currentIndex === false && $currentStatus === 'Available') $currentIndex = 0;
echo '';
foreach ($statuses as $i => $status) {
$class = $currentStatus === 'Cancelled' ? 'cancelled' : ($i < (int)$currentIndex ? 'done' : ($i === (int)$currentIndex ? 'active' : ''));
echo '' . e($status) . ' ';
}
echo '
';
}
function compact_status_progress(string $currentStatus): void
{
$statuses = ['Scheduled','Assigned','Accepted','Intake Started','Intake Complete','Work Started','Paused','Work Complete','Outtake Complete','Ready For Review','Completed'];
$currentIndex = array_search($currentStatus, $statuses, true);
if ($currentIndex === false && $currentStatus === 'Available') $currentIndex = 0;
$currentIndex = $currentIndex === false ? 0 : (int)$currentIndex;
$next = $statuses[$currentIndex + 1] ?? null;
echo '' . status_badge($currentStatus) . '' . ($next ? 'Next: ' . e($next) : 'No next step') . '
';
foreach ($statuses as $i => $status) {
$class = $i < $currentIndex ? 'done' : ($i === $currentIndex ? 'active' : '');
echo ' ';
}
echo '
';
}
function next_tech_step(array $job, array $user): string
{
if (!vehicle_info_complete($job)) return 'confirm';
if (!dash_lights_clear_to_start($job)) return 'dash-lights';
if (!checklist_complete((int)$job['id'], 'intake')) return 'intake-checklist';
if (!photos_complete((int)$job['id'], 'intake')) return 'intake-photos';
if (scalar('SELECT COUNT(*) FROM job_time_logs WHERE job_id=? AND user_id=? AND event_type="complete"', [(int)$job['id'], (int)$user['id']]) === 0) return 'timer';
if (!checklist_complete((int)$job['id'], 'outtake')) return 'outtake-checklist';
if (!photos_complete((int)$job['id'], 'outtake')) return 'outtake-photos';
if (setting('marketing_required', '0') === '1' && !photos_complete((int)$job['id'], 'marketing')) return 'marketing';
return 'submit';
}
function vehicle_info_complete(array $job): bool
{
return (int)$job['vehicle_plate_confirmed'] && (int)$job['vehicle_ymm_confirmed'] && (int)$job['vehicle_mileage_confirmed'];
}
function tech_step_banner(array $job, string $active): void
{
$steps = ['confirm' => 'Vehicle Info', 'dash-lights' => 'Dash Lights', 'intake-checklist' => 'Intake Tasks', 'intake-photos' => 'Intake Photos', 'timer' => 'Work Timer', 'outtake-checklist' => 'Outtake Tasks', 'outtake-photos' => 'Final Photos', 'marketing' => 'Marketing / Issues', 'submit' => 'Submit'];
$keys = array_keys($steps);
$index = array_search($active, $keys, true);
$index = $index === false ? 0 : $index;
echo 'Step ' . ($index + 1) . ' of ' . count($steps) . ' ' . e($steps[$active] ?? 'Vehicle Info') . '
';
}
function tech_step_nav(array $job, string $active): void
{
$steps = [
'confirm' => ['Vehicle Info', vehicle_confirmed($job)],
'intake-checklist' => ['Intake Tasks', checklist_complete((int)$job['id'], 'intake')],
'intake-photos' => ['Intake Photos', photos_complete((int)$job['id'], 'intake')],
'timer' => ['Track Time', scalar('SELECT COUNT(*) FROM job_time_logs WHERE job_id=? AND event_type="complete"', [(int)$job['id']]) > 0],
'outtake-checklist' => ['Outtake Tasks', checklist_complete((int)$job['id'], 'outtake')],
'outtake-photos' => ['Final Photos', photos_complete((int)$job['id'], 'outtake')],
'marketing' => ['Marketing / Issues', true],
'submit' => ['Submit', $job['status'] === 'Ready For Review'],
];
echo '';
foreach ($steps as $key => $step) {
$class = trim(($key === $active ? 'active ' : '') . ($step[1] ? 'done' : ''));
echo '' . e($step[0]) . ' ';
}
echo ' ';
}
function vehicle_info_panel(array $job): void
{
echo 'Vehicle Info Confirm the vehicle and enter mileage. VIN is optional.
' . csrf_field() . ' ';
echo '' . e(vehicle_label($job)) . '
';
echo 'Next: Dash Lights Report Mismatch ';
}
function dash_lights_panel(array $job): void
{
echo 'Dash Lights Select the dash condition. If warning lights are on, V.I.T.A.L. will ask whether the customer was alerted before moving on.
' . csrf_field() . ' ';
echo 'Next ';
if (isset($_GET['notify']) && !empty($_SESSION['pending_dash_lights'][(int)$job['id']])) {
$labels = dash_light_labels($_SESSION['pending_dash_lights'][(int)$job['id']]);
echo '' . csrf_field() . 'Alert Customer? Dash warning lights are on: ' . e(implode(', ', $labels)) . '
Did you alert the customer before work begins?
Yes, Customer Alerted No, Just Log It
';
}
echo ' ';
}
function submit_panel(array $job): void
{
$ready = job_ready_to_submit($job);
echo 'Submit Job Submit sends the proof record, photos, checklist, and labor time to manager review.
';
if (!$ready) {
echo 'Required outtake checklist items or photos are still missing.
';
}
echo '' . csrf_field() . 'Submit Job ';
}
function tech_checklist_panel(array $job, string $stage, array $user): void
{
$title = $stage === 'intake' ? 'Intake Tasks' : 'Outtake Tasks';
echo '';
}
function tech_photos_panel(array $job, string $stage, array $user): void
{
$requirements = requirements_for_job((int)$job['id'], $stage);
$required = array_values(array_filter($requirements, function ($req) {
return (int)$req['is_required'] === 1;
}));
$next = null;
foreach ($required as $req) {
$photo = last_photo((int)$job['id'], $stage, $req['requirement_key']);
if (!$photo) {
$next = $req;
break;
}
}
$completeCount = count($required) - count(array_filter($required, function ($req) use ($job, $stage) {
return !last_photo((int)$job['id'], $stage, $req['requirement_key']);
}));
echo '' . e(ucfirst($stage)) . ' Photos One photo at a time. Take the photo shown, submit it, then the next required photo opens.
';
if ($next) {
echo '' . ($completeCount + 1) . ' of ' . count($required) . '
';
photo_prompt_form($job, $stage, $next, 'Take Photo');
} else {
$nextStep = photo_redirect_step((int)$job['id'], $stage);
echo 'Required photos complete. All required ' . e($stage) . ' proof photos are saved. Continue ';
}
echo '';
foreach ($requirements as $req) {
$photo = last_photo((int)$job['id'], $stage, $req['requirement_key']);
$isRequired = (int)$req['is_required'] === 1;
echo '
' . e($req['name']) . ' ' . ($photo ? 'Done' : ($isRequired ? 'Next Up' : 'Optional')) . ' ';
if ($photo) {
echo 'Retake ';
photo_prompt_form($job, $stage, $req, 'Retake Photo');
echo ' ';
}
echo '
';
}
echo '
';
}
function photo_prompt_form(array $job, string $stage, array $req, string $button): void
{
echo '' . csrf_field() . 'Photo Needed ' . e($req['name']) . '
' . e($button) . ' ';
}
function timer_panel(array $job, array $user): void
{
$last = row('SELECT * FROM job_time_logs WHERE job_id=? AND user_id=? ORDER BY datetime(created_at) DESC, id DESC LIMIT 1', [$job['id'], $user['id']]);
$state = $last['event_type'] ?? 'none';
echo 'Work Timer ' . fmt_minutes(active_time_seconds((int)$job['id'], (int)$user['id'])) . '
';
echo '' . csrf_field() . ' ';
if (in_array($state, ['none','complete'], true)) echo 'Start Work ';
if (in_array($state, ['start','resume'], true)) {
echo 'Pause Reason';
foreach (query('SELECT * FROM pause_reasons WHERE active=1 ORDER BY name') as $r) echo '' . e($r['name']) . ' ';
echo ' Pause Job Complete Work ';
}
if ($state === 'pause') echo 'Resume Job ';
echo ' ';
}
function issue_panel(array $job, array $user): void
{
echo '';
}
function last_photo(int $jobId, string $stage, string $key): ?array
{
return row('SELECT * FROM job_photos WHERE job_id=? AND stage=? AND requirement_key=? ORDER BY created_at DESC, id DESC LIMIT 1', [$jobId, $stage, $key]);
}
function thumb(?array $photo): string
{
if (!$photo) return 'No photo
';
return ' ';
}
function checklist_review(int $jobId, string $stage): void
{
echo '' . e(ucfirst($stage)) . ' ';
foreach (checklist_for_job($jobId, $stage) as $item) {
$resp = row('SELECT * FROM job_checklist_responses WHERE job_id=? AND checklist_item_id=?', [$jobId, $item['id']]);
echo '' . (((int)($resp['checked'] ?? 0) === 1) ? 'Done' : 'Missing') . ': ' . e($item['label']) . ' ' . e($resp['exception_note'] ?? '') . '
';
}
}
function template_selects(?array $edit): string
{
ob_start();
echo 'Intake Checklist';
foreach (query("SELECT * FROM checklist_templates WHERE stage='intake' AND active=1") as $tpl) echo '' . e($tpl['name']) . ' ';
echo ' Outtake Checklist';
foreach (query("SELECT * FROM checklist_templates WHERE stage='outtake' AND active=1") as $tpl) echo '' . e($tpl['name']) . ' ';
echo ' Photo Set';
foreach (query('SELECT * FROM photo_requirement_templates WHERE active=1') as $tpl) echo '' . e($tpl['name']) . ' ';
echo '
';
return ob_get_clean();
}
function jobs_with_missing_requirements(): array
{
$out = [];
foreach (query("SELECT * FROM jobs WHERE status NOT IN ('Scheduled','Available','Assigned','Accepted','Cancelled') ORDER BY updated_at DESC LIMIT 100") as $job) {
foreach (['intake','outtake'] as $stage) {
if (!checklist_complete((int)$job['id'], $stage)) $out[] = ['id' => $job['id'], 'job_title' => $job['job_title'], 'gap' => ucfirst($stage) . ' checklist missing required items'];
if (!photos_complete((int)$job['id'], $stage)) $out[] = ['id' => $job['id'], 'job_title' => $job['job_title'], 'gap' => ucfirst($stage) . ' required photos missing'];
}
}
return $out;
}