12 Commits

18 changed files with 145 additions and 66 deletions

View File

@@ -2,6 +2,31 @@
This file documents all notable changes made to ITFlow. This file documents all notable changes made to ITFlow.
## [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

@@ -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

@@ -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;

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.2");

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, {