14 Commits

Author SHA1 Message Date
johnnyq
3eae71653d Bump App Version 2026-09-03 18:42:55 -04:00
johnnyq
e7015d1595 Fix undefined item name when selecting from product list in quote or recurring invoice 2026-09-03 18:35:46 -04:00
johnnyq
3acb1ad754 Fix ticket creation emails always saying Open, API tickets staying New when assigned, and the bulk assignee list filter 2026-09-03 17:18:32 -04:00
johnnyq
98f8de68ae When opening a ticket on the Agent Area automatically assign ticket to self but the option to Unusassigned remains 2026-09-03 17:10:15 -04:00
johnnyq
22921f5999 Add a Sub-line on the amount column in invoices for Balance of Partially paid invoices 2026-09-03 16:44:54 -04:00
johnnyq
e9f9dac656 Fix dashboard Income by Category excluding partial payments and revenues 2026-09-03 13:20:37 -04:00
johnnyq
a0fe18fff1 Fix Tax Summary double counting tax on partially paid invoices 2026-09-03 13:12:31 -04:00
johnnyq
feae4719da Fix Unpaid widget miscalculating partially paid invoices 2026-09-03 12:38:52 -04:00
johnnyq
b982dfe2d1 Bump App Version and changelog 2026-09-02 19:17:47 -04:00
johnnyq
f1ea055f51 Bump App Version 2026-09-02 16:04:39 -04:00
johnnyq
da28d285fb Update Changelog and app version 2026-09-02 15:38:44 -04:00
Wrongecho
70216f97b9 Contacts API - Contact must be archived before deletion 2026-09-02 07:45:39 +00:00
johnnyq
9e8c4d002a Shorten Description, Text dark IP and make table smaller in IPs 2026-09-01 18:54:44 -04:00
johnnyq
0c690c4e51 Fix 3 Digit octet fix on IP address for IMask plugin 2026-09-01 18:24:46 -04:00
22 changed files with 313 additions and 284 deletions

View File

@@ -2,6 +2,65 @@
This file documents all notable changes made to ITFlow. This file documents all notable changes made to ITFlow.
## [26.09.3] Maint Release
### Upgrading to 26.09.3
Update from Maintenance > Update — Queue Update hands the job to cron and it applies on its own. There is no database change in this release, so nothing else is required.
### Breaking Changes and Notes
- New tickets are now assigned to whoever is creating them. The assignee on the ticket form and on the client bulk-add form starts on your own name instead of Unassigned, and you can still pick anyone else or Unassigned before saving.
- API: a ticket created with an assignee now comes in as Open rather than New, matching what happens when an agent assigns a ticket by hand. Anything of yours that watches for New tickets to pick up work will no longer see assigned ones.
- Tax Summary and the dashboard Income by Category chart will show different numbers than they did before. Both were wrong wherever an invoice had been partly paid, and both usually read high. The corrected figures are described under Bug Fixes.
### New Features & Updates
- Invoices: a partly paid invoice shows what is still owed in red underneath the invoice total in the list, so you can see the outstanding balance without opening the invoice.
### Bug Fixes
- Invoices: the Unpaid figure at the top of the invoice list counted the whole of every partly paid invoice rather than what was left on it, and counted that invoice again for each payment against it. An invoice for $1,000 with three payments of $100 added $3,000 to Unpaid; it now adds the $700 that is actually outstanding.
- Reports: Tax Summary counted an invoice's full tax once per payment recorded against it, so a partly paid invoice with three payments reported three times its tax. Tax is now booked to the month the money came in, in proportion to how much of the invoice that payment covered, and each payment is counted once. Yearly totals also no longer disagree with the months they are made of.
- Dashboard: the Income by Category chart only counted invoices marked Paid, so partly paid invoices contributed nothing and revenues entered outside an invoice never appeared at all. It now counts payments and revenues as they land, matching the Cash Flow chart above it and the Income Summary report.
- Tickets: ticket-created emails told the client the status was Open no matter what the ticket was actually set to. They now carry the real status, on tickets an agent creates and on scheduled tickets from recurring tickets.
- Tickets: the assignee list on the client bulk-add form left out agents on the Accountant role and did not match the list on the normal ticket form. Both lists are now the same.
- Quotes and Recurring Invoices: picking a product from the item autocomplete put the word "undefined" in the item name, while the description and price filled in correctly. Invoices were not affected. The product list behind the box was also missing information on those two pages, so every entry read "No tax", services showed a stock badge, and searching by product code did not match. Reported by @cthompson.
### Developer Updates
- `getMonthlyTax()` and `getQuarterlyTax()` in `functions/app.php` are rewritten. They previously joined `invoice_items` to `invoices` to `payments`, which multiplied the line-item rows by the payment rows — the double counting was row multiplication, not a rounding problem. Both now drive off `payments`, join a pre-aggregated per-invoice tax subquery, and scale by `payment_amount / invoice_amount`, with `invoice_amount > 0` guarding the division. `agent/reports/tax_summary.php` also dropped a second loop that recalculated each row total by calling `getMonthlyTax()` another twelve times; the total accumulates in the first loop instead, cutting the queries behind the monthly view in half.
- `agent/invoices.php`: the Partial total no longer selects `SUM(invoice_amount)` across a `payments` join, and payments against partial invoices are subtracted from the unpaid figure. The list query gained a derived `LEFT JOIN (SELECT payment_invoice_id, SUM(payment_amount) ... GROUP BY payment_invoice_id)` for the per-row balance, which keeps it to one query rather than one per row.
- `agent/dashboard.php`: the `TopCategories` temporary table is now built from a `UNION ALL` of payments (carrying their invoice's category) and revenues, keyed on payment and revenue dates rather than `invoice_status = 'Paid'` and `invoice_date`. The Other bucket is built from the same union.
- `api/v1/tickets/create.php` sets `ticket_status = 2` when `assigned_to > 0`, rather than always inserting status 1.
- `agent/modals/client/client_bulk_add_ticket.php` filtered the assignee list on `user_role_id > 1` where every other assignee list uses `user_type = 1`. Role 1 is the built-in Accountant role, so accountant-role agents were missing from it.
- Product autocomplete is consolidated. The three pages each carried their own product `SELECT` and their own copy of the autocomplete JavaScript; the queries drifted, and quote and recurring invoice were still on a four-column version that had no `product_name` or `prod_id`, so the shared `onSelect` wrote `undefined` into `#name`. Its last line also assigned to `#product_id`, which only the invoice form has, so `onSelect` threw a `TypeError` on those two pages and the `input` handler under it threw on every keystroke. The query now lives in `getProductsForAutocomplete($mysqli)` in `functions/app.php` and the JavaScript in `js/product_autocomplete.js`, with the hidden `#product_id` added to the quote and recurring item forms and treated as optional in the JavaScript. Net 218 lines removed for 134 added. Note that `item_product_id` is still only written by `add_invoice_item` and the API, so the hidden field on those two forms is inert until the handlers are wired up.
## [26.09.2] Maint Release
- Updates the App Version to a proper version number.
## [26.09.1] Maint Release
### Upgrading to 26.09.1
Update from Maintenance > Update — Queue Update hands the job to cron and it applies on its own. There is no database change in this release, so nothing else is required.
### Breaking Changes and Notes
- API: a contact must be archived before the delete endpoint will remove it. Deleting an active contact is refused and reports nothing deleted, so archive it first and then delete. Thanks to @Wrongecho.
### Bug Fixes
- Assets: IP address fields demanded all three digits of every octet, so `10.0.0.1` had to be entered as `010.000.000.001`. They take natural input again, and a field holding DHCP is now left alone rather than being emptied the moment the modal opens.
- Networks: in the IP list, an empty hostname or description shows a dash rather than a blank cell, the column headings match the rest of the app, and the table is tighter so more addresses fit on screen.
### Developer Updates
- The IPv4 mask in `js/app.js` is a regex mask rather than four `IMask.MaskedRange` blocks. A pattern mask will not advance past a separator until the current block reaches its `maxLength`, which is what forced the three-digit octets. A regex mask has no per-block completeness rule and tests the whole value on each keystroke, so a partial `10.0.` is valid on its own. Octets are still bounded to 0-255 and leading zeros are still accepted, matching what the old jquery.inputmask `ip` alias allowed. Any value not made purely of digits and dots is skipped, because `interface_ip` and `asset_ip` are `varchar(200)` and also carry the literal `DHCP` written by the checkbox on those same modals.
- Dead display variables removed from the asset, expense and product listings. `$asset_description_display`, `$client_name_display` and `$product_description_display` are folded into `?: '-'` at the point of assignment.
## [26.09] ## [26.09]
### Upgrading to 26.09 ### Upgrading to 26.09

View File

@@ -556,11 +556,6 @@ $num_rows = mysqli_fetch_row(mysqli_query($mysqli, "SELECT FOUND_ROWS()"));
$asset_type = escapeHtml($row['asset_type']); $asset_type = escapeHtml($row['asset_type']);
$asset_name = escapeHtml($row['asset_name']); $asset_name = escapeHtml($row['asset_name']);
$asset_description = escapeHtml($row['asset_description']); $asset_description = escapeHtml($row['asset_description']);
if ($asset_description) {
$asset_description_display = $asset_description;
} else {
$asset_description_display = "-";
}
$asset_make = escapeHtml($row['asset_make']); $asset_make = escapeHtml($row['asset_make']);
$asset_model = escapeHtml($row['asset_model']); $asset_model = escapeHtml($row['asset_model']);
$asset_serial = escapeHtml($row['asset_serial']); $asset_serial = escapeHtml($row['asset_serial']);

View File

@@ -1025,14 +1025,40 @@ if ($user_config_dashboard_technical_enable == 1) {
data: { data: {
labels: [ labels: [
<?php <?php
mysqli_query($mysqli, "CREATE TEMPORARY TABLE TopCategories SELECT category_name, category_id, SUM(invoice_amount) AS total_income FROM categories, invoices WHERE invoice_category_id = category_id AND invoice_status = 'Paid' AND YEAR(invoice_date) = $year GROUP BY category_name, category_id ORDER BY total_income DESC LIMIT 5"); // Cash basis, matching the Cash Flow chart above and the Income Summary
// report - payments carry their invoice's category, and standalone
// revenues count too. Keying off invoice_status = 'Paid' instead would
// drop every partially paid invoice and every revenue from the chart.
mysqli_query($mysqli, "CREATE TEMPORARY TABLE TopCategories
SELECT category_name, category_id, SUM(income.amount) AS total_income
FROM (SELECT invoice_category_id AS income_category_id, payment_amount AS amount
FROM payments
INNER JOIN invoices ON invoice_id = payment_invoice_id
WHERE YEAR(payment_date) = $year AND invoice_category_id > 0
UNION ALL
SELECT revenue_category_id AS income_category_id, revenue_amount AS amount
FROM revenues
WHERE YEAR(revenue_date) = $year AND revenue_category_id > 0) AS income
INNER JOIN categories ON category_id = income.income_category_id
GROUP BY category_name, category_id
ORDER BY total_income DESC LIMIT 5");
$sql_categories = mysqli_query($mysqli, "SELECT category_name FROM TopCategories"); $sql_categories = mysqli_query($mysqli, "SELECT category_name FROM TopCategories");
while ($row = mysqli_fetch_assoc($sql_categories)) { while ($row = mysqli_fetch_assoc($sql_categories)) {
$category_name = json_encode($row['category_name']); $category_name = json_encode($row['category_name']);
echo "$category_name,"; echo "$category_name,";
} }
$sql_other_categories = mysqli_query($mysqli, "SELECT SUM(invoices.invoice_amount) AS other_income FROM categories LEFT JOIN TopCategories ON categories.category_id = TopCategories.category_id INNER JOIN invoices ON categories.category_id = invoices.invoice_category_id WHERE TopCategories.category_id IS NULL AND invoice_status = 'Paid' AND YEAR(invoice_date) = $year"); $sql_other_categories = mysqli_query($mysqli, "SELECT SUM(income.amount) AS other_income
FROM (SELECT invoice_category_id AS income_category_id, payment_amount AS amount
FROM payments
INNER JOIN invoices ON invoice_id = payment_invoice_id
WHERE YEAR(payment_date) = $year AND invoice_category_id > 0
UNION ALL
SELECT revenue_category_id AS income_category_id, revenue_amount AS amount
FROM revenues
WHERE YEAR(revenue_date) = $year AND revenue_category_id > 0) AS income
LEFT JOIN TopCategories ON TopCategories.category_id = income.income_category_id
WHERE TopCategories.category_id IS NULL");
$row = mysqli_fetch_assoc($sql_other_categories); $row = mysqli_fetch_assoc($sql_other_categories);
$other_income = floatval($row['other_income']); $other_income = floatval($row['other_income']);
if ($other_income > 0) { if ($other_income > 0) {

View File

@@ -268,12 +268,7 @@ $num_rows = mysqli_fetch_row(mysqli_query($mysqli, "SELECT FOUND_ROWS()"));
$category_name = escapeHtml($row['category_name']); $category_name = escapeHtml($row['category_name']);
$account_name = escapeHtml($row['account_name']); $account_name = escapeHtml($row['account_name']);
$expense_account_id = intval($row['expense_account_id']); $expense_account_id = intval($row['expense_account_id']);
$client_name = escapeHtml($row['client_name']); $client_name = escapeHtml($row['client_name']) ?: '-';
if(empty($client_name)) {
$client_name_display = "-";
} else {
$client_name_display = $client_name;
}
$expense_client_id = intval($row['expense_client_id']); $expense_client_id = intval($row['expense_client_id']);
if (empty($expense_receipt)) { if (empty($expense_receipt)) {
@@ -307,7 +302,7 @@ $num_rows = mysqli_fetch_row(mysqli_query($mysqli, "SELECT FOUND_ROWS()"));
<td><?= $vendor_name ?></td> <td><?= $vendor_name ?></td>
<td class="text-end font-monospace"><?= numfmt_format_currency($currency_format, $expense_amount, $expense_currency_code) ?></td> <td class="text-end font-monospace"><?= numfmt_format_currency($currency_format, $expense_amount, $expense_currency_code) ?></td>
<td><?= $account_name ?></td> <td><?= $account_name ?></td>
<td><?= $client_name_display ?></td> <td><?= $client_name ?></td>
<td> <td>
<div class="dropdown dropstart text-center"> <div class="dropdown dropstart text-center">
<button class="btn btn-secondary btn-sm" type="button" data-bs-toggle="dropdown"> <button class="btn btn-secondary btn-sm" type="button" data-bs-toggle="dropdown">

View File

@@ -180,32 +180,7 @@ if (isset($_GET['invoice_id'])) {
$invoice_badge_color = getInvoiceBadgeColor($invoice_status); $invoice_badge_color = getInvoiceBadgeColor($invoice_status);
//Product autocomplete //Product autocomplete
$products_sql = mysqli_query($mysqli, " $json_products = getProductsForAutocomplete($mysqli);
SELECT
IF(product_code IS NULL OR product_code = '', product_name, CONCAT(product_code, ' - ', product_name)) AS label,
product_name,
product_code,
product_type AS type,
product_description AS description,
product_price AS price,
product_tax_id AS tax,
tax_percent,
product_id AS prod_id,
COALESCE(SUM(product_stock.stock_qty), 0) AS available_stock
FROM products
LEFT JOIN product_stock ON product_id = stock_product_id
LEFT JOIN taxes ON product_tax_id = tax_id
WHERE product_archived_at IS NULL
GROUP BY product_id
ORDER BY product_name ASC
");
if (mysqli_num_rows($products_sql) > 0) {
while ($row = mysqli_fetch_assoc($products_sql)) {
$products[] = $row;
}
$json_products = json_encode($products);
}
// Saved Payment Methods // Saved Payment Methods
$sql_saved_payment_methods = mysqli_query($mysqli, " $sql_saved_payment_methods = mysqli_query($mysqli, "
@@ -815,67 +790,12 @@ require_once "../includes/footer.php";
?> ?>
<!-- JSON Autocomplete / type ahead --> <!-- Product autocomplete for the add-item row -->
<script src="/js/product_autocomplete.js"></script>
<script> <script>
document.addEventListener('DOMContentLoaded', function () { document.addEventListener('DOMContentLoaded', function () {
initProductAutocomplete(<?= $json_products ?? '[]' ?>);
var availableProducts = <?= $json_products ?? '[]' ?>;
var nameInput = document.getElementById('name');
if (!nameInput) {
return;
}
itflowAutocomplete(nameInput, {
minLength: 1,
source: availableProducts,
match: function (item, term) {
return String(item.label || '').toLowerCase().indexOf(term) !== -1
|| String(item.product_name || '').toLowerCase().indexOf(term) !== -1
|| String(item.product_code || '').toLowerCase().indexOf(term) !== -1;
},
render: function (item) {
var esc = itflowEscapeHtml;
var typeText = item.type ? item.type.charAt(0).toUpperCase() + item.type.slice(1).toLowerCase() : "";
var showStock = (typeText.toLowerCase() !== "service");
var taxText = (item.tax_percent != null) ? (parseFloat(item.tax_percent) + "%") : "No tax";
var priceText = (item.price != null && item.price !== "") ? String(item.price) : "";
var stockText = (item.available_stock ?? 0);
return "<div class='d-flex justify-content-between align-items-start'>" +
"<div class='flex-fill pe-2'>" +
"<div class='fw-bold'>" + esc(item.label) +
(typeText ? " <small class='text-muted'>(" + esc(typeText) + ")</small>" : "") +
"</div>" +
"<div class='small text-muted'>" + esc(item.description) + "</div>" +
"<div class='mt-1'>" +
"<span class='badge bg-secondary me-1'>Tax: " + esc(taxText) + "</span>" +
(showStock ? "<span class='badge " + (stockText > 0 ? "bg-success" : "bg-danger") + "'>Stock: " + esc(stockText) + "</span>" : "") +
"</div>" +
"</div>" +
"<div class='text-end'>" +
"<div class='fw-bold'>" + esc(priceText) + "</div>" +
"</div>" +
"</div>";
},
onSelect: function (item) {
document.getElementById('name').value = item.product_name;
document.getElementById('desc').value = item.description;
document.getElementById('qty').value = 1;
document.getElementById('price').value = item.price;
setTomSelectValue(document.getElementById('tax'), item.tax);
document.getElementById('product_id').value = item.prod_id;
}
});
// Typing over the name by hand breaks the link to the product
nameInput.addEventListener('input', function () {
document.getElementById('product_id').value = 0;
});
}); });
</script> </script>
<script src="../libs/SortableJS/Sortable.min.js"></script> <script src="../libs/SortableJS/Sortable.min.js"></script>

View File

@@ -52,10 +52,13 @@ $sql_total_cancelled_amount = mysqli_query($mysqli, "SELECT SUM(invoice_amount)
$row = mysqli_fetch_assoc($sql_total_cancelled_amount); $row = mysqli_fetch_assoc($sql_total_cancelled_amount);
$total_cancelled_amount = floatval($row['total_cancelled_amount']); $total_cancelled_amount = floatval($row['total_cancelled_amount']);
$sql_total_partial_amount = mysqli_query($mysqli, "SELECT SUM(invoice_amount) AS total_partial_amount FROM payments, invoices WHERE payment_invoice_id = invoice_id AND invoice_status = 'Partial' $client_query"); $sql_total_partial_amount = mysqli_query($mysqli, "SELECT SUM(invoice_amount) AS total_partial_amount FROM invoices WHERE invoice_status = 'Partial' $client_query");
$row = mysqli_fetch_assoc($sql_total_partial_amount); $row = mysqli_fetch_assoc($sql_total_partial_amount);
$total_partial_amount = floatval($row['total_partial_amount']); $total_partial_amount = floatval($row['total_partial_amount']);
$total_partial_count = mysqli_num_rows($sql_total_partial_amount);
$sql_total_partial_paid_amount = mysqli_query($mysqli, "SELECT SUM(payment_amount) AS total_partial_paid_amount FROM payments, invoices WHERE payment_invoice_id = invoice_id AND invoice_status = 'Partial' $client_query");
$row = mysqli_fetch_assoc($sql_total_partial_paid_amount);
$total_partial_paid_amount = floatval($row['total_partial_paid_amount']);
$sql_total_overdue_partial_amount = mysqli_query($mysqli, "SELECT SUM(payment_amount) AS total_overdue_partial_amount FROM payments, invoices WHERE payment_invoice_id = invoice_id AND invoice_status = 'Partial' AND invoice_due < CURDATE() $client_query"); $sql_total_overdue_partial_amount = mysqli_query($mysqli, "SELECT SUM(payment_amount) AS total_overdue_partial_amount FROM payments, invoices WHERE payment_invoice_id = invoice_id AND invoice_status = 'Partial' AND invoice_due < CURDATE() $client_query");
$row = mysqli_fetch_assoc($sql_total_overdue_partial_amount); $row = mysqli_fetch_assoc($sql_total_overdue_partial_amount);
@@ -66,7 +69,7 @@ $row = mysqli_fetch_assoc($sql_total_overdue_amount);
$total_overdue_amount = floatval($row['total_overdue_amount']); $total_overdue_amount = floatval($row['total_overdue_amount']);
$real_overdue_amount = $total_overdue_amount - $total_overdue_partial_amount; $real_overdue_amount = $total_overdue_amount - $total_overdue_partial_amount;
$total_unpaid_amount = $total_sent_amount + $total_viewed_amount + $total_partial_amount; $total_unpaid_amount = $total_sent_amount + $total_viewed_amount + $total_partial_amount - $total_partial_paid_amount;
$unpaid_count = $sent_count + $viewed_count + $partial_count; $unpaid_count = $sent_count + $viewed_count + $partial_count;
$overdue_query = ''; $overdue_query = '';
@@ -98,10 +101,13 @@ $sql = mysqli_query(
invoice_amount, invoice_created_at, invoice_currency_code, invoice_date, invoice_amount, invoice_created_at, invoice_currency_code, invoice_date,
invoice_discount_amount, invoice_due, invoice_id, invoice_number, invoice_prefix, invoice_discount_amount, invoice_due, invoice_id, invoice_number, invoice_prefix,
invoice_scope, invoice_status, recurring_invoice_id, recurring_invoice_number, invoice_scope, invoice_status, recurring_invoice_id, recurring_invoice_number,
recurring_invoice_prefix FROM invoices recurring_invoice_prefix, IFNULL(invoice_payments.amount_paid, 0) AS amount_paid FROM invoices
LEFT JOIN clients ON invoice_client_id = client_id LEFT JOIN clients ON invoice_client_id = client_id
LEFT JOIN categories ON invoice_category_id = category_id LEFT JOIN categories ON invoice_category_id = category_id
LEFT JOIN recurring_invoices ON invoice_recurring_invoice_id = recurring_invoice_id LEFT JOIN recurring_invoices ON invoice_recurring_invoice_id = recurring_invoice_id
LEFT JOIN (SELECT payment_invoice_id, SUM(payment_amount) AS amount_paid
FROM payments
GROUP BY payment_invoice_id) AS invoice_payments ON payment_invoice_id = invoice_id
WHERE ($status_query) WHERE ($status_query)
$overdue_query $overdue_query
$category_query $category_query
@@ -339,6 +345,8 @@ $num_rows = mysqli_fetch_row(mysqli_query($mysqli, "SELECT FOUND_ROWS()"));
$invoice_due = escapeHtml($row['invoice_due']); $invoice_due = escapeHtml($row['invoice_due']);
$invoice_discount = floatval($row['invoice_discount_amount']); $invoice_discount = floatval($row['invoice_discount_amount']);
$invoice_amount = floatval($row['invoice_amount']); $invoice_amount = floatval($row['invoice_amount']);
$amount_paid = floatval($row['amount_paid']);
$invoice_balance = $invoice_amount - $amount_paid;
$invoice_currency_code = escapeHtml($row['invoice_currency_code']); $invoice_currency_code = escapeHtml($row['invoice_currency_code']);
$invoice_created_at = escapeHtml($row['invoice_created_at']); $invoice_created_at = escapeHtml($row['invoice_created_at']);
$client_id = intval($row['client_id']); $client_id = intval($row['client_id']);
@@ -395,7 +403,12 @@ $num_rows = mysqli_fetch_row(mysqli_query($mysqli, "SELECT FOUND_ROWS()"));
<?php if (!$client_url) { ?> <?php if (!$client_url) { ?>
<td class="text-bold"><a href="invoices.php?client_id=<?= $client_id ?>"><?= $client_name ?></a></td> <td class="text-bold"><a href="invoices.php?client_id=<?= $client_id ?>"><?= $client_name ?></a></td>
<?php } ?> <?php } ?>
<td class="text-end font-monospace"><?= numfmt_format_currency($currency_format, $invoice_amount, $invoice_currency_code) ?></td> <td class="text-end font-monospace">
<?= numfmt_format_currency($currency_format, $invoice_amount, $invoice_currency_code) ?>
<?php if ($amount_paid > 0 && $invoice_balance > 0) { ?>
<br><small class="text-danger"><?= numfmt_format_currency($currency_format, $invoice_balance, $invoice_currency_code) ?> due</small>
<?php } ?>
</td>
<td><?= $invoice_date ?></td> <td><?= $invoice_date ?></td>
<td class="<?= $overdue_color ?>"><?= $invoice_due ?></td> <td class="<?= $overdue_color ?>"><?= $invoice_due ?></td>
<td><?= $category_name ?></td> <td><?= $category_name ?></td>

View File

@@ -86,12 +86,12 @@ ob_start();
$sql = mysqli_query( $sql = mysqli_query(
$mysqli, $mysqli,
"SELECT user_id, user_name FROM users "SELECT user_id, user_name FROM users
WHERE user_role_id > 1 AND user_status = 1 AND user_archived_at IS NULL ORDER BY user_name ASC" WHERE user_type = 1 AND user_status = 1 AND user_archived_at IS NULL ORDER BY user_name ASC"
); );
while ($row = mysqli_fetch_assoc($sql)) { while ($row = mysqli_fetch_assoc($sql)) {
$user_id = intval($row['user_id']); $user_id = intval($row['user_id']);
$user_name = escapeHtml($row['user_name']); ?> $user_name = escapeHtml($row['user_name']); ?>
<option value="<?= $user_id ?>"><?= $user_name ?></option> <option <?php if ($session_user_id == $user_id) { echo "selected"; } ?> value="<?= $user_id ?>"><?= $user_name ?></option>
<?php } ?> <?php } ?>
</select> </select>
</div> </div>

View File

@@ -161,7 +161,7 @@ ob_start();
while ($row = mysqli_fetch_assoc($sql)) { while ($row = mysqli_fetch_assoc($sql)) {
$user_id = intval($row['user_id']); $user_id = intval($row['user_id']);
$user_name = escapeHtml($row['user_name']); ?> $user_name = escapeHtml($row['user_name']); ?>
<option value="<?= $user_id ?>"><?= $user_name ?></option> <option <?php if ($session_user_id == $user_id) { echo "selected"; } ?> value="<?= $user_id ?>"><?= $user_name ?></option>
<?php } ?> <?php } ?>
</select> </select>
</div> </div>

View File

@@ -224,7 +224,7 @@ if (mysqli_num_rows($sql) == 0) {
<form id="bulkActions" action="post.php" method="post"> <form id="bulkActions" action="post.php" method="post">
<input type="hidden" name="csrf_token" value="<?= $_SESSION['csrf_token'] ?>"> <input type="hidden" name="csrf_token" value="<?= $_SESSION['csrf_token'] ?>">
<table class="table table-striped table-borderless table-hover mb-0"> <table class="table table-striped table-borderless table-hover table-sm mb-0">
<thead class="text-dark <?php if ($num_rows[0] == 0) { echo "d-none"; } ?>"> <thead class="text-dark <?php if ($num_rows[0] == 0) { echo "d-none"; } ?>">
<tr> <tr>
<td class="checkbox-column border-end"> <td class="checkbox-column border-end">
@@ -233,17 +233,17 @@ if (mysqli_num_rows($sql) == 0) {
</div> </div>
</td> </td>
<th> <th>
<a class="text-secondary" href="?<?= $url_query_strings_sort ?>&sort=ip_address&order=<?= $disp ?>"> <a class="text-dark" href="?<?= $url_query_strings_sort ?>&sort=ip_address&order=<?= $disp ?>">
IP Address <?php if ($sort == 'ip_address') { echo $order_icon; } ?> IP Address <?php if ($sort == 'ip_address') { echo $order_icon; } ?>
</a> </a>
</th> </th>
<th> <th>
<a class="text-secondary" href="?<?= $url_query_strings_sort ?>&sort=ip_hostname&order=<?= $disp ?>"> <a class="text-dark" href="?<?= $url_query_strings_sort ?>&sort=ip_hostname&order=<?= $disp ?>">
Hostname <?php if ($sort == 'ip_hostname') { echo $order_icon; } ?> Hostname <?php if ($sort == 'ip_hostname') { echo $order_icon; } ?>
</a> </a>
</th> </th>
<th> <th>
<a class="text-secondary" href="?<?= $url_query_strings_sort ?>&sort=ip_description&order=<?= $disp ?>"> <a class="text-dark" href="?<?= $url_query_strings_sort ?>&sort=ip_description&order=<?= $disp ?>">
Description <?php if ($sort == 'ip_description') { echo $order_icon; } ?> Description <?php if ($sort == 'ip_description') { echo $order_icon; } ?>
</a> </a>
</th> </th>
@@ -256,8 +256,8 @@ if (mysqli_num_rows($sql) == 0) {
while ($row = mysqli_fetch_assoc($sql_ips)) { while ($row = mysqli_fetch_assoc($sql_ips)) {
$ip_id = intval($row['ip_id']); $ip_id = intval($row['ip_id']);
$ip_address = escapeHtml($row['ip_address']); $ip_address = escapeHtml($row['ip_address']);
$ip_hostname = escapeHtml($row['ip_hostname']); $ip_hostname = escapeHtml($row['ip_hostname']) ?: '-';
$ip_description = escapeHtml($row['ip_description']); $ip_description = escapeHtml($row['ip_description']) ?: '-';
?> ?>
<tr> <tr>

View File

@@ -192,7 +192,8 @@ if (isset($_POST['bulk_force_recurring_tickets'])) {
$email_subject = "Ticket Created - [$ticket_prefix$ticket_number] - $ticket_subject (scheduled)"; $email_subject = "Ticket Created - [$ticket_prefix$ticket_number] - $ticket_subject (scheduled)";
// SLA response commitment for this client + priority, empty when no SLA applies // SLA response commitment for this client + priority, empty when no SLA applies
$sla_notice = escapeSql(getTicketSlaEmailNotice($id, $company_phone)); $sla_notice = escapeSql(getTicketSlaEmailNotice($id, $company_phone));
$email_body = "<i style=\'color: #808080\'>##- Please type your reply above this line -##</i><br><br>Hello $contact_name,<br><br>A ticket regarding \"$ticket_subject\" has been automatically created for you.<br><br>--------------------------------<br>$ticket_details--------------------------------<br><br>Ticket: $ticket_prefix$ticket_number<br>Subject: $ticket_subject<br>Status: Open<br>Portal: https://$config_base_url/client/ticket.php?id=$id$sla_notice<br><br>--<br>$company_name - Support<br>$config_ticket_from_email<br>$company_phone"; $ticket_status_name = escapeSql(getTicketStatusName($ticket_status));
$email_body = "<i style=\'color: #808080\'>##- Please type your reply above this line -##</i><br><br>Hello $contact_name,<br><br>A ticket regarding \"$ticket_subject\" has been automatically created for you.<br><br>--------------------------------<br>$ticket_details--------------------------------<br><br>Ticket: $ticket_prefix$ticket_number<br>Subject: $ticket_subject<br>Status: $ticket_status_name<br>Portal: https://$config_base_url/client/ticket.php?id=$id$sla_notice<br><br>--<br>$company_name - Support<br>$config_ticket_from_email<br>$company_phone";
$email = [ $email = [
'from' => $config_ticket_from_email, 'from' => $config_ticket_from_email,
@@ -341,7 +342,8 @@ if (isset($_GET['force_recurring_ticket'])) {
$email_subject = "Ticket created - [$ticket_prefix$ticket_number] - $ticket_subject (scheduled)"; $email_subject = "Ticket created - [$ticket_prefix$ticket_number] - $ticket_subject (scheduled)";
// SLA response commitment for this client + priority, empty when no SLA applies // SLA response commitment for this client + priority, empty when no SLA applies
$sla_notice = escapeSql(getTicketSlaEmailNotice($id, $company_phone)); $sla_notice = escapeSql(getTicketSlaEmailNotice($id, $company_phone));
$email_body = "<i style=\'color: #808080\'>##- Please type your reply above this line -##</i><br><br>Hello $contact_name,<br><br>A ticket regarding \"$ticket_subject\" has been automatically created for you.<br><br>--------------------------------<br>$ticket_details--------------------------------<br><br>Ticket: $ticket_prefix$ticket_number<br>Subject: $ticket_subject<br>Status: Open<br>Portal: https://$config_base_url/client/ticket.php?id=$id$sla_notice<br><br>--<br>$company_name - Support<br>$config_ticket_from_email<br>$company_phone"; $ticket_status_name = escapeSql(getTicketStatusName($ticket_status));
$email_body = "<i style=\'color: #808080\'>##- Please type your reply above this line -##</i><br><br>Hello $contact_name,<br><br>A ticket regarding \"$ticket_subject\" has been automatically created for you.<br><br>--------------------------------<br>$ticket_details--------------------------------<br><br>Ticket: $ticket_prefix$ticket_number<br>Subject: $ticket_subject<br>Status: $ticket_status_name<br>Portal: https://$config_base_url/client/ticket.php?id=$id$sla_notice<br><br>--<br>$company_name - Support<br>$config_ticket_from_email<br>$company_phone";
$email = [ $email = [
'from' => $config_ticket_from_email, 'from' => $config_ticket_from_email,

View File

@@ -142,7 +142,7 @@ if (isset($_POST['add_ticket'])) {
$subject = "Ticket Created [$ticket_prefix$ticket_number] - $ticket_subject"; $subject = "Ticket Created [$ticket_prefix$ticket_number] - $ticket_subject";
// SLA response commitment for this client + priority, empty when no SLA applies // SLA response commitment for this client + priority, empty when no SLA applies
$sla_notice = escapeSql(getTicketSlaEmailNotice($ticket_id, $company_phone)); $sla_notice = escapeSql(getTicketSlaEmailNotice($ticket_id, $company_phone));
$body = "<i style=\'color: #808080\'>##- Please type your reply above this line -##</i><br><br>Hello $contact_name,<br><br>A ticket regarding \"$ticket_subject\" has been created for you.<br><br>--------------------------------<br>$ticket_details--------------------------------<br><br>Ticket: $ticket_prefix$ticket_number<br>Subject: $ticket_subject<br>Status: Open<br>Portal: <a href=\'https://$config_base_url/guest/guest_view_ticket.php?ticket_id=$ticket_id&url_key=$url_key\'>View ticket</a>$sla_notice<br><br>--<br>$company_name - Support<br>$config_ticket_from_email<br>$company_phone"; $body = "<i style=\'color: #808080\'>##- Please type your reply above this line -##</i><br><br>Hello $contact_name,<br><br>A ticket regarding \"$ticket_subject\" has been created for you.<br><br>--------------------------------<br>$ticket_details--------------------------------<br><br>Ticket: $ticket_prefix$ticket_number<br>Subject: $ticket_subject<br>Status: $ticket_status_name<br>Portal: <a href=\'https://$config_base_url/guest/guest_view_ticket.php?ticket_id=$ticket_id&url_key=$url_key\'>View ticket</a>$sla_notice<br><br>--<br>$company_name - Support<br>$config_ticket_from_email<br>$company_phone";
// Verify contact email is valid // Verify contact email is valid
if (filter_var($contact_email, FILTER_VALIDATE_EMAIL)) { if (filter_var($contact_email, FILTER_VALIDATE_EMAIL)) {

View File

@@ -232,12 +232,7 @@ $num_rows = mysqli_fetch_row(mysqli_query($mysqli, "SELECT FOUND_ROWS()"));
while ($row = mysqli_fetch_assoc($sql)) { while ($row = mysqli_fetch_assoc($sql)) {
$product_id = intval($row['product_id']); $product_id = intval($row['product_id']);
$product_name = escapeHtml($row['product_name']); $product_name = escapeHtml($row['product_name']);
$product_description = escapeHtml($row['product_description']); $product_description = escapeHtml($row['product_description']) ?: '-';
if (empty($product_description)) {
$product_description_display = "-";
} else {
$product_description_display = "<div style='white-space:pre-line'>$product_description</div>";
}
$product_qty = intval($row['product_qty']); $product_qty = intval($row['product_qty']);
$product_code = escapeHtml($row['product_code']); $product_code = escapeHtml($row['product_code']);
$product_location = escapeHtml($row['product_location']) ?: '-'; $product_location = escapeHtml($row['product_location']) ?: '-';
@@ -267,7 +262,7 @@ $num_rows = mysqli_fetch_row(mysqli_query($mysqli, "SELECT FOUND_ROWS()"));
</a> </a>
</td> </td>
<td><?= $category_name ?></td> <td><?= $category_name ?></td>
<td><?= $product_description_display ?></td> <td><div style='white-space:pre-line'><?= $product_description ?></div></td>
<?php if ($type_filter == 'product') { ?> <?php if ($type_filter == 'product') { ?>
<td><?= $product_qty ?></td> <td><?= $product_qty ?></td>
<td><?= $product_location ?></td> <td><?= $product_location ?></td>

View File

@@ -128,14 +128,7 @@ if (isset($_GET['quote_id'])) {
} }
//Product autocomplete //Product autocomplete
$products_sql = mysqli_query($mysqli, "SELECT product_name AS label, product_description AS description, product_price AS price, product_tax_id AS tax FROM products WHERE product_archived_at IS NULL"); $json_products = getProductsForAutocomplete($mysqli);
if (mysqli_num_rows($products_sql) > 0) {
while ($row = mysqli_fetch_assoc($products_sql)) {
$products[] = $row;
}
$json_products = json_encode($products);
}
// Quote File Attachments // Quote File Attachments
$sql_quote_files = mysqli_query( $sql_quote_files = mysqli_query(
@@ -403,6 +396,7 @@ if (isset($_GET['quote_id'])) {
<form action="post.php" method="post" autocomplete="off"> <form action="post.php" method="post" autocomplete="off">
<input type="hidden" name="csrf_token" value="<?= $_SESSION['csrf_token'] ?>"> <input type="hidden" name="csrf_token" value="<?= $_SESSION['csrf_token'] ?>">
<input type="hidden" name="quote_id" value="<?= $quote_id ?>"> <input type="hidden" name="quote_id" value="<?= $quote_id ?>">
<input type="hidden" id="product_id" name="product_id" value="0">
<input type="hidden" name="item_order" value="<?php <input type="hidden" name="item_order" value="<?php
//find largest order number and add 1 //find largest order number and add 1
$sql = mysqli_query($mysqli, "SELECT MAX(item_order) AS item_order FROM quote_items WHERE item_quote_id = $quote_id"); $sql = mysqli_query($mysqli, "SELECT MAX(item_order) AS item_order FROM quote_items WHERE item_quote_id = $quote_id");
@@ -643,68 +637,12 @@ require_once "../includes/footer.php";
?> ?>
<!-- JSON Autocomplete / type ahead --> <!-- Product autocomplete for the add-item row -->
<!-- //TODO: Move to js/ --> <script src="/js/product_autocomplete.js"></script>
<script> <script>
document.addEventListener('DOMContentLoaded', function () { document.addEventListener('DOMContentLoaded', function () {
initProductAutocomplete(<?= $json_products ?? '[]' ?>);
var availableProducts = <?= $json_products ?? '[]' ?>;
var nameInput = document.getElementById('name');
if (!nameInput) {
return;
}
itflowAutocomplete(nameInput, {
minLength: 1,
source: availableProducts,
match: function (item, term) {
return String(item.label || '').toLowerCase().indexOf(term) !== -1
|| String(item.product_name || '').toLowerCase().indexOf(term) !== -1
|| String(item.product_code || '').toLowerCase().indexOf(term) !== -1;
},
render: function (item) {
var esc = itflowEscapeHtml;
var typeText = item.type ? item.type.charAt(0).toUpperCase() + item.type.slice(1).toLowerCase() : "";
var showStock = (typeText.toLowerCase() !== "service");
var taxText = (item.tax_percent != null) ? (parseFloat(item.tax_percent) + "%") : "No tax";
var priceText = (item.price != null && item.price !== "") ? String(item.price) : "";
var stockText = (item.available_stock ?? 0);
return "<div class='d-flex justify-content-between align-items-start'>" +
"<div class='flex-fill pe-2'>" +
"<div class='fw-bold'>" + esc(item.label) +
(typeText ? " <small class='text-muted'>(" + esc(typeText) + ")</small>" : "") +
"</div>" +
"<div class='small text-muted'>" + esc(item.description) + "</div>" +
"<div class='mt-1'>" +
"<span class='badge bg-secondary me-1'>Tax: " + esc(taxText) + "</span>" +
(showStock ? "<span class='badge " + (stockText > 0 ? "bg-success" : "bg-danger") + "'>Stock: " + esc(stockText) + "</span>" : "") +
"</div>" +
"</div>" +
"<div class='text-end'>" +
"<div class='fw-bold'>" + esc(priceText) + "</div>" +
"</div>" +
"</div>";
},
onSelect: function (item) {
document.getElementById('name').value = item.product_name;
document.getElementById('desc').value = item.description;
document.getElementById('qty').value = 1;
document.getElementById('price').value = item.price;
setTomSelectValue(document.getElementById('tax'), item.tax);
document.getElementById('product_id').value = item.prod_id;
}
});
// Typing over the name by hand breaks the link to the product
nameInput.addEventListener('input', function () {
document.getElementById('product_id').value = 0;
});
}); });
</script> </script>
<script src="../libs/SortableJS/Sortable.min.js"></script> <script src="../libs/SortableJS/Sortable.min.js"></script>

View File

@@ -117,14 +117,7 @@ if (isset($_GET['recurring_invoice_id'])) {
$sql_history = mysqli_query($mysqli, "SELECT history_created_at, history_description, history_status FROM history WHERE history_recurring_invoice_id = $recurring_invoice_id ORDER BY history_id DESC"); $sql_history = mysqli_query($mysqli, "SELECT history_created_at, history_description, history_status FROM history WHERE history_recurring_invoice_id = $recurring_invoice_id ORDER BY history_id DESC");
//Product autocomplete //Product autocomplete
$products_sql = mysqli_query($mysqli, "SELECT product_name AS label, product_description AS description, product_price AS price, product_tax_id AS tax FROM products WHERE product_archived_at IS NULL"); $json_products = getProductsForAutocomplete($mysqli);
if (mysqli_num_rows($products_sql) > 0) {
while ($row = mysqli_fetch_assoc($products_sql)) {
$products[] = $row;
}
$json_products = json_encode($products);
}
enforceClientAccess(); enforceClientAccess();
@@ -340,6 +333,7 @@ if (isset($_GET['recurring_invoice_id'])) {
<form action="post.php" method="post"> <form action="post.php" method="post">
<input type="hidden" name="csrf_token" value="<?= $_SESSION['csrf_token'] ?>"> <input type="hidden" name="csrf_token" value="<?= $_SESSION['csrf_token'] ?>">
<input type="hidden" name="recurring_invoice_id" value="<?= $recurring_invoice_id ?>"> <input type="hidden" name="recurring_invoice_id" value="<?= $recurring_invoice_id ?>">
<input type="hidden" id="product_id" name="product_id" value="0">
<input type="hidden" name="item_order" value="<?php <input type="hidden" name="item_order" value="<?php
//find largest order number and add 1 //find largest order number and add 1
$sql = mysqli_query($mysqli, "SELECT MAX(item_order) AS item_order FROM recurring_invoice_items WHERE item_recurring_invoice_id = $recurring_invoice_id"); $sql = mysqli_query($mysqli, "SELECT MAX(item_order) AS item_order FROM recurring_invoice_items WHERE item_recurring_invoice_id = $recurring_invoice_id");
@@ -361,7 +355,7 @@ if (isset($_GET['recurring_invoice_id'])) {
<input type="text" inputmode="decimal" pattern="[0-9]*\.?[0-9]{0,2}" class="form-control" style="text-align: right;" id="price" name="price" placeholder="Price (<?= $recurring_invoice_currency_code ?>)"> <input type="text" inputmode="decimal" pattern="[0-9]*\.?[0-9]{0,2}" class="form-control" style="text-align: right;" id="price" name="price" placeholder="Price (<?= $recurring_invoice_currency_code ?>)">
</td> </td>
<td> <td>
<select class="form-select" name="tax_id" id="tax" required> <select class="form-select select2" name="tax_id" id="tax" required>
<option value="0">No Tax</option> <option value="0">No Tax</option>
<?php <?php
@@ -506,67 +500,12 @@ require_once "../includes/footer.php";
?> ?>
<!-- JSON Autocomplete / type ahead --> <!-- Product autocomplete for the add-item row -->
<script src="/js/product_autocomplete.js"></script>
<script> <script>
document.addEventListener('DOMContentLoaded', function () { document.addEventListener('DOMContentLoaded', function () {
initProductAutocomplete(<?= $json_products ?? '[]' ?>);
var availableProducts = <?= $json_products ?? '[]' ?>;
var nameInput = document.getElementById('name');
if (!nameInput) {
return;
}
itflowAutocomplete(nameInput, {
minLength: 1,
source: availableProducts,
match: function (item, term) {
return String(item.label || '').toLowerCase().indexOf(term) !== -1
|| String(item.product_name || '').toLowerCase().indexOf(term) !== -1
|| String(item.product_code || '').toLowerCase().indexOf(term) !== -1;
},
render: function (item) {
var esc = itflowEscapeHtml;
var typeText = item.type ? item.type.charAt(0).toUpperCase() + item.type.slice(1).toLowerCase() : "";
var showStock = (typeText.toLowerCase() !== "service");
var taxText = (item.tax_percent != null) ? (parseFloat(item.tax_percent) + "%") : "No tax";
var priceText = (item.price != null && item.price !== "") ? String(item.price) : "";
var stockText = (item.available_stock ?? 0);
return "<div class='d-flex justify-content-between align-items-start'>" +
"<div class='flex-fill pe-2'>" +
"<div class='fw-bold'>" + esc(item.label) +
(typeText ? " <small class='text-muted'>(" + esc(typeText) + ")</small>" : "") +
"</div>" +
"<div class='small text-muted'>" + esc(item.description) + "</div>" +
"<div class='mt-1'>" +
"<span class='badge bg-secondary me-1'>Tax: " + esc(taxText) + "</span>" +
(showStock ? "<span class='badge " + (stockText > 0 ? "bg-success" : "bg-danger") + "'>Stock: " + esc(stockText) + "</span>" : "") +
"</div>" +
"</div>" +
"<div class='text-end'>" +
"<div class='fw-bold'>" + esc(priceText) + "</div>" +
"</div>" +
"</div>";
},
onSelect: function (item) {
document.getElementById('name').value = item.product_name;
document.getElementById('desc').value = item.description;
document.getElementById('qty').value = 1;
document.getElementById('price').value = item.price;
setTomSelectValue(document.getElementById('tax'), item.tax);
document.getElementById('product_id').value = item.prod_id;
}
});
// Typing over the name by hand breaks the link to the product
nameInput.addEventListener('input', function () {
document.getElementById('product_id').value = 0;
});
}); });
</script> </script>
<script src="../libs/SortableJS/Sortable.min.js"></script> <script src="../libs/SortableJS/Sortable.min.js"></script>

View File

@@ -86,21 +86,19 @@ $sql_tax = mysqli_query($mysqli, "SELECT `tax_name` FROM `taxes`");
if ($view == 'monthly') { if ($view == 'monthly') {
// Row total = sum of this taxs 12 months, accumulated as we go
$row_total = 0.0;
for ($i = 1; $i <= 12; $i++) { for ($i = 1; $i <= 12; $i++) {
$monthly_tax = (float) getMonthlyTax($tax_name, $i, $year, $mysqli); $monthly_tax = (float) getMonthlyTax($tax_name, $i, $year, $mysqli);
// Accumulate totals // Accumulate totals
$monthly_totals[$i] += $monthly_tax; $monthly_totals[$i] += $monthly_tax;
$grand_total += $monthly_tax; $grand_total += $monthly_tax;
$row_total += $monthly_tax;
echo "<td class='text-end'>" . numfmt_format_currency($currency_format, $monthly_tax, $company_currency) . "</td>"; echo "<td class='text-end'>" . numfmt_format_currency($currency_format, $monthly_tax, $company_currency) . "</td>";
} }
// Row total = sum of this taxs 12 months
$row_total = 0.0;
for ($i = 1; $i <= 12; $i++) {
$row_total += (float) getMonthlyTax($tax_name, $i, $year, $mysqli);
}
echo "<td class='text-end text-bold'>" . numfmt_format_currency($currency_format, $row_total, $company_currency) . "</td>"; echo "<td class='text-end text-bold'>" . numfmt_format_currency($currency_format, $row_total, $company_currency) . "</td>";
} else { } else {

View File

@@ -12,10 +12,10 @@ $contact_id = intval($_POST['contact_id']);
$delete_count = false; $delete_count = false;
if (!empty($contact_id)) { if (!empty($contact_id)) {
$row = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT contact_name FROM contacts WHERE contact_id = $contact_id AND contact_client_id = $client_id LIMIT 1")); $row = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT contact_name FROM contacts WHERE contact_id = $contact_id AND contact_client_id = $client_id AND contact_archived_at IS NOT NULL LIMIT 1"));
$contact_name = escapeSql($row['contact_name'] ?? ''); $contact_name = escapeSql($row['contact_name'] ?? '');
$delete_sql = mysqli_query($mysqli, "DELETE FROM contacts WHERE contact_id = $contact_id AND contact_client_id = $client_id LIMIT 1"); $delete_sql = mysqli_query($mysqli, "DELETE FROM contacts WHERE contact_id = $contact_id AND contact_client_id = $client_id AND contact_archived_at IS NOT NULL LIMIT 1");
// Check delete & get affected rows // Check delete & get affected rows
if ($delete_sql && !empty($contact_name)) { if ($delete_sql && !empty($contact_name)) {

View File

@@ -32,6 +32,11 @@ if (!empty($subject)) {
$contact = intval($row['contact_id']); $contact = intval($row['contact_id']);
} }
$ticket_status = 1; // Default
if ($assigned_to > 0) {
$ticket_status = 2; // Set to open if we've auto-assigned an agent
}
// Atomically increment and get the new ticket number // Atomically increment and get the new ticket number
mysqli_query($mysqli, " mysqli_query($mysqli, "
UPDATE settings UPDATE settings
@@ -45,7 +50,7 @@ if (!empty($subject)) {
// Insert ticket // Insert ticket
$url_key = randomString(32); $url_key = randomString(32);
$insert_sql = mysqli_query($mysqli,"INSERT INTO tickets SET ticket_prefix = '$config_ticket_prefix', ticket_number = $ticket_number, ticket_source = 'API', ticket_subject = '$subject', ticket_details = '$details', ticket_priority = '$priority', ticket_status = 1, ticket_billable = $billable, ticket_vendor_ticket_number = '$vendor_ticket_number', ticket_vendor_id = $vendor_id, ticket_created_by = 0, ticket_assigned_to = $assigned_to, ticket_contact_id = $contact, ticket_asset_id = $asset, ticket_url_key = '$url_key', ticket_client_id = $client_id"); $insert_sql = mysqli_query($mysqli,"INSERT INTO tickets SET ticket_prefix = '$config_ticket_prefix', ticket_number = $ticket_number, ticket_source = 'API', ticket_subject = '$subject', ticket_details = '$details', ticket_priority = '$priority', ticket_status = $ticket_status, ticket_billable = $billable, ticket_vendor_ticket_number = '$vendor_ticket_number', ticket_vendor_id = $vendor_id, ticket_created_by = 0, ticket_assigned_to = $assigned_to, ticket_contact_id = $contact, ticket_asset_id = $asset, ticket_url_key = '$url_key', ticket_client_id = $client_id");
// Check insert & get insert ID // Check insert & get insert ID
if ($insert_sql) { if ($insert_sql) {

View File

@@ -443,7 +443,8 @@ if (mysqli_num_rows($sql_recurring_tickets) > 0) {
$email_subject = "Ticket created - [$ticket_prefix$ticket_number] - $ticket_subject (scheduled)"; $email_subject = "Ticket created - [$ticket_prefix$ticket_number] - $ticket_subject (scheduled)";
// SLA response commitment for this client + priority, empty when no SLA applies // SLA response commitment for this client + priority, empty when no SLA applies
$sla_notice = escapeSql(getTicketSlaEmailNotice($id, $company_phone)); $sla_notice = escapeSql(getTicketSlaEmailNotice($id, $company_phone));
$email_body = "<i style=\'color: #808080\'>##- Please type your reply above this line -##</i><br><br>Hello $contact_name,<br><br>A ticket regarding \"$ticket_subject\" has been automatically created for you.<br><br>--------------------------------<br>$ticket_details--------------------------------<br><br>Ticket: $ticket_prefix$ticket_number<br>Subject: $ticket_subject<br>Status: Open<br>Portal: https://$config_base_url/client/ticket.php?id=$id$sla_notice<br><br>--<br>$company_name - Support<br>$config_ticket_from_email<br>$company_phone"; $ticket_status_name = escapeSql(getTicketStatusName($ticket_status));
$email_body = "<i style=\'color: #808080\'>##- Please type your reply above this line -##</i><br><br>Hello $contact_name,<br><br>A ticket regarding \"$ticket_subject\" has been automatically created for you.<br><br>--------------------------------<br>$ticket_details--------------------------------<br><br>Ticket: $ticket_prefix$ticket_number<br>Subject: $ticket_subject<br>Status: $ticket_status_name<br>Portal: https://$config_base_url/client/ticket.php?id=$id$sla_notice<br><br>--<br>$company_name - Support<br>$config_ticket_from_email<br>$company_phone";
$email = [ $email = [
'from' => $config_ticket_from_email, 'from' => $config_ticket_from_email,

View File

@@ -644,12 +644,20 @@ function checkForUpdates() {
} }
function getMonthlyTax($tax_name, $month, $year, $mysqli) { function getMonthlyTax($tax_name, $month, $year, $mysqli) {
// SQL to calculate monthly tax // Cash basis - tax is booked to the month the money arrived, in proportion to
$sql = "SELECT SUM(item_tax) AS monthly_tax FROM invoice_items // how much of the invoice that payment covered. Driving off payments (rather
LEFT JOIN invoices ON invoice_items.item_invoice_id = invoices.invoice_id // than invoice_items) counts each payment exactly once, and pre-aggregating
LEFT JOIN payments ON invoices.invoice_id = payments.payment_invoice_id // the line items stops a multi-payment invoice multiplying its own tax.
$sql = "SELECT SUM(invoice_tax.tax_total * (payments.payment_amount / invoices.invoice_amount)) AS monthly_tax
FROM payments
INNER JOIN invoices ON invoices.invoice_id = payments.payment_invoice_id
INNER JOIN (SELECT item_invoice_id, SUM(item_tax) AS tax_total
FROM invoice_items
WHERE item_tax_id = (SELECT tax_id FROM taxes WHERE tax_name = '$tax_name')
GROUP BY item_invoice_id) AS invoice_tax
ON invoice_tax.item_invoice_id = invoices.invoice_id
WHERE YEAR(payments.payment_date) = $year AND MONTH(payments.payment_date) = $month WHERE YEAR(payments.payment_date) = $year AND MONTH(payments.payment_date) = $month
AND invoice_items.item_tax_id = (SELECT tax_id FROM taxes WHERE tax_name = '$tax_name')"; AND invoices.invoice_amount > 0";
$result = mysqli_query($mysqli, $sql); $result = mysqli_query($mysqli, $sql);
$row = mysqli_fetch_assoc($result); $row = mysqli_fetch_assoc($result);
return $row['monthly_tax'] ?? 0; return $row['monthly_tax'] ?? 0;
@@ -660,12 +668,17 @@ function getQuarterlyTax($tax_name, $quarter, $year, $mysqli) {
$start_month = ($quarter - 1) * 3 + 1; $start_month = ($quarter - 1) * 3 + 1;
$end_month = $start_month + 2; $end_month = $start_month + 2;
// SQL to calculate quarterly tax // SQL to calculate quarterly tax - see getMonthlyTax for why it is shaped this way
$sql = "SELECT SUM(item_tax) AS quarterly_tax FROM invoice_items $sql = "SELECT SUM(invoice_tax.tax_total * (payments.payment_amount / invoices.invoice_amount)) AS quarterly_tax
LEFT JOIN invoices ON invoice_items.item_invoice_id = invoices.invoice_id FROM payments
LEFT JOIN payments ON invoices.invoice_id = payments.payment_invoice_id INNER JOIN invoices ON invoices.invoice_id = payments.payment_invoice_id
INNER JOIN (SELECT item_invoice_id, SUM(item_tax) AS tax_total
FROM invoice_items
WHERE item_tax_id = (SELECT tax_id FROM taxes WHERE tax_name = '$tax_name')
GROUP BY item_invoice_id) AS invoice_tax
ON invoice_tax.item_invoice_id = invoices.invoice_id
WHERE YEAR(payments.payment_date) = $year AND MONTH(payments.payment_date) BETWEEN $start_month AND $end_month WHERE YEAR(payments.payment_date) = $year AND MONTH(payments.payment_date) BETWEEN $start_month AND $end_month
AND invoice_items.item_tax_id = (SELECT tax_id FROM taxes WHERE tax_name = '$tax_name')"; AND invoices.invoice_amount > 0";
$result = mysqli_query($mysqli, $sql); $result = mysqli_query($mysqli, $sql);
$row = mysqli_fetch_assoc($result); $row = mysqli_fetch_assoc($result);
return $row['quarterly_tax'] ?? 0; return $row['quarterly_tax'] ?? 0;
@@ -858,3 +871,46 @@ function getSentMethods() {
'Other' 'Other'
]; ];
} }
/*
* Products for the line-item autocomplete on invoices, quotes and recurring
* invoices.
*
* All three pages share js/product_autocomplete.js, so they must all be handed
* the same shape. They used to carry a SELECT each and they drifted: quote and
* recurring invoice only selected label/description/price/tax, so the shared
* onSelect wrote item.product_name - undefined - into the item name field.
*
* Returns a JSON string ready to emit into the page.
*/
function getProductsForAutocomplete($mysqli): string
{
$products = [];
$sql = mysqli_query($mysqli, "
SELECT
IF(product_code IS NULL OR product_code = '', product_name, CONCAT(product_code, ' - ', product_name)) AS label,
product_name,
product_code,
product_type AS type,
product_description AS description,
product_price AS price,
product_tax_id AS tax,
tax_percent,
product_id AS prod_id,
COALESCE(SUM(product_stock.stock_qty), 0) AS available_stock
FROM products
LEFT JOIN product_stock ON product_id = stock_product_id
LEFT JOIN taxes ON product_tax_id = tax_id
WHERE product_archived_at IS NULL
GROUP BY product_id
ORDER BY product_name ASC
");
while ($row = mysqli_fetch_assoc($sql)) {
$products[] = $row;
}
return json_encode($products) ?: '[]';
}

View File

@@ -5,4 +5,4 @@
* Update this file each time we merge develop into master. Format is YY.MM (add a .v if there is more than one release a month. * Update this file each time we merge develop into master. Format is YY.MM (add a .v if there is more than one release a month.
*/ */
DEFINE("APP_VERSION", "26.09"); DEFINE("APP_VERSION", "26.09.3");

View File

@@ -568,15 +568,26 @@ function itflowInit() {
el.dataset.imaskReady = '1'; el.dataset.imaskReady = '1';
var spec = el.getAttribute('data-inputmask') || ''; var spec = el.getAttribute('data-inputmask') || '';
if (spec.indexOf('ip') !== -1) { if (spec.indexOf('ip') !== -1) {
// A pattern mask built from MaskedRange blocks will not advance past the dot
// until the block reaches its maxLength, so 10.0.0.1 had to be entered as
// 010.000.000.001. A regex mask has no per-block completeness rule - it tests
// the whole value on every keystroke, so partial input like "10.0." is valid on
// its own. Octets are still bounded to 0-255 and leading zeros are still
// accepted, matching what jquery.inputmask's 'ip' alias allowed.
//
// interface_ip / asset_ip are varchar(200) and hold free text as well - 'DHCP'
// is written there by the checkbox on these same modals. Masking a value like
// that would strip it to nothing the moment the modal opened, so anything not
// made of digits and dots is left unmasked instead.
if (el.value && !/^[\d.]*$/.test(el.value)) {
return;
}
var octet = '(25[0-5]|2[0-4]\\d|[01]?\\d\\d?)';
var octet4 = octet + '?';
var octet3 = '(' + octet + '(\\.' + octet4 + ')?)?';
var octet2 = '(' + octet + '(\\.' + octet3 + ')?)?';
IMask(el, { IMask(el, {
mask: 'a.b.c.d', mask: new RegExp('^(' + octet + '(\\.' + octet2 + ')?)?$')
blocks: {
a: { mask: IMask.MaskedRange, from: 0, to: 255 },
b: { mask: IMask.MaskedRange, from: 0, to: 255 },
c: { mask: IMask.MaskedRange, from: 0, to: 255 },
d: { mask: IMask.MaskedRange, from: 0, to: 255 }
},
lazy: true
}); });
} else if (spec.indexOf('mac') !== -1) { } else if (spec.indexOf('mac') !== -1) {
IMask(el, { IMask(el, {

View File

@@ -0,0 +1,76 @@
/*
* Product autocomplete for the add-item row on invoices, quotes and recurring
* invoices.
*
* The three pages used to carry a copy of this each, and they drifted - the
* quote and recurring invoice copies were fed a four column product query, so
* item.product_name was undefined and selecting a product wrote the literal
* string "undefined" into the item name.
*
* Call with the array emitted by getProductsForAutocomplete():
*
* initProductAutocomplete(<?= $json_products ?? '[]' ?>);
*/
function initProductAutocomplete(availableProducts) {
var nameInput = document.getElementById('name');
if (!nameInput) {
return;
}
// Quote and recurring invoice do not store the product link yet, so treat
// the hidden input as optional rather than throwing on every keystroke.
var productIdInput = document.getElementById('product_id');
itflowAutocomplete(nameInput, {
minLength: 1,
source: availableProducts || [],
match: function (item, term) {
return String(item.label || '').toLowerCase().indexOf(term) !== -1
|| String(item.product_name || '').toLowerCase().indexOf(term) !== -1
|| String(item.product_code || '').toLowerCase().indexOf(term) !== -1;
},
render: function (item) {
var esc = itflowEscapeHtml;
var typeText = item.type ? item.type.charAt(0).toUpperCase() + item.type.slice(1).toLowerCase() : "";
var showStock = (typeText.toLowerCase() !== "service");
var taxText = (item.tax_percent != null) ? (parseFloat(item.tax_percent) + "%") : "No tax";
var priceText = (item.price != null && item.price !== "") ? String(item.price) : "";
var stockText = (item.available_stock ?? 0);
return "<div class='d-flex justify-content-between align-items-start'>" +
"<div class='flex-fill pe-2'>" +
"<div class='fw-bold'>" + esc(item.label) +
(typeText ? " <small class='text-muted'>(" + esc(typeText) + ")</small>" : "") +
"</div>" +
"<div class='small text-muted'>" + esc(item.description) + "</div>" +
"<div class='mt-1'>" +
"<span class='badge bg-secondary me-1'>Tax: " + esc(taxText) + "</span>" +
(showStock ? "<span class='badge " + (stockText > 0 ? "bg-success" : "bg-danger") + "'>Stock: " + esc(stockText) + "</span>" : "") +
"</div>" +
"</div>" +
"<div class='text-end'>" +
"<div class='fw-bold'>" + esc(priceText) + "</div>" +
"</div>" +
"</div>";
},
onSelect: function (item) {
nameInput.value = item.product_name;
document.getElementById('desc').value = item.description;
document.getElementById('qty').value = 1;
document.getElementById('price').value = item.price;
setTomSelectValue(document.getElementById('tax'), item.tax);
if (productIdInput) {
productIdInput.value = item.prod_id;
}
}
});
// Typing over the name by hand breaks the link to the product
if (productIdInput) {
nameInput.addEventListener('input', function () {
productIdInput.value = 0;
});
}
}