Add Category column and filter into income along with the exports

This commit is contained in:
johnnyq
2026-08-02 18:23:18 -04:00
parent 6b5be0f881
commit 315768d651
5 changed files with 138 additions and 13 deletions

View File

@@ -73,6 +73,9 @@ Only this release needs the command line for the database update. Normal updates
- Update page: **Force Update no longer resets to `origin/master` regardless of the branch you track.** On an install following any other branch it silently moved the files onto master and discarded the code that was actually running. It now resets to the branch in `$repo_branch`, and so does `update_cli.php --force_update`. Installs with no `$repo_branch` in `config.php` still get master, as before.
- Update page: the database update is now offered using a proper version comparison. The old string comparison would have stopped offering updates once a version reached a two-digit part — `2.6.10` sorts below `2.6.9` as plain text.
- Update page: Git output — commit subjects and error text — is escaped before it reaches the page, and the branch name is escaped before it reaches a shell command. The page also no longer runs a second `git fetch` just to read the error message from the first one.
- Income: added a Category filter and a Category column. A revenue carries its own category and a payment inherits the one on the invoice it was paid against, so both are categorised. Only categories actually in use are listed. Category is searchable and sortable, and it carries through to the export.
- Income: the Source column now shows what the money was for. A revenue's category was being displayed there, which meant the revenue description never appeared on the page at all even though it is captured on the add and edit forms. Category has its own column now, so both are visible.
- Income: fixed a PHP warning on every page load without a date range, and the export's filter summary, which was never populated - a filtered PDF came out looking like a full export.
- Cron: the nightly run is safe to repeat. Late fees, overdue invoice reminders and autopay retries now apply at most once per invoice per day, so a Run Now after the scheduled pass no longer stacks fees or re-emails clients. Nightly Tasks only accepts the daily schedule.
- Ticket SLAs, optional throughout. An SLA sets a response target and an optional resolution target, assigned per client and priority with a global default and an explicit "no SLA" override. Targets are measured against your business hours. Tickets show time remaining and turn yellow at a configurable warning threshold and red on breach, on both the ticket list and the kanban board, and can be filtered by SLA state. Nominated statuses pause the resolution clock for "waiting on customer", preserving the remaining budget. Two new reports, SLA Summary and SLA by Client. With no assignments defined nothing behaves any differently.
- Ticket: added an Urgent priority.

View File

@@ -41,6 +41,17 @@ if (isset($_GET['account']) && !empty($_GET['account'])) {
$account_filter = '';
}
// Category Filter - a revenue carries its own category, a payment inherits the one on the
// invoice it was paid against. Both come from the same 'Income' category pool.
if (isset($_GET['category']) && !empty($_GET['category'])) {
$category_query = 'AND (income_category_id = ' . intval($_GET['category']) . ')';
$category_filter = intval($_GET['category']);
} else {
// Default - any
$category_query = '';
$category_filter = '';
}
// Payment Method Filter
if (isset($_GET['method']) && !empty($_GET['method'])) {
$method_query = "AND (income_method = '" . escapeSql($_GET['method']) . "')";
@@ -62,6 +73,8 @@ $income_query =
payment_created_at AS income_created_at,
payment_invoice_id AS income_invoice_id,
CONCAT(invoice_prefix, invoice_number) AS income_source,
category_name AS income_category,
IFNULL(invoice_category_id, 0) AS income_category_id,
invoice_client_id AS income_client_id,
client_name AS income_client,
payment_amount AS income_amount,
@@ -75,6 +88,7 @@ $income_query =
LEFT JOIN invoices ON payment_invoice_id = invoice_id
LEFT JOIN clients ON invoice_client_id = client_id
LEFT JOIN accounts ON payment_account_id = account_id
LEFT JOIN categories ON invoice_category_id = category_id
WHERE payment_archived_at IS NULL
$payment_client_query
$access_permission_query
@@ -87,7 +101,9 @@ $income_query =
revenue_date,
revenue_created_at,
0,
revenue_description,
category_name,
revenue_category_id,
revenue_client_id,
client_name,
revenue_amount,
@@ -108,8 +124,9 @@ $income_query =
$income_filter_query =
"WHERE DATE(income_date) BETWEEN '$dtf' AND '$dtt'
AND (income_source LIKE '%$q%' OR income_client LIKE '%$q%' OR income_account LIKE '%$q%' OR income_method LIKE '%$q%' OR income_reference LIKE '%$q%' OR income_amount LIKE '%$q%')
AND (income_source LIKE '%$q%' OR income_category LIKE '%$q%' OR income_client LIKE '%$q%' OR income_account LIKE '%$q%' OR income_method LIKE '%$q%' OR income_reference LIKE '%$q%' OR income_amount LIKE '%$q%')
$type_query
$category_query
$account_query
$method_query";
@@ -155,14 +172,14 @@ $summary_total_income = floatval($row['total_income']);
<button type="button" class="btn btn-primary dropdown-toggle dropdown-toggle-split" data-toggle="dropdown"></button>
<div class="dropdown-menu">
<a class="dropdown-item text-dark ajax-modal" href="#"
data-modal-url="modals/income/income_export.php?<?= $client_url ?>type=<?= urlencode($type_filter) ?>&account=<?= $account_filter ?>&method=<?= urlencode($_GET['method'] ?? '') ?>&dtf=<?= $dtf ?>&dtt=<?= $dtt ?>&q=<?= urlencode($q ?? '') ?>">
data-modal-url="modals/income/income_export.php?<?= $client_url ?>type=<?= urlencode($type_filter) ?>&category=<?= $category_filter ?>&account=<?= $account_filter ?>&method=<?= urlencode($_GET['method'] ?? '') ?>&dtf=<?= $dtf ?>&dtt=<?= $dtt ?>&q=<?= urlencode($q ?? '') ?>">
<i class="fa fa-fw fa-download mr-2"></i>Export
</a>
</div>
</div>
<?php } else { ?>
<button type="button" class="btn btn-default ajax-modal"
data-modal-url="modals/income/income_export.php?<?= $client_url ?>type=<?= urlencode($type_filter) ?>&account=<?= $account_filter ?>&method=<?= urlencode($_GET['method'] ?? '') ?>&dtf=<?= $dtf ?>&dtt=<?= $dtt ?>&q=<?= urlencode($q ?? '') ?>">
data-modal-url="modals/income/income_export.php?<?= $client_url ?>type=<?= urlencode($type_filter) ?>&category=<?= $category_filter ?>&account=<?= $account_filter ?>&method=<?= urlencode($_GET['method'] ?? '') ?>&dtf=<?= $dtf ?>&dtt=<?= $dtt ?>&q=<?= urlencode($q ?? '') ?>">
<i class="fa fa-fw fa-download mr-2"></i>Export
</button>
<?php } ?>
@@ -194,6 +211,29 @@ $summary_total_income = floatval($row['total_income']);
</select>
</div>
</div>
<div class="col-md-2">
<div class="input-group mb-3 mb-sm-0">
<select class="form-control select2" name="category" onchange="this.form.submit()">
<option value="">- All Categories -</option>
<?php
$sql_categories_filter = mysqli_query($mysqli, "SELECT category_id, category_name FROM categories
WHERE category_type = 'Income'
AND (EXISTS (SELECT 1 FROM revenues WHERE revenue_category_id = category_id)
OR EXISTS (SELECT 1 FROM payments JOIN invoices ON payment_invoice_id = invoice_id WHERE invoice_category_id = category_id))
ORDER BY category_name ASC");
while ($row = mysqli_fetch_assoc($sql_categories_filter)) {
$filter_category_id = intval($row['category_id']);
$filter_category_name = escapeHtml($row['category_name']);
?>
<option <?php if ($category_filter == $filter_category_id) { echo "selected"; } ?> value="<?= $filter_category_id ?>"><?= $filter_category_name ?></option>
<?php
}
?>
</select>
</div>
</div>
<div class="col-md-2">
<div class="input-group mb-3 mb-sm-0">
<select class="form-control select2" name="account" onchange="this.form.submit()">
@@ -217,7 +257,7 @@ $summary_total_income = floatval($row['total_income']);
</div>
</div>
<div class="col-sm-2">
<div class="col-md-2">
<div class="input-group">
<select class="form-control select2" name="method" onchange="this.form.submit()">
<option value="">- All Payment Methods -</option>
@@ -244,7 +284,7 @@ $summary_total_income = floatval($row['total_income']);
<div class="form-group">
<label>Date range</label>
<input type="text" id="dateFilter" class="form-control" autocomplete="off">
<input type="hidden" name="canned_date" id="canned_date" value="<?= escapeHtml($_GET['canned_date']) ?? '' ?>">
<input type="hidden" name="canned_date" id="canned_date" value="<?= escapeHtml($_GET['canned_date'] ?? '') ?>">
<input type="hidden" name="dtf" id="dtf" value="<?= escapeHtml($dtf ?? '') ?>">
<input type="hidden" name="dtt" id="dtt" value="<?= escapeHtml($dtt ?? '') ?>">
</div>
@@ -317,6 +357,11 @@ $summary_total_income = floatval($row['total_income']);
Source <?php if ($sort == 'income_source') { echo $order_icon; } ?>
</a>
</th>
<th>
<a class="text-dark" href="?<?= $url_query_strings_sort ?>&sort=income_category&order=<?= $disp ?>">
Category <?php if ($sort == 'income_category') { echo $order_icon; } ?>
</a>
</th>
<?php if (!$client_url) { ?>
<th>
<a class="text-dark" href="?<?= $url_query_strings_sort ?>&sort=income_client&order=<?= $disp ?>">
@@ -356,6 +401,7 @@ $summary_total_income = floatval($row['total_income']);
$income_date = escapeHtml($row['income_date']);
$income_invoice_id = intval($row['income_invoice_id']);
$income_source = escapeHtml($row['income_source']);
$income_category = escapeHtml($row['income_category']);
$income_client_id = intval($row['income_client_id']);
$income_client = escapeHtml($row['income_client']);
$income_amount = floatval($row['income_amount']);
@@ -403,9 +449,10 @@ $summary_total_income = floatval($row['total_income']);
<?= $income_source ?>
</a>
<?php } else { ?>
<?= $income_source ?>
<?= $income_source === '' ? '-' : $income_source ?>
<?php } ?>
</td>
<td><?= $income_category === '' ? '-' : $income_category ?></td>
<?php if (!$client_url) { ?>
<td>
<?php if ($income_client_id) { ?>

View File

@@ -15,6 +15,13 @@ if (isset($_GET['type']) && !empty($_GET['type']) && in_array($_GET['type'], $in
$type_filter = '';
}
// Category Filter
if (isset($_GET['category']) && !empty($_GET['category'])) {
$category_filter = intval($_GET['category']);
} else {
$category_filter = '';
}
// Account Filter
if (isset($_GET['account']) && !empty($_GET['account'])) {
$account_filter = intval($_GET['account']);
@@ -74,7 +81,7 @@ ob_start();
<div class="input-group-prepend">
<span class="input-group-text"><i class="fa fa-fw fa-search"></i></span>
</div>
<input type="text" class="form-control" name="q" value="<?= stripslashes(escapeHtml($q_filter)) ?>" placeholder="Source, client, account, method, reference or amount">
<input type="text" class="form-control" name="q" value="<?= stripslashes(escapeHtml($q_filter)) ?>" placeholder="Source, category, client, account, method, reference or amount">
</div>
</div>
@@ -95,6 +102,34 @@ ob_start();
</div>
</div>
<div class="form-group">
<label>Category</label>
<div class="input-group">
<div class="input-group-prepend">
<span class="input-group-text"><i class="fa fa-fw fa-tag"></i></span>
</div>
<select class="form-control select2" name="category">
<option value="">- All Categories -</option>
<?php
$sql_categories_filter = mysqli_query($mysqli, "SELECT category_id, category_name FROM categories
WHERE category_type = 'Income'
AND (EXISTS (SELECT 1 FROM revenues WHERE revenue_category_id = category_id)
OR EXISTS (SELECT 1 FROM payments JOIN invoices ON payment_invoice_id = invoice_id WHERE invoice_category_id = category_id))
ORDER BY category_name ASC");
while ($row = mysqli_fetch_assoc($sql_categories_filter)) {
$filter_category_id = intval($row['category_id']);
$filter_category_name = escapeHtml($row['category_name']);
?>
<option <?php if ($category_filter == $filter_category_id) { echo "selected"; } ?> value="<?= $filter_category_id ?>"><?= $filter_category_name ?></option>
<?php
}
?>
</select>
</div>
</div>
<div class="form-group">
<label>Account</label>
<div class="input-group">

View File

@@ -53,6 +53,16 @@ if (isset($_POST['export_income'])) {
$account_query = '';
}
// Category Filter - a revenue carries its own category, a payment inherits the one on the
// invoice it was paid against. Both come from the same 'Income' category pool.
$category = intval($_POST['category'] ?? 0);
if ($category) {
$category_query = "AND (income_category_id = $category)";
} else {
// Default - any
$category_query = '';
}
// Payment Method Filter
if (!empty($_POST['method'])) {
$method_query = "AND (income_method = '" . escapeSql($_POST['method']) . "')";
@@ -64,7 +74,7 @@ if (isset($_POST['export_income'])) {
// Search Filter - mirrors the income page search box
$q = escapeSql($_POST['q']);
if (!empty($q)) {
$search_query = "AND (income_source LIKE '%$q%' OR income_client LIKE '%$q%' OR income_account LIKE '%$q%' OR income_method LIKE '%$q%' OR income_reference LIKE '%$q%' OR income_amount LIKE '%$q%')";
$search_query = "AND (income_source LIKE '%$q%' OR income_category LIKE '%$q%' OR income_client LIKE '%$q%' OR income_account LIKE '%$q%' OR income_method LIKE '%$q%' OR income_reference LIKE '%$q%' OR income_amount LIKE '%$q%')";
} else {
// Default - any
$search_query = '';
@@ -77,6 +87,32 @@ if (isset($_POST['export_income'])) {
$date_query = '';
}
// Filter summary for the export header. This handler was the only one not building it,
// so a filtered PDF came out looking like a full export.
$filter_summary = [];
if ($client_id) {
$filter_summary['Client'] = $client_name;
}
if (!empty($_POST['type']) && in_array($_POST['type'], $income_types_array)) {
$filter_summary['Type'] = $_POST['type'];
}
if ($category) {
$filter_summary['Category'] = getFieldById('categories', $category, 'category_name');
}
if ($account) {
$filter_summary['Account'] = getFieldById('accounts', $account, 'account_name');
}
if (!empty($_POST['method'])) {
$filter_summary['Payment Method'] = $_POST['method'];
}
if (!empty($date_from) && !empty($date_to)) {
$filter_summary['Date'] = "$date_from to $date_to";
}
if (!empty($_POST['q'])) {
$filter_summary['Search'] = $_POST['q'];
}
// Same union as income.php - payments applied to an invoice, and standalone revenues.
// Transfers between accounts are stored as a linked expense + revenue pair, so the revenue leg
// is excluded here (transfer_id IS NULL) - moving your own money is not income.
@@ -89,7 +125,8 @@ if (isset($_POST['export_income'])) {
payment_date AS income_date,
payment_created_at AS income_created_at,
CONCAT(invoice_prefix, invoice_number) AS income_source,
NULL AS income_description,
category_name AS income_category,
IFNULL(invoice_category_id, 0) AS income_category_id,
client_name AS income_client,
payment_amount AS income_amount,
payment_currency_code AS income_currency_code,
@@ -101,6 +138,7 @@ if (isset($_POST['export_income'])) {
LEFT JOIN invoices ON payment_invoice_id = invoice_id
LEFT JOIN clients ON invoice_client_id = client_id
LEFT JOIN accounts ON payment_account_id = account_id
LEFT JOIN categories ON invoice_category_id = category_id
WHERE payment_archived_at IS NULL
$payment_client_query
$access_permission_query
@@ -112,8 +150,9 @@ if (isset($_POST['export_income'])) {
revenue_id,
revenue_date,
revenue_created_at,
category_name,
revenue_description,
category_name,
revenue_category_id,
client_name,
revenue_amount,
revenue_currency_code,
@@ -133,6 +172,7 @@ if (isset($_POST['export_income'])) {
WHERE 1 = 1
$date_query
$type_query
$category_query
$account_query
$method_query
$search_query
@@ -144,7 +184,7 @@ if (isset($_POST['export_income'])) {
guardExportPdfRowCount($format, $num_rows);
$export = beginExport('income', $format, $file_name_prepend . 'Income', 'Income', summarizeExportFilters($filter_summary ?? []));
$export = beginExport('income', $format, $file_name_prepend . 'Income', 'Income', summarizeExportFilters($filter_summary));
while ($row = mysqli_fetch_assoc($sql)) {
addExportRow($export, $row);

View File

@@ -259,8 +259,8 @@ function getExportColumns($export_type) {
'income' => [
'income_date' => ['label' => 'Date'],
'income_type' => ['label' => 'Type'],
'income_source' => ['label' => 'Source'],
'income_description' => ['label' => 'Description', 'weight' => 3],
'income_source' => ['label' => 'Source', 'weight' => 3],
'income_category' => ['label' => 'Category'],
'income_client' => ['label' => 'Client', 'weight' => 2],
'income_amount' => ['label' => 'Amount', 'format' => 'money'],
'income_currency_code' => ['label' => 'Currency'],