diff --git a/CHANGELOG.md b/CHANGELOG.md index 6147d4237..f73982bea 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1195,9 +1195,14 @@ If you are coming from 26.08 or earlier, the database update still has to be run - Billing: the hourly rate and net terms a new client starts with, a tax rate for new lines when there is no default (made and made the default), invoice and quote numbering, and - if you like - Stripe, with its keys put to Stripe before they are saved. - Support: business days and hours, the standard response times for each priority as the default SLAs (Urgent answered in 1 business hour and put right in 4, High 2 and 8, Medium 4 and 16, Low 8 and 40 - one with the same hours is used when there is one, and only priorities with none get them), ticket numbering and the client portal on or off. Open tickets are stamped afresh with the hours and response times saved. - Your team: everyone who signs in, and Add someone - a name, an email and a role. ITFlow makes their password: they are emailed their sign-in details, as Admin > Users does, or, with no email set up yet, it is shown to you once to give them. They change it once signed in. - - Starter content: the packs from Maintenance > Starter Content, each with how much it adds, ticked to add - and Pick which under each, listing what it would add to choose one by one: every item starts ticked, categories grouped by type, tags by what they tag and products by category, with All and None, and the pack's count says how many are picked. Your first client: a name, website and contact, made as the Clients page makes one - or the demo data, to look around first. + - Starter content: the packs from Maintenance > Starter Content, each with how much it adds, ticked to add - and Pick which under each, as on that page (see Starter Content, Pick which). Your first client: a name, website and contact, made as the Clients page makes one - or the demo data, to look around first. - The dashboard shows Finish setting up, with how far it has got, a chip for each step and Carry on, to admins until every step is done or skipped. Its × stops it showing there. Admin > Get started opens it any time, with how many steps are done or skipped beside it, and a summary at the end lists every step with where it stands. - The setup wizard's last step says Get started comes next. +- **Starter Content, Pick which** (new) + - Under each pack on Maintenance > Starter Content and Get started's Starter content step, Pick which lists what the pack would add, to choose one by one. Every item starts ticked, so leaving it alone adds the whole pack as before; All and None tick or clear them, and the pack's count says how many are picked. What is here already isn't listed. + - Each item shows as it will look: a tag as its pill, a category as its chip in its colour and icon, a product with its price and how it is billed - with what each is for. Categories are grouped by type, tags by what they tag and products by income category. + - Preview, on ticket, project, document, vendor and license templates, canned responses and products, opens the whole of it in a window: a ticket template's subject, category, priority, tags, request form, what it says, what it asks for and its tasks; a canned response's reply type, ticket categories, status and what it says; a document template's review period, fill-ins and content; a vendor's support site, status page, portal and what support asks for; a license's type, renewal, vendor and product; a product's price, billing, code and category; a project template's tickets in order. The words filled in as each is used are marked. + - On Maintenance > Starter Content, a pack's Add brings only what is ticked - and waits while nothing is - and Will be added says how many. Add all still adds every pack whole. ### Bug Fixes @@ -2002,7 +2007,7 @@ If you are coming from 26.08 or earlier, the database update still has to be run - Contract prepaid hours: database 3.4.4 adds to `contract_templates` and `contracts` `{prefix}_monthly_hours` and `{prefix}_hours_alert` decimal(8,2) (0.00), and `contracts.contract_hours_alerted_at` datetime, plus two tables. `contract_blocks`: `contract_block_contract_id` (ON DELETE CASCADE), `_hours`, `_price` (NULL for none), `_bought`, `_expires` (NULL for never), `_month` (char(7), for a month's included hours - UNIQUE with the contract, so a month is made once), `_invoice_id`, `_note`, `_created_by`, `_created_at`. `contract_block_uses`: `contract_block_use_block_id` (ON DELETE CASCADE), `_ticket_id`, `_hours`, `_full` (on the use that covered the rest of a ticket), `_created_by`, `_created_at`; a use of 0 hours keeps a ticket that was put back out of the draw. New `functions/contract_hours.php`: `contractHoursReady()`, `contractHoursText()`, `contractHoursWords()`, `contractBlocks()`, `contractHoursSummary()`, `contractHoursLeftFor()`, `contractHasHours()`, `contractClientHours()`, `contractHoursEnsureMonthly()`, `contractHoursSync()` (after a save, Make active, Renew or a countersignature that makes a draft active), `contractHoursTicketUses()`, `contractHoursUses()`, `contractHoursDraw()` (one at a time per client, under `GET_LOCK`), `contractHoursDoneOn()`, `contractHoursRecheck()`, `contractHoursPutBack()`, `contractHoursAlertReset()`, `contractHoursAlertCheck()`, `contractHoursAlertSend()`, `contractHoursFromInput()`, `contractHoursAdd()` (the invoice through `billingInvoiceCreate()` and `billingItemSave()`), `contractHoursBlockDelete()`, `contractBlockRow()` and `contractHoursNightly()`. `billingUnbilledSql()` leaves out a ticket with a full use; `billingUnbilledTickets()` adds `worked` (the hours the time logged makes) and `prepaid`, and `hours` is now what is left after them; `billingUnbilledByClient()` adds `prepaid`. `contractFromInput()` takes `monthly_hours` and `hours_alert`, `contractTermsSetSql()` writes them once 3.4.4 has run, and `contractUpdate()` logs them and clears `contract_hours_alerted_at` when the level changes. `contractsNightly()` returns `drawn` too, logged by `cron/nightly_tasks.php`. `ticketDeleteForGood()` deletes the ticket's uses. `CONTRACT_PLACEHOLDERS` adds Monthly Hours, and `contractDocumentHtml()` adds an Included hours row only when there are some, so documents without them hash as before. `agent/post/contract.php` adds `add_contract_hours` and `draw_contract_hours` (POST, with a `contract_id`, a `client_id` or a `ticket_id`) and `delete_contract_block` and `put_back_contract_hours` (GET with the token), all with Sales Modify. New `agent/modals/contract/contract_hours_add.php`. `emailPreviewContractCatalog()` adds `contract_hours_low`. New CSS: `.itflow-contract-hours-left` and `.itflow-hours-bar` (a ``, as the portal's CSP allows no inline styles). - Contract billing and SLAs: database 3.4.5 adds `contracts.contract_recurring_invoice_id` int (0, keyed) and `contract_recurring_stopped_at` datetime, set when the contract pauses its recurring invoice so it does so once, and sets a rate of 0.00 on contracts and templates to NULL (`contractFromInput()` saves 0 as NULL too). New `functions/contract_billing.php`: `contractBillingReady()`, `contractTermsContracts()` (a client's contracts that can be in force, kept for the request), `contractInForceOn()`, `contractTermsOn()` (the one started last that sets a rate, net terms or a priority's SLA), `contractsInForce()`, `contractSlaFor()`, `contractAfterHours()` (from `getSlaSettings()` and `getBusinessHolidays()`), `contractTicketRate()` (source after_hours, contract, client or default, and a note), `contractClientRate()`, `contractNetTerms()`, `contractNetTermsToday()`, `contractRecurringRow()`, `contractForRecurring()`, `contractRecurringChoices()`, `contractRecurringShouldRun()`, `contractRecurringAmountText()`, `contractRecurringStart()` (skips missed runs with `recurringInvoiceCatchUp()`), `contractRecurringStop()`, `contractRecurringFollow()`, `contractRecurringLink()`, `contractRecurringUnlink()`, `contractRecurringFromInput()`, `contractRecurringFirstDate()`, `contractRecurringCreate()` and `contractRecurringNightly()`. `getTicketSlaId()` asks `contractSlaFor()` first, and takes `$with_contract` (false for the client's own - `contractSlaRows()` uses it). `billingUnbilledTickets()` prices each ticket with `contractTicketRate()` and adds `rate_source` and `rate_note`; `billingUnbilledByClient()` adds `rates`, `rate_note` and `rate_source`; `billingTimeInvoice()` with no price bills each ticket at its own rate, and `combine` makes one line per rate. `billingInvoiceCreate()`, `quoteConvert()`, `recurringInvoiceGenerate()`, New Invoice and Copy in `agent/post/invoice.php` take `contractNetTerms()`. `contractsNightly()` returns `paused` too, logged by `cron/nightly_tasks.php`. `agent/post/contract.php` adds `make_contract_recurring` and `link_contract_recurring` (POST) and `unlink_contract_recurring` (GET with the token), all Sales Modify, and Make active, Renew, Cancel and a countersignature that makes a draft active call `contractRecurringFollow()`; deleting a recurring invoice unlinks its contract. New `agent/modals/contract/contract_recurring.php`. The prepaid hours handlers pass the agent's name unescaped, as the history and logs escape it. - Client portal invites and sign-ups: database 3.4.6 adds `portal_invites` - `invite_kind` (invite or signup), `invite_email`, `invite_name`, `invite_billing`, `invite_technical`, `invite_token_hash` (the SHA-256 of the link's token), `invite_expires_at`, `invite_sent_at`, `invite_sent_count`, `invite_created_by` (an agent), `invite_created_by_contact_id` (a primary contact), `invite_created_at`, `invite_accepted_at`, `invite_contact_id`, `invite_cancelled_at` and `invite_client_id` (ON DELETE CASCADE), keyed on the client and address and on the contact - plus `clients.client_portal_signup` and `settings.config_client_portal_signup` tinyint(1) (0). New `functions/portal_invite.php`: `PORTAL_SIGNUP_LINK_HOURS` (24), `PORTAL_INVITE_MAX` (50), `PORTAL_SIGNUP_IP_LIMIT` (20), `portalInvitesReady()` (keeps every page and the nightly job as they were before 3.4.6 has run), `portalInviteParse()`, `portalInviteWho()` (new, contact, member or elsewhere), `portalInviteRow()`, `portalInviteBy()`, `portalInviteState()`, `portalInviteUrl()`, `portalInviteNewToken()`, `portalInviteByLink()` (hash_equals), `portalInviteMail()`, `portalInviteSend()`, `portalInviteResend()`, `portalInviteCancel()`, `portalInvitesWaiting()`, `portalInviteStateLabel()`, `portalContactJoined()`, `portalInviteAccept()` (spends the link with one guarded UPDATE first, so a second press or a race does nothing), `portalMailSignupJoined()`, `portalMailInviteAccepted()`, `portalSignupOn()`, `portalSignupDomains()`, `portalSignupClients()`, `portalSignupOpen()`, `portalSignupClientFor()`, `portalSignupRequest()` (logged as `Client Login` Sign Up Requested, Sign Up Sent and Sign Up Refused), `portalMailSignupExists()`, `portalSignupSet()` and `portalInvitesNightly()`, which `cron/nightly_tasks.php` runs and logs. `portalContactAccess()` and `portalSignInMethods()` move from `client/functions.php` to `functions/portal.php`, as cron and the agent side use them now. `agent/post/contact.php` adds `send_portal_invites` (POST) and `resend_portal_invite` and `cancel_portal_invite` (GET with the token), and `agent/post/client.php` `set_client_portal_signup` (POST), all Client Modify. `agent/modals/contact/contact_invite.php`, an unused stub (Welcome Letter, Big Wig) with no handler, is replaced by the Invite people window. `agent/contacts.php` adds `role=invites`. `client/post.php` adds `portal_invites_send`, `portal_invite_resend` and `portal_invite_cancel` (the primary contact's). New `client/invite.php`, `client/join.php` (`?invite=&token=`, Referrer-Policy no-referrer) and `client/signup.php` (a form token in the session and a hidden field robots fill in). `edit_client_portal_settings` saves `config_client_portal_signup` once 3.4.6 has run. `emailPreviewInviteCatalog()` adds `portal_join_invite`, `portal_join_invite_colleague`, `portal_join_accepted`, `portal_signup`, `portal_signup_exists` and `portal_signup_joined`. `auditLogActionStyle()` adds Invite and the Sign Up actions. New CSS: `.itflow-join-box`. -- Get started: database 3.4.7 adds `settings.config_getting_started` tinyint(1) (1 in db.sql, so a new install has it; the update sets it to 0 for one already running), `config_getting_started_skipped` varchar(255) (the steps skipped, comma separated) and `config_getting_started_opened_at` datetime (when it was first opened - it opens by itself on signing in only until then). New `functions/getting_started.php`: `GETTING_STARTED_STEPS`, `GETTING_STARTED_SLAS`, `gettingStartedReady()` (nothing shows before 3.4.7 has run), `gettingStartedState()`, `gettingStartedUrl()`, `gettingStartedCronCommand()`, `gettingStartedBackupJob()`, `gettingStartedSteps()` (each step's done, skipped and status, from the settings), `gettingStartedProgress()`, `gettingStartedAfter()`, `gettingStartedSkip()`, `gettingStartedOpened()`, `gettingStartedDismiss()`, `gettingStartedFirstVisit()` (login.php takes the first admin there, after MFA enforcement and not instead of a `last_visited` page), `gettingStartedCard()` (the dashboard's card), `gettingStartedBackupSave()` (the backup job's row in `cron_jobs`, seeded from the registry when the dispatcher hasn't yet), `gettingStartedApplySlas()` and `gettingStartedDaysText()`. New `admin/getting_started.php` (a page of its own, in the setup wizard's frame; `?step=`, and `?cron_status=1` answers in JSON), its steps in `admin/getting_started/` and `admin/post/getting_started.php`: `getting_started_cron_on`, `getting_started_mail` (continue or test), `getting_started_backups`, `getting_started_billing`, `getting_started_support`, `getting_started_team`, `getting_started_content`, `getting_started_skip` and `getting_started_dismiss`. The first client posts `add_client` to `agent/post.php` and the demo data `load_demo_data`, both of which return to the step. Admin > Mail's check of a sending or receiving server moves from a closure in `admin/post/settings_mail.php` to `settingsMailServerCheck($server, $before, $post)` in `functions/settings.php`, which both use. In `admin/post/starter_content_model.php`, `starterContentLoad()` and each `starterLoad*()` take `$only`, the items to load by `starterItemKey()` (null for the whole pack, as Maintenance > Starter Content loads it), and new `starterContentRows()`, `starterItemKey()` (a category's or tag's type with its name), `starterItemName()`, `starterItemGroup()`, `starterOnly()` and `starterContentItems()` (what a pack would add, judged here as each loader judges it); `getting_started_content` takes `picked[pack]` and `items[pack][]`. New `js/getting_started.js` (`data-gs-reveal`, `data-gs-show`, `data-gs-cron-poll`, `data-gs-pack`), and `js/app.js` only starts DataTables where it is loaded, so setup and Get started, which load app.js on its own, no longer log an error to the console. New CSS: `.itflow-gs-*`. +- Get started: database 3.4.7 adds `settings.config_getting_started` tinyint(1) (1 in db.sql, so a new install has it; the update sets it to 0 for one already running), `config_getting_started_skipped` varchar(255) (the steps skipped, comma separated) and `config_getting_started_opened_at` datetime (when it was first opened - it opens by itself on signing in only until then). New `functions/getting_started.php`: `GETTING_STARTED_STEPS`, `GETTING_STARTED_SLAS`, `gettingStartedReady()` (nothing shows before 3.4.7 has run), `gettingStartedState()`, `gettingStartedUrl()`, `gettingStartedCronCommand()`, `gettingStartedBackupJob()`, `gettingStartedSteps()` (each step's done, skipped and status, from the settings), `gettingStartedProgress()`, `gettingStartedAfter()`, `gettingStartedSkip()`, `gettingStartedOpened()`, `gettingStartedDismiss()`, `gettingStartedFirstVisit()` (login.php takes the first admin there, after MFA enforcement and not instead of a `last_visited` page), `gettingStartedCard()` (the dashboard's card), `gettingStartedBackupSave()` (the backup job's row in `cron_jobs`, seeded from the registry when the dispatcher hasn't yet), `gettingStartedApplySlas()` and `gettingStartedDaysText()`. New `admin/getting_started.php` (a page of its own, in the setup wizard's frame; `?step=`, and `?cron_status=1` answers in JSON), its steps in `admin/getting_started/` and `admin/post/getting_started.php`: `getting_started_cron_on`, `getting_started_mail` (continue or test), `getting_started_backups`, `getting_started_billing`, `getting_started_support`, `getting_started_team`, `getting_started_content`, `getting_started_skip` and `getting_started_dismiss`. The first client posts `add_client` to `agent/post.php` and the demo data `load_demo_data`, both of which return to the step. Admin > Mail's check of a sending or receiving server moves from a closure in `admin/post/settings_mail.php` to `settingsMailServerCheck($server, $before, $post)` in `functions/settings.php`, which both use. In `admin/post/starter_content_model.php`, `starterContentLoad()` and each `starterLoad*()` take `$only`, the items to load by `starterItemKey()` (null for the whole pack, as Maintenance > Starter Content loads it), and new `starterContentRows()`, `starterItemKey()` (a category's or tag's type with its name), `starterItemName()`, `starterItemGroup()`, `starterOnly()` and `starterContentItems()` (what a pack would add, judged here as each loader judges it); `getting_started_content` and `load_starter_content` (for one pack) take `picked[pack]` and `items[pack][]`; `starterContentItems()` gives each item's library row. New `admin/includes/starter_content_pick.php` (both pages): `starterItemAbout()`, `starterItemLook()` (through `tagPill()`, `categoryChip()` and `productBilledText()`), `starterPreviewPacks()`, `starterItemPreview()`, `starterPickHtml($pack, $items, $form)` (the list, tied by `form=` to a table row's Add) and `starterPreviewModalHtml()`; new `admin/starter_content_preview.php` answers Preview in JSON (`?pack=&item=`, admins only, 404 for anything else); new `js/starter_content.js` (`data-starter-pick`, `data-starter-item`, `data-starter-pack-check`, `data-starter-count`, `data-starter-add`, `data-starter-preview`) takes over from the picking in `js/getting_started.js`; new CSS `.itflow-starter-*`. New `js/getting_started.js` (`data-gs-reveal`, `data-gs-show`, `data-gs-cron-poll`), and `js/app.js` only starts DataTables where it is loaded, so setup and Get started, which load app.js on its own, no longer log an error to the console. New CSS: `.itflow-gs-*`. ### Library Updates diff --git a/admin/getting_started.php b/admin/getting_started.php index d46685470..b478269ae 100644 --- a/admin/getting_started.php +++ b/admin/getting_started.php @@ -188,6 +188,7 @@ define('FROM_GETTING_STARTED', true); + diff --git a/admin/getting_started/content.php b/admin/getting_started/content.php index 840a290e9..55d4de99b 100644 --- a/admin/getting_started/content.php +++ b/admin/getting_started/content.php @@ -3,17 +3,17 @@ /* * Get started - Starter content: the packs Maintenance > Starter Content has, * each with how much it would add, ticked to add (getting_started_content). - * Pick which, under each, lists what it would add - every item ticked, so the - * whole pack goes in unless some are unticked (picked[pack] says the list was - * there, items[pack][] what is ticked in it; js/getting_started.js keeps the - * pack's tick and count in step). Only what isn't here already is listed or - * added. + * Pick which, under each, lists what it would add as it will look, with + * Preview - every item ticked, so the whole pack goes in unless some are + * unticked (includes/starter_content_pick.php, js/starter_content.js). Only + * what isn't here already is listed or added. */ defined('FROM_GETTING_STARTED') || die("Direct file access is not allowed"); defined('FROM_STARTER_CONTENT') || define('FROM_STARTER_CONTENT', true); require_once dirname(__DIR__) . '/post/starter_content_model.php'; +require_once dirname(__DIR__) . '/includes/starter_content_pick.php'; $gs_packs = starterContentPacks(); @@ -26,11 +26,10 @@ $gs_packs = starterContentPacks(); $gs_items = starterContentItems($mysqli, $gs_key); $gs_total = starterContentTotal($gs_key); $gs_missing = count($gs_items); - $gs_all_here = $gs_missing === 0; - $gs_count = $gs_all_here ? '' : ($gs_missing < $gs_total ? "$gs_missing more to add" : "$gs_missing to add"); ?> -
+ $gs_all_here = $gs_missing === 0; ?> +
- data-gs-pack-check> + data-starter-pack-check>
- -
- Pick which - -
- - - -
-
-
- $gs_item) { - if ($gs_item['group'] !== '' && $gs_item['group'] !== $gs_group) { - $gs_group = $gs_item['group']; ?> -
- -
- - -
- -
-
-
- +
@@ -77,3 +51,4 @@ $gs_packs = starterContentPacks(); + diff --git a/admin/includes/starter_content_pick.php b/admin/includes/starter_content_pick.php new file mode 100644 index 000000000..0d0983e8d --- /dev/null +++ b/admin/includes/starter_content_pick.php @@ -0,0 +1,281 @@ + Starter Content (admin/starter_content.php) and the Starter + * content step of Get started (admin/getting_started/content.php) both offer + * it. Each item is shown as it will look - a tag as its pill, a category as + * its chip, a product with its price - with what it is for, and a template or + * canned response has Preview, the whole of it in a window + * (admin/starter_content_preview.php answers it). js/starter_content.js keeps + * the ticks and counts in step and opens the previews. + * + * The form sends picked[pack] (the list was there) and items[pack][] (what is + * ticked, by starterItemKey()); the item lists come from + * starterContentItems() in post/starter_content_model.php, which has to be + * loaded first. + */ + +defined('FROM_STARTER_CONTENT') || die("Direct file access is not allowed"); + +/* What an item is for, in a line - '' when the library says nothing. */ +function starterItemAbout($pack, $row) { + switch ($pack) { + case 'categories': + return (string) ($row['description'] ?? ''); + case 'tags': + return ''; + case 'products': + return (string) ($row[5] ?? ''); + } + return (string) ($row['description'] ?? ''); +} + +/* + * An item as it will look - a tag as its pill, a category as its chip, a + * product with its price and how it is billed, anything else its name. + */ +function starterItemLook($pack, $row) { + switch ($pack) { + case 'tags': + return tagPill(['tag_name' => $row[1], 'tag_color' => $row[2], 'tag_icon' => $row[3]]); + case 'categories': + return categoryChip(['category_name' => $row['name'], 'category_type' => $row['type'], 'category_color' => $row['color'], 'category_icon' => $row['icon']]); + case 'products': + [$frequency, $seat] = array_pad(explode(':', (string) ($row[6] ?? '')), 2, ''); + return '' . escapeHtml($row[0]) . ' ' + . escapeHtml(billingMoney(floatval($row[3])) . ' · ' . productBilledText($frequency, $seat)) . ''; + } + return '' . escapeHtml(starterItemName($pack, $row)) . ''; +} + +/* The packs whose items have more to them than a line - shown whole by Preview. */ +function starterPreviewPacks() { + return ['ticket_templates', 'project_templates', 'canned_responses', 'vendor_templates', 'document_templates', 'license_templates', 'products']; +} + +/* [Words in brackets] in a template, marked as the bits filled in as it is used. */ +function starterPreviewFills($html) { + return preg_replace('/\[([A-Za-z][A-Za-z0-9 ,.\'\/&;-]{0,40})\]/', '[$1]', (string) $html); +} + +/* A fact of a preview - its label and the value (html), or nothing when there isn't one. */ +function starterPreviewFact($label, $html) { + return trim((string) $html) === '' ? '' : '
' . escapeHtml($label) . '
' . $html . '
'; +} + +/* What a template asks for, as a list - each with its kind, choices and whether it has to be answered. */ +function starterPreviewFillIns(array $fill_ins) { + if (!$fill_ins) { + return ''; + } + $kinds = ['text' => 'text', 'textarea' => 'longer text', 'date' => 'a date', 'number' => 'a number', 'yesno' => 'yes or no', 'list' => 'one of', 'email' => 'an email address']; + $html = '

What it asks for

'; +} + +/* A web address as a link out - its host and path as given, https:// in front. */ +function starterPreviewLink($address) { + $address = trim((string) $address); + if ($address === '') { + return ''; + } + $url = preg_match('#^https?://#i', $address) ? $address : "https://$address"; + return '' . escapeHtml($address) . ''; +} + +/* + * An item whole, for Preview - what it says and sets up, as it will come in. + * '' for a pack whose items are all there is to them (starterItemLook()). + */ +function starterItemPreview($pack, $row) { + $about = starterItemAbout($pack, $row); + $html = $about !== '' && $pack !== 'products' ? '

' . escapeHtml($about) . '

' : ''; + switch ($pack) { + case 'ticket_templates': + $category = !empty($row['category']) ? categoryChip(['category_name' => $row['category']['name'], 'category_type' => 'Ticket', 'category_color' => $row['category']['color'] ?? '', 'category_icon' => $row['category']['icon'] ?? '']) : ''; + $tags = implode(' ', array_map(function ($tag) { + return tagPill(['tag_name' => $tag['name'], 'tag_color' => $tag['color'] ?? '', 'tag_icon' => $tag['icon'] ?? '']); + }, (array) ($row['tags'] ?? []))); + $due = intval($row['due_hours'] ?? 0); + $portal = !empty($row['portal']) ? '' . escapeHtml((string) $row['portal']['title']) . ' - ' . escapeHtml((string) ($row['portal']['description'] ?? '')) + . '
A request form in the client portal - it comes in turned off.
' : ''; + $html .= '
' + . starterPreviewFact('Subject', starterPreviewFills(escapeHtml((string) $row['subject']))) + . starterPreviewFact('Category', $category) + . starterPreviewFact('Priority', escapeHtml((string) ($row['priority'] ?? ''))) + . starterPreviewFact('Due', $due ? ($due % 24 === 0 ? ($due / 24) . ' day' . ($due === 24 ? '' : 's') : "$due hour" . ($due === 1 ? '' : 's')) . ' after it is raised' : '') + . starterPreviewFact('Tags', $tags) + . starterPreviewFact('Clients ask with', $portal) + . '
'; + $html .= '

What it says

' . starterPreviewFills($row['details'] ?? '') . '
'; + $html .= starterPreviewFillIns((array) ($row['fill_ins'] ?? [])); + if (!empty($row['tasks'])) { + $minutes = array_sum(array_map(function ($task) { + return intval($task['estimate'] ?? 0); + }, $row['tasks'])); + $html .= '

Tasks - ' . count($row['tasks']) . ', about ' . escapeHtml(starterMinutesText($minutes)) . '

    '; + foreach ($row['tasks'] as $task) { + $when = !empty($task['when']) ? ' when ' . escapeHtml($task['when']['fill_in'] . ' is ' . $task['when']['value']) . '' : ''; + $html .= '
  1. ' . starterPreviewFills(escapeHtml((string) $task['name'])) . $when + . (!empty($task['estimate']) ? ' ' . intval($task['estimate']) . ' min' : '') . '
  2. '; + } + $html .= '
'; + } + return $html; + + case 'canned_responses': + $types = ['internal' => 'An internal note', 'public' => 'A public note', 'email' => 'An email reply']; + $categories = implode(' ', array_map(function ($category) { + return categoryChip(['category_name' => $category['name'], 'category_type' => 'Ticket', 'category_color' => $category['color'] ?? '', 'category_icon' => $category['icon'] ?? '']); + }, (array) ($row['categories'] ?? []))); + $html .= '
' + . starterPreviewFact('Goes in as', escapeHtml($types[$row['reply_type'] ?? ''] ?? ucfirst((string) ($row['reply_type'] ?? '')))) + . starterPreviewFact('Offered on', $categories !== '' ? $categories : 'Every ticket') + . starterPreviewFact('Sets the status to', escapeHtml((string) ($row['status'] ?? ''))) + . starterPreviewFact('Adds time worked', !empty($row['minutes']) ? intval($row['minutes']) . ' minutes' : '') + . '
'; + return $html . '

What it says

' . starterPreviewFills($row['body'] ?? '') . '
'; + + case 'project_templates': + $html .= '

Its tickets, in order

    '; + foreach ((array) ($row['ticket_templates'] ?? []) as $ticket_template) { + $html .= '
  1. ' . escapeHtml((string) $ticket_template) . '
  2. '; + } + return $html . '

Each from the ticket template of that name - one that isn\'t here is left out.

'; + + case 'vendor_templates': + $html .= '
' + . starterPreviewFact('Support site', starterPreviewLink($row['website'] ?? '')) + . starterPreviewFact('Status page', starterPreviewLink($row['status_page'] ?? '')) + . starterPreviewFact('Cases opened at', starterPreviewLink($row['portal'] ?? '')) + . '
'; + return $html . (!empty($row['notes']) ? '

What support asks for

' . nl2br(escapeHtml((string) $row['notes'])) . '
' : ''); + + case 'document_templates': + $review = intval($row['review_months'] ?? 0); + $html .= '
' + . starterPreviewFact('Reviewed', $review ? 'Every ' . ($review === 1 ? 'month' : "$review months") : '') + . '
'; + $html .= starterPreviewFillIns((array) ($row['fill_ins'] ?? [])); + return $html . '

What it says

' . starterPreviewFills($row['content'] ?? '') . '
'; + + case 'license_templates': + $html .= '
' + . starterPreviewFact('Type', escapeHtml((string) ($row['type'] ?? ''))) + . starterPreviewFact('Licensed per', escapeHtml((string) ($row['license_type'] ?? ''))) + . starterPreviewFact('Renews every', escapeHtml(SOFTWARE_RENEWAL_TERMS[(string) ($row['term'] ?? '')] ?? '')) + . starterPreviewFact('Vendor', escapeHtml((string) ($row['vendor']['name'] ?? ''))) + . starterPreviewFact('Sold as', escapeHtml((string) ($row['product'] ?? ''))) + . '
'; + return $html . (!empty($row['notes']) ? '

What to know

' . nl2br(escapeHtml((string) $row['notes'])) . '
' : ''); + + case 'products': + [$frequency, $seat] = array_pad(explode(':', (string) ($row[6] ?? '')), 2, ''); + $types = productTypes(); + return '

' . escapeHtml((string) $row[5]) . '

' + . starterPreviewFact('Type', escapeHtml($types[$row[1]]['label'] ?? ucfirst((string) $row[1]))) + . starterPreviewFact('Code', escapeHtml((string) $row[2])) + . starterPreviewFact('Price', escapeHtml(billingMoney(floatval($row[3]))) . (floatval($row[3]) == 0 ? ' - priced on each quote' : '')) + . starterPreviewFact('Billed', escapeHtml(productBilledText($frequency, $seat))) + . starterPreviewFact('Income category', escapeHtml((string) $row[4])) + . starterPreviewFact('Becomes', !empty($row[7]) ? escapeHtml('A client asset (' . $row[7] . ') when sold' . (!empty($row[8]) ? ', with ' . intval($row[8]) . ' months of warranty' : '')) : '') + . '

Prices are starting points - change them once it is here.

'; + } + return ''; +} + +/* Minutes as words without the business-hours qualifier - "3 hours 15 minutes". */ +function starterMinutesText($minutes) { + $minutes = intval($minutes); + if ($minutes < 60) { + return $minutes . ' minute' . ($minutes === 1 ? '' : 's'); + } + $hours = intdiv($minutes, 60); + $rest = $minutes % 60; + return $hours . ' hour' . ($hours === 1 ? '' : 's') . ($rest ? " $rest minutes" : ''); +} + +/* + * A pack's Pick which - its items not here yet, each ticked, shown as they + * will look with what they are for, and Preview where there is more to see. + * $form is the id of the form the ticks go with, when they sit outside it (a + * table row's Add). $items from starterContentItems(). + */ +function starterPickHtml($pack, array $items, $form = '') { + if (!$items) { + return ''; + } + $form_attr = $form !== '' ? ' form="' . escapeHtml($form) . '"' : ''; + $previews = in_array($pack, starterPreviewPacks(), true); + $kind = $pack === 'tags' ? ' is-pills' : ''; + ob_start(); ?> +
+ Pick which + > +
+ + + +
+
+
+ $item) { + if ($item['group'] !== '' && $item['group'] !== $group) { + $group = $item['group']; ?> +
+ +
+ > + + + + +
+ +
+
+
+ + + starterItemKey($pack, $row), 'name' => starterItemName($pack, $row), 'group' => starterItemGroup($pack, $row)]; + $items[] = ['key' => starterItemKey($pack, $row), 'name' => starterItemName($pack, $row), 'group' => starterItemGroup($pack, $row), 'row' => $row]; } } return $items; diff --git a/admin/starter_content.php b/admin/starter_content.php index dcac32240..9abbf4730 100644 --- a/admin/starter_content.php +++ b/admin/starter_content.php @@ -5,9 +5,19 @@ require_once "includes/inc_all_admin.php"; define('FROM_STARTER_CONTENT', true); require_once "post/starter_content_model.php"; require_once "post/demo_data_model.php"; +require_once "includes/starter_content_pick.php"; $starter_content_packs = starterContentPacks(); -$starter_content_status = starterContentStatus($mysqli); + +// Each pack's items not here yet - for Pick which, and how many Add brings +$starter_content_items = []; +$starter_content_status = []; +foreach ($starter_content_packs as $pack => $pack_details) { + $starter_content_items[$pack] = starterContentItems($mysqli, $pack); + $total = starterContentTotal($pack); + $missing = count($starter_content_items[$pack]); + $starter_content_status[$pack] = ['total' => $total, 'missing' => $missing, 'present' => $total - $missing]; +} $demo_data_status = demoDataStatus($mysqli); $demo_data_loaded = count($demo_data_status['loaded']); @@ -42,7 +52,8 @@ foreach ($starter_content_status as $pack_status) {

Adding never overwrites or deletes anything. Anything already present under the same name is skipped, so a pack can be added again later to pick up entries you removed by mistake, and - adding twice never produces duplicates. Everything added is editable and deletable + adding twice never produces duplicates. Pick which, under a pack, lists what it would add - as it will + look, with a preview of each template - to add only some of it. Everything added is editable and deletable afterwards like any other record. Ticket templates, canned responses, vendor templates, document templates and license templates added from an earlier version can be brought up to this one with Update - that one does replace what they hold.

@@ -62,8 +73,8 @@ foreach ($starter_content_status as $pack_status) { $pack_details) { $pack_status = $starter_content_status[$pack]; ?> - - + +

Add first, or these come in without them. + - + @@ -87,9 +99,9 @@ foreach ($starter_content_status as $pack_status) {
-
+ -
@@ -210,4 +222,7 @@ foreach ($starter_content_status as $pack_status) {
+ + + diff --git a/admin/starter_content_preview.php b/admin/starter_content_preview.php new file mode 100644 index 000000000..e8a3e7f85 --- /dev/null +++ b/admin/starter_content_preview.php @@ -0,0 +1,40 @@ + 'There is no such starter content.']); + exit; +} + +echo json_encode(['title' => starterItemName($pack, $row), 'pack' => starterContentPacks()[$pack]['label'], 'html' => starterItemPreview($pack, $row)]); diff --git a/css/itflow_custom.css b/css/itflow_custom.css index bd2f015e0..78fe9c65d 100644 --- a/css/itflow_custom.css +++ b/css/itflow_custom.css @@ -8360,43 +8360,6 @@ a.itflow-portal-request:focus-visible { -webkit-box-orient: vertical; overflow: hidden; } -/* Pick which - what a pack would add, each ticked, in a box that scrolls */ -.itflow-gs-pick > summary { - color: var(--bs-link-color); - cursor: pointer; - width: max-content; -} -.itflow-gs-pick-list { - max-height: 16rem; - overflow-y: auto; - padding: .5rem .75rem; - border: 1px solid var(--bs-border-color); - border-radius: .375rem; - background: var(--bs-body-bg); -} -/* Two across when there is room, reading along the rows, each group under its heading - inside the box that scrolls */ -.itflow-gs-pick-cols { - display: grid; - grid-template-columns: repeat(auto-fill, minmax(min(14rem, 100%), 1fr)); - column-gap: 1.5rem; - row-gap: .15rem; -} -.itflow-gs-pick-list .form-check { - margin-bottom: 0; - min-width: 0; -} -.itflow-gs-pick-group { - grid-column: 1 / -1; - font-size: .75rem; - font-weight: 600; - text-transform: uppercase; - letter-spacing: .06em; - color: var(--bs-secondary-color); - margin: .5rem 0 .25rem; -} -.itflow-gs-pick-group:first-child { - margin-top: 0; -} /* "or", across the card between two ways of doing a step */ .itflow-gs-or { display: flex; @@ -8470,6 +8433,151 @@ a.itflow-portal-request:focus-visible { } } +/* ========================================================================== + Starter content's Pick which (admin/includes/starter_content_pick.php, + js/starter_content.js) - on Maintenance > Starter Content and Get started: + what a pack would add, each item ticked and shown as it will look, in a + box that scrolls; and Preview's window. + ========================================================================== */ +.itflow-starter-pick > summary { + color: var(--bs-link-color); + cursor: pointer; + width: max-content; +} +.itflow-starter-pick-box { + max-height: 20rem; + overflow-y: auto; + padding: .25rem .75rem; + border: 1px solid var(--bs-border-color); + border-radius: .375rem; + background: var(--bs-body-bg); + text-align: start; +} +/* One to a line, each with what it is for; tags two or more across, as they are only their pill */ +.itflow-starter-pick-items { + display: grid; + grid-template-columns: 1fr; +} +.itflow-starter-pick-items.is-pills { + grid-template-columns: repeat(auto-fill, minmax(min(12rem, 100%), 1fr)); + column-gap: 1rem; +} +.itflow-starter-item { + display: flex; + align-items: flex-start; + gap: .6rem; + padding: .4rem 0; + min-width: 0; +} +.itflow-starter-pick-items:not(.is-pills) .itflow-starter-item + .itflow-starter-item { + border-top: 1px solid var(--bs-border-color-translucent); +} +.itflow-starter-item > .form-check-input { + margin-top: .2rem; +} +.itflow-starter-item-label { + cursor: pointer; + flex: 1 1 auto; + font-weight: 400; + margin-bottom: 0; +} +/* A tag named longer than its column wraps inside its pill rather than pushing the box sideways */ +.itflow-starter-item-label .itflow-tag { + max-width: 100%; + white-space: normal; +} +.itflow-starter-item-about { + display: -webkit-box; + -webkit-line-clamp: 2; + -webkit-box-orient: vertical; + overflow: hidden; +} +.itflow-starter-pick-group { + grid-column: 1 / -1; + font-size: .75rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: .06em; + color: var(--bs-secondary-color); + margin: .6rem 0 .1rem; +} +.itflow-starter-pick-group:first-child { + margin-top: .25rem; +} +/* Maintenance > Starter Content - the list under its pack, in the first column */ +.itflow-starter-pack-cell { + min-width: 18rem; +} +/* Preview - the facts as a list beside their labels, then what it says as it reads */ +.itflow-starter-preview-facts { + display: grid; + grid-template-columns: max-content 1fr; + gap: .35rem 1rem; + margin-bottom: 1rem; +} +.itflow-starter-preview-facts dt { + font-weight: 400; + color: var(--bs-secondary-color); +} +.itflow-starter-preview-facts dd { + margin: 0; + min-width: 0; + overflow-wrap: anywhere; +} +.itflow-starter-preview-head { + font-size: .8rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: .06em; + color: var(--bs-secondary-color); + margin: 1.25rem 0 .5rem; +} +.itflow-starter-preview-doc, +.itflow-starter-preview-notes { + border: 1px solid var(--bs-border-color); + border-radius: .375rem; + padding: .75rem 1rem; + background: var(--bs-tertiary-bg); + overflow-x: auto; +} +.itflow-starter-preview-doc table { + width: 100%; + margin-bottom: .75rem; + border-collapse: collapse; +} +.itflow-starter-preview-doc th, +.itflow-starter-preview-doc td { + border: 1px solid var(--bs-border-color); + padding: .25rem .5rem; + vertical-align: top; +} +.itflow-starter-preview-doc > :last-child { + margin-bottom: 0; +} +.itflow-starter-preview-list { + padding-left: 1.25rem; + margin-bottom: 0; +} +.itflow-starter-preview-list > li { + padding: .15rem 0; +} +/* [Words in brackets] - filled in when it is used */ +.itflow-starter-fill { + background: rgba(var(--bs-primary-rgb), .12); + color: var(--bs-emphasis-color); + border-radius: .25rem; + padding: 0 .2rem; +} +@media (max-width: 575.98px) { + .itflow-starter-preview-facts { + grid-template-columns: 1fr; + gap: 0; + } + .itflow-starter-preview-facts dd { + margin-bottom: .5rem; + } +} + /* ========================================================================== Settings (admin/settings*.php, functions/settings.php, js/settings.js) - a page header, sections with what they are for beside their fields, a save diff --git a/js/getting_started.js b/js/getting_started.js index 8555c963f..5e4fd613b 100644 --- a/js/getting_started.js +++ b/js/getting_started.js @@ -1,16 +1,15 @@ /* * Get started (admin/getting_started.php, admin/getting_started/) - in the * browser. The server checks everything (admin/post/getting_started.php); - * this only shows and hides, and watches for cron. + * this only shows and hides, and watches for cron. The Starter content + * step's Pick which is js/starter_content.js, shared with Maintenance > + * Starter Content. * * [data-gs-reveal=id] a switch that shows that element while on * [data-gs-show=id] shows the secret in that field, or hides it again * [data-gs-cron-poll=url] asks that address every 10 seconds whether cron * has been heard from, and shows the step again * once it has - * [data-gs-pack] a starter content pack's tick and its Pick which - * list kept in step - ticking the pack ticks every - * item, and its count says how many are ticked * form sent once, the button pressed showing it is on * its way */ @@ -67,37 +66,6 @@ setInterval(ask, 10000); } - // A pack and the items under its Pick which - function initPack(pack) { - var check = pack.querySelector('[data-gs-pack-check]'); - var items = Array.prototype.slice.call(pack.querySelectorAll('[data-gs-item]')); - var count = pack.querySelector('[data-gs-pack-count]'); - if (!check || !items.length) { - return; - } - var of = items.length; - var more = count && count.dataset.gsMore === '1'; - function draw() { - var ticked = items.filter(function (item) { return item.checked; }).length; - check.checked = ticked > 0; - check.indeterminate = ticked > 0 && ticked < of; - if (count) { - count.textContent = ticked === of ? of + (more ? ' more to add' : ' to add') : (ticked ? ticked + ' of ' + of + ' to add' : 'None picked'); - } - } - function setAll(on) { - items.forEach(function (item) { item.checked = on; }); - draw(); - } - check.addEventListener('change', function () { setAll(check.checked); }); - items.forEach(function (item) { item.addEventListener('change', draw); }); - var all = pack.querySelector('[data-gs-pick-all]'); - var none = pack.querySelector('[data-gs-pick-none]'); - if (all) { all.addEventListener('click', function () { setAll(true); }); } - if (none) { none.addEventListener('click', function () { setAll(false); }); } - draw(); - } - function initBusy(form) { form.addEventListener('submit', function (e) { if (e.defaultPrevented) { @@ -121,7 +89,6 @@ document.querySelectorAll('[data-gs-reveal]').forEach(initReveal); document.querySelectorAll('[data-gs-show]').forEach(initShow); document.querySelectorAll('[data-gs-cron-poll]').forEach(initCronPoll); - document.querySelectorAll('[data-gs-pack]').forEach(initPack); document.querySelectorAll('form').forEach(initBusy); } diff --git a/js/starter_content.js b/js/starter_content.js new file mode 100644 index 000000000..73e2814fd --- /dev/null +++ b/js/starter_content.js @@ -0,0 +1,121 @@ +/* + * Pick which - a starter content pack's items chosen one by one, on + * Maintenance > Starter Content and Get started's Starter content step + * (admin/includes/starter_content_pick.php): + * + * [data-starter-pick] a pack - its items ([data-starter-item]), its + * own tick ([data-starter-pack-check]) and count + * ([data-starter-count]) kept in step: ticking + * the pack ticks every item, and the count says + * how many are ticked. Its Add + * ([data-starter-add]) waits while none are. + * [data-starter-all] / -none tick every item of the pack, or none + * [data-starter-preview=pack] the item ([data-starter-key]) whole, in the + * #starterPreview window + * + * Without it, the items can still be ticked and unticked and are sent as they + * are; the server goes by them. + */ +(function () { + 'use strict'; + + function initPick(pack) { + var items = Array.prototype.slice.call(pack.querySelectorAll('[data-starter-item]')); + if (!items.length) { + return; + } + var check = pack.querySelector('[data-starter-pack-check]'); + var counts = Array.prototype.slice.call(pack.querySelectorAll('[data-starter-count]')); + var adds = Array.prototype.slice.call(pack.querySelectorAll('[data-starter-add]')); + var of = items.length; + + function draw() { + var ticked = items.filter(function (item) { return item.checked; }).length; + if (check) { + check.checked = ticked > 0; + check.indeterminate = ticked > 0 && ticked < of; + } + counts.forEach(function (count) { + var short = count.dataset.starterFormat === 'short'; + var more = count.dataset.starterMore === '1'; + if (short) { + count.textContent = ticked === of ? String(of) : ticked + ' of ' + of; + } else { + count.textContent = ticked === of ? of + (more ? ' more to add' : ' to add') : (ticked ? ticked + ' of ' + of + ' to add' : 'None picked'); + } + }); + adds.forEach(function (add) { + add.disabled = ticked === 0; + }); + } + function setAll(on) { + items.forEach(function (item) { item.checked = on; }); + draw(); + } + + if (check) { + check.addEventListener('change', function () { setAll(check.checked); }); + } + items.forEach(function (item) { item.addEventListener('change', draw); }); + pack.querySelectorAll('[data-starter-all]').forEach(function (button) { + button.addEventListener('click', function () { setAll(true); }); + }); + pack.querySelectorAll('[data-starter-none]').forEach(function (button) { + button.addEventListener('click', function () { setAll(false); }); + }); + draw(); + } + + // Preview - asked of the server each time, shown in the one window + function preview(button) { + var modal = document.getElementById('starterPreview'); + if (!modal || typeof bootstrap === 'undefined') { + return; + } + var title = modal.querySelector('#starterPreviewTitle'); + var body = modal.querySelector('[data-starter-preview-body]'); + title.textContent = 'Preview'; + body.innerHTML = '
Opening it
'; + bootstrap.Modal.getOrCreateInstance(modal).show(); + + var url = modal.dataset.starterPreviewUrl + '?pack=' + encodeURIComponent(button.dataset.starterPreview) + '&item=' + encodeURIComponent(button.dataset.starterKey); + fetch(url, { credentials: 'same-origin', headers: { 'Accept': 'application/json' } }) + .then(function (response) { return response.json(); }) + .then(function (answer) { + if (!answer || answer.error) { + throw new Error(answer && answer.error ? answer.error : 'It could not be opened.'); + } + title.textContent = answer.title; + var pack = document.createElement('span'); + pack.className = 'small fw-normal text-body-secondary ms-2'; + pack.textContent = answer.pack; + title.appendChild(pack); + body.innerHTML = answer.html; + body.scrollTop = 0; + }) + .catch(function (error) { + body.textContent = ''; + var message = document.createElement('div'); + message.className = 'alert alert-warning mb-0'; + message.textContent = error && error.message ? error.message : 'It could not be opened.'; + body.appendChild(message); + }); + } + + function init() { + document.querySelectorAll('[data-starter-pick]').forEach(initPick); + document.addEventListener('click', function (e) { + var button = e.target.closest('[data-starter-preview]'); + if (button) { + e.preventDefault(); + preview(button); + } + }); + } + + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', init); + } else { + init(); + } +})();