<?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.
if (!defined('ABSPATH')) exit;

// Wrapped in a named function (2026-07-27, wp.org hosted Plugin Check run):
// this file only ever runs via require from inside paintops_fetch_csjobs()
// in manage-jobs.php, so every variable below is function-local at runtime.
// PHPCS reads the file in isolation, saw code at file scope, and warned that
// every variable here was an unprefixed global. The wrapper makes the real
// scope explicit. function_exists() guard because the file is require'd (not
// require_once) and some SEO plugins run do_shortcode() early, which can pull
// it in twice in one request.
if (!function_exists('paintops_render_estimates_screen')) {
function paintops_render_estimates_screen() {
global $wpdb;


// This file is only ever require()'d from inside paintops_fetch_csjobs() in
// manage-jobs.php. Staff (paintops_user_can_manage()) can reach every
// branch below. A Painter/Helper with the "Can create quotes" permission
// (see paintops_user_can_create_quote()) can also reach it, but ONLY for
// the single "create a new quote for a job I'm assigned to" action,
// the quote list, viewing/editing an existing estimate by ID (?viewID=,
// ?editEid=), emailing, PDFs, and deleting all stay staff-only. Checked
// again here as defense in depth even though paintops_fetch_csjobs() in
// manage-jobs.php already confirmed the *job* itself is one this worker
// can see before routing here, that earlier check says nothing about
// which specific action inside this file is safe to allow.
$getAction = sanitize_text_field(wp_unslash($_GET['csaction'] ?? ''));
$postAction = sanitize_text_field(wp_unslash($_POST['csaction'] ?? ''));
$getJobID = intval($_GET['jobID'] ?? 0);

$paintops_estimates_is_staff = paintops_user_can_manage();

if (!$paintops_estimates_is_staff) {
    $worker_wants_create_form = $getAction === 'create' && !isset($_GET['editEid']);
    $worker_wants_save = $postAction === 'addEstimate' && !isset($_POST['editestId']);
    // On a save POST, authorize against the job id that will actually be
    // written ($_POST['jobID']), not the URL's (see the P0 #4 note at the
    // save handler below); on a plain create-form load there is no POST
    // and the URL id is the one the form will be built for.
    $worker_gate_job_id = $worker_wants_save ? intval($_POST['jobID'] ?? 0) : $getJobID;
    $worker_can_create_quote = function_exists('paintops_user_can_create_quote') && paintops_user_can_create_quote($worker_gate_job_id);

    if (!$worker_can_create_quote || !($worker_wants_create_form || $worker_wants_save)) {
        echo '<div class="paintops-login-required"><p>You do not have access to this page.</p></div>';
        return;
    }
}

// AI Photo Estimate runs entirely over AJAX (see
// includes/ai-photo-estimate.php), nothing to handle here on page load.

// SECURITY (2026-07-19, pre-launch audit P0 #4): authorize the job id the
// save actually WRITES ($_POST['jobID']), never the one in the URL. The
// old check used $getJobID from $_GET, so a worker with create-quote
// permission on job A could load ?csaction=create&jobID=A and submit the
// hidden jobID field tampered to job B, creating an estimate on (and
// repointing estimates of) a job they were never assigned. This is the
// regression SECURITY-AUDIT.md's earlier entry described; the fix now
// checks the POST id, the id used for every write below.
$paintops_post_job_id = intval($_POST['jobID'] ?? 0);
if ($postAction=='addEstimate'
	&& wp_verify_nonce(sanitize_text_field(wp_unslash($_POST['paintops_estimate_nonce'] ?? '')), 'paintops_save_estimate')
	&& ($paintops_estimates_is_staff || (function_exists('paintops_user_can_create_quote') && paintops_user_can_create_quote($paintops_post_job_id)))){
	$current_user_id = get_current_user_id();
	$jobID   	   = $paintops_post_job_id;

	$quoteTitle    = sanitize_text_field(wp_unslash($_POST['quoteTitle'] ?? ''));
	$supplyDesc1   = sanitize_text_field(wp_unslash($_POST['supplyDesc1'] ?? ''));
	$supplyDesc2   = sanitize_text_field(wp_unslash($_POST['supplyDesc2'] ?? ''));
	$supplyDesc3   = sanitize_text_field(wp_unslash($_POST['supplyDesc3'] ?? ''));
	$supplyDesc4   = sanitize_text_field(wp_unslash($_POST['supplyDesc4'] ?? ''));
	$supplyDesc5   = sanitize_text_field(wp_unslash($_POST['supplyDesc5'] ?? ''));
	// Whether this is a bid for a GC is a job-level fact (set once, via
	// the job's own "Current Status" picker, see the recordStatus
	// handling in manage-jobs.php), not something to ask about again on
	// every quote document for that job. Every estimate just inherits the
	// job's current isBid instead of asking the user to check a
	// redundant box here (which used to risk drifting out of sync with
	// the job's own status).
	$paintopsSaveJobRow = $wpdb->get_row($wpdb->prepare(
		"SELECT isBid, quote_label FROM %i WHERE id = %d LIMIT 1", paintops_table('jobs'), $jobID));
	$isBid = (int) ($paintopsSaveJobRow->isBid ?? 0);
	// Quote/Estimate/Bid, for the save notices below.
	$paintopsSaveDocWord = function_exists('paintops_quote_doc_word')
		? paintops_quote_doc_word($isBid, (string) ($paintopsSaveJobRow->quote_label ?? 'quote'))
		: 'Quote';
	$showItemized  = intval($_POST['showItemized'] ?? 0);
	$supplyCost1   = intval($_POST['supplyCost1'] ?? 0);
	$supplyCost2   = intval($_POST['supplyCost2'] ?? 0);
	$supplyCost3   = intval($_POST['supplyCost3'] ?? 0);
	$supplyCost4   = intval($_POST['supplyCost4'] ?? 0);
	$supplyCost5   = intval($_POST['supplyCost5'] ?? 0);
	// Overflow line items (slots 6-20, see the "+ Add Line" button and
	// the calculators' slot-claiming JS) come in as two parallel arrays
	// in DOM order rather than individually-named fields, since there
	// can be anywhere from 0 to 15 of them, zipped together here and
	// stored as one JSON blob (supplyExtra) rather than 30 more discrete
	// columns. Blank pairs (nothing typed, row never revealed) are
	// dropped so the stored JSON only ever reflects real entries.
	// Each element is sanitized right here at intake: descriptions with
	// sanitize_text_field, costs cast to int.
	$supplyExtraDescRaw = array_map('sanitize_text_field', array_map('wp_unslash', (array) ($_POST['supplyExtraDesc'] ?? []))); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.ValidatedSanitizedInput.MissingUnslash -- sanitized element-by-element by the array_map wrappers on this line
	$supplyExtraCostRaw = array_map('intval', (array) ($_POST['supplyExtraCost'] ?? [])); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.ValidatedSanitizedInput.MissingUnslash -- sanitized element-by-element by the intval array_map on this line
	$supplyExtraItems   = [];
	foreach ($supplyExtraDescRaw as $i => $rawDesc) {
		$extraDesc = (string) $rawDesc;
		$extraCost = intval($supplyExtraCostRaw[$i] ?? 0);
		if ($extraDesc === '' && $extraCost === 0) {
			continue;
		}
		$supplyExtraItems[] = ['desc' => $extraDesc, 'cost' => $extraCost];
	}
	$supplyExtra   = wp_json_encode($supplyExtraItems);
	// estDetails is RICH TEXT from the contenteditable editor (bold,
	// lists, line structure live in HTML tags, not newlines), so it is
	// sanitized with wp_kses_post: formatting tags survive, scripts and
	// event handlers are stripped. sanitize_textarea_field here (added in
	// the 2026-07 security sweep) deleted every tag and flattened saved
	// descriptions into one unformatted blob.
	$estDetails    = wp_kses_post(wp_unslash($_POST['estDetails'] ?? ''));
	$estTotal	   = intval($_POST['estTotal'] ?? 0);
	// Sales Tax / VAT amount, computed client-side by the itemize card's
	// own calculateTotal() JS (see further down this file and
	// paintops_calc_tax_amount() in paintops.php) and submitted alongside
	// estTotal (which already has tax baked into it). Stored as its own
	// column purely so the PDF/email/customer view can show it as its
	// own line item rather than a silent, invisible part of the total,
	// same reasoning as storing supplyCost1-5 individually instead of
	// just one lump estTotal. Defaults to 0 for quotes saved before this
	// feature existed or with tax_percent at 0 (no tax configured).
	$estTax        = intval($_POST['estTax'] ?? 0);
	// Exclusions for this specific quote/estimate document, see
	// paintops_scope_terms()/paintops_scope_situational_clauses() and the
	// scanner+checklist above the quote editor.
	$scopeNotes    = sanitize_textarea_field(wp_unslash($_POST['scopeNotes'] ?? ''));
	// Collect job data in one place
	$est_data = [
		'jobID'            => $jobID,
		'createdBy'        => $current_user_id,
		'quoteTitle'	   => $quoteTitle,
		'isBid'	  		   => $isBid,
		'show_itemized'    => $showItemized,
		'supplyDesc1'      => $supplyDesc1,
		'supplyDesc2'      => $supplyDesc2,
		'supplyDesc3'      => $supplyDesc3,
		'supplyDesc4'      => $supplyDesc4,
		'supplyDesc5'      => $supplyDesc5,
		'supplyCost1'      => $supplyCost1,
		'supplyCost2'      => $supplyCost2,
		'supplyCost3'      => $supplyCost3,
		'supplyCost4'      => $supplyCost4,
		'supplyCost5'      => $supplyCost5,
		'supplyExtra'      => $supplyExtra,
		'estDetails'       => $estDetails,
		'estTotal'         => $estTotal,
		'estTax'           => $estTax,
		'scope_notes'      => $scopeNotes,
	];

	$formats = [
		'jobID'            => '%d',
		'createdBy'  	   => '%d',
		'quoteTitle'	   => '%s',
		'isBid'	   		   => '%d',
		'show_itemized'    => '%d',
		'supplyDesc1'      => '%s',
		'supplyDesc2'      => '%s',
		'supplyDesc3'      => '%s',
		'supplyDesc4'      => '%s',
		'supplyDesc5'      => '%s',
		'supplyCost1'      => '%d',
		'supplyCost2'      => '%d',
		'supplyCost3'      => '%d',
		'supplyCost4'      => '%d',
		'supplyCost5'      => '%d',
		'supplyExtra'      => '%s',
		'estDetails'       => '%s',
		'estTotal'         => '%d',
		'estTax'           => '%d',
		'scope_notes'      => '%s',
	];
	// Built by walking $est_data's own key order and looking up each
	// field's format by name, $wpdb->insert() matches a format ARRAY to
	// a data array purely by position, not by key, so this only stays
	// correct if both arrays list their keys in the exact same order.
	// Deriving the format list from $est_data's actual order (rather than
	// $formats' own order) makes this correct regardless (see the same
	// fix applied to the job-save arrays in manage-jobs.php, where the
	// two lists having drifted out of order had silently corrupted the
	// payment_plan field).
	$est_formats = [];
	foreach (array_keys($est_data) as $est_data_key) {
		$est_formats[] = $formats[$est_data_key] ?? '%s';
	}
	$result = $wpdb->insert(
		paintops_table('estimates'),
		$est_data,
		$est_formats
	);
	if ($result === false) {
		paintops_report_db_error('save estimate');

	} else {
		if (isset($editJobId) && $editJobId > 0){
			echo wp_kses("<div class=\"paintops-notice is-success\">{$paintopsSaveDocWord} was updated.</div>", paintops_kses_allowed_html(), paintops_kses_protocols());

		}
		else{
			$newEstId = intval($wpdb->insert_id);
			// The old one-line banner sat alone on an otherwise empty page and
			// its convert-to-PDF mention was not even a link (2026-08-31
			// walkthrough). Give the three real next steps as actions instead;
			// printing and PDF live on the document view behind Open.
			echo '<div class="paintops-notice is-success">' . esc_html($paintopsSaveDocWord) . ' #' . (int) $newEstId . ' was added.</div>';
			echo '<p style="margin:14px 0 0;">'
				. '<a class="paintops-btn paintops-btn-primary" href="' . esc_url(paintops_admin_url(['estimates' => 1, 'viewID' => $newEstId])) . '">Open ' . esc_html($paintopsSaveDocWord) . '</a>'
				. ' <a class="paintops-btn paintops-btn-secondary" style="margin-left:8px;" href="' . esc_url(paintops_admin_url(['estimates' => 1, 'csaction' => 'send', 'getEstId' => (int) $newEstId])) . '">Email It to the Customer</a>'
				. ' <a style="margin-left:12px;" href="' . esc_url(paintops_admin_url(['viewEid' => (int) $jobID])) . '">Back to the job</a>'
				. '</p>';
			// Update job with estimate ID

			if (!empty($newEstId)) {
				$result = $wpdb->update(
					paintops_table('jobs'),
					[ 'estimateID' => $newEstId ],  // only update this field
					[ 'id' => $jobID ],           // WHERE clause
					[ '%d' ],                         // format for estimateID
					[ '%d' ]                          // format for id
				);

				if ($result === false) {
					paintops_report_db_error();
				} elseif ($result === 0) {
					// Job row missing (shouldn't happen with a validated
					// jobID); surface it as a real notice, not debug text.
					echo '<div class="paintops-notice is-error">The estimate was saved but could not be linked to the job. Please open the job and try again.</div>';
				}
				// Success is already covered by the green notice above;
				// the old "<br>Linked job #X with estimate #Y" debug echo
				// is gone (2026-07-19, audit P0 #6).
			}
			
		}
	}
}
elseif ($getAction=='create'){
	// Generate results to edit
	if (isset($_GET['editEid'])){
		$getEditId = intval ($_GET['editEid']);
		$formAction = "<input type=\"hidden\" name=\"editestId\" value=\"$getEditId\">" . wp_nonce_field('paintops_save_estimate', 'paintops_estimate_nonce', true, false);
		$submitName = "Update Job";
		$results = $wpdb->get_results($wpdb->prepare("SELECT * FROM %i WHERE estID = %d LIMIT 1", paintops_table('estimates'), $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.
				}
			}
		}
	}
	else{
		$formAction = "<input type=\"hidden\" name=\"csaction\" value=\"addEstimate\">" . wp_nonce_field('paintops_save_estimate', 'paintops_estimate_nonce', true, false);
		$submitName = "Add Quote or Bid";
	}

// This form's small static helpers (toggleFade, toggleSection,
// fillTextareaFromSelect, formatText/syncEditor,
// paintopsRevealNextSupplyRow), formerly several short inline script
// blocks scattered through this branch, merged into one asset in
// original page order (wp.org enqueue conversion 2026-07).
paintops_enqueue_script('estimates-quote-form');

// Get Job Info
// isBid/quote_label are read here (the job's own current values) rather
// than trusted from an existing estimate row, if this is ?editEid=,
// the loop just above already set $isBid/$quote_label from that old
// estimate, but the job's own "Current Status" picker is the actual
// source of truth (see manage-jobs.php), so this intentionally
// overwrites them with whatever the job says right now.
$results = $wpdb->get_results($wpdb->prepare("SELECT jobTitle, jobCat, estimateID, custFirst, custLast, custServiceAddress, custServiceCity, custServiceState, custServiceZip, jobDesc, reqWorkers, reqDays, isBid, quote_label, scopeInterior, scopeExterior FROM %i WHERE id = %d LIMIT 1", paintops_table('jobs'), $getJobID));

if ($wpdb->last_error) {
	paintops_report_db_error();
	exit;
}
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.
		}
	}
}
else{
	echo "Job ID Not Found.";
	get_footer();
	exit;
}



$jobDesc = nl2br($jobDesc);

// What this document actually is, Quote, Estimate, or Bid, same
// derivation used on the customer-facing pages (see
// paintops_render_quote_body_fragment() in customer-payments.php), so
// the heading below says what the record really is instead of the
// generic "Quote/Estimate" it used to.
$recordWord = ((int) ($isBid ?? 0) === 1)
	? 'Bid'
	: (((string) ($quote_label ?? 'quote') === 'estimate') ? 'Estimate' : 'Quote');

// Exclusions: this quote/estimate's own field (see the
// scope_notes column on cs_estimates), editing an existing estimate
// re-populates $scope_notes above via the `${$field} = $value` loop over
// the fetched estimates row; a brand-new estimate starts with the
// Situational Clauses marked "Default on" in Business Settings
// pre-filled in, same idea as the job form used to do before this moved
// here.
if (isset($_GET['editEid'])) {
	$scope_notes = $scope_notes ?? '';
} else {
	$scope_notes = '';
	if (function_exists('paintops_scope_situational_clauses')) {
		foreach (paintops_scope_situational_clauses() as $clause) {
			if (!empty($clause['default_on']) && !empty($clause['disclaimer'])) {
				$scope_notes .= ($scope_notes !== '' ? "\n\n" : '') . $clause['disclaimer'];
			}
		}
	}
}
$extraTools = '';

// Category-specific resources: fully data-driven (see
// paintops_job_categories() in paintops.php and
// includes/trade-calculators.php). Adding a calculator, tip content, or a
// quote note for a different trade never requires editing this file, it's
// all just data on the job category, editable under PaintOps > Business.
$category = paintops_job_categories()[$jobCat] ?? paintops_normalize_job_category([]);
$supplyHints = $category['supply_hints'];
$placeHolder1 = $supplyHints[0] ?? '';
$placeHolder2 = $supplyHints[1] ?? '';
$placeHolder3 = $supplyHints[2] ?? '';
$placeHolder4 = $supplyHints[3] ?? '';
$placeHolder5 = $supplyHints[4] ?? '';
$placeHolderT = $category['default_quote_title'] !== '' ? $category['default_quote_title'] : $jobTitle;
$catResourceTitle = $category['resource_title'];
$catResources = $category['resource_links'];
// This job's Interior/Exterior checkboxes (see manage-jobs.php), same
// $scopeInterior/$scopeExterior columns the scope-components filter
// below (search $job_scope_interior) uses, just computed here too so
// the calculator can be told which section(s) are actually relevant to
// this job (an exterior-only job doesn't need Baseboards, etc.). Fail
// open (both true) if neither checkbox is set, same rule as scope
// components, so older jobs saved before this existed still see
// everything rather than a calculator that looks broken/empty.
$job_scope_interior = !empty($scopeInterior);
$job_scope_exterior = !empty($scopeExterior);
// A category can have more than one calculator assigned (see the
// 'calculators' array + Job Categories admin checkbox list),
// paintops_render_category_calculators() renders each as its own tool
// card + modal, all sharing this same $context.
$extraTools = function_exists('paintops_render_category_calculators')
	? paintops_render_category_calculators((array) ($category['calculators'] ?? []), [
		'interior' => $job_scope_interior,
		'exterior' => $job_scope_exterior,
	])
	: '';

// The category "Tip: <resource title>" expander used to render here on
// the quote form; removed entirely (Mike 2026-07-22). The Resource tip
// title / Reference links fields remain in Business > Job Categories but
// no longer surface on this page.

// Get coordinates for Google Earth
//
// Guarded with function_exists() because this file is require()'d (not
// require_once) from inside paintops_fetch_csjobs() in manage-jobs.php. If
// paintops_fetch_csjobs() is ever invoked more than once in the same request (some
// SEO/social-preview plugins run do_shortcode() early to build a meta
// description, then the theme renders the real content again), this file
// gets pulled in a second time and an unguarded function declaration here
// would fatal with "Cannot redeclare paintops_get_coordinates()", exactly the
// kind of WordPress "critical error" this function_exists() check
// prevents.
if (!function_exists('paintops_get_coordinates')) {
function paintops_get_coordinates($address) {
    $api_key = function_exists('paintops_maps_api_key') ? paintops_maps_api_key() : '';
    if ($api_key === '') {
        return null;
    }

    $encodedAddress = urlencode($address);
    $url = "https://maps.googleapis.com/maps/api/geocode/json?address={$encodedAddress}&key=" . $api_key;

    $response = wp_remote_get($url);
    if (is_wp_error($response)) {
        return null;
    }
    $data = json_decode(wp_remote_retrieve_body($response), true);

    if (!empty($data['status']) && $data['status'] === 'OK') {
        $location = $data['results'][0]['geometry']['location'];
        return [
            'lat' => $location['lat'],
            'lng' => $location['lng']
        ];
    } else {
        return null; // Handle errors accordingly
    }
}
}
// Was hardcoded "+FL$custServiceZip", silently geocoded every job as if
// it were in Florida, regardless of the job's actual state (now that
// custServiceState is a real field, use it instead of assuming).
$cfAddress = "$custServiceAddress+$custServiceCity+" . ($custServiceState !== '' ? "$custServiceState " : '') . "$custServiceZip";
$coords = paintops_get_coordinates($cfAddress);
$lat = $coords['lat'] ?? null;
$lng = $coords['lng'] ?? null;
$earthUrl = ($lat !== null && $lng !== null)
    ? "https://earth.google.com/web/@{$lat},{$lng},100a,0d,35y,0h,0t,0r"
    : '';

//
$categoryName = paintops_get_category_details($jobCat, 1);
// esc_html() on every user-controllable piece, this is echoed
// directly into the "Create a New Quote For..." heading below.
$fullWorkTitle = esc_html($categoryName) . ' ' . esc_html($jobTitle) . ' (' . esc_html($custFirst) . ' ' . esc_html($custLast) . ')';
$totalLaborSuggested = '';
$reqWorkersDesc = '';
$reqWorkersCount = 0;
// Nothing shown by default, the pre-filled labor cost in the
// itemize card below (see $totalLaborSuggested) speaks for itself once
// workers/days are actually on the job; the note below only appears
// once there's something to explain.
$reqWorkersFormTip = '';
$userIds = paintops_parse_worker_ids($reqWorkers);
$pricing = paintops_pricing_rules();
$laborRatePerDay = $pricing['labor_rate_per_day'];
$quoteMarkupPercent = $pricing['markup_percent'];
// 'Labor'/'Labour', see paintops_spelling() in paintops.php. Only
// ever used on visible text below (tips, field labels, PDF rows),
// $laborRatePerDay and every other internal field/setting name stays
// spelled "labor" regardless of Country.
$laborWord = paintops_spelling('Labor', 'Labour');
$laborWordLower = strtolower($laborWord);

if ($reqDays > 0 && !empty($userIds)) {

    // Count workers directly
    $reqWorkersCount = count($userIds);

    if ($reqWorkersCount > 0) {
        $totalLaborSuggested = $reqDays * $reqWorkersCount * $laborRatePerDay;

        // Description
        $reqWorkersDesc = "{$reqWorkersCount} Workers On-Site";

        // Show that this is auto filled based on these inputs. New jobs
        // always start at 1 day (see manage-jobs.php, duration isn't
        // known until you've actually scoped the work), so say so and
        // point at where to adjust it if the real job will run longer.
        if ($reqDays <= 1) {
            $reqWorkersFormTip = "$laborWord costs are pre-filled for $reqWorkersCount worker(s) @ \${$laborRatePerDay}/day, 1 day. If this job will take longer, adjust the " . strtolower($laborWord) . " line below, once it's an actual accepted Job, set the real day count from Edit Job.";
        } else {
            $reqWorkersFormTip = "$laborWord costs are pre-filled based on inputs from the job description. Workers: $reqWorkersCount @ \${$laborRatePerDay} for $reqDays day(s).";
        }
    }
}

// Itemized cost fields: editing an existing estimate re-populates
// $supplyDesc1-5/$supplyCost1-5/$estTotal above via the `${$field} =
// $value` loop over the fetched estimates row (see the top of this
// file), previously the input fields below just ignored all of that
// and hardcoded blank values instead, so re-opening an already-itemized
// quote looked like the line items had vanished, and the Quote Total
// reset to blank every time. Fall back to a blank/suggested starting
// point only for a genuinely new quote.
if (isset($_GET['editEid'])) {
    $supplyDesc1 = ($supplyDesc1 ?? '') !== '' ? $supplyDesc1 : $reqWorkersDesc;
    $supplyDesc2 = $supplyDesc2 ?? '';
    $supplyDesc3 = $supplyDesc3 ?? '';
    $supplyDesc4 = $supplyDesc4 ?? '';
    $supplyDesc5 = $supplyDesc5 ?? '';
    $supplyCost1 = ($supplyCost1 ?? '') !== '' ? $supplyCost1 : $totalLaborSuggested;
    $supplyCost2 = $supplyCost2 ?? '';
    $supplyCost3 = $supplyCost3 ?? '';
    $supplyCost4 = $supplyCost4 ?? '';
    $supplyCost5 = $supplyCost5 ?? '';
    $estTotal    = $estTotal ?? '';
    $estTax      = $estTax ?? 0;
} else {
    $supplyDesc1 = $reqWorkersDesc;
    $supplyDesc2 = $supplyDesc3 = $supplyDesc4 = $supplyDesc5 = '';
    $supplyCost1 = $totalLaborSuggested;
    $supplyCost2 = $supplyCost3 = $supplyCost4 = $supplyCost5 = '';
    $estTotal    = '';
    $estTax      = 0;
    $supplyExtra = '';
}

// "Replace Quote" / a new quote for a job that already has one: carry the
// most recent existing quote forward as the starting point, since a
// replacement is almost always a modification of the last one, so the
// estimator tweaks instead of retyping everything. Only on the create
// path with no ?editEid= (editing loads the exact estimate being edited),
// and only when a prior estimate actually exists (a job's very first quote
// still starts from the job defaults set above). This still creates a NEW
// estimate on save (csaction stays addEstimate), the old quote is left
// intact. A calculator round-trip (?supplyDesc1=, handled just below) is
// the fresher in-progress state and still wins over this.
$paintops_carried_forward_quote = false;
if ($getAction === 'create' && !isset($_GET['editEid'])) {
    $paintops_prior_est = $wpdb->get_row($wpdb->prepare(
        "SELECT * FROM %i WHERE jobID = %d ORDER BY estID DESC LIMIT 1", paintops_table('estimates'), (int) $getJobID));
    if ($paintops_prior_est) {
        $paintops_carried_forward_quote = true;
        // Description of Work: the editor (#quoteEditor) seeds from
        // $jobDesc; use the prior quote's scope. estDetails is stored as
        // HTML, so it must NOT be run through nl2br the way the plain job
        // description was above.
        if (trim((string) $paintops_prior_est->estDetails) !== '') {
            $jobDesc = (string) $paintops_prior_est->estDetails;
        }
        // Exclusions start from the prior quote's own field, THEN any
        // default-on Situational Clause it doesn't already contain gets
        // appended (Mike 2026-07-23: "pre-selected Situational clauses
        // are not added under exclusions", the original verbatim
        // carry-forward silently dropped every default-on clause the
        // prior quote didn't happen to contain, e.g. quotes written or
        // imported before a clause was added to the library or flagged
        // Default on, and the chips below rendered unticked, so each one
        // had to be clicked back on by hand). A clause deliberately
        // removed on the prior quote does re-appear, but it re-appears
        // as a TICKED chip that is one click to remove, which beats a
        // liability clause silently vanishing from every replacement
        // quote. The stripslashes probe mirrors the chip pre-tick check
        // further down so chip state and textarea can never disagree.
        $scope_notes = (string) ($paintops_prior_est->scope_notes ?? '');
        if (function_exists('paintops_scope_situational_clauses')) {
            foreach (paintops_scope_situational_clauses() as $paintops_cf_clause) {
                $paintops_cf_disc = (string) ($paintops_cf_clause['disclaimer'] ?? '');
                if (empty($paintops_cf_clause['default_on']) || $paintops_cf_disc === '') {
                    continue;
                }
                if (strpos($scope_notes, $paintops_cf_disc) === false
                    && strpos(stripslashes($scope_notes), $paintops_cf_disc) === false) {
                    $scope_notes .= ($scope_notes !== '' ? "\n\n" : '') . $paintops_cf_disc;
                }
            }
        }
        // Itemized supplies / costs / overflow items / total / tax.
        $supplyDesc1 = (string) $paintops_prior_est->supplyDesc1;
        $supplyDesc2 = (string) $paintops_prior_est->supplyDesc2;
        $supplyDesc3 = (string) $paintops_prior_est->supplyDesc3;
        $supplyDesc4 = (string) $paintops_prior_est->supplyDesc4;
        $supplyDesc5 = (string) $paintops_prior_est->supplyDesc5;
        $supplyCost1 = $paintops_prior_est->supplyCost1;
        $supplyCost2 = $paintops_prior_est->supplyCost2;
        $supplyCost3 = $paintops_prior_est->supplyCost3;
        $supplyCost4 = $paintops_prior_est->supplyCost4;
        $supplyCost5 = $paintops_prior_est->supplyCost5;
        $supplyExtra = (string) ($paintops_prior_est->supplyExtra ?? '');
        $estTotal    = $paintops_prior_est->estTotal;
        $estTax      = (int) ($paintops_prior_est->estTax ?? 0);
    }
}
// A calculator's own "Calculate" button is a real <form method="get">
// submit (see includes/trade-calculators.php), with more than one
// calculator assigned to this category, submitting a *different*
// calculator's form reloads this whole page, which would otherwise
// throw away any not-yet-saved itemize edits made via an earlier
// calculator's "Add These Numbers to Quote" button (that's plain DOM
// manipulation, never part of $_GET/$_POST, so a fresh page load has no
// way to know about it). Every calc form now snapshots the itemize
// card's live field values into itself right before it submits (see the
// shared JS in paintops_calc_add_to_quote_script()), so they ride along
// in its GET request, prefer those over whatever POST-save or
// DB-row-editing logic above already set, since they're the freshest
// state of the itemize card the browser actually had.
if (isset($_GET['supplyDesc1'])) {
    $supplyDesc1 = sanitize_text_field(wp_unslash((string) ($_GET['supplyDesc1'] ?? '')));
    $supplyDesc2 = sanitize_text_field(wp_unslash((string) ($_GET['supplyDesc2'] ?? '')));
    $supplyDesc3 = sanitize_text_field(wp_unslash((string) ($_GET['supplyDesc3'] ?? '')));
    $supplyDesc4 = sanitize_text_field(wp_unslash((string) ($_GET['supplyDesc4'] ?? '')));
    $supplyDesc5 = sanitize_text_field(wp_unslash((string) ($_GET['supplyDesc5'] ?? '')));
    $supplyCost1 = intval($_GET['supplyCost1'] ?? 0);
    $supplyCost2 = intval($_GET['supplyCost2'] ?? 0);
    $supplyCost3 = intval($_GET['supplyCost3'] ?? 0);
    $supplyCost4 = intval($_GET['supplyCost4'] ?? 0);
    $supplyCost5 = intval($_GET['supplyCost5'] ?? 0);

    // Each element is sanitized right here at intake, same treatment as
    // the POST path above.
    $getExtraDesc = array_map('sanitize_text_field', array_map('wp_unslash', (array) ($_GET['supplyExtraDesc'] ?? []))); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.ValidatedSanitizedInput.MissingUnslash -- sanitized element-by-element by the array_map wrappers on this line
    $getExtraCost = array_map('intval', (array) ($_GET['supplyExtraCost'] ?? [])); // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.ValidatedSanitizedInput.MissingUnslash -- sanitized element-by-element by the intval array_map on this line
    $getExtraItems = [];
    foreach ($getExtraDesc as $paintops_gi => $paintops_gd) {
        $paintops_gd = (string) $paintops_gd;
        $paintops_gc = intval($getExtraCost[$paintops_gi] ?? 0);
        if ($paintops_gd === '' && $paintops_gc === 0) {
            continue;
        }
        $getExtraItems[] = ['desc' => $paintops_gd, 'cost' => $paintops_gc];
    }
    $supplyExtra = wp_json_encode($getExtraItems);
}
// Overflow line items (slots 6-20), see paintops_decode_supply_extra_items()
// and the "+ Add Line" button below. $supplyExtra itself was already
// repopulated (as a raw JSON string) by the same `${$field} = $value`
// loop mentioned above when editing an existing quote, or overridden
// just above if a calculator's own submission just carried fresher
// values forward.
$supplyExtraItems = function_exists('paintops_decode_supply_extra_items')
    ? paintops_decode_supply_extra_items($supplyExtra ?? null)
    : [];
// Auto-expand the itemize card when editing a quote that already has
// itemized line items on it, otherwise the numbers are technically
// still there (see above) but hidden behind the collapsed "Click to
// Itemize Expenses" toggle, which looks exactly like an empty, never-
// itemized quote.
//
// Slot 1 always carries an auto-suggested labor cost by default (see
// $totalLaborSuggested above), even on a brand-new, never-itemized
// quote, counting it here unconditionally meant the itemize card
// auto-expanded on every single new quote regardless of whether the
// estimator had actually itemized anything yet. Slot 1 only counts
// toward "has data" when editing an existing quote (its saved value is
// genuinely meaningful) or right after a calculator's own round trip
// (isset($_GET['supplyDesc1']), the estimator just took a real
// action, e.g. clicked Add These Numbers to Quote then Calculate on a
// different section), a brand-new quote's still-untouched default
// doesn't count.
$slot1CountsAsItemizeData = isset($_GET['editEid']) || isset($_GET['supplyDesc1']) || !empty($paintops_carried_forward_quote);
$itemizeHasData = ($slot1CountsAsItemizeData && (float) $supplyCost1 > 0)
    || (float) $supplyCost2 > 0
    || (float) $supplyCost3 > 0
    || (float) $supplyCost4 > 0
    || (float) $supplyCost5 > 0
    || !empty($supplyExtraItems);

?>
<b style="color:#2f5aae;font-size:125%;padding-top:24px;">Create a New Quote For <a target="_blank" href="<?php echo paintops_href(['viewEid' => (int) $getJobID]); ?>">Job #<?php echo esc_html("$getJobID $fullWorkTitle"); ?></a></b><br>
<?php if (!empty($paintops_carried_forward_quote)): ?>
<div class="paintops-notice is-success" style="margin-top:8px;">Prefilled from this job's most recent quote so you can adjust it. Saving creates a new quote and leaves the previous one intact.</div>
<?php endif; ?>

<?php
// Anchor <form> for every calculator's fields/buttons (see
// includes/trade-calculators.php), deliberately declared here, well
// before the big <form method="POST"> below opens (that one wraps the
// actual quote-save fields and doesn't close until near the bottom of
// this page). The calculator markup itself still renders further down,
// visually right above the itemize card, INSIDE that POST form, but
// its own <input>/<select>/<button> elements use the HTML5 form="..."
// attribute to belong to THIS form instead of physically nesting a
// second <form> inside the POST one. Nested <form> elements are invalid
// HTML; browsers silently drop the inner form tag and hand its controls
// to whichever form is still open, so a nested calculator form's
// Calculate button would submit the whole quote-save POST form instead
// (looks like the calculator's modal "just closes", nothing about
// the calculator ever actually runs). form="" sidesteps that entirely:
// it works regardless of where the referenced form lives in the DOM.
?>
<form id="paintopsCalcForm" method="get" class="paintops-calc-form" style="display:none;"></form>

<?php
// This screen's static styles (tool cards, editor toolbar/box, itemize
// card), formerly three inline style blocks on this form, merged into
// one stylesheet in original page order (wp.org enqueue conversion
// 2026-07).
paintops_enqueue_style('estimates-quote-form');
?>
<div class="paintops-tool-cards">
    <?php if ($earthUrl !== '') : ?>
    <a class="paintops-tool-card" href="<?php echo esc_url($earthUrl); ?>" target="_blank" rel="noopener noreferrer">
        <span class="tool-card-title">Measure Perimeter &amp; Calculate True Exterior SF <?php echo wp_kses(paintops_create_link_arrow(14, '#2563eb', '6px', '6px'), paintops_kses_allowed_html(), paintops_kses_protocols()); ?></span>
        <span class="tool-card-sub">Google Earth - <?php echo esc_html($custServiceAddress); ?></span>
    </a>
    <?php else :
        // No coordinates = no Earth link. Say WHY instead of rendering a
        // dead card with a blank href (Mike 2026-07-22: the missing-key
        // warning had silently disappeared): geocoding needs the Maps API
        // key, so no key gets the settings pointer (same message family
        // as the address-autocomplete notice), and a key with a failed
        // lookup points at the address instead.
        $paintopsMapsKeyMissing = !function_exists('paintops_maps_api_key') || paintops_maps_api_key() === '';
    ?>
    <div class="paintops-tool-card" style="cursor:default;">
        <span class="tool-card-title" style="color:var(--po-text-muted,#777);">Measure Perimeter &amp; Calculate True Exterior SF</span>
        <span class="tool-card-sub" style="color:#b00;"><?php echo wp_kses($paintopsMapsKeyMissing
            ? 'Needs a Google Maps API key to open this address in Google Earth. <a href="' . esc_url(paintops_settings_open_url('admin.php?page=paintops-settings&po_open=setup-health&po_entry=google-maps-key')) . '">Add one in PaintOps &gt; Settings</a>.'
            : 'Could not locate this address on the map. Double-check the service address on this job.', paintops_kses_allowed_html(), paintops_kses_protocols()); ?></span>
    </div>
    <?php endif; ?>
    <?php echo wp_kses(function_exists('paintops_render_price_list_popup_link') ? paintops_render_price_list_popup_link() : '', paintops_kses_allowed_html(), paintops_kses_protocols()); ?>
    <?php // AI Photo Estimate's trigger + box now live down by the
    // Description of Work label instead of here, it's the exact field
    // the tool reads context from and writes its result into, so it
    // belongs right next to it rather than disconnected from it up here. ?>
</div>

<?php if (function_exists('paintops_pro_active') && paintops_pro_active()) :
    $openai_ready = function_exists('paintops_openai_is_configured') && paintops_openai_is_configured();

    // Effective job id for pulling existing Job Photos into the picker: the
    // estimate's own jobID when editing an existing quote (?editEid=), else
    // the job this new quote is being created for (?jobID=). $getJobID can be
    // stale in the edit case (see the note further down the form), so prefer
    // the estimate row's $jobID that was extracted just above when present.
    $paintops_ai_job_id = (int) $getJobID;
    if (isset($getEditId) && $getEditId && isset($jobID) && (int) $jobID > 0) {
        $paintops_ai_job_id = (int) $jobID;
    }
    $paintops_ai_photos = ($paintops_ai_job_id > 0 && function_exists('paintops_get_job_photos'))
        ? paintops_get_job_photos($paintops_ai_job_id, 'photo')
        : [];
?>
<div id="aiPhotoEstimate" style="display:none;border:1px solid var(--po-card-border,#ddd);border-radius:8px;padding:14px 16px;margin-bottom:18px;background:var(--po-tile-bg,#f9fafb);color:var(--po-text,#333);">
    <strong style="display:inline-flex;align-items:center;gap:6px;"><svg viewBox="0 0 24 24" width="18" height="18" fill="none" aria-hidden="true" focusable="false"><path d="M2.5 9A2.5 2.5 0 0 1 5 6.5h1.5l.85-1.35A1 1 0 0 1 8.2 4.7h3.6a1 1 0 0 1 .85.45L13.5 6.5H15A2.5 2.5 0 0 1 17.5 9v6A2.5 2.5 0 0 1 15 17.5H5A2.5 2.5 0 0 1 2.5 15V9Z" fill="currentColor" opacity="0.12" stroke="currentColor" stroke-width="1.4"/><circle cx="10" cy="11.8" r="3" stroke="currentColor" stroke-width="1.4"/><path d="M19 3.5l.62 1.63 1.63.62-1.63.62L19 8l-.62-1.63L16.75 5.75l1.63-.62L19 3.5Z" fill="currentColor"/></svg>AI Job Photo Estimator (beta)</strong>
    <p class="description" style="margin-top:4px;">Pick up to 3 photos of the room or area and OpenAI will estimate its dimensions, the paint and primer it needs by surface, and a supplies list, plus a draft Description of Work. Whatever's already typed above is sent along as context. Photos are only sent to OpenAI for this one analysis, PaintOps doesn't save the ones you upload here.</p>

    <?php if (!$openai_ready) : ?>
        <p style="color:#a94442;">OpenAI isn't configured yet. Add an API key under <a href="<?php echo esc_url(admin_url('admin.php?page=paintops-settings')); ?>" target="_blank" rel="noopener noreferrer">PaintOps Settings &gt; AI Photo Estimating</a>.</p>
    <?php else : ?>
        <?php if (!empty($paintops_ai_photos)) : ?>
        <div style="margin-top:12px;">
            <div style="font-size:13px;font-weight:600;margin-bottom:6px;">Photos already on this job <span style="font-weight:400;color:#777;">(tap to select)</span></div>
            <div id="aiExistingPhotos" style="display:flex;gap:8px;flex-wrap:wrap;max-height:220px;overflow:auto;padding:2px;">
                <?php foreach ($paintops_ai_photos as $ph) :
                    $thumb = isset($ph['url']) ? (string) $ph['url'] : '';
                    if ($thumb === '') { continue; }
                    $pid = (int) $ph['id'];
                ?>
                <button type="button" class="ai-existing-thumb" data-photo-id="<?php echo esc_attr($pid); ?>" onclick="paintopsAiToggleExisting(this)" style="position:relative;width:84px;height:84px;padding:0;border:2px solid #ddd;border-radius:8px;overflow:hidden;cursor:pointer;background:#fff;">
                    <img src="<?php echo esc_url($thumb); ?>" alt="Job photo" style="width:100%;height:100%;object-fit:cover;display:block;">
                    <span class="ai-thumb-check" style="display:none;position:absolute;top:3px;right:3px;width:20px;height:20px;border-radius:50%;background:#2563eb;color:#fff;font-size:13px;line-height:20px;text-align:center;">&#10003;</span>
                </button>
                <?php endforeach; ?>
            </div>
        </div>
        <?php endif; ?>

        <div style="margin-top:12px;display:flex;align-items:center;gap:8px;flex-wrap:wrap;">
            <input type="file" id="aiCamInput" accept="image/*" capture="environment" style="display:none;" onchange="paintopsAiOnPick(this)">
            <input type="file" id="aiLibInput" accept="image/*" multiple style="display:none;" onchange="paintopsAiOnPick(this)">
            <button type="button" class="paintops-btn paintops-btn-secondary" onclick="document.getElementById('aiCamInput').click();">Take Photo</button>
            <button type="button" class="paintops-btn paintops-btn-secondary" onclick="document.getElementById('aiLibInput').click();">Upload Photo</button>
            <span id="aiSelCount" style="color:#777;font-size:12px;">0 of 3 selected</span>
        </div>
        <div id="aiNewThumbs" style="display:flex;gap:8px;flex-wrap:wrap;margin-top:8px;"></div>

        <div style="margin-top:12px;">
            <button type="button" id="aiPhotoAnalyzeBtn" class="paintops-btn paintops-btn-primary" onclick="paintopsAnalyzePhoto()">Analyze Photos</button>
        </div>
        <p id="aiPhotoStatus" style="margin-top:8px;"></p>
        <div id="aiPhotoResults"></div>
        <?php
        // Static estimator logic lives in assets/js/estimates-ai-photo.js;
        // only the per-page config (ajax URL, nonce, job id) is dynamic.
        paintops_enqueue_script(
            'estimates-ai-photo',
            'var paintopsAjaxUrl = ' . wp_json_encode(admin_url('admin-ajax.php')) . ";\n"
            . 'var paintopsAiPhotoNonce = ' . wp_json_encode(wp_create_nonce('paintops_ai_photo_estimate')) . ";\n"
            . 'var paintopsAiJobId = ' . (int) $paintops_ai_job_id . ';'
        );
        ?>
    <?php endif; ?>
</div>
<?php endif; ?>

<br><br>
This <?php echo esc_html(strtolower($recordWord)); ?> is a firm, binding price once it's sent, so double-check the numbers before saving, each save creates a new revision rather than changing the original. <?php echo esc_html($laborWord); ?> and supplies stay as one total unless you check "itemized" below.<br><br>
<?php if ($reqWorkersFormTip !== '') : ?>
<p style="margin:8px 0 20px;color:#555;"><?php echo wp_kses($reqWorkersFormTip, paintops_kses_allowed_html(), paintops_kses_protocols()); ?></p>
<?php endif; ?>

<h2 class="po-screen-title" style="margin-top:0;"><?php echo wp_kses((isset($getEditId) && $getEditId) ? "Edit {$recordWord} #" . (int) $getEditId : "New {$recordWord}", paintops_kses_allowed_html(), paintops_kses_protocols()); ?></h2>

<?php
// Only for the "new quote for this job" case, editing an existing
// estimate/quote revision already shows its own job context further
// down the form, and $getJobID isn't even the right ID to look up once
// $getEditId is set (it's occasionally left over from the query string
// of the page that linked here).
if ((!isset($getEditId) || !$getEditId) && $getJobID > 0 && function_exists('paintops_render_job_customer_summary')) {
    $paintops_quote_job_row = $wpdb->get_row($wpdb->prepare(
        "SELECT * FROM %i WHERE id = %d LIMIT 1", paintops_table('jobs'), $getJobID));
    if ($paintops_quote_job_row) {
        echo wp_kses(paintops_render_job_customer_summary($paintops_quote_job_row), paintops_kses_allowed_html(), paintops_kses_protocols());
    }
}
?>

<div class="job-form">
<form method="POST" onsubmit="syncEditor()"><hr><br>
<?php // toggleSection() ships in estimates-quote-form.js, enqueued above. ?>
<?php
// Whether this is a GC bid used to be a checkbox right here, separate
// from (and sometimes out of sync with) the job's own "Current Status"
// picker on the job form. It's not asked here anymore, every quote for
// this job just inherits the job's status automatically (see $isBid
// above), since a bid is a property of the job/engagement, not something
// that varies per quote document.
?>
<div style="margin-bottom:18px;">
    <label for="quoteTitle">Title <?php echo wp_kses(paintops_tooltip('The main title shown on this document, whichever it is, a quote, an estimate, or a bid.'), paintops_kses_allowed_html(), paintops_kses_protocols()); ?></label>
    <input type="text" id="quoteTitle" name="quoteTitle" placeholder="" value="<?php echo esc_attr($placeHolderT); ?>">
</div>

<?php
// Scope wording helper: an optional, per-category checklist (see
// paintops_normalize_job_category() / Business Settings > Job Categories >
// Scope Components in paintops.php). Unlike the Exclusions checklist below
// (which adds a disclaimer about what's NOT included), ticking one of
// these auto-inserts specific, ready-to-read wording about what IS
// included straight into the Description of Work editor below, a fast way
// to build an accurate quote without retyping the same line every time.
// Presented as a COLLAPSED drawer above the editor (Mike 2026-07-22): the
// description often already covers the scope in the owner's own words, so
// the boilerplate stays one click away instead of always on screen.
// Filtered by this job's Interior/Exterior checkboxes (see manage-jobs.php)
// so the checklist only offers what's actually relevant; 'both' components
// always show. If neither checkbox is set on the job, fail open and show
// every component rather than hiding the whole drawer. A chip renders
// pre-ticked when its exact wording is already in the description.
// $job_scope_interior/$job_scope_exterior are computed once, up near
// paintops_render_category_calculator() above, and reused here.
$scope_components_list = $category['scope_components'] ?? [];
if ($job_scope_interior || $job_scope_exterior) {
    $scope_components_list = array_filter($scope_components_list, function ($comp) use ($job_scope_interior, $job_scope_exterior) {
        $applies_to = $comp['applies_to'] ?? 'both';
        if ($applies_to === 'both') {
            return true;
        }
        if ($applies_to === 'interior') {
            return $job_scope_interior;
        }
        if ($applies_to === 'exterior') {
            return $job_scope_exterior;
        }
        return true;
    });
}
$scope_helper_area = ($job_scope_interior || $job_scope_exterior)
    ? trim(($job_scope_interior ? 'Interior' : '') . ($job_scope_interior && $job_scope_exterior ? ' + ' : '') . ($job_scope_exterior ? 'Exterior' : ''))
    : '';
?>
<?php if (!empty($scope_components_list)) : ?>
<details class="paintops-scope-helper" style="margin:0 0 16px;border:1px solid var(--po-card-border,#ddd);border-radius:6px;background:var(--po-tile-bg,#fafbfc);">
    <summary style="cursor:pointer;padding:10px 14px;font-size:13px;color:var(--po-text,#444);"><strong>Scope wording helper</strong> <span style="color:var(--po-text-muted,#777);">- auto-insert standard lines into the Description of Work (<?php echo count($scope_components_list); ?> available<?php echo wp_kses($scope_helper_area !== '' ? ', ' . esc_html($scope_helper_area) : '', paintops_kses_allowed_html(), paintops_kses_protocols()); ?>)</span></summary>
    <div style="padding:4px 14px 14px;">
        <p class="description" style="margin:0 0 10px;">Standard wording for <?php echo esc_html($category['name'] !== '' ? $category['name'] : 'this category'); ?>. Tick to insert into the description below, untick to remove. Lines already in the description are pre-ticked.</p>
        <div class="paintops-situational-grid">
            <?php foreach ($scope_components_list as $comp_id => $comp) :
                $comp_text = $comp['text'] ?? '';
                // Compare against both the raw and the decoded/unslashed
                // editor content (2026-07-22 audit): components containing
                // & or an apostrophe were rendering unticked even when
                // their wording was already in the description.
                $comp_already_included = ($comp_text !== '' && (strpos($jobDesc, $comp_text) !== false || strpos(html_entity_decode(stripslashes($jobDesc), ENT_QUOTES), $comp_text) !== false));
            ?>
            <label class="paintops-situational-chip<?php echo esc_attr($comp_already_included ? ' checked' : ''); ?>">
                <input type="checkbox" class="scope-component-check" data-scope-text="<?php echo esc_attr($comp_text); ?>" <?php checked($comp_already_included); ?>>
                <span><?php echo esc_html($comp['label'] ?? ''); ?></span>
            </label>
            <?php endforeach; ?>
        </div>
    </div>
</details>
<?php endif; ?>

    <label for="estDetails"><?php echo esc_attr(($recordWord === 'Bid') ? 'Inclusions' : 'Description of Work'); ?></label>
    <?php if (function_exists('paintops_pro_active') && paintops_pro_active()) : ?>
    <a id="aiPhotoEstimateToggle" style="display:inline-flex;align-items:center;gap:6px;margin:2px 0 10px;font-size:13px;color:#2563eb;cursor:pointer;text-decoration:none;" onclick="toggleSection('aiPhotoEstimate')"><svg viewBox="0 0 24 24" width="16" height="16" fill="none" aria-hidden="true" focusable="false"><path d="M2.5 9A2.5 2.5 0 0 1 5 6.5h1.5l.85-1.35A1 1 0 0 1 8.2 4.7h3.6a1 1 0 0 1 .85.45L13.5 6.5H15A2.5 2.5 0 0 1 17.5 9v6A2.5 2.5 0 0 1 15 17.5H5A2.5 2.5 0 0 1 2.5 15V9Z" fill="currentColor" opacity="0.12" stroke="currentColor" stroke-width="1.4"/><circle cx="10" cy="11.8" r="3" stroke="currentColor" stroke-width="1.4"/><path d="M19 3.5l.62 1.63 1.63.62-1.63.62L19 8l-.62-1.63L16.75 5.75l1.63-.62L19 3.5Z" fill="currentColor"/></svg><span><span style="font-weight:600;text-decoration:underline;">AI Job Photo Estimator</span> <span style="color:#6b7280;font-weight:400;">dimensions, gallons &amp; supplies from a photo</span> &rarr;</span></a>
    <?php
    // Moves the AI Photo Estimate box (authored up in the tool-cards
    // section) to sit directly under this label; see the asset file for
    // the full rationale. Runs from the footer, whole DOM present.
    paintops_enqueue_script('estimates-ai-box-mover');
    ?>
    <?php endif; ?>
	<br><br>

	<?php // fillTextareaFromSelect(), formatText(), and syncEditor() ship
	// in estimates-quote-form.js, enqueued above. ?>

<?php // Editor toolbar/box styles merged into estimates-quote-form.css (enqueued above). ?>
	  <div class="editor-toolbar">
    <button type="button" onclick="formatText('bold')" title="Bold"><b>B</b></button>
    <button type="button" onclick="formatText('italic')" title="Italic"><i>I</i></button>
    <button type="button" onclick="formatText('underline')" title="Underline"><u>U</u></button>
    <span class="editor-toolbar-sep"></span>
    <button type="button" onclick="formatText('insertUnorderedList')" title="Bulleted list">&bull;&nbsp;List</button>
    <button type="button" onclick="formatText('insertOrderedList')" title="Numbered list">1.&nbsp;List</button>
    <span class="editor-toolbar-sep"></span>
    <button type="button" onclick="formatText('insertHorizontalRule')" title="Insert divider">&mdash;</button>
    <button type="button" onclick="formatText('removeFormat')" title="Clear formatting">Tx</button>
  </div>

  <div id="quoteEditor" class="editor-box" contenteditable="true">
    <?php
    // wp_kses_post, not raw (wp.org review 2026-07-20): estDetails is
    // saved raw, and a Painter/Helper with only the create_quotes
    // opt-in can write it. Echoing it unfiltered here executed any
    // planted script in the STAFF session that next opened this
    // editor. kses keeps all the formatting the editor needs.
    echo wp_kses_post(stripslashes($jobDesc));
    ?>
  </div>
<?php if (function_exists('paintops_render_ai_scope_tools')) { paintops_render_ai_scope_tools('quoteEditor', 'contenteditable', (int) $getJobID); } ?>
    <textarea style="display:none;" id="estDetails" name="estDetails"></textarea>
	<br><br>

<?php
// Scope Components moved ABOVE the Description of Work as a collapsed
// "Scope wording helper" drawer (Mike 2026-07-22, his pick from the
// presented options): it's a helper that auto-inserts standard lines,
// not a required section, so it sits above the editor as an input to
// the description instead of reading like a duplicate checklist after
// it. See the drawer right above the editor label further up.
?>
<?php
// Exclusions: lives on this quote/estimate, not the job, see
// paintops_scope_terms() and paintops_scope_situational_clauses() in
// paintops.php (both managed under PaintOps > Business > Scope &
// Exclusions Library, that admin tool's own name is unchanged, it's
// just the library of reusable disclaimers this field draws from). The
// scanner watches the quote editor above (the actual customer-facing
// wording) rather than the internal Job Description, since that's the
// document this disclaimer is protecting. Labeled "Exclusions" (not
// "Scope & Exclusions") here and everywhere it's displayed, the quote
// description above already is the scope; this field only ever holds
// what's NOT included.
$scope_terms_list = function_exists('paintops_scope_terms') ? paintops_scope_terms() : [];
$scope_situational_list = function_exists('paintops_scope_situational_clauses') ? paintops_scope_situational_clauses() : [];
?>
<label for="scopeNotes">Exclusions (Optional. Printed on this quote so the customer sees the boundary of the work up front.)</label>
<div id="scopeSuggestions" class="hidden-box" style="max-height:none;overflow:visible;"></div>
<textarea id="scopeNotes" name="scopeNotes" rows="5" placeholder="i.e. Caulking limited to wall-to-trim joints; does not include baseboard-to-floor gaps."><?php echo esc_textarea(stripslashes($scope_notes)); ?></textarea>

<?php if (!empty($scope_situational_list)) : ?>
<div style="margin-top:14px;">
    <p class="description" style="margin-bottom:8px;">Situational clauses (check any that apply to this quote):</p>
    <div class="paintops-situational-grid">
        <?php foreach ($scope_situational_list as $sit_id => $sit) :
            $sit_disclaimer = $sit['disclaimer'] ?? '';
            // Probe the unslashed form too (parity with the scope
            // component chips' 2026-07-22 fix): a DB-loaded scope_notes
            // holding escaped apostrophes must still pre-tick its chip.
            $already_included = ($sit_disclaimer !== '' && (strpos($scope_notes, $sit_disclaimer) !== false || strpos(stripslashes($scope_notes), $sit_disclaimer) !== false));
        ?>
        <label class="paintops-situational-chip<?php echo esc_attr($already_included ? ' checked' : ''); ?>">
            <input type="checkbox" class="scope-situational-check" data-disclaimer="<?php echo esc_attr($sit_disclaimer); ?>" <?php checked($already_included); ?>>
            <span><?php echo esc_html($sit['label'] ?? ''); ?></span>
        </label>
        <?php endforeach; ?>
    </div>
</div>
<?php
// Chip grid styles + the checked-class toggler, converted to assets
// (wp.org enqueue conversion 2026-07).
paintops_enqueue_style('estimates-situational-chips');
paintops_enqueue_script('estimates-situational-chips');
?>
<?php endif; ?>

<?php
// Exclusions scanner + chip wiring lives in
// assets/js/estimates-scope-tools.js; only the term/disclaimer list is
// dynamic and rides along as config.
paintops_enqueue_script(
    'estimates-scope-tools',
    'var paintopsScopeTerms = ' . wp_json_encode(array_values(array_map(function ($t) {
        return ['term' => $t['term'] ?? '', 'disclaimer' => $t['disclaimer'] ?? ''];
    }, $scope_terms_list))) . ';'
);
?>

<?php if ($extraTools !== '') : ?>
<!-- Calculators live here (right above the itemize card, not up with
     Google Earth/Price List near the top) specifically so that after
     clicking Calculate, which reloads the page and reopens this same
     calculator's modal, the itemize card each one feeds via "Add
     These Numbers to Quote" is right there, not a long scroll away. New
     users especially were losing track of where a calculator's numbers
     actually went. -->
<div class="paintops-tool-cards" style="margin-top:0;">
    <?php echo wp_kses($extraTools, paintops_kses_allowed_html(), paintops_kses_protocols()); ?>
</div>
<?php endif; ?>

<?php // Checkbox + bold question on ONE flex line, centered against each
      // other, with the muted description as its own indented line below
      // (was: checkbox floating at flex-start beside a two-line label,
      // which left it hanging above the text, Mike 2026-07-22). ?>
<div style="margin:24px 0 18px;">
    <label for="showItemized" style="display:flex;align-items:center;gap:10px;margin:0;cursor:pointer;">
        <input type="checkbox" name="showItemized" id="showItemized" value="1"<?php echo esc_attr(!empty($show_itemized) ? ' checked' : ''); ?>>
        <strong>Show an itemized price breakdown to the customer?</strong>
    </label>
    <span style="display:block;color:#777;font-size:13px;margin:4px 0 0 32px;">Most quotes just show one total - check this only if the customer specifically asked to see <?php echo esc_html(strtolower($laborWord)); ?>/material costs broken out.</span>
</div>

<?php // Itemize-card styles merged into estimates-quote-form.css (enqueued above). ?>
<div class="paintops-itemize-card">
    <div class="paintops-itemize-card-header" onclick="toggleSection('quote1')">
        <strong>Click to Itemize Expenses</strong> <span style="color:#777;">and/or Provide a Total Below</span>
    </div>

    <?php // Always collapsed on page load (Mike 2026-08-31); the header click or a calculator Add to Quote opens it. ?>
    <div id="quote1" style="display:none;margin-top:14px;">
		<div class="addr-row">
			<div>
			<label for="supplyDesc1">[1] Total <?php echo esc_html($laborWord); ?> Description</label>
			<input type="text" id="supplyDesc1" name="supplyDesc1" placeholder="<?php echo esc_attr($placeHolder1); ?>" value="<?php echo esc_attr($supplyDesc1); ?>">
			</div>
			<div>
			<label for="supplyCost1">[1] Total <?php echo esc_html($laborWord); ?> Cost</label>
			<input type="number" class="calc-field" inputmode="numeric" step="1" min="0" max="50000" id="supplyCost1" name="supplyCost1" value="<?php echo esc_attr($supplyCost1); ?>">
			</div>
		</div>
		<div class="addr-row">
			<div>
			<label for="supplyDesc2">[2] Supply Description</label>
			<input type="text" id="supplyDesc2" name="supplyDesc2" placeholder="<?php echo esc_attr($placeHolder2); ?>" value="<?php echo esc_attr($supplyDesc2); ?>">
			</div>
			<div>
			<label for="supplyCost2">[2] Supply Cost</label>
			<input type="number" class="calc-field" inputmode="numeric" step="1" min="0" max="50000" id="supplyCost2" name="supplyCost2" value="<?php echo esc_attr($supplyCost2); ?>">
			</div>
		</div>
		<div class="addr-row">
			<div>
			<label for="supplyDesc3">[3] Supply Description 3</label>
			<input type="text" id="supplyDesc3" name="supplyDesc3" placeholder="<?php echo esc_attr($placeHolder3); ?>" value="<?php echo esc_attr($supplyDesc3); ?>">
			</div>
			<div>
			<label for="supplyCost3">[3] Supply Cost</label>
			<input type="number" class="calc-field" inputmode="numeric" step="1" min="0" max="50000" id="supplyCost3" name="supplyCost3" value="<?php echo esc_attr($supplyCost3); ?>">
			</div>
		</div>
		<div class="addr-row">
			<div>
			<label for="supplyDesc4">[4] Supply Description</label>
			<input type="text" id="supplyDesc4" name="supplyDesc4" placeholder="<?php echo esc_attr($placeHolder4); ?>" value="<?php echo esc_attr($supplyDesc4); ?>">
			</div>
			<div>
			<label for="supplyCost4">[4] Supply Cost</label>
			<input type="number" class="calc-field" inputmode="numeric" step="1" min="0" max="50000" id="supplyCost4" name="supplyCost4" value="<?php echo esc_attr($supplyCost4); ?>">
			</div>
		</div>
		<div class="addr-row">
			<div>
			<label for="supplyDesc5">[5] Supply Description</label>
			<input type="text" id="supplyDesc5" name="supplyDesc5" placeholder="<?php echo esc_attr($placeHolder5); ?>" value="<?php echo esc_attr($supplyDesc5); ?>">
			</div>
			<div>
			<label for="supplyCost5">[5] Supply Cost</label>
			<input type="number" class="calc-field" inputmode="numeric" step="1" min="0" max="50000" id="supplyCost5" name="supplyCost5" value="<?php echo esc_attr($supplyCost5); ?>">
			</div>
		</div>

		<?php
		// Overflow line items, 15 more slots (6-20) beyond the original
		// fixed 5, added because a single interior job can now easily stack
		// paint + cabinet/trim + labor calculators into the same quote and
		// run out of room. These render server-side every time (not built
		// by cloning a JS template) so an existing quote that already has
		// overflow rows saved shows them immediately with no flash/JS
		// dependency, rows with no saved data just start hidden. Both the
		// "+ Add Line" button below and any calculator's "Add These Numbers
		// to Quote" button (see paintops_calc_add_to_quote_script() in
		// includes/trade-calculators.php) can reveal them on demand by
		// slot id (supplyRow6..supplyRow20).
		for ($paintops_extra_i = 1; $paintops_extra_i <= 15; $paintops_extra_i++) :
			$paintops_extra_slot = $paintops_extra_i + 5; // 6..20
			$paintops_extra_row  = $supplyExtraItems[$paintops_extra_i - 1] ?? ['desc' => '', 'cost' => ''];
			$paintops_extra_desc = $paintops_extra_row['desc'];
			$paintops_extra_cost = $paintops_extra_row['cost'];
			$paintops_extra_has_data = $paintops_extra_desc !== '' || ((float) $paintops_extra_cost) !== 0.0;
		?>
			<div class="addr-row paintops-extra-supply-row" id="supplyRow<?php echo (int) $paintops_extra_slot; ?>" style="<?php echo esc_attr($paintops_extra_has_data ? '' : 'display:none;'); ?>">
				<div>
				<label for="supplyDesc<?php echo (int) $paintops_extra_slot; ?>">[<?php echo (int) $paintops_extra_slot; ?>] Supply Description</label>
				<input type="text" id="supplyDesc<?php echo (int) $paintops_extra_slot; ?>" name="supplyExtraDesc[]" value="<?php echo esc_attr($paintops_extra_desc); ?>">
				</div>
				<div>
				<label for="supplyCost<?php echo (int) $paintops_extra_slot; ?>">[<?php echo (int) $paintops_extra_slot; ?>] Supply Cost</label>
				<input type="number" class="calc-field" inputmode="numeric" step="1" min="0" max="50000" id="supplyCost<?php echo (int) $paintops_extra_slot; ?>" name="supplyExtraCost[]" value="<?php echo esc_attr($paintops_extra_cost); ?>">
				</div>
			</div>
		<?php endfor; ?>

		<button type="button" class="button" onclick="paintopsRevealNextSupplyRow()" style="margin:4px 0 10px;">+ Add Line</button>

		<?php // paintopsRevealNextSupplyRow() ships in estimates-quote-form.js, enqueued above. ?>

		<?php
		// Sales Tax / VAT, see paintops_tax_percent()/paintops_tax_label()
		// in paintops.php (driven by PaintOps > Business > Pricing Rules).
		// Defaults to 0%, in which case $showTaxLine stays false and
		// nothing below changes at all from how this looked before tax
		// support existed, same "Total Suggested" line, no extra rows,
		// no behavior change for anyone who hasn't set a rate.
		$paintopsTaxPercent = function_exists('paintops_tax_percent') ? paintops_tax_percent() : 0;
		$paintopsTaxLabel   = function_exists('paintops_tax_label') ? paintops_tax_label() : 'Sales Tax';
		$showTaxLine        = $paintopsTaxPercent > 0;
		// Trimmed display like "8.25" or "20" instead of "8.250000",
		// number_format always pads to 2 decimals, so strip trailing
		// zeros (and a trailing dot if nothing's left after that).
		$paintopsTaxPercentDisplay = rtrim(rtrim(number_format($paintopsTaxPercent, 2), '0'), '.');
		?>
		<?php
		// calculateTotal() and its listeners live in
		// assets/js/estimates-itemize-calc.js; only the pricing-rule
		// numbers and the prefilled-total flag are dynamic config.
		paintops_enqueue_script(
			'estimates-itemize-calc',
			'var paintopsQuoteMarkupMultiplier = 1 + (' . (float) $quoteMarkupPercent . " / 100);\n"
			. 'var paintopsTaxPercent = ' . (float) $paintopsTaxPercent . ";\n"
			. 'var paintopsEstTotalPrefilled = ' . ((isset($estTotal) && (float) $estTotal > 0) ? 'true' : 'false') . ';'
		);
		?>
		<br><br><strong>Subtotal:</strong><br><?php echo esc_html(paintops_currency_symbol()); ?><span id="calc-subtotal">0.00</span>
		<br><strong>Total Suggested (with <?php echo esc_html((string) $quoteMarkupPercent); ?>% markup):</strong><br><?php echo esc_html(paintops_currency_symbol()); ?><span id="calc-total">0.00</span><br>
		<?php if ($showTaxLine) : ?>
		<br><strong><?php echo esc_html($paintopsTaxLabel); ?> (<?php echo esc_html($paintopsTaxPercentDisplay); ?>%):</strong><br><?php echo esc_html(paintops_currency_symbol()); ?><span id="calc-tax">0.00</span><br>
		<br><strong>Grand Total:</strong><br><?php echo esc_html(paintops_currency_symbol()); ?><span id="calc-grand-total">0.00</span><br>
		<?php endif; ?>
    </div>
</div>

<label for="estTotal">Quote Total</label>
<input type="number" step="1" min="0" max="500000" inputmode="numeric" id="estTotal" name="estTotal" value="<?php echo esc_attr($estTotal); ?>" required>
<input type="hidden" id="estTax" name="estTax" value="<?php echo esc_attr($estTax); ?>">

    <?php echo wp_kses($formAction, paintops_kses_allowed_html(), paintops_kses_protocols()); ?>
    <input type="hidden" name="jobID" value="<?php echo (int) $getJobID; ?>">
    <div class="paintops-sticky-save">
        <button type="submit" class="button button-primary"><?php echo esc_html($submitName); ?></button>
    </div>
  </form>
</div>
</div>
<?php
}
elseif ($getAction=='send'){
$getEstId = intval($_GET['getEstId'] ?? 0);
$results = $wpdb->get_results($wpdb->prepare(
	"SELECT e.estID as d_estID, e.jobID as d_jobID, j.isBid, j.jobCat, j.custEmail, j.custServiceAddress, j.id as d_id, j.custFirst, j.jobTitle, j.quote_label FROM %i j, %i e WHERE e.estID = %d AND e.jobID=j.id LIMIT 1", paintops_table('jobs'), paintops_table('estimates'), $getEstId));

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.
		}
	}
}
$current_user = wp_get_current_user();
$first_name   = $current_user->first_name;
$last_name   = $current_user->last_name;
$jobCatN1 = paintops_get_category_details($jobCat, 3);
// Quote / Estimate / Bid, for the heading, the pre-filled subject line,
// and the accept wording in the message body (Mike 2026-07-22: a
// contractor sending an Estimate shouldn't email "Quote For ...").
$sendDocWord = function_exists('paintops_quote_doc_word')
	? paintops_quote_doc_word((int) ($isBid ?? 0), (string) ($quote_label ?? 'quote'))
	: 'Quote';
$sendDocWordLower = strtolower($sendDocWord);
// The category email phrase is often just a noun phrase ("commercial
// painting"), which made the opener read "Here is your commercial
// painting for the work at ..." (Mike 2026-07-22). When the phrase does
// not already name the document, append the record word so it reads
// "your commercial painting estimate". A phrase the owner wrote WITH
// quote/estimate/bid in it is left exactly as configured.
if (stripos($jobCatN1, 'quote') === false && stripos($jobCatN1, 'estimate') === false && stripos($jobCatN1, 'bid') === false) {
	$jobCatN1 = trim($jobCatN1 . ' ' . $sendDocWordLower);
}

		  
if (!isset($_POST['send_email'])) {
	$send_email_nonce_field = wp_nonce_field('paintops_send_quote_email', 'paintops_send_quote_email_nonce', true, false);
	$company = paintops_company_profile();
	$companyWebsiteHost = preg_replace('#^https?://#', '', rtrim($company['website'], '/'));

	// Give the customer a link to view and accept the quote online
	// (and pay a deposit by card, for jobs set up that way) if we can
	// generate one; otherwise fall back to the original email-reply
	// acceptance, so this never blocks sending a quote.
	$quote_link = function_exists('paintops_customer_quote_url') ? paintops_customer_quote_url((int) $getEstId) : '';
	if ($quote_link !== '') {
		// No "or just reply to this email" alternative here (Mike
		// 2026-07-22): the link IS the acceptance flow (view + sign
		// online), and offering an email-reply shortcut alongside it
		// undercuts getting a signed record. The reply fallback below
		// stays for the no-link case, where it's the only path.
		$accept_instructions = "To move forward, view and accept your {$sendDocWordLower} here:\n\t$quote_link";
	} else {
		$accept_instructions = "To move forward, simply reply with \"I accept this {$sendDocWordLower}\" and we'll get you on the schedule.";
	}

	// Plain-text email body (concatenation, no heredoc, per wp.org review
	// guidance).
	// Signature from the parts that actually exist (2026-08-31): with no
	// user name and no company profile yet, the old fixed lines produced
	// a bare "Best regards," followed by blanks in every email sent
	// before setup was finished. Fall back to the display name and the
	// site title so the email always signs off as somebody.
	$paintops_sender_name = trim("$first_name $last_name");
	if ($paintops_sender_name === '') {
		$paintops_sender_name = (string) $current_user->display_name;
	}
	$paintops_company_display = trim((string) ($company['name'] ?? ''));
	if ($paintops_company_display === '') {
		$paintops_company_display = (string) get_bloginfo('name');
	}
	$paintops_signature_lines = [];
	foreach ([$paintops_sender_name, $paintops_company_display, trim((string) ($company['phone'] ?? '')), trim((string) ($company['email'] ?? '')), trim((string) $companyWebsiteHost)] as $paintops_sig_line) {
		if ($paintops_sig_line !== '') {
			$paintops_signature_lines[] = $paintops_sig_line;
		}
	}
	$message = "Hi $custFirst,\n\n"
		. "Here is your {$jobCatN1} for the work at $custServiceAddress.\n\n"
		. "If you have any questions, feel free to reach out - I'm happy to help.\n\n"
		. "{$accept_instructions}\n\n"
		. "Best regards,\n\n"
		. implode("\n", $paintops_signature_lines);
	$message = stripslashes($message);
	// Opt-in credit line at the very bottom of the email (plain text;
	// shown only when the admin enabled it, see paintops_show_powered_by()).
	if (function_exists('paintops_powered_by_footer_text')) {
		$paintops_pb = paintops_powered_by_footer_text();
		if ($paintops_pb !== '') {
			$message .= "\n\n" . $paintops_pb;
		}
	}
	// Pre-escaped versions for the two form-field values interpolated
	// directly into the raw HTML string below (value="..." attributes).
	$jobTitleAttr = esc_attr($jobTitle);
	$custEmailAttr = esc_attr($custEmail);
	$po_tpl_html = "<div class=\"paintops-email-quote-box\">
	<h2>Email This {$sendDocWord}</h2>"
	. (empty($custEmail) ? "<div class=\"notice notice-warning\"><p>No email address is on file for this customer - the To field below is blank. Enter one here to send just this once, or add it to the job/customer record so it's there next time.</p></div>" : "")
	. "
	<form method=\"post\" enctype=\"multipart/form-data\">
	{$send_email_nonce_field}
	<p>
		<label for=\"to\">To</label>
		<input type=\"email\" id=\"to\" name=\"to\" value=\"$custEmailAttr\" required>
	</p>
	<p>
		<label for=\"subject\">Subject</label>
		<input type=\"text\" id=\"subject\" name=\"subject\" value=\"{$sendDocWord} For $jobTitleAttr\" required>
	</p>
	<p>
		<label for=\"message\">Message</label>
		<textarea id=\"message\" name=\"message\" rows=\"15\" required>" . htmlspecialchars($message, ENT_NOQUOTES, 'UTF-8') . "</textarea>
	</p>
	<p>
		<label for=\"attachment\">Attach a file (optional)</label>" . (
			$quote_link !== ''
				? "<span style=\"color:#777;font-size:13px;display:block;margin-bottom:6px;\">Not required - the message above already includes a secure link to view and accept the current {$sendDocWordLower} online. Only attach a file if you want to send something extra (e.g. a signed contract or photos).</span>"
				: ''
		) . "
		<input type=\"file\" id=\"attachment\" name=\"attachment\">
	</p>"
	. paintops_document_checklist_field()
	. "
	<p>
		<button type=\"submit\" name=\"send_email\" class=\"button button-primary\">Send Email</button>
	</p>
	</form>
	</div>";
	echo wp_kses($po_tpl_html, paintops_kses_allowed_html(), paintops_kses_protocols());
}
	if (isset($_POST['send_email'])
		&& wp_verify_nonce(sanitize_text_field(wp_unslash($_POST['paintops_send_quote_email_nonce'] ?? '')), 'paintops_send_quote_email')
		&& paintops_user_can_manage()) {
		$to      = sanitize_email(wp_unslash($_POST['to'] ?? ''));
		$subject = sanitize_text_field(wp_unslash($_POST['subject'] ?? ''));
		$message = sanitize_textarea_field(wp_unslash($_POST['message'] ?? ''));
		$attachments = [];

		// The To field is blank whenever the customer has no email on
		// file (see the notice above the form), the <input required>
		// stops that in a normal browser, but a blank/malformed address
		// could still reach here (autofill quirks, the field cleared
		// after the page loaded, etc.), and wp_mail() with an empty
		// recipient fails in ways that aren't always obvious from the
		// "Failed to send" message alone. Catch it explicitly instead.
		if (!is_email($to)) {
			echo '<div class="notice notice-error"><p>No valid email address was entered, so nothing was sent. Enter one in the To field above and try again.</p></div>';
			return;
		}

		if (!empty($_FILES['attachment']['tmp_name'])) {
			// phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.ValidatedSanitizedInput.MissingUnslash -- the whole FILES entry is sanitized field-by-field by paintops_sanitize_upload() before wp_handle_upload() validates it
			$upload = wp_handle_upload(paintops_sanitize_upload($_FILES['attachment']), ['test_form' => false]);
			if (!isset($upload['error'])) {
				$attachments[] = $upload['file'];
			}
		}

		// Reusable Documents, standing paperwork (insurance cert,
		// contract terms, etc.) the sender checked off above.
		$selected_docs = $_POST['send_doc_ids'] ?? []; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.ValidatedSanitizedInput.MissingUnslash -- array is iterated below and each element is individually sanitized or cast where consumed
		if (!empty($selected_docs) && function_exists('paintops_documents_library')) {
			$doc_library = paintops_documents_library();
			foreach ($selected_docs as $doc_id) {
				$doc_id = (int) $doc_id;
				if (!empty($doc_library[$doc_id]['file']) && file_exists($doc_library[$doc_id]['file'])) {
					$attachments[] = $doc_library[$doc_id]['file'];
				}
			}
		}

		$email_sent = wp_mail($to, $subject, $message, paintops_mail_headers(), $attachments);

		if ($email_sent) {
			echo "<div class=\"notice notice-success\"><p>Email sent successfully to " . esc_html($to) . ".</p></div>";

			// Communications log (2026-07-21): record the send so the job
			// page can show "Quote emailed on <date>".
			if (function_exists('paintops_log_communication')) {
				paintops_log_communication([
					'job_id'      => $results ? (int) $results[0]->d_jobID : 0,
					'estimate_id' => intval($_GET['getEstId'] ?? 0),
					'comm_type'   => 'quote_email',
					'recipient'   => $to,
					'subject'     => $subject,
				]);
			}

			// === Append to IMAP "Sent" folder ===
			// Opt-in convenience: runs ONLY when all three MAILBOX_IMAP_*
			// constants are defined in wp-config.php AND the imap extension
			// exists. Previously only function_exists was checked, so on an
			// ordinary site every quote email attempted imap_open(null,
			// null, null): a guaranteed failed login, a PHP 8.1+ null
			// deprecation, and a scary "failed to connect" notice for a
			// feature the site never configured (wp.org review 2026-07-20).
			// Sites without the constants skip all of this silently.
			if (function_exists('imap_open')
				&& defined('MAILBOX_IMAP_HOST') && defined('MAILBOX_IMAP_USER') && defined('MAILBOX_IMAP_PASS')) {
				$imap_host = MAILBOX_IMAP_HOST;
				$imap_user = MAILBOX_IMAP_USER;
				$imap_pass = MAILBOX_IMAP_PASS;
				$sent_folder = '.Sent';                              // May also be 'Sent Items' or 'INBOX.Sent'

				$headers  = "From: PaintOps <" . get_option('admin_email') . ">\r\n";
				$headers .= "To: $to\r\n";
				$headers .= "Subject: $subject\r\n";
				$headers .= "Date: " . date("r") . "\r\n";
				$headers .= "Content-Type: text/plain; charset=UTF-8\r\n";
				$headers .= "\r\n";

				$full_email = $headers . $message;

				$imap_stream = @imap_open($imap_host, $imap_user, $imap_pass);

				if ($imap_stream) {
					$append_result = imap_append($imap_stream, $imap_host . $sent_folder, $full_email, "\\Seen");
					imap_close($imap_stream);

					if (!$append_result) {
						echo "<div class=\"notice notice-warning\"><p>Email sent, but could not save to Sent folder: " . esc_html((string) imap_last_error()) . "</p></div>";
					}
				} else {
					echo "<div class=\"notice notice-warning\"><p>Email sent, but failed to connect to IMAP: " . esc_html((string) imap_last_error()) . "</p></div>";
				}
			}

		} else {
			// A failed wp_mail used to dead-end the whole flow with one bare
			// line (2026-08-31 walkthrough). Cheap hosts often ship with no
			// working mail service, so hand the owner the customer link to
			// send themselves and say what to fix.
			$paintops_fail_est_id = absint(wp_unslash($_GET['getEstId'] ?? 0));
			$paintops_fail_link = ($paintops_fail_est_id > 0 && function_exists('paintops_customer_quote_url'))
				? paintops_customer_quote_url($paintops_fail_est_id)
				: '';
			echo "<div class=\"notice notice-error\"><p><strong>The email to " . esc_html($to) . " could not be sent.</strong> This usually means the site's hosting has no mail service configured; an SMTP plugin normally fixes it.</p>";
			if ($paintops_fail_link !== '') {
				echo "<p>The customer link still works. Copy it and text or email it yourself:</p>";
				echo "<p><input type=\"text\" readonly value=\"" . esc_attr($paintops_fail_link) . "\" style=\"width:100%;max-width:560px;\"></p>";
			}
			echo "<p>You can also use the Text " . esc_html($sendDocWord) . " option from the " . esc_html($sendDocWordLower) . " page.</p></div>";
		}
	}

}
elseif (isset($_GET['viewID'])){
$viewID = intval($_GET['viewID']);
$d_jobCat = 0;
$d_jobID = 0;
$results = $wpdb->get_results($wpdb->prepare("SELECT jobID, viewed_at, view_count, createdOn, accepted, accepted_date, accepted_name, accepted_at, accepted_ip, declined_date, declined_reason FROM %i WHERE estID = %d LIMIT 1", paintops_table('estimates'), $viewID));
$quoteExtraNotes = '';
$d_viewedAt = null;
$d_viewCount = 0;
$d_createdOn = null;
$d_accepted = 0;
$d_acceptedDate = null;
$d_acceptedName = '';
$d_acceptedAt = null;
$d_acceptedIp = '';
$d_declinedDate = null;
$d_declinedReason = '';
if ($results) {
	foreach ($results as $row) {
		$d_jobID = $row->jobID;
		$d_viewedAt = $row->viewed_at;
		$d_viewCount = (int) $row->view_count;
		$d_createdOn = $row->createdOn;
		$d_accepted = (int) $row->accepted;
		$d_acceptedDate = $row->accepted_date ?? null;
		$d_acceptedName = (string) ($row->accepted_name ?? '');
		$d_acceptedAt = $row->accepted_at ?? null;
		$d_acceptedIp = (string) ($row->accepted_ip ?? '');
		$d_declinedDate = $row->declined_date ?? null;
		$d_declinedReason = (string) ($row->declined_reason ?? '');
	}
	$d_custPhone = '';
	$d_custFirst = '';
	$d_custEmail = '';
	if (!empty($d_jobID)) {
		$d_job_row = $wpdb->get_row($wpdb->prepare("SELECT jobCat, jobType, isBid, custPhone, custFirst, custEmail, quote_label FROM %i WHERE id = %d LIMIT 1", paintops_table('jobs'), $d_jobID));
		if ($d_job_row) {
			$d_jobCat = (int) $d_job_row->jobCat;
			$d_custPhone = (string) $d_job_row->custPhone;
			$d_custFirst = (string) $d_job_row->custFirst;
			$d_custEmail = (string) $d_job_row->custEmail;
			$d_quoteLabel = (string) ($d_job_row->quote_label ?? 'quote');
			// The JOB's isBid, not the estimate row's snapshot: isBid is a
			// job-level fact, and a record switched Bid -> Estimate was
			// still showing "Email Bid"/"Text Bid" from the stale snapshot
			// on its existing documents (Mike 2026-07-22).
			$d_isBid = (int) ($d_job_row->isBid ?? 0);
			$d_jobType = (int) ($d_job_row->jobType ?? 1);
		}
	}
	// Category-specific "Quote note" used to be injected here, but that
	// field was removed in favor of Situational Clauses (PaintOps >
	// Business > Exclusions Library), same idea, a reusable
	// reminder that can default on, but editable per quote instead of
	// silently fixed. $quoteExtraNotes stays declared (see above) so the
	// "What's Next?" paragraph below still works with nothing prepended.
}

// Quote-view action menu pieces, built here (where the customer/quote
// vars are in scope) and echoed as one toolbar right above the white
// quote sheet further down, so the layout mirrors the Job View page.
// Initialized empty so the toolbar echo is always safe even if $d_jobID
// turns out empty.
// What this document is called on every action label below,
// Quote / Estimate / Bid (see paintops_quote_doc_word()).
$d_docWord = function_exists('paintops_quote_doc_word')
	? paintops_quote_doc_word((int) ($d_isBid ?? 0), (string) ($d_quoteLabel ?? 'quote'))
	: 'Quote';
$d_docWordLower = strtolower($d_docWord);

$emailQuoteLink   = '';
$textQuoteLink    = '';
$moreItemsHtml    = '';
$statusBadgesHtml = '';
$quoteMoreId      = 'pv-more-actions-quote-' . (int) $viewID;

if (!empty($d_jobID)){
	// Optional "click-to-text" link: opens the office staff's own phone/
	// desktop messaging app with the customer's number and quote link
	// pre-filled, for the occasional customer who asks for a text instead
	// of an email. No SMS provider/account needed, staff still hits
	// send themselves, from their own number. Only shown when a phone
	// number is on file. Promoted next to Email Quote (was buried in
	// More) since it's used just as often.
	if (!empty($d_custPhone) && function_exists('paintops_customer_quote_url')) {
		$smsQuoteUrl = paintops_customer_quote_url((int) $viewID);
		if ($smsQuoteUrl !== '') {
			$smsPhone = preg_replace('/[^0-9+]/', '', $d_custPhone);
			$smsFirst = $d_custFirst !== '' ? $d_custFirst : 'there';
			$smsBody = rawurlencode("Hi {$smsFirst}, here's your {$d_docWordLower}: {$smsQuoteUrl}");
			// "?&body=" (rather than plain "?body=" or ";body=") is the
			// widely-used pattern that opens correctly pre-filled on both
			// current iOS and Android messaging apps.
			$smsHref = 'sms:' . $smsPhone . '?&body=' . $smsBody;
			// Class + data attrs feed the tap logger below (Mike
			// 2026-07-23), see paintops_ajax_log_quote_text in
			// paintops.php for why a tap is what gets logged.
			$textQuoteLink = '<a href="' . esc_attr($smsHref) . '" class="paintops-text-quote-log" data-po-job="' . (int) $d_jobID . '" data-po-est="' . (int) $viewID . '" data-po-doc="' . esc_attr($d_docWordLower) . '" data-po-phone="' . esc_attr($smsPhone) . '"><span class="dashicons dashicons-format-chat" aria-hidden="true"></span>Text ' . esc_html($d_docWord) . '</a>';
		}
	}

	// Follow-Up actions, a one-click nudge for a quote that's gone
	// quiet, using the editable template from PaintOps > Business >
	// Follow-Up Templates. The text version reuses the exact same
	// sms: deep-link pattern as "Text Quote" above (opens staff's own
	// messaging app, pre-filled, no SMS provider involved). Both
	// Send Follow-Up Email and Text Follow-Up only show once the quote
	// has had a day to be read on its own and hasn't been accepted yet,
	// following up sooner than that just feels pushy, whichever channel
	// it's sent through.
	// Follow-Up actions live in the "More Actions" panel (built as list
	// items with icons, exactly like the Job View page's More Actions
	// dropdown). Both only show once the quote has had a day to be read
	// and hasn't been accepted, following up sooner just feels pushy.
	$followupEmailItem = '';
	$followupTextItem  = '';
	$followupEligible = empty($d_accepted) && !empty($d_createdOn) && strtotime($d_createdOn) <= strtotime('-1 day', current_time('timestamp'));
	if (!empty($d_jobID) && !empty($d_custEmail) && $followupEligible && function_exists('paintops_followup_template') && function_exists('paintops_render_followup_placeholders')) {
		$followupNonce = wp_create_nonce('paintops_send_followup_' . (int) $d_jobID);
		// Links to the compose/preview screen (2026-07-21); the send
		// itself happens from that form's POST, never from a bare GET.
		$followupEmailItem = '<a href="' . paintops_href(['followupCompose' => (int) $d_jobID]) . '"><span class="dashicons dashicons-email" aria-hidden="true"></span>Send Follow-Up Email</a>';

		if (!empty($d_custPhone)) {
			$followupJobForSms = (object) ['custFirst' => $d_custFirst, 'custLast' => ''];
			$followupEstForSms = (object) ['estID' => (int) $viewID];
			$followupTemplate = paintops_followup_template();
			$followupSmsText = paintops_render_followup_placeholders($followupTemplate['sms_body'], $followupJobForSms, $followupEstForSms);
			$followupSmsPhone = preg_replace('/[^0-9+]/', '', $d_custPhone);
			$followupSmsHref = 'sms:' . $followupSmsPhone . '?&body=' . rawurlencode($followupSmsText);
			$followupTextItem = '<a href="' . esc_attr($followupSmsHref) . '" data-po-comm="text" data-po-job="' . (int) $d_jobID . '" data-po-recipient="' . esc_attr($followupSmsPhone) . '" data-po-src="Follow-Up nudge"><span class="dashicons dashicons-format-chat" aria-hidden="true"></span>Text Follow-Up</a>';
		}
	}

	// Email Quote only shows when there's an email on file to send to
	// (Mike's call), otherwise the action would open the email screen
	// with no recipient. When contact info is missing, the status badge
	// below points staff to add it.
	$emailQuoteLink = !empty($d_custEmail)
		? '<a href="' . paintops_href(['estimates' => 1, 'csaction' => 'send', 'getEstId' => (int) $viewID]) . '"><span class="dashicons dashicons-email-alt" aria-hidden="true"></span>Email ' . esc_html($d_docWord) . '</a>'
		: '';

	// More Actions panel items (the less-frequent actions, one tap behind
	// the gear, same treatment as the Job View page).
	$moreItemsHtml = $followupEmailItem . $followupTextItem
		. '<a href="' . paintops_href(['estimates' => 1, 'csaction' => 'create', 'jobID' => (int) $d_jobID]) . '"><span class="dashicons dashicons-update" aria-hidden="true"></span>Replace ' . esc_html($d_docWord) . '</a>';

	// "Opened" status + missing-contact nudge, shown as small badges just
	// above the action row. See paintops_mark_quote_viewed() in
	// customer-payments.php for where viewed_at/view_count are set.
	if (!empty($d_viewedAt)) {
		$viewedDisplay = date("M jS, Y g:ia", strtotime($d_viewedAt));
		$statusBadgesHtml .= '<span class="job-badge job-badge-complete-yes">Viewed by customer - ' . esc_html($viewedDisplay) . ($d_viewCount > 1 ? ' (' . esc_html((string) $d_viewCount) . ' opens)' : '') . '</span>';
	} else {
		$statusBadgesHtml .= '<span class="job-badge job-badge-muted">Not yet viewed by customer</span>';
	}
	// The e-signature acceptance record (2026-07-19, v1.0.104): typed
	// name + exact time + IP, captured on the public accept page. Older
	// acceptances (before these columns existed) fall back to the legacy
	// date-only display so they still read as accepted.
	if ($d_accepted === 1) {
		if ($d_acceptedName !== '' && !empty($d_acceptedAt)) {
			$acceptedDisplay = date("M jS, Y g:ia", strtotime($d_acceptedAt));
			$statusBadgesHtml .= '<span class="job-badge job-badge-complete-yes" title="Accepting IP: ' . esc_attr($d_acceptedIp) . '">Accepted &amp; signed by ' . esc_html($d_acceptedName) . ' - ' . esc_html($acceptedDisplay) . '</span>';
		} else {
			$legacyAccepted = !empty($d_acceptedDate) ? date("M jS, Y", strtotime($d_acceptedDate)) : '';
			$statusBadgesHtml .= '<span class="job-badge job-badge-complete-yes">Accepted' . ($legacyAccepted !== '' ? ' - ' . esc_html($legacyAccepted) : '') . '</span>';
		}
	}
	// Declined (v1.0.105): red badge with the customer's optional reason in
	// the tooltip. Only while not accepted, an accept clears the decline
	// (re-accept allowed), so the two never show together.
	if ($d_accepted !== 1 && !empty($d_declinedDate)) {
		$declinedDisplay = date("M jS, Y", strtotime($d_declinedDate));
		$declinedTitle = $d_declinedReason !== '' ? ' title="Their reason: ' . esc_attr($d_declinedReason) . '"' : '';
		$statusBadgesHtml .= '<span class="job-badge job-badge-complete-no"' . $declinedTitle . '>Declined by customer - ' . esc_html($declinedDisplay) . ($d_declinedReason !== '' ? ' (hover for reason)' : '') . '</span>';
	}
	$missingContact = [];
	if (empty($d_custEmail)) { $missingContact[] = 'email'; }
	if (empty($d_custPhone)) { $missingContact[] = 'phone'; }
	if (!empty($missingContact)) {
		$statusBadgesHtml .= '<span class="job-badge job-badge-muted">No ' . esc_html(implode(' or ', $missingContact)) . ' on file - <a href="' . paintops_href(['editEid' => (int) $d_jobID]) . '">Add contact info</a></span>';
	}
}
	$results = $wpdb->get_results($wpdb->prepare("SELECT * FROM %i WHERE estID = %d LIMIT 1", paintops_table('estimates'), $viewID));
	if ($wpdb->last_error) {
		paintops_report_db_error();
	}
	if ($results) {
		foreach ($results as $row) {
			$dateText = date("M jS, Y", strtotime("{$row->createdOn}"));
			//$quoteTitle  = $row->quoteTitle;
			
			$estDetails  = $row->estDetails;
			$estTotal 	 = $row->estTotal;
			$estTax      = (int) ($row->estTax ?? 0);
			// Get prefills
			$results = $wpdb->get_results($wpdb->prepare("SELECT jobCat, jobTitle, custFirst, custLast, custServiceAddress, custServiceCity, custServiceState, custServiceZip, custPhone, custEmail, payment_plan FROM %i WHERE id = %d LIMIT 1", paintops_table('jobs'), (int) $row->jobID));

			if ($results) {

				foreach ($results as $rowJ) {
					foreach ($rowJ 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.
					}

				}
			}
			
			// Full job row for the shared quote-body renderer below, and the
			// record word (Quote/Estimate/Bid) the letterhead label uses,
			// same derivation as the customer pages. isBid comes from the
			// JOB (job-level fact), not the estimate row's stale snapshot.
			$paintopsQuoteJob = $wpdb->get_row($wpdb->prepare("SELECT * FROM %i WHERE id = %d LIMIT 1", paintops_table('jobs'), (int) $row->jobID));
			$isBid = (int) ($paintopsQuoteJob->isBid ?? $row->isBid); // Is this a GC bid?
			$paintopsQuoteWord = function_exists('paintops_quote_doc_word')
				? paintops_quote_doc_word((int) $isBid, (string) ($paintopsQuoteJob->quote_label ?? 'quote'))
				: 'Quote';

			// The quote-document body (title line, price table incl. the
			// itemized/tax logic, Description of Work, Exclusions, Our
			// Standard) is NOT built here anymore: it renders below via the
			// shared paintops_render_quote_body_fragment(), the exact same
			// function both customer-facing quote pages use
			// (customer-payments.php), so the three views can never drift
			// apart again (2026-07-21; this file used to carry its own copy,
			// and it HAD drifted: raw unformatted prices, and quotes labeled
			// "Quote" even when the business setting says "Estimate"). Only
			// the letterhead, What's Next, Payment Terms, and the legal
			// footer remain page-specific further down.
?>

			<?php
			// Action menu + status badges, styled and positioned exactly
			// like the Job View page (job-view.php): an icon quick-actions
			// row with a gear "More Actions" dropdown, sitting right above
			// the content it acts on, here, the white quote sheet below.
			// Constrained to the sheet's width so its left edge lines up.
			// The toggle onclick is copied verbatim from
			// paintops_build_job_actions_group() so behavior is identical.
			if (!empty($d_jobID)) :
			?>
			<?php $d_recordLabel = function_exists('paintops_job_record_label')
				? paintops_job_record_label((int) ($d_jobType ?? 1), (int) ($d_isBid ?? 0), (string) ($d_quoteLabel ?? 'quote'))
				: 'Job'; ?>
			<a href="<?php echo paintops_href(['viewEid' => (int) $d_jobID]); ?>" class="paintops-back-link"><span class="dashicons dashicons-arrow-left-alt2" aria-hidden="true"></span>View <?php echo esc_html($d_recordLabel); ?></a>
			<div class="paintops-quote-toolbar" style="max-width:700px;margin:24px auto 0;">
				<?php if ($statusBadgesHtml !== '') : ?>
					<div style="display:flex;flex-wrap:wrap;gap:8px;margin-bottom:12px;"><?php echo wp_kses($statusBadgesHtml, paintops_kses_allowed_html(), paintops_kses_protocols()); ?></div>
				<?php endif; ?>
				<div class="pv-quick-actions" style="margin:0 0 6px;">
					<?php if ($emailQuoteLink !== '') { echo wp_kses($emailQuoteLink, paintops_kses_allowed_html(), paintops_kses_protocols()); } ?>
					<?php echo wp_kses($textQuoteLink, paintops_kses_allowed_html(), paintops_kses_protocols()); ?>
					<?php if ($textQuoteLink !== '') : ?>
					<?php // Short dynamic fragment (nonce + ajax URL baked in),
					// routed through the scripts API; captured body only, the
					// API prints its own script tags.
					ob_start(); ?>
					(function() {
						// Log the Text-link tap via sendBeacon so the sms:
						// navigation is never delayed; the endpoint notes
						// that delivery itself is not trackable.
						document.querySelectorAll('.paintops-text-quote-log').forEach(function(a) {
							if (a.dataset.poLogWired) { return; }
							a.dataset.poLogWired = '1';
							a.addEventListener('click', function() {
								try {
									var fd = new FormData();
									fd.append('action', 'paintops_log_quote_text');
									fd.append('nonce', <?php echo wp_json_encode(wp_create_nonce('paintops_log_quote_text')); ?>);
									fd.append('job_id', a.getAttribute('data-po-job') || '0');
									fd.append('estimate_id', a.getAttribute('data-po-est') || '0');
									fd.append('doc', a.getAttribute('data-po-doc') || 'quote');
									fd.append('recipient', a.getAttribute('data-po-phone') || '');
									navigator.sendBeacon(<?php echo wp_json_encode(admin_url('admin-ajax.php')); ?>, fd);
								} catch (e) {}
							});
						});
					})();
					<?php paintops_add_inline_script((string) ob_get_clean()); ?>
					<?php endif; ?>
					<a href="#" onclick="printQuoteWithFilename();return false;"><span class="dashicons dashicons-printer" aria-hidden="true"></span>Print / PDF</a>
					<?php paintops_enqueue_script('po-toggles'); ?>
					<a href="#" class="pv-more-toggle" data-po-toggle="<?php echo esc_attr($quoteMoreId); ?>" data-open-label="Hide More Actions" data-closed-label="More Actions"><span class="dashicons dashicons-admin-generic" aria-hidden="true"></span><span class="po-toggle-label">More Actions</span> <span class="pv-more-chevron">&#9662;</span></a>
				</div>
				<div id="<?php echo esc_attr($quoteMoreId); ?>" class="hidden-box pv-more-actions"><?php echo wp_kses($moreItemsHtml, paintops_kses_allowed_html(), paintops_kses_protocols()); ?></div>
			</div>
			<?php endif; ?>

			<?php
			// White-sheet document styling + print/phone rules for the quote
			// preview, moved to assets/css/estimates-quote-view.css (wp.org
			// enqueue conversion 2026-07). @media print rules apply the same
			// from an enqueued stylesheet.
			paintops_enqueue_style('estimates-quote-view');
			?>
			<div id="printableArea">
			<div style="padding: 30px;font-family: Arial, sans-serif; font-size: 12px; max-width: 700px; width: 100%; box-sizing: border-box; margin: 20px auto;" id="estimateArea">

			  <!-- Header -->
			  <?php $company = paintops_company_profile(); ?>
			  <div class="po-quote-header" style="display: flex; justify-content: space-between; align-items: flex-start; border-bottom: 2px solid #333; margin-bottom: 20px;">
				<!-- Logo + Company Info -->
				<div><a href="<?php echo esc_url($company['website']); ?>"><img style="max-width: 150px;" src="<?php echo esc_url($company['logo']); ?>" alt="<?php echo esc_attr($company['name']); ?>" style="float: left;"></a>
				  <br>
				  <strong><?php echo esc_html($company['name']); ?></strong><br>
				  <?php echo esc_html($company['address1']); ?><br>
				  <?php echo esc_html($company['address2']); ?><br>
				  <?php echo esc_html($company['email']); ?>
				</div>

				<!-- Customer Info -->
				<div style="text-align: right;">
				  <strong><?php echo esc_html($paintopsQuoteWord); ?> For:</strong><br>
			  <?php
			  $jobID = $row->jobID;
			  $estID = $row->estID;
			  $createdOn = $row->createdOn;

			  // Filename convention: customerfirst-customerlast-jobid-estid
			  // (e.g. "bob-price-29-34"), matching the naming Mike already
			  // uses when manually saving from the browser's print dialog.
			  // sanitize_title() lowercases, strips punctuation, and turns
			  // spaces into hyphens, so names like "Bob O'Brien" become a
			  // safe "bob-obrien" instead of breaking the filename.
			  $pdfBaseName = trim(sanitize_title("{$custFirst} {$custLast}"), '-');
			  if ($pdfBaseName === '') {
				  $pdfBaseName = 'customer';
			  }
			  $pdfBaseName .= "-{$jobID}-{$estID}";
			  $pdfFileName = "{$pdfBaseName}.pdf";
			  // Was hardcoded "FL", printed every quote's PDF header as if
			  // the customer were in Florida regardless of their actual
			  // state. Uses the real custServiceState now that it exists.
			  $custCityStateLine = trim($custServiceCity . ($custServiceState !== '' ? ', ' . $custServiceState : ',') . ' ' . $custServiceZip);
			  // Escape-at-output (wp.org review 2026-07-20): job-save
			  // sanitizing strips tags on the way in, but output escaping
			  // is the rule reviewers check.
			  echo esc_html(trim("$custFirst $custLast")) . "<br>
			  " . esc_html($custServiceAddress) . "<br>
			  " . esc_html($custCityStateLine) . "<br>
			  " . esc_html($custEmail) . "<br>
			  </div></div>";
			  

			  if ($paintopsQuoteJob) {
				  // Shared quote body, same renderer as the customer pages.
				  // false = no next-steps section; this page has its own
				  // What's Next + Payment Terms below.
				  paintops_render_quote_body_fragment($row, $paintopsQuoteJob, false);
			  }
			  ?>
			  <!-- Work Acceptance -->
			  <?php
			  if ($isBid!=1){
				  $po_tpl_html = "<div style=\"margin-bottom: 20px;\">
					<h3>What's Next?</h3>
					<p style=\"margin: 0;\">
					$quoteExtraNotes
					  If you'd like to move forward, please reply to this email with 'I accept this quote'. We will email, text or call you to schedule work.
					</p>
				  </div>";
				  echo wp_kses($po_tpl_html, paintops_kses_allowed_html(), paintops_kses_protocols());
				}
				?>
			  
			  <!-- Payments -->
			  <?php
			  $pricing = paintops_pricing_rules();
			  $cardFeeText = "We also accept credit card payments, but there is a " . rtrim(rtrim(number_format($pricing['card_fee_percent'], 2), '0'), '.') . "% processing fee.";
			  // Business Settings > Company Profile > Currency's symbol, was
			  // hardcoded "$" below regardless of what currency was configured.
			  $currencySymbol = paintops_currency_symbol();
			  $payment_plan = paintops_sanitize_payment_plan((string) ($payment_plan ?? 'deposit_online'));
			  $deposit_info = paintops_calculate_deposit((float) $estTotal, $payment_plan);

			  // This is a larger job.
			  if ($isBid==1){

			  }
			  // Trusted repeat customer, deposit waived on this job. Checked
			  // before any of the tiered deposit math below, and this is also
			  // the flag that keeps a Stripe deposit link from ever being
			  // offered/charged for this job.
			  elseif ($payment_plan === 'no_deposit'){
				  $paymentMsg = "As a valued repeat customer, no deposit is required for this job. Payment is due upon completion of work. Payments can be made in cash, check, or card. {$cardFeeText}";
			  }
			  elseif ($deposit_info['requires_deposit']){
				  $depositPct = $deposit_info['percent'];
				  $deposit = $deposit_info['amount'];
				  $remainingBalance = $deposit_info['remaining'];
				  $paymentMsg = "A deposit of <b>{$currencySymbol}{$deposit}</b> ({$depositPct}%) must be made within 10 days before work begins. If scheduled sooner than 10 days, please submit payment as soon as possible to avoid delays. This allows {$company['name']} to purchase materials and secures scheduling.<br><br>
				  The remainder <b>{$currencySymbol}{$remainingBalance}</b> (" . (100 - $depositPct) . "%) is due upon completion of work. Payments can be made in cash or by check. {$cardFeeText}";
			  }
			  // This is a small job (or the deposit percentage came out to $0).
			  else{
				  $paymentMsg = "Payment is due upon completion of work. Payments can be made in cash or by check. {$cardFeeText}";
			  }

			  
			   if ($isBid!=1){
				   $po_tpl_html = "
				  <div style=\"margin-bottom: 20px;\">
					<h3>Payment Terms</h3>
					<p style=\"margin: 0;\">
					  $paymentMsg
					</p>
				  </div>";
				   echo wp_kses($po_tpl_html, paintops_kses_allowed_html(), paintops_kses_protocols());
				}

				// Footer / Legal: the owner's Quote Terms & Conditions from
				// Business Settings (paintops_quote_terms()), rendered in FULL,
				// exactly like the customer's printable quote page
				// (customer-payments.php), so what staff prints and what the
				// customer signs against are the same document. The settings
				// field always said it appears "on the printable quote"; this
				// staff sheet IS the printable quote when staff hits Print/PDF
				// (Mike 2026-07-22). Only when no terms are configured does the
				// old one-line generic disclaimer render as a fallback (worded
				// with the record's own Quote/Estimate/Bid word).
				$paintopsValidityEst = (object) ['createdOn' => $createdOn, 'accepted' => $d_accepted];
				$paintopsValidityLine = function_exists('paintops_quote_validity_line') ? paintops_quote_validity_line($paintopsValidityEst, strtolower($paintopsQuoteWord)) : '';
				if ($paintopsValidityLine !== '') {
					echo '<div style="margin-top:22px;font-size:12px;color:#555;">' . esc_html($paintopsValidityLine) . '</div>';
				}
				$paintopsStaffTerms = function_exists('paintops_quote_terms') ? paintops_quote_terms() : '';
				if ($paintopsStaffTerms !== '') {
					echo '<div style="margin-top:28px;border-top:1px solid #ddd;padding-top:16px;">'
						. '<h3 style="font-size:15px;margin:0 0 8px;">Terms &amp; Conditions</h3>'
						. '<div style="font-size:12px;color:#444;line-height:1.55;white-space:pre-line;">' . wp_kses_post($paintopsStaffTerms) . '</div>'
						. '</div>';
				} elseif ($isBid != 1) {
					echo '<div style="font-size: 10px; color: #555; border-top: 1px solid #ccc; padding-top: 10px; margin-top: 40px;">'
						. '<p style="margin: 0;">Legal Disclaimer: Change requests may impact the price of this ' . esc_html(strtolower($paintopsQuoteWord)) . '. Scheduling is subject to change depending on environmental conditions.</p>'
						. '</div>';
				}
			  ?>
			  <?php if (function_exists('paintops_powered_by_footer_html')) { echo wp_kses(paintops_powered_by_footer_html(), paintops_kses_allowed_html(), paintops_kses_protocols()); } ?>
			  </div>
			</div>
</div></div>
			
    <?php // Short dynamic fragment (the suggested PDF filename is baked
      // in), routed through the scripts API; captured body only, the API
      // prints its own script tags.
      ob_start(); ?>
      // "Print/PDF (Small File)" uses the browser's own print engine, which
      // already produces a real, small, selectable-text PDF, no library
      // needed. The one bit of friction was having to manually rename the
      // file every time. Browsers use the current page title as the
      // suggested file name in the print/save dialog, so this swaps
      // document.title to the same customerfirst-customerlast-jobid-estid
      // convention already used for $pdfFileName above, right before
      // printing, then restores the tab's normal title afterward.
      function printQuoteWithFilename() {
        var originalTitle = document.title;
        document.title = "<?php echo esc_js($pdfBaseName); ?>";
        window.print();
        window.setTimeout(function () {
          document.title = originalTitle;
        }, 1000);
      }
    <?php paintops_add_inline_script((string) ob_get_clean()); ?>
<?php
			} // end foreach ($results as $row)
		} // end if ($results)
	} // end elseif (isset($_GET['viewID']))
} // end paintops_render_estimates_screen()
} // end function_exists() guard
paintops_render_estimates_screen();
