<?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.
/**
 * PaintOps - Job View Card
 *
 * The single-job page (?viewEid=X) that gets emailed to a painter the
 * day before a job, redesigned for "hopping into the van ready to go":
 * a quick-action bar (call/text/directions), an at-a-glance card, the
 * Paint Colors section, and a collapsible "More Details" section for
 * the less time-critical fields.
 *
 * One shared renderer, paintops_render_job_view_card(), is used for
 * both the staff view (paintops_fetch_csjobs() in manage-jobs.php) and the
 * painter/helper view (paintops_render_worker_job_view() below). The
 * $opts['is_staff'] flag controls which extra fields/links show, the
 * job profit report is never passed into or rendered by this shared
 * function at all, staff-only code appends it separately, so there's
 * no path by which a painter's view can leak profit data.
 */

if (!defined('ABSPATH')) exit;

/**
 * Whether the current user may view a specific job's page.
 *
 * True for any staff-level user (paintops_user_can_manage()). Also true
 * for a Painter/Helper role account, but ONLY for a job they're actually
 * listed in (job.reqWorkers), never the job list, never another job,
 * never financials. Everyone else (including any other logged-in
 * non-staff account, e.g. a customer) is denied.
 */
function paintops_user_can_view_job(int $job_id, ?object $job_row = null): bool {
    if (!is_user_logged_in() || $job_id <= 0) {
        return false;
    }

    if (function_exists('paintops_user_can_manage') && paintops_user_can_manage()) {
        return true;
    }

    if (!function_exists('paintops_worker_role_slug')) {
        return false;
    }

    $user = wp_get_current_user();
    if (!in_array(paintops_worker_role_slug(), (array) $user->roles, true)) {
        return false;
    }

    global $wpdb;
    if (!$job_row) {
        $job_row = $wpdb->get_row($wpdb->prepare(
            "SELECT reqWorkers FROM %i WHERE id = %d LIMIT 1", paintops_table('jobs'), $job_id));
    }

    if (!$job_row || !isset($job_row->reqWorkers)) {
        return false;
    }

    $assigned_ids = paintops_parse_worker_ids($job_row->reqWorkers);

    return in_array((int) $user->ID, $assigned_ids, true);
}

/**
 * The maps apps the "Directions" action can open, all via keyless URL
 * schemes (no Google Maps API key needed). Array order is the menu order.
 */
function paintops_directions_providers(): array {
    return [
        'google' => 'Google Maps',
        'apple'  => 'Apple Maps',
        'waze'   => 'Waze',
    ];
}

/**
 * The current user's preferred maps app for Directions, saved per-user in
 * usermeta. Defaults to Google; validated against the provider whitelist so
 * a stale/unknown value can't get through.
 */
function paintops_user_directions_provider(int $user_id = 0): string {
    $user_id = $user_id > 0 ? $user_id : get_current_user_id();
    $saved   = $user_id > 0 ? (string) get_user_meta($user_id, 'paintops_directions_provider', true) : '';
    return array_key_exists($saved, paintops_directions_providers()) ? $saved : 'google';
}

/**
 * Build a keyless "start navigation to this address" URL for one provider.
 * All three open the native maps app on a phone and the web otherwise.
 */
function paintops_directions_url(string $provider, string $address): string {
    $enc = urlencode(trim($address));
    switch ($provider) {
        case 'apple':
            return 'https://maps.apple.com/?daddr=' . $enc;
        case 'waze':
            return 'https://waze.com/ul?q=' . $enc . '&navigate=yes';
        case 'google':
        default:
            return 'https://www.google.com/maps/dir/?api=1&destination=' . $enc;
    }
}

/**
 * AJAX: remember the current user's preferred Directions maps app. Any
 * logged-in user can set their own preference; the value is validated
 * against the whitelist so only google/apple/waze can be stored.
 */
function paintops_ajax_set_directions_provider(): void {
    if (!is_user_logged_in() || !check_ajax_referer('paintops_dir_provider', 'nonce', false)) {
        wp_send_json_error(['message' => 'Could not save that preference.'], 403);
    }
    $provider = isset($_POST['provider']) ? sanitize_key(wp_unslash($_POST['provider'])) : '';
    if (!array_key_exists($provider, paintops_directions_providers())) {
        wp_send_json_error(['message' => 'Unknown maps app.'], 400);
    }
    update_user_meta(get_current_user_id(), 'paintops_directions_provider', $provider);
    wp_send_json_success(['provider' => $provider]);
}
add_action('wp_ajax_paintops_set_directions_provider', 'paintops_ajax_set_directions_provider');

/**
 * The "Directions" quick action rendered as a split control: the main button
 * opens the user's preferred maps app, and a small gear beside it opens a
 * menu to pick Google / Apple / Waze. Picking one opens that app now and
 * remembers it (per-user, via the AJAX handler above) so the main button
 * uses it next time. Prints its small script once per page.
 */
function paintops_render_directions_widget(array $item): string {
    $current = (string) ($item['provider'] ?? 'google');
    $menu    = (array) ($item['directions_menu'] ?? []);
    static $widget_n = 0;
    $widget_n++;
    $dom = 'pv-dir-' . $widget_n;

    ob_start();
    ?>
    <span class="pv-dir-wrap">
        <a class="pv-dir-main" href="<?php echo esc_url($item['url']); ?>" target="_blank" rel="noopener noreferrer"><span class="dashicons dashicons-location-alt" aria-hidden="true"></span>Directions</a><a href="#" class="pv-dir-gear" title="Choose maps app" aria-label="Choose maps app" onclick="paintopsDirToggle('<?php echo esc_js($dom); ?>');return false;"><span class="dashicons dashicons-admin-generic" aria-hidden="true"></span></a>
        <span id="<?php echo esc_attr($dom); ?>" class="pv-dir-menu">
            <?php foreach ($menu as $m):
                $is_cur = ((string) $m['provider'] === $current); ?>
                <a href="<?php echo esc_url($m['url']); ?>" target="_blank" rel="noopener noreferrer" data-provider="<?php echo esc_attr($m['provider']); ?>" onclick="paintopsDirPick(this);"<?php echo wp_kses($is_cur ? ' class="is-current"' : '', paintops_kses_allowed_html(), paintops_kses_protocols()); ?>><span class="pv-dir-check dashicons dashicons-yes" aria-hidden="true" style="<?php echo esc_attr($is_cur ? '' : 'visibility:hidden;'); ?>"></span><?php echo esc_html($m['label']); ?></a>
            <?php endforeach; ?>
        </span>
    </span>
    <?php
    paintops_enqueue_directions_picker_script();
    return (string) ob_get_clean();
}

/**
 * The one-time JS for the Directions gear menu (toggle open/close, and on
 * pick update the main button + checkmark and POST the choice to usermeta;
 * the chosen menu item is a real link too, so it also opens that maps app).
 * The static logic lives in assets/js/job-view-directions.js; only the
 * ajax URL + nonce config is attached here as an inline before-fragment.
 * Footer execution is fine: both globals are only ever called from
 * onclick attributes, never during parse.
 */
function paintops_enqueue_directions_picker_script(): void {
    static $enqueued = false;
    if ($enqueued) {
        return;
    }
    $enqueued = true;
    paintops_enqueue_script('job-view-directions', 'var paintopsDirCfg = ' . wp_json_encode([
        'ajax'  => admin_url('admin-ajax.php'),
        'nonce' => wp_create_nonce('paintops_dir_provider'),
    ]) . ';');
}

/**
 * The single ordered list of every possible quick-action for a job,
 * each with its own visibility rule. Shared by the job list cards and
 * Job Board cards (manage-jobs.php / job-pipeline.php, one flat gear
 * dropdown) and this page's own Quick Actions / More Actions submenus
 * (paintops_render_job_view_card() below), one place to define who
 * sees what, so all three surfaces can never drift out of sync.
 *
 * This doesn't gate page access, it assumes the caller already has
 * permission to see this row at all (staff-only cards, or a worker
 * already cleared by paintops_user_can_view_job()), it only decides
 * which of these 9 actions apply to this one job for that viewer.
 */
function paintops_job_action_items(object $row): array {
    $job_id = (int) $row->id;

    $isJobType = function_exists('paintops_job_record_label')
        ? paintops_job_record_label((int) $row->jobType, (int) ($row->isBid ?? 0), (string) ($row->quote_label ?? 'quote'))
        : (((int) $row->jobType === 1) ? 'Quote' : 'Job');

    $address = trim($row->custServiceAddress . ', ' . $row->custServiceCity . ' ' . (string) ($row->custServiceState ?? '') . ' ' . $row->custServiceZip);
    $address = preg_replace('/\s+/', ' ', $address); // collapse the extra space when custServiceState is blank
    // The "Directions" action opens the driver's preferred maps app
    // (Google/Apple/Waze, saved per-user, see paintops_user_directions_provider())
    // in DIRECTIONS mode with this job as the destination, so tapping it starts
    // navigation right away instead of just showing the address on a map. All
    // three URL schemes are keyless (no Google Maps API key needed). The gear
    // beside the button (rendered by paintops_render_directions_widget()) picks
    // the app. The plain "Address:" links elsewhere still use a view/search URL.
    $dir_provider = paintops_user_directions_provider();
    $map_url = paintops_directions_url($dir_provider, $address);
    $dir_menu = [];
    foreach (paintops_directions_providers() as $dp_key => $dp_label) {
        $dir_menu[] = ['provider' => $dp_key, 'label' => $dp_label, 'url' => paintops_directions_url($dp_key, $address)];
    }

    $customerPhoneDigits = preg_replace('/\D+/', '', (string) ($row->custPhone ?? ''));

    $is_staff = function_exists('paintops_user_can_manage') && paintops_user_can_manage();
    $can_view_financials = function_exists('paintops_user_can_view_financials') && paintops_user_can_view_financials();
    $can_add_expense = function_exists('paintops_user_can_add_job_receipt') && paintops_user_can_add_job_receipt($job_id);
    $can_add_photos = function_exists('paintops_user_can_manage_job_photos') && paintops_user_can_manage_job_photos($job_id);
    $worker_can_create_quote = !$is_staff && function_exists('paintops_user_can_create_quote') && paintops_user_can_create_quote($job_id);

    // NOTE: "Log Vehicle Expense / Log Mileage" used to be a per-job action
    // here (it deep-linked to the Vehicle page with this job pre-selected).
    // Removed from the job actions (Mike's call): vehicle costs are a
    // business-level expense for taxes (deducted on the return, not against a
    // single job), and one gas fill-up rarely maps cleanly to one job, so
    // prompting to attach it to a job caused confusion. Vehicle logging still
    // lives on the standalone Vehicle page (nav / More), which can still
    // optionally tag a job for anyone who wants internal per-job insight.

    $has_quote = (int) ($row->estimateID ?? 0) > 0;
    $quoteShow = $is_staff || ($worker_can_create_quote && !$has_quote);
    // Quote/Estimate/Bid, whatever this business/record actually calls it.
    $docWord = function_exists('paintops_quote_doc_word')
        ? paintops_quote_doc_word((int) ($row->isBid ?? 0), (string) ($row->quote_label ?? 'quote'))
        : 'Quote';
    $quoteLabel = $has_quote ? "View {$docWord}" : "Create {$docWord}";
    $quoteUrl = $has_quote
        ? paintops_admin_url(['estimates' => 1, 'viewID' => (int) $row->estimateID])
        : paintops_admin_url(['estimates' => 1, 'csaction' => 'create', 'jobID' => $job_id]);

    // The record label itself is now "Request" pre-conversion (see
    // paintops_job_record_label), so no suffix logic needed anymore.
    $editLabel = "Edit {$isJobType}";

    // Order here is the render order within each group (the quick row and the
    // More Actions panel each pull their own subset of keys, in this order).
    // Quick row stays the few field-constant actions; Manage Financials moved
    // into More Actions to de-clutter, and Log Vehicle Expense sits above the
    // quote link.
    return [
        [
            'key' => 'directions', 'icon' => 'dashicons-location-alt', 'label' => 'Directions',
            'url' => $map_url, 'target_blank' => true, 'show' => true,
            'provider' => $dir_provider, 'directions_menu' => $dir_menu,
        ],
        [
            'key' => 'expense', 'icon' => 'dashicons-media-spreadsheet', 'label' => 'Add Expense',
            'url' => paintops_admin_url(['jobAddReceipt' => $job_id]), 'show' => $can_add_expense,
        ],
        [
            'key' => 'photos', 'icon' => 'dashicons-format-status', 'label' => 'Notes & Photos',
            'url' => paintops_admin_url(['jobPhotos' => $job_id]), 'show' => $can_add_photos,
        ],
        [
            // "Job Financials", ABOVE Create Quote (Mike 2026-07-23), the
            // renderers preserve THIS master array's order, so position
            // here is what changes the menu.
            'key' => 'financials', 'icon' => 'dashicons-money-alt', 'label' => 'Job Financials',
            // Tiny superscript Pro marker on the free plan, same .pro-badge
            // the nav's Profits/Vehicle/Business items use (Mike 2026-07-22),
            // so nothing in More Actions looks free that isn't.
            'pro_sup' => (function_exists('paintops_pro_active') && !paintops_pro_active()),
            'url' => paintops_admin_url(['jobFinancials' => $job_id]), 'show' => $can_view_financials && function_exists('paintops_pro_code_present'),
        ],
        [
            'key' => 'quote', 'icon' => 'dashicons-media-document', 'label' => $quoteLabel,
            'url' => $quoteUrl, 'show' => $quoteShow,
        ],
        [
            'key' => 'edit', 'icon' => 'dashicons-edit', 'label' => $editLabel,
            'url' => paintops_admin_url(['editEid' => $job_id]), 'show' => $is_staff,
        ],
        [
            'key' => 'callback', 'icon' => 'dashicons-calendar-alt', 'label' => 'Add Callback / Punchlist',
            'url' => paintops_admin_url(['scheduleFollowUp' => $job_id]), 'show' => $is_staff,
        ],
        [
            // 'comm' marks a CUSTOMER-contact action: both action-item
            // renderers turn it into the data-po-comm attributes the
            // app-wide tap logger watches (Mike 2026-07-23).
            'key' => 'call', 'icon' => 'dashicons-phone', 'label' => 'Call Customer',
            'url' => 'tel:' . $customerPhoneDigits, 'show' => $customerPhoneDigits !== '',
            'comm' => 'call', 'comm_recipient' => $customerPhoneDigits,
        ],
        [
            'key' => 'text', 'icon' => 'dashicons-format-chat', 'label' => 'Text Customer',
            'url' => 'sms:' . $customerPhoneDigits, 'show' => $customerPhoneDigits !== '',
            'comm' => 'text', 'comm_recipient' => $customerPhoneDigits,
        ],
    ];
}

/**
 * The subset of paintops_job_action_items() matching $only_keys and
 * actually visible to this viewer, shared by both the plain inline
 * links renderer and the gear-dropdown renderer below, so filtering
 * logic lives in exactly one place.
 */
function paintops_filter_job_action_items(object $row, array $only_keys): array {
    $items = [];
    foreach (paintops_job_action_items($row) as $item) {
        if (!empty($item['show']) && in_array($item['key'], $only_keys, true)) {
            $items[] = $item;
        }
    }
    return $items;
}

/**
 * Render a subset of paintops_job_action_items() as plain, always-
 * visible icon links (no dropdown), used for the Job View page's
 * Quick Actions row, which stays bare links rather than a toggle since
 * these are the ones reached for constantly (Mike's call: the original
 * Directions/Add Expense/Add Photos row never needed a click-to-reveal
 * step, and Manage Financials joining them doesn't change that).
 */
function paintops_render_job_actions_inline(object $row, array $only_keys): string {
    $items = paintops_filter_job_action_items($row, $only_keys);
    if (empty($items)) {
        return '';
    }

    ob_start();
    foreach ($items as $item) {
        // Directions renders as a split button + gear menu (pick Google/
        // Apple/Waze), not a plain link.
        if ($item['key'] === 'directions' && !empty($item['directions_menu'])) {
            echo wp_kses(paintops_render_directions_widget($item), paintops_kses_allowed_html(), paintops_kses_protocols());
            continue;
        }
        ?>
        <a href="<?php echo esc_attr($item['url']); ?>"<?php echo wp_kses(!empty($item['target_blank']) ? ' target="_blank" rel="noopener noreferrer"' : '', paintops_kses_allowed_html(), paintops_kses_protocols()); ?><?php echo wp_kses(!empty($item['comm']) ? ' data-po-comm="' . esc_attr($item['comm']) . '" data-po-job="' . (int) $row->id . '" data-po-recipient="' . esc_attr((string) ($item['comm_recipient'] ?? '')) . '" data-po-src="job actions"' : '', paintops_kses_allowed_html(), paintops_kses_protocols()); ?>><span class="dashicons <?php echo esc_attr($item['icon']); ?>" aria-hidden="true"></span><?php echo esc_html($item['label']); ?><?php echo wp_kses(!empty($item['pro_sup']) ? '<sup class="pro-badge">Pro</sup>' : '', paintops_kses_allowed_html(), paintops_kses_protocols()); ?></a>
        <?php
    }
    return ob_get_clean();
}

/**
 * Build one gear-dropdown group (a toggle link + its hidden panel) from
 * a subset of paintops_job_action_items(), pass every key for a
 * single flat "Actions" dropdown (job list / Job Board cards), or the
 * More Actions subset on the Job View page. $dom_id must be unique per
 * group per page (multiple cards/groups can render at once).
 *
 * Returns ['toggle' => <a> html, 'panel' => hidden-box html] rather
 * than one combined string, so callers can group the toggle link into
 * a .pv-quick-actions row (alongside any inline links from
 * paintops_render_job_actions_inline()) and place the panel as a
 * sibling after it.
 */
function paintops_build_job_actions_group(object $row, array $only_keys, string $dom_id, string $menu_label): array {
    $items = paintops_filter_job_action_items($row, $only_keys);

    if (empty($items)) {
        return ['toggle' => '', 'panel' => ''];
    }

    ob_start();
    ?>
    <?php paintops_enqueue_script('po-toggles'); ?>
    <a href="#" class="pv-more-toggle" data-po-toggle="<?php echo esc_attr($dom_id); ?>" data-open-label="<?php echo esc_attr('Hide ' . $menu_label); ?>" data-closed-label="<?php echo esc_attr($menu_label); ?>"><span class="dashicons dashicons-admin-generic" aria-hidden="true"></span><span class="po-toggle-label"><?php echo esc_html($menu_label); ?></span> <span class="pv-more-chevron">&#9662;</span></a>
    <?php
    $toggle = ob_get_clean();

    ob_start();
    ?>
    <div id="<?php echo esc_attr($dom_id); ?>" class="hidden-box pv-more-actions">
        <?php foreach ($items as $item): ?>
            <?php // esc_attr(), not esc_url(), for the href, esc_url()'s
            // protocol whitelist doesn't include "sms:", which would
            // silently strip the Text Customer link. Every URL here is
            // built by this function itself (never raw user input), so
            // esc_attr()'s plain HTML-entity escaping is all that's
            // actually needed. ?>
            <a href="<?php echo esc_attr($item['url']); ?>"<?php echo wp_kses(!empty($item['target_blank']) ? ' target="_blank" rel="noopener noreferrer"' : '', paintops_kses_allowed_html(), paintops_kses_protocols()); ?><?php echo wp_kses(!empty($item['comm']) ? ' data-po-comm="' . esc_attr($item['comm']) . '" data-po-job="' . (int) $row->id . '" data-po-recipient="' . esc_attr((string) ($item['comm_recipient'] ?? '')) . '" data-po-src="job actions menu"' : '', paintops_kses_allowed_html(), paintops_kses_protocols()); ?>><span class="dashicons <?php echo esc_attr($item['icon']); ?>" aria-hidden="true"></span><?php echo esc_html($item['label']); ?><?php echo wp_kses(!empty($item['pro_sup']) ? '<sup class="pro-badge">Pro</sup>' : '', paintops_kses_allowed_html(), paintops_kses_protocols()); ?></a>
        <?php endforeach; ?>
    </div>
    <?php
    $panel = ob_get_clean();

    return ['toggle' => $toggle, 'panel' => $panel];
}

/**
 * Render a single flat gear dropdown containing every applicable
 * action for a job, used by the job list cards and Job Board cards,
 * which (unlike the Job View page) show one combined "Actions" menu
 * rather than two separate submenus.
 */
function paintops_render_job_actions_menu(object $row, string $dom_id): string {
    $all_keys = ['directions', 'financials', 'expense', 'photos', 'quote', 'edit', 'callback', 'call', 'text'];
    $group = paintops_build_job_actions_group($row, $all_keys, $dom_id, 'Actions');

    if ($group['toggle'] === '') {
        return '';
    }

    return '<div class="pv-quick-actions" style="margin:6px 0;">' . $group['toggle'] . '</div>' . $group['panel'];
}

/**
 * A small "make sure this is the right job" box, Customer, Address
 * (linked to a map search), and Scheduled date/time/duration, shown
 * right under the <h2> heading on every job sub-page (Financials, Add
 * Expense, Photos, Create Quote, Schedule Follow-Up). Mike's point: it's
 * easy to land on one of these pages, get pulled away, and come back
 * to add photos/hours/an expense to what you assume is still the same
 * job, so every one of these single-job forms gets the same at-a-
 * glance reminder of whose job this actually is.
 *
 * $include_scheduled defaults true, but the Schedule Follow-Up form
 * passes false: $row there is the *parent* job being followed up on,
 * so its own startYear/Time is the original visit's schedule, not the
 * new one this form is about to create, showing it would look like
 * the new visit's date rather than a stale, unrelated one.
 */
/**
 * "Jul 18th, 2026 07:00 for 4 days", the schedule line shown on the job
 * view's glance box and the Jobs page Today widget (2026-07-22), built
 * in ONE place so the two can't drift. Empty string when the record has
 * no start date. Naive date()/mktime() on the stored wall-clock values,
 * per the plugin's timezone convention.
 */
function paintops_job_scheduled_text(object $row): string {
    if ((int) ($row->startYear ?? 0) <= 0) {
        return '';
    }
    $dateText = date('M jS, Y', mktime(0, 0, 0, (int) ($row->startMonth ?? 1), (int) ($row->startDay ?? 1), (int) $row->startYear));
    $startTime = trim((string) ($row->startTime ?? ''));

    $reqDays = (int) ($row->reqDays ?? 0);
    if ($reqDays === 1) {
        $reqDaysText = ' for 1 day';
    } elseif ($reqDays > 1) {
        $reqDaysText = " for {$reqDays} days";
    } else {
        $reqDaysText = ' for < a day';
    }

    return trim($dateText . ' ' . $startTime) . $reqDaysText;
}

function paintops_render_job_customer_summary(object $row, bool $include_scheduled = true): string {
    $custName = trim(($row->custFirst ?? '') . ' ' . ($row->custLast ?? ''));

    // (2026-07-19, Mike's call) The Address row (with its maps link) was
    // dropped from this glance block: it renders only on job SUB-pages
    // (Financials, Notes & Photos, Add Expense, the quote form), where
    // the address is noise, the job view itself still shows the address
    // and Directions. In its place, the Scheduled row carries a View Job
    // link back to the full job.
    $scheduledText = $include_scheduled ? paintops_job_scheduled_text($row) : '';

    if ($custName === '' && $scheduledText === '') {
        return '';
    }

    ob_start();
    ?>
    <div class="cs-financial-box pv-glance-inline" style="margin-bottom:18px;padding:12px 20px;">
        <?php if ($custName !== ''): ?>
            <div class="pv-glance-row"><strong>Customer:</strong> <?php echo esc_html($custName); ?></div>
        <?php endif; ?>
        <?php /* No View Job link here (2026-07-19, Mike's call, same
               day it was added): every sub-page showing this block
               already carries a correctly-labeled "Back to Quote /
               Job / Estimate / Bid" link at the top, so the extra
               link was a duplicate with a worse label. */ ?>
        <?php if ($scheduledText !== ''): ?>
            <div class="pv-glance-row"><strong>Scheduled:</strong> <?php echo esc_html($scheduledText); ?></div>
        <?php endif; ?>
    </div>
    <?php
    return ob_get_clean();
}

/**
 * Shared job-view card. $row is a full row from the jobs table.
 * $opts:
 *   'is_staff' (bool) - show edit links, quote/invoice IDs, and the
 *                        audit fields (added by / created / updated).
 *                        Defaults false (the painter-safe rendering).
 */
function paintops_render_job_view_card($row, array $opts = []): string {
    $is_staff = !empty($opts['is_staff']);

    $startTime = trim((string) $row->startTime);
    if ($startTime !== '' && strpos($startTime, ':') === false) {
        $startTime = str_pad($startTime, 2, '0', STR_PAD_LEFT) . ':00';
    }

    $dateString = "{$row->startMonth} {$row->startDay}, {$row->startYear} {$startTime}";
    $target = DateTime::createFromFormat('n j, Y H:i', $dateString, wp_timezone());
    $dateText = date("M jS, Y", strtotime("{$row->startYear}-{$row->startMonth}-{$row->startDay}"));

    $now = new DateTime('now', wp_timezone());
    $diff = $now->diff($target);

    if ($target < $now) {
        $arrivalTextBefore = "Started ";
        $arrivalTextAfter = " ago";
    } else {
        $arrivalTextBefore = "Arrive in ";
        $arrivalTextAfter = "";
    }

    // Full interval, two most significant units (Mike 2026-07-23: a job
    // scheduled 8.5 months ago showed "Started 18 days ago", because
    // DateInterval->d is only the DAYS COMPONENT (0-30); the ->m months
    // and ->y years were being silently dropped). "8 months, 18 days"
    // now, and minutes only matter when the gap is under an hour.
    $pvUnits = [
        ['y', 'year'], ['m', 'month'], ['d', 'day'], ['h', 'hour'], ['i', 'minute'],
    ];
    $parts = [];
    foreach ($pvUnits as $pvUnit) {
        $pvVal = (int) $diff->{$pvUnit[0]};
        if ($pvVal > 0) {
            $parts[] = $pvVal . ' ' . $pvUnit[1] . ($pvVal > 1 ? 's' : '');
        }
        if (count($parts) === 2) {
            break;
        }
    }
    if (!$parts) {
        $parts[] = 'moments';
    }
    $arriveBy = "$arrivalTextBefore" . implode(', ', $parts) . "$arrivalTextAfter";

    // For staff on an actual Job, the Complete/Not Complete pill is a
    // TOGGLE (Mike 2026-07-23): click flips it, both directions, behind a
    // confirm() and a nonce (handler in manage-jobs.php's viewEid route).
    // Workers and non-Job records keep the static pill.
    $pvCompleteIsOn = ((int) $row->completed === 1);
    $pvCompleteClass = $pvCompleteIsOn ? 'job-badge-complete-yes' : 'job-badge-complete-no';
    $pvCompleteLabel = $pvCompleteIsOn ? 'Complete' : 'Not Complete';
    if ($is_staff && (int) $row->jobType === 2 && function_exists('paintops_user_can_manage') && paintops_user_can_manage()) {
        // A tiny POST form styled as the same pill (2026-08-31): the old
        // GET link could be prefetched or double-requested, toggling the
        // job twice in a row so nothing seemed to stick (Mike's 1.0.273
        // stale-state report). Forms are never prefetched.
        $pvConfirm = $pvCompleteIsOn ? 'Mark this job as NOT complete?' : 'Mark this job complete?';
        $pvTitle = $pvCompleteIsOn ? 'Click to mark as not complete' : 'Click to mark complete';
        $isCompleted = '<form method="post" action="' . esc_url(paintops_admin_url(['viewEid' => (int) $row->id])) . '" style="display:inline;margin:0;" onsubmit="return confirm(' . esc_attr(wp_json_encode($pvConfirm)) . ');">'
            . wp_nonce_field('paintops_toggle_complete_' . (int) $row->id, 'paintops_toggle_nonce', true, false)
            . '<button type="submit" name="paintopsToggleComplete" value="1" class="job-badge ' . $pvCompleteClass . '" style="margin-left:8px;vertical-align:middle;cursor:pointer;border:none;font-family:inherit;" title="' . esc_attr($pvTitle) . '">' . $pvCompleteLabel . '</button>'
            . '</form>';
    } elseif ((int) $row->jobType === 2) {
        $isCompleted = '<span class="job-badge ' . $pvCompleteClass . '" style="margin-left:8px;vertical-align:middle;">' . $pvCompleteLabel . '</span>';
    } else {
        // A Request (quote/estimate/bid) has no meaningful complete state
        // (Mike 2026-07-23): no pill at all until it becomes a Job. A
        // record marked Not Won shows that instead.
        $isCompleted = ((int) ($row->not_won ?? 0) === 1)
            ? '<span class="job-badge job-badge-muted" style="margin-left:8px;vertical-align:middle;">Not Won</span>'
            : '';
    }

    // Balance badge (2026-07-22, Mike): a Job shows what's still owed right
    // in its header, same canonical summary the board cards use. Dollar
    // amounts only for viewers with the financials opt-in. On Pro the
    // Balance Due badge links straight to the Payments log on the
    // Financials page; on free (which has no Financials page) a separate
    // Mark-as-Paid button renders below (staff view only), otherwise every
    // free job would show a balance forever with no way to clear it.
    $balanceBadge = '';
    $pvBalanceRemaining = 0.0;
    if ((int) $row->jobType === 2
        && function_exists('paintops_user_can_view_financials') && paintops_user_can_view_financials()
        && function_exists('paintops_job_financial_summary')) {
        $pvFin = paintops_job_financial_summary((int) $row->id);
        $pvGross = (float) ($pvFin['gross'] ?? 0);
        $pvBalanceRemaining = (float) ($pvFin['remaining_balance'] ?? 0);
        $pvCur = function_exists('paintops_currency_symbol') ? paintops_currency_symbol() : '$';
        if ($pvGross > 0) {
            if ($pvBalanceRemaining > 0.005) {
                $pvBalanceText = esc_html($pvCur . number_format($pvBalanceRemaining, 2)) . ' Balance Due';
                if (function_exists('paintops_pro_active') && paintops_pro_active()) {
                    $balanceBadge = '<a class="job-badge job-badge-complete-no" style="margin-left:8px;vertical-align:middle;text-decoration:none;" href="' . esc_url(paintops_admin_url(['jobFinancials' => (int) $row->id]) . '#paintops-payments') . '" title="Record a payment">' . $pvBalanceText . '</a>';
                } else {
                    $balanceBadge = '<span class="job-badge job-badge-complete-no" style="margin-left:8px;vertical-align:middle;">' . $pvBalanceText . '</span>';
                }
            } else {
                $balanceBadge = '<span class="job-badge job-badge-complete-yes" style="margin-left:8px;vertical-align:middle;">Paid in full</span>';
            }
        }
    }

    $isJobType = function_exists('paintops_job_record_label')
        ? paintops_job_record_label((int) $row->jobType, (int) ($row->isBid ?? 0), (string) ($row->quote_label ?? 'quote'))
        : (($row->jobType == 1) ? 'Quote' : 'Job');

    if ($row->reqDays == 1) {
        $reqDaysText = " for 1 day";
    } elseif ($row->reqDays > 1) {
        $reqDaysText = " for {$row->reqDays} days";
    } else {
        $reqDaysText = " for < a day";
    }

    $address = $row->custServiceAddress . ', ' . $row->custServiceCity . ' ' . (string) ($row->custServiceState ?? '') . ' ' . $row->custServiceZip;
    $address = preg_replace('/\s+/', ' ', $address); // collapse the extra space when custServiceState is blank
    $map_url = "https://www.google.com/maps/search/?api=1&query=" . urlencode($address);

    // Assigned team members for the "Workers:" glance row. Each shows the
    // person's nickname (falling back to display name), plus one-tap Call
    // and Text links when they've saved an opt-in phone number under
    // PaintOps > Painters/Helpers, so others assigned to this same job
    // can reach them on site. The job view is already access-controlled to
    // staff + workers assigned to this job, so these numbers are never
    // exposed to customers.
    $userIds = paintops_parse_worker_ids($row->reqWorkers ?? '');
    $current_uid = get_current_user_id();
    $teamMembersHtml = '';
    foreach ($userIds as $userId) {
        $userId = (int) $userId;
        $label = function_exists('paintops_worker_display_label') ? paintops_worker_display_label($userId) : '';
        if ($label === '') {
            $u = get_userdata($userId);
            $label = $u ? $u->display_name : '';
        }
        if ($label === '') {
            continue;
        }
        $is_self = ($userId === (int) $current_uid);
        // Each worker renders as its own bordered chip (Mike 2026-07-22:
        // plain names ran together and were hard to tell apart), with the
        // YOU tag or Call/Text mini-pills grouped INSIDE the same capsule
        // so it stays compact. pv-team-nameonly evens out the right padding
        // on a chip with no inner pills.
        $memberExtras = '';
        if ($is_self) {
            // No point offering to call/text yourself; tag the chip instead
            // so it's clear which assigned worker is the person viewing.
            $memberExtras .= '<span class="pv-team-you">You</span>';
        } else {
            $phone = function_exists('paintops_get_worker_phone') ? paintops_get_worker_phone($userId) : '';
            $phoneDigits = ($phone !== '' && function_exists('paintops_phone_href_digits')) ? paintops_phone_href_digits($phone) : '';
            if ($phoneDigits !== '') {
                $memberExtras .= '<a class="pv-team-contact" href="tel:' . esc_attr($phoneDigits) . '" title="' . esc_attr('Call ' . $label . ' (' . $phone . ')') . '">Call</a>';
                $memberExtras .= '<a class="pv-team-contact" href="sms:' . esc_attr($phoneDigits) . '" title="' . esc_attr('Text ' . $label . ' (' . $phone . ')') . '">Text</a>';
            }
        }
        $memberHtml = '<span class="pv-team-member' . ($memberExtras === '' ? ' pv-team-nameonly' : '') . '"><span class="pv-team-name">' . esc_html($label) . '</span>' . $memberExtras . '</span>';
        $teamMembersHtml .= $memberHtml . ' ';
    }

    $addedByUser = (int) ($row->addedBy ?? 0) > 0 ? get_userdata((int) $row->addedBy) : false;
    $addedByName = $addedByUser ? $addedByUser->display_name : 'Unknown';

    $categoryName = function_exists('paintops_get_category_details') ? paintops_get_category_details($row->jobCat, 0) : '';

    $customerPhoneDigits = preg_replace('/\D+/', '', (string) $row->custPhone);
    $customerPhFm = function_exists('paintops_format_phone_number') ? paintops_format_phone_number((string) $row->custPhone, 1) : esc_html($row->custPhone);

    if ($row->estimateID > 0) {
        $estLink = '<a href="' . paintops_href(['estimates' => 1, 'viewID' => (int) $row->estimateID]) . '">' . (int) $row->estimateID . '</a>';
    } else {
        $estLink = '<a href="' . paintops_href(['estimates' => 1, 'csaction' => 'create', 'jobID' => (int) $row->id]) . '">None</a>';
    }

    $invoiceLink = $row->invoiceID > 0
        ? '<a href="' . paintops_href(['invoices' => 1, 'viewID' => (int) $row->invoiceID]) . '">' . (int) $row->invoiceID . '</a>'
        : 'None';

    if (function_exists('paintops_maps_api_key') && paintops_maps_api_key() !== '') {
        // Reuses $address (already built above from the actual address/
        // city/zip fields) instead of re-concatenating the pieces with a
        // hardcoded "FL", this plugin has no state field, and the
        // literal state was wrong for any business outside Florida.
        // urlencode() also fixes addresses containing "#", "&", or "+",
        // which previously broke or truncated the embed URL.
        $embed_src = esc_url('https://www.google.com/maps/embed/v1/place?key=' . paintops_maps_api_key()
            . '&q=' . urlencode($address));
        $mapEmbedHtml = '<iframe width="100%" height="320" style="border:0;max-width:600px;" loading="lazy" allowfullscreen referrerpolicy="no-referrer-when-downgrade" src="' . $embed_src . '"></iframe>';
    } else {
        // No API key: the embedded preview needs one, but directions do not.
        // Google's Maps URLs API (maps/dir and maps/search with api=1) is
        // keyless and opens the maps app on a phone, so fall back to plain
        // links instead of an error, the actionable part still works.
        $addr_trim = trim((string) $address);
        if ($addr_trim !== '') {
            $dir_url    = 'https://www.google.com/maps/dir/?api=1&destination=' . urlencode($addr_trim);
            $search_url = 'https://www.google.com/maps/search/?api=1&query=' . urlencode($addr_trim);
            $mapEmbedHtml = '<p style="margin:0;">'
                . '<a class="paintops-btn paintops-btn-secondary" target="_blank" rel="noopener noreferrer" href="' . esc_url($dir_url) . '"><span class="dashicons dashicons-location-alt" aria-hidden="true"></span> Get Directions</a> '
                . '<a class="paintops-btn paintops-btn-secondary" target="_blank" rel="noopener noreferrer" href="' . esc_url($search_url) . '"><span class="dashicons dashicons-location" aria-hidden="true"></span> View on Google Maps</a>'
                . '</p>'
                . '<p class="description" style="margin-top:6px;">The inline map preview needs a Google Maps API key (PaintOps &gt; Settings). These links work without one and open the maps app.</p>';
        } else {
            $mapEmbedHtml = '<p class="description" style="margin:0;">No service address on file for this job.</p>';
        }
    }

    $paintColorsHtml = function_exists('paintops_render_paint_colors_list') ? paintops_render_paint_colors_list((int) $row->id) : '';
    $jobPhotosHtml = function_exists('paintops_render_job_photos_list') ? paintops_render_job_photos_list((int) $row->id) : '';
    $visitBadge = function_exists('paintops_visit_type_badge_html') ? paintops_visit_type_badge_html((string) ($row->visit_type ?? 'original')) : '';
    $followupsHtml = function_exists('paintops_render_followups_section') ? paintops_render_followups_section($row, $is_staff) : '';

    // Exclusions (labeled "Scope & Exclusions" in older comments/settings
    // names, but just "Exclusions" wherever it's displayed, the job
    // description above already is the scope, this field only ever holds
    // what's not included) lives on the quote/estimate itself (see
    // includes/estimates.php) rather than the job record, a job can go
    // through several estimate revisions before it's awarded, so this
    // pulls whichever one is most recent, the same one that was actually
    // sent to (or will be sent to) the customer.
    $scopeNotesHtml = '';
    global $wpdb;
    $latest_scope_notes = $wpdb->get_var($wpdb->prepare(
        "SELECT scope_notes FROM %i WHERE jobID = %d AND scope_notes IS NOT NULL AND scope_notes != '' ORDER BY estID DESC LIMIT 1", paintops_table('estimates'), (int) $row->id));
    if (!empty($latest_scope_notes)) {
        $scopeNotesHtml = nl2br(esc_html(stripslashes($latest_scope_notes)));
    }

    // When this record is an awarded Job (not a quote/estimate/bid) that has a
    // quote attached, the most useful scope in the field is the quote's own
    // Description of Work (estDetails), the version actually sent to the
    // customer, shown above Exclusions. The plain Job Description is then
    // less needed up top, so it moves down into More Details.
    $has_quote = (int) ($row->estimateID ?? 0) > 0;
    $quoteScopeHtml = '';
    if ($isJobType === 'Job' && $has_quote) {
        $latest_details = $wpdb->get_var($wpdb->prepare(
            "SELECT estDetails FROM %i WHERE jobID = %d AND estDetails IS NOT NULL AND estDetails != '' ORDER BY estID DESC LIMIT 1", paintops_table('estimates'), (int) $row->id));
        if (!empty($latest_details)) {
            $quoteScopeHtml = wp_kses_post(stripslashes($latest_details));
        }
    }
    $moveJobDescToDetails = ($quoteScopeHtml !== '');

    ob_start();
    ?>
    <?php // .pv-quick-actions/.pv-more-toggle now live in job-style.php
    // (required on every app page), so both this page and the quote
    // view page (?estimates=1&viewID=X, see includes/estimates.php)
    // share one copy instead of drifting apart. Only the rules
    // specific to this page's own layout live in the page's own
    // stylesheet (assets/css/job-view-card.css), .pv-glance-row
    // also moved to job-style.php since paintops_render_job_customer_summary()
    // now reuses it on other job sub-pages too.
    paintops_enqueue_style('job-view-card'); ?>

    <h1 class="job-title">
        <span class="job-id"><?php echo esc_html(stripslashes("{$isJobType} #{$row->id} {$categoryName} {$row->jobTitle}")); ?><?php echo wp_kses($visitBadge, paintops_kses_allowed_html(), paintops_kses_protocols()); ?><?php echo wp_kses($isCompleted, paintops_kses_allowed_html(), paintops_kses_protocols()); ?><?php echo wp_kses($balanceBadge, paintops_kses_allowed_html(), paintops_kses_protocols()); ?></span>
    </h1>

    <?php
    // Muted meta line, not shouty body text, "Started 19 days, 14
    // hours, 34 minutes ago [Refresh Time]" at 120% with a bracketed
    // blue link read like debug output at the top of the page. Same
    // info, styled like the metadata it is; #777 is one of the inline
    // grays the Dark theme knows how to re-color.
    ?>
    <?php if ((int) $row->jobType === 2) : // "Started/Arrive in" only makes sense for a real Job (Mike 2026-07-23) ?>
    <div style="font-size:13px;color:#777;margin:2px 0 4px;">
        <?php echo esc_html($arriveBy); ?> &middot; <a href="#" style="font-weight:600;text-decoration:none;" onclick="location.reload(true); return false;">Refresh</a>
    </div>
    <?php endif; ?>


    <?php if (!$is_staff && (int) $row->jobType === 2 && (int) $row->completed !== 1) :
        // Worker "Mark Complete" (2026-07-19): only on the worker view of
        // an active, not-yet-complete job; POST handled by the worker
        // ?viewEid= route in manage-jobs.php (job-scoped nonce, assignment
        // re-checked there via the route's own gate).
    ?>
    <form method="post" style="margin:10px 0 4px;" onsubmit="return confirm('Mark this job complete? The office will be notified.');">
        <?php wp_nonce_field('paintops_worker_complete_' . (int) $row->id, 'paintops_worker_complete_nonce'); ?>
        <input type="hidden" name="job_id" value="<?php echo (int) $row->id; ?>">
        <button type="submit" name="paintops_worker_complete" value="1" class="paintops-btn paintops-btn-primary">&#10003; Mark Job Complete</button>
    </form>
    <?php endif; ?>

    <?php if ($is_staff && (int) $row->jobType === 1) :
        // Quote-stage record viewed by staff: offer the win right here
        // (2026-07-19, pre-launch audit P0 #1). Handled by the POST block
        // at the top of the ?viewEid= route in manage-jobs.php (nonce +
        // paintops_user_can_manage), same state change as the Job Board's
        // "Mark as Job" move (jobType 2, completed 0, not_won 0). This is
        // the manual half; online acceptance now auto-converts too
        // (customer-payments.php).
    ?>
    <form method="post" style="margin:10px 0 4px;display:inline-block;">
        <?php wp_nonce_field('paintops_mark_won_action', 'paintops_mark_won_nonce'); ?>
        <input type="hidden" name="job_id" value="<?php echo (int) $row->id; ?>">
        <button type="submit" name="paintops_mark_won" value="1" class="paintops-btn paintops-btn-primary">&#10003; Mark as Won, convert to Job</button>
    </form>
    <?php if ((int) ($row->not_won ?? 0) !== 1) : ?>
    <?php // The losing half (Mike 2026-07-23: "no way to mark it as not
          // approved" from the record itself). Same state change as the
          // Job Board's Mark as Not Won move: not_won = 1 + the manual
          // win stamp cleared; the record drops into the board's Not Won
          // drawer, where it can be revived later. ?>
    <form method="post" style="margin:10px 0 4px 6px;display:inline-block;" onsubmit="return confirm('Mark this as Not Won? It moves to the Not Won drawer on the Job Board, where it can be revived later.');">
        <?php wp_nonce_field('paintops_mark_not_won_action', 'paintops_mark_not_won_nonce'); ?>
        <input type="hidden" name="job_id" value="<?php echo (int) $row->id; ?>">
        <button type="submit" name="paintops_mark_not_won" value="1" class="paintops-btn paintops-btn-secondary">Mark as Not Won</button>
    </form>
    <?php endif; ?>
    <?php endif; ?>

    <?php
    // Every possible action for this job is defined once in
    // paintops_job_action_items() (job-view.php, above). Quick Actions
    // (Directions, Add Expense, Notes & Photos) stay as plain always-visible
    // links, these are reached for constantly, so no click-to-reveal step.
    // More Actions (Log Vehicle Expense, quote, edit, callback, Manage
    // Financials, call/text, less frequent) stays behind its gear toggle.
    // Manage Financials moved here to de-clutter the quick row. Each item
    // still re-checks its own permission inside paintops_job_action_items(),
    // so an assigned worker only ever sees the subset they're allowed to use.
    $pv_quick_links = paintops_render_job_actions_inline(
        $row,
        ['directions', 'expense', 'photos']
    );
    $pv_more_group = paintops_build_job_actions_group(
        $row,
        ['quote', 'edit', 'callback', 'financials', 'call', 'text'],
        'pv-actions-more-' . (int) $row->id,
        'More Actions'
    );
    ?>

    <div class="pv-quick-actions pv-quick-actions-tiles">
        <?php echo wp_kses($pv_quick_links, paintops_kses_allowed_html(), paintops_kses_protocols()); ?>
        <?php echo wp_kses($pv_more_group['toggle'], paintops_kses_allowed_html(), paintops_kses_protocols()); ?>
    </div>
    <?php echo wp_kses($pv_more_group['panel'], paintops_kses_allowed_html(), paintops_kses_protocols()); ?>

    <?php
    // Time Clock (check in / check out, see includes/job-checkin.php),
    // placed here, above the job details, because it's the first thing a
    // crew member needs on arrival. The box also self-gates (renders '' for
    // anyone who can't punch this job's clock, including when the shop-wide
    // Time Clock toggle is off), but we ALSO check that toggle here so the
    // job view never even calls the box when the feature is disabled. Belt
    // and suspenders: the toggle lives in paintops.php while the box's own
    // gate lives in job-checkin.php, so gating at this call site keeps the
    // job view honoring the setting even if an older job-checkin.php (from
    // before the toggle shipped, which carried no version bump) is deployed.
    $timeclock_on = !function_exists('paintops_timeclock_enabled') || paintops_timeclock_enabled();
    // Jobs only (Mike 2026-07-23): checking in to a not-yet-accepted
    // Request makes no sense, the box appears once it's a real Job.
    if ($timeclock_on && (int) $row->jobType === 2 && function_exists('paintops_render_job_checkin_box')) {
        echo wp_kses(paintops_render_job_checkin_box((int) $row->id), paintops_kses_allowed_html(), paintops_kses_protocols());
    }

    // Communications log moved DOWN next to the Map drawer (Mike
    // 2026-07-22: useful record, rarely opened, didn't earn a top slot).
    // See right above the Map <details> further down.
    ?>

    <?php
    // "Where does this job stand in time" status chip (Mike 2026-07-22):
    // the page told you everything about the job except whether it's
    // upcoming, mid-run, or past. Jobs only; a Request's stage lives on
    // the Job Board. Date math on the scheduled start + reqDays.
    $jobStatusLine = '';
    if ((int) $row->jobType === 2 && (int) $row->startYear > 2000 && (int) $row->startMonth >= 1 && (int) $row->startDay >= 1) {
        $psDays  = max((int) $row->reqDays, 1);
        $psStart = mktime(0, 0, 0, (int) $row->startMonth, (int) $row->startDay, (int) $row->startYear);
        $psToday = strtotime(gmdate('Y-m-d', current_time('timestamp')));
        $psDiff  = (int) floor(($psToday - $psStart) / DAY_IN_SECONDS);
        if ((int) ($row->completed ?? 0) === 1) {
            $jobStatusLine = '<span class="pv-status-chip pv-status-done">Completed</span>';
        } elseif ($psDiff < 0) {
            $inDays = -$psDiff;
            $jobStatusLine = '<span class="pv-status-chip pv-status-upcoming">' . ($inDays === 1 ? 'Starts tomorrow' : 'Starts in ' . $inDays . ' days') . '</span>';
        } elseif ($psDiff < $psDays) {
            $jobStatusLine = '<span class="pv-status-chip pv-status-active">' . ($psDays === 1 ? 'In progress today' : 'Day ' . ($psDiff + 1) . ' of ' . $psDays) . '</span>';
        } else {
            $jobStatusLine = '<span class="pv-status-chip pv-status-past">Scheduled days ended ' . esc_html(date('M j, Y', $psStart + (($psDays - 1) * DAY_IN_SECONDS))) . '</span>';
        }
    }
    ?>
    <div class="cs-financial-box">
        <?php if ($jobStatusLine !== ''): ?>
            <div class="pv-glance-row"><strong>Status:</strong> <?php echo wp_kses($jobStatusLine, paintops_kses_allowed_html(), paintops_kses_protocols()); ?></div>
        <?php endif; ?>
        <?php // Customer contact is a two-click reveal (Mike 2026-07-22):
              // tapping the NAME shows the Call/Text pills, so a pocket
              // tap can't dial the customer. Workers keep one-tap pills,
              // a stray call to a coworker is understood. ?>
        <div class="pv-glance-row"><strong>Customer:</strong>
            <?php if ($customerPhoneDigits !== ''): ?>
                <span class="pv-cust-toggle" onclick="var c=document.getElementById('pv-custc-<?php echo (int) $row->id; ?>'); var open=(c.style.display==='none'); c.style.display=open?'':'none'; var ch=this.querySelector('.pv-more-chevron'); if(ch){ch.innerHTML=open?'&#9652;':'&#9662;';}" title="Show call/text options"><?php echo esc_html(trim($row->custFirst . ' ' . $row->custLast)); ?> <span class="pv-more-chevron">&#9662;</span></span>
                <span id="pv-custc-<?php echo (int) $row->id; ?>" style="display:none;">
                    <a class="pv-team-contact" href="tel:<?php echo esc_attr($customerPhoneDigits); ?>" data-po-comm="call" data-po-job="<?php echo (int) $row->id; ?>" data-po-recipient="<?php echo esc_attr($customerPhoneDigits); ?>" data-po-src="job page" title="<?php echo esc_attr('Call ' . trim($row->custFirst . ' ' . $row->custLast) . ' (' . $customerPhFm . ')'); ?>">Call</a>
                    <a class="pv-team-contact" href="sms:<?php echo esc_attr($customerPhoneDigits); ?>" data-po-comm="text" data-po-job="<?php echo (int) $row->id; ?>" data-po-recipient="<?php echo esc_attr($customerPhoneDigits); ?>" data-po-src="job page" title="<?php echo esc_attr('Text ' . trim($row->custFirst . ' ' . $row->custLast) . ' (' . $customerPhFm . ')'); ?>">Text</a>
                </span>
            <?php else: ?>
                <?php echo esc_html(trim($row->custFirst . ' ' . $row->custLast)); ?>
            <?php endif; ?>
        </div>
        <?php // Plain text address (Mike 2026-07-22): the Directions tile
              // sits right above this box, the map-search link duplicated
              // it and invited accidental taps. ?>
        <div class="pv-glance-row"><strong>Address:</strong> <?php echo esc_html($address); ?></div>
        <div class="pv-glance-row"><strong>Scheduled:</strong> <?php echo esc_html($dateText . ' ' . $row->startTime . $reqDaysText); ?></div>
        <?php if (trim($teamMembersHtml) !== ''): ?>
            <div class="pv-glance-row"><strong>Workers:</strong> <?php echo wp_kses(trim($teamMembersHtml), paintops_kses_allowed_html(), paintops_kses_protocols()); ?></div>
        <?php endif; ?>

        <?php if ($moveJobDescToDetails): ?>
            <div class="job-desc" style="margin-top:14px;">
                <strong>Description of Work:</strong><br><br>
                <?php echo wp_kses($quoteScopeHtml, paintops_kses_allowed_html(), paintops_kses_protocols()); ?>
            </div>
        <?php else: ?>
            <p class="job-desc" style="margin-top:14px;">
                <strong>Job Description:</strong><br><br>
                <?php echo wp_kses(nl2br(esc_html(stripslashes((string) $row->jobDesc))), paintops_kses_allowed_html(), paintops_kses_protocols()); ?>
            </p>
        <?php endif; ?>

        <?php if ($scopeNotesHtml !== ''): ?>
            <div class="job-desc" style="margin-top:14px;">
                <strong>Exclusions:</strong><br><br>
                <?php echo wp_kses($scopeNotesHtml, paintops_kses_allowed_html(), paintops_kses_protocols()); ?>
            </div>
        <?php endif; ?>
    </div>

    <?php
    // Master Notes: pinned, always-visible crew reference (materials, door
    // codes, parking, pets, quiet hours). Anyone who can see this job reads
    // it; only staff edit (inline, via the paintops_save_job_master_notes
    // AJAX endpoint). Shown to staff even when empty (with an add prompt);
    // hidden from crew when empty.
    // stripslashes: legacy rows saved before the 2026-07-23 wp_unslash
    // fix in manage-jobs.php may hold escaped apostrophes.
    $masterNotesRaw = isset($row->master_notes) ? stripslashes((string) $row->master_notes) : '';
    $hasMasterNotes = trim($masterNotesRaw) !== '';
    $mnEmptyPrompt  = 'No master notes yet. Click Edit to add door codes, parking, pets, materials, and other on-site details.';
    if ($is_staff || $hasMasterNotes): ?>
    <div class="cs-financial-box pv-master-notes" style="margin-top:18px;" id="pv-mn-<?php echo (int) $row->id; ?>">
        <h3 style="margin-top:0;display:flex;align-items:center;gap:12px;">Master Notes
            <?php if ($is_staff): ?><button type="button" class="button pv-mn-edit" style="font-size:12px;line-height:1.6;">Edit</button><?php endif; ?>
        </h3>
        <div class="pv-mn-display"<?php echo wp_kses($hasMasterNotes ? '' : ' style="color:var(--po-text-muted,#888);"', paintops_kses_allowed_html(), paintops_kses_protocols()); ?>><?php echo wp_kses($hasMasterNotes ? nl2br(esc_html($masterNotesRaw)) : ($is_staff ? esc_html($mnEmptyPrompt) : ''), paintops_kses_allowed_html(), paintops_kses_protocols()); ?></div>
        <?php if ($is_staff): ?>
        <div class="pv-mn-editor" style="display:none;margin-top:8px;">
            <textarea class="pv-mn-textarea" rows="6" style="width:100%;box-sizing:border-box;" placeholder="Materials & supplies, gate/door code, parking, pets, quiet hours, etc."><?php echo esc_textarea($masterNotesRaw); ?></textarea>
            <p style="margin:8px 0 0;"><button type="button" class="paintops-btn paintops-btn-primary pv-mn-save">Save Notes</button> <button type="button" class="paintops-btn paintops-btn-secondary pv-mn-cancel">Cancel</button> <span class="pv-mn-status" style="margin-left:8px;font-size:13px;"></span></p>
        </div>
        <?php endif; ?>
    </div>
    <?php if ($is_staff):
        // Master Notes inline editor wiring. Dynamic per card (box id, job
        // id, fresh nonce), so it goes through the scripts API as a captured
        // fragment rather than a script file; it prints with the footer
        // scripts, when the box markup above already exists.
        ob_start(); ?>
    (function(){
        var box = document.getElementById('pv-mn-<?php echo (int) $row->id; ?>');
        if (!box) { return; }
        var editBtn = box.querySelector('.pv-mn-edit'), editor = box.querySelector('.pv-mn-editor'),
            display = box.querySelector('.pv-mn-display'), ta = box.querySelector('.pv-mn-textarea'),
            saveBtn = box.querySelector('.pv-mn-save'), cancelBtn = box.querySelector('.pv-mn-cancel'),
            statusEl = box.querySelector('.pv-mn-status');
        var ajaxUrl = <?php echo wp_json_encode(admin_url('admin-ajax.php')); ?>;
        var nonce = <?php echo wp_json_encode(wp_create_nonce('paintops_job_master_notes')); ?>;
        var jobId = <?php echo (int) $row->id; ?>;
        var emptyPrompt = <?php echo wp_json_encode($mnEmptyPrompt); ?>;
        function esc(s){ return String(s).replace(/[&<>"']/g, function(c){ return {'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]; }); }
        function show(edit){ editor.style.display = edit ? '' : 'none'; display.style.display = edit ? 'none' : ''; if (editBtn) editBtn.style.display = edit ? 'none' : ''; }
        if (editBtn) { editBtn.addEventListener('click', function(){ show(true); ta.focus(); }); }
        cancelBtn.addEventListener('click', function(){ show(false); statusEl.textContent = ''; });
        saveBtn.addEventListener('click', function(){
            saveBtn.disabled = true; statusEl.textContent = 'Saving…';
            var b = new URLSearchParams();
            b.append('action', 'paintops_save_job_master_notes'); b.append('nonce', nonce);
            b.append('job_id', jobId); b.append('mode', 'replace'); b.append('notes', ta.value);
            fetch(ajaxUrl, {method:'POST', body:b, credentials:'same-origin'}).then(function(r){ return r.json(); }).then(function(res){
                saveBtn.disabled = false;
                if (!res || !res.success) { statusEl.textContent = (res && res.data && res.data.message) || 'Save failed.'; return; }
                var notes = (res.data && res.data.notes) || '';
                if (notes.trim() !== '') { display.innerHTML = esc(notes).replace(/\n/g, '<br>'); display.style.color = ''; }
                else { display.innerHTML = esc(emptyPrompt); display.style.color = 'var(--po-text-muted,#888)'; }
                statusEl.textContent = 'Saved.'; show(false);
            }).catch(function(){ saveBtn.disabled = false; statusEl.textContent = 'Could not reach the server.'; });
        });
    })();
    <?php paintops_add_inline_script((string) ob_get_clean()); ?>
    <?php endif; ?>
    <?php endif; ?>

    <?php if ($paintColorsHtml !== ''): ?>
        <div style="margin-top:18px;"><?php echo wp_kses($paintColorsHtml, paintops_kses_allowed_html(), paintops_kses_protocols()); ?></div>
    <?php endif; ?>

    <?php
    // Communications log (Mike 2026-07-21, moved down 2026-07-22):
    // staff-only record of outbound customer emails for this job (quote
    // sent, follow-up sent, invoice sent) so "did we already follow up,
    // and when?" has an answer on the job page. Lives with the other
    // rarely-opened drawers above the Map, restyled to the same
    // cs-fin-acc look with the count as its summary meta. Renders
    // nothing when empty (house convention). Deliberately NOT shown to
    // workers, it is customer-contact history, not field information.
    if (function_exists('paintops_user_can_manage') && paintops_user_can_manage()
        && function_exists('paintops_get_job_communications')) {
        $pv_comms = paintops_get_job_communications((int) $row->id);
        if ($pv_comms) {
            echo wp_kses('<details class="cs-fin-acc" style="margin-top:18px;"><summary><span class="cs-fin-acc-title">Communications</span><span class="cs-fin-acc-meta">' . count($pv_comms) . ' sent</span></summary><div class="cs-fin-acc-body">', paintops_kses_allowed_html(), paintops_kses_protocols());
            echo '<table style="width:100%;border-collapse:collapse;font-size:13px;">';
            foreach ($pv_comms as $pv_cm) {
                echo '<tr style="border-bottom:1px solid var(--po-card-border,#eee);">'
                    . '<td style="padding:6px 8px 6px 0;white-space:nowrap;vertical-align:top;">' . esc_html(date('M j, Y g:i a', strtotime((string) $pv_cm->sent_at))) . '</td>'
                    . '<td style="padding:6px 0 6px 8px;vertical-align:top;"><strong>' . esc_html(function_exists('paintops_comm_type_label') ? paintops_comm_type_label((string) $pv_cm->comm_type) : $pv_cm->comm_type) . '</strong> to ' . esc_html($pv_cm->recipient)
                    . '<br><span style="color:var(--po-text-muted,#777);">' . esc_html($pv_cm->subject) . '</span></td>'
                    . '</tr>';
            }
            echo '</table></div></details>';
        }
    }
    ?>
    <?php // Map as a collapsed drawer (Mike 2026-07-22): usually already
          // seen, Directions covers navigation, and it saves a lot of
          // vertical space. NOTE (audit correction): browsers DO fetch an
          // iframe inside a closed <details>, so the perf win comes from
          // the loading="lazy" attribute on the iframe (off-viewport skip),
          // not from the drawer being closed. ?>
    <details class="cs-fin-acc" style="margin-top:18px;">
        <summary><span class="cs-fin-acc-title">Map</span></summary>
        <div class="cs-fin-acc-body"><?php echo wp_kses($mapEmbedHtml, paintops_kses_allowed_html(), paintops_kses_protocols()); ?></div>
    </details>

    <?php if ($jobPhotosHtml !== ''): ?>
        <div style="margin-top:18px;"><?php echo wp_kses($jobPhotosHtml, paintops_kses_allowed_html(), paintops_kses_protocols()); ?></div>
    <?php endif; ?>

    <?php if ($followupsHtml !== ''): ?>
        <div style="margin-top:18px;"><?php echo wp_kses($followupsHtml, paintops_kses_allowed_html(), paintops_kses_protocols()); ?></div>
    <?php endif; ?>

    <?php // More Details as a real accordion (Mike 2026-07-22), the same
          // cs-fin-acc drawer as the Financials page, replacing the old
          // toggle-link + hidden box. Keeps the pv-more-details class for
          // its table spacing rule. ?>
    <details class="cs-fin-acc pv-more-details" id="pv-more-<?php echo (int) $row->id; ?>" style="margin-top:18px;">
        <summary><span class="cs-fin-acc-title">More Details</span></summary>
        <div class="cs-fin-acc-body">
        <div class="cs-financial-box">
            <?php if ($moveJobDescToDetails && trim((string) $row->jobDesc) !== ''): ?>
            <p class="job-desc" style="margin-top:0;">
                <strong>Job Description:</strong><br><br>
                <?php echo wp_kses(nl2br(esc_html(stripslashes((string) $row->jobDesc))), paintops_kses_allowed_html(), paintops_kses_protocols()); ?>
            </p>
            <?php endif; ?>
            <table class="cs-fin-table">
                <tr><td>Job Category</td><td><?php echo esc_html($categoryName); ?></td></tr>
                <tr><td>Job Class</td><td><?php echo esc_html(function_exists('paintops_get_job_class_name') ? paintops_get_job_class_name($row->jobClass) : ''); ?></td></tr>
                <tr><td># Days</td><td><?php echo esc_html((string) $row->reqDays); ?></td></tr>
                <tr><td>Customer Phone</td><td><?php echo wp_kses($customerPhFm, paintops_kses_allowed_html(), paintops_kses_protocols()); ?></td></tr>
                <tr><td>Customer Email</td><td><?php echo esc_html($row->custEmail); ?></td></tr>
                <?php if ($is_staff): ?>
                    <tr><td>Quote ID</td><td><?php echo wp_kses($estLink, paintops_kses_allowed_html(), paintops_kses_protocols()); ?></td></tr>
                    <tr><td>Invoice ID</td><td><?php echo wp_kses($invoiceLink, paintops_kses_allowed_html(), paintops_kses_protocols()); ?></td></tr>
                    <tr><td>Added By</td><td><?php echo esc_html($addedByName); ?></td></tr>
                    <tr><td>Job Added On</td><td><?php echo esc_html((string) $row->created_on); ?></td></tr>
                    <tr><td>Last Updated On</td><td><?php echo esc_html((string) $row->updated_on); ?></td></tr>
                <?php endif; ?>
            </table>
        </div>
        </div>
    </details>

    <?php
    return ob_get_clean();
}

/**
 * Painter/Helper entry point: one job, read-only, no edit links, no
 * profit data, just what they need to show up and do the work.
 */
function paintops_render_worker_job_view(int $job_id): string {
    global $wpdb;

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

    if (!$row) {
        return '<p>Job not found.</p>';
    }

    if (!paintops_user_can_view_job($job_id, $row)) {
        return '<div class="paintops-login-required"><p>You do not have access to this job.</p></div>';
    }

    return paintops_render_job_view_card($row, ['is_staff' => false]);
}

/**
 * AJAX: save a job's Master Notes. Two modes:
 *   - 'replace' : overwrite with the submitted text (the inline editor on
 *     the job view).
 *   - 'append'  : append the submitted text to whatever is already there,
 *     separated by a blank line (the "Add to job notes" button on the AI
 *     materials list, over on the quote builder).
 * Editing is staff-only (crew can read master notes but not change them),
 * matching paintops_user_can_manage(). Returns the full saved notes so the
 * caller can refresh its display.
 */
function paintops_ajax_save_job_master_notes(): void {
    if (!check_ajax_referer('paintops_job_master_notes', 'nonce', false)) {
        wp_send_json_error(['message' => 'That request could not be verified, please refresh the page and try again.'], 403);
    }
    if (!function_exists('paintops_user_can_manage') || !paintops_user_can_manage()) {
        wp_send_json_error(['message' => 'Only staff can edit master notes.'], 403);
    }
    $job_id = (int) ($_POST['job_id'] ?? 0); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.ValidatedSanitizedInput.MissingUnslash -- value is cast to a number, sanitization not applicable
    if ($job_id <= 0) {
        wp_send_json_error(['message' => 'Missing job.'], 400);
    }

    global $wpdb;
    $jobs = paintops_table('jobs');

    // Confirm the job exists before writing (a stale tab could post a
    // deleted job's id).
    $exists = (int) $wpdb->get_var($wpdb->prepare("SELECT id FROM %i WHERE id = %d LIMIT 1", $jobs, $job_id));
    if ($exists <= 0) {
        wp_send_json_error(['message' => 'That job could not be found.'], 404);
    }

    $mode     = (($_POST['mode'] ?? 'replace') === 'append') ? 'append' : 'replace'; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.ValidatedSanitizedInput.MissingUnslash -- the ternary whitelists the value to append or replace, raw input is never used
    $incoming = sanitize_textarea_field(wp_unslash($_POST['notes'] ?? ''));

    if ($mode === 'append') {
        $incoming = trim($incoming);
        if ($incoming === '') {
            wp_send_json_error(['message' => 'There is nothing to add.']);
        }
        $current = (string) $wpdb->get_var($wpdb->prepare("SELECT master_notes FROM %i WHERE id = %d", $jobs, $job_id));
        $notes   = (trim($current) === '') ? $incoming : rtrim($current) . "\n\n" . $incoming;
    } else {
        $notes = $incoming;
    }

    $result = $wpdb->update($jobs, ['master_notes' => $notes], ['id' => $job_id], ['%s'], ['%d']);
    if ($result === false) {
        wp_send_json_error(['message' => 'Could not save the notes. Please try again.']);
    }

    wp_send_json_success(['notes' => $notes]);
}
add_action('wp_ajax_paintops_save_job_master_notes', 'paintops_ajax_save_job_master_notes');

/**
 * Worker home: "My Jobs" (2026-07-19, pre-launch audit P0 #5).
 *
 * A worker (non-staff) who opens the app with no route params used to hit
 * "You do not have access to this job.", a dead-end error, as their very
 * first screen, and their ONLY path to a job was the deep link in the
 * night-before email. This renders their actual landing page instead:
 * every not-completed record they're assigned to (jobs AND quote visits,
 * membership via the reqWorkers '#id#' convention), grouped Today /
 * Upcoming / Needs attention (past-dated or unscheduled, still open),
 * each row linking to the painter-safe ?viewEid= card they already know.
 * Mike's original scope call: today + upcoming + recent unfinished, not
 * completed history.
 *
 * 2026-07-25 (Mike: "show past jobs the painter was assigned to... may be
 * notes, receipts needed to be uploaded or verbal callbacks"): added a
 * collapsed "Past Jobs" drawer, closed by default so it doesn't bury the
 * still-open work above, listing every COMPLETED job/visit this worker
 * was on. A completed job's ?viewEid= card and its Add Receipt/Notes
 * actions were always reachable, paintops_user_can_view_job() only checks
 * assignment, never completed status, a painter just had no way to find
 * an old job again once it dropped out of Today/Upcoming; this is that
 * way back in. Follow-up visits (Callback/Punch List/Add-On) get a small
 * badge in both this drawer and the sections above, so a painter can spot
 * which of their old jobs was a callback at a glance. Called from the
 * worker gate in paintops_fetch_csjobs() (manage-jobs.php); the caller
 * handles styling includes and footer.
 */
function paintops_render_worker_home(int $user_id): string {
    global $wpdb;

    $needle = '%' . $wpdb->esc_like('#' . $user_id . '#') . '%';
    $rows = $wpdb->get_results($wpdb->prepare(
        "SELECT * FROM %i
         WHERE completed != 1 AND reqWorkers LIKE %s
         ORDER BY startYear DESC, startMonth DESC, startDay DESC
         LIMIT 100", paintops_table('jobs'), $needle));

    // Past Jobs drawer source: same assignment membership check, completed
    // jobs only, newest-scheduled first (there's no completed-at timestamp
    // on the table, startDate is the closest proxy to "most recently
    // worked").
    $past_rows = $wpdb->get_results($wpdb->prepare(
        "SELECT * FROM %i
         WHERE completed = 1 AND reqWorkers LIKE %s
         ORDER BY startYear DESC, startMonth DESC, startDay DESC
         LIMIT 100", paintops_table('jobs'), $needle));

    $todayKey = (int) current_time('Ymd');
    $today = [];
    $upcoming = [];
    $needsAttention = [];
    foreach ((array) $rows as $row) {
        $y = (int) $row->startYear; $m = (int) $row->startMonth; $d = (int) $row->startDay;
        $key = ($y > 0 && $m > 0 && $d > 0) ? ($y * 10000 + $m * 100 + $d) : 0;
        if ($key === $todayKey) {
            $today[] = $row;
        } elseif ($key > $todayKey) {
            $upcoming[] = $row;
        } else {
            // Past-dated but not completed, or never scheduled.
            $needsAttention[] = $row;
        }
    }
    // Upcoming reads soonest-first; the fetch sorted newest-first.
    $upcoming = array_reverse($upcoming);

    // Same follow-up-visit labels paintops_render_worker_add_receipt_box()
    // uses to suggest logging an expense on the parent job; here they're
    // just a glance-able badge, not a link.
    $visit_type_badges = ['callback' => 'Callback', 'punch_list' => 'Punch List', 'addon' => 'Add-On'];

    $card = function ($row) use ($visit_type_badges): string {
        $label = function_exists('paintops_job_record_label')
            ? paintops_job_record_label((int) $row->jobType, (int) ($row->isBid ?? 0), (string) ($row->quote_label ?? 'quote'))
            : (((int) $row->jobType === 1) ? 'Quote' : 'Job');
        $dateText = ((int) $row->startYear > 0 && (int) $row->startMonth > 0 && (int) $row->startDay > 0)
            ? date('D, M jS', strtotime(sprintf('%04d-%02d-%02d', (int) $row->startYear, (int) $row->startMonth, (int) $row->startDay)))
            : 'Not scheduled yet';
        $place = trim((string) $row->custServiceAddress . ', ' . (string) $row->custServiceCity);
        $visit_type = (string) ($row->visit_type ?? 'original');
        $badge = isset($visit_type_badges[$visit_type])
            ? ' <span style="display:inline-block;font-size:11px;font-weight:600;color:#8a5a00;background:#fff8ec;border:1px solid #e0c36a;border-radius:4px;padding:1px 6px;margin-left:6px;vertical-align:middle;">' . esc_html($visit_type_badges[$visit_type]) . '</span>'
            : '';
        return '<a href="' . esc_url(paintops_admin_url(['viewEid' => (int) $row->id])) . '"'
            . ' style="display:block;padding:12px 14px;margin:0 0 10px;border:1px solid var(--po-card-border,#ddd);border-radius:var(--po-radius,8px);background:var(--po-card-bg,#fff);text-decoration:none;color:var(--po-text,#333);">'
            . '<strong>' . esc_html(stripslashes($label . ' #' . (int) $row->id . ' ' . (string) $row->jobTitle)) . '</strong>' . $badge
            . '<span style="display:block;font-size:13px;color:var(--po-text-muted,#667581);margin-top:3px;">'
            . esc_html($dateText) . ($place !== ',' && $place !== '' ? ' &middot; ' . esc_html(trim($place, ', ')) : '')
            . '</span></a>';
    };

    $section = function (string $title, array $list) use ($card): string {
        if (!$list) {
            return '';
        }
        $out = '<h3 style="margin:18px 0 8px;">' . esc_html($title) . '</h3>';
        foreach ($list as $row) {
            $out .= $card($row);
        }
        return $out;
    };

    $body = $section('Today', $today)
        . $section('Upcoming', $upcoming)
        . $section('Needs attention', $needsAttention);

    if ($past_rows) {
        $past_body = '';
        foreach ($past_rows as $row) {
            $past_body .= $card($row);
        }
        $body .= '<details style="margin-top:18px;">'
            . '<summary style="cursor:pointer;font-weight:600;padding:4px 0;">Past Jobs (' . (int) count($past_rows) . ')</summary>'
            . '<div style="margin-top:10px;">'
            . '<p style="color:var(--po-text-muted,#667581);font-size:13px;margin-top:0;">Old jobs you were on, in case there\'s a note to leave, a receipt still to upload, or a callback to follow up on.</p>'
            . $past_body . '</div>'
            . '</details>';
    }

    if ($body === '') {
        $body = '<p style="color:var(--po-text-muted,#667581);">No jobs are assigned to you yet. '
            . 'When the office assigns you to a job, it will show up here (and you\'ll get an email the day before with the details).</p>';
    }

    return '<div class="job-form"><h2 class="po-screen-title" style="margin-top:0;">My Jobs</h2>' . $body . '</div>';
}

/**
 * Worker "My Hours" (2026-07-19): a worker's own logged hours and pay,
 * grouped by week, newest first. Data is the same cs_job_worker_hours
 * rows the Time Clock checkout and Job Financials write; ONLY the
 * current user's own rows are ever queried (the route passes
 * get_current_user_id(), never a request param), so there's no way to
 * see a teammate's hours or rate. Earnings = hours x the hourly_rate
 * snapshotted on each row (so a raise doesn't rewrite past weeks).
 * Reached from the worker nav ("My Hours") and the My Jobs home.
 */
function paintops_render_worker_hours(int $user_id): string {
    global $wpdb;
    if ($user_id <= 0) {
        return '';
    }

    // Last ~12 weeks of rows, joined to the job for a friendly label.
    $rows = $wpdb->get_results($wpdb->prepare(
        "SELECT h.work_date, h.hours, h.hourly_rate, h.job_id, j.jobTitle
         FROM %i h
         LEFT JOIN %i j ON j.id = h.job_id
         WHERE h.user_id = %d AND h.work_date >= DATE_SUB(CURDATE(), INTERVAL 84 DAY)
         ORDER BY h.work_date DESC, h.id DESC", paintops_table('job_worker_hours'), paintops_table('jobs'), $user_id));

    $currency = function_exists('paintops_currency_symbol') ? paintops_currency_symbol() : '$';

    $out = '<div class="job-form"><h2 class="po-screen-title" style="margin-top:0;">My Hours</h2>';

    if (!$rows) {
        $out .= '<p style="color:var(--po-text-muted,#667581);">No hours logged in the last 12 weeks. Hours show up here when you check in and out with the Time Clock on a job (or when the office logs them for you).</p></div>';
        return $out;
    }

    // Group by week starting Monday. Keys sort naturally (Y-m-d of that
    // week's Monday).
    $weeks = [];
    foreach ($rows as $r) {
        $ts = strtotime((string) $r->work_date);
        $monday = date('Y-m-d', strtotime('monday this week', $ts));
        if (!isset($weeks[$monday])) {
            $weeks[$monday] = ['hours' => 0.0, 'earnings' => 0.0, 'rows' => []];
        }
        $weeks[$monday]['hours'] += (float) $r->hours;
        $weeks[$monday]['earnings'] += (float) $r->hours * (float) $r->hourly_rate;
        $weeks[$monday]['rows'][] = $r;
    }
    krsort($weeks);

    $this_monday = gmdate('Y-m-d', strtotime('monday this week', current_time('timestamp')));
    $first = true;
    foreach ($weeks as $monday => $wk) {
        $sunday = date('M jS', strtotime($monday . ' +6 days'));
        $label = ($monday === $this_monday)
            ? 'This week'
            : (($monday === gmdate('Y-m-d', strtotime('monday last week', current_time('timestamp')))) ? 'Last week' : date('M jS', strtotime($monday)) . ' - ' . $sunday);
        $summaryLine = esc_html($label) . ' &middot; <strong>' . esc_html(number_format($wk['hours'], 1)) . ' hrs</strong>'
            . ($wk['earnings'] > 0 ? ' &middot; ' . esc_html($currency . number_format($wk['earnings'], 2)) : '');

        // Current week open, older weeks collapsed.
        $out .= '<details' . ($first ? ' open' : '') . ' style="border:1px solid var(--po-card-border,#ddd);border-radius:var(--po-radius,8px);background:var(--po-card-bg,#fff);margin:0 0 10px;padding:10px 14px;">';
        $out .= '<summary style="cursor:pointer;font-size:15px;">' . $summaryLine . '</summary>';
        $out .= '<div style="margin-top:8px;">';
        foreach ($wk['rows'] as $r) {
            $jobLabel = trim((string) ($r->jobTitle ?? '')) !== '' ? (string) $r->jobTitle : ('Job #' . (int) $r->job_id);
            $out .= '<div style="display:flex;justify-content:space-between;gap:10px;padding:7px 0;border-top:1px solid var(--po-line,#eee);font-size:14px;">'
                . '<span>' . esc_html(date('D, M j', strtotime((string) $r->work_date))) . ' &middot; <a href="' . esc_url(paintops_admin_url(['viewEid' => (int) $r->job_id])) . '" style="text-decoration:none;">' . esc_html($jobLabel) . '</a></span>'
                . '<span style="white-space:nowrap;">' . esc_html(number_format((float) $r->hours, 1)) . ' hrs</span>'
                . '</div>';
        }
        $out .= '</div></details>';
        $first = false;
    }

    $out .= '<p style="font-size:12px;color:var(--po-text-soft,#8b96a3);margin:12px 0 0;">Showing the last 12 weeks. Rates are what was in effect when each day was logged; questions about hours or pay go to the office.</p>';
    $out .= '</div>';
    return $out;
}
