?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 '
' . csrf_field() . '
' . logo_markup('login-logo') . 'V.I.T.A.L.

Vehicle Intake, Time, And Labor

'; if (!empty($error)) echo '
' . e($error) . '
'; 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 '
'; foreach ($cards as $card) echo '' . e((string)$card[1]) . '' . e($card[0]) . '' . e($card[2]) . ''; echo '
'; echo '
Shop Command CenterAction queues for today, review, proof gaps, and blocked work.
Create JobReview QueueOpen Schedule
'; 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 guideAdmins manage everything. Managers schedule and review work. Technicians only see bay workflow screens.
Manage Teams
'; echo '

' . ($edit ? 'Edit User' : 'Create User') . '

' . csrf_field() . ''; echo '
Identity
Access

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 ''; foreach ($users as $u) echo ''; echo '
NameEmailRoleStatusLast Login
' . e($u['name']) . '' . e($u['email']) . '' . e($u['role']) . '' . ((int)$u['active'] ? 'Active' : 'Inactive') . '' . e($u['last_login_at'] ?: 'Never') . 'Edit / Reset
'; 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') . '

' . csrf_field() . ''; echo '

Technicians

'; foreach ($techs as $t) echo ''; echo '

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 ''; foreach ($teams as $team) echo ''; echo '
NameManagerStatus
' . e($team['name']) . '' . e($team['manager']) . '' . ((int)$team['archived'] ? 'Archived' : 'Active') . 'Edit
'; 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 DatabaseFind customers, vehicles, and job history fast.
Add New Customer
'; echo ''; if ($showForm) { echo '

' . ($edit ? 'Edit Customer' : 'Add New Customer') . '

'; echo '
' . csrf_field() . '
Contact
'; foreach (['first_name'=>'First Name','last_name'=>'Last Name','company_name'=>'Company','phone'=>'Phone','email'=>'Email'] as $k => $label) echo ''; echo '
Address
'; foreach (['address'=>'Address','city'=>'City','state'=>'State','zip'=>'ZIP'] as $k => $label) echo ''; echo '
Notes
Cancel
'; } 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

'; foreach ($custVehicles as $v) { $vJobs = array_filter($custJobs, fn($j) => (int)$j['vehicle_id'] === (int)$v['id']); echo '' . e(vehicle_label($v)) . '' . e(($v['vin'] ? $v['vin'] . ' | ' : '') . ($v['license_plate'] ?: '')) . '' . count($vJobs) . ' job' . (count($vJobs) !== 1 ? 's' : '') . ''; } echo '
'; } // Job history if ($custJobs) { echo '

Job History

'; foreach ($custJobs as $j) { $statusClass = in_array($j['status'], ['Completed'], true) ? 'ok' : (in_array($j['status'], ['Cancelled'], true) ? 'muted' : 'active'); echo ''; echo '
' . e($j['job_title']) . '' . e($j['year'].' '.$j['make'].' '.$j['model'].($j['trim'] ? ' '.$j['trim'] : '')) . '
'; echo '
' . e($j['status']) . '' . e($j['scheduled_date'] ? date('M j, Y', strtotime($j['scheduled_date'])) : 'Unscheduled') . '
'; echo '
'; } echo '
'; } 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 '
'; foreach ($customers as $c) echo ''; echo '
CustomerContactVehiclesOpen JobsLast ActivityActions
' . 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') . 'ViewAdd VehicleEdit
'; } 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 DatabaseFind vehicles by customer, VIN, plate, or model.
Add New Vehicle
'; echo ''; if ($showForm) { echo '

' . ($edit ? 'Edit Vehicle' : 'Add New Vehicle') . '

' . csrf_field() . '
Owner'; echo '
Vehicle Identity
'; foreach (['year'=>'Year','make'=>'Make','model'=>'Model','trim'=>'Trim','vin'=>'VIN','license_plate'=>'License Plate','color'=>'Color','mileage'=>'Mileage','drivetrain'=>'Drivetrain'] as $k => $label) echo ''; echo '
Notes
Cancel
'; } 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

'; foreach ($history as $job) echo '' . e($job['job_title']) . '' . e(compact_datetime($job['scheduled_date'], $job['scheduled_start_time']) . ' | ' . $job['status']) . 'Open'; echo '
'; } 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 '
'; foreach ($vehicles as $v) echo ''; echo '
VehicleCustomerVIN / PlateMileageOpen JobsLast ActivityActions
' . 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') . 'ViewCreate JobEdit
'; } 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 CenterSearch, filter, assign, and review shop work.
Create Job
'; echo '
Clear
'; $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 '
' . e($m['template_name']) . 'Estimated ' . (int)$m['estimated_duration_minutes'] . ' minutesUse TemplateUse And Modify
'; 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 '
'; echo '
'; echo '
'; // ── New Customer modal ───────────────────────────────────────────────── 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 ''; echo '
'; echo '
'; // ── New Vehicle modal ────────────────────────────────────────────────── echo ''; echo '
3. Job Type'; echo ''; echo '
4. Estimated Duration'; echo ''; echo '
5. Technician'; echo ''; echo ''; echo ''; echo '
6. Schedule'; $scheduledDate = $job['scheduled_date'] ?? ''; $scheduledTime = $job['scheduled_start_time'] ?? setting('workday_start', '08:00'); echo ''; echo ''; echo ''; echo '
Proof Requirements
'; echo '
Notes
'; echo '
'; if ($job) echo 'Review Job'; 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 '
Print Proof PacketEdit JobBack To Jobs
'; $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'] . 'mEstimated
' . ($variance >= 0 ? '+' : '') . $variance . 'mVariance
' . 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 '

Admin Actions

' . csrf_field() . '
' . csrf_field() . '
'; 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 BoardWeek view for scheduled work and open pool jobs.
Quick Create Job
'; echo '
Prev WeekTodayNext Week
'; echo '
'; 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 '
My JobsTeam JobsOpen Jobs
'; 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 '
Viewing as technician' . e($user['name']) . 'Return To Admin
'; 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() . '
'; layout_footer(); return; } elseif ((int)$job['assigned_technician_id'] === (int)$user['id'] && $job['status'] === 'Assigned') { echo '
' . csrf_field() . '
'; 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 '

Create Checklist Template

Build reusable required steps for intake or outtake. Prefix optional items with ? and issue-note items with !.

' . csrf_field() . '

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 '

Create Photo Requirement Set

These labels become technician photo cards with TAKE PHOTO and RETAKE PHOTO actions.

' . csrf_field() . '

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 '
'; field('estimated_duration_minutes','Estimated Minutes',(string)($edit['estimated_duration_minutes'] ?? 120),true,'number'); echo ''; echo template_selects($edit); echo '

Template Library

'; echo ''; foreach (query('SELECT * FROM job_templates ORDER BY active DESC, make, model, job_type') as $tpl) echo ''; echo '
TemplateVehicleTypeEstimate
' . e($tpl['template_name']) . '' . e($tpl['year_start'].'-'.$tpl['year_end'].' '.$tpl['make'].' '.$tpl['model']) . '' . e($tpl['job_type']) . '' . (int)$tpl['estimated_duration_minutes'] . 'mEdit
'; 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 CompletedClosed proof records
' . (int)$overEstimate . 'Jobs Over EstimateLabor variance
' . (int)$missingPhotos . 'Missing Required PhotosProof gaps
' . e(fmt_minutes($activeSeconds)) . 'Active LaborTotal tracked time
'; echo '

Technician Performance

'; 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 ''; } echo '
TechnicianJobs CompletedTotal ActiveAvg ActivePauses
' . e($tech['name']) . '' . (int)$tech['jobs'] . '' . fmt_minutes($total) . '' . fmt_minutes((int)$tech['jobs'] ? (int)($total / (int)$tech['jobs']) : 0) . '' . (int)$tech['pauses'] . '

Job Type Timing

'; 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 ''; } echo '
Job TypeJobsAvg EstimateAvg Actual
' . e($r['job_type']) . '' . (int)$r['jobs'] . '' . (int)$r['est'] . 'm' . fmt_minutes((int)$r['jobs'] ? (int)($total / (int)$r['jobs']) : 0) . '

Vehicle Timing

'; 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 ''; } echo '
VehicleJobsAvg Active
' . e($r['year'].' '.$r['make'].' '.$r['model']) . '' . (int)$r['jobs'] . '' . fmt_minutes((int)($total / max(1, (int)$r['jobs']))) . '

Template Accuracy

'; 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 ''; } echo '
TemplateEstimateAvg ActualJobsRecommendation
' . e($t['template_name']) . '' . (int)$t['estimated_duration_minutes'] . 'm' . $avg . 'm' . (int)$t['jobs'] . '' . e($rec) . '

Checklist & Photo Gaps

'; $miss = jobs_with_missing_requirements(); if (!$miss) echo '

No missing required checklist items or photos found.

'; foreach ($miss as $m) echo '
' . e($m['job_title']) . '' . e($m['gap']) . '
'; 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 '

Setup Areas

Job TemplatesManage reusable services, estimates, default teams, checklists, and photo sets.ManagePhoto Requirement SetsConfigure required intake, outtake, and marketing photo prompts.ManageChecklist TemplatesConfigure required intake and outtake checklist steps.ManageSecurity / UsersManage roles, active users, and password resets.Manage
'; $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.

'; if (!$previewJobs) { echo '
No active jobs available.

Create a job first, then use this area to view the technician workflow.

'; } foreach ($previewJobs as $job) { echo '#' . (int)$job['id'] . ' ' . e(short_job_title($job['job_title'])) . '' . e(short_vehicle_label($job) . ' | ' . $job['first_name'] . ' ' . $job['last_name']) . 'View'; } echo '
'; 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 ''; echo '
'; echo '
Job Rules
'; echo '
Photo Rules
'; echo '
Job Types
'; 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 '
' . e($title) . '

' . e($copy) . '

' . e($action) . '
'; } 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 ''; } 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 '
' . ($actions ? '' : '') . ''; foreach ($jobs as $j) { $assigned = ($j['tech_name'] ?? '') ?: (($j['team_name'] ?? '') ?: 'Unassigned'); echo ''; if ($actions) echo ''; echo ''; } echo '
JobVehicleScheduledStatusEstimate / ActualActions
' . 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'])) . '' . primary_job_action($j) . 'Edit
'; } 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 '

' . e($title) . '

'; if (!$jobs) echo '

No jobs here.

'; foreach ($jobs as $j) { $actual = fmt_minutes(active_time_seconds((int)$j['id'])); $cta = tech_job_cta_label($j['status']); echo '
#' . (int)$j['id'] . ' ' . e($j['job_title']) . '' . status_badge($j['status']) . '
' . e($j['year'].' '.$j['make'].' '.$j['model']) . '' . e($j['first_name'].' '.$j['last_name']) . '
EST ' . (int)$j['estimated_duration_minutes'] . 'mACT ' . e($actual) . '
' . e($cta) . '
'; } 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 ''; } 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)) . '
Plate' . e($job['license_plate']) . '
Color' . e($job['color']) . '
'; echo '
'; } 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 '
'; $lights = ['dash_airbag' => 'Airbag light on', 'dash_abs' => 'ABS light on', 'dash_check_engine' => 'Check engine light on', 'dash_tpms' => 'TPMS / tire pressure light on', 'dash_other' => 'Other dash light']; foreach ($lights as $name => $label) { echo ''; } echo '
'; if (isset($_GET['notify']) && !empty($_SESSION['pending_dash_lights'][(int)$job['id']])) { $labels = dash_light_labels($_SESSION['pending_dash_lights'][(int)$job['id']]); echo ''; } 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() . '
'; } function tech_checklist_panel(array $job, string $stage, array $user): void { $title = $stage === 'intake' ? 'Intake Tasks' : 'Outtake Tasks'; echo '

' . e($title) . '

Complete only the hands-on tasks for this step. Photos and vehicle info complete themselves when those steps are done.

' . csrf_field() . ''; foreach (checklist_for_job((int)$job['id'], $stage) as $item) { if ($stage === 'intake' && is_auto_vehicle_item($item['label'])) { continue; } $resp = row('SELECT * FROM job_checklist_responses WHERE job_id=? AND checklist_item_id=?', [$job['id'], $item['id']]); $done = (int)($resp['checked'] ?? 0) === 1; $required = (int)$item['is_required'] === 1; echo ''; if ((int)$item['allow_note']) echo ''; } 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']) . '
'; } 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 ''; if (in_array($state, ['start','resume'], true)) { echo ''; } if ($state === 'pause') echo ''; echo '
'; } function issue_panel(array $job, array $user): void { echo '

Add Issue

' . csrf_field() . '
'; } 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 '' . e($photo['requirement_name']) . ''; } 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 '
'; 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; }