Add Quick Send Quote / Invoice to replicate the old single click to Send Email

This commit is contained in:
johnnyq
2026-08-28 14:40:25 -04:00
parent 68b15ca49b
commit 86ba041cdc
9 changed files with 222 additions and 47 deletions

View File

@@ -246,9 +246,13 @@ if (isset($_GET['invoice_id'])) {
</button>
<div class="dropdown-menu">
<?php if (!empty($config_smtp_provider) && $emailable_contacts > 0) { ?>
<button type="submit" class="dropdown-item" form="quickSendInvoice"
name="invoice_id" value="<?= $invoice_id ?>">
<i class="fas fa-fw fa-bolt me-2"></i>Quick Send
</button>
<a class="dropdown-item ajax-modal" href="#"
data-modal-url="modals/invoice/invoice_email.php?invoice_id=<?= $invoice_id ?>">
<i class="fas fa-fw fa-paper-plane me-2"></i>Send Email
<i class="fas fa-fw fa-paper-plane me-2"></i>Send Email<span class="text-muted">...</span>
</a>
<div class="dropdown-divider"></div>
<?php } ?>
@@ -314,9 +318,13 @@ if (isset($_GET['invoice_id'])) {
<i class="fa fa-fw fa-box-open text-secondary me-2"></i>Packing Slip
</a>
<?php if (!empty($config_smtp_provider) && $emailable_contacts > 0) { ?>
<button type="submit" class="dropdown-item" form="quickSendInvoice"
name="invoice_id" value="<?= $invoice_id ?>">
<i class="fa fa-fw fa-bolt text-secondary me-2"></i>Quick Send
</button>
<a class="dropdown-item ajax-modal" href="#"
data-modal-url="modals/invoice/invoice_email.php?invoice_id=<?= $invoice_id ?>">
<i class="fa fa-fw fa-paper-plane text-secondary me-2"></i>Send Email
<i class="fa fa-fw fa-paper-plane text-secondary me-2"></i>Send Email<span class="text-muted">...</span>
</a>
<?php } ?>
<a class="dropdown-item clipboardjs" href="#" data-clipboard-text="https://<?= $config_base_url ?>/guest/guest_view_invoice.php?invoice_id=<?= "$invoice_id&url_key=$invoice_url_key" ?>">
@@ -758,6 +766,25 @@ if (isset($_GET['invoice_id'])) {
</div>
</div>
</div>
<?php if (lookupUserPermission("module_sales") >= 2 && !empty($config_smtp_provider) && $emailable_contacts > 0) { ?>
<?php
/*
* One hidden form for the page, targeted by the Quick Send buttons via
* their form="" attribute, so a button can sit inside a dropdown
* without needing a form of its own. The button carries the id as its
* own name/value, which a submit button contributes to the submission.
*
* Must stay inside this block - $emailable_contacts is only set on the
* path where the document was found.
*/
?>
<form id="quickSendInvoice" action="post.php" method="post" class="d-none">
<input type="hidden" name="csrf_token" value="<?= $_SESSION['csrf_token'] ?>">
<input type="hidden" name="email_invoice" value="1">
<input type="hidden" name="quick_send" value="1">
</form>
<?php } ?>
<?php
include_once "modals/invoice/invoice_add_ticket.php";

View File

@@ -434,9 +434,13 @@ $num_rows = mysqli_fetch_row(mysqli_query($mysqli, "SELECT FOUND_ROWS()"));
</a>
<div class="dropdown-divider"></div>
<?php if (!empty($config_smtp_provider)) { ?>
<button type="submit" class="dropdown-item" form="quickSendInvoice"
name="invoice_id" value="<?= $invoice_id ?>">
<i class="fas fa-fw fa-bolt me-2"></i>Quick Send
</button>
<a class="dropdown-item ajax-modal" href="#"
data-modal-url="modals/invoice/invoice_email.php?invoice_id=<?= $invoice_id ?>">
<i class="fas fa-fw fa-paper-plane me-2"></i>Send Email
<i class="fas fa-fw fa-paper-plane me-2"></i>Send Email<span class="text-muted">...</span>
</a>
<div class="dropdown-divider"></div>
<?php } ?>
@@ -468,6 +472,24 @@ $num_rows = mysqli_fetch_row(mysqli_query($mysqli, "SELECT FOUND_ROWS()"));
<?php require_once "../includes/filter_footer.php"; ?>
</div>
<?php if (lookupUserPermission("module_sales") >= 2 && !empty($config_smtp_provider)) { ?>
<?php
/*
* One hidden form for the whole page, targeted by the Quick Send buttons via
* their form="" attribute. It cannot be a form per button: agent/invoices.php
* wraps its table in a bulkActions form, and a nested form is invalid
* HTML - the browser drops the inner one and the click silently submits the
* bulk action instead. The button carries the id as its own name/value, which
* a submit button contributes to the submission.
*/
?>
<form id="quickSendInvoice" action="post.php" method="post" class="d-none">
<input type="hidden" name="csrf_token" value="<?= $_SESSION['csrf_token'] ?>">
<input type="hidden" name="email_invoice" value="1">
<input type="hidden" name="quick_send" value="1">
</form>
<?php } ?>
<script src="../js/bulk_actions.js"></script>
<?php

View File

@@ -34,6 +34,22 @@ $sql_contacts = mysqli_query(
$contact_count = mysqli_num_rows($sql_contacts);
// Which boxes open ticked. Read from the same helper Quick Send uses, so the
// modal and the one-click path can never disagree about who "the defaults" are.
$default_contact_ids = [];
$sql_defaults = mysqli_query(
$mysqli,
"SELECT contact_id FROM contacts
WHERE contact_client_id = $client_id
AND contact_archived_at IS NULL
AND contact_email IS NOT NULL
AND contact_email != ''
" . documentDefaultContactFilterSql('invoice')
);
while ($row = mysqli_fetch_assoc($sql_defaults)) {
$default_contact_ids[] = intval($row['contact_id']);
}
ob_start();
?>
@@ -74,10 +90,7 @@ ob_start();
$contact_primary = intval($row['contact_primary']);
$contact_billing = intval($row['contact_billing']);
// Default selection reproduces the old link exactly: the
// primary contact was the recipient and every billing
// contact got a copy
$contact_checked = ($contact_primary == 1 || $contact_billing == 1);
$contact_checked = in_array($contact_id, $default_contact_ids, true);
?>

View File

@@ -33,6 +33,22 @@ $sql_contacts = mysqli_query(
$contact_count = mysqli_num_rows($sql_contacts);
// Which boxes open ticked. Read from the same helper Quick Send uses, so the
// modal and the one-click path can never disagree about who "the defaults" are.
$default_contact_ids = [];
$sql_defaults = mysqli_query(
$mysqli,
"SELECT contact_id FROM contacts
WHERE contact_client_id = $client_id
AND contact_archived_at IS NULL
AND contact_email IS NOT NULL
AND contact_email != ''
" . documentDefaultContactFilterSql('quote')
);
while ($row = mysqli_fetch_assoc($sql_defaults)) {
$default_contact_ids[] = intval($row['contact_id']);
}
ob_start();
?>
@@ -75,11 +91,7 @@ ob_start();
$contact_technical = intval($row['contact_technical']);
$contact_important = intval($row['contact_important']);
// Default selection reproduces the old link: quotes went to
// the primary contact only. Billing contacts are listed and
// one click away, but stay unchecked - a quote is a sales
// conversation, not a bill
$contact_checked = ($contact_primary == 1);
$contact_checked = in_array($contact_id, $default_contact_ids, true);
?>

View File

@@ -544,35 +544,49 @@ if (isset($_POST['email_invoice'])) {
enforceClientAccess();
// Recipients come from the Send Email modal's contact picker. Scoping the
// lookup to this invoice's client is what makes a tampered contact_id
// harmless - it simply matches nothing.
$selected_contacts = $_POST['contacts'] ?? [];
if (!is_array($selected_contacts)) {
$selected_contacts = [];
}
$selected_contact_ids = array_filter(array_unique(array_map('intval', $selected_contacts)));
// Two ways in. Quick Send skips the modal and resolves the default
// recipients server-side; the modal posts an explicit contacts[] list.
// Scoping either lookup to this invoice's client is what makes a tampered
// contact_id harmless - it simply matches nothing.
if (!empty($_POST['quick_send'])) {
if (empty($selected_contact_ids)) {
flashAlert("Select at least one contact to send to", 'error');
redirect();
}
$recipient_filter = documentDefaultContactFilterSql('invoice');
$selected_contact_id_list = implode(',', $selected_contact_ids);
} else {
$selected_contacts = $_POST['contacts'] ?? [];
if (!is_array($selected_contacts)) {
$selected_contacts = [];
}
$selected_contact_ids = array_filter(array_unique(array_map('intval', $selected_contacts)));
if (empty($selected_contact_ids)) {
flashAlert("Select at least one contact to send to", 'error');
redirect();
}
$selected_contact_id_list = implode(',', $selected_contact_ids);
$recipient_filter = "AND contact_id IN ($selected_contact_id_list)";
}
$sql_recipients = mysqli_query(
$mysqli,
"SELECT contact_email, contact_name FROM contacts
WHERE contact_id IN ($selected_contact_id_list)
AND contact_client_id = $client_id
WHERE contact_client_id = $client_id
AND contact_archived_at IS NULL
AND contact_email IS NOT NULL
AND contact_email != ''
$recipient_filter
ORDER BY contact_primary DESC, contact_billing DESC, contact_name ASC"
);
if (mysqli_num_rows($sql_recipients) == 0) {
flashAlert("None of the selected contacts have a usable email address", 'error');
if (!empty($_POST['quick_send'])) {
flashAlert("No default contacts to quick send to - use Send Email to choose recipients", 'error');
} else {
flashAlert("None of the selected contacts have a usable email address", 'error');
}
redirect();
}

View File

@@ -596,35 +596,49 @@ if (isset($_POST['email_quote'])) {
enforceClientAccess();
// Recipients come from the Send Email modal's contact picker. Scoping the
// lookup to this quote's client is what makes a tampered contact_id
// harmless - it simply matches nothing.
$selected_contacts = $_POST['contacts'] ?? [];
if (!is_array($selected_contacts)) {
$selected_contacts = [];
}
$selected_contact_ids = array_filter(array_unique(array_map('intval', $selected_contacts)));
// Two ways in. Quick Send skips the modal and resolves the default
// recipients server-side; the modal posts an explicit contacts[] list.
// Scoping either lookup to this quote's client is what makes a tampered
// contact_id harmless - it simply matches nothing.
if (!empty($_POST['quick_send'])) {
if (empty($selected_contact_ids)) {
flashAlert("Select at least one contact to send to", 'error');
redirect();
}
$recipient_filter = documentDefaultContactFilterSql('quote');
$selected_contact_id_list = implode(',', $selected_contact_ids);
} else {
$selected_contacts = $_POST['contacts'] ?? [];
if (!is_array($selected_contacts)) {
$selected_contacts = [];
}
$selected_contact_ids = array_filter(array_unique(array_map('intval', $selected_contacts)));
if (empty($selected_contact_ids)) {
flashAlert("Select at least one contact to send to", 'error');
redirect();
}
$selected_contact_id_list = implode(',', $selected_contact_ids);
$recipient_filter = "AND contact_id IN ($selected_contact_id_list)";
}
$sql_recipients = mysqli_query(
$mysqli,
"SELECT contact_email, contact_name FROM contacts
WHERE contact_id IN ($selected_contact_id_list)
AND contact_client_id = $client_id
WHERE contact_client_id = $client_id
AND contact_archived_at IS NULL
AND contact_email IS NOT NULL
AND contact_email != ''
$recipient_filter
ORDER BY contact_primary DESC, contact_billing DESC, contact_name ASC"
);
if (mysqli_num_rows($sql_recipients) == 0) {
flashAlert("None of the selected contacts have a usable email address", 'error');
if (!empty($_POST['quick_send'])) {
flashAlert("No default contacts to quick send to - use Send Email to choose recipients", 'error');
} else {
flashAlert("None of the selected contacts have a usable email address", 'error');
}
redirect();
}

View File

@@ -168,9 +168,13 @@ if (isset($_GET['quote_id'])) {
</button>
<div class="dropdown-menu">
<?php if (!empty($config_smtp_provider) && $emailable_contacts > 0) { ?>
<button type="submit" class="dropdown-item" form="quickSendQuote"
name="quote_id" value="<?= $quote_id ?>">
<i class="fas fa-fw fa-bolt me-2"></i>Quick Send
</button>
<a class="dropdown-item ajax-modal" href="#"
data-modal-url="modals/quote/quote_email.php?quote_id=<?= $quote_id ?>">
<i class="fas fa-fw fa-paper-plane me-2"></i>Send Email
<i class="fas fa-fw fa-paper-plane me-2"></i>Send Email<span class="text-muted">...</span>
</a>
<div class="dropdown-divider"></div>
<?php } ?>
@@ -233,9 +237,13 @@ if (isset($_GET['quote_id'])) {
<i class="fa fa-fw fa-download text-secondary me-2"></i>Download PDF
</a>
<?php if (!empty($config_smtp_provider) && $emailable_contacts > 0) { ?>
<button type="submit" class="dropdown-item" form="quickSendQuote"
name="quote_id" value="<?= $quote_id ?>">
<i class="fa fa-fw fa-bolt text-secondary me-2"></i>Quick Send
</button>
<a class="dropdown-item ajax-modal" href="#"
data-modal-url="modals/quote/quote_email.php?quote_id=<?= $quote_id ?>">
<i class="fa fa-fw fa-paper-plane text-secondary me-2"></i>Send Email
<i class="fa fa-fw fa-paper-plane text-secondary me-2"></i>Send Email<span class="text-muted">...</span>
</a>
<?php } ?>
<a class="dropdown-item clipboardjs" href="#" data-clipboard-text="https://<?= $config_base_url ?>/guest/guest_view_quote.php?quote_id=<?= "$quote_id&url_key=$quote_url_key" ?>">
@@ -598,6 +606,25 @@ if (isset($_GET['quote_id'])) {
</div>
</div>
<?php if (lookupUserPermission("module_sales") >= 2 && !empty($config_smtp_provider) && $emailable_contacts > 0) { ?>
<?php
/*
* One hidden form for the page, targeted by the Quick Send buttons via
* their form="" attribute, so a button can sit inside a dropdown
* without needing a form of its own. The button carries the id as its
* own name/value, which a submit button contributes to the submission.
*
* Must stay inside this block - $emailable_contacts is only set on the
* path where the document was found.
*/
?>
<form id="quickSendQuote" action="post.php" method="post" class="d-none">
<input type="hidden" name="csrf_token" value="<?= $_SESSION['csrf_token'] ?>">
<input type="hidden" name="email_quote" value="1">
<input type="hidden" name="quick_send" value="1">
</form>
<?php } ?>
<?php
}

View File

@@ -224,9 +224,13 @@ $num_rows = mysqli_fetch_row(mysqli_query($mysqli, "SELECT FOUND_ROWS()"));
</a>
<?php if (!empty($config_smtp_provider)) { ?>
<div class="dropdown-divider"></div>
<button type="submit" class="dropdown-item" form="quickSendQuote"
name="quote_id" value="<?= $quote_id ?>">
<i class="fas fa-fw fa-bolt me-2"></i>Quick Send
</button>
<a class="dropdown-item ajax-modal" href="#"
data-modal-url="modals/quote/quote_email.php?quote_id=<?= $quote_id ?>">
<i class="fas fa-fw fa-paper-plane me-2"></i>Email
<i class="fas fa-fw fa-paper-plane me-2"></i>Email<span class="text-muted">...</span>
</a>
<?php } ?>
<?php if (lookupUserPermission("module_sales") >= 3) { ?>
@@ -254,5 +258,23 @@ $num_rows = mysqli_fetch_row(mysqli_query($mysqli, "SELECT FOUND_ROWS()"));
?>
</div>
<?php if (lookupUserPermission("module_sales") >= 2 && !empty($config_smtp_provider)) { ?>
<?php
/*
* One hidden form for the whole page, targeted by the Quick Send buttons via
* their form="" attribute. It cannot be a form per button: agent/invoices.php
* wraps its table in a bulkActions form, and a nested form is invalid
* HTML - the browser drops the inner one and the click silently submits the
* bulk action instead. The button carries the id as its own name/value, which
* a submit button contributes to the submission.
*/
?>
<form id="quickSendQuote" action="post.php" method="post" class="d-none">
<input type="hidden" name="csrf_token" value="<?= $_SESSION['csrf_token'] ?>">
<input type="hidden" name="email_quote" value="1">
<input type="hidden" name="quick_send" value="1">
</form>
<?php } ?>
<?php
require_once "../includes/footer.php";

View File

@@ -814,6 +814,30 @@ function documentContactFilterSql($document_type) {
return "AND (contact_primary = 1 OR contact_billing = 1)";
}
/*
* Which of the offered contacts are selected by default, as a SQL fragment.
*
* Two things read this and they must agree: the Send Email picker uses it to
* decide which boxes open ticked, and Quick Send uses it as the whole
* recipient list - Quick Send is exactly "send to the ticked ones without
* opening the modal".
*
* invoice - primary and billing, which is everyone the picker offers. A bill
* has no recipient you would routinely leave out.
* quote - primary only. Technical and important contacts are offered
* because they often want the quote, but sending unasked to
* someone who did not request pricing is not a safe default.
*
* Always a subset of documentContactFilterSql() for the same document type.
*/
function documentDefaultContactFilterSql($document_type) {
if ($document_type === 'quote') {
return "AND contact_primary = 1";
}
return "AND (contact_primary = 1 OR contact_billing = 1)";
}
/*
* The delivery methods offered by the Mark Sent modal on invoices and quotes.
*