From 8a992039fb4ae384fc4b4a16bd65c3aebd4ee4aa Mon Sep 17 00:00:00 2001 From: johnnyq Date: Sat, 17 Oct 2026 15:22:59 -0400 Subject: [PATCH] Categories stage 1: Admin > Categories rebuilt with the ten kinds in a side nav with counts and what each is for, each category shown as it looks on records (icon or dot in its color, asset statuses as their badge) with its description and what uses it linked to the filtered lists, search, sort by name, most used or order, Current and Archived tabs, Edit, Merge into, Archive, Restore and Delete only while unused, bulk merge, archive, restore and delete, drag or Move up and Move down ordering for the ordered kinds, names in use that are not categories listed with Add, one shared category window with live preview, color swatches or none and searchable icons or none, names unique per kind and held to their column length, renaming a kind kept by name carries the new name onto its records, merge moves every reference including budget months folded per month, payment provider fees and the mileage setting, all without touching record updated_at, database 3.1.5 normalizing category colors to #rrggbb and icons to fa-name, seed data in hex, new functions/categories.php, agent + window handler routed through the same validation, note-type icons fixed in pickers, Settings > Defaults payment methods read from Payment Methods, changelog updated --- CHANGELOG.md | 35 +- admin/categories.php | 470 ++++++++++------- admin/database_updates/3.1.5.php | 62 +++ admin/modals/category/category_add.php | 63 +-- admin/modals/category/category_edit.php | 68 +-- admin/modals/category/category_form.php | 120 +++++ admin/modals/category/category_merge.php | 110 ++++ admin/post/category.php | 259 ++++++++-- admin/post/category_model.php | 7 - admin/settings_default.php | 28 +- agent/post/category.php | 23 +- agent/post/category_model.php | 6 - css/itflow_custom.css | 58 +++ functions.php | 1 + functions/categories.php | 624 +++++++++++++++++++++++ functions/format.php | 5 +- js/category_form.js | 201 ++++++++ setup/seed_data.php | 70 +-- 18 files changed, 1812 insertions(+), 398 deletions(-) create mode 100644 admin/database_updates/3.1.5.php create mode 100644 admin/modals/category/category_form.php create mode 100644 admin/modals/category/category_merge.php delete mode 100644 admin/post/category_model.php delete mode 100644 agent/post/category_model.php create mode 100644 functions/categories.php create mode 100644 js/category_form.js 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']; ?> -
-
-

- Categories -

- +
+
+

Categories

- +
-
-
-
- -
- -
+
+ +
+ +
- -
-
- - - - -
-
-
- - -
-
-
- Archived + +
+
+

+ + + + +
+ + +
+
+ [$sort_label, $sort_icon, $sort_title]) { ?> + + +
+ + -
+
+ +
+
- - "> +
+ + - - - + + + + - - - - - - - - - - + + > + $row) { + $name = escapeHtml($row['category_name']); + $used = $row['used']; + $edit_url = "modals/category/category_edit.php?id=$category_id"; + $used_html = $used['all'] ? categoryUsageHtml($row, $used) : '' . ($row['category_archived_at'] ? 'Nothing uses it' : 'Nothing yet') . ''; + $blocker = categoryDeleteBlocker($row, $used); + $position = array_search($category_id, $positions, true); ?> - + + + + + + + + + +
- - Name - + +
+ +
ColorActionOrderUsed onAction
- - -
-
-
- -
+
+ +
+
+ + +
+ +
+ +
Archived
+ +
+ +
-
+ + + +
Drag them into the order they're offered in.
+ +
Clear the search to drag them into order.
+ + + + $config['many'], + 'title' => $show_archived ? 'No archived ' . $config['many'] : 'No ' . $config['many'] . ' yet', + 'text' => $show_archived ? 'An archived category stays on its records, but isn\'t offered for new ones.' : $config['about'], + 'icon' => $config['icon'], + 'filtered' => $search !== '', + 'clear_url' => $page_url(['q' => null]), + 'actions' => $show_archived ? [] : [['label' => $new_label, 'modal' => $add_url]], + ]) ?> + + + +
+
In use, but not set up here
+

Records have these, but they aren't - so they can't be picked again, and have no color or icon. Add one to make it ; to fold it into another, add it, then merge it.

+
    + $loose_used) { ?> +
  • + + 0, 'category_name' => $loose_name, 'category_type' => $kind], $loose_used) ?> +
    + + + + +
    +
  • + +
+
+
- strtolower($category_label) . ' categories', - 'icon' => 'fa-list-ul', - 'views' => ['category'], - 'actions' => [['label' => "New $category_label Category", 'modal' => 'modals/category/category_add.php?category=' . urlencode($category)]], - ]; - ?> -
+ + + + + + '#f0f8ff', 'antiquewhite' => '#faebd7', 'aqua' => '#00ffff', 'aquamarine' => '#7fffd4', 'azure' => '#f0ffff', + 'beige' => '#f5f5dc', 'bisque' => '#ffe4c4', 'black' => '#000000', 'blanchedalmond' => '#ffebcd', 'blue' => '#0000ff', + 'blueviolet' => '#8a2be2', 'brown' => '#a52a2a', 'burlywood' => '#deb887', 'cadetblue' => '#5f9ea0', 'chartreuse' => '#7fff00', + 'chocolate' => '#d2691e', 'coral' => '#ff7f50', 'cornflowerblue' => '#6495ed', 'cornsilk' => '#fff8dc', 'crimson' => '#dc143c', + 'cyan' => '#00ffff', 'darkblue' => '#00008b', 'darkcyan' => '#008b8b', 'darkgoldenrod' => '#b8860b', 'darkgray' => '#a9a9a9', + 'darkgreen' => '#006400', 'darkgrey' => '#a9a9a9', 'darkkhaki' => '#bdb76b', 'darkmagenta' => '#8b008b', 'darkolivegreen' => '#556b2f', + 'darkorange' => '#ff8c00', 'darkorchid' => '#9932cc', 'darkred' => '#8b0000', 'darksalmon' => '#e9967a', 'darkseagreen' => '#8fbc8f', + 'darkslateblue' => '#483d8b', 'darkslategray' => '#2f4f4f', 'darkslategrey' => '#2f4f4f', 'darkturquoise' => '#00ced1', 'darkviolet' => '#9400d3', + 'deeppink' => '#ff1493', 'deepskyblue' => '#00bfff', 'dimgray' => '#696969', 'dimgrey' => '#696969', 'dodgerblue' => '#1e90ff', + 'firebrick' => '#b22222', 'floralwhite' => '#fffaf0', 'forestgreen' => '#228b22', 'fuchsia' => '#ff00ff', 'gainsboro' => '#dcdcdc', + 'ghostwhite' => '#f8f8ff', 'gold' => '#ffd700', 'goldenrod' => '#daa520', 'gray' => '#808080', 'green' => '#008000', + 'greenyellow' => '#adff2f', 'grey' => '#808080', 'honeydew' => '#f0fff0', 'hotpink' => '#ff69b4', 'indianred' => '#cd5c5c', + 'indigo' => '#4b0082', 'ivory' => '#fffff0', 'khaki' => '#f0e68c', 'lavender' => '#e6e6fa', 'lavenderblush' => '#fff0f5', + 'lawngreen' => '#7cfc00', 'lemonchiffon' => '#fffacd', 'lightblue' => '#add8e6', 'lightcoral' => '#f08080', 'lightcyan' => '#e0ffff', + 'lightgoldenrodyellow' => '#fafad2', 'lightgray' => '#d3d3d3', 'lightgreen' => '#90ee90', 'lightgrey' => '#d3d3d3', 'lightpink' => '#ffb6c1', + 'lightsalmon' => '#ffa07a', 'lightseagreen' => '#20b2aa', 'lightskyblue' => '#87cefa', 'lightslategray' => '#778899', 'lightslategrey' => '#778899', + 'lightsteelblue' => '#b0c4de', 'lightyellow' => '#ffffe0', 'lime' => '#00ff00', 'limegreen' => '#32cd32', 'linen' => '#faf0e6', + 'magenta' => '#ff00ff', 'maroon' => '#800000', 'mediumaquamarine' => '#66cdaa', 'mediumblue' => '#0000cd', 'mediumorchid' => '#ba55d3', + 'mediumpurple' => '#9370db', 'mediumseagreen' => '#3cb371', 'mediumslateblue' => '#7b68ee', 'mediumspringgreen' => '#00fa9a', 'mediumturquoise' => '#48d1cc', + 'mediumvioletred' => '#c71585', 'midnightblue' => '#191970', 'mintcream' => '#f5fffa', 'mistyrose' => '#ffe4e1', 'moccasin' => '#ffe4b5', + 'navajowhite' => '#ffdead', 'navy' => '#000080', 'oldlace' => '#fdf5e6', 'olive' => '#808000', 'olivedrab' => '#6b8e23', + 'orange' => '#ffa500', 'orangered' => '#ff4500', 'orchid' => '#da70d6', 'palegoldenrod' => '#eee8aa', 'palegreen' => '#98fb98', + 'paleturquoise' => '#afeeee', 'palevioletred' => '#db7093', 'papayawhip' => '#ffefd5', 'peachpuff' => '#ffdab9', 'peru' => '#cd853f', + 'pink' => '#ffc0cb', 'plum' => '#dda0dd', 'powderblue' => '#b0e0e6', 'purple' => '#800080', 'rebeccapurple' => '#663399', + 'red' => '#ff0000', 'rosybrown' => '#bc8f8f', 'royalblue' => '#4169e1', 'saddlebrown' => '#8b4513', 'salmon' => '#fa8072', + 'sandybrown' => '#f4a460', 'seagreen' => '#2e8b57', 'seashell' => '#fff5ee', 'sienna' => '#a0522d', 'silver' => '#c0c0c0', + 'skyblue' => '#87ceeb', 'slateblue' => '#6a5acd', 'slategray' => '#708090', 'slategrey' => '#708090', 'snow' => '#fffafa', + 'springgreen' => '#00ff7f', 'steelblue' => '#4682b4', 'tan' => '#d2b48c', 'teal' => '#008080', 'thistle' => '#d8bfd8', + 'tomato' => '#ff6347', 'turquoise' => '#40e0d0', 'violet' => '#ee82ee', 'wheat' => '#f5deb3', 'white' => '#ffffff', + 'whitesmoke' => '#f5f5f5', 'yellow' => '#ffff00', 'yellowgreen' => '#9acd32', + ]; + $sql = mysqli_query($mysqli, "SELECT category_id, category_color, category_icon FROM categories"); + while ($row = mysqli_fetch_assoc($sql)) { + $color = strtolower(trim((string) $row['category_color'])); + if (preg_match('/^#([0-9a-f])([0-9a-f])([0-9a-f])$/', $color, $m)) { + $color = "#$m[1]$m[1]$m[2]$m[2]$m[3]$m[3]"; + } elseif (preg_match('/^#[0-9a-f]{8}$/', $color)) { + $color = substr($color, 0, 7); + } elseif (!preg_match('/^#[0-9a-f]{6}$/', $color)) { + $color = $category_css_colors[$color] ?? ''; + } + $icon = preg_replace('/^(fa[srb]? +)?fa-/', '', strtolower(trim(strip_tags((string) $row['category_icon'])))); + $icon = trim(preg_replace('/[^a-z0-9-]/', '', $icon), '-'); + $category_id = intval($row['category_id']); + mysqli_query($mysqli, "UPDATE categories SET category_color = " . ($color === '' ? 'NULL' : "'$color'") . ", category_icon = " . ($icon === '' ? 'NULL' : "'fa-$icon'") . ", + category_updated_at = category_updated_at WHERE category_id = $category_id"); + } diff --git a/admin/modals/category/category_add.php b/admin/modals/category/category_add.php index b174d94ee..23c4909d8 100644 --- a/admin/modals/category/category_add.php +++ b/admin/modals/category/category_add.php @@ -1,69 +1,34 @@ Categories (?category= the kind), or the + + * beside a category picker in a form (which posts to the agent's post.php). + * The fields are category_form.php. + */ + require_once '../../includes/modal_header.php'; -$category = escapeHtml($_GET['category'] ?? ''); +$category = null; +$kind = categoryKind($_GET['category'] ?? '') ? (string) $_GET['category'] : ''; +$kind_config = $kind !== '' ? categoryKind($kind) : null; -$category_types_array = ['Expense', 'Income', 'Referral', 'Ticket']; +ob_start(); ?>
diff --git a/admin/modals/category/category_edit.php b/admin/modals/category/category_edit.php index 96bf08f22..511db664b 100644 --- a/admin/modals/category/category_edit.php +++ b/admin/modals/category/category_edit.php @@ -1,60 +1,46 @@ + +if (!$category) { ?> + + +
- +
- 0, 'archived' => 0, 'uses' => []]; +$c_preview = categoryChip([ + 'category_name' => $c['category_name'] ?? 'New category', + 'category_type' => $kind, + 'category_color' => $c_color, + 'category_icon' => $c_icon, +]); +$c_max = $c_config['max'] ?? 200; + +?> + +
+
+ Looks like + + + + + + +
+ + +
+ +
+ + +
+
+ + + + +
+ +
+ + data-category-name> +
+ + + +
+ +
+ +
+ + +
+
+ +
+ + +
+
+ + + + +
+
+ + + +
+
+
+ +
+ + +
+ + +
+
+ + + + + + +
+ + Asset statuses show as a badge in their color - the icon shows in the status picker. + +
+
+ + diff --git a/admin/modals/category/category_merge.php b/admin/modals/category/category_merge.php new file mode 100644 index 000000000..70454cc1d --- /dev/null +++ b/admin/modals/category/category_merge.php @@ -0,0 +1,110 @@ + 1) { + $into = $merging; + uasort($into, function ($a, $b) use ($used) { + return $used($b['category_id']) <=> $used($a['category_id']) ?: strnatcasecmp($a['category_name'], $b['category_name']); + }); + } + foreach (categoryRows($config['kind']) as $category_id => $row) { + if (!isset($merging[$category_id]) && !$row['category_archived_at']) { + $into[$category_id] = $row; + } + } +} +$keep_id = count($merging) > 1 ? intval(array_key_first($into)) : 0; + +// What merging does, in words +$one = count($merging) === 1; +$records = 0; +foreach ($merging as $merge_id => $row) { + $records += $used($merge_id); +} +$merge_text = ''; +if ($config) { + $merge_text = ($records ? 'The ' . number_format($records) . ' ' . ($records === 1 ? 'record' : 'records') . ' with ' . ($one ? 'it get' : 'these get') . ' the ' . $config['one'] . ' you keep instead' + . ($config['by'] === 'name' ? ' - renamed to it' : '') . ($config['kind'] === 'Expense' ? ', and a month budgeted under more than one ends with one line of their total' : '') . '.' + : 'Nothing uses ' . ($one ? 'it' : 'these') . ' yet.') + . ($one ? ' It is then deleted.' : ' The others are then deleted.') . " This can't be undone."; +} + +ob_start(); + +?> + + + + + + + + +
+ + $row) { ?> + + + + + +
+ + + Categories) - making, changing, ordering, merging, + * archiving and deleting them. The work is in functions/categories.php. */ defined('FROM_POST_HANDLER') || die("Direct file access is not allowed"); +// "asset status" - what a category is, for the audit log and messages +$category_kind_word = function ($row) { + return categoryKind($row['category_type'] ?? '')['one'] ?? 'category'; +}; + +// Admin > Categories on a kind, to go back to after making one +$category_page = function ($kind, $archived = false) { + return 'categories.php?category=' . urlencode($kind) . ($archived ? '&archived=1' : ''); +}; + if (isset($_POST['add_category'])) { validateCSRFToken(); - require_once 'category_model.php'; + [$fields, $error] = categoryFromInput($_POST); + if ($error !== '') { + flashAlert(escapeHtml($error), 'error'); + redirect(); + } - mysqli_query($mysqli,"INSERT INTO categories SET category_name = '$name', category_description = '$description', category_type = '$type', category_color = '$color'"); + $category_id = categoryCreate($fields); + $kind = $category_kind_word(['category_type' => $fields['type']]); - $category_id = mysqli_insert_id($mysqli); + logAudit("Category", "Create", "$session_name created $kind " . escapeSql($fields['name']), 0, $category_id); - logAudit("Category", "Create", "$session_name created category $type $name", 0, $category_id); + flashAlert(ucfirst($kind) . " " . escapeHtml($fields['name']) . " created"); - flashAlert("Category $type $name created"); - - redirect(); + redirect($category_page($fields['type'])); } @@ -28,15 +42,25 @@ if (isset($_POST['edit_category'])) { validateCSRFToken(); - require_once 'category_model.php'; + $row = categoryRow(intval($_POST['category_id'] ?? 0)); + if (!$row) { + flashAlert("That category is gone - it may have been merged into another, or deleted", 'error'); + redirect(); + } - $category_id = intval($_POST['category_id']); + [$fields, $error] = categoryFromInput($_POST, $row); + if ($error !== '') { + flashAlert(escapeHtml($error), 'error'); + redirect(); + } - mysqli_query($mysqli,"UPDATE categories SET category_name = '$name', category_description = '$description', category_type = '$type', category_color = '$color' WHERE category_id = $category_id"); + $renamed = categoryUpdate($row, $fields); + $kind = $category_kind_word($row); + $was = $row['category_name'] !== $fields['name'] ? " (was " . escapeSql($row['category_name']) . ")" : ''; - logAudit("Category", "Edit", "$session_name edited category $type $name", 0, $category_id); + logAudit("Category", "Edit", "$session_name edited $kind " . escapeSql($fields['name']) . $was . ($renamed ? " - renamed on $renamed " . ($renamed === 1 ? 'record' : 'records') : ''), 0, intval($row['category_id'])); - flashAlert("Category $type $name edited"); + flashAlert(ucfirst($kind) . " " . escapeHtml($fields['name']) . " saved" . ($renamed ? " - and renamed on the " . number_format($renamed) . ' ' . ($renamed === 1 ? 'record' : 'records') . " that had it" : '')); redirect(); @@ -46,19 +70,14 @@ if (isset($_GET['archive_category'])) { validateCSRFToken(); - $category_id = intval($_GET['archive_category']); - - // Get Category Name and Type for logging - $sql = mysqli_query($mysqli,"SELECT category_name, category_type FROM categories WHERE category_id = $category_id"); - $row = mysqli_fetch_assoc($sql); - $category_name = escapeSql($row['category_name']); - $category_type = escapeSql($row['category_type']); - - mysqli_query($mysqli,"UPDATE categories SET category_archived_at = NOW() WHERE category_id = $category_id"); - - logAudit("Category", "Archive", "$session_name archived category $category_type $category_name", 0, $category_id); - - flashAlert("Category $category_type $category_name archived", 'error'); + $row = categoryRow(intval($_GET['archive_category'])); + if ($row && !$row['category_archived_at']) { + categoryArchive($row['category_id']); + $used = categoryUsageFor($row['category_id']); + logAudit("Category", "Archive", "$session_name archived " . $category_kind_word($row) . " " . escapeSql($row['category_name']), 0, intval($row['category_id'])); + flashAlert(ucfirst($category_kind_word($row)) . " " . escapeHtml($row['category_name']) . " archived - " + . ($used['all'] ? "it stays on the " . escapeHtml(categoryUsageText($row, $used)) . " that have it, but isn't offered for new ones" : "it isn't offered for new ones")); + } redirect(); @@ -68,19 +87,12 @@ if (isset($_GET['restore_category'])) { validateCSRFToken(); - $category_id = intval($_GET['restore_category']); - - // Get Category Name and Type for logging - $sql = mysqli_query($mysqli,"SELECT category_name, category_type FROM categories WHERE category_id = $category_id"); - $row = mysqli_fetch_assoc($sql); - $category_name = escapeSql($row['category_name']); - $category_type = escapeSql($row['category_type']); - - mysqli_query($mysqli,"UPDATE categories SET category_archived_at = NULL WHERE category_id = $category_id"); - - logAudit("Category", "Restore", "$session_name retored category $category_type $category_name", 0, $category_id); - - flashAlert("Category $category_type $category_name restored"); + $row = categoryRow(intval($_GET['restore_category'])); + if ($row && $row['category_archived_at']) { + categoryRestore($row['category_id']); + logAudit("Category", "Restore", "$session_name restored " . $category_kind_word($row) . " " . escapeSql($row['category_name']), 0, intval($row['category_id'])); + flashAlert(ucfirst($category_kind_word($row)) . " " . escapeHtml($row['category_name']) . " restored"); + } redirect(); @@ -90,19 +102,168 @@ if (isset($_GET['delete_category'])) { validateCSRFToken(); - $category_id = intval($_GET['delete_category']); + $row = categoryRow(intval($_GET['delete_category'])); + if (!$row) { + redirect(); + } - // Get Category Name and Type for logging - $sql = mysqli_query($mysqli,"SELECT category_name, category_type FROM categories WHERE category_id = $category_id"); - $row = mysqli_fetch_assoc($sql); - $category_name = escapeSql($row['category_name']); - $category_type = escapeSql($row['category_type']); + $error = ''; + if (!categoryDelete($row['category_id'], $error)) { + flashAlert(escapeHtml(ucfirst($category_kind_word($row)) . " " . $row['category_name'] . " is kept - " . lcfirst($error)), 'error'); + redirect(); + } - mysqli_query($mysqli,"DELETE FROM categories WHERE category_id = $category_id"); + logAudit("Category", "Delete", "$session_name deleted " . $category_kind_word($row) . " " . escapeSql($row['category_name'])); - logAudit("Category", "Delete", "$session_name deleted category $category_type $category_name"); - - flashAlert("Category $category_type $category_name deleted", 'error'); + flashAlert(ucfirst($category_kind_word($row)) . " " . escapeHtml($row['category_name']) . " deleted", 'error'); + + redirect(); + +} + +if (isset($_POST['merge_categories'])) { + + validateCSRFToken(); + + $into = categoryRow(intval($_POST['into_category_id'] ?? 0)); + if (!$into) { + flashAlert("Pick the category to keep", 'error'); + redirect(); + } + $into_id = intval($into['category_id']); + + // Their names before they go - other categories of the same kind only + $from = []; + foreach (array_unique(array_map('intval', (array) ($_POST['category_ids'] ?? []))) as $from_id) { + $from_row = $from_id !== $into_id ? categoryRow($from_id) : null; + if ($from_row && $from_row['category_type'] === $into['category_type']) { + $from[$from_id] = $from_row['category_name']; + } + } + if (!$from) { + flashAlert("Pick another " . escapeHtml($category_kind_word($into)) . " to merge into " . escapeHtml($into['category_name']), 'error'); + redirect(); + } + + $moved = categoryMerge(array_keys($from), $into_id); + $kind = $category_kind_word($into); + $records = $moved ? number_format($moved) . ($moved === 1 ? ' record' : ' records') : ''; + + logAudit("Category", "Merge", "$session_name merged " . (count($from) === 1 ? $kind : categoryKind($into['category_type'])['many']) . " " . escapeSql(implode(', ', $from)) . " into " . escapeSql($into['category_name']) + . ($moved ? " - $records" : ''), 0, $into_id); + + flashAlert("Merged " . implode(', ', array_map(function ($name) { + return '' . escapeHtml($name) . ''; + }, $from)) . " into " . escapeHtml($into['category_name']) . "" + . ($moved ? " - the $records that had " . (count($from) === 1 ? 'it' : 'them') . " have it now" : '')); + + redirect(); + +} + +if (isset($_GET['move_category'])) { + + validateCSRFToken(); + + // One place up or down among its kind's current categories, in the order pickers use + $row = categoryRow(intval($_GET['move_category'])); + $config = $row ? categoryKind($row['category_type']) : null; + if ($config && $config['ordered']) { + $ids = array_keys(array_filter(categoryRows($row['category_type']), function ($r) { + return !$r['category_archived_at']; + })); + $at = array_search(intval($row['category_id']), $ids, true); + $to = ($_GET['direction'] ?? '') === 'up' ? $at - 1 : $at + 1; + if ($at !== false && isset($ids[$to])) { + [$ids[$at], $ids[$to]] = [$ids[$to], $ids[$at]]; + categoryReorder($row['category_type'], $ids); + logAudit("Category", "Edit", "$session_name moved " . $category_kind_word($row) . " " . escapeSql($row['category_name']) . (($_GET['direction'] ?? '') === 'up' ? ' up' : ' down'), 0, intval($row['category_id'])); + } + } + + redirect(); + +} + +if (isset($_POST['reorder_categories'])) { + + // Dragged into order on Admin > Categories - answers with JSON, as the page saves it in the background + header('Content-Type: application/json'); + if (!hash_equals((string) ($_SESSION['csrf_token'] ?? ''), (string) ($_POST['csrf_token'] ?? ''))) { + echo json_encode(['ok' => false, 'error' => 'The page is out of date - reload it']); + exit; + } + + $config = categoryKind($_POST['category'] ?? ''); + if (!$config || !$config['ordered']) { + echo json_encode(['ok' => false, 'error' => 'Those categories keep no order']); + exit; + } + + // The current ones in the order dragged, the archived ones after them + $rows = categoryRows($config['kind']); + $ids = array_values(array_filter(array_map('intval', (array) ($_POST['category_ids'] ?? [])), function ($id) use ($rows) { + return isset($rows[$id]); + })); + if (categoryReorder($config['kind'], $ids)) { + logAudit("Category", "Edit", "$session_name reordered the " . $config['many']); + } + + echo json_encode(['ok' => true]); + exit; + +} + +if (isset($_POST['bulk_archive_categories']) || isset($_POST['bulk_restore_categories'])) { + + validateCSRFToken(); + + $archive = isset($_POST['bulk_archive_categories']); + $count = 0; + foreach (array_unique(array_map('intval', (array) ($_POST['category_ids'] ?? []))) as $category_id) { + $row = categoryRow($category_id); + if (!$row || (bool) $row['category_archived_at'] === $archive) { + continue; + } + if ($archive) { + categoryArchive($category_id); + } else { + categoryRestore($category_id); + } + logAudit("Category", $archive ? "Archive" : "Restore", "$session_name " . ($archive ? 'archived ' : 'restored ') . $category_kind_word($row) . " " . escapeSql($row['category_name']), 0, $category_id); + $count++; + } + + flashAlert(($archive ? "Archived " : "Restored ") . $count . ($count === 1 ? " category" : " categories") . ($archive && $count ? " - they stay on their records, but aren't offered for new ones" : '')); + + redirect(); + +} + +if (isset($_POST['bulk_delete_categories'])) { + + validateCSRFToken(); + + $deleted = 0; + $kept = []; + foreach (array_unique(array_map('intval', (array) ($_POST['category_ids'] ?? []))) as $category_id) { + $row = categoryRow($category_id); + if (!$row) { + continue; + } + $error = ''; + if (!categoryDelete($category_id, $error)) { + if ($error !== '') { + $kept[] = '' . escapeHtml($row['category_name']) . ''; + } + continue; + } + logAudit("Category", "Delete", "$session_name deleted " . $category_kind_word($row) . " " . escapeSql($row['category_name'])); + $deleted++; + } + + flashAlert("Deleted $deleted " . ($deleted === 1 ? 'category' : 'categories') + . ($kept ? ". Kept " . implode(', ', $kept) . " - " . (count($kept) === 1 ? 'it is' : 'they are') . " in use, so merge " . (count($kept) === 1 ? 'it' : 'them') . " into another, or archive " . (count($kept) === 1 ? 'it' : 'them') : ''), $kept ? 'warning' : 'error'); redirect(); diff --git a/admin/post/category_model.php b/admin/post/category_model.php deleted file mode 100644 index ff17b0391..000000000 --- a/admin/post/category_model.php +++ /dev/null @@ -1,7 +0,0 @@ - - + // Admin > Payment Methods - and the saved one, should it no longer be among them + $default_methods = paymentMethods(); + if ((string) $config_default_payment_method !== '' && !in_array((string) $config_default_payment_method, $default_methods, true)) { + $default_methods[] = (string) $config_default_payment_method; + } + foreach ($default_methods as $payment_method) { ?> + @@ -181,13 +181,13 @@ $net_terms_array = array ( diff --git a/agent/post/category.php b/agent/post/category.php index b1dcd68d3..fcaf9fdf3 100644 --- a/agent/post/category.php +++ b/agent/post/category.php @@ -1,7 +1,10 @@ $name created"); + flashAlert(ucfirst($kind) . " " . escapeHtml($fields['name']) . " created"); redirect(); diff --git a/agent/post/category_model.php b/agent/post/category_model.php deleted file mode 100644 index a382aabf0..000000000 --- a/agent/post/category_model.php +++ /dev/null @@ -1,6 +0,0 @@ - Categories - a long kind's name shortened rather than pushing its + count out, and dragging the kinds that keep an order into it */ +@media (min-width: 768px) { + .itflow-category-kinds .nav-link { + min-width: 0; + } +} +.itflow-category-grip { + display: inline-block; + padding: .25rem .125rem; + cursor: grab; + touch-action: none; +} +[data-category-sortable] .sortable-ghost > * { + background-color: var(--bs-tertiary-bg) !important; +} .itflow-clients-street { max-width: 16rem; } diff --git a/functions.php b/functions.php index 62e8c459e..1c4079fd6 100644 --- a/functions.php +++ b/functions.php @@ -44,6 +44,7 @@ require_once __DIR__ . '/functions/dashboard.php'; require_once __DIR__ . '/functions/reports.php'; require_once __DIR__ . '/functions/client_overview.php'; require_once __DIR__ . '/functions/tags.php'; +require_once __DIR__ . '/functions/categories.php'; require_once __DIR__ . '/functions/ticket_fields.php'; require_once __DIR__ . '/functions/ticket.php'; require_once __DIR__ . '/functions/project.php'; diff --git a/functions/categories.php b/functions/categories.php new file mode 100644 index 000000000..1d1d8f5fb --- /dev/null +++ b/functions/categories.php @@ -0,0 +1,624 @@ + Categories + * (admin/categories.php); the handlers are admin/post/category.php. + * + * Each kind is a category_type. Expense, income and ticket categories go on + * records by id (tickets.ticket_category holds it as text); the other kinds + * by name - a client's referral, an asset's status, an interface's type - so + * renaming one of those renames it on every record that has it, and merging + * moves the records by name. categoryKinds() says which, and every table and + * column that uses each kind: what the counts, merging and the safe delete + * work from. + * + * Colours are stored as #rrggbb, or NULL for none (an asset status without + * one keeps its built-in colour). Icons as "fa-name", or NULL. + */ + +/* + * The kinds, by category_type. Each: + * label what Admin > Categories calls the kind + * one/many the category itself, in a sentence - "an asset status" + * icon the kind's icon (Font Awesome class) + * about what the kind is for, under the page's heading + * by 'id' or 'name' - how records hold it + * ordered whether pickers list them in the order set here (else by name) + * max the longest name the records' columns hold + * uses where records hold it - table, column, one/many (the record, + * in a sentence), url (the list showing them, {id} or {name} + * filled in; null when there is no such list), archived and + * updated (columns, or null), text (an id held as text) + */ +function categoryKinds() { + $use = function ($table, $column, $one, $many, $url = null, $archived = null, $updated = null, $text = false) { + return ['table' => $table, 'column' => $column, 'one' => $one, 'many' => $many, 'url' => $url, 'archived' => $archived, 'updated' => $updated, 'text' => $text]; + }; + return [ + 'Expense' => [ + 'label' => 'Expense', 'one' => 'expense category', 'many' => 'expense categories', 'icon' => 'fa-shopping-cart', + 'about' => 'What money goes out on. Every expense, recurring expense and budget line has one, and the expense reports add up by them.', + 'by' => 'id', 'ordered' => false, 'max' => 200, + 'uses' => [ + $use('expenses', 'expense_category_id', 'expense', 'expenses', '/agent/expenses.php?category={id}', 'expense_archived_at', 'expense_updated_at'), + $use('recurring_expenses', 'recurring_expense_category_id', 'recurring expense', 'recurring expenses', '/agent/recurring_expenses.php?category={id}&status=all', 'recurring_expense_archived_at', 'recurring_expense_updated_at'), + $use('budget', 'budget_category_id', 'budget month', 'budget months', '/agent/reports/budget.php', null, 'budget_updated_at'), + $use('expense_import_rules', 'import_rule_category_id', 'import rule', 'import rules', null, null, 'import_rule_updated_at'), + $use('payment_providers', 'payment_provider_expense_category', 'payment provider\'s fees', 'payment providers\' fees', '/admin/payment_providers.php', null, 'payment_provider_updated_at'), + $use('settings', 'config_mileage_category_id', 'mileage setting', 'mileage settings', '/admin/settings_invoice.php#mileage'), + ], + ], + 'Income' => [ + 'label' => 'Income', 'one' => 'income category', 'many' => 'income categories', 'icon' => 'fa-hand-holding-usd', + 'about' => 'What money comes in for. Invoices, quotes, recurring invoices, products and other income each have one, and the income reports add up by them.', + 'by' => 'id', 'ordered' => false, 'max' => 200, + 'uses' => [ + $use('invoices', 'invoice_category_id', 'invoice', 'invoices', '/agent/invoices.php?category={id}', 'invoice_archived_at', 'invoice_updated_at'), + $use('quotes', 'quote_category_id', 'quote', 'quotes', null, 'quote_archived_at', 'quote_updated_at'), + $use('recurring_invoices', 'recurring_invoice_category_id', 'recurring invoice', 'recurring invoices', null, 'recurring_invoice_archived_at', 'recurring_invoice_updated_at'), + $use('revenues', 'revenue_category_id', 'other income entry', 'other income entries', '/agent/income.php?category={id}&show=other', 'revenue_archived_at', 'revenue_updated_at'), + $use('products', 'product_category_id', 'product', 'products', '/agent/products.php?category={id}', 'product_archived_at', 'product_updated_at'), + $use('income_import_rules', 'import_rule_category_id', 'import rule', 'import rules', null, null, 'import_rule_updated_at'), + ], + ], + 'Referral' => [ + 'label' => 'Referral', 'one' => 'referral', 'many' => 'referrals', 'icon' => 'fa-share-alt', + 'about' => 'How a client found you - picked on the client, and a filter on the clients list.', + 'by' => 'name', 'ordered' => false, 'max' => 200, + 'uses' => [ + $use('clients', 'client_referral', 'client', 'clients', '/agent/clients.php?referral={name}', 'client_archived_at', 'client_updated_at'), + ], + ], + 'Ticket' => [ + 'label' => 'Ticket', 'one' => 'ticket category', 'many' => 'ticket categories', 'icon' => 'fa-life-ring', + 'about' => 'What a ticket is about. Tickets, recurring tickets and canned responses have one, and the ticket reports add up by them.', + 'by' => 'id', 'ordered' => false, 'max' => 200, + 'uses' => [ + $use('tickets', 'ticket_category', 'ticket', 'tickets', '/agent/tickets.php?category={id}&show=all', 'ticket_archived_at', 'ticket_updated_at', true), + $use('recurring_tickets', 'recurring_ticket_category', 'recurring ticket', 'recurring tickets', '/agent/recurring_tickets.php?category={id}', null, 'recurring_ticket_updated_at', true), + $use('canned_responses', 'canned_response_category_id', 'canned response', 'canned responses', '/admin/canned_responses.php', 'canned_response_archived_at', 'canned_response_updated_at'), + ], + ], + 'network_interface' => [ + 'label' => 'Network Interface', 'one' => 'interface type', 'many' => 'interface types', 'icon' => 'fa-ethernet', + 'about' => 'The kinds of port on an asset - Ethernet, SFP+, WiFi - picked on each of its interfaces.', + 'by' => 'name', 'ordered' => true, 'max' => 50, + 'uses' => [ + $use('asset_interfaces', 'interface_type', 'interface', 'interfaces', null, 'interface_archived_at', 'interface_updated_at'), + ], + ], + 'asset_status' => [ + 'label' => 'Asset Status', 'one' => 'asset status', 'many' => 'asset statuses', 'icon' => 'fa-heartbeat', + 'about' => 'Where an asset stands - deployed, out for repair, retired. Its colour is the status badge\'s; without one the built-in statuses keep theirs.', + 'by' => 'name', 'ordered' => true, 'max' => 200, + 'uses' => [ + $use('assets', 'asset_status', 'asset', 'assets', null, 'asset_archived_at', 'asset_updated_at'), + ], + ], + 'software_type' => [ + 'label' => 'Software Type', 'one' => 'software type', 'many' => 'software types', 'icon' => 'fa-cube', + 'about' => 'What kind of software a license is for - SaaS, security, operating system. Its icon is the license\'s.', + 'by' => 'name', 'ordered' => true, 'max' => 200, + 'uses' => [ + $use('software', 'software_type', 'license', 'licenses', null, 'software_archived_at', 'software_updated_at'), + $use('software_templates', 'software_template_type', 'license template', 'license templates', '/admin/software_templates.php', 'software_template_archived_at', 'software_template_updated_at'), + ], + ], + 'rack_type' => [ + 'label' => 'Rack Type', 'one' => 'rack type', 'many' => 'rack types', 'icon' => 'fa-server', + 'about' => 'The kinds of rack - open frame, enclosed cabinet, wall mount - picked on each rack.', + 'by' => 'name', 'ordered' => true, 'max' => 50, + 'uses' => [ + $use('racks', 'rack_type', 'rack', 'racks', null, 'rack_archived_at', 'rack_updated_at'), + ], + ], + 'contact_note_type' => [ + 'label' => 'Contact Note Type', 'one' => 'contact note type', 'many' => 'contact note types', 'icon' => 'fa-address-book', + 'about' => 'What a note on a contact records - a call, an email, a meeting. Its icon marks the note.', + 'by' => 'name', 'ordered' => true, 'max' => 200, + 'uses' => [ + $use('contact_notes', 'contact_note_type', 'contact note', 'contact notes', null, 'contact_note_archived_at', 'contact_note_updated_at'), + ], + ], + 'asset_note_type' => [ + 'label' => 'Asset Note Type', 'one' => 'asset note type', 'many' => 'asset note types', 'icon' => 'fa-desktop', + 'about' => 'What a note on an asset records - maintenance, a repair, an upgrade. Its icon marks the note.', + 'by' => 'name', 'ordered' => true, 'max' => 200, + 'uses' => [ + $use('asset_notes', 'asset_note_type', 'asset note', 'asset notes', null, 'asset_note_archived_at', 'asset_note_updated_at'), + ], + ], + ]; +} + +/* One kind's settings, with 'kind' (its category_type) - or null. */ +function categoryKind($kind) { + $kinds = categoryKinds(); + $kind = (string) $kind; + return isset($kinds[$kind]) ? $kinds[$kind] + ['kind' => $kind] : null; +} + +/* "an asset status", "a ticket category" - for messages. */ +function categoryKindPhrase($kind) { + $one = categoryKind($kind)['one'] ?? 'category'; + return (in_array($one[0], ['a', 'e', 'i', 'o', 'u'], true) ? 'an ' : 'a ') . $one; +} + +/* "12 assets" / "1 asset" - a count of records of one use. */ +function categoryUseText($n, array $use) { + return number_format($n) . ' ' . ($n === 1 ? $use['one'] : $use['many']); +} + +/* The colours offered in the category window - any other can be picked too. */ +const CATEGORY_COLORS = ['#0d6efd', '#6610f2', '#6f42c1', '#d63384', '#dc3545', '#fd7e14', '#ffc107', '#198754', '#20c997', '#0dcaf0', '#795548', '#6c757d', '#343a40', '#001f3f']; + +/* Icons offered in the category window, by what they suit - any Font Awesome solid icon's name works. */ +const CATEGORY_ICONS = [ + 'shopping-cart', 'receipt', 'file-invoice-dollar', 'money-bill-wave', 'coins', 'credit-card', 'piggy-bank', 'hand-holding-usd', 'dollar-sign', 'percent', + 'university', 'landmark', 'balance-scale', 'briefcase', 'building', 'store', 'industry', 'warehouse', 'truck', 'shipping-fast', + 'plane', 'car', 'gas-pump', 'route', 'utensils', 'coffee', 'bullhorn', 'ad', 'gift', 'graduation-cap', + 'user', 'user-tie', 'users', 'user-plus', 'user-minus', 'user-secret', 'handshake', 'share-alt', 'globe', 'thumbs-up', + 'life-ring', 'question-circle', 'exclamation-triangle', 'bug', 'fire', 'bell', 'shield-alt', 'lock', 'key', 'fish', + 'desktop', 'laptop', 'server', 'hdd', 'database', 'print', 'mobile-alt', 'phone-alt', 'fax', 'microchip', + 'network-wired', 'ethernet', 'wifi', 'cloud', 'plug', 'satellite-dish', 'project-diagram', 'code', 'terminal', 'cube', + 'layer-group', 'window-maximize', 'cogs', 'tools', 'wrench', 'sliders-h', 'arrow-circle-up', 'sync-alt', 'clipboard-check', 'calendar-check', + 'heartbeat', 'check-circle', 'pause-circle', 'times-circle', 'ban', 'archive', 'box', 'recycle', 'search', 'map-marker-alt', + 'envelope', 'comments', 'people-arrows', 'sticky-note', 'book', 'file-alt', 'star', 'flag', 'tag', 'circle', +]; + +/* A colour as #rrggbb - or '' for none (anything that isn't a colour). */ +function categoryColor($color) { + $color = strtolower(trim((string) $color)); + if (preg_match('/^#([0-9a-f])([0-9a-f])([0-9a-f])$/', $color, $m)) { + return "#$m[1]$m[1]$m[2]$m[2]$m[3]$m[3]"; + } + if (preg_match('/^#[0-9a-f]{8}$/', $color)) { + return substr($color, 0, 7); + } + return preg_match('/^#[0-9a-f]{6}$/', $color) ? $color : ''; +} + +/* An icon's bare Font Awesome name ("phone-alt") from "fa-phone-alt", "fas fa-phone-alt" or the name - or '' for none. */ +function categoryIcon($icon) { + $icon = preg_replace('/^(fa[srb]? +)?fa-/', '', strtolower(trim(strip_tags((string) $icon)))); + $icon = preg_replace('/[^a-z0-9-]/', '', $icon); + return mb_substr(trim($icon, '-'), 0, 50); +} + +/* A colour for a new category - the next one round the palette. */ +function categoryNextColor($kind) { + global $mysqli; + $palette = ['#0d6efd', '#198754', '#6f42c1', '#fd7e14', '#d63384', '#20c997', '#dc3545', '#0dcaf0', '#6610f2', '#ffc107']; + $row = mysqli_fetch_row(mysqli_query($mysqli, "SELECT COUNT(category_id) FROM categories WHERE category_type = '" . escapeSql($kind) . "'")); + return $palette[intval($row[0]) % count($palette)]; +} + +/* + * A category as it looks on records - an asset status as its badge, + * anything else its icon (or a dot) in its colour, then the name. With + * nowhere to go - for Admin > Categories and the windows. $row: category_name, + * category_type, category_color, category_icon, category_description. + */ +function categoryChip(array $row) { + $name = escapeHtml((string) ($row['category_name'] ?? '')); + $about = trim((string) ($row['category_description'] ?? '')); + $title = $about !== '' ? ' title="' . escapeHtml($about) . '"' : ''; + $color = categoryColor($row['category_color'] ?? ''); + if (($row['category_type'] ?? '') === 'asset_status') { + // Its colour with text that reads on it, or the built-in status's tone + $badge = assetStatusBadge((string) ($row['category_name'] ?? ''), $color); + $look = $color ? "\" style=\"background-color: $color; color: " . tagTextColor($color) : ' text-bg-' . ($badge['class'] ?: 'secondary'); + return "$name"; + } + $icon = categoryIcon($row['category_icon'] ?? ''); + $style = $color ? " style=\"color: $color\"" : ''; + $mark = $icon !== '' + ? "" + : ''; + return "$mark$name"; +} + +/* One category, or null. */ +function categoryRow($category_id) { + global $mysqli; + $category_id = intval($category_id); + return $category_id ? (mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT category_id, category_name, category_description, category_type, category_color, category_icon, + category_order, category_created_at, category_archived_at + FROM categories WHERE category_id = $category_id LIMIT 1")) ?: null) : null; +} + +/* A kind's categories - id => row, archived ones too. In the order pickers list them. */ +function categoryRows($kind) { + global $mysqli; + $config = categoryKind($kind); + if (!$config) { + return []; + } + $order = $config['ordered'] ? 'category_order ASC, category_name ASC' : 'category_name ASC'; + $rows = []; + $sql = mysqli_query($mysqli, "SELECT category_id, category_name, category_description, category_type, category_color, category_icon, category_order, + category_created_at, category_archived_at + FROM categories WHERE category_type = '" . escapeSql($config['kind']) . "' ORDER BY $order"); + while ($row = mysqli_fetch_assoc($sql)) { + $rows[intval($row['category_id'])] = $row; + } + return $rows; +} + +/* How a by-name value is matched to a category - as the database compares them, case and trailing spaces aside. */ +function categoryNameKey($name) { + return mb_strtolower(rtrim((string) $name)); +} + +/* + * What uses each of a kind's categories - id => ['all' => n, 'archived' => n, + * 'uses' => [use index => ['all' => n, 'archived' => n]]]. Archived records + * count too; 'archived' says how many of them are. A category nothing uses + * isn't in it. + */ +function categoryUsage($kind) { + global $mysqli; + $config = categoryKind($kind); + if (!$config) { + return []; + } + + // A by-name value belongs to every category of the kind with that name (only ever one, but for duplicates made before names were unique) + $by_name = []; + if ($config['by'] === 'name') { + foreach (categoryRows($kind) as $category_id => $row) { + $by_name[categoryNameKey($row['category_name'])][] = $category_id; + } + } + + $usage = []; + foreach ($config['uses'] as $index => $use) { + $archived = $use['archived'] ? "SUM({$use['archived']} IS NOT NULL)" : '0'; + $sql = mysqli_query($mysqli, "SELECT {$use['column']}, COUNT(*), $archived FROM {$use['table']} + WHERE {$use['column']} IS NOT NULL AND {$use['column']} <> " . ($config['by'] === 'name' || $use['text'] ? "''" : '0') . " + GROUP BY {$use['column']}"); + while ($row = mysqli_fetch_row($sql)) { + $ids = $config['by'] === 'name' ? ($by_name[categoryNameKey($row[0])] ?? []) : [intval($row[0])]; + foreach ($ids as $category_id) { + if (!$category_id) { + continue; + } + $usage[$category_id] = $usage[$category_id] ?? ['all' => 0, 'archived' => 0, 'uses' => []]; + $usage[$category_id]['all'] += intval($row[1]); + $usage[$category_id]['archived'] += intval($row[2]); + $usage[$category_id]['uses'][$index] = [ + 'all' => ($usage[$category_id]['uses'][$index]['all'] ?? 0) + intval($row[1]), + 'archived' => ($usage[$category_id]['uses'][$index]['archived'] ?? 0) + intval($row[2]), + ]; + } + } + } + return $usage; +} + +/* + * For a kind held by name: the names records have that aren't one of its + * categories (typed in before there was a picker, or a category since + * deleted) - name => ['all' => n, 'archived' => n, 'uses' => [...]], as + * categoryUsage(), most used first. Spelt as most records have it. + */ +function categoryLooseNames($kind) { + global $mysqli; + $config = categoryKind($kind); + if (!$config || $config['by'] !== 'name') { + return []; + } + $known = []; + foreach (categoryRows($kind) as $row) { + $known[categoryNameKey($row['category_name'])] = true; + } + $loose = []; + $spelling = []; + foreach ($config['uses'] as $index => $use) { + $archived = $use['archived'] ? "SUM({$use['archived']} IS NOT NULL)" : '0'; + $sql = mysqli_query($mysqli, "SELECT {$use['column']}, COUNT(*), $archived FROM {$use['table']} + WHERE {$use['column']} IS NOT NULL AND TRIM({$use['column']}) <> '' GROUP BY {$use['column']} ORDER BY COUNT(*) DESC"); + while ($row = mysqli_fetch_row($sql)) { + $key = categoryNameKey($row[0]); + if (isset($known[$key])) { + continue; + } + $spelling[$key] = $spelling[$key] ?? trim($row[0]); + $loose[$key] = $loose[$key] ?? ['all' => 0, 'archived' => 0, 'uses' => []]; + $loose[$key]['all'] += intval($row[1]); + $loose[$key]['archived'] += intval($row[2]); + $loose[$key]['uses'][$index] = [ + 'all' => ($loose[$key]['uses'][$index]['all'] ?? 0) + intval($row[1]), + 'archived' => ($loose[$key]['uses'][$index]['archived'] ?? 0) + intval($row[2]), + ]; + } + } + uasort($loose, function ($a, $b) { + return $b['all'] <=> $a['all']; + }); + $named = []; + foreach ($loose as $key => $used) { + $named[$spelling[$key]] = $named[$spelling[$key]] ?? $used; + } + return $named; +} + +/* What uses one category - as categoryUsage(), all zero when nothing does. */ +function categoryUsageFor($category_id) { + $row = categoryRow($category_id); + $empty = ['all' => 0, 'archived' => 0, 'uses' => []]; + return $row ? (categoryUsage($row['category_type'])[intval($row['category_id'])] ?? $empty) : $empty; +} + +/* + * What uses a category, in words - "42 expenses (3 archived) · 2 budget + * months", each a link to the list showing them when there is one ($links). + * HTML. '' when nothing does. + */ +function categoryUsageHtml(array $row, array $used, $links = true) { + $config = categoryKind($row['category_type']); + if (!$config || empty($used['uses'])) { + return ''; + } + $parts = []; + foreach ($config['uses'] as $index => $use) { + if (empty($used['uses'][$index]['all'])) { + continue; + } + $n = $used['uses'][$index]['all']; + $text = escapeHtml(categoryUseText($n, $use)); + if ($links && $use['url']) { + $url = str_replace(['{id}', '{name}'], [intval($row['category_id']), rawurlencode((string) $row['category_name'])], $use['url']); + $text = '' . $text . ''; + } + $archived = $used['uses'][$index]['archived'] ?? 0; + $parts[] = '' . $text . ($archived ? ' (' . number_format($archived) . ' archived)' : '') . ''; + } + return implode(' ', $parts); +} + +/* What uses a category, as plain text for the audit log and messages - "42 expenses and 2 budget months". */ +function categoryUsageText(array $row, array $used) { + $config = categoryKind($row['category_type']); + $parts = []; + foreach ($config['uses'] ?? [] as $index => $use) { + if (!empty($used['uses'][$index]['all'])) { + $parts[] = categoryUseText($used['uses'][$index]['all'], $use); + } + } + if (count($parts) > 1) { + $last = array_pop($parts); + return implode(', ', $parts) . ' and ' . $last; + } + return $parts[0] ?? ''; +} + +/* + * A category from the category window - [$fields, $error]. $in: name, + * description, color ('' for none), icon ('' for none), and for a new one + * type. Raw values; categoryCreate() and categoryUpdate() escape them. The + * name is unique within its kind - archived ones too - and no longer than + * the records' columns hold. + */ +function categoryFromInput(array $in, $existing = null) { + global $mysqli; + $kind = $existing ? (string) $existing['category_type'] : (string) ($in['type'] ?? ''); + $config = categoryKind($kind); + if (!$config) { + return [null, 'Pick what the category is for']; + } + // Stored without markup (escapeSql() strips it), so checked without it + $name = trim(preg_replace('/\s+/u', ' ', strip_tags((string) ($in['name'] ?? '')))); + $description = trim(mb_substr(preg_replace('/\s+/u', ' ', strip_tags((string) ($in['description'] ?? ''))), 0, 255)); + if ($name === '') { + return [null, ucfirst(categoryKindPhrase($kind)) . ' needs a name']; + } + if (mb_strlen($name) > $config['max']) { + return [null, 'Keep the name to ' . $config['max'] . ' characters - that is all ' . $config['uses'][0]['many'] . ' hold']; + } + // A category already sharing its name with another (made before names were unique) can still be saved under it + $kept_name = $existing && categoryNameKey($existing['category_name']) === categoryNameKey($name); + $exclude = $existing ? 'AND category_id != ' . intval($existing['category_id']) : ''; + $same = $kept_name ? null : mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT category_name, category_archived_at FROM categories + WHERE category_type = '" . escapeSql($kind) . "' AND category_name = '" . escapeSql($name) . "' $exclude LIMIT 1")); + if ($same) { + return [null, 'There is already ' . ($same['category_archived_at'] ? 'an archived ' . $config['one'] : categoryKindPhrase($kind)) . ' called ' . $same['category_name'] + . ($same['category_archived_at'] ? ' - restore it instead' : '')]; + } + $color = trim((string) ($in['color'] ?? '')); + $color = $color === '' ? '' : categoryColor($color); + return [['type' => $kind, 'name' => $name, 'description' => $description, 'color' => $color, 'icon' => categoryIcon($in['icon'] ?? '')], '']; +} + +/* The SET list for a category's fields. */ +function categorySetSql(array $f) { + return "category_name = '" . escapeSql($f['name']) . "', category_description = " . ($f['description'] !== '' ? "'" . escapeSql($f['description']) . "'" : 'NULL') . ", + category_color = " . ($f['color'] !== '' ? "'" . escapeSql($f['color']) . "'" : 'NULL') . ", category_icon = " . ($f['icon'] !== '' ? "'fa-" . escapeSql($f['icon']) . "'" : 'NULL'); +} + +/* Make a category - last in its kind's order. Its id. */ +function categoryCreate(array $fields) { + global $mysqli; + $kind = escapeSql($fields['type']); + $order = intval(mysqli_fetch_row(mysqli_query($mysqli, "SELECT COALESCE(MAX(category_order), 0) FROM categories WHERE category_type = '$kind'"))[0]) + 1; + mysqli_query($mysqli, "INSERT INTO categories SET category_type = '$kind', category_order = $order, " . categorySetSql($fields)); + return intval(mysqli_insert_id($mysqli)); +} + +/* + * Save a category's name, description, colour and icon. A kind held by name + * takes a new name onto every record that has the old one - unless another + * category of the kind still has the old name. How many records were renamed. + */ +function categoryUpdate(array $row, array $fields) { + global $mysqli; + $category_id = intval($row['category_id']); + mysqli_query($mysqli, "UPDATE categories SET " . categorySetSql($fields) . " WHERE category_id = $category_id"); + + $config = categoryKind($row['category_type']); + if (!$config || $config['by'] !== 'name' || $row['category_name'] === $fields['name']) { + return 0; + } + $kind = escapeSql($config['kind']); + $old = escapeSql($row['category_name']); + if (mysqli_fetch_row(mysqli_query($mysqli, "SELECT 1 FROM categories WHERE category_type = '$kind' AND category_name = '$old' AND category_id != $category_id LIMIT 1"))) { + return 0; + } + return categoryMoveRecords($config, "= '$old'", "'" . escapeSql($fields['name']) . "'"); +} + +/* + * Point every record of a kind whose category matches $where_sql (" = 'x'", + * " IN (1, 2)") at $to_sql instead - leaving when each was last changed as + * it was, as the record itself didn't change. How many records moved. + */ +function categoryMoveRecords(array $config, $where_sql, $to_sql) { + global $mysqli; + $moved = 0; + foreach ($config['uses'] as $use) { + $keep = $use['updated'] ? ", {$use['updated']} = {$use['updated']}" : ''; + mysqli_query($mysqli, "UPDATE {$use['table']} SET {$use['column']} = $to_sql$keep WHERE {$use['column']} $where_sql"); + $moved += max(0, mysqli_affected_rows($mysqli)); + } + return $moved; +} + +/* Archive a category - it stays on its records, and isn't offered for new ones. */ +function categoryArchive($category_id) { + global $mysqli; + mysqli_query($mysqli, "UPDATE categories SET category_archived_at = NOW() WHERE category_archived_at IS NULL AND category_id = " . intval($category_id)); +} + +/* Offer an archived category again. */ +function categoryRestore($category_id) { + global $mysqli; + mysqli_query($mysqli, "UPDATE categories SET category_archived_at = NULL WHERE category_id = " . intval($category_id)); +} + +/* + * Whether a category can be deleted - only while nothing uses it, so no + * record is left pointing at nothing (or, for a kind held by name, at a name + * that can't be picked). One made before names were unique can go while + * another of its kind has its name, as its records are that one's. + * '' when it can, else why not. + */ +function categoryDeleteBlocker(array $row, array $used) { + global $mysqli; + if (!$used['all']) { + return ''; + } + $config = categoryKind($row['category_type']); + if ($config && $config['by'] === 'name' && mysqli_fetch_row(mysqli_query($mysqli, "SELECT 1 FROM categories + WHERE category_type = '" . escapeSql($config['kind']) . "' AND category_name = '" . escapeSql($row['category_name']) . "' AND category_id != " . intval($row['category_id']) . " LIMIT 1"))) { + return ''; + } + return 'In use on ' . categoryUsageText($row, $used) . ' - merge it into another, or archive it'; +} + +/* Delete a category, if nothing uses it (categoryDeleteBlocker()). Whether it went; $error says why not. */ +function categoryDelete($category_id, &$error = '') { + global $mysqli; + $row = categoryRow($category_id); + if (!$row) { + $error = 'That category is gone'; + return false; + } + $error = categoryDeleteBlocker($row, categoryUsageFor($row['category_id'])); + if ($error !== '') { + return false; + } + mysqli_query($mysqli, "DELETE FROM categories WHERE category_id = " . intval($row['category_id'])); + return mysqli_affected_rows($mysqli) > 0; +} + +/* + * Merge categories into one of the same kind: every record with one of them + * gets $into_id instead (by id or by name, as the kind is held), and they + * are deleted. A month budgeted under more than one ends with one line, of + * their total. How many records moved. + */ +function categoryMerge(array $from_ids, $into_id) { + global $mysqli; + $into = categoryRow($into_id); + $config = $into ? categoryKind($into['category_type']) : null; + if (!$config) { + return 0; + } + $into_id = intval($into['category_id']); + + // Only categories of the same kind + $from = []; + $from_list = implode(',', array_map('intval', array_diff(array_unique(array_filter(array_map('intval', $from_ids))), [$into_id])) ?: [0]); + $sql = mysqli_query($mysqli, "SELECT category_id, category_name FROM categories WHERE category_id IN ($from_list) AND category_type = '" . escapeSql($config['kind']) . "'"); + while ($row = mysqli_fetch_assoc($sql)) { + $from[intval($row['category_id'])] = $row['category_name']; + } + if (!$from) { + return 0; + } + + if ($config['by'] === 'name') { + // Names the kept one has are already its + $names = array_filter($from, function ($name) use ($into) { + return categoryNameKey($name) !== categoryNameKey($into['category_name']); + }); + $moved = $names ? categoryMoveRecords($config, "IN ('" . implode("', '", array_map('escapeSql', $names)) . "')", "'" . escapeSql($into['category_name']) . "'") : 0; + } else { + // An id held as text (tickets) is matched as text - comparing it to a number can fail on a value that isn't one + $moved = 0; + foreach ($config['uses'] as $use) { + $list = $use['text'] ? "'" . implode("', '", array_keys($from)) . "'" : implode(', ', array_keys($from)); + $moved += categoryMoveRecords(['uses' => [$use]], "IN ($list)", $use['text'] ? "'$into_id'" : $into_id); + } + if ($config['kind'] === 'Expense') { + categoryFoldBudget($into_id); + } + } + + mysqli_query($mysqli, "DELETE FROM categories WHERE category_id IN (" . implode(',', array_keys($from)) . ")"); + return $moved; +} + +/* One budget line per month for an expense category - after a merge gave it two, their total on the first. */ +function categoryFoldBudget($category_id) { + global $mysqli; + $category_id = intval($category_id); + $sql = mysqli_query($mysqli, "SELECT budget_year, budget_month, MIN(budget_id), SUM(budget_amount) FROM budget + WHERE budget_category_id = $category_id GROUP BY budget_year, budget_month HAVING COUNT(*) > 1"); + while ($row = mysqli_fetch_row($sql)) { + $keep = intval($row[2]); + mysqli_query($mysqli, "UPDATE budget SET budget_amount = " . round(floatval($row[3]), 2) . " WHERE budget_id = $keep"); + mysqli_query($mysqli, "DELETE FROM budget WHERE budget_category_id = $category_id AND budget_year = " . intval($row[0]) . " AND budget_month = " . intval($row[1]) . " AND budget_id != $keep"); + } +} + +/* + * Put a kind's categories in this order - the ids as listed, then the rest + * (the archived ones, say) after them in the order they had. Numbered from + * 1. How many moved. + */ +function categoryReorder($kind, array $ids) { + global $mysqli; + $rows = categoryRows($kind); + $ordered = []; + foreach ($ids as $id) { + $id = intval($id); + if (isset($rows[$id]) && !in_array($id, $ordered, true)) { + $ordered[] = $id; + } + } + foreach (array_keys($rows) as $id) { + if (!in_array($id, $ordered, true)) { + $ordered[] = $id; + } + } + $moved = 0; + foreach ($ordered as $position => $id) { + if (intval($rows[$id]['category_order']) !== $position + 1) { + mysqli_query($mysqli, "UPDATE categories SET category_order = " . ($position + 1) . " WHERE category_id = $id"); + $moved++; + } + } + return $moved; +} diff --git a/functions/format.php b/functions/format.php index 5f42e86e0..5d4ff8d16 100644 --- a/functions/format.php +++ b/functions/format.php @@ -410,11 +410,14 @@ function selectOptionContact($client_id, $contact_name, $contact_photo = '', $co /** A tag, category or status - its colour, and its icon if it has one. */ function selectOptionColor($color, $icon = '') { - // A hex value, or a CSS colour name - categories store names like "lightblue" + // A hex value, or a CSS colour name $color = preg_match('/^(#[0-9a-f]{3,8}|[a-z]{3,24})$/i', trim((string) $color)) ? trim($color) : ''; + // "star", "fa-star" (as categories keep them) or "fas fa-star" $icon = trim(preg_replace('/[^a-z0-9 -]/', '', strtolower((string) $icon))); if ($icon !== '' && strpos($icon, 'fa-') === false) { $icon = "fas fa-$icon"; + } elseif ($icon !== '' && !preg_match('/(^| )fa[srbl]?( |$)/', $icon)) { + $icon = "fas $icon"; } return selectOptionData(['color' => $color, 'icon' => $icon]); } diff --git a/js/category_form.js b/js/category_form.js new file mode 100644 index 000000000..1fc9db3df --- /dev/null +++ b/js/category_form.js @@ -0,0 +1,201 @@ +// New Category and Edit Category (admin/modals/category/category_form.php). +// The preview shows the category as it will look on records while its name, +// colour and icon change - an asset status as its badge, anything else its +// icon (or a dot) in its colour, as categoryChip() in functions/categories.php +// draws it. "None" is a choice for both. A swatch, the colour picker or a +// typed hex sets the colour; the search narrows the icons, and a whole Font +// Awesome icon name that isn't in the list can be used too. Renaming a kind +// held by name says the records will be renamed with it. The server checks +// it all again. +// +// Wrapped in an IIFE because a modal can be opened, closed and opened again +// in one page load, which re-runs this file. + +(function () { + + // Dark or white text on a #rrggbb colour - tagTextColor() in functions/tags.php + function textColor(value) { + const channel = function (hex) { + const c = parseInt(hex, 16) / 255; + return c <= 0.03928 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4); + }; + const luminance = 0.2126 * channel(value.substr(1, 2)) + 0.7152 * channel(value.substr(3, 2)) + 0.0722 * channel(value.substr(5, 2)); + return luminance > 0.4 ? '#212529' : '#fff'; + } + + // Whether Font Awesome draws an icon by this name + function iconExists(probe, name) { + probe.className = 'fas fa-' + name; + const content = window.getComputedStyle(probe, '::before').content; + return content !== '' && content !== 'none' && content !== 'normal'; + } + + // #rrggbb from what was typed - '' for none, null when it isn't a colour yet + function hexColor(value) { + value = String(value).trim().toLowerCase(); + if (value === '') { + return ''; + } + if (value.charAt(0) !== '#') { + value = '#' + value; + } + if (/^#[0-9a-f]{3}$/.test(value)) { + value = '#' + value[1] + value[1] + value[2] + value[2] + value[3] + value[3]; + } + return /^#[0-9a-f]{6}$/.test(value) ? value : null; + } + + document.querySelectorAll('[data-category-form]:not([data-wired])').forEach(function (form) { + form.setAttribute('data-wired', '1'); + + const preview = form.querySelector('[data-category-preview]'); + // An asset status's badge - its built-in tone when it has no colour of its own + const badge = preview.dataset.categoryBadge || ''; + const name = form.querySelector('[data-category-name]'); + const nameWas = form.dataset.categoryNameWas || ''; + const renameNote = form.querySelector('[data-category-rename-note]'); + const color = form.querySelector('[data-category-color]'); + const pick = form.querySelector('[data-category-color-pick]'); + const hex = form.querySelector('[data-category-color-hex]'); + const swatches = form.querySelectorAll('[data-category-swatch]'); + const icon = form.querySelector('[data-category-icon]'); + const search = form.querySelector('[data-category-icon-search]'); + const choices = form.querySelectorAll('[data-category-icon-choice]'); + const custom = form.querySelector('[data-category-icon-custom]'); + const none = form.querySelector('[data-category-icon-none]'); + const probe = document.createElement('i'); + probe.style.position = 'absolute'; + probe.style.visibility = 'hidden'; + form.appendChild(probe); + + // Redraw the preview from the fields + function draw() { + const value = color.value; + const label = name.value.trim() || 'New category'; + if (badge) { + const pill = preview.querySelector('.badge'); + pill.textContent = label; + pill.className = 'badge rounded-pill fw-normal itflow-category-badge' + (value ? '' : ' text-bg-' + badge); + pill.style.backgroundColor = value; + pill.style.color = value ? textColor(value) : ''; + return; + } + // Its icon in its colour, or a dot of it + const old = preview.querySelector('.itflow-category-mark'); + const mark = document.createElement(icon.value ? 'i' : 'span'); + mark.className = icon.value ? 'fas fa-fw fa-' + icon.value + ' itflow-category-mark' : 'itflow-category-dot itflow-category-mark'; + mark.setAttribute('aria-hidden', 'true'); + mark.style[icon.value ? 'color' : 'backgroundColor'] = value; + old.replaceWith(mark); + preview.querySelector('.itflow-category-name').textContent = label; + } + + function setColor(value, from) { + value = hexColor(value); + if (value === null) { + return; + } + color.value = value; + if (from !== pick && value !== '') { + pick.value = value; + } + if (from !== hex) { + hex.value = value; + } + swatches.forEach(function (swatch) { + const on = swatch.dataset.categorySwatch === value; + swatch.classList.toggle('active', on); + swatch.setAttribute('aria-pressed', on ? 'true' : 'false'); + }); + draw(); + } + + function setIcon(value) { + icon.value = value; + choices.forEach(function (choice) { + const on = choice.dataset.categoryIconChoice === value; + choice.classList.toggle('active', on); + choice.setAttribute('aria-pressed', on ? 'true' : 'false'); + }); + custom.classList.toggle('active', value !== '' && custom.dataset.icon === value); + draw(); + } + + function filterIcons() { + const term = search.value.trim().toLowerCase().replace(/^(fa[srb]?\s+)?fa-/, '').replace(/\s+/g, '-'); + let shown = 0; + let exact = false; + choices.forEach(function (choice) { + const value = choice.dataset.categoryIconChoice; + // "None" stays while nothing is searched + const match = term === '' || (value !== '' && value.indexOf(term) !== -1); + choice.hidden = !match; + shown += match ? 1 : 0; + exact = exact || value === term; + }); + const usable = term !== '' && !exact && /^[a-z0-9-]+$/.test(term) && iconExists(probe, term); + custom.hidden = !usable; + if (usable) { + custom.dataset.icon = term; + custom.querySelector('i').className = 'fas fa-fw fa-' + term + ' me-1'; + custom.querySelector('span').textContent = 'Use ' + term; + custom.classList.toggle('active', icon.value === term); + } + none.hidden = shown > 0 || usable; + return shown; + } + + name.addEventListener('input', function () { + draw(); + if (renameNote) { + renameNote.hidden = name.value.trim() === '' || name.value.trim() === nameWas; + } + }); + swatches.forEach(function (swatch) { + swatch.addEventListener('click', function () { + setColor(swatch.dataset.categorySwatch); + }); + }); + pick.addEventListener('input', function () { + setColor(pick.value, pick); + }); + hex.addEventListener('input', function () { + setColor(hex.value, hex); + }); + hex.addEventListener('change', function () { + hex.value = color.value; + }); + // Enter in a colour or search box picks, rather than saving + hex.addEventListener('keydown', function (e) { + if (e.key === 'Enter') { + e.preventDefault(); + hex.value = color.value; + } + }); + + choices.forEach(function (choice) { + choice.addEventListener('click', function () { + setIcon(choice.dataset.categoryIconChoice); + }); + }); + custom.addEventListener('click', function () { + setIcon(custom.dataset.icon); + }); + search.addEventListener('input', filterIcons); + search.addEventListener('keydown', function (e) { + if (e.key !== 'Enter') { + return; + } + e.preventDefault(); + const visible = Array.prototype.filter.call(choices, function (choice) { + return !choice.hidden && choice.dataset.categoryIconChoice !== ''; + }); + if (!custom.hidden) { + setIcon(custom.dataset.icon); + } else if (visible.length === 1) { + setIcon(visible[0].dataset.categoryIconChoice); + } + }); + }); + +})(); diff --git a/setup/seed_data.php b/setup/seed_data.php index 48ad0d683..b9ccf321b 100644 --- a/setup/seed_data.php +++ b/setup/seed_data.php @@ -27,45 +27,45 @@ function seedDefaultData($mysqli) // Create Categories // Expense Categories Examples - mysqli_query($mysqli,"INSERT INTO categories SET category_name = 'Office Supplies', category_type = 'Expense', category_color = 'blue'"); - mysqli_query($mysqli,"INSERT INTO categories SET category_name = 'Travel', category_type = 'Expense', category_color = 'purple'"); - mysqli_query($mysqli,"INSERT INTO categories SET category_name = 'Advertising', category_type = 'Expense', category_color = 'orange'"); - mysqli_query($mysqli,"INSERT INTO categories SET category_name = 'Processing Fee', category_type = 'Expense', category_color = 'gray'"); - mysqli_query($mysqli,"INSERT INTO categories SET category_name = 'Shipping and Postage', category_type = 'Expense', category_color = 'teal'"); - mysqli_query($mysqli,"INSERT INTO categories SET category_name = 'Software', category_type = 'Expense', category_color = 'lightblue'"); - mysqli_query($mysqli,"INSERT INTO categories SET category_name = 'Bank Fees', category_type = 'Expense', category_color = 'yellow'"); - mysqli_query($mysqli,"INSERT INTO categories SET category_name = 'Payroll', category_type = 'Expense', category_color = 'green'"); - mysqli_query($mysqli,"INSERT INTO categories SET category_name = 'Professional Services', category_type = 'Expense', category_color = 'darkblue'"); - mysqli_query($mysqli,"INSERT INTO categories SET category_name = 'Contractor', category_type = 'Expense', category_color = 'brown'"); - mysqli_query($mysqli,"INSERT INTO categories SET category_name = 'Insurance', category_type = 'Expense', category_color = 'red'"); - mysqli_query($mysqli,"INSERT INTO categories SET category_name = 'Infrastructure', category_type = 'Expense', category_color = 'darkgreen'"); - mysqli_query($mysqli,"INSERT INTO categories SET category_name = 'Equipment', category_type = 'Expense', category_color = 'gray'"); - mysqli_query($mysqli,"INSERT INTO categories SET category_name = 'Education', category_type = 'Expense', category_color = 'lightyellow'"); + mysqli_query($mysqli,"INSERT INTO categories SET category_name = 'Office Supplies', category_type = 'Expense', category_color = '#0000ff'"); + mysqli_query($mysqli,"INSERT INTO categories SET category_name = 'Travel', category_type = 'Expense', category_color = '#800080'"); + mysqli_query($mysqli,"INSERT INTO categories SET category_name = 'Advertising', category_type = 'Expense', category_color = '#ffa500'"); + mysqli_query($mysqli,"INSERT INTO categories SET category_name = 'Processing Fee', category_type = 'Expense', category_color = '#808080'"); + mysqli_query($mysqli,"INSERT INTO categories SET category_name = 'Shipping and Postage', category_type = 'Expense', category_color = '#008080'"); + mysqli_query($mysqli,"INSERT INTO categories SET category_name = 'Software', category_type = 'Expense', category_color = '#add8e6'"); + mysqli_query($mysqli,"INSERT INTO categories SET category_name = 'Bank Fees', category_type = 'Expense', category_color = '#ffff00'"); + mysqli_query($mysqli,"INSERT INTO categories SET category_name = 'Payroll', category_type = 'Expense', category_color = '#008000'"); + mysqli_query($mysqli,"INSERT INTO categories SET category_name = 'Professional Services', category_type = 'Expense', category_color = '#00008b'"); + mysqli_query($mysqli,"INSERT INTO categories SET category_name = 'Contractor', category_type = 'Expense', category_color = '#a52a2a'"); + mysqli_query($mysqli,"INSERT INTO categories SET category_name = 'Insurance', category_type = 'Expense', category_color = '#ff0000'"); + mysqli_query($mysqli,"INSERT INTO categories SET category_name = 'Infrastructure', category_type = 'Expense', category_color = '#006400'"); + mysqli_query($mysqli,"INSERT INTO categories SET category_name = 'Equipment', category_type = 'Expense', category_color = '#808080'"); + mysqli_query($mysqli,"INSERT INTO categories SET category_name = 'Education', category_type = 'Expense', category_color = '#ffffe0'"); // Income Categories Examples - mysqli_query($mysqli,"INSERT INTO categories SET category_name = 'Managed Services', category_type = 'Income', category_color = 'green'"); - mysqli_query($mysqli,"INSERT INTO categories SET category_name = 'Consulting', category_type = 'Income', category_color = 'blue'"); - mysqli_query($mysqli,"INSERT INTO categories SET category_name = 'Projects', category_type = 'Income', category_color = 'purple'"); - mysqli_query($mysqli,"INSERT INTO categories SET category_name = 'Hardware Sales', category_type = 'Income', category_color = 'silver'"); - mysqli_query($mysqli,"INSERT INTO categories SET category_name = 'Software Sales', category_type = 'Income', category_color = 'lightblue'"); - mysqli_query($mysqli,"INSERT INTO categories SET category_name = 'Cloud Services', category_type = 'Income', category_color = 'skyblue'"); - mysqli_query($mysqli,"INSERT INTO categories SET category_name = 'Support', category_type = 'Income', category_color = 'yellow'"); - mysqli_query($mysqli,"INSERT INTO categories SET category_name = 'Training', category_type = 'Income', category_color = 'lightyellow'"); - mysqli_query($mysqli,"INSERT INTO categories SET category_name = 'Telecom Services', category_type = 'Income', category_color = 'orange'"); - mysqli_query($mysqli,"INSERT INTO categories SET category_name = 'Backup', category_type = 'Income', category_color = 'darkblue'"); - mysqli_query($mysqli,"INSERT INTO categories SET category_name = 'Security', category_type = 'Income', category_color = 'red'"); - mysqli_query($mysqli,"INSERT INTO categories SET category_name = 'Licensing', category_type = 'Income', category_color = 'green'"); - mysqli_query($mysqli,"INSERT INTO categories SET category_name = 'Monitoring', category_type = 'Income', category_color = 'teal'"); + mysqli_query($mysqli,"INSERT INTO categories SET category_name = 'Managed Services', category_type = 'Income', category_color = '#008000'"); + mysqli_query($mysqli,"INSERT INTO categories SET category_name = 'Consulting', category_type = 'Income', category_color = '#0000ff'"); + mysqli_query($mysqli,"INSERT INTO categories SET category_name = 'Projects', category_type = 'Income', category_color = '#800080'"); + mysqli_query($mysqli,"INSERT INTO categories SET category_name = 'Hardware Sales', category_type = 'Income', category_color = '#c0c0c0'"); + mysqli_query($mysqli,"INSERT INTO categories SET category_name = 'Software Sales', category_type = 'Income', category_color = '#add8e6'"); + mysqli_query($mysqli,"INSERT INTO categories SET category_name = 'Cloud Services', category_type = 'Income', category_color = '#87ceeb'"); + mysqli_query($mysqli,"INSERT INTO categories SET category_name = 'Support', category_type = 'Income', category_color = '#ffff00'"); + mysqli_query($mysqli,"INSERT INTO categories SET category_name = 'Training', category_type = 'Income', category_color = '#ffffe0'"); + mysqli_query($mysqli,"INSERT INTO categories SET category_name = 'Telecom Services', category_type = 'Income', category_color = '#ffa500'"); + mysqli_query($mysqli,"INSERT INTO categories SET category_name = 'Backup', category_type = 'Income', category_color = '#00008b'"); + mysqli_query($mysqli,"INSERT INTO categories SET category_name = 'Security', category_type = 'Income', category_color = '#ff0000'"); + mysqli_query($mysqli,"INSERT INTO categories SET category_name = 'Licensing', category_type = 'Income', category_color = '#008000'"); + mysqli_query($mysqli,"INSERT INTO categories SET category_name = 'Monitoring', category_type = 'Income', category_color = '#008080'"); // Referral Examples - mysqli_query($mysqli,"INSERT INTO categories SET category_name = 'Friend', category_type = 'Referral', category_color = 'blue'"); - mysqli_query($mysqli,"INSERT INTO categories SET category_name = 'Search', category_type = 'Referral', category_color = 'orange'"); - mysqli_query($mysqli,"INSERT INTO categories SET category_name = 'Social Media', category_type = 'Referral', category_color = 'green'"); - mysqli_query($mysqli,"INSERT INTO categories SET category_name = 'Email', category_type = 'Referral', category_color = 'yellow'"); - mysqli_query($mysqli,"INSERT INTO categories SET category_name = 'Partner', category_type = 'Referral', category_color = 'purple'"); - mysqli_query($mysqli,"INSERT INTO categories SET category_name = 'Event', category_type = 'Referral', category_color = 'red'"); - mysqli_query($mysqli,"INSERT INTO categories SET category_name = 'Affiliate', category_type = 'Referral', category_color = 'pink'"); - mysqli_query($mysqli,"INSERT INTO categories SET category_name = 'Client', category_type = 'Referral', category_color = 'lightblue'"); + mysqli_query($mysqli,"INSERT INTO categories SET category_name = 'Friend', category_type = 'Referral', category_color = '#0000ff'"); + mysqli_query($mysqli,"INSERT INTO categories SET category_name = 'Search', category_type = 'Referral', category_color = '#ffa500'"); + mysqli_query($mysqli,"INSERT INTO categories SET category_name = 'Social Media', category_type = 'Referral', category_color = '#008000'"); + mysqli_query($mysqli,"INSERT INTO categories SET category_name = 'Email', category_type = 'Referral', category_color = '#ffff00'"); + mysqli_query($mysqli,"INSERT INTO categories SET category_name = 'Partner', category_type = 'Referral', category_color = '#800080'"); + mysqli_query($mysqli,"INSERT INTO categories SET category_name = 'Event', category_type = 'Referral', category_color = '#ff0000'"); + mysqli_query($mysqli,"INSERT INTO categories SET category_name = 'Affiliate', category_type = 'Referral', category_color = '#ffc0cb'"); + mysqli_query($mysqli,"INSERT INTO categories SET category_name = 'Client', category_type = 'Referral', category_color = '#add8e6'"); // Payment Methods mysqli_query($mysqli,"INSERT INTO payment_methods SET payment_method_name = 'Cash'");