<?php
// Plugin Check / PHPCS: category-level exclusions with reasons.
// Line-level findings in this file are handled individually below.
// phpcs:disable WordPress.DB.DirectDatabaseQuery.DirectQuery, WordPress.DB.DirectDatabaseQuery.NoCaching -- PaintOps keeps its records in its own custom tables (built by paintops_table()); $wpdb is the supported API for plugin-owned tables, there is no core CRUD alternative, and results are request-scoped.
// phpcs:disable WordPress.DateTime.RestrictedFunctions.date_date -- date() here is deliberately paired with current_time() timestamps: crews are scheduled on the site's local calendar day, and gmdate() would shift jobs across midnight UTC.
// phpcs:disable WordPress.Security.NonceVerification.Recommended -- read-only GET routing and filter parameters (which screen/record to show); every state-changing handler verifies a nonce before writing.
/**
 * Included File: Job Manager
 * Description: A custom admin page that displays and manages jobs to schedule.
 * Version: 1.3
 * Author: You
 */
if (!defined('ABSPATH')) exit;

// Admin permission-error rescue for PaintOps pages (see the file for why it lives there).
require_once __DIR__ . '/upgrade-routing.php';

global $wpdb;
require_once __DIR__ . '/job-financials-addon.php';

/**
 * Report a DB error without leaking query/schema details to visitors.
 * Full detail goes to the PHP error log; only admins see it on-screen.
 */
function paintops_report_db_error(string $context = ''): void {
    global $wpdb;

    if (!$wpdb->last_error) {
        return;
    }

    if (defined('WP_DEBUG') && WP_DEBUG) {
        error_log('PaintOps DB error' . ($context ? " ({$context})" : '') . ': ' . $wpdb->last_error); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log -- deliberately WP_DEBUG-gated diagnostics, silent in production.
    }

    if (current_user_can('manage_options')) {
        echo "<p style='color:red'>DB Error: " . esc_html($wpdb->last_error) . "</p>";
    } else {
        echo "<p style='color:red'>Something went wrong loading that data. Please try again.</p>";
    }
}
function paintops_create_link_arrow($size = 24, $color = 'currentColor', $margin = '7px', $marginbottom = '2px') {
// Plain concatenation (no heredoc, per wp.org review guidance); the only
// dynamic values are the integer size and CSS-token parameters this
// internal helper is called with.
return '<svg xmlns="http://www.w3.org/2000/svg" width="' . $size . '" height="' . $size . '"'
    . ' viewBox="0 0 24 24" fill="none" stroke="' . $color . '" stroke-width="2"'
    . ' stroke-linecap="round" stroke-linejoin="round"'
    . ' style="vertical-align: middle; margin-left: ' . $margin . '; margin-bottom: ' . $marginbottom . ';">'
    . '<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"/>'
    . '<polyline points="15 3 21 3 21 9"/>'
    . '<line x1="10" y1="14" x2="21" y2="3"/>'
    . '</svg>';
}

/**
 * A small "(i)" hover tooltip next to a label, for a short note that
 * doesn't need a permanent line under the field (see .paintops-tooltip
 * in job-style.php). Used in place of a `<p class="description">` when
 * the note is a one-time clarification rather than something worth
 * always keeping visible.
 */
function paintops_tooltip(string $text): string {
    return '<span class="paintops-tooltip" tabindex="0">i<span class="paintops-tooltip-text">' . esc_html($text) . '</span></span>';
}

// Note: this used to be where create_customer_user_if_needed() lived,
// it silently created a real WordPress user account (with login
// credentials) for anyone whose email showed up on a job, with no
// notice or consent. Removed for privacy; see
// paintops_find_or_create_customer() in paintops.php, which now matches
// or creates a plain customer *data record* instead, never an account.


/**
 * Format phone number
 */
function paintops_format_phone_number($number, $link) {
    // Remove non-digits
    $digits = preg_replace('/\D+/', '', $number);

    // Only format if exactly 10 digits
    if (strlen($digits) === 10) {
        $formatted = sprintf('(%s) %s-%s',
            substr($digits, 0, 3),
            substr($digits, 3, 3),
            substr($digits, 6)
        );
		if ($link==1){
        return '<a href="tel:' . $digits . '">' . $formatted . '</a>';
		}
		else{
        return $formatted;
		}
    }

    // esc_html() here is defense-in-depth: $number is echoed straight
    // back out in this fallback string, so even though callers are
    // expected to pass an already-sanitized value, this guarantees it
    // can never render as raw HTML/script if one doesn't.
    return esc_html($number) . ' is not a valid phone number';
}
function paintops_starts_in_days(string $date): string {
    $target = new DateTime($date, wp_timezone());
    $today = new DateTime("today", wp_timezone());

    $diff = $today->diff($target);
    $days = (int)$diff->format("%r%a"); // signed number of days

    $absDays = abs($days);
    $dayWord = ($absDays === 1) ? "day" : "days";

    if ($days > 0) {
        return "Starts in $absDays $dayWord";
    } elseif ($days === 0) {
        return "Starts <b>today</b>";
    } elseif ($days === 1) {
        return "Starts <b>tomorrow</b>";
    } else {
        return "Started $absDays $dayWord ago";
    }
}
function paintops_get_job_class_name($jobClass) {
    $jclasses = function_exists('paintops_job_classifications') ? paintops_job_classifications() : [];
    return $jclasses[(int) $jobClass] ?? 'Unknown';
}
/**
 * Derives the "Quote/Estimate/Bid/Job" label for a job record from its
 * stored jobType/isBid/quote_label columns, using the same precedence as
 * the "Current Status" pills on the job form (see $recordStatus in the
 * save handler below): Job always wins once jobType flips to 2, then
 * Bid, then Estimate, else Quote. Centralized here so this label can't
 * drift out of sync in any one place that shows it, the edit heading,
 * the save confirmation banner, the job list cards, the Financials
 * button, etc., the way it used to (several spots only ever checked
 * jobType and hardcoded everything else to "Quote").
 */
function paintops_job_record_label(int $jobType, int $isBid, string $quote_label): string {
    // Terminology (Mike 2026-07-22): a record is a REQUEST until it's won
    // (then a Job); Quote/Estimate/Bid is reserved for the DOCUMENT (see
    // paintops_quote_doc_word()). Before this, the record label reused the
    // document words, which made "quote" mean two different things.
    // $isBid/$quote_label stay as parameters so call sites don't change
    // (and so a future per-type record label has what it needs).
    if ($jobType === 2) {
        return 'Job';
    }
    return 'Request';
}
/**
 * The word for the quote DOCUMENT attached to a record, Quote, Estimate,
 * or Bid, regardless of whether the record itself has already been
 * converted to a Job. Labels and subjects that talk about the document
 * (Email X, Text X, View X, "X For ..." email subjects) use this, not
 * paintops_job_record_label(), which says what the RECORD is (Request or
 * Job). One derivation so wording can't drift between screens (Mike
 * 2026-07-22: "Email Quote"/"Quote For ..." read wrong to a contractor
 * sending Estimates or a GC bid).
 */
/**
 * When a request is WON into a real Job (Mark as Won on the job view, or
 * the Job Board's "Mark as Job" move), its quote document is by
 * definition accepted, so stamp the job's current estimate row. Covers
 * the phone-call/handshake win that never touches the online Accept
 * button (Mike 2026-07-22: won+completed jobs showed 0% acceptance).
 * Fills only what a manual win can know, accepted + accepted_date; the
 * typed-name/IP e-signature fields stay exclusive to the online accept.
 * Clears a stale decline the same way a real accept does. No-op when the
 * job has no estimate or it's already accepted.
 */
function paintops_mark_estimate_accepted_on_win(int $job_id): void {
    global $wpdb;
    $est_id = (int) $wpdb->get_var($wpdb->prepare(
        "SELECT estimateID FROM %i WHERE id = %d LIMIT 1", paintops_table('jobs'), $job_id));
    if ($est_id <= 0) {
        return;
    }
    $wpdb->query($wpdb->prepare(
        "UPDATE %i SET accepted = 1, accepted_date = %s, declined_date = NULL, declined_reason = ''"
            . " WHERE estID = %d AND accepted != 1", paintops_table('estimates'), current_time('mysql'), $est_id));
}
/**
 * The mirror of paintops_mark_estimate_accepted_on_win(): when a record
 * is demoted back to a Request or marked Not Won, remove the accepted
 * stamp a manual win put on its estimate, otherwise Request Performance
 * keeps counting it as accepted forever (Mike 2026-07-22: moving Ruth
 * back to a quote + Not Won left the rate at 100%). ONLY clears
 * manual-win stamps (accepted with NO typed name and NO accepted_at):
 * a real customer e-signature acceptance is a legal record and is never
 * silently un-accepted by a status toggle.
 */
function paintops_clear_estimate_win_stamp(int $job_id): void {
    global $wpdb;
    $est_id = (int) $wpdb->get_var($wpdb->prepare(
        "SELECT estimateID FROM %i WHERE id = %d LIMIT 1", paintops_table('jobs'), $job_id));
    if ($est_id <= 0) {
        return;
    }
    $wpdb->query($wpdb->prepare(
        "UPDATE %i SET accepted = 0, accepted_date = NULL"
            . " WHERE estID = %d AND accepted = 1"
            . " AND (accepted_name IS NULL OR accepted_name = '')"
            . " AND accepted_at IS NULL", paintops_table('estimates'), $est_id));
}
function paintops_quote_doc_word(int $isBid, string $quote_label): string {
    if ($isBid === 1) {
        return 'Bid';
    }
    if ($quote_label === 'estimate') {
        return 'Estimate';
    }
    return 'Quote';
}
/**
 * Look up job category display data.
 *
 * Categories used to be a hardcoded if/elseif chain here. They now come
 * from the paintops_job_categories option (editable under PaintOps >
 * Business), so any business can rename, add, or remove categories
 * without touching code. Existing jobCat IDs stored in the database keep
 * resolving the same way as long as the category with that ID still
 * exists; if it's been deleted, this falls back to "[N]" exactly like
 * the old code did for an unrecognized category.
 */
function paintops_get_category_details($jobCat, $opt) {
	$categories = function_exists('paintops_job_categories') ? paintops_job_categories() : [];
	$jobCat = (int) $jobCat;
	$category = $categories[$jobCat] ?? null;

	$jobCatName = $category['name'] ?? "[$jobCat]";
	$ourStandards = $category['standards'] ?? '';
	$jobCatN1 = !empty($category['email_phrase']) ? $category['email_phrase'] : null;

	// Category name. Used in PDF and Calendar
	if ($opt==0){
		return $jobCatName;
	}
	// Headline in PDF file
	elseif ($opt==1){
		$jobCatName = "$jobCatName Quote";
		return $jobCatName;
	}
	// Our standards in PDF file
	elseif ($opt==2){
		return $ourStandards;
	}
	// Send an email
	elseif ($opt==3){
		if (!isset($jobCatN1)){
		$jobCatN1 = strtolower($jobCatName);
		}
	return $jobCatN1;
	}
}
/**
 * Consistent top navigation shown across every view in the app,
 * Jobs, Customers, Profits, Calendar, Resources, instead of each
 * sub-view showing only a bare "Return to Main Page" link with no way
 * to jump straight to another section.
 *
 * There's no separate "New Request" tile: creating one is a
 * prominent "+ New Request" button at the top of the Jobs list
 * itself (same pattern as "+ New Customer" on the Customers page), so
 * $active still accepts 'newJob' here purely to keep the Jobs tile
 * highlighted while that form is open.
 *
 * $active names which tile (if any) represents the page currently being
 * rendered ('dashboard', 'newJob', 'customers', 'profits', 'calendar',
 * 'resources'), so that tile can be highlighted while the rest stay the
 * same neutral color.
 */
/**
 * Inline SVG cargo-van icon for the Vehicle menu items (2026-07-19,
 * Mike's ask: "not too many cars in painting"). Dashicons has no
 * truck/van glyph, so this is drawn in currentColor at the caller's
 * size, matching how the pipeline view icon is done. Box profile with a
 * windshield notch + two wheels, reads as a work van at 20-24px.
 */
function paintops_van_icon(int $size = 24, string $extra_style = ''): string {
    $s = (int) $size;
    return '<svg width="' . $s . '" height="' . $s . '" viewBox="0 0 24 24" fill="none" aria-hidden="true" style="flex:0 0 auto;vertical-align:middle;' . esc_attr($extra_style) . '">'
        . '<path d="M2 7.5C2 6.7 2.7 6 3.5 6H14v9.5H2V7.5Z" fill="currentColor" opacity="0.9"/>'
        . '<path d="M14 8h3.6c.5 0 .9.2 1.2.6l2.7 3.3c.3.4.5.9.5 1.4v2.2h-8V8Z" fill="currentColor" opacity="0.55"/>'
        . '<path d="M15.5 9.5h2l1.9 2.5h-3.9V9.5Z" fill="#fff" opacity="0.85"/>'
        . '<circle cx="7" cy="16.5" r="1.9" fill="currentColor"/>'
        . '<circle cx="17.5" cy="16.5" r="1.9" fill="currentColor"/>'
        . '<circle cx="7" cy="16.5" r="0.8" fill="#fff" opacity="0.8"/>'
        . '<circle cx="17.5" cy="16.5" r="0.8" fill="#fff" opacity="0.8"/>'
        . '</svg>';
}

/**
 * One standardized screen title (2026-07-19, Mike's consistency pass):
 * every app screen renders its name through this, same element (h2, per
 * Mike, NOT h1, which also keeps these clear of the theme-title H1
 * hider in paintops.php), same class, same themed styling
 * (.po-screen-title in job-style.php). Text is escaped here; pass plain
 * text.
 */
/**
 * Sanitize a $_FILES entry at intake. tmp_name is left as-is on purpose:
 * it is a server-generated path that wp_handle_upload() verifies with
 * is_uploaded_file() before anything touches it.
 */
function paintops_sanitize_upload($file): ?array {
    if (!is_array($file)) {
        return null;
    }
    return [
        'name'     => sanitize_file_name((string) ($file['name'] ?? '')),
        'type'     => sanitize_mime_type((string) ($file['type'] ?? '')),
        'tmp_name' => (string) ($file['tmp_name'] ?? ''),
        'error'    => (int) ($file['error'] ?? UPLOAD_ERR_NO_FILE),
        'size'     => (int) ($file['size'] ?? 0),
    ];
}

/**
 * Escape-at-echo for app-built HTML: wp_kses with an allow-list covering
 * the form controls and app chrome the PaintOps builders emit. Dynamic
 * values are escaped where they are assembled; this output pass enforces
 * safe structure at the echo site (no stray tags, filtered URLs/CSS).
 */
function paintops_kses_allowed_html(): array {
    static $allowed = null;
    if (null !== $allowed) {
        return $allowed;
    }
    $allowed = wp_kses_allowed_html('post');
    $globals = [
        'id' => true, 'class' => true, 'style' => true, 'title' => true,
        'tabindex' => true, 'role' => true, 'hidden' => true, 'lang' => true,
        'dir' => true, 'draggable' => true, 'data-*' => true, 'aria-*' => true,
        // Inline handlers are app chrome (menu toggles, print buttons);
        // every dynamic value inside them is esc_js()/esc_attr()-escaped
        // at assembly, and this allow-list is only applied to plugin-built
        // markup, never to user-typed content.
        'onclick' => true, 'onchange' => true, 'onsubmit' => true,
        'oninput' => true, 'onmouseover' => true, 'onkeyup' => true,
    ];
    $extra = [
        'form'     => ['action' => true, 'method' => true, 'enctype' => true, 'target' => true, 'novalidate' => true, 'name' => true, 'accept-charset' => true, 'autocomplete' => true],
        'input'    => ['type' => true, 'name' => true, 'value' => true, 'placeholder' => true, 'checked' => true, 'required' => true, 'min' => true, 'max' => true, 'step' => true, 'minlength' => true, 'maxlength' => true, 'pattern' => true, 'readonly' => true, 'disabled' => true, 'autocomplete' => true, 'autocapitalize' => true, 'autofocus' => true, 'inputmode' => true, 'accept' => true, 'capture' => true, 'multiple' => true, 'size' => true, 'list' => true, 'form' => true],
        'select'   => ['name' => true, 'multiple' => true, 'size' => true, 'required' => true, 'disabled' => true, 'form' => true, 'autocomplete' => true],
        'option'   => ['value' => true, 'selected' => true, 'disabled' => true, 'label' => true],
        'optgroup' => ['label' => true, 'disabled' => true],
        'textarea' => ['name' => true, 'rows' => true, 'cols' => true, 'placeholder' => true, 'required' => true, 'maxlength' => true, 'readonly' => true, 'disabled' => true, 'form' => true, 'wrap' => true, 'autocomplete' => true],
        'button'   => ['type' => true, 'name' => true, 'value' => true, 'disabled' => true, 'form' => true, 'formaction' => true, 'formtarget' => true],
        'label'    => ['for' => true],
        'fieldset' => ['name' => true, 'disabled' => true, 'form' => true],
        'legend'   => [],
        'datalist' => [],
        'progress' => ['value' => true, 'max' => true],
        'meter'    => ['value' => true, 'min' => true, 'max' => true, 'low' => true, 'high' => true, 'optimum' => true],
        'canvas'   => ['width' => true, 'height' => true],
        'details'  => ['open' => true],
        'summary'  => [],
        'dialog'   => ['open' => true],
        'iframe'   => ['src' => true, 'width' => true, 'height' => true, 'frameborder' => true, 'allow' => true, 'allowfullscreen' => true, 'loading' => true, 'referrerpolicy' => true, 'sandbox' => true],
        'svg'      => ['xmlns' => true, 'viewbox' => true, 'width' => true, 'height' => true, 'fill' => true, 'stroke' => true, 'stroke-width' => true, 'stroke-linecap' => true, 'stroke-linejoin' => true, 'preserveaspectratio' => true, 'focusable' => true, 'opacity' => true],
        'path'     => ['d' => true, 'fill' => true, 'stroke' => true, 'stroke-width' => true, 'stroke-linecap' => true, 'stroke-linejoin' => true, 'fill-rule' => true, 'clip-rule' => true, 'opacity' => true],
        'g'        => ['fill' => true, 'stroke' => true, 'transform' => true, 'opacity' => true],
        'circle'   => ['cx' => true, 'cy' => true, 'r' => true, 'fill' => true, 'stroke' => true, 'stroke-width' => true, 'opacity' => true],
        'rect'     => ['x' => true, 'y' => true, 'width' => true, 'height' => true, 'rx' => true, 'ry' => true, 'fill' => true, 'stroke' => true],
        'line'     => ['x1' => true, 'y1' => true, 'x2' => true, 'y2' => true, 'stroke' => true, 'stroke-width' => true, 'stroke-linecap' => true],
        'polyline' => ['points' => true, 'fill' => true, 'stroke' => true, 'stroke-width' => true],
        'polygon'  => ['points' => true, 'fill' => true, 'stroke' => true],
    ];
    foreach ($extra as $tag => $attrs) {
        $allowed[$tag] = isset($allowed[$tag]) ? array_merge($allowed[$tag], $attrs) : $attrs;
    }
    foreach ($allowed as $tag => $attrs) {
        $allowed[$tag] = array_merge(is_array($attrs) ? $attrs : [], $globals);
    }
    // img extras the photo tools use.
    if (isset($allowed['img'])) {
        $allowed['img'] = array_merge($allowed['img'], ['srcset' => true, 'sizes' => true, 'loading' => true, 'decoding' => true]);
    }
    if (isset($allowed['a'])) {
        $allowed['a'] = array_merge($allowed['a'], ['download' => true, 'target' => true, 'rel' => true]);
    }
    return $allowed;
}

/** CSS properties the app uses beyond core's safecss defaults. */
function paintops_kses_style_props(array $props): array {
    return array_merge($props, [
        'cursor', 'gap', 'row-gap', 'column-gap', 'position', 'top', 'right',
        'bottom', 'left', 'z-index', 'transform', 'transition', 'object-fit',
        'overflow', 'overflow-x', 'overflow-y', 'box-shadow', 'outline',
        'white-space', 'pointer-events', 'opacity', 'visibility', 'resize',
        'word-break', 'overflow-wrap', 'box-sizing', 'flex-flow', 'inset',
        'appearance', 'line-break', 'text-transform', 'letter-spacing',
        'flex-wrap', 'align-self', 'justify-self', 'place-items',
        'text-overflow', '-webkit-line-clamp', '-webkit-box-orient',
    ]);
}

/**
 * Allowed URL protocols for app HTML: core's list lacks sms: (Text
 * Customer / Text Quote links) and webcal: (the calendar subscribe
 * link); without them wp_kses strips those hrefs entirely.
 */
function paintops_kses_protocols(): array {
    return array_merge(wp_allowed_protocols(), ['sms', 'webcal']);
}

// The extended CSS-property list applies whenever kses filters our
// markup. Registered globally (the properties are all benign layout
// props) so the literal wp_kses(...) calls at every echo site, which
// Plugin Check recognizes as escaping, get the same treatment the old
// wp_kses(, paintops_kses_allowed_html(), paintops_kses_protocols()) wrapper scoped per call.
add_filter('safe_style_css', 'paintops_kses_style_props');

/**
 * Escape-late helper kept for internal callers; echo sites now call
 * wp_kses($html, paintops_kses_allowed_html(), paintops_kses_protocols())
 * literally so scanners recognize the escaping function by name.
 */
function paintops_kses(string $html): string {
    return wp_kses($html, paintops_kses_allowed_html(), paintops_kses_protocols());
}

function paintops_screen_title(string $title): string {
    return '<h2 class="po-screen-title">' . esc_html($title) . '</h2>';
}

function paintops_render_app_nav(string $active = ''): string {
	// Financials (Profits, worker profit-per-hour rankings, a job's own
	// expenses) are Administrator-only, see paintops_user_can_view_financials().
	// Non-admin staff (Editors, Contributors, Painters/Helpers who somehow
	// reach this) never even see the button, rather than seeing it and
	// hitting a permission wall after clicking.
	$can_view_financials = function_exists('paintops_user_can_view_financials') && paintops_user_can_view_financials();

	// Premium-build presence: the wp.org free build ships none of the Pro
	// pages (Profits, Vehicle, Business), so their nav items are simply
	// absent there. On a premium build without an active license the items
	// show with a small Pro marker plus an Upgrade link.
	$pro_present = function_exists('paintops_pro_code_present');
	$pro_unlicensed = $pro_present && function_exists('paintops_pro_active') && !paintops_pro_active();

	$profits_badge = $pro_unlicensed
		? ' <span class="pro-badge">Pro</span>'
		: '';

	$upgrade_url = ($pro_unlicensed && function_exists('paintops_upgrade_url'))
		? esc_url(paintops_upgrade_url())
		: '';

	// Role-aware items (2026-07-19): the nav only shows what the CURRENT
	// USER can actually open. Before this, a default worker saw Calendar/
	// Customers/etc., all of which bounced them to their My Jobs home,
	// links that LOOK like features but never work read as broken. Staff
	// see everything (as before); workers see Jobs (= their My Jobs home),
	// New Request only with the create permission, and a More menu of
	// My Hours + Vehicle (permission-gated). Financials gating unchanged.
	$is_staff_nav = function_exists('paintops_user_can_manage') && paintops_user_can_manage();
	$can_new_nav = $is_staff_nav || (function_exists('paintops_user_can_create_job') && paintops_user_can_create_job());
	$can_vehicle_nav = ($is_staff_nav || (function_exists('paintops_user_can_use_vehicle_log') && paintops_user_can_use_vehicle_log())) && $pro_present;

	// All items share one inactive (muted) color; whichever one matches
	// the page you're currently on gets the accent color instead, so it's
	// obvious at a glance where you are. Same classes desktop + mobile.
	$is_dashboard = $active === 'dashboard' ? ' is-active' : '';
	$is_new = $active === 'newJob' ? ' is-active' : '';
	$is_customers = $active === 'customers' ? ' is-active' : '';
	$is_profits = $active === 'profits' ? ' is-active' : '';
	$is_calendar = $active === 'calendar' ? ' is-active' : '';
	$is_resources = $active === 'resources' ? ' is-active' : '';
	$is_vehicle = $active === 'vehicle' ? ' is-active' : '';
	$is_business = $active === 'business' ? ' is-active' : '';
	$is_checkup = $active === 'checkup' ? ' is-active' : '';

	// Vehicle/Resources/Business are used less often than Jobs/Calendar/
	// Customers/Profits, so on desktop they live under one "More"
	// dropdown at the bar's right end, keeps the bar from growing every
	// time a new section gets added (this is also where Pipeline or Time
	// Off could go later). The More button itself still picks up the
	// accent when you're on one of its pages, but the panel always
	// starts collapsed, staying open after navigating there just adds
	// clutter once you've already arrived.
	$desktop_more_active = $is_vehicle !== '' || $is_resources !== '' || $is_business !== '';
	$is_more = $desktop_more_active ? ' is-active' : '';

	// Dashicons ships with WordPress core (wp-includes/css/dashicons.min.css)
	// so the nav icons work with zero external dependency, no CDN font to
	// fail to load, no extra request beyond what WordPress already serves.
	wp_enqueue_style('dashicons');

	// One unified app-bar navigation (2026-07-19, Mike's call): the retired
	// Pills / Plain Tabs styles are gone, and the mobile bottom bar's design
	// language now also drives a TOP app bar on large screens, so phone and
	// desktop share the same icons, labels, grouping, and accent treatment.
	// Desktop (>=783px): a horizontal bar with the common destinations
	// inline (Jobs, a raised accent "+" New Request, Calendar, Customers,
	// Profits) and a right-aligned More dropdown for the long tail
	// (Vehicle, Resources, Business, Upgrade), same groups as the phone
	// sheet. Mobile (<=782px): the fixed bottom bar + sheet, unchanged.
	// Sticky (Settings > Appearance) pins the desktop bar while scrolling.
	$navSticky = function_exists('paintops_nav_sticky') && paintops_nav_sticky();

	// Unified app bar CSS (desktop top bar + mobile bottom bar/sheet):
	// the static rules load from assets/css/app-nav.css through the styles
	// API. (The old inline block here also carried a stray </style> closer
	// for the since-retired "job-style.php leaves its stylesheet open"
	// convention; the design system is an enqueued asset now, see
	// paintops_enqueue_app_style(), so no closer is needed.)
	paintops_enqueue_style('app-nav');
	if ($navSticky) {
		// position:sticky is silently defeated by ANY ancestor with a
		// clipping overflow (overflow:hidden/auto/scroll). The Neve theme
		// (and many others) wraps the whole page in a div.wrapper set to
		// overflow:hidden, that becomes the sticky element's scroll
		// container, but it never scrolls itself (the page body does), so
		// the nav "sticks" to something that doesn't move and looks like
		// it isn't sticky at all. Relax that clip so the nav binds to the
		// real scroller (the body). This fragment only emits when the
		// sticky nav is enabled, so it can't affect the rest of the site;
		// horizontal overflow is still contained by the body's own
		// overflow-x.
		paintops_add_inline_style('.wrapper { overflow: visible !important; }');
	}
	// Nav behavior (More dropdown outside-click close, contact-tap logging
	// beacon, mobile bottom sheet, link "jail", double-submit guard) lives
	// in assets/js/app-nav.js; the beacon's endpoint + nonce ride along.
	paintops_enqueue_script('app-nav', 'var paintopsAppNavCfg = ' . wp_json_encode([
		'ajaxUrl'         => admin_url('admin-ajax.php'),
		'logContactNonce' => wp_create_nonce('paintops_log_contact'),
	]) . ';');

	ob_start();
	?>
	<nav class="paintops-appbar<?php echo esc_attr($navSticky ? ' paintops-appbar-sticky' : ''); ?>" aria-label="Primary">
		<a class="pab-item<?php echo esc_attr($is_dashboard); ?>" href="<?php echo esc_url(paintops_admin_url()); ?>">
			<span class="dashicons dashicons-admin-home" aria-hidden="true"></span><span class="pab-label"><?php echo esc_attr($is_staff_nav ? 'Jobs' : 'My Jobs'); ?></span>
		</a>
		<?php if ($can_new_nav) : ?>
		<a class="pab-item pab-new<?php echo esc_attr($is_new); ?>" href="<?php echo esc_url(paintops_admin_url(['newJob' => 1])); ?>" aria-label="New Request">
			<span class="pab-plus"><span class="dashicons dashicons-plus-alt2" aria-hidden="true"></span></span><span class="pab-label">New</span>
		</a>
		<?php endif; ?>
		<?php if ($is_staff_nav) : ?>
		<a class="pab-item<?php echo esc_attr($is_calendar); ?>" href="<?php echo esc_url(paintops_admin_url(['calendar' => 1])); ?>">
			<span class="dashicons dashicons-calendar-alt" aria-hidden="true"></span><span class="pab-label">Calendar</span>
		</a>
		<a class="pab-item<?php echo esc_attr($is_customers); ?>" href="<?php echo esc_url(paintops_admin_url(['customers' => 1])); ?>">
			<span class="dashicons dashicons-groups" aria-hidden="true"></span><span class="pab-label">Customers</span>
		</a>
		<?php else : ?>
		<a class="pab-item<?php echo esc_attr(isset($_GET['myHours']) ? ' is-active' : ''); ?>" href="<?php echo esc_url(paintops_admin_url(['myHours' => 1])); ?>">
			<span class="dashicons dashicons-clock" aria-hidden="true"></span><span class="pab-label">My Hours</span>
		</a>
		<?php endif; ?>
		<?php if ($can_view_financials && $pro_present) : ?>
		<a class="pab-item<?php echo esc_attr($is_profits); ?>" href="<?php echo esc_url(paintops_admin_url(['profits' => 1])); ?>">
			<span class="dashicons dashicons-chart-bar" aria-hidden="true"></span><span class="pab-label">Profits<?php echo wp_kses($profits_badge, paintops_kses_allowed_html(), paintops_kses_protocols()); ?></span>
		</a>
		<?php endif; ?>
		<?php if ($is_staff_nav || $can_vehicle_nav) : ?>
		<details class="pab-more">
			<summary class="pab-item<?php echo esc_attr($is_more); ?>"><span class="dashicons dashicons-menu" aria-hidden="true"></span><span class="pab-label">More</span></summary>
			<div class="pab-panel">
				<div class="pab-group">On the job</div>
				<?php if ($can_vehicle_nav) : ?>
				<a class="<?php echo esc_attr(trim('' . $is_vehicle)); ?>" href="<?php echo esc_url(paintops_admin_url(['vehicle' => 1])); ?>"><?php echo wp_kses(paintops_van_icon(20, 'color:var(--po-accent,#4682B4);'), paintops_kses_allowed_html(), paintops_kses_protocols()); ?>Vehicle<?php echo wp_kses($profits_badge, paintops_kses_allowed_html(), paintops_kses_protocols()); ?></a>
				<?php endif; ?>
				<?php if ($is_staff_nav) : ?>
				<a class="<?php echo esc_attr(trim('' . $is_resources)); ?>" href="<?php echo esc_url(paintops_admin_url(['resources' => 1])); ?>"><span class="dashicons dashicons-book-alt" aria-hidden="true"></span>Resources</a>
				<?php endif; ?>
				<?php if ($can_view_financials) : ?>
				<div class="pab-group">Office</div>
				<a class="<?php echo esc_attr(trim('' . $is_checkup)); ?>" href="<?php echo esc_url(paintops_admin_url(['checkup' => 1])); ?>"><span class="dashicons dashicons-yes-alt" aria-hidden="true"></span>Checkup</a>
				<?php if ($pro_present) : ?>
				<a class="<?php echo esc_attr(trim('' . $is_business)); ?>" href="<?php echo esc_url(paintops_admin_url(['business' => 1])); ?>"><span class="dashicons dashicons-building" aria-hidden="true"></span>Business<?php echo wp_kses($profits_badge, paintops_kses_allowed_html(), paintops_kses_protocols()); ?></a>
				<?php endif; ?>
				<?php endif; ?>
				<a class="<?php echo esc_attr($active === 'help' ? 'is-active' : ''); ?>" href="<?php echo esc_url(paintops_admin_url(['help' => 1])); ?>"><span class="dashicons dashicons-editor-help" aria-hidden="true"></span>Help</a>
				<?php if ($is_staff_nav && $upgrade_url !== '') : ?>
					<a class="paintops-nav-upgrade" href="<?php echo esc_url($upgrade_url); ?>" target="_blank" rel="noopener noreferrer"><span class="dashicons dashicons-star-filled" aria-hidden="true"></span>Upgrade to Pro</a>
				<?php endif; ?>
			</div>
		</details>
		<?php endif; ?>
	</nav>
	<?php

	// Mobile bottom bar (always on; the top app bar above is what desktop
	// sees, and it hides itself at <=782px where this fixed bar takes over).
	// Three balanced slots, Jobs, a raised center New Request, and More,
	// with More opening an upward sheet that groups the on-the-job
	// destinations above the office ones. On screens with a fixed Save bar
	// (.paintops-sticky-save) that bar is lifted to sit ABOVE this one (see
	// job-style.php) so they stack rather than overlap, and the content
	// padding below leaves room for both.
	$bn_jobs_active = $is_dashboard;
	$bn_new_active  = $is_new;
	$bn_more_active = ($active === 'calendar' || $active === 'profits' || $is_customers !== '' || $desktop_more_active) ? ' is-active' : '';
	?>

		<nav class="paintops-bottomnav" aria-label="Primary">
			<a class="pbn-item<?php echo esc_attr($bn_jobs_active); ?>" href="<?php echo esc_url(paintops_admin_url()); ?>">
				<span class="dashicons dashicons-admin-home" aria-hidden="true"></span><span class="pbn-label"><?php echo esc_attr($is_staff_nav ? 'Jobs' : 'My Jobs'); ?></span>
			</a>
			<?php if ($can_new_nav) : ?>
			<a class="pbn-center<?php echo esc_attr($bn_new_active); ?>" href="<?php echo esc_url(paintops_admin_url(['newJob' => 1])); ?>" aria-label="New Request">
				<span class="pbn-plus"><span class="dashicons dashicons-plus-alt2" aria-hidden="true"></span></span><span class="pbn-label">New</span>
			</a>
			<?php else : ?>
			<a class="pbn-item<?php echo esc_attr(isset($_GET['myHours']) ? ' is-active' : ''); ?>" href="<?php echo esc_url(paintops_admin_url(['myHours' => 1])); ?>">
				<span class="dashicons dashicons-clock" aria-hidden="true"></span><span class="pbn-label">My Hours</span>
			</a>
			<?php endif; ?>
			<button type="button" class="pbn-item pbn-more-btn<?php echo esc_attr($bn_more_active); ?>" aria-haspopup="dialog" aria-expanded="false">
				<span class="dashicons dashicons-menu" aria-hidden="true"></span><span class="pbn-label">More</span>
			</button>
		</nav>

		<div class="pbn-sheet-backdrop" hidden></div>
		<div class="pbn-sheet" role="dialog" aria-modal="true" aria-label="More" hidden>
			<div class="pbn-sheet-handle" aria-hidden="true"></div>

			<div class="pbn-sheet-group">On the job</div>
			<?php if ($is_staff_nav) : ?>
			<a class="<?php echo esc_attr($active === 'calendar' ? 'is-active' : ''); ?>" href="<?php echo esc_url(paintops_admin_url(['calendar' => 1])); ?>"><span class="dashicons dashicons-calendar-alt" aria-hidden="true"></span>Calendar</a>
			<?php endif; ?>
			<?php if (!$is_staff_nav && $can_new_nav) : ?>
			<a class="<?php echo esc_attr(isset($_GET['myHours']) ? 'is-active' : ''); ?>" href="<?php echo esc_url(paintops_admin_url(['myHours' => 1])); ?>"><span class="dashicons dashicons-clock" aria-hidden="true"></span>My Hours</a>
			<?php endif; ?>
			<?php if ($can_vehicle_nav) : ?>
			<a class="<?php echo esc_attr($active === 'vehicle' ? 'is-active' : ''); ?>" href="<?php echo esc_url(paintops_admin_url(['vehicle' => 1])); ?>"><?php echo wp_kses(paintops_van_icon(22, 'color:var(--po-accent,#4682B4);'), paintops_kses_allowed_html(), paintops_kses_protocols()); ?>Vehicle<?php echo wp_kses($profits_badge, paintops_kses_allowed_html(), paintops_kses_protocols()); ?></a>
			<?php endif; ?>
			<?php if ($is_staff_nav) : ?>
			<a class="<?php echo esc_attr($active === 'resources' ? 'is-active' : ''); ?>" href="<?php echo esc_url(paintops_admin_url(['resources' => 1])); ?>"><span class="dashicons dashicons-book-alt" aria-hidden="true"></span>Resources</a>
			<a class="<?php echo esc_attr($active === 'customers' ? 'is-active' : ''); ?>" href="<?php echo esc_url(paintops_admin_url(['customers' => 1])); ?>"><span class="dashicons dashicons-groups" aria-hidden="true"></span>Customers</a>
			<?php endif; ?>

			<?php if ($can_view_financials && $pro_present) : ?>
			<div class="pbn-sheet-group">Office</div>
			<a class="<?php echo esc_attr($active === 'profits' ? 'is-active' : ''); ?>" href="<?php echo esc_url(paintops_admin_url(['profits' => 1])); ?>"><span class="dashicons dashicons-chart-bar" aria-hidden="true"></span>Profits<?php echo wp_kses($profits_badge, paintops_kses_allowed_html(), paintops_kses_protocols()); ?></a>
			<a class="<?php echo esc_attr($active === 'business' ? 'is-active' : ''); ?>" href="<?php echo esc_url(paintops_admin_url(['business' => 1])); ?>"><span class="dashicons dashicons-building" aria-hidden="true"></span>Business<?php echo wp_kses($profits_badge, paintops_kses_allowed_html(), paintops_kses_protocols()); ?></a>
			<?php elseif ($is_staff_nav && $upgrade_url !== '') : ?>
			<div class="pbn-sheet-group">Office</div>
			<?php endif; ?>

			<a class="<?php echo esc_attr($active === 'help' ? 'is-active' : ''); ?>" href="<?php echo esc_url(paintops_admin_url(['help' => 1])); ?>"><span class="dashicons dashicons-editor-help" aria-hidden="true"></span>Help</a>
			<?php if ($is_staff_nav && $upgrade_url !== '') : ?>
			<a class="paintops-nav-upgrade" href="<?php echo esc_url($upgrade_url); ?>" target="_blank" rel="noopener noreferrer"><span class="dashicons dashicons-star-filled" aria-hidden="true"></span>Upgrade to Pro</a>
			<?php endif; ?>
		</div>

		<?php

	return ob_get_clean();
}

/**
 * Main job manager function (shortcode + page renderer)
 */
function paintops_fetch_csjobs() {
    global $wpdb;

    // A Painter/Helper account can't pass the staff gate just below (they
    // only have 'read'), but they still need to: open the single job link
    // emailed to them the day before a job (?viewEid=X), add before/after
    // or damage photos to it (?jobPhotos=X), log an expense + receipt for
    // something they bought on-site (?jobAddReceipt=X), and, if their
    // individual PaintOps > Painters/Helpers permissions allow it (see
    // paintops_worker_has_permission()), create a new job/quote
    // (?newJob=1), create a quote for a job they're on (?estimates=1),
    // or view financials (?jobFinancials=X, ?profits=1, ?jobRanks=1).
    // Every one of these is checked against the specific per-job or
    // per-user permission it needs; nothing else in this function (the
    // job list, another job, another worker's financials, customers,
    // calendar) is reachable for them.
    if (is_user_logged_in() && !paintops_user_can_manage()) {
        $workerJobId = 0;
        $allowedKey = '';

        if (isset($_GET['viewEid']) && (int) $_GET['viewEid'] > 0) {
            $workerJobId = (int) $_GET['viewEid'];
            $allowedKey = 'viewEid';
        } elseif (isset($_GET['jobPhotos']) && (int) $_GET['jobPhotos'] > 0) {
            $workerJobId = (int) $_GET['jobPhotos'];
            $allowedKey = 'jobPhotos';
        } elseif (isset($_GET['jobAddReceipt']) && (int) $_GET['jobAddReceipt'] > 0) {
            $workerJobId = (int) $_GET['jobAddReceipt'];
            $allowedKey = 'jobAddReceipt';
        } elseif (isset($_GET['jobFinancials']) && (int) $_GET['jobFinancials'] > 0) {
            $workerJobId = (int) $_GET['jobFinancials'];
            $allowedKey = 'jobFinancials';
        } elseif (isset($_GET['estimates']) && (int) ($_GET['jobID'] ?? 0) > 0) { // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.ValidatedSanitizedInput.MissingUnslash -- value is cast to a number, sanitization not applicable
            $workerJobId = (int) $_GET['jobID'];
            $allowedKey = 'estimates';
        }

        $workerAllowed = $workerJobId > 0 && function_exists('paintops_user_can_view_job') && paintops_user_can_view_job($workerJobId);

        // The two permission-gated routes below have no existing job to
        // check against (a brand-new job doesn't exist yet; Profits/worker
        // rankings are business-wide), so they're evaluated separately
        // from the per-job check above.
        $workerCanCreateJob = !$workerAllowed && isset($_GET['newJob']) && !isset($_GET['editEid'])
            && function_exists('paintops_user_can_create_job') && paintops_user_can_create_job();
        if ($workerCanCreateJob) {
            $allowedKey = 'newJob';
        }

        $workerCanViewFinancials = !$workerAllowed && !$workerCanCreateJob
            && (isset($_GET['profits']) || isset($_GET['jobRanks']))
            && function_exists('paintops_user_can_view_financials') && paintops_user_can_view_financials();
        if ($workerCanViewFinancials) {
            $allowedKey = isset($_GET['profits']) ? 'profits' : 'jobRanks';
        }

        // Vehicle log is business-wide (each worker logs their own trips/
        // expenses), so like Profits it has no per-job check, just the
        // Painter/Helper-or-staff gate in paintops_user_can_use_vehicle_log().
        $workerCanUseVehicle = !$workerAllowed && !$workerCanCreateJob && !$workerCanViewFinancials
            && isset($_GET['vehicle'])
            && function_exists('paintops_user_can_use_vehicle_log') && paintops_user_can_use_vehicle_log();
        if ($workerCanUseVehicle) {
            $allowedKey = 'vehicle';
        }

        // My Hours (2026-07-19): a worker's own logged hours/pay. Always
        // available to any logged-in worker, it can ONLY ever show their
        // own rows (the renderer is called with get_current_user_id(),
        // the request carries no user id to tamper with).
        if (!$workerAllowed && !$workerCanCreateJob && !$workerCanViewFinancials && !$workerCanUseVehicle
            && isset($_GET['myHours'])) {
            ob_start();
            require __DIR__ . '/job-style.php';
            echo wp_kses(paintops_render_app_nav(''), paintops_kses_allowed_html(), paintops_kses_protocols());
            echo wp_kses(function_exists('paintops_render_worker_hours')
                ? paintops_render_worker_hours(get_current_user_id())
                : '<p>Hours view unavailable.</p>', paintops_kses_allowed_html(), paintops_kses_protocols());
            if (!is_admin()) { get_footer(); exit; }
            return ob_get_clean();
        }

        if (!$workerAllowed && !$workerCanCreateJob && !$workerCanViewFinancials && !$workerCanUseVehicle) {
            // No specific job/action matched. This is a worker's HOME, not
            // an error (2026-07-19, pre-launch audit P0 #5): before this, a
            // default worker who typed the app's base URL or tapped "Jobs"
            // in the nav got "You do not have access to this job." as their
            // very first screen, and their only path to any job was the
            // deep link in the night-before email. Render "My Jobs"
            // (today / upcoming / needs-attention, see
            // paintops_render_worker_home() in job-view.php) plus links to
            // anything their individual permissions unlock.
            $can_create_job_general = function_exists('paintops_user_can_create_job') && paintops_user_can_create_job();
            $can_view_financials_general = function_exists('paintops_user_can_view_financials') && paintops_user_can_view_financials();

            ob_start();
            require __DIR__ . '/job-style.php';
            echo wp_kses(paintops_render_app_nav('dashboard'), paintops_kses_allowed_html(), paintops_kses_protocols());
            echo wp_kses(function_exists('paintops_render_worker_home')
                ? paintops_render_worker_home(get_current_user_id())
                : '<div class="job-form"><h2>PaintOps</h2></div>', paintops_kses_allowed_html(), paintops_kses_protocols());
            if ($can_create_job_general || $can_view_financials_general) {
                echo '<div class="job-form" style="margin-top:14px;"><ul style="font-size:16px;line-height:2;margin:0;">';
                if ($can_create_job_general) {
                    echo '<li><a href="' . esc_url(paintops_admin_url(['newJob' => 1])) . '">+ New Request</a></li>';
                }
                if ($can_view_financials_general) {
                    echo '<li><a href="' . esc_url(paintops_admin_url(['profits' => 1])) . '">Profits</a></li>';
                }
                echo '</ul></div>';
            }
            if (!is_admin()) { get_footer(); exit; }
            return ob_get_clean();
        }

        // Close off a parameter-pollution hole: several branches further
        // down in this function trust a $_GET key being present with no
        // capability check of their own (Calendar, for instance, has none
        // at all). Without this, a worker could pair an allowed key with
        // an unrelated one in the same query string (e.g.
        // "?jobPhotos=<their job>&calendar=1") and reach a page meant to
        // be staff-only. Scrub every other recognized routing key so only
        // the one this worker was actually just cleared for survives.
        foreach (['viewEid', 'jobPhotos', 'jobAddReceipt', 'jobFinancials', 'jobInvoice', 'estimates', 'newJob', 'editEid', 'profits', 'jobRanks', 'calendar', 'customers', 'resources', 'pipeline', 'timeOff', 'vehicle', 'scheduleFollowUp', 'jobSearch', 'myHours', 'help'] as $routingKey) {
            if ($routingKey !== $allowedKey) {
                unset($_GET[$routingKey]);
            }
        }

        // ?viewEid= gets its own dedicated painter-safe card here (no edit
        // links, no financial data) rather than falling through to the
        // staff routing below. The other allowed routes fall through on
        // purpose, the same branches staff use further down already
        // re-check this exact per-job or per-user permission themselves
        // (see paintops_user_can_manage_job_photos() /
        // paintops_user_can_add_job_receipt() / paintops_user_can_view_financials() /
        // paintops_user_can_create_quote()), so reusing that routing avoids
        // maintaining two copies of the same render logic.
        if ($allowedKey === 'viewEid') {
            ob_start();
            require __DIR__ . '/job-style.php';
            echo wp_kses(paintops_render_app_nav('dashboard'), paintops_kses_allowed_html(), paintops_kses_protocols());

            // Worker "Mark Complete" (2026-07-19): an assigned painter can
            // close out an active job from the field, which is exactly when
            // it's known. Guarded by the same per-job assignment check as
            // the page itself + a job-scoped nonce; one-way (only staff can
            // un-complete, via the edit form) and the owner is emailed so
            // completion can trigger invoicing. Runs BEFORE render so the
            // page shows the new state.
            if (isset($_POST['paintops_worker_complete'])
                && wp_verify_nonce(sanitize_text_field(wp_unslash($_POST['paintops_worker_complete_nonce'] ?? '')), 'paintops_worker_complete_' . $workerJobId)
                && (int) ($_POST['job_id'] ?? 0) === $workerJobId) { // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.ValidatedSanitizedInput.MissingUnslash -- value is cast to a number, sanitization not applicable
                $completeRow = $wpdb->get_row($wpdb->prepare(
                    "SELECT id, jobTitle, custFirst, custLast, jobType, completed FROM %i WHERE id = %d LIMIT 1", paintops_table('jobs'), $workerJobId));
                if ($completeRow && (int) $completeRow->jobType === 2 && (int) $completeRow->completed !== 1) {
                    $wpdb->update(paintops_table('jobs'), ['completed' => 1], ['id' => $workerJobId], ['%d'], ['%d']);
                    $completer = wp_get_current_user();
                    $completerName = function_exists('paintops_worker_display_label') ? paintops_worker_display_label((int) $completer->ID) : '';
                    if ($completerName === '') { $completerName = $completer->display_name; }
                    $ownerProfile = function_exists('paintops_company_profile') ? paintops_company_profile() : ['email' => ''];
                    $ownerTo = !empty($ownerProfile['email']) ? $ownerProfile['email'] : get_option('admin_email');
                    $completeUrl = paintops_admin_url(['viewEid' => $workerJobId]);
                    wp_mail(
                        $ownerTo,
                        "Job #{$workerJobId} marked complete by {$completerName}",
                        "{$completerName} just marked this job complete from the field.\n\nJob #{$workerJobId}: {$completeRow->jobTitle}\nCustomer: {$completeRow->custFirst} {$completeRow->custLast}\n\nReview it (and invoice the balance) here: {$completeUrl}",
                        function_exists('paintops_mail_headers') ? paintops_mail_headers() : []
                    );
                    echo '<div class="paintops-notice is-success" style="margin:0 0 12px;">Nice work! The job is marked complete and the office has been notified.</div>';
                }
            }

            if (function_exists('paintops_render_worker_job_view')) {
                echo wp_kses(paintops_render_worker_job_view($workerJobId), paintops_kses_allowed_html(), paintops_kses_protocols());
            } else {
                echo '<p>Job view unavailable.</p>';
            }
            if (!is_admin()) { get_footer(); exit; }
            return ob_get_clean();
        }
    } elseif (!is_user_logged_in()) {
        // Defense in depth: paintops_render_app() already gates the page
        // this shortcode normally renders on, but the shortcode itself is
        // also registered globally and could be embedded elsewhere, or hit
        // directly by a direct request. Never show job/customer/financial
        // data to anyone not logged in.
        return function_exists('paintops_login_required_box')
            ? paintops_login_required_box()
            : '<div class="paintops-login-required"><p>Please log in to access PaintOps.</p></div>';
    }

    ob_start();
require __DIR__ . '/job-style.php';
// Prevents all jobs, email, phone API from running.
if ((isset($_GET['resources'])) || (isset($_GET['calendar'])) || (isset($_GET['jobRanks'])) || (isset($_GET['profits'])) || (isset($_GET['customers'])) || (isset($_GET['newJob'])) || isset($_GET['viewEid']) && $_GET['viewEid'] > 0 || isset($_GET['editEid']) || isset($_GET['estimates']) || isset($_GET['timeOff']) || isset($_GET['pipeline']) || isset($_GET['jobFinancials']) || isset($_GET['jobInvoice']) || isset($_GET['jobPhotos']) || isset($_GET['jobAddReceipt']) || isset($_GET['scheduleFollowUp']) || isset($_GET['jobSearch']) || isset($_GET['vehicle']) || isset($_GET['business']) || isset($_GET['checkup']) || isset($_GET['help'])){
	$actionSelected = 1;

	$active_nav = '';
	if (isset($_GET['calendar'])) {
		$active_nav = 'calendar';
	} elseif (isset($_GET['resources'])) {
		$active_nav = 'resources';
	} elseif (isset($_GET['profits'])) {
		$active_nav = 'profits';
	} elseif (isset($_GET['customers'])) {
		$active_nav = 'customers';
	} elseif (isset($_GET['vehicle'])) {
		$active_nav = 'vehicle';
	} elseif (isset($_GET['business'])) {
		$active_nav = 'business';
	} elseif (isset($_GET['checkup'])) {
		$active_nav = 'checkup';
	} elseif (isset($_GET['help'])) {
		$active_nav = 'help';
	} elseif (isset($_GET['newJob'])) {
		$active_nav = 'newJob';
	} else {
		// Everything else this big if-condition covers just above
		// (viewEid, editEid, estimates, jobPhotos,
		// jobAddReceipt, jobFinancials, scheduleFollowUp) is a single
		// job's own sub-page, none of them are a separate nav
		// destination, they're all just "inside a job," so the Jobs tile
		// stays highlighted here the same way it already does for
		// newJob, instead of leaving the nav bar showing nothing active.
		$active_nav = 'dashboard';
	}
	echo wp_kses(paintops_render_app_nav($active_nav), paintops_kses_allowed_html(), paintops_kses_protocols());

	if (isset($_GET['estimates'])){
		require __DIR__ . '/estimates.php';
		if (!is_admin()) { get_footer(); exit; }
		return ob_get_clean();
	}
	
	if (isset($_GET['calendar'])){
		require __DIR__ . '/calendar.php';
		if (!is_admin()) { get_footer(); exit; }
		return ob_get_clean();
	}
	
	if (isset($_GET['timeOff'])){
		echo wp_kses(paintops_screen_title('Time Off'), paintops_kses_allowed_html(), paintops_kses_protocols());
		require __DIR__ . '/timeoff.php';
		if (!is_admin()) { get_footer(); exit; }
		return ob_get_clean();
	}

	if (isset($_GET['vehicle'])){
		echo wp_kses(paintops_screen_title('Vehicle Log'), paintops_kses_allowed_html(), paintops_kses_protocols());
		require __DIR__ . '/vehicle-log.php';
		if (!is_admin()) { get_footer(); exit; }
		return ob_get_clean();
	}

	if (isset($_GET['business'])){
		echo wp_kses(paintops_screen_title('Business'), paintops_kses_allowed_html(), paintops_kses_protocols());
		require __DIR__ . '/business.php';
		if (!is_admin()) { get_footer(); exit; }
		return ob_get_clean();
	}

	if (isset($_GET['checkup'])){
		// Business Checkup (Mike 2026-07-23): FREE what's-falling-through-
		// the-cracks scans, see includes/business-checkup.php.
		echo wp_kses(paintops_screen_title('Business Checkup'), paintops_kses_allowed_html(), paintops_kses_protocols());
		require __DIR__ . '/business-checkup.php';
		if (!is_admin()) { get_footer(); exit; }
		return ob_get_clean();
	}

	if (isset($_GET['jobRanks'])){
	if (!paintops_user_can_view_financials()) {
		echo '<p>You do not have permission to view financials.</p>';
	} else {
		require_once __DIR__ . '/job-financials-addon.php';
		require_once __DIR__ . '/job-profit-hour-rankings.php';
		// Guarded since the premium stripping (2026-07-21): the rankings
		// engine is absent from the free build; never fatal a nav route.
		if (function_exists('paintops_render_profit_per_hour_rankings')) {
			echo wp_kses(paintops_render_profit_per_hour_rankings(3), paintops_kses_allowed_html(), paintops_kses_protocols());
		} elseif (function_exists('paintops_render_upgrade_notice')) {
			paintops_render_upgrade_notice('Profit Per Hour Rankings', 'See which jobs made you the most (and least) money per hour worked.');
		} else {
			echo '<p>Profit Per Hour Rankings is a PaintOps Pro feature.</p>';
		}
	}
	}

	if (isset($_GET['pipeline'])){
		require_once __DIR__ . '/job-pipeline.php';
		echo wp_kses(paintops_render_job_pipeline(), paintops_kses_allowed_html(), paintops_kses_protocols());
		if (!is_admin()) { get_footer(); exit; }
		return ob_get_clean();
	}

	if (isset($_GET['jobSearch'])){
		if (!paintops_user_can_manage()) {
			echo '<p>You do not have permission to search jobs.</p>';
		} else {
			echo wp_kses(paintops_render_job_search_page(), paintops_kses_allowed_html(), paintops_kses_protocols());
		}
		if (!is_admin()) { get_footer(); exit; }
		return ob_get_clean();
	}

	if (isset($_GET['profits'])){
		if (!paintops_user_can_view_financials()) {
			echo '<p>You do not have permission to view financials.</p>';
		} else {
			echo wp_kses(paintops_screen_title('Profits'), paintops_kses_allowed_html(), paintops_kses_protocols());
			require_once __DIR__ . '/job-financials-addon.php';
			if (function_exists('paintops_render_yearly_financial_dashboard')) {
				$profitYear = !empty($_GET['profitYear']) ? (int) $_GET['profitYear'] : null;
				paintops_render_yearly_financial_dashboard($profitYear);
			} else {
				// Teaser, not an error (2026-07-21): on a free build the
				// dashboard renderer may be absent entirely.
				if (function_exists('paintops_render_upgrade_notice')) {
					paintops_render_upgrade_notice('Profits', 'See yearly gross revenue, profit, margin, and profit-per-hour across your whole business.');
				} else {
					echo '<p>Profits is a PaintOps Pro feature.</p>';
				}
			}
		}
		if (!is_admin()) { get_footer(); exit; }
		return ob_get_clean();
	}

	if (isset($_GET['customers'])){
		require_once __DIR__ . '/customers.php';
		echo wp_kses(paintops_render_customers_page(), paintops_kses_allowed_html(), paintops_kses_protocols());
		if (!is_admin()) { get_footer(); exit; }
		return ob_get_clean();
	}

	if (isset($_GET['jobInvoice'])){
		$invId = (int) $_GET['jobInvoice'];
		if (!function_exists('paintops_user_can_manage_invoices') || !paintops_user_can_manage_invoices()) {
			echo '<p>You do not have permission to view invoices.</p>';
		} else {
			$invoiceRow = function_exists('paintops_get_invoice') ? paintops_get_invoice($invId) : null;
			if (!$invoiceRow) {
				echo '<p>Invoice not found.</p>';
			} else {
				$invoiceNotice = function_exists('paintops_process_invoice_owner_action') ? paintops_process_invoice_owner_action($invoiceRow) : '';
				if ($invoiceNotice !== '') {
					$invoiceRow = paintops_get_invoice($invId); // reflect status change (e.g. sent)
				}
				echo '<a href="' . esc_url(paintops_admin_url(['jobFinancials' => (int) $invoiceRow->job_id])) . '" class="paintops-back-link paintops-noprint"><span class="dashicons dashicons-arrow-left-alt2" aria-hidden="true"></span>Back to Financials</a>';
				if ($invoiceNotice !== '') { echo wp_kses('<div class="paintops-noprint">' . $invoiceNotice . '</div>', paintops_kses_allowed_html(), paintops_kses_protocols()); }
				echo wp_kses(paintops_render_invoice_document($invoiceRow, false), paintops_kses_allowed_html(), paintops_kses_protocols());
			}
		}
		if (!is_admin()) { get_footer(); exit; }
		return ob_get_clean();
	}

	if (isset($_GET['jobFinancials'])){
		require_once __DIR__ . '/job-financials-addon.php';
		$financialsJobId = (int) $_GET['jobFinancials'];

		if (!paintops_user_can_view_financials()) {
			echo '<p>You do not have permission to edit financials.</p>';
		} else {
			// Inline "Add Expense" form (the same one the ?jobAddReceipt=
			// page uses) posts here now that it is embedded on the
			// Financials page.
			if (isset($_POST['paintops_save_worker_receipt'])) {
				$financialsExpNotices = paintops_save_worker_expense_receipt($financialsJobId);
				foreach ($financialsExpNotices as $financialsExpNotice) {
					echo wp_kses($financialsExpNotice, paintops_kses_allowed_html(), paintops_kses_protocols());
				}
			}

			// Same nonce this page rendered the form with (see
			// paintops_render_job_financial_box()), so this only ever saves
			// the job the form on screen was actually showing.
			if (isset($_POST['cs_save_financials_only'])) {
				$financialsSaveResult = paintops_save_job_financials_from_post($financialsJobId);
				if (!empty($financialsSaveResult['saved'])) {
					echo '<div class="paintops-notice is-success">Financials saved.</div>';
				} elseif (!empty($financialsSaveResult['conflict'])) {
					// Payments or hours changed underneath the open page
					// (a crew check-out, an online payment, or another
					// admin), saving the stale snapshot would have wiped
					// the newer rows (2026-07-23, Mike's concurrency
					// question). The page below now shows the fresh data.
					echo '<div class="paintops-form-errors" role="alert"><strong>Not saved: this job\'s payments or hours changed while the page was open</strong> (a crew check-out or an online payment came in). The form below now shows the latest data, please re-apply your edits and save again.</div>';
				} else {
					// Expired nonce (page open too long), permission, or
					// lapsed Pro: the submit did NOT save. Say so instead
					// of a false success (2026-07-22 audit).
					echo '<div class="paintops-form-errors" role="alert"><strong>Those changes were not saved.</strong> This page had been open too long, so the save was rejected for safety. The form below shows what is actually stored, please re-apply your edits and save again.</div>';
				}
				foreach (($financialsSaveResult['notices'] ?? []) as $financialsSaveNotice) {
					echo wp_kses($financialsSaveNotice, paintops_kses_allowed_html(), paintops_kses_protocols());
				}
			}

			if (function_exists('paintops_process_job_receipts_post')) {
				require_once __DIR__ . '/job-photos.php';
				$receiptsStatusMsg = paintops_process_job_receipts_post($financialsJobId);
				if ($receiptsStatusMsg !== '') {
					echo wp_kses($receiptsStatusMsg, paintops_kses_allowed_html(), paintops_kses_protocols());
				}
			}

			$financialsJobRow = $wpdb->get_row($wpdb->prepare(
				"SELECT * FROM %i WHERE id = %d LIMIT 1", paintops_table('jobs'), $financialsJobId));

			if (!$financialsJobRow) {
				echo '<p>Job not found.</p>';
			} else {
				$financialsIsJobType = paintops_job_record_label((int) $financialsJobRow->jobType, (int) $financialsJobRow->isBid, (string) $financialsJobRow->quote_label);
				$financialsCategory = function_exists('paintops_get_category_details') ? paintops_get_category_details((int) $financialsJobRow->jobCat, 0) : '';
				echo '<a href="' . esc_url(paintops_admin_url(['viewEid' => $financialsJobId])) . '" class="paintops-back-link"><span class="dashicons dashicons-arrow-left-alt2" aria-hidden="true"></span>View ' . esc_html($financialsIsJobType) . '</a>';
				echo wp_kses(paintops_screen_title('Financials for ' . $financialsIsJobType . ' #' . (string) $financialsJobId . ' ' . trim($financialsCategory . ' ' . $financialsJobRow->jobTitle)), paintops_kses_allowed_html(), paintops_kses_protocols());
				echo wp_kses(function_exists('paintops_render_job_customer_summary') ? paintops_render_job_customer_summary($financialsJobRow) : '', paintops_kses_allowed_html(), paintops_kses_protocols());
				// Requests have no financials (Mike 2026-07-23): whether
				// reached from the record or straight from the admin menu,
				// this page just explains when financials will exist,
				// instead of tiles/accordions that read like real money.
				if ((int) $financialsJobRow->jobType !== 2) {
					echo '<div class="cs-financial-box"><p style="margin:0 0 8px;font-weight:600;">Nothing to track yet.</p><p style="margin:0;color:var(--po-text-muted,#777);">Financials &amp; profitability, payments, invoices, expenses, and hours, will appear here once this ' . esc_html(strtolower($financialsIsJobType)) . ' is won and becomes a Job. Win it from the record\'s page ("Mark as Won, convert to Job") or from the Job Board.</p></div>';
					if (!is_admin()) { get_footer(); exit; }
					return ob_get_clean();
				}
				paintops_render_job_financial_box($financialsJobId);
			}
		}

		if (!is_admin()) { get_footer(); exit; }
		return ob_get_clean();
	}

	// Paint Colors used to be its own destination page (?jobPaintColors=X,
	// same "own page" pattern as Financials/Photos above). Mike's call was
	// that a simple color/area/quantity table didn't need a whole extra
	// page, so it's now embedded directly in the Job Edit form instead
	// (see the editEid branch further down, paintops_render_job_paint_colors_box()
	// call). A stale bookmark/link to the old URL just falls through to the
	// normal Jobs list below rather than erroring.

	if (isset($_GET['jobPhotos'])){
		require_once __DIR__ . '/job-photos.php';
		$photosJobId = (int) $_GET['jobPhotos'];

		if (!function_exists('paintops_user_can_manage_job_photos') || !paintops_user_can_manage_job_photos($photosJobId)) {
			echo '<p>You do not have permission to edit job photos.</p>';
		} else {
			$photosStatusMsg = paintops_process_job_photos_post($photosJobId);
			if ($photosStatusMsg !== '') {
				echo wp_kses($photosStatusMsg, paintops_kses_allowed_html(), paintops_kses_protocols());
			}

			$photosJobRow = $wpdb->get_row($wpdb->prepare(
				"SELECT * FROM %i WHERE id = %d LIMIT 1", paintops_table('jobs'), $photosJobId));

			if (!$photosJobRow) {
				echo '<p>Job not found.</p>';
			} else {
				$photosIsJobType = paintops_job_record_label((int) $photosJobRow->jobType, (int) $photosJobRow->isBid, (string) $photosJobRow->quote_label);
				$photosCategory = function_exists('paintops_get_category_details') ? paintops_get_category_details((int) $photosJobRow->jobCat, 0) : '';
				echo '<a href="' . esc_url(paintops_admin_url(['viewEid' => $photosJobId])) . '" class="paintops-back-link"><span class="dashicons dashicons-arrow-left-alt2" aria-hidden="true"></span>View ' . esc_html($photosIsJobType) . '</a>';
				echo wp_kses(paintops_screen_title('Notes for ' . $photosIsJobType . ' #' . (string) $photosJobId . ' ' . trim($photosCategory . ' ' . $photosJobRow->jobTitle)), paintops_kses_allowed_html(), paintops_kses_protocols());
				echo wp_kses(function_exists('paintops_render_job_customer_summary') ? paintops_render_job_customer_summary($photosJobRow) : '', paintops_kses_allowed_html(), paintops_kses_protocols());
				if (function_exists('paintops_render_job_notes_box')) {
					paintops_render_job_notes_box($photosJobId);
				} else {
					paintops_render_job_photos_box($photosJobId);
				}
			}
		}

		if (!is_admin()) { get_footer(); exit; }
		return ob_get_clean();
	}

	if (isset($_GET['jobAddReceipt'])){
		require_once __DIR__ . '/job-financials-addon.php';
		require_once __DIR__ . '/job-photos.php';
		$addReceiptJobId = (int) $_GET['jobAddReceipt'];

		if (!function_exists('paintops_user_can_add_job_receipt') || !paintops_user_can_add_job_receipt($addReceiptJobId)) {
			echo '<p>You do not have permission to add a receipt to this job.</p>';
		} else {
			if (isset($_POST['paintops_save_worker_receipt'])) {
				$addReceiptNotices = paintops_save_worker_expense_receipt($addReceiptJobId);
				foreach ($addReceiptNotices as $addReceiptNotice) {
					echo wp_kses($addReceiptNotice, paintops_kses_allowed_html(), paintops_kses_protocols());
				}
			}

			if (function_exists('paintops_process_job_receipts_post')) {
				$addReceiptDeleteMsg = paintops_process_job_receipts_post($addReceiptJobId);
				if ($addReceiptDeleteMsg !== '') {
					echo wp_kses($addReceiptDeleteMsg, paintops_kses_allowed_html(), paintops_kses_protocols());
				}
			}

			$addReceiptJobRow = $wpdb->get_row($wpdb->prepare(
				"SELECT * FROM %i WHERE id = %d LIMIT 1", paintops_table('jobs'), $addReceiptJobId));

			if (!$addReceiptJobRow) {
				echo '<p>Job not found.</p>';
			} else {
				$addReceiptIsJobType = paintops_job_record_label((int) $addReceiptJobRow->jobType, (int) $addReceiptJobRow->isBid, (string) $addReceiptJobRow->quote_label);
				$addReceiptCategory = function_exists('paintops_get_category_details') ? paintops_get_category_details((int) $addReceiptJobRow->jobCat, 0) : '';
				echo '<a href="' . esc_url(paintops_admin_url(['viewEid' => $addReceiptJobId])) . '" class="paintops-back-link"><span class="dashicons dashicons-arrow-left-alt2" aria-hidden="true"></span>View ' . esc_html($addReceiptIsJobType) . '</a>';
				echo wp_kses(paintops_screen_title('Add Expense for ' . $addReceiptIsJobType . ' #' . (string) $addReceiptJobId . ' ' . trim($addReceiptCategory . ' ' . $addReceiptJobRow->jobTitle)), paintops_kses_allowed_html(), paintops_kses_protocols());
				echo wp_kses(function_exists('paintops_render_job_customer_summary') ? paintops_render_job_customer_summary($addReceiptJobRow) : '', paintops_kses_allowed_html(), paintops_kses_protocols());
				paintops_render_worker_add_receipt_box($addReceiptJobId);
				if (function_exists('paintops_render_job_receipts_box')) {
					// Show the vendor/category/amount columns only to someone
					// allowed to see money (Administrator, or a worker with the
					// view_financials opt-in); a plain Painter/Helper still sees
					// only which receipt goes where, never the dollar amounts,
					// same privacy rule the rest of the app uses. Expense logging
					// itself is free, so this list is reachable on the free tier.
					$addReceiptShowCosts = function_exists('paintops_user_can_view_financials')
						&& paintops_user_can_view_financials();
					paintops_render_job_receipts_box($addReceiptJobId, $addReceiptShowCosts, false); // no "Manage or Add Expenses" button, it linked to this same page (Mike 2026-07-22)
				}
			}
		}

		if (!is_admin()) { get_footer(); exit; }
		return ob_get_clean();
	}

	if (isset($_GET['scheduleFollowUp'])){
		require_once __DIR__ . '/job-followups.php';
		paintops_render_schedule_followup_form((int) $_GET['scheduleFollowUp']);
		if (!is_admin()) { get_footer(); exit; }
		return ob_get_clean();
	}

	if (isset($_GET['help'])){
		echo wp_kses(paintops_screen_title('Help'), paintops_kses_allowed_html(), paintops_kses_protocols());
		if (function_exists('paintops_render_help_center')) {
			paintops_render_help_center();
		}
		if (!is_admin()) { get_footer(); exit; }
		return ob_get_clean();
	}

	if (isset($_GET['resources'])){
		echo wp_kses(paintops_screen_title('Resources'), paintops_kses_allowed_html(), paintops_kses_protocols());
		require_once __DIR__ . '/resource-links.php';
		echo wp_kses(paintops_display_resources(), paintops_kses_allowed_html(), paintops_kses_protocols());
		if (!is_admin()) { get_footer(); exit; }
		return ob_get_clean();
	}
}
else{
	$actionSelected = 0;
}
    // ===================================================
    // Handle new job submission
    // ===================================================
    
    if ((isset($_POST['newjob']) || isset($_POST['editjobId']))
        && wp_verify_nonce(sanitize_text_field(wp_unslash($_POST['paintops_job_nonce'] ?? '')), 'paintops_save_job')
        // A Painter/Helper with the "Can create jobs" permission
        // (paintops_user_can_create_job()) may only ever hit the
        // isset($_POST['newjob']) side of this, editing an existing job
        // (editjobId) stays paintops_user_can_manage()-only regardless of
        // that permission, same scoping used by the routing gate in
        // paintops_fetch_csjobs() above that let this request reach here at all.
        && (paintops_user_can_manage() || (isset($_POST['newjob']) && !isset($_POST['editjobId']) && function_exists('paintops_user_can_create_job') && paintops_user_can_create_job()))) {
		if (isset($_POST['editjobId'])){
		$editJobId = intval($_POST['editjobId']);
		}

        $required_fields = [
			'jobCat'          	 => 'Job Type',
            'custFirst'          => 'First Name',
            'custLast'           => 'Last Name',
            'custServiceAddress' => 'Service Address',
            'custServiceCity'    => 'City',
            'custServiceZip'     => 'Zip',
            'custPhone'          => 'Customer Phone',
            'jobDesc'            => 'Job Description',
        ];

        $missing_fields = [];
        $clean_input    = [];
        $formErr        = '';

        foreach ($required_fields as $field => $label) {
            // wp_unslash BEFORE sanitizing (Mike 2026-07-23: the job view
            // showed \' for apostrophes): WP slashes ALL request data, so
            // without this, names/addresses/descriptions were stored as
            // "O\'Brien". Same fix the Add Expense vendor got 2026-07-22.
            $value = trim(wp_unslash($_POST[$field] ?? '')); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- only length-checked here, sanitized per field type immediately below (sanitize_textarea_field for jobDesc, sanitize_text_field for the rest)
            if ($value === '') {
                $missing_fields[] = $label;
            } else {
				if ($field=='jobDesc'){
				$clean_input[$field] = sanitize_textarea_field($value);
				}
				else {
                $clean_input[$field] = sanitize_text_field($value);
				}
            }
        }

        if ($missing_fields) {
            echo '<div class="paintops-form-errors" role="alert" tabindex="-1"><strong>Missing required fields:</strong> ' . esc_html(implode(', ', $missing_fields)) . '</div>';
        }

        // Create variables for fields. Explicit loop instead of extract()
        // (2026-07-22, Plugin Check flags extract; $clean_input is fully
        // sanitized above, this is a like-for-like replacement).
        foreach ($clean_input as $paintops_ci_key => $paintops_ci_val) {
            // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedVariableFound -- keys come from the fixed $clean_input field whitelist built above, never user-chosen names, so no global (incl. $wpdb) can be overwritten.
            ${$paintops_ci_key} = $paintops_ci_val;
        }

        // Extra fields
        $custEmail  = sanitize_email(wp_unslash($_POST['custEmail'] ?? ''));
        // Not in $required_fields above, State is optional (unlike
        // Address/City/Zip), so a job saved without it never blocks on a
        // "missing field" error.
        $custServiceState = strtoupper(sanitize_text_field(wp_unslash($_POST['custServiceState'] ?? '')));
        $reqWorkersArr = array_map('absint', (array) wp_unslash($_POST['reqWorkers'] ?? []));
        
		//$reqWorkersArr = $_POST['reqWorkers']; // How many workers are required to be there?
		$reqWorkers = '';
		$reqWorkersCount = 0;
		$reqWorkerIds = [];
		if (is_array($reqWorkersArr)){
			foreach ($reqWorkersArr as $reqWorker){
				$reqWorkerIds[] = intval($reqWorker);
			}
		}
		// A Painter/Helper creating this job via the "Can create jobs"
		// permission isn't shown (or wouldn't necessarily check) their own
		// name in the Workers checklist above, without being added here,
		// they'd have no way to view the job they just created at all
		// (paintops_user_can_view_job() only allows an assigned worker
		// through). Staff aren't affected: paintops_user_can_manage()
		// already gives them full access regardless of reqWorkers.
		if (!paintops_user_can_manage() && isset($_POST['newjob'])) {
			$reqWorkerIds[] = get_current_user_id();
		}
		foreach (array_unique(array_filter($reqWorkerIds)) as $reqWorker) {
			$reqWorkers .= '#' . $reqWorker . '#';
			$reqWorkersCount += 1;
		}
		//$reqWorkers = $reqWorker;
		$reqDays    = max(1, intval($_POST['reqDays'] ?? 1)); // How many days are required for the job? Field only shows once "Job" is picked (see updateJobOnlyFieldsVisibility() below); floors to 1 so a quote/estimate/bid saved without it never ends up at 0.
		// Interior/Exterior: not mutually exclusive, a full repaint
		// checks both. Used to filter the Job Category's Scope Components
		// checklist on the New/Edit Quote screen down to what's actually
		// relevant to this job (see estimates.php).
		$scopeInterior = !empty($_POST['scopeInterior']) ? 1 : 0;
		$scopeExterior = !empty($_POST['scopeExterior']) ? 1 : 0;
		// Master Notes: single free-form crew-reference note (materials,
		// door code, parking, pets, quiet hours, etc.). Multi-line free
		// text, so sanitize_textarea_field (keeps newlines, strips tags).
		$master_notes  = sanitize_textarea_field(wp_unslash($_POST['master_notes'] ?? ''));
		// Exclude this job from the Business Financial Report + tax totals.
		$exclude_from_reports = !empty($_POST['exclude_from_reports']) ? 1 : 0;
        $estimateID = intval($_POST['estimateID'] ?? 0);
        $invoiceID  = intval($_POST['invoiceID'] ?? 0);
        $startTime  = sanitize_text_field(wp_unslash($_POST['startTime'] ?? ''));
        $startDay   = intval($_POST['startDay'] ?? '');
        // Which crew quick-filled the workers (0 = none), v1.0.105.
        $crew_id    = intval($_POST['crew_id'] ?? 0);
        $startMonth = intval($_POST['startMonth'] ?? '');
        $startYear  = intval($_POST['startYear'] ?? '');
		// custPhone is intentionally NOT re-read from $_POST here, it's
		// already sanitized via sanitize_text_field() into $clean_input
		// above (custPhone is one of $required_fields) and extracted into
		// scope at line ~731. Re-reading the raw POST value here used to
		// silently overwrite that sanitized value with unsanitized input.
		// $completed is deliberately NO LONGER read or saved here (Mike
		// 2026-07-23): completion is toggled from the job view's
		// Complete/Not Complete pill (paintopsToggleComplete route), the
		// board's pipeline moves, and the worker mark-complete action.
		// The edit form's old "Check If Job Is Complete" checkbox was
		// clutter, and keeping the column out of this save means editing
		// a job can never silently flip its completion state.

		// "Current Status" on the form is one Quote/Estimate/Bid/Job
		// choice, translated here into the fields other features already
		// read: jobType (1 = not yet a job, 2 = Job, unchanged, still
		// what Pipeline/Calendar/Financials/etc. all key off of), isBid
		// (persists even after a bid is awarded and jobType flips to
		// Job, for future bid-specific features like punch lists), and
		// quote_label (Quote vs Estimate is just a wording choice shown
		// on the customer-facing quote page, see
		// paintops_render_public_quote_details() in customer-payments.php).
		$recordStatus = sanitize_text_field(wp_unslash($_POST['recordStatus'] ?? 'quote'));
		// Remember this user's pick so the next New Request form defaults
		// to it, companies have concrete habits about which of these they
		// create (Mike 2026-07-22). See the $currentStatus derivation.
		if (in_array($recordStatus, array('quote', 'estimate', 'bid', 'job'), true) && get_current_user_id()) {
			update_user_meta(get_current_user_id(), 'paintops_last_record_status', $recordStatus);
		}
		$jobType     = ($recordStatus === 'job') ? 2 : 1;
		$isBid       = ($recordStatus === 'bid') ? 1 : 0;
		$quote_label = ($recordStatus === 'estimate') ? 'estimate' : 'quote';
		$jobClass  = intval($_POST['jobClass'] ?? 1); // Repaint, New Construction, etc
		$jobTitle  = sanitize_text_field(wp_unslash($_POST['jobTitle'] ?? ''));
		$payment_plan = paintops_sanitize_payment_plan((string) ($_POST['payment_plan'] ?? (function_exists('paintops_default_payment_plan') ? paintops_default_payment_plan() : 'deposit_online'))); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.ValidatedSanitizedInput.MissingUnslash -- paintops_sanitize_payment_plan() whitelists the value against the fixed plan keys. How the deposit is collected, checked before any Stripe call is made for this job.
		

        // Validate date
        if ($startYear < 2025) {
            $formErr .= "Year must be greater than or equal to 2025<br>";
        } elseif ($startYear > 2099) {
            $formErr .= "Year must be less than 2100<br>";
        }

        if ($startMonth < 1) {
            $formErr .= "Month must be greater than or equal to 1<br>";
        } elseif ($startMonth > 12) {
            $formErr .= "Month must be less than or equal to 12<br>";
        }

        if ($startDay < 1) {
            $formErr .= "Day must be greater than or equal to 1<br>";
        } elseif ($startDay > 31) {
            $formErr .= "Day must be less than or equal to 31<br>";
        }

        if ($formErr) {
            echo wp_kses('<div class="paintops-form-errors" role="alert" tabindex="-1"><strong>There are incorrect items for this job</strong><br>'
                . $formErr . '<br>Please correct this and submit the job again.</div>', paintops_kses_allowed_html(), paintops_kses_protocols());
        }

        // If anything failed validation, make sure the crew actually SEES it.
        // The Save button is fixed to the footer, so after a submit the page
        // can reload scrolled near the bottom with the error off-screen above,
        // which reads as "it saved". This scrolls the first error banner into
        // view (and focuses it) on load so a failed save is never assumed good.
        if ($missing_fields || $formErr) {
            paintops_add_inline_script('(function(){function go(){var e=document.querySelector(".paintops-form-errors");if(!e){return;}try{e.scrollIntoView({behavior:"smooth",block:"center"});}catch(x){e.scrollIntoView();}if(typeof e.focus==="function"){try{e.focus({preventScroll:true});}catch(y){e.focus();}}}if(document.readyState==="loading"){document.addEventListener("DOMContentLoaded",function(){setTimeout(go,60);});}else{setTimeout(go,60);}})();');
        }

        // Insert or update job
        if (empty($missing_fields) && empty($formErr)) {
		$custWPId = intval($_POST['custId'] ?? '');
		$addedBy = intval($_POST['addedBy'] ?? '');

			// Match or create a customer *data record*, never a WP user
			// account. See paintops_find_or_create_customer(): this only
			// ever writes to the customers table, so a customer's info
			// showing up here never silently gives them a login they
			// never asked for.
			//
			// If the "Select Existing Customer" picker on the form was
			// used, trust that ID directly instead of re-matching,
			// avoids creating a duplicate customer record for someone
			// already on file, and avoids accidentally re-linking to a
			// *different* existing customer who happens to share an
			// email or phone number.
			//
			// Otherwise, always match-or-create, this used to be
			// gated on "only if an email was entered," which meant a
			// job saved with just a name/phone/address (email is
			// optional on this form) never created a customer record at
			// all, and so never showed up on the Customers page.
			// custFirst/custLast/custPhone/address are all required
			// fields above, so there's always enough here to record.
			$selectedCustomerId = intval($_POST['selected_customer_id'] ?? 0);
			$customerId = $selectedCustomerId > 0
				? $selectedCustomerId
				: paintops_find_or_create_customer((string) $custEmail, (string) ($custFirst ?? ''), (string) ($custLast ?? ''), (string) ($custPhone ?? ''), (string) ($custServiceAddress ?? ''), (string) ($custServiceCity ?? ''), (string) ($custServiceZip ?? ''), (string) ($custServiceState ?? ''));
            // New job. Updates are passed as a hidden field via $_POST['addedBy']
            if (empty($addedBy)){
				$addedBy = get_current_user_id();
			}
			
			
			$estimateID = $estimateID ?? '';
			$invoiceID = $invoiceID ?? '';
			
			// Collect job data in one place
			$job_data = [
				'jobCat'           => $jobCat,
				'jobClass'         => $jobClass,
				'jobType'          => $jobType,
				'isBid'            => $isBid,
				'quote_label'      => $quote_label,
				'custFirst'        => $custFirst,
				'custLast'         => $custLast,
				'custServiceAddress' => $custServiceAddress,
				'custServiceCity'  => $custServiceCity,
				'custServiceState' => $custServiceState,
				'custServiceZip'   => $custServiceZip,
				'custPhone'        => $custPhone,
				'jobTitle'         => $jobTitle,
				'jobDesc'          => $jobDesc,
				'custEmail'        => $custEmail,
				'reqWorkers'       => $reqWorkers,
				'reqDays'          => $reqDays,
				'crew_id'          => $crew_id,
				'scopeInterior'    => $scopeInterior,
				'scopeExterior'    => $scopeExterior,
				'master_notes'     => $master_notes,
				'exclude_from_reports' => $exclude_from_reports,
				'startTime'        => $startTime,
				'startDay'         => $startDay,
				'startMonth'       => $startMonth,
				'startYear'        => $startYear,
				'estimateID'       => $estimateID,
				'invoiceID'        => $invoiceID,
				'addedBy'          => $addedBy,
				'custId'           => $custWPId,
				'customer_id'      => $customerId,
				'payment_plan'     => $payment_plan,
			];

			$formats = [
				'jobCat'           => '%d',
				'jobClass'         => '%d',
				'jobType'          => '%d',
				'isBid'            => '%d',
				'quote_label'      => '%s',
				'custFirst'        => '%s',
				'custLast'         => '%s',
				'custServiceAddress' => '%s',
				'custServiceCity'  => '%s',
				'custServiceState' => '%s',
				// Was '%d', silently mangled any zip with a leading zero
				// (Boston's 02134 became 2134) and would fully break on
				// alphanumeric postal codes (Canada, UK, etc.). A postal
				// code is an identifier, never a number to do math on.
				'custServiceZip'   => '%s',
				'custPhone'        => '%s',
				'jobTitle'         => '%s',
				'jobDesc'          => '%s',
				'custEmail'        => '%s',
				'reqWorkers'       => '%s',
				'reqDays'          => '%d',
				'crew_id'          => '%d',
				'scopeInterior'    => '%d',
				'scopeExterior'    => '%d',
				'master_notes'     => '%s',
				'exclude_from_reports' => '%d',
				'startTime'        => '%s',
				'startDay'         => '%d',
				'startMonth'       => '%d',
				'startYear'        => '%d',
				'estimateID'       => '%d',
				'invoiceID'        => '%d',
				'payment_plan'     => '%s',
				'addedBy'          => '%d',
				'custId'           => '%d',
				'customer_id'      => '%d',
			];
			// Built by walking $job_data's own key order (not $formats')
			// and looking up each field's format by name, $wpdb->insert()/
			// update() match a format ARRAY to a data array purely by
			// position, not by key, so if $job_data and $formats ever list
			// their keys in a different order, fields silently get the
			// wrong format (this previously corrupted payment_plan,
			// a string, into 0 by matching it against customer_id's %d).
			// Deriving the format list from $job_data's actual order makes
			// this correct regardless of how $formats above is ordered.
			$job_formats = [];
			foreach (array_keys($job_data) as $job_data_key) {
				$job_formats[] = $formats[$job_data_key] ?? '%s';
			}
			// Update existing job
			if (isset($editJobId) && $editJobId > 0){
			// (2026-07-19, v1.0.104) If the START DATE changed, clear the
			// per-worker notification ledger (and the legacy notifsSent
			// flag) so the night-before cron re-notifies EVERYONE for the
			// new date; a stale "you were told about Tuesday" ledger must
			// not suppress the email about Thursday. A reqWorkers-only
			// change deliberately does NOT clear it, the ledger's whole
			// point is that newly added workers get emailed while the
			// already-notified crew isn't re-spammed (see
			// paintops_run_job_notifications in job-manager-notifications.php).
			$prior_dates = $wpdb->get_row($wpdb->prepare(
				"SELECT startYear, startMonth, startDay FROM %i WHERE id = %d LIMIT 1", paintops_table('jobs'), $editJobId));
			if ($prior_dates
				&& ((int) $prior_dates->startYear !== (int) $startYear
					|| (int) $prior_dates->startMonth !== (int) $startMonth
					|| (int) $prior_dates->startDay !== (int) $startDay)) {
				$job_data['notified_worker_ids'] = '';
				$job_data['notifsSent'] = 0;
				$job_formats[] = '%s';
				$job_formats[] = '%d';
			}
			$result = $wpdb->update(
				paintops_table('jobs'),
				$job_data,
				[ 'id' => $editJobId ],   // WHERE clause
				array_values($job_formats), // field formats
				[ '%d' ]              // id format
			);
			}
			// Insert new job
			else{
				$result = $wpdb->insert(
					paintops_table('jobs'),
					$job_data,
					array_values($job_formats) // extract formats as numeric array
				);
			}

            if ($result === false) {
                paintops_report_db_error('save job');

            } else {
				$isJobType = paintops_job_record_label((int) $jobType, (int) $isBid, (string) $quote_label);
				$bannerStyle = "margin:14px 0;padding:12px 16px;background:#d7f5d7;border-radius:6px;font-size:15px;";
				$savedJobId = (isset($editJobId) && $editJobId > 0) ? (int) $editJobId : intval($wpdb->insert_id);

				// Same "does this job already have a quote/estimate/bid
				// document?" check the job list cards use, offer the
				// natural next step right in the save banner instead of
				// making you go find it: a link to the existing document,
				// or straight to "Create Quote" if there isn't one yet.
				// A Painter/Helper who just created this job via the
				// "Can create jobs" permission never gets "View Quote"
				// (viewID stays staff-only, see estimates.php's own gate)
				// and only gets "Create Quote" if they also separately have
				// "Can create quotes"; the "edit it" link is likewise
				// staff-only (editEid), so it's dropped for them too.
				if ($jobType !== 2 && function_exists('paintops_clear_estimate_win_stamp')) {
					paintops_clear_estimate_win_stamp($savedJobId);
				}
				$bannerJobRow = $wpdb->get_row($wpdb->prepare(
					"SELECT estimateID, isBid, quote_label FROM %i WHERE id = %d LIMIT 1", paintops_table('jobs'), $savedJobId));
				$bannerEstimateId = (int) ($bannerJobRow->estimateID ?? 0);
				// Quote/Estimate/Bid, so the banner link says what the
				// document is ("Bid #2 was updated ... View Quote" read
				// wrong, Mike 2026-07-22).
				$bannerDocWord = function_exists('paintops_quote_doc_word')
					? paintops_quote_doc_word((int) ($bannerJobRow->isBid ?? 0), (string) ($bannerJobRow->quote_label ?? 'quote'))
					: 'Quote';
				$bannerIsStaff = paintops_user_can_manage();
				if ($bannerIsStaff) {
					$quoteLinkHtml = ($bannerEstimateId > 0)
						? " <a href=\"" . paintops_href(['estimates' => 1, 'viewID' => (int) $bannerEstimateId]) . "\">View {$bannerDocWord} Document</a>"
						: " <a href=\"" . paintops_href(['estimates' => 1, 'csaction' => 'create', 'jobID' => (int) $savedJobId]) . "\">Create {$bannerDocWord}</a>";
				} elseif (function_exists('paintops_user_can_create_quote') && paintops_user_can_create_quote($savedJobId)) {
					$quoteLinkHtml = " <a href=\"" . paintops_href(['estimates' => 1, 'csaction' => 'create', 'jobID' => (int) $savedJobId]) . "\">Create {$bannerDocWord}</a>";
				} else {
					$quoteLinkHtml = '';
				}

				if (isset($editJobId) && $editJobId > 0){
					// Financials/Photos are still saved separately via their
					// own pages (?jobFinancials=ID / ?jobPhotos=ID). Paint
					// Colors used to be the same way, but now rides along in
					// this same POST (see paintops_render_job_paint_colors_box()'s
					// $embedded=true fields in the form above), so it's saved
					// right here alongside the job itself.
					if (function_exists('paintops_save_job_paint_colors_from_post')) {
						paintops_save_job_paint_colors_from_post($editJobId);
					}
					echo wp_kses("<div class=\"notice notice-success paintops-save-banner\" tabindex=\"-1\" style=\"$bannerStyle\">{$isJobType} #{$editJobId} was updated. <a href=\"" . paintops_href(['viewEid' => (int) $editJobId]) . "\">View it here</a>.{$quoteLinkHtml}</div>", paintops_kses_allowed_html(), paintops_kses_protocols());

				} elseif ($bannerIsStaff) {
					echo wp_kses("<div class=\"notice notice-success paintops-save-banner\" tabindex=\"-1\" style=\"$bannerStyle\">{$isJobType} #{$savedJobId} was added. <a href=\"" . paintops_href(['viewEid' => (int) $savedJobId]) . "\">View it</a> or <a href=\"" . paintops_href(['editEid' => (int) $savedJobId]) . "\">edit it</a> if you need to make changes.{$quoteLinkHtml}</div>", paintops_kses_allowed_html(), paintops_kses_protocols());
				} else {
					echo wp_kses("<div class=\"notice notice-success paintops-save-banner\" tabindex=\"-1\" style=\"$bannerStyle\">{$isJobType} #{$savedJobId} was added. <a href=\"" . paintops_href(['viewEid' => (int) $savedJobId]) . "\">View it</a>.{$quoteLinkHtml}</div>", paintops_kses_allowed_html(), paintops_kses_protocols());
				}

				// The Save button is fixed to the footer, so after a submit the
				// page can stay scrolled near the bottom, leaving this success
				// banner off-screen at the top (reads as "nothing happened").
				// Scroll it into view and focus it on load, mirroring the error
				// banner's behavior above.
				paintops_add_inline_script('(function(){function go(){var e=document.querySelector(".paintops-save-banner");if(!e){return;}try{e.scrollIntoView({behavior:"smooth",block:"center"});}catch(x){e.scrollIntoView();}if(typeof e.focus==="function"){try{e.focus({preventScroll:true});}catch(y){e.focus();}}}if(document.readyState==="loading"){document.addEventListener("DOMContentLoaded",function(){setTimeout(go,60);});}else{setTimeout(go,60);}})();');
            }
        }
    }
	else{
		paintops_enqueue_style('job-form-hidden-box');
	}
    // ===================================================
    // Handle delete
    // ===================================================
    if (!empty($_GET['delEid'])) {
        $event_id = intval($_GET['delEid']);
        // Administrators only (manage_options), matching the Delete control on
        // the job view. Tightened from the old staff-level gate now that this
        // route triggers the full cascade delete (paintops_delete_job_deep).
        $valid_request = current_user_can('manage_options')
            && wp_verify_nonce(sanitize_text_field(wp_unslash($_GET['_wpnonce'] ?? '')), 'paintops_delete_job_' . $event_id);

        if (!$valid_request) {
            echo "<div style=\"color:red\">That delete link is invalid or expired.</div>";
        } else {
            // Deep delete: removes the job AND all of its child records
            // (estimates, financials, expenses, hours, payments, paint
            // colors, photos/receipts + their media files, check-ins,
            // invoices) plus any follow-up visits, rather than the old
            // shallow jobs-row-only delete that orphaned all of that.
            $deleted = function_exists('paintops_delete_job_deep')
                ? paintops_delete_job_deep($event_id)
                : $wpdb->delete(paintops_table('jobs'), [ 'id' => $event_id ], [ '%d' ]);
            if ($deleted) {
                echo wp_kses("<div class=\"paintops-notice is-success\">Job #$event_id deleted.</div>", paintops_kses_allowed_html(), paintops_kses_protocols());
            }
        }
    }

    // Follow-Up email compose/preview screen (Mike's request 2026-07-21;
    // replaces the old one-click ?sendFollowupEmail= GET, which sent the
    // templated email immediately with no preview and no chance to edit).
    // GET ?followupCompose=<job> renders the prefilled, EDITABLE email;
    // only the form's POST actually sends. Old ?sendFollowupEmail= links
    // are treated as an alias for the compose screen, nothing sends on
    // GET anymore.
    $followup_compose_id = intval($_GET['followupCompose'] ?? ($_GET['sendFollowupEmail'] ?? 0));
    if ($followup_compose_id > 0) {
        if (!paintops_user_can_manage()) {
            echo "<div style=\"color:red\">You do not have permission to send follow-ups.</div>";
        } else {
            $followup_job = $wpdb->get_row($wpdb->prepare("SELECT * FROM %i WHERE id = %d", paintops_table('jobs'), $followup_compose_id));
            $followup_estimate = $followup_job && function_exists('paintops_get_latest_estimate_for_job')
                ? paintops_get_latest_estimate_for_job($followup_compose_id)
                : null;

            // Same "at least a day old and not yet accepted" rule the
            // Send Follow-Up link is only shown under (see estimates.php),
            // enforced server-side so it can't be bypassed by hand-typing.
            $followup_eligible = $followup_estimate
                && empty($followup_estimate->accepted)
                && !empty($followup_estimate->createdOn)
                && strtotime($followup_estimate->createdOn) <= strtotime('-1 day', current_time('timestamp'));

            if (!$followup_job || !$followup_estimate) {
                echo "<div style=\"color:red\">Couldn't find a quote for that job to follow up on.</div>";
            } elseif (!$followup_eligible) {
                echo "<div style=\"color:red\">This quote is too new or has already been accepted, so there's nothing to follow up on.</div>";
            } elseif (empty($followup_job->custEmail) || !is_email($followup_job->custEmail)) {
                echo "<div style=\"color:red\">No valid email address is on file for this customer.</div>";
            } elseif (isset($_POST['paintops_send_followup'])
                && wp_verify_nonce(sanitize_text_field(wp_unslash($_POST['paintops_followup_nonce'] ?? '')), 'paintops_send_followup_' . $followup_compose_id)) {
                // The SEND: uses whatever the owner edited on the form.
                $followup_subject = sanitize_text_field(wp_unslash($_POST['followup_subject'] ?? ''));
                $followup_body    = sanitize_textarea_field(wp_unslash($_POST['followup_body'] ?? ''));
                if ($followup_subject === '' || $followup_body === '') {
                    echo "<div style=\"color:red\">Subject and message are both required.</div>";
                } elseif (wp_mail($followup_job->custEmail, $followup_subject, $followup_body, paintops_mail_headers())) {
                    if (function_exists('paintops_log_communication')) {
                        paintops_log_communication([
                            'job_id'      => (int) $followup_job->id,
                            'estimate_id' => (int) $followup_estimate->estID,
                            'comm_type'   => 'followup_email',
                            'recipient'   => (string) $followup_job->custEmail,
                            'subject'     => $followup_subject,
                        ]);
                    }
                    echo "<div class=\"paintops-notice is-success\">Follow-up email sent to " . esc_html($followup_job->custEmail) . ".</div>";
                    echo '<a href="' . paintops_href(['viewEid' => (int) $followup_job->id]) . '" class="paintops-back-link"><span class="dashicons dashicons-arrow-left-alt2" aria-hidden="true"></span>View Job</a>';
                    if (!is_admin()) { get_footer(); exit; }
                } else {
                    echo "<div style=\"color:red\">The email could not be sent. Check your site's email settings and try again.</div>";
                }
            } else {
                // The COMPOSE screen: template prefilled with placeholders
                // resolved, fully editable before anything is sent.
                $followup_template = function_exists('paintops_followup_template') ? paintops_followup_template() : ['email_subject' => '', 'email_body' => ''];
                $followup_prefill_subject = function_exists('paintops_render_followup_placeholders')
                    ? paintops_render_followup_placeholders($followup_template['email_subject'], $followup_job, $followup_estimate)
                    : $followup_template['email_subject'];
                $followup_prefill_body = function_exists('paintops_render_followup_placeholders')
                    ? paintops_render_followup_placeholders($followup_template['email_body'], $followup_job, $followup_estimate)
                    : $followup_template['email_body'];
                echo wp_kses(paintops_screen_title('Send Follow-Up'), paintops_kses_allowed_html(), paintops_kses_protocols());
                ?>
                <div class="cs-financial-box">
                    <p style="margin-top:0;color:var(--po-text-muted,#777);font-size:13px;">Review and edit before sending. The wording comes from your Follow-Up Template (Business Settings); changes here apply to this email only.</p>
                    <form method="post">
                        <?php wp_nonce_field('paintops_send_followup_' . (int) $followup_job->id, 'paintops_followup_nonce'); ?>
                        <p>
                            <label><strong>To</strong></label><br>
                            <input type="text" value="<?php echo esc_attr($followup_job->custEmail); ?>" readonly style="width:100%;max-width:420px;background:var(--po-tile-bg,#f6f7f8);">
                        </p>
                        <p>
                            <label for="followup_subject"><strong>Subject</strong></label><br>
                            <input type="text" id="followup_subject" name="followup_subject" value="<?php echo esc_attr($followup_prefill_subject); ?>" required style="width:100%;max-width:560px;">
                        </p>
                        <p>
                            <label for="followup_body"><strong>Message</strong></label><br>
                            <textarea id="followup_body" name="followup_body" rows="10" required style="width:100%;max-width:560px;"><?php echo esc_textarea($followup_prefill_body); ?></textarea>
                        </p>
                        <p>
                            <button type="submit" name="paintops_send_followup" value="1" class="paintops-btn paintops-btn-primary">Send Follow-Up Email</button>
                            <a href="<?php echo paintops_href(['viewEid' => (int) $followup_job->id]); ?>" class="paintops-back-link" style="margin-left:10px;"><span class="dashicons dashicons-arrow-left-alt2" aria-hidden="true"></span>Cancel</a>
                        </p>
                    </form>
                </div>
                <?php
                if (!is_admin()) { get_footer(); exit; }
            }
        }
    }

    // Google review request compose/preview screen (premium; the render
    // function only exists in the Pro build, so the free build falls
    // straight through to the normal Jobs page).
    $review_compose_id = intval($_GET['reviewCompose'] ?? 0);
    if ($review_compose_id > 0 && function_exists('paintops_render_review_compose_page')) {
        paintops_render_review_compose_page($review_compose_id);
    }

    // ===================================================
    // Generate form
    // ===================================================
if (isset($_GET['newJob']) || isset($_GET['editEid'])){
	// Generate results to edit
	if (isset($_GET['editEid'])){
		$getEditId = intval ($_GET['editEid']);


		$submitName = "Update Job";
		$results = $wpdb->get_results($wpdb->prepare("SELECT * FROM %i WHERE id = %d LIMIT 1", paintops_table('jobs'), $getEditId));

		if ($wpdb->last_error) {
			paintops_report_db_error();
		}
		if ($results) {
			foreach ($results as $row) {
				foreach ($row as $field => $value) {
					// phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedVariableFound -- keys are column names of a plugin-owned table row, a fixed schema list, so no global (incl. $wpdb) can be overwritten.
					${$field} = $value; // creates $custFirst, $custLast, etc.
				}
			}
			$formAction = "
			<input type=\"hidden\" name=\"editjobId\" value=\"$getEditId\">
			<input type=\"hidden\" name=\"addedBy\" value=\"$addedBy\">
			<input type=\"hidden\" name=\"custId\" value=\"$custId\">
			<input type=\"hidden\" name=\"estimateID\" value=\"$estimateID\">
			<input type=\"hidden\" name=\"invoiceID\" value=\"$invoiceID\">
			" . wp_nonce_field('paintops_save_job', 'paintops_job_nonce', true, false) . "
			";
		}
	}
	else{
		$formAction = "<input type=\"hidden\" name=\"newjob\" value=\"1\">" . wp_nonce_field('paintops_save_job', 'paintops_job_nonce', true, false);
		$submitName = "Create";
		//$jobCat = $jobTitle = $jobDesc = $custFirst = $custLast = $custServiceAddress = $custPhone = $custEmail = $custServiceCity = $custServiceZip = $custServiceZip ?? '';

		// Arriving here from a "New Request" link on the Customers page
		// (?newJob=1&customerId=X), pre-fill the customer fields and
		// remember the ID so the existing-customer picker below shows
		// this customer already selected, instead of making someone
		// re-pick a customer they just came from.
		$prefillCustomerId = intval($_GET['customerId'] ?? 0);
		if ($prefillCustomerId > 0) {
			$prefill_customer_row = $wpdb->get_row($wpdb->prepare(
				"SELECT id, first_name, last_name, service_address, service_city, service_state, service_zip, phone, email FROM %i WHERE id = %d LIMIT 1", paintops_table('customers'), $prefillCustomerId));
			if ($prefill_customer_row) {
				$custFirst          = $prefill_customer_row->first_name;
				$custLast           = $prefill_customer_row->last_name;
				$custServiceAddress = $prefill_customer_row->service_address;
				$custServiceCity    = $prefill_customer_row->service_city;
				$custServiceState   = $prefill_customer_row->service_state;
				$custServiceZip     = $prefill_customer_row->service_zip;
				$custPhone          = $prefill_customer_row->phone;
				$custEmail          = $prefill_customer_row->email;
			} else {
				$prefillCustomerId = 0; // not found, fall back to a blank form
			}
		}

		// Arriving here from the "+" button on a calendar day (?newJob=1&
		// prefillDate=YYYY-MM-DD), pre-fill the Scheduling date field so
		// creating a job from the calendar doesn't require re-picking the
		// date you just clicked on.
		$prefillDate = sanitize_text_field(wp_unslash($_GET['prefillDate'] ?? ''));
		if ($prefillDate !== '' && preg_match('/^(\d{4})-(\d{2})-(\d{2})$/', $prefillDate, $prefillDateParts)) {
			if (checkdate((int) $prefillDateParts[2], (int) $prefillDateParts[3], (int) $prefillDateParts[1])) {
				$startYear  = (int) $prefillDateParts[1];
				$startMonth = (int) $prefillDateParts[2];
				$startDay   = (int) $prefillDateParts[3];
			}
		}
	}
	// toggleFade + the date-field sync + the job-only field visibility
	// toggle for this form load from assets/js/job-form.js (footer;
	// toggleFade is only ever invoked by user clicks on onclick= toggles,
	// so footer timing is safe).
	paintops_enqueue_script('job-form');
?>

<div class="job-form">
	
	<?php
	if (isset($_GET['editEid'])){
		$isJobType = paintops_job_record_label((int) ($jobType ?? 1), (int) ($isBid ?? 0), (string) ($quote_label ?? 'quote'));

	  echo wp_kses(paintops_screen_title("Edit $isJobType #$getEditId") . "
	  <div>", paintops_kses_allowed_html(), paintops_kses_protocols());
	}
	else{
	  // Nav is already shown once at the top of the page (see the routing
	  // block above), this branch only runs for ?newJob=1, its own
	  // dedicated page now rather than a collapsible box on the
	  // dashboard, so no need to render it again here.
	  echo "<h2 id=\"paintopsNewJobHeading\" class=\"po-screen-title\">New Request</h2>
	  <div>";
	  // Every "+ New Request" link across the app (Jobs list, Customers
	  // page, dashboard alerts) points at ?newJob=1, the Current Status
	  // pill just inside the form is where Quote/Estimate/Bid/Job actually
	  // gets picked, so this heading deliberately doesn't try to name all
	  // four itself. Landing here puts the nav bar above the fold, so
	  // jump straight past it to the actual form instead of leaving the
	  // visitor to scroll down manually every time.
	  paintops_add_inline_script("document.addEventListener('DOMContentLoaded', function () { var h = document.getElementById('paintopsNewJobHeading'); if (h) { h.scrollIntoView({block: 'start'}); } });");
	}
	
	$jobCat = $jobCat ?? '';
	$jobClass = $jobClass ?? '';
	$jobType = $jobType ?? '';
	$reqDays = (isset($reqDays) && $reqDays !== '') ? $reqDays : 1; // Defaults to 1 day, shown/editable whenever "Job" is picked, new or existing.
	$isBid = $isBid ?? 0;
	$quote_label = $quote_label ?? 'quote';
	$payment_plan = $payment_plan ?? 'deposit_online';
	$jobTitle = $jobTitle ?? '';
	$jobDesc = $jobDesc ?? '';
	$master_notes = $master_notes ?? '';
	$exclude_from_reports = isset($exclude_from_reports) ? (int) $exclude_from_reports : 0;
	$custFirst = $custFirst ?? '';
	$custLast = $custLast ?? '';
	$custServiceAddress = $custServiceAddress ?? '';
	$custServiceCity = $custServiceCity ?? '';
	$custServiceState = $custServiceState ?? '';
	$custServiceZip = $custServiceZip ?? '';
	$custPhone = $custPhone ?? '';
	$custEmail = $custEmail ?? '';

	// The single "Current Status" dropdown below shows one of four
	// choices, derived from jobType + isBid + quote_label (which is all
	// that's actually stored), Job always wins if jobType is already
	// 2, then Bid, then Estimate, else Quote. New (non-edit) forms fall
	// through to 'quote', matching what "Quote" used to be the only
	// real default.
	if ((string) $jobType === '2') {
		$currentStatus = 'job';
	} elseif (!empty($isBid)) {
		$currentStatus = 'bid';
	} elseif ($quote_label === 'estimate') {
		$currentStatus = 'estimate';
	} else {
		$currentStatus = 'quote';
	}

	// A NEW request defaults to whatever this user picked last time
	// (stored at save, see paintops_last_record_status), quoting habits
	// are sticky per company. An edit keeps showing the record's real
	// stored status derived above.
	if (!isset($_GET['editEid'])) {
		$paintopsLastStatus = (string) get_user_meta(get_current_user_id(), 'paintops_last_record_status', true);
		if (in_array($paintopsLastStatus, array('quote', 'estimate', 'bid', 'job'), true)) {
			$currentStatus = $paintopsLastStatus;
		}
	}

	?>

	<?php if (paintops_maps_api_key() !== '') : ?>
	<?php // The Maps JS loader is enqueued with the script that defines
	      // initAutocomplete as a dependency (see the addr-row below), so the
	      // async load can never run the callback before it exists (Mike
	      // 2026-07-24: autocomplete dead until a refresh won the race). ?>
	<?php else : ?>
	<p class="description" style="margin:0 0 10px;color:var(--po-text-muted,#777);"><em>Address autocomplete and geocoding are off (no Google Maps API key set). Type the address manually below, or <a href="<?php echo esc_url(paintops_settings_open_url('admin.php?page=paintops-settings&po_open=setup-health&po_entry=google-maps-key')); ?>">add a key under PaintOps &gt; Settings</a> to turn them on.</em></p>
	<?php endif; ?>


  <form method="POST">
	<span class="paintops-required">*</span> Required fields.<hr><br>
	

<div class="job-form-section">Customer Info</div>

<?php
// Existing-customer picker: fills First/Last/Address/City/Zip/Phone/
// Email from a customer already in the Customers table (see
// includes/customers.php), the same "select known, or + New" pattern
// already used for expense Vendors on the Financials form. "+ New
// Customer" (or leaving the picker on its default option) just leaves
// the fields below exactly as they already were, manual entry, same
// as before this picker existed.
$existing_customers = $wpdb->get_results( $wpdb->prepare(
    "SELECT id, first_name, last_name, service_address, service_city, service_state, service_zip, phone, email FROM %i ORDER BY first_name ASC, last_name ASC", paintops_table('customers')) );
// Keep the manual entry fields collapsed by default only when a real,
// specific customer is already selected, either because we're editing a
// job that has real data to show, or because we arrived here from a "New
// Quote" link on the Customers page with $prefillCustomerId already set
// (see above). Otherwise (no customers yet, or a brand-new job where
// nobody has picked anyone from the dropdown) show the fields open,
// there's no other way to type in a new customer's info, since the
// picker's own "+ New Customer" option only reveals them via JS after
// it's touched (see the change handler below), and someone creating a
// job as a shortcut may never touch the picker at all.
$show_customer_fields = empty($existing_customers) || isset($_GET['editEid']) || empty($prefillCustomerId ?? 0);
$customer_lookup = [];
foreach (($existing_customers ?: []) as $c) {
    $customer_lookup[(int) $c->id] = [
        'first'   => $c->first_name,
        'last'    => $c->last_name,
        'address' => $c->service_address,
        'city'    => $c->service_city,
        'state'   => $c->service_state,
        'zip'     => $c->service_zip,
        'phone'   => $c->phone,
        'email'   => $c->email,
    ];
}

// Multiple addresses per customer (2026-07-19, Mike's ask, DERIVED
// design): a customer's known addresses = their primary (above) plus
// every distinct service address on their past jobs, no separate
// address book to maintain, importing old jobs grows each customer's
// list automatically. One grouped query for all customers; each
// customer's list is attached to the same lookup map the picker JS
// already uses. Dedupe is case-insensitive on address+zip.
$paintops_addr_rows = $wpdb->get_results( $wpdb->prepare(
    "SELECT customer_id, custServiceAddress, custServiceCity, custServiceState, custServiceZip, COUNT(*) AS uses
     FROM %i
     WHERE customer_id > 0 AND custServiceAddress != ''
     GROUP BY customer_id, custServiceAddress, custServiceCity, custServiceState, custServiceZip", paintops_table('jobs')) );
foreach ((array) $paintops_addr_rows as $ar) {
    $cid = (int) $ar->customer_id;
    if (!isset($customer_lookup[$cid])) {
        continue;
    }
    if (!isset($customer_lookup[$cid]['addresses'])) {
        // Primary first (when set), so it stays the default pick.
        $customer_lookup[$cid]['addresses'] = [];
        if (trim((string) $customer_lookup[$cid]['address']) !== '') {
            $customer_lookup[$cid]['addresses'][] = [
                'address' => $customer_lookup[$cid]['address'],
                'city'    => $customer_lookup[$cid]['city'],
                'state'   => $customer_lookup[$cid]['state'],
                'zip'     => $customer_lookup[$cid]['zip'],
            ];
        }
    }
    $dupe = false;
    foreach ($customer_lookup[$cid]['addresses'] as $known) {
        if (strcasecmp(trim((string) $known['address']), trim((string) $ar->custServiceAddress)) === 0
            && strcasecmp(trim((string) $known['zip']), trim((string) $ar->custServiceZip)) === 0) {
            $dupe = true;
            break;
        }
    }
    if (!$dupe) {
        $customer_lookup[$cid]['addresses'][] = [
            'address' => $ar->custServiceAddress,
            'city'    => $ar->custServiceCity,
            'state'   => $ar->custServiceState,
            'zip'     => $ar->custServiceZip,
        ];
    }
}
?>
<div>
    <label for="existingCustomerPicker">Select Existing Customer</label>
    <select id="existingCustomerPicker">
        <option value="">-- Select existing customer --</option>
        <?php foreach (($existing_customers ?: []) as $c) :
            $picker_label = trim($c->first_name . ' ' . $c->last_name);
            if ($picker_label === '') { $picker_label = 'Customer #' . $c->id; }
            if (!empty($c->service_address)) { $picker_label .= ', ' . $c->service_address; }
        ?>
        <option value="<?php echo (int) $c->id; ?>" <?php selected((int) $c->id, (int) ($prefillCustomerId ?? 0)); ?>><?php echo esc_html($picker_label); ?></option>
        <?php endforeach; ?>
        <option value="__new__">+ New Customer</option>
    </select>
    <input type="hidden" id="selected_customer_id" name="selected_customer_id" value="<?php echo esc_attr(!empty($prefillCustomerId) ? (int) $prefillCustomerId : ''); ?>">
</div>

<div id="customerAddressPickWrap" style="display:none;margin-top:10px;">
    <label for="customerAddressPick">Service address for this job</label>
    <select id="customerAddressPick"></select>
</div>

<?php
// Existing-customer picker behavior (fill/collapse the manual fields,
// known-address picker) lives in assets/js/job-form-customer-picker.js;
// the lookup map built above rides along as its config.
paintops_enqueue_script('job-form-customer-picker', 'var paintopsCustomerPickerData = ' . wp_json_encode($customer_lookup) . ';');
?>

    <div id="customerManualFields" class="hidden-box<?php echo esc_attr($show_customer_fields ? ' show' : ''); ?>">
    <label for="custFirst">First Name <span class="paintops-required">*</span></label>
    <input type="text" id="custFirst" name="custFirst" autocomplete="given-name" autocapitalize="words" placeholder="i.e. Stacy" value="<?php echo esc_attr($custFirst); ?>">

    <label for="custLast">Last Name <span class="paintops-required">*</span></label>
    <input type="text" id="custLast" name="custLast" autocomplete="family-name" autocapitalize="words"  placeholder="i.e. Holmes" value="<?php echo esc_attr($custLast); ?>">
<?php
// City/State/Zip placeholder examples pulled from this business's own
// Company Profile address (PaintOps > Business > Company Profile),
// falls back to a generic example for any part that isn't set/parseable,
// e.g. on a fresh install with only the sample defaults still in place.
$paintopsAddrFmt  = function_exists('paintops_address_format') ? paintops_address_format() : ['state_label' => 'State', 'state_maxlength' => 2, 'state_uppercase' => true, 'zip_label' => 'Zip Code', 'example_city' => 'Englewood', 'example_state' => 'FL', 'example_zip' => '34224'];
$paintopsBizAddr  = function_exists('paintops_business_address_parts') ? paintops_business_address_parts() : ['city' => '', 'state' => '', 'zip' => ''];
$cityPlaceholder  = 'i.e. ' . ($paintopsBizAddr['city']  !== '' ? $paintopsBizAddr['city']  : $paintopsAddrFmt['example_city']);
$statePlaceholder = 'i.e. ' . ($paintopsBizAddr['state'] !== '' ? $paintopsBizAddr['state'] : $paintopsAddrFmt['example_state']);
$zipPlaceholder   = 'i.e. ' . ($paintopsBizAddr['zip']   !== '' ? $paintopsBizAddr['zip']   : $paintopsAddrFmt['example_zip']);
// Country (Business Settings > Company Profile) drives the State/Zip
// field labels below and whether State gets capped to 2 letters + forced
// uppercase, US/Canadian province codes fit that, Australian ones
// (NSW, VIC...) don't, and most other countries don't use a short code
// at all, so this only applies where it actually holds.
$stateFieldLabel = esc_html($paintopsAddrFmt['state_label']);
$zipFieldLabel   = esc_html($paintopsAddrFmt['zip_label']);
$stateMaxlengthAttr = ((int) $paintopsAddrFmt['state_maxlength'] > 0) ? ' maxlength="' . (int) $paintopsAddrFmt['state_maxlength'] . '"' : '';
$stateUppercaseStyle = !empty($paintopsAddrFmt['state_uppercase']) ? ' style="text-transform:uppercase;"' : '';
?>
<div class="addr-row">
	<?php
	// Defines the global initAutocomplete() Maps callback
	// (assets/js/job-form-address-autocomplete.js); the Google loader below
	// lists that handle as a dependency, so the async Maps load can never
	// run the callback before it exists (Mike 2026-07-24: autocomplete dead
	// until a refresh won the race).
	paintops_enqueue_script('job-form-address-autocomplete');
	if (paintops_maps_api_key() !== '') {
		wp_enqueue_script('paintops-google-maps-job-form', 'https://maps.googleapis.com/maps/api/js?key=' . paintops_maps_api_key() . '&loading=async&libraries=places&callback=initAutocomplete', ['paintops-job-form-address-autocomplete'], null, ['strategy' => 'async', 'in_footer' => true]); // phpcs:ignore WordPress.WP.EnqueuedResourceParameters.MissingVersion -- Google serves the current Maps JS; a version pin is not applicable
	}
	?>

	<div>
    <label for="custServiceAddress">Service Address <span class="paintops-required">*</span></label>
    <input type="text" id="custServiceAddress" name="custServiceAddress" autocomplete="street-address" autocapitalize="words"  placeholder="i.e. 1234 Easy St" value="<?php echo esc_attr($custServiceAddress); ?>">
    </div>

	<div>
    <label for="custServiceCity">City <span class="paintops-required">*</span></label>
    <input type="text" id="custServiceCity" name="custServiceCity" autocomplete="address-level2" autocapitalize="words"  placeholder="<?php echo esc_attr($cityPlaceholder); ?>" value="<?php echo esc_attr($custServiceCity); ?>">
    </div>

	<div>
    <label for="custServiceState"><?php echo wp_kses($stateFieldLabel, paintops_kses_allowed_html(), paintops_kses_protocols()); ?></label>
    <input type="text" id="custServiceState" name="custServiceState" autocomplete="address-level1" autocapitalize="characters"<?php echo wp_kses($stateMaxlengthAttr, paintops_kses_allowed_html(), paintops_kses_protocols()); ?> placeholder="<?php echo esc_attr($statePlaceholder); ?>" value="<?php echo esc_attr($custServiceState); ?>"<?php echo wp_kses($stateUppercaseStyle, paintops_kses_allowed_html(), paintops_kses_protocols()); ?>>
    </div>

	<div>
    <label for="custServiceZip"><?php echo wp_kses($zipFieldLabel, paintops_kses_allowed_html(), paintops_kses_protocols()); ?> <span class="paintops-required">*</span></label>
    <input type="text" id="custServiceZip" name="custServiceZip" inputmode="numeric" autocomplete="postal-code"  placeholder="<?php echo esc_attr($zipPlaceholder); ?>" value="<?php echo esc_attr($custServiceZip); ?>">
    </div>
</div>
    <label for="custPhone">Customer Phone <span class="paintops-required">*</span></label>
    <input type="tel" id="custPhone" name="custPhone" inputmode="tel" autocomplete="tel"  placeholder="i.e. 941-123-4567" value="<?php echo esc_attr($custPhone); ?>">
    
    <label for="custEmail">Customer Email <i>(Optional)</i> Not required until a quote is complete.</label>
    <input type="email" id="custEmail" name="custEmail" inputmode="email" autocomplete="email" autocapitalize="none" spellcheck="false"  placeholder="i.e. somebody@gmail.com" value="<?php echo esc_attr($custEmail); ?>">
    </div>
    
<div class="job-form-section">Job Info</div>
    <label>Current Status <span class="paintops-required">*</span></label>
    <div class="paintops-status-pills" id="recordStatusPills">
      <div class="paintops-status-pill<?php echo esc_attr($currentStatus === 'quote' ? ' active' : ''); ?>" data-value="quote">Quote</div>
      <div class="paintops-status-pill<?php echo esc_attr($currentStatus === 'estimate' ? ' active' : ''); ?>" data-value="estimate">Estimate</div>
      <div class="paintops-status-pill<?php echo esc_attr($currentStatus === 'bid' ? ' active' : ''); ?>" data-value="bid">Bid</div>
      <div class="paintops-status-pill<?php echo esc_attr($currentStatus === 'job' ? ' active' : ''); ?>" data-value="job">Job</div>
    </div>
    <input type="hidden" id="recordStatus" name="recordStatus" value="<?php echo esc_attr($currentStatus); ?>">
    <?php
    // A one-line reminder of what each status actually means, swapped in
    // as each pill is clicked, these four terms get used loosely in
    // everyday conversation, but they're distinct stages with different
    // implications (binding vs. not, competitive vs. direct, already
    // agreed-to vs. still being sold).
    $status_descriptions = [
        'quote'    => 'A fixed, legally binding price for a specific, well-defined scope of work, given directly to one client, usually valid for a limited time. A pre-job stage.',
        'estimate' => 'An educated guess of what a project might cost, not fixed or binding, just the best read on it so far. A pre-job stage; really just a wording choice, since the customer-facing page will say "Estimate" instead of "Quote."',
        'bid'      => 'A detailed, formal proposal submitted in a competitive selection process (e.g. government or commercial work), where multiple vendors compete for the same contract. A pre-job stage that also flags this as a GC bid, a label that sticks even after it becomes a Job.',
        'job'      => 'Work that\'s already been agreed to. Use this to add already-accepted work into PaintOps, not to seek new business.',
    ];
    ?>
    <div id="recordStatusDescription" class="paintops-status-description"><?php echo esc_html($status_descriptions[$currentStatus] ?? ''); ?></div>

<?php paintops_enqueue_style('job-form'); // .paintops-status-description + .paintops-scope-area-checks (assets/css/job-form.css) ?>
<?php ob_start(); ?>
(function() {
    var pillsWrap = document.getElementById('recordStatusPills');
    var hiddenField = document.getElementById('recordStatus');
    var descriptionBox = document.getElementById('recordStatusDescription');
    var statusDescriptions = <?php echo wp_json_encode($status_descriptions); ?>;
    if (!pillsWrap || !hiddenField) return;

    pillsWrap.addEventListener('click', function(e) {
        var pill = e.target.closest('.paintops-status-pill');
        if (!pill) return;

        pillsWrap.querySelectorAll('.paintops-status-pill').forEach(function(p) {
            p.classList.remove('active');
        });
        pill.classList.add('active');
        var value = pill.getAttribute('data-value');
        hiddenField.value = value;
        hiddenField.dispatchEvent(new Event('change', { bubbles: true }));
        if (descriptionBox) {
            descriptionBox.textContent = statusDescriptions[value] || '';
        }
    });
})();
<?php paintops_add_inline_script((string) ob_get_clean()); ?>

    <label for="jobCat">Job Category <span class="paintops-required">*</span> <?php echo wp_kses(paintops_tooltip('Manage categories under PaintOps > Business.'), paintops_kses_allowed_html(), paintops_kses_protocols()); ?></label>
    <select id="jobCat" name="jobCat">
      <?php foreach (paintops_job_categories() as $cat_id => $cat) : ?>
      <option value="<?php echo esc_attr((string) $cat_id); ?>" <?php selected($jobCat, (string) $cat_id); ?>><?php echo esc_html($cat['name'] ?? "[$cat_id]"); ?></option>
      <?php endforeach; ?>
    </select>

    <label>Work Areas <?php echo wp_kses(paintops_tooltip('Check any that apply, a full repaint checks both. Narrows the Scope Components checklist on the New/Edit Quote screen to just what\'s relevant (interior items won\'t show up on an exterior-only job, and vice versa).'), paintops_kses_allowed_html(), paintops_kses_protocols()); ?></label>
    <div class="paintops-scope-area-checks">
        <label class="paintops-scope-area-check"><input type="checkbox" id="scopeInterior" name="scopeInterior" value="1" <?php checked(!empty($scopeInterior)); ?>> Interior</label>
        <label class="paintops-scope-area-check"><input type="checkbox" id="scopeExterior" name="scopeExterior" value="1" <?php checked(!empty($scopeExterior)); ?>> Exterior</label>
    </div>
    <?php // .paintops-scope-area-checks styling is part of
          // assets/css/job-form.css (enqueued just above). ?>

    <label for="jobClass">Job Classification <?php echo wp_kses(paintops_tooltip('For internal financial report usage. This allows PaintOps to calculate profits per job classification.'), paintops_kses_allowed_html(), paintops_kses_protocols()); ?></label>
	<select id="jobClass" name="jobClass">
	  <?php foreach (paintops_job_classifications() as $class_id => $class_label) : ?>
	  <option value="<?php echo esc_attr((string) $class_id); ?>" <?php selected($jobClass, (string) $class_id); ?>><?php echo esc_html($class_label); ?></option>
	  <?php endforeach; ?>
	</select>

    <label for="payment_plan">Deposit / Payment Plan <?php echo wp_kses(paintops_tooltip('Set this before sending the quote, it controls whether an online card-deposit link is offered to this customer at all.'), paintops_kses_allowed_html(), paintops_kses_protocols()); ?></label>
    <select id="payment_plan" name="payment_plan">
      <?php
      $paintops_plan_default = function_exists('paintops_default_payment_plan') ? paintops_default_payment_plan() : 'deposit_online';
      foreach (paintops_payment_plan_options() as $plan_key => $plan_label) :
          // Free tier: don't OFFER the online-card plan (Stripe is Pro),
          // but keep it listed when this record already uses it, so
          // editing an old job never silently rewrites its plan.
          if ($plan_key === 'deposit_online'
              && !(function_exists('paintops_pro_active') && paintops_pro_active())
              && (string) ($payment_plan ?? '') !== 'deposit_online') {
              continue;
          }
      ?>
      <option value="<?php echo esc_attr($plan_key); ?>" <?php selected($payment_plan ?? $paintops_plan_default, $plan_key); ?>><?php echo esc_html($plan_label); ?></option>
      <?php endforeach; ?>
    </select>

<?php if (paintops_user_can_manage()) : // The picker below lists the 40 most recent jobs' customer names and
    // description snippets across the whole business, fine for staff,
    // but well beyond what a Painter/Helper's "Can create jobs" permission
    // is meant to expose, so it's staff-only rather than shown to a
    // worker creating a job of their own. ?>
<?php
// Job Title Remarks / Job Description helper: a "copy from a past job"
// picker that fills both fields at once from a real past job, this used
// to be paired with a separate, category-based static template, but real
// past jobs are consistently more useful than a generic one, so the
// template field was removed and this picker is now the only auto-fill.
$past_jobs = $wpdb->get_results( $wpdb->prepare(
    "SELECT id, jobTitle, jobCat, custFirst, custLast, jobDesc FROM %i WHERE jobDesc != '' ORDER BY id DESC LIMIT 40", paintops_table('jobs')) );
$past_job_lookup = [];
foreach (($past_jobs ?: []) as $pj) {
    $past_job_lookup[(int) $pj->id] = [
        'title' => stripslashes($pj->jobTitle),
        'desc'  => stripslashes($pj->jobDesc),
    ];
}
?>
<div class="pv-quick-actions" style="margin:-6px 0 14px;">
    <a class="job-form-toggle" onclick="toggleFade('pastJobPickerBox')"><span class="dashicons dashicons-backup" aria-hidden="true"></span>Copy Title &amp; Description From a Past Job</a>
</div>
<div id="pastJobPickerBox" class="hidden-box">
    <label for="pastJobPicker">Select a Past Job</label>
    <select id="pastJobPicker">
        <option value="">-- Select a past job --</option>
        <?php foreach (($past_jobs ?: []) as $pj) :
            $pj_label = trim($pj->custFirst . ' ' . $pj->custLast);
            if (!empty($pj->jobTitle)) { $pj_label .= ', ' . stripslashes($pj->jobTitle); }
            $pj_snippet = trim(str_replace(["\r", "\n"], ' ', stripslashes($pj->jobDesc)));
            if (strlen($pj_snippet) > 60) { $pj_snippet = substr($pj_snippet, 0, 60) . '...'; }
        ?>
        <option value="<?php echo (int) $pj->id; ?>"><?php echo esc_html(trim($pj_label . ' (' . $pj_snippet . ')')); ?></option>
        <?php endforeach; ?>
    </select>
    <p class="description">Picking one fills in both Job Title Add-On and Job Description below - you'll be asked to confirm if either already has text.</p>
</div>
<?php else: $past_job_lookup = []; endif; ?>

    <label for="jobTitle">Job Title Add-On (Optional, Keep it Short)</label>
    <input type="text" id="jobTitle" name="jobTitle" placeholder="i.e. Touchups Only" value="<?php echo esc_attr(stripslashes($jobTitle)); ?>">

    <label for="jobDesc">Brief Job Description <span class="paintops-required">*</span></label>
    <textarea id="jobDesc" name="jobDesc" rows="8" placeholder="i.e. Full house exterior quote for stucco and facia. Owner will be home for a walkaround the property."><?php echo esc_textarea(stripslashes($jobDesc)); ?></textarea>
<?php if (function_exists('paintops_render_ai_scope_tools')) { paintops_render_ai_scope_tools('jobDesc', 'textarea'); } ?>

    <label for="master_notes">Master Notes (Optional)</label>
    <textarea id="master_notes" name="master_notes" rows="5" placeholder="Pinned reference for the crew, e.g.:&#10;Materials &amp; supplies: 6 gal SW Emerald exterior, 12 tubes caulk&#10;Gate/door code: 4821#&#10;Parking: driveway only, don't block neighbor&#10;Pets: friendly dog in back yard&#10;Quiet hours: no loud work before 8am"><?php echo esc_textarea(stripslashes($master_notes)); ?></textarea>
    <p class="description">Always-visible notes shown on the job page for anyone assigned to this job. Good for the practical details a crew needs on site. The AI materials list has an "Add to job notes" button that drops its results in here.</p>

    <?php // The "No materials needed" flag MOVED to the Add Expense page
          // (?jobAddReceipt=, Mike 2026-07-23: "probably my first place to
          // look", it's where the no-materials reminder links to). The job
          // save deliberately does NOT write no_materials_needed at all,
          // so editing a job never resets a flag set over there. ?>
    <label style="display:block;margin-top:14px;font-weight:normal;">
        <input type="checkbox" name="exclude_from_reports" value="1" <?php checked($exclude_from_reports, 1); ?>>
        Exclude this job from financial reports
    </label>
    <p class="description">When checked, this job's payments, costs, and hours are left out of the Business Financial Report and the Business page's tax-year totals. Use it for a demo, a freebie, or a personal job you don't want in the books. (It still appears normally everywhere else.)</p>

<?php ob_start(); ?>
(function() {
    var pastJobData = <?php echo wp_json_encode($past_job_lookup); ?>;
    var jobTitleField = document.getElementById('jobTitle');
    var jobDescField = document.getElementById('jobDesc');

    document.addEventListener('change', function(e) {
        if (e.target.id === 'pastJobPicker') {
            var picked = e.target.value;
            var data = pastJobData[picked];
            if (picked === '' || !data) return;

            var hasExisting = (jobTitleField && jobTitleField.value.trim() !== '') || (jobDescField && jobDescField.value.trim() !== '');
            if (hasExisting && !window.confirm('Replace the current Job Title Add-On and/or Job Description with this past job\'s text?')) {
                e.target.value = '';
                return;
            }

            if (jobTitleField) jobTitleField.value = data.title || '';
            if (jobDescField) jobDescField.value = data.desc || '';
            e.target.value = '';
        }
    });
})();
<?php paintops_add_inline_script((string) ob_get_clean()); ?>


	<div class="job-form-section">Scheduling</div>


<div class="addr-row" style="grid-template-columns: 1fr 1fr; align-items: start;">
	<div>
    <label for="jobDate">Date <span class="paintops-required">*</span></label>
    <input type="date" id="jobDate" min="2025-01-01" max="2099-12-31" value="<?php echo esc_attr(sprintf('%04d-%02d-%02d', $startYear ?? gmdate('Y', current_time('timestamp')), $startMonth ?? gmdate('m', current_time('timestamp')), $startDay ?? gmdate('d', current_time('timestamp')))); ?>">
    <input type="hidden" id="pyear" name="startYear" value="<?php echo esc_attr($startYear ?? gmdate('Y', current_time('timestamp'))); ?>">
    <input type="hidden" id="pmonth" name="startMonth" value="<?php echo esc_attr($startMonth ?? gmdate('m', current_time('timestamp'))); ?>">
    <input type="hidden" id="pday" name="startDay" value="<?php echo esc_attr($startDay ?? gmdate('d', current_time('timestamp'))); ?>">
    <?php // The jobDate -> hidden year/month/day sync is part of
          // assets/js/job-form.js (enqueued at the top of this form). ?>
    </div>

	<div>
	<label for="startTime">Arrival Time <span class="paintops-required">*</span></label>
	<select id="startTime" name="startTime">
	  <?php
	  // Generate 15-minute intervals from 9:00 to 18:00
	  for ($h = 7; $h <= 19; $h++) {
		  for ($m = 0; $m < 60; $m += 15) {
			  $time = sprintf('%02d:%02d', $h, $m);
			  $label = date('g:i A', strtotime($time));
			  $selected = (isset($startTime) && $startTime === $time) ? 'selected' : '';
			  echo wp_kses("<option value=\"$time\" $selected>$label</option>", paintops_kses_allowed_html(), paintops_kses_protocols());
		  }
	  }
	  if (empty($startDay)){
		  $workerConflictMsg = "Please note that scheduling conflicts are not checked until \"Scheduling\" is saved first and this job is added.<br><br>";
	  }
	  else{
		  $workerConflictMsg = "";
	  }
	  ?>
	</select>
	<div>
    <label for="reqWorkers">Workers Or Estimator(s)</label>
	  <?php
		// Crew quick-fill (2026-07-19, v1.0.105): picking a crew checks its
		// members into the boxes below and stamps the hidden crew_id
		// (recorded on the job for the calendar filter + per-crew profit).
		// Manual checkbox changes after picking keep the stamp, real life
		// is "Crew A plus Danny"; picking "No crew" clears the stamp and
		// unchecks everyone. Workers stay the source of truth (reqWorkers).
		$paintops_form_crews = function_exists('paintops_get_crews') ? paintops_get_crews() : [];
		$paintops_form_crew_id = (int) ($crew_id ?? 0);
		if ($paintops_form_crews) {
			echo '<div style="margin:0 0 10px;display:flex;align-items:center;gap:8px;flex-wrap:wrap;">';
			echo '<label for="paintopsCrewPick" style="margin:0;">Quick-fill a crew:</label>';
			echo '<select id="paintopsCrewPick" style="min-width:160px;">';
			echo '<option value="0">No crew</option>';
			foreach ($paintops_form_crews as $pc) {
				$pc_members = paintops_crew_member_ids($pc);
				echo wp_kses('<option value="' . (int) $pc->id . '" data-members="' . esc_attr(implode(',', $pc_members)) . '"' . selected($paintops_form_crew_id, (int) $pc->id, false) . '>'
					. esc_html($pc->name) . ' (' . count($pc_members) . ')</option>', paintops_kses_allowed_html(), paintops_kses_protocols());
			}
			echo '</select>';
			echo '<span style="display:inline-block;width:14px;height:14px;border-radius:4px;background:transparent;" id="paintopsCrewSwatch"></span>';
			echo '</div>';
			echo '<input type="hidden" name="crew_id" id="paintopsCrewId" value="' . (int) $paintops_form_crew_id . '">';
			$paintops_crew_colors = [];
			foreach ($paintops_form_crews as $pc) { $paintops_crew_colors[(int) $pc->id] = paintops_sanitize_crew_color((string) $pc->color); }
			paintops_add_inline_script('
			(function(){
				var sel = document.getElementById("paintopsCrewPick");
				if (!sel) { return; }
				var colors = ' . wp_json_encode($paintops_crew_colors) . ';
				function swatch(){ var s = document.getElementById("paintopsCrewSwatch"); if (s) { s.style.background = colors[sel.value] || "transparent"; } }
				swatch();
				sel.addEventListener("change", function(){
					var opt = sel.options[sel.selectedIndex];
					var ids = (opt.getAttribute("data-members") || "").split(",").filter(Boolean);
					document.querySelectorAll("input[name=\'reqWorkers[]\']").forEach(function(cb){
						cb.checked = ids.indexOf(cb.value) !== -1;
					});
					var hid = document.getElementById("paintopsCrewId");
					if (hid) { hid.value = sel.value; }
					swatch();
				});
			})();
			');
		}
		echo wp_kses($workerConflictMsg, paintops_kses_allowed_html(), paintops_kses_protocols());
		// Administrators/Editors plus anyone with the Painter/Helper role
		// (see paintops_get_assignable_workers(), a plain user_level
		// check alone misses Painter/Helper accounts, which don't carry one).
		$results = paintops_get_assignable_workers();

		if ($results) {
			foreach ($results as $row) {
				$uid = $row->ID;
				// Prefer the person's Painters/Helpers nickname over the WP
				// username/display name so assignment reads in friendly names.
				$display_name = function_exists('paintops_worker_display_label')
					? paintops_worker_display_label((int) $uid)
					: $row->display_name;
				if ($display_name === '') {
					$display_name = $row->display_name;
				}
				if (!empty($startDay)){
					// Check other jobs for each worker.
					$getEditId = intval($getEditId ?? 0);
					$resultsC = $wpdb->get_results($wpdb->prepare(
						"SELECT id FROM %i WHERE id != %d AND startDay = %d AND startMonth = %d AND startYear = %d AND reqWorkers LIKE %s LIMIT 1", paintops_table('jobs'), $getEditId, (int) $startDay, (int) $startMonth, (int) $startYear, '%#' . (int) $uid . '#%'));
			
					if ($wpdb->last_error) {
						paintops_report_db_error();
					}
					$onJobs = '';
					if ($resultsC) {
						foreach ($resultsC as $rowC) {
							$onJobs .= " Job <a href=\"" . paintops_href(['viewEid' => (int) $rowC->id]) . "\" target=\"_blank\" rel=\"noopener noreferrer\">#".$rowC->id."</a>";
						}
					}
					if (!empty($onJobs)){
						$display_name .= " <span style='color:red;font-weight: normal;'>Already assigned to $onJobs</span>";
					}
					// $reqWorkers stored like #1##5##6#
					if (in_array((int) $uid, paintops_parse_worker_ids($reqWorkers), true)){
						$checked = " checked";
					}
					else{
						$checked = "";
					}
				}
				else{
					$checked = "";
				}
				$po_tpl_html = "<div style='display: inline-flex; align-items: center; margin-bottom: 8px;'>
    <input 
        style='width: 28px; height: 28px; margin: 0 8px 0 0;' 
        type='checkbox' 
        id='worker_$uid' 
        value='$uid' 
        name='reqWorkers[]' $checked>
    <label for='worker_$uid' style='margin: 0; line-height: 28px;'>$display_name</label>
</div><br>";
				echo wp_kses($po_tpl_html, paintops_kses_allowed_html(), paintops_kses_protocols());
			}
		}

	  ?>

    </div>
	
	<div id="job-only-reqdays">
    <label for="reqDays"># Day Job (For Calendar &amp; Scheduling)</label>
    <input type="number" step="1" min="1" max="90" inputmode="numeric" id="reqDays" name="reqDays" value="<?php echo esc_attr($reqDays ?? ''); ?>">
    </div>

    </div>

</div>
<?php // "Check If Job Is Complete" removed (Mike 2026-07-23): completion
      // lives on the job view's Complete pill / board moves, the edit
      // form no longer reads or writes the completed column at all. ?>

<?php // The "# Day Job" / Paint Colors job-only visibility toggle is part of
      // assets/js/job-form.js (enqueued at the top of this form). ?>

	<br><br>

    <?php
    // Paint Colors used to be its own click-through page (?jobPaintColors=X),
    // then its own separate <form>/save-button sitting below this one,
    // Mike's call both times was that a color/area/quantity table doesn't
    // need a save action of its own, it should just be part of saving the
    // job. $embedded=true here skips that inner <form>/submit button (a
    // <form> can't nest inside this one anyway) and just renders the
    // fields + hidden nonce, so they ride along inside THIS form and get
    // saved by paintops_save_job_paint_colors_from_post() from the main job-save
    // handler above once "Update Job" is clicked, see the
    // isset($editJobId) success branch.
    if (isset($_GET['editEid']) && function_exists('paintops_render_job_paint_colors_box')) {
        // Paint Colors only applies once this is an actual Job (Mike
        // 2026-07-22): a Quote/Estimate/Bid record hides the table via the
        // same job-only toggle as reqDays/complete above, and it appears
        // live the moment the "Job" pill is picked. The fields still ride
        // along in the POST while hidden, so any color rows already saved
        // survive a record that moves between statuses.
        echo '<div id="job-only-paintcolors" style="margin-top:18px;' . ($currentStatus === 'job' ? '' : 'display:none;') . '">';
        paintops_render_job_paint_colors_box($getEditId, true);
        echo '</div>';
    }
    ?>

    <?php echo wp_kses($formAction, paintops_kses_allowed_html(), paintops_kses_protocols()); ?>
    <div class="paintops-sticky-save">
        <button type="submit" class="button button-primary"><?php echo esc_html($submitName); ?></button>
    </div>
  </form>
  <?php if (isset($_GET['editEid'])) : ?>
    <p style="margin-top:16px;">
        <?php if (paintops_user_can_view_financials()): ?>
        <a href="<?php echo esc_url(paintops_admin_url(['jobFinancials' => (int) $getEditId])); ?>" class="paintops-btn paintops-btn-secondary">Edit Financials for this <?php echo esc_html($isJobType); ?> &rarr;</a>
        <?php endif; ?>
        <a href="<?php echo esc_url(paintops_admin_url(['jobPhotos' => (int) $getEditId])); ?>" class="paintops-btn paintops-btn-secondary">Job Notes &amp; Photos &rarr;</a>
    </p>
    <p style="color:#777;font-size:13px;">Financials and photos still save separately from their own links above, paint colors now save together with the rest of the job.</p>
  <?php endif; ?>
</div>
</div>

    <?php
}
    // ===================================================
    // Show single job
    // ===================================================
    if (isset($_GET['viewEid']) && $_GET['viewEid'] > 0){
	$getJobID = intval($_GET['viewEid']);

		// "Mark as Won" from the job view (2026-07-19, audit P0 #1): staff
		// converts a quote-stage record to an active Job right where they're
		// looking, same state change as the Job Board's "Mark as Job" move.
		// Runs BEFORE the row fetch below so the page renders the new state.
		// The posted id must be the job being viewed, this is a per-record
		// action, not a bulk endpoint.
		if (isset($_POST['paintops_mark_won'])
			&& paintops_user_can_manage()
			&& wp_verify_nonce(sanitize_text_field(wp_unslash($_POST['paintops_mark_won_nonce'] ?? '')), 'paintops_mark_won_action')
			&& (int) ($_POST['job_id'] ?? 0) === $getJobID) { // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.ValidatedSanitizedInput.MissingUnslash -- value is cast to a number, sanitization not applicable
			$wpdb->update(
				paintops_table('jobs'),
				['jobType' => 2, 'completed' => 0, 'not_won' => 0],
				['id' => $getJobID],
				['%d', '%d', '%d'],
				['%d']
			);
			if (function_exists('paintops_mark_estimate_accepted_on_win')) {
				paintops_mark_estimate_accepted_on_win($getJobID);
			}
			echo '<div class="paintops-notice is-success" style="margin:0 0 12px;">Marked as won. This record is now an active Job.</div>';
		}

		// Mark as Not Won from the job view (Mike 2026-07-23), the losing
		// mirror of Mark as Won above. Same state change as the Job Board's
		// not_won move (job-pipeline.php): not_won = 1, jobType untouched,
		// and the manual win stamp cleared so Request Performance stays
		// honest. The record lands in the board's Not Won drawer (revivable).
		if (isset($_POST['paintops_mark_not_won'])
			&& paintops_user_can_manage()
			&& wp_verify_nonce(sanitize_text_field(wp_unslash($_POST['paintops_mark_not_won_nonce'] ?? '')), 'paintops_mark_not_won_action')
			&& (int) ($_POST['job_id'] ?? 0) === $getJobID) { // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.ValidatedSanitizedInput.MissingUnslash -- value is cast to a number, sanitization not applicable
			$wpdb->update(paintops_table('jobs'), ['not_won' => 1], ['id' => $getJobID], ['%d'], ['%d']);
			if (function_exists('paintops_clear_estimate_win_stamp')) {
				paintops_clear_estimate_win_stamp($getJobID);
			}
			echo '<div class="paintops-notice is-success" style="margin:0 0 12px;">Marked as Not Won. It now lives in the Not Won drawer on the Job Board, where it can be revived if the customer comes back.</div>';
		}

		// Toggle complete from the header pill (Mike 2026-07-23): staff
		// clicks the Complete / Not Complete badge on the job view to flip
		// it, both directions, behind a nonce + a confirm() on the pill.
		// Jobs only; runs BEFORE the row fetch so the page renders the new
		// state. The worker-facing Mark Complete flow (one-way, emails the
		// office) is separate and unchanged.
		// POST, not GET (2026-08-31): a state-changing GET link could be
		// fired by link prefetchers or duplicated requests, silently
		// flipping the job and flipping it right back, which matches the
		// stale-state symptom Mike reported on 1.0.273. A form post is
		// never prefetched.
		if (isset($_POST['paintopsToggleComplete'])
			&& paintops_user_can_manage()
			&& wp_verify_nonce(sanitize_text_field(wp_unslash($_POST['paintops_toggle_nonce'] ?? '')), 'paintops_toggle_complete_' . $getJobID)) {
			$toggleRow = $wpdb->get_row($wpdb->prepare("SELECT id, jobType, completed FROM %i WHERE id = %d LIMIT 1", paintops_table('jobs'), $getJobID));
			if ($toggleRow && (int) $toggleRow->jobType === 2) {
				$toggleNew = ((int) $toggleRow->completed === 1) ? 0 : 1;
				$wpdb->update(paintops_table('jobs'), ['completed' => $toggleNew], ['id' => $getJobID], ['%d'], ['%d']);
				// Redirect-after-write (2026-08-31, Mike's stale-state report):
				// landing back on a CLEAN ?viewEid= URL means refresh/back can
				// never re-fire the toggle, and the fresh param defeats any
				// page cache holding the old state (paired with the new
				// DONOTCACHEPAGE guard in paintops_enqueue_app_style()).
				wp_safe_redirect(paintops_admin_url(['viewEid' => $getJobID, 'completeToggled' => $toggleNew === 1 ? 'yes' : 'no']));
				exit;
			}
		}
		if (isset($_GET['completeToggled'])) {
			// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- display-only notice flag set by the nonce-verified toggle redirect above, value compared against fixed strings
			echo '<div class="paintops-notice is-success" style="margin:0 0 12px;">' . (sanitize_key(wp_unslash($_GET['completeToggled'])) === 'yes' ? 'Marked complete.' : 'Marked as not complete.') . '</div>';
		}

		// (The free-tier "Mark as Paid" action lived here 1.0.140-1.0.144;
		// it moved to the Needs Your Attention card on the main Jobs page,
		// Mike's call 2026-07-22, see dashboard-alerts.php.)

		$results = $wpdb->get_results($wpdb->prepare("SELECT * FROM %i WHERE id = %d LIMIT 1", paintops_table('jobs'), $getJobID));
		if ($wpdb->last_error) {
			paintops_report_db_error();
		}
		if ($results) {
			foreach ($results as $row) {
				// Shared with the painter-facing view, see
				// includes/job-view.php. is_staff => true here shows the
				// edit links, quote/invoice IDs, and audit fields; the
				// profit report is deliberately kept out of that shared
				// renderer entirely and only ever appended here, in the
				// staff-only code path.
				echo wp_kses(function_exists('paintops_render_job_view_card')
					? paintops_render_job_view_card($row, ['is_staff' => true])
					: '<p>Job view unavailable.</p>', paintops_kses_allowed_html(), paintops_kses_protocols());

				if (paintops_user_can_view_financials()) {
					// Pro + existence gated (2026-07-22, live bug on the free
					// tier): the report shell wp_die()d its Pro teaser, which
					// APPENDS WordPress's 500 die page (and its body
					// max-width:700px CSS) onto the already-rendered job view,
					// silent 500 + the whole desktop app squeezed to phone
					// width. Embedded contexts never call into it on free.
					if ((int) $row->jobType !== 2) {
						// A Request has no financials yet (Mike 2026-07-23):
						// no gross/profit tiles that read like real money,
						// just say when they'll appear.
						$pvFinLabel = function_exists('paintops_job_record_label')
							? paintops_job_record_label((int) $row->jobType, (int) ($row->isBid ?? 0), (string) ($row->quote_label ?? 'quote'))
							: 'quote';
						echo '<div class="cs-financial-box"><p style="margin:0;color:var(--po-text-muted,#777);">Financials &amp; profitability will appear here once this ' . esc_html(strtolower($pvFinLabel)) . ' is won and becomes a Job.</p></div>';
					} elseif (!function_exists('paintops_pro_code_present')) {
						if (function_exists('paintops_render_pro_pointer')) {
							paintops_render_pro_pointer('Job Financials & Profitability');
						}
					} elseif (function_exists('paintops_pro_active') && paintops_pro_active() && function_exists('paintops_render_job_profit_report_page_minimal')) {
						paintops_render_job_profit_report_page_minimal((int) $row->id);
					} elseif (function_exists('paintops_render_upgrade_notice')) {
						paintops_render_upgrade_notice('Job Financials & Profitability', 'Track gross revenue, expenses, and hours for every job to see real profit and profit-per-hour.');
					}
				}

				// Administrators-only permanent delete. Only on the full job
				// view (never the shared card, so workers and non-admins never
				// see it). Triggers the ?delEid= route, which runs the cascade
				// delete (paintops_delete_job_deep) and lands back on the list.
				if (current_user_can('manage_options')) {
					$del_label = function_exists('paintops_job_record_label')
						? paintops_job_record_label((int) $row->jobType, (int) $row->isBid, (string) $row->quote_label)
						: 'job';
					$del_url = wp_nonce_url(paintops_admin_url(['delEid' => (int) $row->id]), 'paintops_delete_job_' . (int) $row->id);
					// Neutral drawer (Mike 2026-07-22): with the drawer AND the
					// confirm() as two deliberate steps, an always-red box read
					// as an alarm on every visit. White card, gray border, muted
					// summary; only the Permanently Delete button inside stays
					// red, it's the actual destructive control and is only seen
					// after the drawer is opened on purpose.
					echo '<details style="border:1px solid var(--po-card-border,#ccd0d4);background:var(--po-card-bg,#fff);border-radius:8px;margin:26px 0 20px;max-width:560px;overflow:hidden;">';
					echo '<summary style="cursor:pointer;padding:12px 16px;font-weight:600;color:var(--po-text-muted,#666);list-style:none;">Delete this ' . esc_html($del_label) . '</summary>';
					echo '<div style="padding:2px 16px 16px;color:var(--po-text,#444);font-size:14px;">';
					echo '<p style="margin:8px 0;"><strong>Permanently deletes this ' . esc_html($del_label) . '</strong> and everything attached to it: quote and estimate revisions, financials, expenses, hours, payments, paint colors, photos and receipts, check-ins, invoices, and any follow-up visits. This cannot be undone.</p>';
					echo '<p style="margin:12px 0 4px;"><a href="' . esc_url($del_url) . '" onclick="return confirm(\'Permanently delete this ' . esc_js($del_label) . ' and everything attached to it? This cannot be undone.\');" class="paintops-btn" style="background:#c0392b;border-color:#a5281b;color:#fff;">Permanently Delete</a></p>';
					echo '</div></details>';
				}
			}
		} else {
			echo "<p>Job not found.</p>";
		}
	}
	// <a href=\"?delEid=". $row->id ."\" onclick=\"return confirm('Delete this job?');\">Remove</a>
    // ===================================================
    // Show job list
    // ===================================================
	elseif ($actionSelected!=1){
		echo wp_kses(paintops_render_app_nav('dashboard'), paintops_kses_allowed_html(), paintops_kses_protocols());
		echo wp_kses(paintops_screen_title('Jobs'), paintops_kses_allowed_html(), paintops_kses_protocols());
		// The standalone "+ New Request" button that used to sit here was
		// removed 2026-07-19 (Mike's call): the unified app bar now puts a
		// prominent raised "+ New" item on EVERY page (top bar on desktop,
		// center of the bottom bar on phones), so the extra button was
		// pure duplication at the top of the list.
		// "Finish setting up" banner (admins only, dismissible, renders
		// nothing once the core Business identity items are filled in).
		if (function_exists('paintops_render_setup_banner')) {
			paintops_render_setup_banner();
		}
		if (function_exists('paintops_handle_jobs_view_toggle')) {
			paintops_handle_jobs_view_toggle();
		}
		echo wp_kses((function_exists('paintops_render_dashboard_alerts') ? paintops_render_dashboard_alerts() : ''), paintops_kses_allowed_html(), paintops_kses_protocols());
		// Review Requests card, premium build only (see pro-services.php).
		echo wp_kses((function_exists('paintops_render_review_requests_card') ? paintops_render_review_requests_card() : ''), paintops_kses_allowed_html(), paintops_kses_protocols());
		echo wp_kses((function_exists('paintops_render_todays_crew_widget') ? paintops_render_todays_crew_widget() : ''), paintops_kses_allowed_html(), paintops_kses_protocols());
		// Rows view (2026-08-31 redesign) is the default; the board stays
		// available behind the toggle. See includes/job-rows.php.
		if (function_exists('paintops_jobs_view') && paintops_jobs_view() === 'rows' && function_exists('paintops_render_job_rows')) {
			echo wp_kses(paintops_render_job_rows(), paintops_kses_allowed_html(), paintops_kses_protocols());
		} else {
			echo wp_kses((function_exists('paintops_render_job_pipeline') ? paintops_render_job_pipeline() : ''), paintops_kses_allowed_html(), paintops_kses_protocols());
			echo wp_kses('<div style="margin:10px 0 0;font-size:13.5px;">' . (function_exists('paintops_render_jobs_view_toggle') ? paintops_render_jobs_view_toggle() : '') . '</div>', paintops_kses_allowed_html(), paintops_kses_protocols());
		}
		echo wp_kses((function_exists('paintops_render_quote_stats_widget') ? paintops_render_quote_stats_widget() : ''), paintops_kses_allowed_html(), paintops_kses_protocols());
	}

	return ob_get_clean();
}

/**
 * Dedicated job-search page (?jobSearch=1). This used to be the
 * "Filter These Results" section baked directly into the bottom of the
 * Jobs list (sort/completion filters, a single-column search, then up
 * to 20 matching job rows), it was pulled out onto its own page so
 * the Jobs page itself stays short (nav, New Request, alerts, crew,
 * Job Board, quote stats) instead of growing a search form and a
 * second job list underneath everything else. Reached via the search
 * box in the Job Board header (see paintops_render_job_pipeline() in
 * job-pipeline.php) rather than a nav link, since this is an
 * occasional lookup tool, not a primary destination.
 */
function paintops_render_job_search_page(): string {
	global $wpdb;

	ob_start();

	echo '<a href="' . esc_url(paintops_admin_url([])) . '" class="paintops-back-link"><span class="dashicons dashicons-arrow-left-alt2" aria-hidden="true"></span>Back to Jobs</a>';
	echo wp_kses(paintops_screen_title('Search Jobs'), paintops_kses_allowed_html(), paintops_kses_protocols());

	$selJn = "";
	$selDs = " selected";
	$sqlExtra = '';
		$ob = "startYear ASC, startMonth ASC, startDay ASC, startTime ASC"; // Default if not selected
		$searchFor = $selJAll = $selJActive = $selInFirst = $selInLast = $selInEmail = $selInAddress = $selInPhone = $selInJobNum = $selJCompleted = $empty ?? '';
		
		if (isset($_GET['orderby'])){
			if ($_GET['orderby']=='jobNum'){
				$selJn = " selected";
				$ob = "id DESC";				
			}
			//dStrt
			else{
				$selDs = " selected";
				$ob = "startYear DESC, startMonth DESC, startDay DESC, startTime DESC";
			}
		}
		
		if (!empty($_GET['searchFor']) && isset($_GET['searchIn'])){
			$searchFor = sanitize_text_field(wp_unslash($_GET['searchFor'])); // term
			$searchField = sanitize_text_field(wp_unslash($_GET['searchIn'])); // custFirst

			if ($searchField=='jobNum'){
				$searchField = 'id';
				$selInJobNum = " selected";
			}

			// Whitelist the column name before it ever touches SQL. Column
			// names can't be parameterized with $wpdb->prepare(), so this
			// explicit allow-list is what prevents SQL injection here.
			$searchable_columns = ['id', 'custFirst', 'custLast', 'custEmail', 'custServiceAddress', 'custPhone'];
			if (!in_array($searchField, $searchable_columns, true)) {
				$searchField = 'custFirst';
			}

			// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared -- $searchField passed the explicit column allow-list check above
			$sqlExtra = $wpdb->prepare(" WHERE {$searchField} LIKE %s", '%' . $wpdb->esc_like($searchFor) . '%');
			if ($searchField=='custFirst'){
				$selInFirst = " selected";
			}
			elseif ($searchField=='custLast'){
				$selInLast = " selected";
			}
			elseif ($searchField=='custEmail'){
				$selInEmail = " selected";
			}
			elseif ($searchField=='custServiceAddress'){
				$selInAddress = " selected";
			}
			else{
				$selInPhone = " selected";
			}
		}

		// Quick multi-field search from the Job Board header's search box
		// (see paintops_render_job_pipeline() in job-pipeline.php), one term
		// OR'd across every searchable column at once, unlike the "Search
		// For" + "Search In" pair above which only searches one column at
		// a time. Only applies when the advanced form's own searchFor
		// wasn't already submitted, so a bookmarked advanced-search link
		// still behaves exactly as before.
		if (empty($sqlExtra) && !empty($_GET['q'])) {
			$quickTerm = sanitize_text_field(wp_unslash($_GET['q']));
			$searchFor = $quickTerm;
			$quickLike = '%' . $wpdb->esc_like($quickTerm) . '%';

			$quickColumns = ['custFirst', 'custLast', 'custEmail', 'custServiceAddress', 'custPhone'];
			$quickWhere = [];
			$quickValues = [];
			foreach ($quickColumns as $quickColumn) {
				$quickWhere[] = "{$quickColumn} LIKE %s";
				$quickValues[] = $quickLike;
			}

			// A job # match only makes sense if the term is purely
			// numeric, "id LIKE '%25%'" would otherwise also match job
			// #125, #251, #1250, etc. for a search that was never about
			// numbers.
			if (ctype_digit($quickTerm)) {
				$quickWhere[] = "id = %d";
				$quickValues[] = (int) $quickTerm;
			}

			$sqlExtra = $wpdb->prepare(" WHERE (" . implode(' OR ', $quickWhere) . ")", $quickValues); // phpcs:ignore WordPress.DB.PreparedSQLPlaceholders.UnfinishedPrepare, WordPress.DB.PreparedSQL.NotPrepared -- quickWhere holds only literal "field LIKE %s" fragments built in lockstep with quickValues in the loop above; placeholder and argument counts always match
		}


		$showCompleted = sanitize_text_field(wp_unslash($_GET['showCompleted'] ?? ''));

		if ($showCompleted=='showCompletedOnly'){
			$selJCompleted = " selected";
			if (!empty($sqlExtra)){
				$sqlExtra .= " AND completed='1'";
			}
			else{
				$sqlExtra = " WHERE completed='1'";
			}
		}
		elseif ($showCompleted=='showActiveOnly'){
			$selJActive = " selected";
			if (!empty($sqlExtra)){
				$sqlExtra .= " AND completed!='1'";
			}
			else{
				$sqlExtra = " WHERE completed!='1'";
			}
		}
		// Default (empty, e.g. a quick search from the Jobs page that only
		// passes ?q=, or an explicit "showAllJobs"): no completion filter,
		// so searching finds completed jobs too. Was previously Active-Only
		// by default, which silently hid completed jobs from a quick search.
		else{
			$selJAll = " selected";
		}

		// SECURITY (2026-07-19, audit P1): the search term is echoed into a
		// value="" attribute inside the big string below. sanitize_text_field
		// strips tags but does NOT encode quotes, so a crafted link
		// (?searchFor=x" onmouseover=...) could break out of the attribute,
		// reflected XSS against any logged-in staffer. esc_attr closes it.
		$searchForAttr = esc_attr($searchFor);

		$po_tpl_html = "<div class=\"cs-financial-box\">
<div class=\"job-form-section\" style=\"margin-bottom:12px;\">Filter These Results</div>
<form method=\"GET\">
  <!-- Keep the app on the Search Jobs page when the filter is submitted.
       Without this, a plain GET drops ?jobSearch=1 and the router falls
       back to the main Jobs page instead of re-running the search. -->
  <input type=\"hidden\" name=\"jobSearch\" value=\"1\">
  <div style=\"display:flex; flex-wrap:wrap; gap:16px; align-items:flex-end;\">

    <div>
      <label for=\"orderby\">Order By</label><br>
      <select style=\"height:40px;border:1px solid var(--po-input-border,#ccc);border-radius:4px;padding:0 8px;\" id=\"orderby\" name=\"orderby\">
        <option$selDs value=\"dStrt\">Start Day</option>
        <option$selJn value=\"jobNum\">Job #</option>
      </select>
    </div>

    <div>
      <label for=\"showCompleted\">Job Completion</label><br>
      <select style=\"height:40px;border:1px solid var(--po-input-border,#ccc);border-radius:4px;padding:0 8px;\" id=\"showCompleted\" name=\"showCompleted\">
        <option$selJActive value=\"showActiveOnly\">Show Active Jobs Only</option>
        <option$selJCompleted value=\"showCompletedOnly\">Show Completed Jobs Only</option>
        <option$selJAll value=\"showAllJobs\">Show All Jobs</option>
      </select>
    </div>

    <!-- Search For grows to fill the row, matching the wide search input
         on the Customers page (.paintops-customers-search). -->
    <div style=\"flex:1 1 240px; min-width:200px;\">
      <label for=\"searchFor\">Search For</label><br>
      <input style=\"width:100%; box-sizing:border-box; height:40px;border:1px solid var(--po-input-border,#ccc);border-radius:4px;padding:0 10px;\" type=\"text\" id=\"searchFor\" value=\"$searchForAttr\" name=\"searchFor\" placeholder=\"Name, email, address, phone, or job #\">
    </div>

    <div>
      <label for=\"searchIn\">Search In</label><br>
      <select style=\"height:40px;border:1px solid var(--po-input-border,#ccc);border-radius:4px;padding:0 8px;\" id=\"searchIn\" name=\"searchIn\">
        <option$selInFirst value=\"custFirst\">First Name</option>
        <option$selInLast value=\"custLast\">Last Name</option>
        <option$selInEmail value=\"custEmail\">Email</option>
        <option$selInAddress value=\"custServiceAddress\">Street Address</option>
        <option$selInPhone value=\"custPhone\">Phone Number</option>
        <option$selInJobNum value=\"jobNum\">Job #</option>
      </select>
    </div>

    <div>
      <label>&nbsp;</label><br> <!-- dummy label to align button -->
      <button class=\"paintops-btn paintops-btn-primary\" type=\"submit\">Filter</button>
    </div>

  </div>
</form></div>";
		echo wp_kses($po_tpl_html, paintops_kses_allowed_html(), paintops_kses_protocols());

		// phpcs:ignore WordPress.DB.PreparedSQL.InterpolatedNotPrepared, PluginCheck.Security.DirectDB.UnescapedDBParameter -- $sqlExtra is pre-prepared above with an allow-listed column; $ob is one of the fixed literal ORDER BY strings set in this block
		$results = $wpdb->get_results( $wpdb->prepare("SELECT * FROM %i{$sqlExtra} ORDER BY $ob LIMIT 20", paintops_table('jobs')) );
		if ($wpdb->last_error) {
			paintops_report_db_error();
		}
		if ($results) {
			foreach ($results as $row) {
				$dateText = date("M jS, Y", strtotime("{$row->startYear}-{$row->startMonth}-{$row->startDay}"));
				$shortDesc = substr($row->jobDesc, 0, 100); // first 100 chars
				if (strlen($row->jobDesc) > 100) {
					$shortDesc .= '...';
				}
				// Escape-at-output for the card below (wp.org review
				// 2026-07-20); fullWorkTitle already gets esc_html later.
				$shortDesc = esc_html($shortDesc);
				
				// "Starts in / Started ago" is a Job concept; requests
				// show their scheduled date in the meta line instead.
				$startsText = ((int) $row->jobType === 2 && (int) ($row->not_won ?? 0) !== 1)
					? paintops_starts_in_days("$dateText")
					: '';

					// Request-aware status badge (Mike 2026-07-23: a Not
					// Won request read "Not Complete / Started 260 days
					// ago"). Not Won wins; Complete/Not Complete is Jobs
					// only; a pending request says Pending.
					if ((int) ($row->not_won ?? 0) === 1) {
						$completedBadgeClass = "job-badge-muted";
						$completedLabel = "Not Won";
					} elseif ((int) $row->jobType !== 2) {
						$completedBadgeClass = "job-badge-muted";
						$completedLabel = "Pending";
					} elseif ($row->completed==1){
						$completedBadgeClass = "job-badge-complete-yes";
						$completedLabel = "Complete";
					}
					else{
						$completedBadgeClass = "job-badge-complete-no";
						$completedLabel = "Not Complete";
					}

					// Current Status (Quote/Estimate/Bid/Job), same
					// precedence as the "Current Status" pills on the job
					// form itself (see paintops_job_record_label()), so
					// this list never shows a stale label like "Quote"
					// for a job that's actually an Estimate or Bid.
					$isJobType = paintops_job_record_label((int) $row->jobType, (int) $row->isBid, (string) $row->quote_label);
					$badgeClass = 'job-badge-' . strtolower($isJobType);

					if ($row->reqDays==1){
						$reqDaysText = " for 1 day";
					}
					elseif ($row->reqDays > 1){
						$reqDaysText = " for {$row->reqDays} days";
					}
					else{
						$reqDaysText = " for < a day";
					}
					
					// The View/Create Quote link itself now lives in the
					// per-card gear dropdown (paintops_render_job_actions_menu(),
					// which recomputes it from $row->estimateID on its own),
					// but the quoted dollar amount is worth keeping visible
					// on the card face rather than burying it in the menu.
					$quoteInfo = '';
					if ($row->estimateID > 0){
						$resultsQ = $wpdb->get_results($wpdb->prepare("SELECT * FROM %i WHERE jobID = %d ORDER BY estID desc LIMIT 1", paintops_table('estimates'), (int) $row->id));
						if ($wpdb->last_error) {
							paintops_report_db_error();
						}
						if ($resultsQ) {
							foreach ($resultsQ as $rowQ) {
								$totalQuotePrice = $rowQ->estTotal;
							}
							$quoteInfo = "Quote of " . paintops_currency_symbol() . number_format($totalQuotePrice);
						}
					}
				$categoryName = paintops_get_category_details($row->jobCat, 1);
				// esc_html() on every user-controllable piece (job title,
				// customer first/last name), this card is echoed
				// directly into the Jobs list markup below.
				$fullWorkTitle = esc_html($categoryName) . ' ' . esc_html($row->jobTitle) . ' (' . esc_html($row->custFirst) . ' ' . esc_html($row->custLast) . ')';
				
				//$jobFinancials = paintops_get_job_quote_for_financials($row->id, "");
				$perHourReturnRes = "";

				// Pro-gated (Mike's call, 2026-07-21): profit-per-hour and
				// margin are paid-tier analytics; on free, financial viewers
				// see the card without the chip.
				if (paintops_user_can_view_financials() && function_exists('paintops_pro_active') && paintops_pro_active()) {
					$summary = paintops_job_financial_summary($row->id);
					if ($summary > 0){
					$perHourReturn = $summary['profit_per_hour'] = $summary['hours'] > 0 ? $summary['profit'] / $summary['hours'] : 0;
					$perHourReturn = round($perHourReturn, 2);
					$returnProfit = number_format($summary['profit'], 2);
					$returnProfitMargin = 0;
					if ($summary['gross'] > 0){
						$returnProfitMargin = round((($summary['profit'] / $summary['gross']) * 100),2);
					}
						 if ($perHourReturn==0){
							 $perHourReturnRes = "Per hour return: ?";
						 }
						 elseif (function_exists('paintops_profit_per_hour_is_valid') && !paintops_profit_per_hour_is_valid($perHourReturn)){
							 // Rate past the plausibility cap (stray test
							 // clock-in with near-zero hours): show the rest
							 // but dash the noise rate.
							 $currencySymbol = paintops_currency_symbol();
							 $perHourReturnRes = "Per hour return: <b>&mdash;</b> Profit: <b>{$currencySymbol}{$returnProfit}</b> Profit Margin: <b>$returnProfitMargin%</b>";
						 }
						 else{
							 // Business Settings > Company Profile > Currency's symbol,
							 // was hardcoded "$" regardless of what currency was configured.
							 $currencySymbol = paintops_currency_symbol();
							 $perHourReturnRes = "Per hour return: <b>{$currencySymbol}{$perHourReturn}</b> Profit: <b>{$currencySymbol}{$returnProfit}</b> Profit Margin: <b>$returnProfitMargin%</b>";
						 }
					}
				}
				$address = $row->custServiceAddress . ', ' . $row->custServiceCity . ' ' . $row->custServiceZip;
				$map_url = "https://www.google.com/maps/search/?api=1&query=" . urlencode($address);

				// Every action for this job (Directions, Manage Financials,
				// Add Expense, Add Photos, Create/View Quote, Edit, Add
				// Callback/Punchlist, Call/Text Customer) now lives in one
				// gear dropdown instead of a pipe-separated link row,
				// see paintops_job_action_items() in job-view.php for the
				// single shared list of what shows and why. $dom_id needs
				// to be unique per card since this whole list can render
				// many cards on one page.
				$actionsMenuHtml = function_exists('paintops_render_job_actions_menu')
					? paintops_render_job_actions_menu($row, 'job-actions-' . (int) $row->id)
					: '';

				// Callback/Punch List/Add-On visit pill on the card too
				// (Mike 2026-07-23), same badge the job view shows.
				$cardVisitBadge = function_exists('paintops_visit_type_badge_html')
					? paintops_visit_type_badge_html((string) ($row->visit_type ?? 'original'))
					: '';
				$po_tpl_html = "<div class=\"job-card\">
				<div class=\"job-card-header\">
				<a href='" . paintops_href(['viewEid' => (int) $row->id]) . "'><b class=\"job-title\">{$isJobType} #{$row->id} {$fullWorkTitle}</b></a>
				<div class=\"job-card-badges\">
				  <span class=\"job-badge {$badgeClass}\">{$isJobType}</span>{$cardVisitBadge}
				  <span class=\"job-badge {$completedBadgeClass}\">{$completedLabel}</span>
				  " . ($startsText !== '' ? "<span class=\"job-badge job-badge-muted\">{$startsText}</span>" : "") . "
				</div>
				 </div>
				  <div class=\"job-meta\">
					<span class=\"job-date\">Scheduled for {$dateText}{$reqDaysText} | Customer: ". esc_html($row->custFirst) ." ". esc_html($row->custLast) ." Phone: ". esc_html(paintops_format_phone_number("$row->custPhone", 0)) ."<br>Address: <a href=\"". esc_url($map_url) ."\" target=\"_blank\" rel=\"noopener noreferrer\">". esc_html($address) ."</a></span>
				  </div>
				  {$actionsMenuHtml}
				  <div class=\"job-desc\">{$shortDesc}</div>
				  <div class=\"job-financials-line\" style=\"font-size:13px;color:#555;margin-top:6px;\">{$perHourReturnRes}". ($quoteInfo !== '' ? ' | ' . $quoteInfo : '') ."</div>
				</div>";
				echo wp_kses($po_tpl_html, paintops_kses_allowed_html(), paintops_kses_protocols());
			}
		}
		else {
			echo "<p style=\"color:#777;\">No jobs found.</p>";
		}

	return ob_get_clean();
}