diff --git a/CHANGELOG.md b/CHANGELOG.md index a794d9d0..e66bdd95 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/agent/income.php b/agent/income.php index e2633193..95c5b4b0 100644 --- a/agent/income.php +++ b/agent/income.php @@ -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']); @@ -194,6 +211,29 @@ $summary_total_income = floatval($row['total_income']); +
+
+ +
+
@@ -244,7 +284,7 @@ $summary_total_income = floatval($row['total_income']);
- +
@@ -317,6 +357,11 @@ $summary_total_income = floatval($row['total_income']); Source + + + Category + + @@ -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']); - + + diff --git a/agent/modals/income/income_export.php b/agent/modals/income/income_export.php index 8ef75c9a..2670cebd 100644 --- a/agent/modals/income/income_export.php +++ b/agent/modals/income/income_export.php @@ -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();
- +
@@ -95,6 +102,34 @@ ob_start(); +
+ +
+
+ +
+ +
+
+
diff --git a/agent/post/income.php b/agent/post/income.php index 74eea686..cc1900e2 100644 --- a/agent/post/income.php +++ b/agent/post/income.php @@ -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); diff --git a/functions/export.php b/functions/export.php index 617c59c0..d80739f2 100644 --- a/functions/export.php +++ b/functions/export.php @@ -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'],