diff --git a/CHANGELOG.md b/CHANGELOG.md index 75104570a..960448bd9 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,9 +6,9 @@ This file documents all notable changes made to ITFlow. ### Upgrading to 26.11 -Update from Maintenance > Update — Queue Update hands the job to cron, which updates the files and applies this release's database changes (2.7.9 to 3.1.4) in the same pass. Nothing else is required. +Update from Maintenance > Update — Queue Update hands the job to cron, which updates the files and applies this release's database changes (2.7.9 to 3.1.5) in the same pass. Nothing else is required. -This is a big release, with thirty-five database steps in one go, so take a backup before you update. +This is a big release, with thirty-six database steps in one go, so take a backup before you update. The update also clears out data that deletes in older versions left behind, all of it pointing at something that no longer exists: @@ -35,6 +35,8 @@ Taxes keep their names and rates, now kept to four decimal places. No client has Tags keep their names, colors, icons and records. A color saved as a word, such as dark or primary, becomes the color it stands for, a short one such as #abc is written out in full, and a tag with no color, or one that isn't a color, turns grey, so its pills can be read (see Bug Fixes). An icon saved with `fas fa-` in front keeps just its name. A tag archived in an older version now counts as archived (see Breaking Changes, Tags). Licenses, services, networks, documents and tickets can now be tagged too, and start with no tags - the Tags pack in Maintenance > Starter Content adds a few for each. +Categories keep their names, colors, icons, order and records. A color saved as a word, such as lightblue or gray, becomes the same color written as #rrggbb, and a short one such as #abc is written out in full, so nothing looks different, but the ticket lists and the reports, which only read colors written that way, now show it (see Bug Fixes). A color that isn't one is taken off, and a category with no color stays without one. Interface types, asset statuses, software types, rack types and note types keep the order they are offered in. + If you are coming from 26.08 or earlier, the database update still has to be run from the command line one time, exactly as described under Upgrading to 26.09 below. ### Breaking Changes and Notes @@ -137,6 +139,11 @@ If you are coming from 26.08 or earlier, the database update still has to be run - A tag that a recurring invoice line counts users or devices by can't be deleted. Archive it, or merge it into another tag, which moves the line along with it. - Pills now have dark text on light colors such as yellow and light blue, where every pill had white text. - The search box at the top, and the license, service, network, ticket and Files searches, also match tag names, so a search can bring back records whose own details don't have the words in them. In the search box and on its full results page, the tag that matched shows next to each. +- Categories: + - Renaming a referral, asset status, interface type, software type, rack type, or contact or asset note type now renames it on every client, asset, interface, license, license template, rack or note that has it. Records keep these by name, so renaming one used to leave its records on the old name, which could no longer be picked. + - A category can only be deleted while nothing uses it - merge it into another, or archive it. Deleting one in use used to leave expenses, invoices and tickets with no category, and clients and assets with a name that could no longer be picked. Delete is now on current categories too, not only archived ones. + - A category's name is now unique within its kind, in any case: a second expense category called Travel can't be made, though an income category can be. Categories already named alike keep their names. + - Interface types and rack types are held to 50 characters, which is all an interface or a rack keeps. - Notifications now follow access too. An agent only sees notifications for clients and areas they can open, so someone without sales access no longer sees invoice and quote notifications. - Drop-downs: Tom Select is gone, replaced by ITFlow's own picker. It works the way you are used to: type to search, tick several in a multi-select, and type a new value in fields that allow it, such as ticket watchers. See Developer Updates if you have custom pages that used Tom Select. - Several small windows are gone because the change is now made where the value is shown: @@ -774,6 +781,19 @@ If you are coming from 26.08 or earlier, the database update still has to be run - Search finds records by their tags: searching VIP finds the clients, assets or tickets tagged VIP, with the tag next to each. A Tags group lists the tags that match, each in its color, opening its list filtered by it - or for a document tag, the search for it, as documents are listed one client at a time. - API: read the tags, one record's tags or the records with a tag, set a record's tags or add and take some off, and make a tag (admin keys only). Every record read now includes its tags. - Starter Content: the Tags pack has tags for licenses, services, networks, documents and tickets - We Resell, Managed, PCI, Runbook, Escalated and so on. Demo data tags its licenses, services, documents and tickets with them. +- **Categories** (rebuilt) + - Admin > Categories lists each kind - expense, income, referral, ticket, network interface, asset status, software type, rack type, and contact and asset note types - down the side with how many each has, and says under the heading what the kind is for. + - Each category shows as it looks on records - its icon, or a dot, in its color, and an asset status as its badge - with its description and what uses it, such as "42 expenses · 3 recurring expenses · 24 budget months" (and how many of those are archived). Each opens its list filtered by the category where there is one: expenses, recurring expenses, invoices, other income, products, tickets, recurring tickets, and clients by referral. + - Search by name or description, sort by name or by most used, and Current and Archived tabs. The kinds offered in an order of their own - interface types, asset statuses, software types, rack types and note types - list in that order first, and are dragged into it, or moved with Move up and Move down. + - Each category has Edit, Merge into..., Archive or Restore, and Delete while nothing uses it - otherwise Delete is greyed out and says what uses it. Tick several for Merge into one, Archive or Restore, and Delete, which keeps the ones in use and says which. + - The category window shows it as it will look while you change it: + - its name and a description, shown when you point at it + - a color from the swatches, any other from the color picker or typed as a hex value, or none - an asset status with none keeps its built-in color + - an icon from the grid, which you can search, any Font Awesome icon by its name, or none + - what uses it, and for a kind records keep by name, that renaming it renames it on them too + - Merge moves everything with the merged categories onto the one you keep, and the others are deleted: expenses, recurring expenses, budget months (a month budgeted under both becomes one line of their total), import rules, a payment provider's fees and the mileage setting, invoices, quotes, recurring invoices, other income, products, tickets, recurring tickets and canned responses - and for the kinds kept by name, the clients, assets, interfaces, licenses, license templates, racks and notes that have them. + - Names that records have but aren't categories - notes typed General, referrals entered before there was a list - are listed under the kind's categories with how many have each, linked to them where there is a list, and Add makes one a category. To fold one into another, add it, then merge it. + - Renaming or merging doesn't mark the records it changes as edited. ### Bug Fixes @@ -862,6 +882,9 @@ If you are coming from 26.08 or earlier, the database update still has to be run - Tags: the + beside the Tags field in record forms opened Admin's New Tag window, which reloaded the page and lost what had been typed into the form, and showed an error to anyone who wasn't an admin. - Tags: a tag saved with no color, or a color word such as dark, showed white text on no color at all. - Tags: the Archived button on Admin > Tags showed the same tags as without it, as nothing could be archived. +- Categories: editing a category whose color was saved as a word, such as lightblue, turned it black, as the color picker can't show words. The ticket list, a ticket's quick look and the recurring tickets list left those colors out, and the reports drew them in a color of their own. +- Categories: the icons of contact and asset note types didn't show in the type pickers, and making a category from the + beside a category picker left out its description. +- Admin > Settings > Defaults: Payment Method and Expense Payment Method listed the payment method categories that moved to Admin > Payment Methods in 2.3.1, so a new install had nothing to pick. They list Admin > Payment Methods now. - Messages, hints and window titles were reworded where they were unclear, and bulk windows say "1 asset" or "3 assets" correctly. ### Developer Updates @@ -1241,6 +1264,14 @@ If you are coming from 26.08 or earlier, the database update still has to be run - Global search: a group's `tags` (`[entity, id column]`) also matches its records' tag names and hands back each item's matching `tags` (`name`, `color`, `text`, `icon`); the new `tags` group (`module` null - it checks each kind's module in its own query) gives each item its `tag`. CSS: `.itflow-search-tag-icon`, `.itflow-search-item-text` and `.itflow-search-item-tags`. - API: `api/v1/tags/read.php` (`kind` - a kind's name, `software` or its type - `record_id`, `tag_id` with `tag_record_ids`, `tag_name`, `archived`, `limit`, `offset`), `update.php` (`kind`, `record_id`, `tags` as an array or comma-separated ids or names, `mode` `set`, `add` or `remove`, and `client_id` for a key limited to certain clients) and `create.php` (admin keys; `kind`, `name`, `description`, `color`, `icon`). `enforce_api_rbac.php` maps `tags` to `''`, so `tagApiKind()` checks the module of the kind asked for. `read_output.php` adds `tags` to each row when the endpoint sets `$api_tag_entity`, as the twelve kinds' reads now do; `credentials/read.php` adds them itself. - Seeds: `starterContentTags()` has types 8 to 12, and demo data tags its documents, services, licenses and tickets (`demoTicketTags()`). +- Categories (database 3.1.5): + - The migration writes every `category_color` as `#rrggbb` - short hex grows, an eight-digit one loses its alpha, the 148 CSS color words become their own hex, anything else NULL - and every `category_icon` as `fa-` and the name (`fas fa-`, markup and anything but `a-z0-9-` taken off, NULL when empty), leaving `category_updated_at` alone. No column changes; `setup/seed_data.php` writes hex colors. + - New `functions/categories.php`, loaded by `functions.php`: `categoryKinds()` - each `category_type` with `label`, `one`, `many`, `icon`, `about`, `by` (`id` or `name`), `ordered`, `max` (name length), and `uses` (`table`, `column`, `one`, `many`, `url` with `{id}` or `{name}`, `archived` and `updated` columns, and `text` for an id kept as text, `tickets.ticket_category` and `recurring_tickets.recurring_ticket_category`). Also `categoryKind()` (adds `kind`), `categoryKindPhrase()`, `categoryUseText()`, `CATEGORY_COLORS`, `CATEGORY_ICONS`, `categoryColor()` (`#rrggbb` or `''`), `categoryIcon()` (the bare name or `''`), `categoryNextColor()`, `categoryChip($row)`, `categoryRow()`, `categoryRows($kind)` (in picker order), `categoryNameKey()`, `categoryUsage($kind)` (id => `all`, `archived`, `uses`), `categoryLooseNames($kind)`, `categoryUsageFor()`, `categoryUsageHtml()`, `categoryUsageText()`, `categoryFromInput($in, $existing)` (`[$fields, $error]`: `type`, `name`, `description`, `color`, `icon`), `categorySetSql()`, `categoryCreate()` (last in the order), `categoryUpdate($row, $fields)` (renames a by-name kind on its records; returns how many), `categoryMoveRecords($config, $where_sql, $to_sql)` (keeps each record's `*_updated_at`), `categoryArchive()`, `categoryRestore()`, `categoryDeleteBlocker($row, $used)`, `categoryDelete($id, &$error)`, `categoryMerge($from_ids, $into_id)` (same kind; returns records moved), `categoryFoldBudget()` and `categoryReorder($kind, $ids)`. + - Admin: `admin/categories.php` is rebuilt (`category`, `q`, `sort` - `order`, `name` or `use` - and `archived`; no paging), with `admin/modals/category/category_form.php` shared by `category_add.php` (`category`, or none to pick the kind) and `category_edit.php` (`id`), the new `category_merge.php` (`category_ids[]`), and `js/category_form.js`. `admin/post/category.php`: `add_category` and `edit_category` (`category_id`, `type`, `name`, `description`, `color`, `icon`), GET `archive_category`, `restore_category`, `delete_category` and `move_category` (`direction` `up` or `down`), POST `merge_categories` (`category_ids[]`, `into_category_id`), `reorder_categories` (`category`, `category_ids[]`; answers JSON), and `bulk_archive_categories`, `bulk_restore_categories` and `bulk_delete_categories` (`category_ids[]`). Audit entries are Category Create, Edit, Archive, Restore, Delete and Merge, naming the kind. + - `agent/post/category.php` (what the + window posts to from an agent page) takes the same fields through `categoryFromInput()` and is admins only, like the window. Removed: `admin/post/category_model.php` and `agent/post/category_model.php`. + - `selectOptionColor()` turns an icon kept as `fa-name` into `fas fa-name`. + - `admin/settings_default.php` reads `paymentMethods()`, keeping a saved value that is no longer one. + - CSS: `.itflow-category`, `-mark`, `-name` and `-badge` (`categoryChip()`), `.itflow-category-swatch-none`, `.itflow-category-kinds` and `.itflow-category-grip`. - Empty states: every list page sets `$empty_state` before including `includes/filter_footer.php`, and `includes/inc_empty_state.php` draws it with `emptyStateHtml()` (`functions/format.php`) when the list has nothing - `thing`, `icon`, `title`, `text`, `actions` (`label`, `modal` or `href`, `size`, `icon`; the first is the main one), `archived`, `view`, `filtered`, `views`, `match_title`, `match_text` and `clear_url`. `emptyStateFiltered($views)` reads the GET string for a search or filter, leaving out paging, sorting, `client_id`, `archived` and the page's views, and `emptyStateClearUrl($views)` is the page without them. `emptyStatePill($pills, $key, $default, $good)` builds a pill's own state from the page's pill array (`label`, `icon`, `title`, `alarm`), with a tick for `alarm` pills and the keys in `$good`. Files, notifications and racks call `emptyStateHtml()` in their own layout; files and notifications set `$empty_state_skip`. A page that sets nothing still gets one, worded from `$page_title`. The pages' own empty blocks (`.itflow-asset-empty` under a list) are gone. CSS: `.itflow-empty-state` with `-icon` (a grey round tile), `-title`, `-text` and `-actions`. - Removed unused helpers and files: - `credentialLink()`, `projectLink()`, `linkCounts()`, `assetContactAvatar()`, `clientOverviewPersonInitials()`, and `getMonthlyTax()` and `getQuarterlyTax()`, which looked a tax up by name diff --git a/admin/categories.php b/admin/categories.php index f2cdb9b15..232b78eb4 100644 --- a/admin/categories.php +++ b/admin/categories.php @@ -1,225 +1,319 @@ Categories - the categories of each kind: expense, income, + * referral and ticket, and the interface, asset status, software, rack and + * note types. + * + * Each shows as it looks on records, with its description and what uses it + * (opening the list of them, where there is one). A category can be + * changed, merged into another of its kind, archived - it stays on its + * records but isn't offered for new ones - or, while nothing uses it, + * deleted. The kinds pickers list in an order set here are dragged into it. + * The work is in functions/categories.php; the handlers are + * admin/post/category.php. + */ require_once "includes/inc_all_admin.php"; +$kinds = categoryKinds(); +$kind = isset($kinds[$_GET['category'] ?? '']) ? (string) $_GET['category'] : 'Expense'; +$config = categoryKind($kind); +$show_archived = isset($_GET['archived']); +$search = trim((string) ($_GET['q'] ?? '')); -if (isset($_GET['category'])) { - $category = escapeSql($_GET['category']); -} else { - $category = "Expense"; +// How the list is sorted - the order pickers use first, for the kinds that keep one +$sorts = ($config['ordered'] ? ['order' => ['Order', 'fa-sort-numeric-down', 'The order they are offered in']] : []) + + ['name' => ['Name', 'fa-sort-alpha-down', 'A to Z'], 'use' => ['Most used', 'fa-sort-amount-down', 'The ones on the most records first']]; +$sort_by = isset($sorts[$_GET['sort'] ?? '']) ? (string) $_GET['sort'] : array_key_first($sorts); +$default_sort = array_key_first($sorts); + +$usage = categoryUsage($kind); +$empty_use = ['all' => 0, 'archived' => 0, 'uses' => []]; +$categories = []; +foreach (categoryRows($kind) as $category_id => $row) { + if ((bool) $row['category_archived_at'] !== $show_archived) { + continue; + } + if ($search !== '' && mb_stripos($row['category_name'], $search) === false && mb_stripos((string) $row['category_description'], $search) === false) { + continue; + } + $row['used'] = $usage[$category_id] ?? $empty_use; + $categories[$category_id] = $row; +} +if ($sort_by === 'name') { + uasort($categories, function ($a, $b) { + return strnatcasecmp($a['category_name'], $b['category_name']); + }); +} elseif ($sort_by === 'use') { + uasort($categories, function ($a, $b) { + return $b['used']['all'] <=> $a['used']['all'] ?: strnatcasecmp($a['category_name'], $b['category_name']); + }); } -$sql = mysqli_query( - $mysqli, - "SELECT SQL_CALC_FOUND_ROWS category_color, category_description, category_id, category_name FROM categories - WHERE category_name LIKE '%$q%' - AND category_type = '$category' - AND category_$archive_query - ORDER BY $sort $order LIMIT $record_from, $record_to" -); -$num_rows = mysqli_fetch_row(mysqli_query($mysqli, "SELECT FOUND_ROWS()")); +// Names records have that aren't categories - a kind held by name, in the plain current view +$loose = !$show_archived && $search === '' ? categoryLooseNames($kind) : []; -// Category types shown in the left nav -$category_types = [ - 'Expense' => ['label' => 'Expense', 'icon' => 'fa-shopping-cart'], - 'Income' => ['label' => 'Income', 'icon' => 'fa-hand-holding-usd'], - 'Referral' => ['label' => 'Referral', 'icon' => 'fa-share-alt'], - 'Ticket' => ['label' => 'Ticket', 'icon' => 'fa-life-ring'], - 'network_interface' => ['label' => 'Network Interface', 'icon' => 'fa-ethernet'], - 'asset_status' => ['label' => 'Asset Status', 'icon' => 'fa-heartbeat'], - 'software_type' => ['label' => 'Software Type', 'icon' => 'fa-cube'], - 'rack_type' => ['label' => 'Rack Type', 'icon' => 'fa-server'], - 'contact_note_type' => ['label' => 'Contact Note Type', 'icon' => 'fa-address-book'], - 'asset_note_type' => ['label' => 'Asset Note Type', 'icon' => 'fa-desktop'], -]; +// Dragged into order - in the order view, with the whole current list showing +$can_reorder = $config['ordered'] && $sort_by === 'order' && !$show_archived && $search === '' && count($categories) > 1; -// Label for the selected type, falling back for anything not in the map -$category_label = $category_types[$category]['label'] ?? ucwords(str_replace('_', ' ', $category)); - -// Row count per type for the nav badges, respecting the archived view -$category_type_counts = []; -$sql_category_type_counts = mysqli_query( - $mysqli, - "SELECT category_type, COUNT(category_id) AS category_type_count FROM categories - WHERE category_$archive_query - GROUP BY category_type" -); -while ($row = mysqli_fetch_assoc($sql_category_type_counts)) { - $category_type_counts[$row['category_type']] = intval($row['category_type_count']); +// Current categories of each kind for the nav, and this kind's archived ones for the tab +$kind_counts = []; +$archived_count = 0; +$sql = mysqli_query($mysqli, "SELECT category_type, SUM(category_archived_at IS NULL), SUM(category_archived_at IS NOT NULL) FROM categories GROUP BY category_type"); +while ($row = mysqli_fetch_row($sql)) { + $kind_counts[$row[0]] = intval($row[1]); + if ($row[0] === $kind) { + $archived_count = intval($row[2]); + } } -// Archived nav item toggles the view while holding the selected type/search -$archive_toggle_query = $_GET; -unset($archive_toggle_query['page']); -$archive_toggle_query['category'] = $category; -if ($archived) { - unset($archive_toggle_query['archived']); -} else { - $archive_toggle_query['archived'] = 1; -} -$archive_toggle_url = '?' . http_build_query($archive_toggle_query); +// This page with some of its query changed - null drops a key +$page_url = function (array $change = []) use ($kind, $show_archived, $sort_by, $default_sort, $search) { + $query = array_merge(['category' => $kind, 'q' => $search !== '' ? $search : null, 'sort' => $sort_by !== $default_sort ? $sort_by : null, 'archived' => $show_archived ? 1 : null], $change); + return 'categories.php?' . http_build_query(array_filter($query, function ($v) { + return $v !== null; + })); +}; + +$new_label = 'New ' . ucwords($config['one']); +$add_url = 'modals/category/category_add.php?category=' . urlencode($kind); +$csrf = $_SESSION['csrf_token']; ?> -