diff --git a/admin/post/demo_data_model.php b/admin/post/demo_data_model.php new file mode 100644 index 000000000..67d6d4bc8 --- /dev/null +++ b/admin/post/demo_data_model.php @@ -0,0 +1,2402 @@ + Starter Content. + * + * This is NOT starter content. Starter content is configuration you keep; + * this is disposable sample data that has no business sitting in a live + * install, so everything it creates hangs off a demo client and every demo + * client carries the 'Demo Data' client tag. Removal works off that tag and + * nothing else - untag a client and it stops being demo data. + * + * Dates are generated relative to today so the dashboard, aging, SLA clocks + * and reports have something live to show rather than a flat wall of rows. + * + * Named _model so admin/post.php does not glob it in on every admin request. + */ + +require_once __DIR__ . '/starter_content_model.php'; + +// The one marker. Everything else keys off which clients carry it. +const DEMO_DATA_TAG = 'Demo Data'; + +// ------------------------------ +// demoDataTagId +// The 'Demo Data' client tag (tag_type 1), created on demand. +// ------------------------------ +function demoDataTagId($mysqli, $create = false) { + $name = escapeSql(DEMO_DATA_TAG); + $row = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT tag_id FROM tags WHERE tag_name = '$name' AND tag_type = 1 LIMIT 1")); + $tag_id = intval($row['tag_id'] ?? 0); + + if (!$tag_id && $create) { + $tag_id = starterInsert($mysqli, 'tags', [ + 'tag_name' => DEMO_DATA_TAG, + 'tag_type' => 1, + 'tag_color' => '#e83e8c', + 'tag_icon' => 'flask', + ]); + } + + return $tag_id; +} + +// ------------------------------ +// demoDataClients +// Client IDs currently carrying the demo tag, in name order. +// ------------------------------ +function demoDataClients($mysqli) { + $clients = []; + $tag_id = demoDataTagId($mysqli); + if (!$tag_id) { + return $clients; + } + + $sql = mysqli_query($mysqli, "SELECT clients.client_id, client_name FROM clients LEFT JOIN client_tags ON client_tags.client_id = clients.client_id WHERE client_tags.tag_id = $tag_id ORDER BY client_name ASC"); + while ($row = mysqli_fetch_assoc($sql)) { + $clients[intval($row['client_id'])] = $row['client_name']; + } + + return $clients; +} + +// ------------------------------ +// demoDataStatus +// What the page needs: how many demo clients the library holds, how many are +// already here, and whether a real book of business is already in the install. +// ------------------------------ +function demoDataStatus($mysqli) { + $loaded = demoDataClients($mysqli); + + $row = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT COUNT(client_id) AS total FROM clients WHERE client_archived_at IS NULL")); + $all_clients = intval($row['total'] ?? 0); + + return [ + 'total' => count(demoDataProfiles()), + 'loaded' => $loaded, + 'other_clients' => $all_clients - count($loaded), + ]; +} + +// ------------------------------ +// demoNextNumber +// The same atomic increment the real handlers use, so demo rows do not leave +// ticket/invoice/quote numbering sitting on a number that is already taken. +// ------------------------------ +function demoNextNumber($mysqli, $setting) { + mysqli_query($mysqli, " + UPDATE settings + SET + $setting = LAST_INSERT_ID($setting), + $setting = $setting + 1 + WHERE company_id = 1 + "); + return intval(mysqli_insert_id($mysqli)); +} + +// ------------------------------ +// demoTicketStatusId +// Built-in statuses are IDs 1-5 on an install seeded by setup, but look them +// up by name anyway - an install that renamed or reordered them still works. +// ------------------------------ +function demoTicketStatusId($mysqli, $name, $fallback) { + $name = escapeSql($name); + $row = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT ticket_status_id FROM ticket_statuses WHERE ticket_status_name = '$name' LIMIT 1")); + return intval($row['ticket_status_id'] ?? 0) ?: $fallback; +} + +// ------------------------------ +// demoFirstId +// First live row in a table, or 0. Used for the account payments post to and +// the calendar events land on - demo data never creates either of those. +// ------------------------------ +function demoFirstId($mysqli, $table, $id_column, $archived_column = '') { + $where = $archived_column ? "WHERE $archived_column IS NULL" : ''; + $row = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT $id_column FROM $table $where ORDER BY $id_column ASC LIMIT 1")); + return intval($row[$id_column] ?? 0); +} + +// ------------------------------ +// demoDateTime / demoDate +// Everything is generated relative to now. +// ------------------------------ +function demoDateTime($days_ago, $hour = 9, $minute = 0) { + return date('Y-m-d H:i:s', mktime($hour, $minute, 0, date('n'), date('j') - $days_ago, date('Y'))); +} + +function demoDate($days_ago) { + return date('Y-m-d', mktime(12, 0, 0, date('n'), date('j') - $days_ago, date('Y'))); +} + +// ------------------------------ +// demoSerial +// Deterministic stand-in for a serial number - same input, same serial, so a +// reload after a purge does not produce a whole new set of numbers. +// ------------------------------ +function demoSerial($seed, $length = 10) { + return strtoupper(substr(md5('itflow-demo-' . $seed), 0, $length)); +} + +// ------------------------------ +// demoDataLoad +// Builds the whole book of business. Clients already present by name are +// skipped, so a second run adds nothing and a run after a partial purge only +// fills the gaps. Returns a per-area count for the flash message. +// ------------------------------ +function demoDataLoad($mysqli) { + global $session_user_id, $session_company_currency; + + $user_id = intval($session_user_id ?? 0); + $currency = $session_company_currency ?? ''; + + $counts = [ + 'clients' => 0, + 'contacts' => 0, + 'assets' => 0, + 'documentation' => 0, + 'tickets' => 0, + 'projects' => 0, + 'billing' => 0, + 'company' => 0, + 'skipped_credentials' => 0, + ]; + + $existing = starterExistingNames($mysqli, 'clients', 'client_name'); + $tag_id = demoDataTagId($mysqli, true); + + // Shared lookups - one query each rather than one per client + $context = [ + 'user_id' => $user_id, + 'currency' => $currency, + 'tag_id' => $tag_id, + 'account_id' => demoFirstId($mysqli, 'accounts', 'account_id', 'account_archived_at'), + 'accounts' => demoEnsureAccounts($mysqli, $currency), + 'calendar_id' => demoFirstId($mysqli, 'calendars', 'calendar_id'), + 'status_new' => demoTicketStatusId($mysqli, 'New', 1), + 'status_open' => demoTicketStatusId($mysqli, 'Open', 2), + 'status_hold' => demoTicketStatusId($mysqli, 'On Hold', 3), + 'status_resolved' => demoTicketStatusId($mysqli, 'Resolved', 4), + 'status_closed' => demoTicketStatusId($mysqli, 'Closed', 5), + 'vault_open' => encryptCredentialEntry('itflow-demo-probe') !== false, + ]; + + // One transaction for the whole load - several thousand rows go in a great + // deal faster, and a failure part way through does not leave half a demo + mysqli_begin_transaction($mysqli); + + foreach (demoDataProfiles() as $index => $profile) { + + if (isset($existing[mb_strtolower($profile['name'])])) { + continue; + } + + demoBuildClient($mysqli, $profile, $index, $context, $counts); + $counts['clients']++; + } + + // The MSP's own accounts, operating costs and transfers + if ($counts['clients']) { + demoBuildCompanyFinancials($mysqli, $context, $counts); + } + + mysqli_commit($mysqli); + + if (!$context['vault_open'] && $counts['clients']) { + $counts['skipped_credentials'] = 1; + } + + return $counts; +} + +// ------------------------------ +// demoDataRemove +// Purges every client carrying the demo tag and everything hanging off it. +// This deliberately goes further than the client delete handler, which leaves +// tags, notes, tasks, ticket history, projects and expenses behind. +// ------------------------------ +function demoDataRemove($mysqli) { + $clients = demoDataClients($mysqli); + if (!$clients) { + return 0; + } + + foreach (array_keys($clients) as $client_id) { + + $client_id = intval($client_id); + + // Tickets and everything under them + $ticket_ids = []; + $sql = mysqli_query($mysqli, "SELECT ticket_id FROM tickets WHERE ticket_client_id = $client_id"); + while ($row = mysqli_fetch_assoc($sql)) { + $ticket_ids[] = intval($row['ticket_id']); + } + if ($ticket_ids) { + $ticket_ids = implode(',', $ticket_ids); + mysqli_query($mysqli, "DELETE FROM ticket_replies WHERE ticket_reply_ticket_id IN ($ticket_ids)"); + mysqli_query($mysqli, "DELETE FROM ticket_history WHERE ticket_history_ticket_id IN ($ticket_ids)"); + mysqli_query($mysqli, "DELETE FROM ticket_assets WHERE ticket_id IN ($ticket_ids)"); + mysqli_query($mysqli, "DELETE FROM ticket_watchers WHERE watcher_ticket_id IN ($ticket_ids)"); + mysqli_query($mysqli, "DELETE FROM ticket_views WHERE view_ticket_id IN ($ticket_ids)"); + mysqli_query($mysqli, "DELETE FROM ticket_attachments WHERE ticket_attachment_ticket_id IN ($ticket_ids)"); + mysqli_query($mysqli, "DELETE FROM tasks WHERE task_ticket_id IN ($ticket_ids)"); + mysqli_query($mysqli, "DELETE FROM sla_history WHERE sla_history_ticket_id IN ($ticket_ids)"); + } + mysqli_query($mysqli, "DELETE FROM tickets WHERE ticket_client_id = $client_id"); + + $sql = mysqli_query($mysqli, "SELECT recurring_ticket_id FROM recurring_tickets WHERE recurring_ticket_client_id = $client_id"); + while ($row = mysqli_fetch_assoc($sql)) { + $recurring_ticket_id = intval($row['recurring_ticket_id']); + mysqli_query($mysqli, "DELETE FROM recurring_ticket_tasks WHERE recurring_ticket_task_recurring_ticket_id = $recurring_ticket_id"); + mysqli_query($mysqli, "DELETE FROM recurring_ticket_assets WHERE recurring_ticket_id = $recurring_ticket_id"); + } + mysqli_query($mysqli, "DELETE FROM recurring_tickets WHERE recurring_ticket_client_id = $client_id"); + + // Billing + $sql = mysqli_query($mysqli, "SELECT invoice_id FROM invoices WHERE invoice_client_id = $client_id"); + while ($row = mysqli_fetch_assoc($sql)) { + $invoice_id = intval($row['invoice_id']); + mysqli_query($mysqli, "DELETE FROM invoice_items WHERE item_invoice_id = $invoice_id"); + mysqli_query($mysqli, "DELETE FROM payments WHERE payment_invoice_id = $invoice_id"); + mysqli_query($mysqli, "DELETE FROM history WHERE history_invoice_id = $invoice_id"); + } + mysqli_query($mysqli, "DELETE FROM invoices WHERE invoice_client_id = $client_id"); + + $sql = mysqli_query($mysqli, "SELECT quote_id FROM quotes WHERE quote_client_id = $client_id"); + while ($row = mysqli_fetch_assoc($sql)) { + $quote_id = intval($row['quote_id']); + mysqli_query($mysqli, "DELETE FROM quote_items WHERE item_quote_id = $quote_id"); + mysqli_query($mysqli, "DELETE FROM history WHERE history_quote_id = $quote_id"); + } + mysqli_query($mysqli, "DELETE FROM quotes WHERE quote_client_id = $client_id"); + + $sql = mysqli_query($mysqli, "SELECT recurring_invoice_id FROM recurring_invoices WHERE recurring_invoice_client_id = $client_id"); + while ($row = mysqli_fetch_assoc($sql)) { + $recurring_invoice_id = intval($row['recurring_invoice_id']); + mysqli_query($mysqli, "DELETE FROM recurring_invoice_items WHERE item_recurring_invoice_id = $recurring_invoice_id"); + mysqli_query($mysqli, "DELETE FROM history WHERE history_recurring_invoice_id = $recurring_invoice_id"); + } + mysqli_query($mysqli, "DELETE FROM recurring_invoices WHERE recurring_invoice_client_id = $client_id"); + + mysqli_query($mysqli, "DELETE FROM expenses WHERE expense_client_id = $client_id"); + mysqli_query($mysqli, "DELETE FROM revenues WHERE revenue_client_id = $client_id"); + mysqli_query($mysqli, "DELETE FROM trips WHERE trip_client_id = $client_id"); + + // Documentation and the join tables the client delete handler misses + $sql = mysqli_query($mysqli, "SELECT asset_id FROM assets WHERE asset_client_id = $client_id"); + while ($row = mysqli_fetch_assoc($sql)) { + $asset_id = intval($row['asset_id']); + mysqli_query($mysqli, "DELETE FROM asset_tags WHERE asset_tag_asset_id = $asset_id"); + mysqli_query($mysqli, "DELETE FROM asset_notes WHERE asset_note_asset_id = $asset_id"); + mysqli_query($mysqli, "DELETE FROM asset_history WHERE asset_history_asset_id = $asset_id"); + mysqli_query($mysqli, "DELETE FROM asset_credentials WHERE asset_id = $asset_id"); + mysqli_query($mysqli, "DELETE FROM asset_documents WHERE asset_id = $asset_id"); + mysqli_query($mysqli, "DELETE FROM asset_files WHERE asset_id = $asset_id"); + mysqli_query($mysqli, "DELETE FROM contact_assets WHERE asset_id = $asset_id"); + mysqli_query($mysqli, "DELETE FROM service_assets WHERE asset_id = $asset_id"); + mysqli_query($mysqli, "DELETE FROM software_assets WHERE asset_id = $asset_id"); + } + mysqli_query($mysqli, "DELETE FROM assets WHERE asset_client_id = $client_id"); + + $sql = mysqli_query($mysqli, "SELECT contact_id FROM contacts WHERE contact_client_id = $client_id"); + while ($row = mysqli_fetch_assoc($sql)) { + $contact_id = intval($row['contact_id']); + mysqli_query($mysqli, "DELETE FROM contact_tags WHERE contact_id = $contact_id"); + mysqli_query($mysqli, "DELETE FROM contact_notes WHERE contact_note_contact_id = $contact_id"); + mysqli_query($mysqli, "DELETE FROM contact_credentials WHERE contact_id = $contact_id"); + mysqli_query($mysqli, "DELETE FROM contact_documents WHERE contact_id = $contact_id"); + mysqli_query($mysqli, "DELETE FROM contact_files WHERE contact_id = $contact_id"); + mysqli_query($mysqli, "DELETE FROM service_contacts WHERE contact_id = $contact_id"); + mysqli_query($mysqli, "DELETE FROM software_contacts WHERE contact_id = $contact_id"); + } + mysqli_query($mysqli, "DELETE FROM contacts WHERE contact_client_id = $client_id"); + + $sql = mysqli_query($mysqli, "SELECT credential_id FROM credentials WHERE credential_client_id = $client_id"); + while ($row = mysqli_fetch_assoc($sql)) { + $credential_id = intval($row['credential_id']); + mysqli_query($mysqli, "DELETE FROM credential_tags WHERE credential_id = $credential_id"); + mysqli_query($mysqli, "DELETE FROM service_credentials WHERE credential_id = $credential_id"); + mysqli_query($mysqli, "DELETE FROM software_credentials WHERE credential_id = $credential_id"); + mysqli_query($mysqli, "DELETE FROM vendor_credentials WHERE credential_id = $credential_id"); + } + mysqli_query($mysqli, "DELETE FROM credentials WHERE credential_client_id = $client_id"); + + $sql = mysqli_query($mysqli, "SELECT location_id FROM locations WHERE location_client_id = $client_id"); + while ($row = mysqli_fetch_assoc($sql)) { + $location_id = intval($row['location_id']); + mysqli_query($mysqli, "DELETE FROM location_tags WHERE location_id = $location_id"); + } + mysqli_query($mysqli, "DELETE FROM locations WHERE location_client_id = $client_id"); + + $sql = mysqli_query($mysqli, "SELECT service_id FROM services WHERE service_client_id = $client_id"); + while ($row = mysqli_fetch_assoc($sql)) { + $service_id = intval($row['service_id']); + mysqli_query($mysqli, "DELETE FROM service_documents WHERE service_id = $service_id"); + mysqli_query($mysqli, "DELETE FROM service_domains WHERE service_id = $service_id"); + mysqli_query($mysqli, "DELETE FROM service_certificates WHERE service_id = $service_id"); + mysqli_query($mysqli, "DELETE FROM service_vendors WHERE service_id = $service_id"); + } + mysqli_query($mysqli, "DELETE FROM services WHERE service_client_id = $client_id"); + + $sql = mysqli_query($mysqli, "SELECT software_id FROM software WHERE software_client_id = $client_id"); + while ($row = mysqli_fetch_assoc($sql)) { + $software_id = intval($row['software_id']); + mysqli_query($mysqli, "DELETE FROM software_keys WHERE software_key_software_id = $software_id"); + mysqli_query($mysqli, "DELETE FROM software_documents WHERE software_id = $software_id"); + mysqli_query($mysqli, "DELETE FROM software_files WHERE software_id = $software_id"); + } + mysqli_query($mysqli, "DELETE FROM software WHERE software_client_id = $client_id"); + + $sql = mysqli_query($mysqli, "SELECT document_id FROM documents WHERE document_client_id = $client_id"); + while ($row = mysqli_fetch_assoc($sql)) { + $document_id = intval($row['document_id']); + mysqli_query($mysqli, "DELETE FROM document_files WHERE document_id = $document_id"); + mysqli_query($mysqli, "DELETE FROM document_versions WHERE document_version_document_id = $document_id"); + } + mysqli_query($mysqli, "DELETE FROM documents WHERE document_client_id = $client_id"); + + $sql = mysqli_query($mysqli, "SELECT vendor_id FROM vendors WHERE vendor_client_id = $client_id"); + while ($row = mysqli_fetch_assoc($sql)) { + $vendor_id = intval($row['vendor_id']); + mysqli_query($mysqli, "DELETE FROM vendor_credentials WHERE vendor_id = $vendor_id"); + mysqli_query($mysqli, "DELETE FROM vendor_documents WHERE vendor_id = $vendor_id"); + mysqli_query($mysqli, "DELETE FROM vendor_files WHERE vendor_id = $vendor_id"); + } + mysqli_query($mysqli, "DELETE FROM vendors WHERE vendor_client_id = $client_id"); + + mysqli_query($mysqli, "DELETE FROM certificates WHERE certificate_client_id = $client_id"); + mysqli_query($mysqli, "DELETE FROM domains WHERE domain_client_id = $client_id"); + mysqli_query($mysqli, "DELETE FROM networks WHERE network_client_id = $client_id"); + mysqli_query($mysqli, "DELETE FROM projects WHERE project_client_id = $client_id"); + mysqli_query($mysqli, "DELETE FROM contracts WHERE contract_client_id = $client_id"); + mysqli_query($mysqli, "DELETE FROM client_notes WHERE client_note_client_id = $client_id"); + mysqli_query($mysqli, "DELETE FROM calendar_events WHERE event_client_id = $client_id"); + mysqli_query($mysqli, "DELETE FROM shared_items WHERE item_client_id = $client_id"); + mysqli_query($mysqli, "DELETE FROM sla_assignments WHERE sla_assignment_client_id = $client_id"); + mysqli_query($mysqli, "DELETE FROM logs WHERE log_client_id = $client_id"); + mysqli_query($mysqli, "DELETE FROM notifications WHERE notification_client_id = $client_id"); + mysqli_query($mysqli, "DELETE FROM client_tags WHERE client_id = $client_id"); + mysqli_query($mysqli, "DELETE FROM clients WHERE client_id = $client_id"); + + // Never leave the company's own-client designation pointing at a client that is gone + mysqli_query($mysqli, "UPDATE settings SET config_internal_client_id = 0 WHERE config_internal_client_id = $client_id"); + + } + + // Company level demo rows carry a DEMO- reference because they hang off no + // client. Transfers go first - each one owns an expense and a revenue. + $sql = mysqli_query($mysqli, "SELECT transfer_id, transfer_expense_id, transfer_revenue_id FROM transfers + LEFT JOIN expenses ON expense_id = transfer_expense_id + WHERE expense_reference LIKE 'DEMO-%'"); + while ($row = mysqli_fetch_assoc($sql)) { + $transfer_id = intval($row['transfer_id']); + $expense_id = intval($row['transfer_expense_id']); + $revenue_id = intval($row['transfer_revenue_id']); + mysqli_query($mysqli, "DELETE FROM expenses WHERE expense_id = $expense_id"); + mysqli_query($mysqli, "DELETE FROM revenues WHERE revenue_id = $revenue_id"); + mysqli_query($mysqli, "DELETE FROM transfers WHERE transfer_id = $transfer_id"); + } + + mysqli_query($mysqli, "DELETE FROM expenses WHERE expense_client_id = 0 AND expense_reference LIKE 'DEMO-%'"); + mysqli_query($mysqli, "DELETE FROM revenues WHERE revenue_client_id = 0 AND revenue_reference LIKE 'DEMO-%'"); + + // Accounts are shared company config, so one only goes if nothing at all is + // left pointing at it - an account that was already here keeps its history + foreach (demoDataAccounts() as $account) { + $account_name = escapeSql($account[0]); + $row = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT account_id FROM accounts WHERE account_name = '$account_name' LIMIT 1")); + $account_id = intval($row['account_id'] ?? 0); + if (!$account_id) { + continue; + } + $in_use = 0; + foreach (['payments' => 'payment_account_id', 'expenses' => 'expense_account_id', 'revenues' => 'revenue_account_id'] as $table => $column) { + $row = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT COUNT(*) AS total FROM $table WHERE $column = $account_id")); + $in_use = $in_use + intval($row['total'] ?? 0); + } + if (!$in_use) { + mysqli_query($mysqli, "DELETE FROM accounts WHERE account_id = $account_id"); + } + } + + // Tidy up the marker tag itself once nothing is wearing it + $tag_id = demoDataTagId($mysqli); + if ($tag_id) { + $row = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT COUNT(tag_id) AS total FROM client_tags WHERE tag_id = $tag_id")); + if (!intval($row['total'] ?? 0)) { + mysqli_query($mysqli, "DELETE FROM tags WHERE tag_id = $tag_id"); + } + } + + return count($clients); +} + +// ------------------------------ +// demoBuildClient +// One client and everything that hangs off it. Order matters - locations and +// contacts come first because assets, tickets and networks point at them. +// ------------------------------ +function demoBuildClient($mysqli, $profile, $index, $context, &$counts) { + + $currency = $context['currency']; + $user_id = $context['user_id']; + + $client_id = starterInsert($mysqli, 'clients', [ + 'client_name' => $profile['name'], + 'client_type' => $profile['type'], + 'client_website' => 'https://www.' . $profile['domain'], + 'client_referral' => $profile['referral'], + 'client_rate' => $profile['rate'], + 'client_currency_code' => $currency, + 'client_net_terms' => $profile['net_terms'], + 'client_abbreviation' => $profile['abbreviation'], + 'client_notes' => $profile['notes'], + 'client_created_at' => demoDateTime($profile['age_days'], 9, 14), + 'client_accessed_at' => demoDateTime($index % 5, 11, 20), + ]); + + // The marker - this is what removal works off + mysqli_query($mysqli, "INSERT INTO client_tags SET client_id = $client_id, tag_id = {$context['tag_id']}"); + demoAttachTags($mysqli, 'client_tags', 'client_id', 'tag_id', $client_id, 1, $profile['tags']); + + // Locations + $location_ids = []; + foreach ($profile['sites'] as $site_index => $site) { + $location_ids[] = starterInsert($mysqli, 'locations', [ + 'location_name' => $site['name'], + 'location_description' => $site['description'], + 'location_country' => 'United States', + 'location_address' => $site['address'], + 'location_city' => $site['city'], + 'location_state' => $site['state'], + 'location_zip' => $site['zip'], + 'location_phone_country_code' => '1', + 'location_phone' => $site['phone'], + 'location_hours' => $site['hours'], + 'location_primary' => $site_index === 0 ? 1 : 0, + 'location_client_id' => $client_id, + 'location_created_at' => demoDateTime($profile['age_days'], 9, 20), + ]); + demoAttachTags($mysqli, 'location_tags', 'location_id', 'tag_id', end($location_ids), 2, $site['tags']); + } + $primary_location_id = $location_ids[0] ?? 0; + $counts['documentation'] += count($location_ids); + + // Contacts + $contact_ids = []; + foreach ($profile['people'] as $person_index => $person) { + $email = demoContactEmail($person[0], $profile['domain']); + $contact_id = starterInsert($mysqli, 'contacts', [ + 'contact_name' => $person[0], + 'contact_title' => $person[1], + 'contact_email' => $email, + 'contact_phone_country_code' => '1', + 'contact_phone' => $profile['sites'][0]['phone'], + 'contact_extension' => (string)(100 + ($person_index * 7)), + 'contact_mobile_country_code' => '1', + 'contact_mobile' => demoMobileNumber($profile['area_code'], $index, $person_index), + 'contact_department' => $person[2], + 'contact_primary' => $person_index === 0 ? 1 : 0, + 'contact_important' => $person[3], + 'contact_billing' => $person[4], + 'contact_technical' => $person[5], + 'contact_notes' => $person[6], + 'contact_location_id' => $primary_location_id, + 'contact_client_id' => $client_id, + 'contact_created_at' => demoDateTime($profile['age_days'] - $person_index, 10, 5), + ]); + $contact_ids[] = $contact_id; + demoAttachTags($mysqli, 'contact_tags', 'contact_id', 'tag_id', $contact_id, 3, $person[7]); + $counts['contacts']++; + } + $primary_contact_id = $contact_ids[0] ?? 0; + $technical_contact_id = $contact_ids[2] ?? $primary_contact_id; + + // A note on the client and on the technical contact + starterInsert($mysqli, 'client_notes', [ + 'client_note_type' => 'General', + 'client_note' => $profile['account_note'], + 'client_note_created_by' => $user_id, + 'client_note_client_id' => $client_id, + 'client_note_created_at' => demoDateTime(max(2, $profile['age_days'] - 30), 15, 40), + ]); + if ($technical_contact_id) { + starterInsert($mysqli, 'contact_notes', [ + 'contact_note_type' => 'General', + 'contact_note' => 'Main day to day contact for approvals and site access.', + 'contact_note_created_by' => $user_id, + 'contact_note_contact_id' => $technical_contact_id, + 'contact_note_created_at' => demoDateTime(max(2, $profile['age_days'] - 45), 11, 15), + ]); + } + + // Vendor the client owns (ISP, line of business software, etc) + $vendor_id = starterInsert($mysqli, 'vendors', [ + 'vendor_name' => $profile['vendor'][0], + 'vendor_description' => $profile['vendor'][1], + 'vendor_contact_name' => 'Business Support', + 'vendor_phone_country_code' => '1', + 'vendor_phone' => $profile['vendor'][2], + 'vendor_email' => 'support@' . $profile['vendor'][3], + 'vendor_website' => 'https://www.' . $profile['vendor'][3], + 'vendor_hours' => '24/7', + 'vendor_sla' => $profile['vendor'][4], + 'vendor_account_number' => demoSerial($profile['abbreviation'] . 'vendor', 8), + 'vendor_client_id' => $client_id, + 'vendor_created_at' => demoDateTime($profile['age_days'] - 5, 13, 0), + ]); + $counts['documentation']++; + + // Assets + $asset_ids = demoBuildAssets($mysqli, $profile, $client_id, $primary_location_id, $contact_ids, $vendor_id, $counts); + + // Networks + $network_index = 0; + foreach ($profile['networks'] as $network) { + starterInsert($mysqli, 'networks', [ + 'network_name' => $network[0], + 'network_description' => $network[1], + 'network_vlan' => $network[2], + 'network' => $network[3], + 'network_subnet' => '24', + 'network_gateway' => $network[4], + 'network_primary_dns' => $network[5], + 'network_secondary_dns' => '1.1.1.1', + 'network_dhcp_range' => $network[6], + 'network_location_id' => $primary_location_id, + 'network_client_id' => $client_id, + 'network_created_at' => demoDateTime($profile['age_days'] - 8, 14, 30), + ]); + $network_index++; + $counts['documentation']++; + } + + // Domain and its certificate + $domain_id = starterInsert($mysqli, 'domains', [ + 'domain_name' => $profile['domain'], + 'domain_description' => 'Primary domain - website and email', + 'domain_expire' => demoDate(-1 * (60 + ($index * 21))), + 'domain_ip' => '203.0.113.' . (10 + $index), + 'domain_name_servers' => 'ns1.demo-registrar.example, ns2.demo-registrar.example', + 'domain_mail_servers' => $profile['mail_platform'], + 'domain_registrar' => $vendor_id, + 'domain_client_id' => $client_id, + 'domain_created_at' => demoDateTime($profile['age_days'] - 3, 9, 45), + ]); + + starterInsert($mysqli, 'certificates', [ + 'certificate_name' => 'www.' . $profile['domain'], + 'certificate_description' => 'Public website certificate', + 'certificate_domain' => 'www.' . $profile['domain'], + 'certificate_issued_by' => $index % 2 === 0 ? "Let's Encrypt" : 'DigiCert', + 'certificate_expire' => demoDate(-1 * (20 + ($index * 9))), + 'certificate_domain_id' => $domain_id, + 'certificate_client_id' => $client_id, + 'certificate_created_at' => demoDateTime($profile['age_days'] - 3, 9, 50), + ]); + $counts['documentation'] += 2; + + // Credentials - only when the vault actually opened for this session + if ($context['vault_open']) { + foreach ($profile['credentials'] as $credential) { + $credential_id = starterInsert($mysqli, 'credentials', [ + 'credential_name' => $credential[0], + 'credential_description' => $credential[1], + 'credential_uri' => $credential[2], + 'credential_username' => encryptCredentialEntry($credential[3]), + 'credential_password' => encryptCredentialEntry(demoSerial($profile['abbreviation'] . $credential[0], 14) . '!aA1'), + 'credential_note' => $credential[4], + 'credential_client_id' => $client_id, + 'credential_created_at' => demoDateTime($profile['age_days'] - 6, 16, 10), + 'credential_password_changed_at' => demoDateTime(45 + $index, 16, 10), + ]); + demoAttachTags($mysqli, 'credential_tags', 'credential_id', 'tag_id', $credential_id, 4, $credential[5]); + $counts['documentation']++; + } + } + + // Services + foreach ($profile['services'] as $service) { + starterInsert($mysqli, 'services', [ + 'service_name' => $service[0], + 'service_description' => $service[1], + 'service_category' => $service[2], + 'service_importance' => $service[3], + 'service_backup' => $service[4], + 'service_notes' => $service[5], + 'service_review_due' => demoDate(-1 * (90 + ($index * 5))), + 'service_client_id' => $client_id, + 'service_created_at' => demoDateTime($profile['age_days'] - 10, 10, 30), + ]); + $counts['documentation']++; + } + + // Software and licensing + foreach ($profile['software'] as $software) { + $software_id = starterInsert($mysqli, 'software', [ + 'software_name' => $software[0], + 'software_description' => $software[1], + 'software_version' => $software[2], + 'software_type' => $software[3], + 'software_license_type' => $software[4], + 'software_seats' => $software[5], + 'software_purchase_reference' => 'PO-' . demoSerial($profile['abbreviation'] . $software[0], 6), + 'software_purchase' => demoDate(300 + ($index * 3)), + 'software_expire' => demoDate(-1 * (65 + ($index * 11))), + 'software_notes' => $software[6], + 'software_vendor_id' => $vendor_id, + 'software_client_id' => $client_id, + 'software_created_at' => demoDateTime($profile['age_days'] - 12, 11, 45), + ]); + starterInsert($mysqli, 'software_keys', [ + 'software_key' => demoLicenseKey($profile['abbreviation'] . $software[0]), + 'software_key_software_id' => $software_id, + ]); + $counts['documentation']++; + } + + // Documentation, written at different points rather than all on day one + $document_months = demoClientMonths($profile); + foreach ($profile['documents'] as $document_index => $document) { + $written_months_ago = max(0, $document_months - 1 - ($document_index * 6)); + starterInsert($mysqli, 'documents', [ + 'document_name' => $document[0], + 'document_description' => $document[1], + 'document_content' => $document[2], + 'document_content_raw' => strip_tags($document[2]), + 'document_client_visible' => 0, + 'document_created_by' => $user_id, + 'document_updated_by' => $user_id, + 'document_client_id' => $client_id, + 'document_created_at' => demoMonthDateTime($written_months_ago, 12 + $document_index, 15, 20), + ], ['document_content', 'document_content_raw']); + $counts['documentation']++; + } + + // Support and billing + $project_id = demoBuildProjects($mysqli, $profile, $client_id, $context, $counts); + demoBuildTickets($mysqli, $profile, $index, $client_id, $contact_ids, $asset_ids, $primary_location_id, $project_id, $context, $counts); + demoBuildBilling($mysqli, $profile, $index, $client_id, $vendor_id, $context, $counts); + + // Calendar and mileage + if ($context['calendar_id']) { + starterInsert($mysqli, 'calendar_events', [ + 'event_title' => $profile['abbreviation'] . ' - ' . $profile['onsite_visit'], + 'event_location' => $profile['sites'][0]['address'] . ', ' . $profile['sites'][0]['city'], + 'event_description' => 'Scheduled onsite visit', + 'event_start' => demoDateTime(-1 * (3 + $index), 10, 0), + 'event_end' => demoDateTime(-1 * (3 + $index), 12, 30), + 'event_client_id' => $client_id, + 'event_location_id' => $primary_location_id, + 'event_calendar_id' => $context['calendar_id'], + 'event_created_at' => demoDateTime(5, 9, 0), + ]); + } + + starterInsert($mysqli, 'trips', [ + 'trip_date' => demoDate(12 + $index), + 'trip_purpose' => 'Onsite - ' . $profile['onsite_visit'], + 'trip_source' => 'Office', + 'trip_destination' => $profile['sites'][0]['city'] . ', ' . $profile['sites'][0]['state'], + 'trip_miles' => 8.5 + ($index * 2.5), + 'round_trip' => 1, + 'trip_user_id' => $user_id, + 'trip_client_id' => $client_id, + 'trip_created_at' => demoDateTime(12 + $index, 17, 30), + ]); + +} + +// ------------------------------ +// demoAttachTags +// Tags are matched to the library by name - anything not present is skipped +// rather than created, so the Tags starter pack stays the single source. +// NOTE asset_tags does not follow the convention the other four join tables +// use, hence the explicit tag column rather than a hard coded tag_id. +// ------------------------------ +function demoAttachTags($mysqli, $join_table, $join_column, $tag_column, $row_id, $tag_type, $tag_names) { + foreach ($tag_names as $tag_name) { + $tag_name = escapeSql($tag_name); + $row = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT tag_id FROM tags WHERE tag_name = '$tag_name' AND tag_type = $tag_type LIMIT 1")); + $tag_id = intval($row['tag_id'] ?? 0); + if ($tag_id) { + mysqli_query($mysqli, "INSERT INTO $join_table SET $join_column = $row_id, $tag_column = $tag_id"); + } + } +} + +// ------------------------------ +// demoContactEmail +// ------------------------------ +function demoContactEmail($name, $domain) { + $parts = explode(' ', mb_strtolower($name)); + $first = $parts[0] ?? 'user'; + $last = end($parts); + return mb_substr($first, 0, 1) . preg_replace('/[^a-z]/', '', $last) . '@' . $domain; +} + +// ------------------------------ +// demoMobileNumber +// 555-01xx is the reserved fictional range - nothing here can dial a real desk. +// ------------------------------ +function demoMobileNumber($area_code, $index, $offset) { + return $area_code . '-555-' . str_pad((string)(100 + ($index * 9) + $offset), 4, '0', STR_PAD_LEFT); +} + +// ------------------------------ +// demoLicenseKey +// ------------------------------ +function demoLicenseKey($seed) { + $raw = demoSerial($seed, 20); + return implode('-', str_split($raw, 5)); +} + +// ------------------------------ +// demoBuildAssets +// Builds the fleet from the profile's counts, named the way an MSP names kit. +// ------------------------------ +function demoBuildAssets($mysqli, $profile, $client_id, $location_id, $contact_ids, $vendor_id, &$counts) { + + $models = demoAssetModels(); + $prefixes = demoAssetPrefixes(); + $asset_ids = []; + $seed = 0; + + foreach ($profile['fleet'] as $asset_type => $quantity) { + + $type_models = $models[$asset_type] ?? [['Generic', 'Standard']]; + $prefix = $prefixes[$asset_type] ?? 'AST'; + + for ($number = 1; $number <= $quantity; $number++) { + + $model = $type_models[($number - 1) % count($type_models)]; + $seed++; + + // Workstations and laptops belong to a person, infrastructure does not + $contact_id = 0; + if (in_array($asset_type, ['Desktop', 'Laptop', 'Mobile Phone', 'Tablet']) && $contact_ids) { + $contact_id = $contact_ids[($number - 1) % count($contact_ids)]; + } + + $purchase_days = 400 + ($seed * 17); + $status = 'Deployed'; + if ($number === $quantity && $quantity > 3) { + $status = 'Ready to Deploy'; + } + + $asset_id = starterInsert($mysqli, 'assets', [ + 'asset_type' => $asset_type, + 'asset_name' => $profile['abbreviation'] . '-' . $prefix . '-' . str_pad((string)$number, 2, '0', STR_PAD_LEFT), + 'asset_description' => demoAssetDescription($asset_type), + 'asset_make' => $model[0], + 'asset_model' => $model[1], + 'asset_serial' => demoSerial($profile['abbreviation'] . $asset_type . $number), + 'asset_os' => demoAssetOs($asset_type, $number), + 'asset_status' => $status, + 'asset_purchase_reference' => 'PO-' . demoSerial($profile['abbreviation'] . 'po' . $seed, 6), + 'asset_purchase_date' => demoDate($purchase_days), + 'asset_warranty_expire' => demoDate($purchase_days - 1095), + 'asset_install_date' => demoDate($purchase_days - 14), + 'asset_physical_location' => demoAssetPhysicalLocation($asset_type), + 'asset_vendor_id' => $vendor_id, + 'asset_location_id' => $location_id, + 'asset_contact_id' => $contact_id, + 'asset_client_id' => $client_id, + 'asset_created_at' => demoDateTime(max(1, $profile['age_days'] - 20), 12, 0), + ]); + + $asset_ids[] = $asset_id; + demoAttachTags($mysqli, 'asset_tags', 'asset_tag_asset_id', 'asset_tag_tag_id', $asset_id, 5, demoAssetTags($asset_type)); + $counts['assets']++; + + } + } + + return $asset_ids; +} + +// ------------------------------ +// demoAssetDescription / demoAssetOs / demoAssetPhysicalLocation / demoAssetTags +// ------------------------------ +function demoAssetDescription($asset_type) { + $descriptions = [ + 'Firewall/Router' => 'Edge firewall - site to site VPN and content filtering', + 'Switch' => 'Access layer switch - PoE for phones and access points', + 'Access Point' => 'Ceiling mounted wireless access point', + 'Server' => 'Line of business application and file server', + 'Desktop' => 'Standard staff workstation', + 'Laptop' => 'Mobile staff laptop', + 'Printer' => 'Shared multifunction printer', + 'Virtual Machine' => 'Virtual server guest', + 'Phone' => 'Desk handset', + 'Tablet' => 'Shared tablet', + 'Mobile Phone' => 'Company mobile handset', + 'Camera' => 'Site security camera', + ]; + return $descriptions[$asset_type] ?? 'Client equipment'; +} + +function demoAssetOs($asset_type, $number) { + $systems = [ + 'Firewall/Router' => 'FortiOS 7.4.4', + 'Switch' => 'Managed switch firmware', + 'Access Point' => 'UniFi 7.0', + 'Server' => 'Windows Server 2022 Standard', + 'Desktop' => 'Windows 11 Pro 24H2', + 'Laptop' => 'Windows 11 Pro 24H2', + 'Printer' => 'Vendor firmware', + 'Virtual Machine' => 'Ubuntu Server 24.04 LTS', + 'Phone' => 'Vendor firmware', + 'Tablet' => 'iPadOS 18', + 'Mobile Phone' => 'iOS 18', + 'Camera' => 'Vendor firmware', + ]; + if ($asset_type === 'Desktop' && $number % 4 === 0) { + return 'Windows 10 Pro 22H2'; + } + return $systems[$asset_type] ?? ''; +} + +function demoAssetPhysicalLocation($asset_type) { + if (in_array($asset_type, ['Firewall/Router', 'Switch', 'Server', 'Virtual Machine'])) { + return 'Server room rack'; + } + if ($asset_type === 'Access Point') { + return 'Ceiling - open office'; + } + if ($asset_type === 'Printer') { + return 'Copy room'; + } + return 'Staff desk'; +} + +function demoAssetTags($asset_type) { + $tags = [ + 'Firewall/Router' => ['Firewall', 'Business Critical', 'Monitored'], + 'Switch' => ['Switch', 'Monitored'], + 'Access Point' => ['Access Point', 'Monitored'], + 'Server' => ['Server', 'Business Critical', 'Backed Up', 'Monitored'], + 'Desktop' => ['Workstation', 'Endpoint Protection'], + 'Laptop' => ['Laptop', 'Endpoint Protection'], + 'Printer' => ['Printer'], + 'Virtual Machine' => ['Server', 'Backed Up'], + 'Phone' => ['VoIP Handset'], + 'Tablet' => ['Mobile'], + 'Mobile Phone' => ['Mobile'], + ]; + return $tags[$asset_type] ?? []; +} + +// ------------------------------ +// demoMonthDateTime / demoMonthDate +// Anchored to a month rather than a day count, so a two year run lands on +// sensible calendar months instead of drifting. +// ------------------------------ +function demoMonthDateTime($months_ago, $day, $hour = 9, $minute = 0) { + return date('Y-m-d H:i:s', mktime($hour, $minute, 0, date('n') - $months_ago, $day, date('Y'))); +} + +function demoMonthDate($months_ago, $day) { + return date('Y-m-d', mktime(12, 0, 0, date('n') - $months_ago, $day, date('Y'))); +} + +// ------------------------------ +// demoIsFuture +// Month anchored rows can land past today depending on what day of the month +// the load is run - a payroll run dated the 28th when it is the 24th. Money +// that has not happened yet has no business in the ledger, so it is skipped. +// ------------------------------ +function demoIsFuture($date) { + return strtotime($date) > time(); +} + +// ------------------------------ +// demoClientMonths +// How many months of history a client gets - their whole life with us, capped +// at the two year window. Newer clients ramp up rather than appearing fully +// formed, which is what makes the trend charts worth looking at. +// ------------------------------ +function demoClientMonths($profile) { + return max(3, min(24, (int)floor($profile['age_days'] / 30))); +} + +// ------------------------------ +// demoBuildProjects +// A finished project behind them and, for some, one still running. Returns +// the live project ID so tickets can be filed against it. +// ------------------------------ +function demoBuildProjects($mysqli, $profile, $client_id, $context, &$counts) { + + global $config_project_prefix; + + $user_id = $context['user_id']; + $prefix = $config_project_prefix ?? ''; + $months = demoClientMonths($profile); + $active_project_id = 0; + + // Something delivered last year, for the history + if ($months >= 14) { + starterInsert($mysqli, 'projects', [ + 'project_prefix' => $prefix, + 'project_number' => demoNextNumber($mysqli, 'config_project_next_number'), + 'project_name' => 'Server and network refresh', + 'project_description' => 'Replaced end of life server hardware, rebuilt the switching and brought the site onto the standard build.', + 'project_due' => demoMonthDate(13, 20), + 'project_manager' => $user_id, + 'project_client_id' => $client_id, + 'project_created_at' => demoMonthDateTime(15, 4, 9, 30), + 'project_completed_at' => demoMonthDateTime(13, 18, 16, 45), + ]); + $counts['projects']++; + } + + // And whatever is live now + if (!empty($profile['project'])) { + $active_project_id = starterInsert($mysqli, 'projects', [ + 'project_prefix' => $prefix, + 'project_number' => demoNextNumber($mysqli, 'config_project_next_number'), + 'project_name' => $profile['project'][0], + 'project_description' => $profile['project'][1], + 'project_due' => demoMonthDate(-2, 15), + 'project_manager' => $user_id, + 'project_client_id' => $client_id, + 'project_created_at' => demoMonthDateTime(1, 8, 10, 0), + ]); + $counts['projects']++; + } + + return $active_project_id; +} + +// ------------------------------ +// demoBuildTickets +// Two years of closed work behind a live queue. The history is what makes the +// ticket, time and technician reports worth opening; the six staged tickets at +// the front are what the queue and kanban look like today. +// ------------------------------ +function demoBuildTickets($mysqli, $profile, $index, $client_id, $contact_ids, $asset_ids, $location_id, $project_id, $context, &$counts) { + + global $config_ticket_prefix; + + $user_id = $context['user_id']; + $pool = demoTicketPool(); + $pool_size = count($pool); + $prefix = $config_ticket_prefix ?? ''; + $months = demoClientMonths($profile); + $sources = ['Email', 'Agent', 'Portal']; + + // Category IDs are the same handful over and over - look them up once + $category_ids = []; + foreach ($pool as $entry) { + if (!isset($category_ids[$entry['category']])) { + $category_ids[$entry['category']] = starterCategoryId($mysqli, $entry['category'], 'Ticket'); + } + } + + // ---- Closed history, month by month ---- + $per_month = max(2, (int)floor($profile['seats'] / 10)); + $sequence = 0; + + for ($month = $months; $month >= 1; $month--) { + for ($n = 0; $n < $per_month; $n++) { + + $sequence++; + $ticket = $pool[(($index * 5) + $sequence) % $pool_size]; + $day = 2 + ((($index + $month + $n) * 7) % 25); + $hour = 8 + (($sequence + $n) % 9); + + $contact_id = $contact_ids ? $contact_ids[$sequence % count($contact_ids)] : 0; + $asset_id = $asset_ids ? $asset_ids[$sequence % count($asset_ids)] : 0; + + $ticket_id = starterInsert($mysqli, 'tickets', [ + 'ticket_prefix' => $prefix, + 'ticket_number' => demoNextNumber($mysqli, 'config_ticket_next_number'), + 'ticket_source' => $sources[$sequence % 3], + 'ticket_category' => (string)$category_ids[$ticket['category']], + 'ticket_subject' => $ticket['subject'], + 'ticket_details' => $ticket['details'], + 'ticket_priority' => $ticket['priority'], + 'ticket_status' => $context['status_closed'], + 'ticket_billable' => $ticket['billable'], + 'ticket_url_key' => randomString(32), + 'ticket_created_at' => demoMonthDateTime($month, $day, $hour, 20), + 'ticket_first_response_at' => demoMonthDateTime($month, $day, $hour + 1, 5), + 'ticket_resolved_at' => demoMonthDateTime($month, $day + 1, 14, 30), + 'ticket_closed_at' => demoMonthDateTime($month, $day + 2, 9, 15), + 'ticket_created_by' => $user_id, + 'ticket_assigned_to' => $user_id, + 'ticket_closed_by' => $user_id, + 'ticket_contact_id' => $contact_id, + 'ticket_location_id' => $location_id, + 'ticket_asset_id' => $asset_id, + 'ticket_client_id' => $client_id, + ], ['ticket_details']); + + if ($asset_id) { + mysqli_query($mysqli, "INSERT INTO ticket_assets SET ticket_id = $ticket_id, asset_id = $asset_id"); + } + + // The agent reply carries the time, which is what the time reports read + $worked = ''; + foreach ($ticket['replies'] as $reply) { + if ($reply[0] !== 'Client' && !empty($reply[2])) { + $worked = $reply[2]; + starterInsert($mysqli, 'ticket_replies', [ + 'ticket_reply' => $reply[1], + 'ticket_reply_type' => $reply[0], + 'ticket_reply_time_worked' => $reply[2], + 'ticket_reply_by' => $user_id, + 'ticket_reply_ticket_id' => $ticket_id, + 'ticket_reply_created_at' => demoMonthDateTime($month, $day + 1, 13, 45), + ], ['ticket_reply']); + break; + } + } + + starterInsert($mysqli, 'ticket_history', [ + 'ticket_history_status' => 'New', + 'ticket_history_description' => 'Ticket created', + 'ticket_history_ticket_id' => $ticket_id, + 'ticket_history_created_at' => demoMonthDateTime($month, $day, $hour, 20), + ]); + starterInsert($mysqli, 'ticket_history', [ + 'ticket_history_status' => 'Resolved', + 'ticket_history_description' => 'Ticket resolved', + 'ticket_history_ticket_id' => $ticket_id, + 'ticket_history_created_at' => demoMonthDateTime($month, $day + 1, 14, 30), + ]); + starterInsert($mysqli, 'ticket_history', [ + 'ticket_history_status' => 'Closed', + 'ticket_history_description' => 'Ticket closed', + 'ticket_history_ticket_id' => $ticket_id, + 'ticket_history_created_at' => demoMonthDateTime($month, $day + 2, 9, 15), + ]); + + $counts['tickets']++; + + } + } + + // ---- The live queue ---- + // created / first response / resolved / closed, in days back from today + $stages = [ + ['status' => $context['status_closed'], 'created' => 74, 'response' => 74, 'resolved' => 71, 'closed' => 68], + ['status' => $context['status_closed'], 'created' => 41, 'response' => 41, 'resolved' => 39, 'closed' => 36], + ['status' => $context['status_resolved'], 'created' => 17, 'response' => 17, 'resolved' => 3, 'closed' => 0], + ['status' => $context['status_open'], 'created' => 6, 'response' => 6, 'resolved' => 0, 'closed' => 0], + ['status' => $index % 2 === 0 ? $context['status_hold'] : $context['status_open'], 'created' => 2, 'response' => 2, 'resolved' => 0, 'closed' => 0], + ['status' => $context['status_new'], 'created' => 0, 'response' => 0, 'resolved' => 0, 'closed' => 0], + ]; + + foreach ($stages as $slot => $stage) { + + $ticket = $pool[(($index * 6) + $slot) % $pool_size]; + + $contact_id = $contact_ids ? $contact_ids[$slot % count($contact_ids)] : 0; + $asset_id = $asset_ids ? $asset_ids[($index + $slot) % count($asset_ids)] : 0; + + // The newest ticket is deliberately left unassigned - a real queue has one + $assigned_to = $stage['status'] === $context['status_new'] ? 0 : $user_id; + + $fields = [ + 'ticket_prefix' => $prefix, + 'ticket_number' => demoNextNumber($mysqli, 'config_ticket_next_number'), + 'ticket_source' => $sources[$slot % 3], + 'ticket_category' => (string)$category_ids[$ticket['category']], + 'ticket_subject' => $ticket['subject'], + 'ticket_details' => $ticket['details'], + 'ticket_priority' => $ticket['priority'], + 'ticket_status' => $stage['status'], + 'ticket_billable' => $ticket['billable'], + 'ticket_url_key' => randomString(32), + 'ticket_created_at' => demoDateTime($stage['created'], 8 + $slot, 25), + 'ticket_created_by' => $user_id, + 'ticket_assigned_to' => $assigned_to, + 'ticket_contact_id' => $contact_id, + 'ticket_location_id' => $location_id, + 'ticket_asset_id' => $asset_id, + 'ticket_client_id' => $client_id, + ]; + + // Hang a couple of the live tickets off the running project + if ($project_id && in_array($slot, [3, 4])) { + $fields['ticket_project_id'] = $project_id; + } + + if ($stage['status'] !== $context['status_new']) { + $fields['ticket_first_response_at'] = demoDateTime($stage['response'], 9 + $slot, 40); + } + if ($stage['resolved']) { + $fields['ticket_resolved_at'] = demoDateTime($stage['resolved'], 15, 10); + } + if ($stage['closed']) { + $fields['ticket_closed_at'] = demoDateTime($stage['closed'], 15, 30); + $fields['ticket_closed_by'] = $user_id; + } + + $ticket_id = starterInsert($mysqli, 'tickets', $fields, ['ticket_details']); + $counts['tickets']++; + + if ($asset_id) { + mysqli_query($mysqli, "INSERT INTO ticket_assets SET ticket_id = $ticket_id, asset_id = $asset_id"); + } + + // History - opened, worked, and where it ended up + starterInsert($mysqli, 'ticket_history', [ + 'ticket_history_status' => 'New', + 'ticket_history_description' => 'Ticket created', + 'ticket_history_ticket_id' => $ticket_id, + 'ticket_history_created_at' => demoDateTime($stage['created'], 8 + $slot, 25), + ]); + if ($stage['status'] !== $context['status_new']) { + starterInsert($mysqli, 'ticket_history', [ + 'ticket_history_status' => 'Open', + 'ticket_history_description' => 'Assigned and first response sent', + 'ticket_history_ticket_id' => $ticket_id, + 'ticket_history_created_at' => demoDateTime($stage['response'], 9 + $slot, 40), + ]); + } + if ($stage['resolved']) { + starterInsert($mysqli, 'ticket_history', [ + 'ticket_history_status' => 'Resolved', + 'ticket_history_description' => 'Ticket resolved', + 'ticket_history_ticket_id' => $ticket_id, + 'ticket_history_created_at' => demoDateTime($stage['resolved'], 15, 10), + ]); + } + + // Replies - the client raising it, the agent working it, and internal notes + $reply_day = $stage['created']; + foreach ($ticket['replies'] as $reply_index => $reply) { + + $reply_day = max($stage['resolved'], $reply_day - 1); + $reply_by = $reply[0] === 'Client' ? $contact_id : $user_id; + + $reply_fields = [ + 'ticket_reply' => $reply[1], + 'ticket_reply_type' => $reply[0], + 'ticket_reply_by' => $reply_by, + 'ticket_reply_ticket_id' => $ticket_id, + 'ticket_reply_created_at' => demoDateTime($reply_day, 10 + $reply_index, 15), + ]; + + // Only agent time is billable time - client replies never book time + if ($reply[0] !== 'Client' && !empty($reply[2])) { + $reply_fields['ticket_reply_time_worked'] = $reply[2]; + } + + starterInsert($mysqli, 'ticket_replies', $reply_fields, ['ticket_reply']); + } + + // Tasks on the live tickets only + if (!empty($ticket['tasks']) && !$stage['closed']) { + foreach ($ticket['tasks'] as $task_order => $task) { + $task_fields = [ + 'task_name' => $task, + 'task_order' => $task_order + 1, + 'task_completion_estimate' => 15, + 'task_ticket_id' => $ticket_id, + 'task_created_at' => demoDateTime($stage['created'], 8 + $slot, 30), + ]; + if ($task_order === 0) { + $task_fields['task_completed_at'] = demoDateTime(max(0, $stage['created'] - 1), 11, 0); + $task_fields['task_completed_by'] = $user_id; + } + starterInsert($mysqli, 'tasks', $task_fields); + } + } + + } + + // A standing recurring ticket - the monthly maintenance visit + $recurring_category_id = starterCategoryId($mysqli, 'Maintenance', 'Ticket'); + $recurring_ticket_id = starterInsert($mysqli, 'recurring_tickets', [ + 'recurring_ticket_category' => (string)$recurring_category_id, + 'recurring_ticket_subject' => $profile['recurring_ticket'], + 'recurring_ticket_details' => '
Scheduled maintenance visit. Work the task list, log time against this ticket, and note anything needing quoting.
', + 'recurring_ticket_priority' => 'Low', + 'recurring_ticket_frequency' => 'Monthly', + 'recurring_ticket_billable' => 0, + 'recurring_ticket_start_date' => demoMonthDate($months, 1), + 'recurring_ticket_next_run' => demoDate(-1 * (5 + ($index % 20))), + 'recurring_ticket_created_by' => $user_id, + 'recurring_ticket_assigned_to' => $user_id, + 'recurring_ticket_client_id' => $client_id, + 'recurring_ticket_contact_id' => $contact_ids[0] ?? 0, + 'recurring_ticket_created_at' => demoMonthDateTime($months, 1, 9, 0), + ], ['recurring_ticket_details']); + + $recurring_tasks = ['Check backup job results', 'Review patch compliance', 'Check disk and memory headroom', 'Walk the site for anything obviously broken']; + foreach ($recurring_tasks as $task_order => $task) { + starterInsert($mysqli, 'recurring_ticket_tasks', [ + 'recurring_ticket_task_name' => $task, + 'recurring_ticket_task_order' => $task_order + 1, + 'recurring_ticket_task_completion_estimate' => 15, + 'recurring_ticket_task_recurring_ticket_id' => $recurring_ticket_id, + ]); + } + $counts['tickets']++; + +} + +// ------------------------------ +// demoAssetModels / demoAssetPrefixes +// ------------------------------ +function demoAssetModels() { + return [ + 'Firewall/Router' => [['Fortinet', 'FortiGate 60F'], ['SonicWall', 'TZ 470'], ['Ubiquiti', 'Dream Machine Pro']], + 'Switch' => [['Cisco', 'CBS350-24P'], ['Ubiquiti', 'USW-Pro-24-PoE'], ['NETGEAR', 'GS724TPv2']], + 'Access Point' => [['Ubiquiti', 'U6-Pro'], ['Cisco', 'CBW150AX'], ['RUCKUS', 'R550']], + 'Server' => [['Dell', 'PowerEdge R650'], ['HPE', 'ProLiant DL360 Gen10'], ['Dell', 'PowerEdge T440']], + 'Virtual Machine' => [['Proxmox', 'QEMU Guest'], ['VMware', 'vSphere Guest']], + 'Desktop' => [['Dell', 'OptiPlex 7010'], ['HP', 'ProDesk 600 G9'], ['Lenovo', 'ThinkCentre M70q']], + 'Laptop' => [['Dell', 'Latitude 5450'], ['HP', 'EliteBook 840 G10'], ['Lenovo', 'ThinkPad T14 Gen 4']], + 'Printer' => [['HP', 'LaserJet Pro M404dn'], ['Brother', 'MFC-L8900CDW'], ['Canon', 'imageRUNNER 1643i']], + 'Phone' => [['Yealink', 'T46U'], ['Poly', 'VVX 450']], + 'Tablet' => [['Apple', 'iPad 10th Gen'], ['Samsung', 'Galaxy Tab A9']], + 'Mobile Phone' => [['Apple', 'iPhone 14'], ['Samsung', 'Galaxy S23']], + 'Camera' => [['Ubiquiti', 'G4 Bullet'], ['Axis', 'M2036-LE']], + ]; +} + +function demoAssetPrefixes() { + return [ + 'Firewall/Router' => 'FW', + 'Switch' => 'SW', + 'Access Point' => 'AP', + 'Server' => 'SRV', + 'Virtual Machine' => 'VM', + 'Desktop' => 'WS', + 'Laptop' => 'LT', + 'Printer' => 'PRN', + 'Phone' => 'PH', + 'Tablet' => 'TAB', + 'Mobile Phone' => 'MOB', + 'Camera' => 'CAM', + ]; +} + +// ------------------------------ +// demoTicketPool +// The work an MSP actually sees. Each client draws six at a rolling offset, so +// the same issue turning up at more than one client is intentional. +// ------------------------------ +function demoTicketPool() { + return [ + [ + 'subject' => 'Outlook stuck on disconnected after password reset', + 'category' => 'Microsoft 365', + 'priority' => 'Medium', + 'billable' => 1, + 'details' => 'User reset their password this morning and Outlook has been sat on Disconnected since. Webmail works fine.
', + 'replies' => [ + ['Client', 'Still not connecting after a reboot. Can someone take a look today?
', ''], + ['Public', 'Cleared the cached credential in Credential Manager and re-authenticated the profile. Mail is flowing again - let me know if it drops.
', '00:25:00'], + ['Internal', 'Stale credential left over from the reset. Worth adding to the password change checklist.
', '00:05:00'], + ], + 'tasks' => ['Clear cached credentials', 'Re-authenticate profile', 'Confirm mail flow with user'], + ], + [ + 'subject' => 'Workstation running very slowly since last week', + 'category' => 'Workstation', + 'priority' => 'Low', + 'billable' => 1, + 'details' => 'Machine takes several minutes to get to a usable desktop and browsing is sluggish once it does.
', + 'replies' => [ + ['Public', 'Remoted on - disk was at 100% with a stalled update. Cleared the update cache, applied pending patches and rebooted. Boot time is back to normal.
', '00:45:00'], + ['Internal', 'Drive is a spinning disk and is on the refresh list. Flagged for the next hardware quote.
', '00:10:00'], + ], + 'tasks' => ['Check resource usage', 'Clear update cache', 'Confirm with user'], + ], + [ + 'subject' => 'Cannot print to the front office printer', + 'category' => 'Printer', + 'priority' => 'Medium', + 'billable' => 0, + 'details' => 'Nothing comes out from any of the front desk machines. The printer display shows ready.
', + 'replies' => [ + ['Client', 'It was working yesterday afternoon.
', ''], + ['Public', 'The printer had picked up a new DHCP address. Set a reservation on the firewall and repointed the print queues. Test pages came out on all three machines.
', '00:35:00'], + ], + 'tasks' => ['Check printer IP', 'Set DHCP reservation', 'Repoint print queues'], + ], + [ + 'subject' => 'Site internet dropping intermittently', + 'category' => 'Network', + 'priority' => 'High', + 'billable' => 1, + 'details' => 'Connection drops for a minute or two several times an hour. Affecting card processing and phones.
', + 'replies' => [ + ['Public', 'Logged into the firewall - WAN interface is flapping. Opened a ticket with the ISP and gave them the timestamps.
', '00:40:00'], + ['Internal', 'ISP found a bad line card at the pole. Replacement scheduled. Monitor for another 48 hours before closing.
', '00:15:00'], + ], + 'tasks' => ['Pull firewall WAN logs', 'Raise ISP ticket', 'Monitor for 48 hours'], + ], + [ + 'subject' => 'New starter setup - workstation, mailbox and access', + 'category' => 'Onboarding', + 'priority' => 'Medium', + 'billable' => 1, + 'details' => 'New hire starting a week Monday. Needs the standard build, a mailbox and access to the shared drives.
', + 'replies' => [ + ['Public', 'Machine is built and joined, mailbox is licensed, and the group memberships match the department template. Credentials will be handed over on their first morning.
', '01:30:00'], + ], + 'tasks' => ['Build and join workstation', 'Create mailbox and assign licence', 'Apply department group memberships', 'Handover on day one'], + ], + [ + 'subject' => 'Suspicious invoice email reported by staff', + 'category' => 'Phishing Report', + 'priority' => 'High', + 'billable' => 0, + 'details' => 'Staff member forwarded an emailed invoice from what looks like a supplier address but with a changed bank account.
', + 'replies' => [ + ['Client', 'Nobody clicked anything as far as I know. Wanted to flag it straight away.
', ''], + ['Public', 'Confirmed a spoofed display name from an external domain. Blocked the sender, checked mail rules on the mailbox, and confirmed no forwarding rules were added. Good catch reporting it.
', '00:50:00'], + ['Internal', 'Worth a short reminder to the finance team about verifying bank detail changes by phone.
', '00:10:00'], + ], + 'tasks' => ['Confirm sender and headers', 'Block sender', 'Audit mailbox rules', 'Confirm no credential entry'], + ], + [ + 'subject' => 'Backup job reporting failures overnight', + 'category' => 'Backup and Recovery', + 'priority' => 'High', + 'billable' => 0, + 'details' => 'Backup console has flagged three consecutive failed runs on the file server.
', + 'replies' => [ + ['Public', 'Snapshot was failing on a locked database file. Excluded the live file and added the database dump to the job. Last two runs are green and a test restore completed.
', '01:10:00'], + ['Internal', 'Restore test evidence saved with the client documentation for the compliance file.
', '00:15:00'], + ], + 'tasks' => ['Review job logs', 'Adjust job scope', 'Run a test restore', 'File restore evidence'], + ], + [ + 'subject' => 'Wireless dropping in the back offices', + 'category' => 'Wireless', + 'priority' => 'Medium', + 'billable' => 1, + 'details' => 'Staff at the far end of the building lose wireless when they move between rooms.
', + 'replies' => [ + ['Public', 'Survey shows a dead spot behind the store room. Adjusted transmit power on the nearest access point as a stopgap and quoted an additional unit for proper coverage.
', '01:00:00'], + ], + 'tasks' => ['Survey coverage', 'Adjust AP power', 'Quote additional access point'], + ], + [ + 'subject' => 'Shared drive permissions wrong after department move', + 'category' => 'Account and Access', + 'priority' => 'Medium', + 'billable' => 1, + 'details' => 'Two staff moved departments and can still see their old folders but not the new ones.
', + 'replies' => [ + ['Public', 'Group memberships were updated but the sessions had not refreshed. Corrected the groups, removed the leftover membership and confirmed access after sign out and back in.
', '00:30:00'], + ], + 'tasks' => ['Audit group membership', 'Correct permissions', 'Confirm access with both users'], + ], + [ + 'subject' => 'Server disk space warning', + 'category' => 'Monitoring Alert', + 'priority' => 'High', + 'billable' => 0, + 'details' => 'Monitoring raised a low disk space alert on the system volume - under 8% free.
', + 'replies' => [ + ['Public', 'Cleared shadow copies and old update files, which recovered enough headroom for now. Recommend extending the volume at the next maintenance window.
', '00:40:00'], + ['Internal', 'Volume extension needs a short reboot - put it on the next scheduled visit.
', '00:05:00'], + ], + 'tasks' => ['Free immediate space', 'Identify growth', 'Schedule volume extension'], + ], + [ + 'subject' => 'Staff member leaving - offboarding', + 'category' => 'Offboarding', + 'priority' => 'Medium', + 'billable' => 1, + 'details' => 'Leaving at the end of the week. Mailbox needs to be delegated to their manager and the laptop collected.
', + 'replies' => [ + ['Public', 'Account disabled at close of business, mailbox converted and delegated, licence reclaimed and the laptop wiped and returned to spare stock.
', '01:15:00'], + ], + 'tasks' => ['Disable account', 'Delegate mailbox', 'Reclaim licence', 'Collect and wipe laptop'], + ], + [ + 'subject' => 'Phones not ringing at the front desk', + 'category' => 'Phone and VoIP', + 'priority' => 'High', + 'billable' => 1, + 'details' => 'Inbound calls go straight to voicemail on the front desk handsets.
', + 'replies' => [ + ['Public', 'The ring group had lost one of its members after a handset was swapped. Re-added the extension and tested inbound from an outside line.
', '00:35:00'], + ], + 'tasks' => ['Check ring group', 'Re-add extension', 'Test inbound call'], + ], + [ + 'subject' => 'Line of business application will not launch', + 'category' => 'Line of Business Application', + 'priority' => 'High', + 'billable' => 1, + 'details' => 'Application closes immediately on launch for two users since this morning.
', + 'replies' => [ + ['Client', 'Everyone else seems fine. It is just the two of us.
', ''], + ['Public', 'Vendor client version was behind the server after their update. Updated both machines to the matching build and confirmed login.
', '00:55:00'], + ['Internal', 'Vendor pushes server side updates without notice - worth asking them to add us to the release list.
', '00:10:00'], + ], + 'tasks' => ['Confirm client version', 'Update to matching build', 'Confirm login with both users'], + ], + [ + 'subject' => 'Laptop screen cracked - needs replacement', + 'category' => 'Hardware Failure', + 'priority' => 'Medium', + 'billable' => 1, + 'details' => 'Laptop was dropped in transit. Screen is cracked and the display is unusable.
', + 'replies' => [ + ['Public', 'Unit is out of warranty. Quoted a replacement and issued a loaner from spare stock so they can keep working.
', '00:45:00'], + ], + 'tasks' => ['Check warranty status', 'Issue loaner', 'Quote replacement'], + ], + [ + 'subject' => 'Multi factor prompts looping on mobile', + 'category' => 'Account and Access', + 'priority' => 'Medium', + 'billable' => 0, + 'details' => 'Authenticator prompts keep repeating and never complete on the user mobile.
', + 'replies' => [ + ['Public', 'Removed and re-registered the authenticator entry, then confirmed sign in on both mobile and desktop.
', '00:30:00'], + ], + 'tasks' => ['Re-register authenticator', 'Confirm sign in'], + ], + [ + 'subject' => 'Quote request - workstation refresh', + 'category' => 'Procurement', + 'priority' => 'Low', + 'billable' => 0, + 'details' => 'Client has asked for pricing on replacing the oldest machines in the office.
', + 'replies' => [ + ['Public', 'Pulled the asset list and put together pricing for the machines past five years old. Quote sent for review.
', '00:40:00'], + ], + 'tasks' => ['Pull asset age report', 'Build quote', 'Send for approval'], + ], + ]; +} + +// ------------------------------ +// demoDataProfiles +// Ten fictional clients. Domains sit on the RFC 2606 reserved .example TLD on +// purpose - demo contacts must never be deliverable and a demo domain must +// never resolve to somebody's real business if a refresh gets clicked. +// The compact spec below is expanded into full profiles underneath it. +// ------------------------------ +function demoDataProfiles() { + + $specs = [ + [ + 'name' => 'Ravenwood Dental Group', 'abbreviation' => 'RDG', 'type' => 'Healthcare', + 'domain' => 'ravenwooddental.example', 'referral' => 'Client', 'rate' => 145.00, 'terms' => 15, + 'city' => 'Pittsburgh', 'state' => 'PA', 'zip' => '15212', 'area' => '412', 'street' => '1140 Ridge Avenue', + 'second_site' => ['Wexford Practice', '3025 Church Road', 'Wexford', 'PA', '15090'], + 'age_days' => 880, 'seats' => 26, 'servers' => 2, + 'tags' => ['Managed', 'Key Account', 'Compliance', 'Multi Site'], + 'notes' => 'Two practices sharing one patient system. Any work touching the imaging server needs to be out of clinic hours.', + 'account_note' => 'Practice owner signs off anything over $500. Quarterly review is booked with the office manager, not the owner.', + 'onsite_visit' => 'Quarterly review and maintenance', + 'recurring_ticket' => 'Monthly maintenance visit - Ravenwood Dental', + 'project' => ['Imaging server replacement', 'Replace the end of life imaging server and migrate the patient imaging database with no clinic downtime.'], + 'vendor' => ['Keystone Business Fiber', 'Primary internet and failover circuit', '412-555-0140', 'keystonefiber.example', '4 hour response'], + 'mail_platform' => 'Microsoft 365', + 'fleet' => ['Firewall/Router' => 1, 'Switch' => 2, 'Access Point' => 4, 'Server' => 2, 'Desktop' => 14, 'Laptop' => 3, 'Printer' => 3, 'Phone' => 8], + 'people' => [ + ['Marla Whitfield', 'Practice Owner', 'Management', 1, 1, 0, 'Signs off spend. Prefers a phone call over email.', ['Primary', 'Executive', 'Authorized Approver']], + ['Dana Prescott', 'Office Manager', 'Administration', 1, 1, 0, 'Day to day contact for scheduling and invoices.', ['Billing', 'Onsite Point of Contact']], + ['Owen Castellano', 'Operations Lead', 'Operations', 0, 0, 1, 'Knows the imaging system better than the vendor does.', ['Technical', 'After Hours']], + ['Priya Raghavan', 'Front Desk Supervisor', 'Reception', 0, 0, 0, 'First to report anything broken at the front desk.', []], + ], + ], + [ + 'name' => 'Kestrel Precision Manufacturing', 'abbreviation' => 'KPM', 'type' => 'Manufacturing', + 'domain' => 'kestrelprecision.example', 'referral' => 'Networking Group', 'rate' => 155.00, 'terms' => 30, + 'city' => 'Erie', 'state' => 'PA', 'zip' => '16510', 'area' => '814', 'street' => '4400 Foundry Way', + 'second_site' => ['Plant Two', '820 Industrial Parkway', 'Erie', 'PA', '16511'], + 'age_days' => 1240, 'seats' => 42, 'servers' => 4, + 'tags' => ['Managed', 'Key Account', 'Multi Site', 'After Hours Support'], + 'notes' => 'Shop floor runs two shifts. Anything touching the ERP has to wait for the Sunday window.', + 'account_note' => 'Plant manager owns the maintenance window. ERP vendor must be on any call involving the production database.', + 'onsite_visit' => 'Shop floor equipment check', + 'recurring_ticket' => 'Monthly maintenance visit - Kestrel Precision', + 'project' => ['Plant Two network rebuild', 'Replace the unmanaged switching in Plant Two, add VLAN separation for shop floor equipment and extend wireless to the yard.'], + 'vendor' => ['Lakeshore Telecom', 'Fibre circuits for both plants', '814-555-0175', 'lakeshoretelecom.example', 'Next business day'], + 'mail_platform' => 'Microsoft 365', + 'fleet' => ['Firewall/Router' => 2, 'Switch' => 6, 'Access Point' => 8, 'Server' => 3, 'Virtual Machine' => 4, 'Desktop' => 22, 'Laptop' => 9, 'Printer' => 4, 'Phone' => 14, 'Camera' => 6], + 'people' => [ + ['Grant Holloway', 'Operations Director', 'Management', 1, 0, 0, 'Approves project work and downtime windows.', ['Primary', 'Executive', 'Authorized Approver']], + ['Renata Vasquez', 'Finance Manager', 'Finance', 0, 1, 0, 'Handles purchase orders - invoices need a PO number.', ['Billing']], + ['Tobias Lindqvist', 'Maintenance Supervisor', 'Operations', 1, 0, 1, 'Site contact for anything on the shop floor.', ['Technical', 'Onsite Point of Contact', 'After Hours']], + ['Alicia Freeman', 'Quality Coordinator', 'Quality', 0, 0, 0, 'Owns the audit paperwork that our documentation feeds.', ['Emergency']], + ], + ], + [ + 'name' => 'Blackburn and Meyer LLP', 'abbreviation' => 'BML', 'type' => 'Legal', + 'domain' => 'blackburnmeyer.example', 'referral' => 'Partner', 'rate' => 165.00, 'terms' => 15, + 'city' => 'Pittsburgh', 'state' => 'PA', 'zip' => '15222', 'area' => '412', 'street' => '600 Grant Street, Suite 1900', + 'second_site' => null, + 'age_days' => 700, 'seats' => 18, 'servers' => 2, + 'tags' => ['Managed', 'Compliance', 'Cyber Insurance', 'Key Account'], + 'notes' => 'Cyber insurance renewal drives most of the security work here. Everything is documented for the questionnaire.', + 'account_note' => 'Managing partner wants a written summary after any security incident, however small.', + 'onsite_visit' => 'Security review and staff walkthrough', + 'recurring_ticket' => 'Monthly maintenance visit - Blackburn and Meyer', + 'project' => null, + 'vendor' => ['Meridian Legal Systems', 'Practice management platform', '412-555-0188', 'meridianlegal.example', '8x5 support'], + 'mail_platform' => 'Microsoft 365', + 'fleet' => ['Firewall/Router' => 1, 'Switch' => 2, 'Access Point' => 3, 'Server' => 2, 'Desktop' => 11, 'Laptop' => 8, 'Printer' => 2, 'Phone' => 12], + 'people' => [ + ['Eleanor Blackburn', 'Managing Partner', 'Management', 1, 1, 0, 'Final say on anything security related.', ['Primary', 'Executive', 'Authorized Approver']], + ['Curtis Meyer', 'Partner', 'Management', 1, 0, 0, 'Travels constantly - mobile is the reliable route.', ['Executive']], + ['Sylvia Nakamura', 'Practice Administrator', 'Administration', 1, 1, 1, 'Runs the office and the practice management system.', ['Technical', 'Billing', 'Onsite Point of Contact']], + ['Devon Ashcroft', 'Paralegal', 'Legal', 0, 0, 0, 'Reports issues on behalf of the paralegal team.', []], + ], + ], + [ + 'name' => 'Harbor Point Credit Union', 'abbreviation' => 'HPCU', 'type' => 'Financial Services', + 'domain' => 'harborpointcu.example', 'referral' => 'Chamber of Commerce', 'rate' => 175.00, 'terms' => 30, + 'city' => 'Cleveland', 'state' => 'OH', 'zip' => '44113', 'area' => '216', 'street' => '1250 Lakeside Avenue', + 'second_site' => ['Parma Branch', '7300 Ridge Road', 'Parma', 'OH', '44129'], + 'age_days' => 1500, 'seats' => 34, 'servers' => 3, + 'tags' => ['Managed', 'Compliance', 'Cyber Insurance', 'After Hours Support', 'Multi Site'], + 'notes' => 'Regulated environment. Change control paperwork is required before anything touches the core banking network.', + 'account_note' => 'Annual examination in the autumn - documentation and restore evidence must be current before then.', + 'onsite_visit' => 'Branch equipment and compliance check', + 'recurring_ticket' => 'Monthly maintenance visit - Harbor Point CU', + 'project' => ['Branch firewall standardisation', 'Bring both branches onto the same firewall platform with central logging and documented rule sets for the examination.'], + 'vendor' => ['Northcoast Data Services', 'Core banking platform hosting', '216-555-0122', 'northcoastdata.example', '24/7 with 1 hour response'], + 'mail_platform' => 'Microsoft 365', + 'fleet' => ['Firewall/Router' => 2, 'Switch' => 4, 'Access Point' => 5, 'Server' => 3, 'Virtual Machine' => 3, 'Desktop' => 24, 'Laptop' => 6, 'Printer' => 4, 'Phone' => 20, 'Camera' => 8], + 'people' => [ + ['Vernon Ashby', 'Chief Executive', 'Management', 1, 0, 0, 'Sees the quarterly summary, not the day to day.', ['Primary', 'Executive']], + ['Karen Doyle', 'Controller', 'Finance', 0, 1, 0, 'Owns the budget and the invoices.', ['Billing', 'Authorized Approver']], + ['Miguel Santoro', 'Operations Manager', 'Operations', 1, 0, 1, 'Coordinates change control and branch access.', ['Technical', 'Onsite Point of Contact', 'After Hours']], + ['Hannah Brightwell', 'Branch Manager - Parma', 'Operations', 0, 0, 0, 'Point of contact for the Parma branch.', ['Emergency']], + ], + ], + [ + 'name' => 'Sinclair Property Group', 'abbreviation' => 'SPG', 'type' => 'Real Estate', + 'domain' => 'sinclairproperty.example', 'referral' => 'Website', 'rate' => 135.00, 'terms' => 30, + 'city' => 'Columbus', 'state' => 'OH', 'zip' => '43215', 'area' => '614', 'street' => '88 East Broad Street', + 'second_site' => null, + 'age_days' => 520, 'seats' => 15, 'servers' => 1, + 'tags' => ['Co-Managed', 'Managed'], + 'notes' => 'Client has an internal person who handles first line. We pick up escalations and all infrastructure work.', + 'account_note' => 'Internal IT contact triages first - tickets should come through them rather than direct from staff.', + 'onsite_visit' => 'Office equipment check', + 'recurring_ticket' => 'Monthly maintenance visit - Sinclair Property', + 'project' => null, + 'vendor' => ['Buckeye Broadband Business', 'Office internet', '614-555-0166', 'buckeyebusiness.example', 'Next business day'], + 'mail_platform' => 'Google Workspace', + 'fleet' => ['Firewall/Router' => 1, 'Switch' => 2, 'Access Point' => 3, 'Server' => 1, 'Desktop' => 8, 'Laptop' => 7, 'Printer' => 2, 'Mobile Phone' => 6], + 'people' => [ + ['Roland Sinclair', 'Principal', 'Management', 1, 1, 0, 'Owner. Wants short answers and no jargon.', ['Primary', 'Executive', 'Authorized Approver']], + ['Beatrix Nolan', 'Office Manager', 'Administration', 0, 1, 0, 'Handles invoices and supplier accounts.', ['Billing']], + ['Jeremy Okafor', 'Internal IT Coordinator', 'Operations', 1, 0, 1, 'Co-managed counterpart - handles first line internally.', ['Technical', 'Onsite Point of Contact']], + ['Nadia Kellerman', 'Lettings Coordinator', 'Operations', 0, 0, 0, 'Field staff - mostly mobile issues.', []], + ], + ], + [ + 'name' => 'Northgate Veterinary Clinic', 'abbreviation' => 'NVC', 'type' => 'Veterinary', + 'domain' => 'northgatevet.example', 'referral' => 'Friend', 'rate' => 125.00, 'terms' => 15, + 'city' => 'Wexford', 'state' => 'PA', 'zip' => '15090', 'area' => '724', 'street' => '910 Perry Highway', + 'second_site' => null, + 'age_days' => 400, 'seats' => 12, 'servers' => 1, + 'tags' => ['Break Fix', 'Managed'], + 'notes' => 'Small practice, price sensitive. Moved onto a light agreement after a hardware failure took them down for a day.', + 'account_note' => 'Was break fix until the server failure. Watch for scope creep against the light agreement.', + 'onsite_visit' => 'Practice equipment check', + 'recurring_ticket' => 'Monthly maintenance visit - Northgate Veterinary', + 'project' => null, + 'vendor' => ['Allegheny Cable Business', 'Internet and phone', '724-555-0119', 'alleghenybusiness.example', 'Best effort'], + 'mail_platform' => 'Microsoft 365', + 'fleet' => ['Firewall/Router' => 1, 'Switch' => 1, 'Access Point' => 2, 'Server' => 1, 'Desktop' => 7, 'Laptop' => 2, 'Printer' => 2, 'Tablet' => 3], + 'people' => [ + ['Dr Imogen Hartley', 'Practice Owner', 'Management', 1, 1, 0, 'Owner and lead vet. Best reached between appointments.', ['Primary', 'Executive', 'Authorized Approver']], + ['Cassidy Boyle', 'Practice Manager', 'Administration', 1, 1, 1, 'Handles everything administrative and technical on site.', ['Technical', 'Billing', 'Onsite Point of Contact']], + ['Elliot Marsh', 'Veterinary Nurse', 'Clinical', 0, 0, 0, 'Reports issues from the treatment rooms.', []], + ['Georgia Pemberton', 'Reception Lead', 'Reception', 0, 0, 0, 'Front desk and booking system.', []], + ], + ], + [ + 'name' => 'Delaney Construction', 'abbreviation' => 'DLC', 'type' => 'Construction', + 'domain' => 'delaneyconstruction.example', 'referral' => 'Client', 'rate' => 140.00, 'terms' => 30, + 'city' => 'Greensburg', 'state' => 'PA', 'zip' => '15601', 'area' => '724', 'street' => '2200 Route 30 East', + 'second_site' => ['Yard and Workshop', '145 Depot Street', 'Latrobe', 'PA', '15650'], + 'age_days' => 640, 'seats' => 22, 'servers' => 1, + 'tags' => ['Managed', 'Multi Site'], + 'notes' => 'Field staff work off laptops and tablets with patchy connectivity. Site offices come and go.', + 'account_note' => 'Field kit takes a beating - replacements are expected rather than exceptional. Keep spares on the shelf.', + 'onsite_visit' => 'Office and yard equipment check', + 'recurring_ticket' => 'Monthly maintenance visit - Delaney Construction', + 'project' => null, + 'vendor' => ['Westmoreland Wireless', 'Site office connectivity', '724-555-0154', 'westmorelandwireless.example', 'Next business day'], + 'mail_platform' => 'Microsoft 365', + 'fleet' => ['Firewall/Router' => 2, 'Switch' => 2, 'Access Point' => 4, 'Server' => 1, 'Desktop' => 6, 'Laptop' => 14, 'Printer' => 2, 'Tablet' => 6, 'Mobile Phone' => 10], + 'people' => [ + ['Fiona Delaney', 'Owner', 'Management', 1, 1, 0, 'Runs the business. Usually on a site rather than at a desk.', ['Primary', 'Executive', 'Authorized Approver']], + ['Warren Tiller', 'Commercial Manager', 'Finance', 0, 1, 0, 'Approves invoices and equipment purchases.', ['Billing']], + ['Sasha Nowak', 'Site Systems Coordinator', 'Operations', 1, 0, 1, 'Sets up and strips down site office kit.', ['Technical', 'Onsite Point of Contact']], + ['Bram Whitaker', 'Project Supervisor', 'Operations', 0, 0, 0, 'Field escalation for laptops and tablets.', ['After Hours']], + ], + ], + [ + 'name' => 'Aurora Wellness Center', 'abbreviation' => 'AWC', 'type' => 'Healthcare', + 'domain' => 'aurorawellness.example', 'referral' => 'Social Media', 'rate' => 130.00, 'terms' => 15, + 'city' => 'Morgantown', 'state' => 'WV', 'zip' => '26505', 'area' => '304', 'street' => '415 Chestnut Ridge Road', + 'second_site' => null, + 'age_days' => 300, 'seats' => 16, 'servers' => 1, + 'tags' => ['Managed', 'Onboarding', 'Compliance'], + 'notes' => 'Recently onboarded. Documentation is still being built out as we find things.', + 'account_note' => 'Onboarding discovery is not finished - expect gaps in the asset list for another month.', + 'onsite_visit' => 'Onboarding discovery visit', + 'recurring_ticket' => 'Monthly maintenance visit - Aurora Wellness', + 'project' => ['Onboarding and documentation', 'Complete discovery, document the environment, standardise endpoint protection and get backups verified.'], + 'vendor' => ['Mountaineer Connect', 'Internet and managed wifi', '304-555-0133', 'mountaineerconnect.example', '4 hour response'], + 'mail_platform' => 'Google Workspace', + 'fleet' => ['Firewall/Router' => 1, 'Switch' => 2, 'Access Point' => 4, 'Server' => 1, 'Desktop' => 9, 'Laptop' => 4, 'Printer' => 2, 'Tablet' => 4], + 'people' => [ + ['Talia Mendoza', 'Clinic Director', 'Management', 1, 1, 0, 'New client contact - still learning what we do.', ['Primary', 'Executive', 'Authorized Approver']], + ['Ruth Ellery', 'Business Manager', 'Administration', 0, 1, 0, 'Invoices and supplier accounts.', ['Billing']], + ['Colm Bradigan', 'Facilities Coordinator', 'Operations', 1, 0, 1, 'Holds the keys and the cupboard the switch lives in.', ['Technical', 'Onsite Point of Contact']], + ['Yusuf Karim', 'Therapist', 'Clinical', 0, 0, 0, 'Reports issues in the treatment rooms.', []], + ], + ], + [ + 'name' => 'Pinnacle Freight Systems', 'abbreviation' => 'PFS', 'type' => 'Logistics', + 'domain' => 'pinnaclefreight.example', 'referral' => 'Cold Outreach', 'rate' => 150.00, 'terms' => 30, + 'city' => 'Youngstown', 'state' => 'OH', 'zip' => '44505', 'area' => '330', 'street' => '3300 Logistics Drive', + 'second_site' => ['Distribution Warehouse', '760 Terminal Road', 'Youngstown', 'OH', '44506'], + 'age_days' => 1050, 'seats' => 30, 'servers' => 3, + 'tags' => ['Managed', 'After Hours Support', 'Multi Site', 'Past Due'], + 'notes' => 'Warehouse runs around the clock. Scanner and label printer issues are the bulk of the ticket volume.', + 'account_note' => 'Account has run past terms twice this year - finance chases rather than the account manager.', + 'onsite_visit' => 'Warehouse scanner and network check', + 'recurring_ticket' => 'Monthly maintenance visit - Pinnacle Freight', + 'project' => null, + 'vendor' => ['Mahoning Valley Fiber', 'Warehouse and office circuits', '330-555-0147', 'mahoningfiber.example', '24/7 with 2 hour response'], + 'mail_platform' => 'Microsoft 365', + 'fleet' => ['Firewall/Router' => 2, 'Switch' => 5, 'Access Point' => 9, 'Server' => 3, 'Virtual Machine' => 2, 'Desktop' => 16, 'Laptop' => 6, 'Printer' => 5, 'Phone' => 12, 'Tablet' => 8], + 'people' => [ + ['Desmond Iverson', 'General Manager', 'Management', 1, 0, 0, 'Approves spend and downtime.', ['Primary', 'Executive', 'Authorized Approver']], + ['Lorraine Pike', 'Accounts Payable', 'Finance', 0, 1, 0, 'Pays on their own schedule - chase politely.', ['Billing']], + ['Ike Ferrante', 'Warehouse Systems Lead', 'Operations', 1, 0, 1, 'Night shift contact for scanner and label printer problems.', ['Technical', 'After Hours', 'Onsite Point of Contact']], + ['Simone Vasser', 'Dispatch Supervisor', 'Operations', 0, 0, 0, 'Raises most of the day to day tickets.', ['Emergency']], + ], + ], + [ + 'name' => 'Copperline Coffee Roasters', 'abbreviation' => 'CCR', 'type' => 'Retail', + 'domain' => 'copperlinecoffee.example', 'referral' => 'Event', 'rate' => 120.00, 'terms' => 15, + 'city' => 'Pittsburgh', 'state' => 'PA', 'zip' => '15201', 'area' => '412', 'street' => '2115 Butler Street', + 'second_site' => ['Roastery', '48 Preble Avenue', 'Pittsburgh', 'PA', '15233'], + 'age_days' => 180, 'seats' => 9, 'servers' => 0, + 'tags' => ['Break Fix', 'Onboarding', 'Multi Site'], + 'notes' => 'Two retail sites and a roastery. Point of sale and card processing are the only things that really matter to them.', + 'account_note' => 'Still deciding whether to move onto a full agreement - keep the value visible on every invoice.', + 'onsite_visit' => 'Point of sale and network check', + 'recurring_ticket' => 'Monthly maintenance visit - Copperline Coffee', + 'project' => null, + 'vendor' => ['Steel City Point of Sale', 'Till system and card terminals', '412-555-0198', 'steelcitypos.example', '8x5 support'], + 'mail_platform' => 'Google Workspace', + 'fleet' => ['Firewall/Router' => 2, 'Switch' => 2, 'Access Point' => 4, 'Desktop' => 3, 'Laptop' => 2, 'Tablet' => 5, 'Printer' => 2], + 'people' => [ + ['Jonah Rickerby', 'Owner', 'Management', 1, 1, 0, 'Hands on owner - usually behind the counter.', ['Primary', 'Executive', 'Authorized Approver']], + ['Mira Lachlan', 'Operations Manager', 'Operations', 1, 1, 1, 'Runs both sites and the roastery.', ['Technical', 'Billing', 'Onsite Point of Contact']], + ['Felix Oyelaran', 'Store Lead - Butler Street', 'Retail', 0, 0, 0, 'Front of house point of contact.', []], + ['Suzanne Kirby', 'Roastery Supervisor', 'Production', 0, 0, 0, 'Reports issues from the roastery.', []], + ], + ], + ]; + + $profiles = []; + foreach ($specs as $spec) { + $profiles[] = demoExpandProfile($spec); + } + + return $profiles; +} + +// ------------------------------ +// demoExpandProfile +// Turns a compact client spec into the full profile the builders consume. The +// documentation, agreement lines and quotes are derived from the spec so the +// numbers hang together - seat counts match licence counts match invoice lines. +// ------------------------------ +function demoExpandProfile($spec) { + + $name = $spec['name']; + $short = $spec['abbreviation']; + $domain = $spec['domain']; + $seats = $spec['seats']; + $servers = $spec['servers']; + $mail = $spec['mail_platform']; + $phone = $spec['area'] . '-555-0100'; + + // Sites + $sites = [[ + 'name' => 'Main Office', + 'description' => 'Primary site - server room, main office space and reception', + 'address' => $spec['street'], + 'city' => $spec['city'], + 'state' => $spec['state'], + 'zip' => $spec['zip'], + 'phone' => $phone, + 'hours' => 'Mon-Fri 8:00 - 17:00', + 'tags' => ['Head Office', 'Server Room'], + ]]; + if (!empty($spec['second_site'])) { + $sites[] = [ + 'name' => $spec['second_site'][0], + 'description' => 'Secondary site connected back to the main office', + 'address' => $spec['second_site'][1], + 'city' => $spec['second_site'][2], + 'state' => $spec['second_site'][3], + 'zip' => $spec['second_site'][4], + 'phone' => $spec['area'] . '-555-0101', + 'hours' => 'Mon-Fri 8:00 - 17:00', + 'tags' => ['Branch'], + ]; + } + + // Networks + $networks = [ + ['Office LAN', 'Staff workstations, printers and handsets', 10, '192.168.10.0', '192.168.10.1', '192.168.10.1', '192.168.10.50 - 192.168.10.220'], + ['Guest Wireless', 'Isolated guest access - no route to the office LAN', 40, '192.168.40.0', '192.168.40.1', '192.168.40.1', '192.168.40.20 - 192.168.40.200'], + ]; + if ($servers > 1) { + $networks[] = ['Server VLAN', 'Servers and management interfaces', 20, '192.168.20.0', '192.168.20.1', '192.168.20.1', 'Static addressing only']; + } + if (in_array($spec['type'], ['Manufacturing', 'Logistics'])) { + $networks[] = ['Equipment VLAN', 'Shop floor and warehouse equipment - no internet access', 30, '192.168.30.0', '192.168.30.1', '192.168.30.1', '192.168.30.40 - 192.168.30.240']; + } + + // Credentials + $mail_admin_uri = $mail === 'Google Workspace' ? 'https://admin.google.com' : 'https://admin.microsoft.com'; + $credentials = [ + ['Firewall Administrator', 'Edge firewall management interface', 'https://192.168.10.1', 'msp-admin', 'Local administrator on the edge firewall. Rotate at every quarterly review.', ['Firewall', 'Local Admin', 'Rotate Quarterly']], + [$mail . ' Administrator', 'Tenant administration for mail and identity', $mail_admin_uri, 'msp-admin@' . $domain, 'Break glass administrator. MFA enforced - the token lives with the on call phone.', ['Microsoft 365', 'Domain Admin', 'Break Glass']], + ['Backup Console', 'Backup platform for servers and endpoints', 'https://backup.example', 'msp-' . strtolower($short), 'Console login for restore requests and job monitoring.', ['Backup Console', 'Service Account']], + ['Switch Management', 'Access layer switch management', 'https://192.168.10.2', 'msp-admin', 'Shared across the access switches at this site.', ['Switch', 'Shared']], + ]; + + // Services + $services = [ + [$mail, 'Company mail, calendars and identity for ' . $seats . ' users', 'Email', 'High', 'Third party mailbox backup', 'Tenant administered by us. Licence count is reconciled against the monthly invoice.'], + ['Managed Backup', 'Image level backup of ' . max(1, $servers) . ' servers with offsite copy', 'Backup', 'High', 'Offsite replication', 'Nightly job with a monthly test restore. Evidence is filed against the client documentation.'], + ['Endpoint Protection and Monitoring', 'Agent based protection and monitoring across the fleet', 'Security', 'High', 'Vendor cloud console', 'Alerts raise tickets automatically. Monthly patch compliance is reviewed at the maintenance visit.'], + ]; + + // Software and licensing + $software = [ + [$mail . ' Business Premium', 'Per user licensing resold and managed', 'Current', 'Software as a Service (SaaS)', 'Subscription', $seats, 'Licence count is reviewed monthly against staff numbers.'], + ['Endpoint Protection', 'Managed endpoint protection agent', 'Current', 'Security Software', 'Subscription', $seats + max(1, $servers), 'Covers workstations, laptops and servers.'], + [$spec['vendor'][0] . ' Platform', $spec['vendor'][1], 'Vendor managed', 'Web Application', 'Subscription', $seats, 'Vendor supported - we hold the administrative access only.'], + ]; + + // Documentation + $documents = [ + [ + $short . ' - Site Runbook', + 'How this site is put together and what to do first when something breaks', + '' . $name . ' operates from ' . count($sites) . ' site(s) with the main office at ' . $spec['street'] . ', ' . $spec['city'] . '.
' + . 'Office LAN on 192.168.10.0/24 behind the edge firewall. Guest wireless is isolated on VLAN 40 with no route to the office LAN.
' + . '' . $mail . ' tenant administered by us. Break glass credentials are held in the credential vault against this client.
' + . 'Site access is arranged through the onsite point of contact. Work outside opening hours must be agreed in advance.
', + ], + [ + $short . ' - Backup and Recovery Plan', + 'What is protected, how often, and how to get it back', + 'Image level backup of ' . max(1, $servers) . ' server(s) plus mailbox level protection for ' . $seats . ' users.
' + . 'Nightly incremental with a weekly synthetic full. Offsite copy replicates daily.
' + . '30 days local, 12 months offsite.
' + . 'Monthly. Evidence is attached to the maintenance ticket and referenced in the compliance file.
', + ], + [ + $short . ' - Vendors and Escalation', + 'Who to call, in what order, and what they are responsible for', + '' . $spec['vendor'][0] . ' - ' . $spec['vendor'][1] . '. Response target: ' . $spec['vendor'][4] . '.
' + . '' . $mail . ' - identity and mail. Raised through the tenant administration portal with our partner credentials.
' + . 'Spend and downtime are approved by the authorised approver on the contact list. Do not take verbal approval from anyone else for work that will be billed.
', + ], + ]; + + // The monthly agreement, which the invoices are generated from + $agreement = [ + ['Managed Services - Per User', $seats, 89.00, 'Monitoring, patching, endpoint protection and remote support'], + ]; + if ($servers > 0) { + $agreement[] = ['Managed Services - Per Server', $servers, 145.00, 'Server monitoring, patching and backup verification']; + } + $agreement[] = [$mail . ' Business Premium', $seats, 22.00, 'Per user licence, resold and managed']; + $agreement[] = ['Cloud Backup', max(1, $servers), 65.00, 'Offsite backup with monthly test restore']; + + // Quotes in flight + $refresh_count = max(2, (int)floor($seats / 5)); + $quotes = [ + [ + 'Workstation refresh - machines past five years old', + [ + ['Business Desktop', $refresh_count, 985.00, 'Small form factor desktop, 16GB memory, 512GB solid state drive'], + ['Deployment and Data Migration', $refresh_count, 145.00, 'Build, migrate and hand over per machine'], + ['Recycling and Data Destruction', $refresh_count, 25.00, 'Certified disposal of the replaced machines'], + ], + 'Sent', + ], + ]; + if (!empty($spec['project'])) { + $quotes[] = [ + $spec['project'][0], + [ + ['Project Labor', 24, 145.00, 'Design, build, migration and handover'], + ['Project Hardware', 1, 4250.00, 'Hardware supplied as part of the project'], + ['Out of Hours Cutover', 6, 195.00, 'Cutover work outside operating hours'], + ], + 'Accepted', + ]; + } + + // Client attributable costs + $expenses = [ + ['Hardware supplied to ' . $name, 1450.00 + ($seats * 12), 'Hardware - Cost of Goods', 47], + [$mail . ' licensing for ' . $name, $seats * 14.50, 'Licensing - Cost of Goods', 18], + ]; + + return [ + 'name' => $name, + 'abbreviation' => $short, + 'type' => $spec['type'], + 'domain' => $domain, + 'referral' => $spec['referral'], + 'rate' => number_format($spec['rate'], 2, '.', ''), + 'net_terms' => $spec['terms'], + 'seats' => $seats, + 'servers' => $servers, + 'notes' => $spec['notes'], + 'account_note' => $spec['account_note'], + 'age_days' => $spec['age_days'], + 'area_code' => $spec['area'], + 'tags' => $spec['tags'], + 'sites' => $sites, + 'people' => $spec['people'], + 'vendor' => $spec['vendor'], + 'fleet' => $spec['fleet'], + 'networks' => $networks, + 'mail_platform' => $mail, + 'credentials' => $credentials, + 'services' => $services, + 'software' => $software, + 'documents' => $documents, + 'agreement' => $agreement, + 'quotes' => $quotes, + 'expenses' => $expenses, + 'onsite_visit' => $spec['onsite_visit'], + 'recurring_ticket' => $spec['recurring_ticket'], + 'project' => $spec['project'], + ]; + +} + +// ------------------------------ +// demoDataAccounts +// The bank accounts an MSP actually runs. Matched by name, so an install that +// already has an account called Operating Checking keeps the one it has. +// ------------------------------ +function demoDataAccounts() { + return [ + ['Operating Checking', 'Day to day business banking - client payments in, bills out', 18500.00], + ['Business Savings', 'Tax and reserve holding', 42000.00], + ['Merchant Settlement', 'Card and online payments waiting to be paid out', 0.00], + ['Petty Cash', 'Small cash purchases', 250.00], + ]; +} + +// ------------------------------ +// demoEnsureAccounts +// Returns name => id for every demo account, creating the missing ones. +// ------------------------------ +function demoEnsureAccounts($mysqli, $currency) { + + $accounts = []; + $existing = []; + + $sql = mysqli_query($mysqli, "SELECT account_id, account_name FROM accounts"); + while ($row = mysqli_fetch_assoc($sql)) { + $existing[mb_strtolower($row['account_name'])] = intval($row['account_id']); + } + + foreach (demoDataAccounts() as $account) { + $key = mb_strtolower($account[0]); + if (isset($existing[$key])) { + $accounts[$account[0]] = $existing[$key]; + continue; + } + $accounts[$account[0]] = starterInsert($mysqli, 'accounts', [ + 'account_name' => $account[0], + 'account_description' => $account[1], + 'opening_balance' => number_format($account[2], 2, '.', ''), + 'account_currency_code' => $currency, + 'account_created_at' => demoMonthDateTime(24, 1, 9, 0), + ]); + } + + return $accounts; +} + +// ------------------------------ +// demoBuildBilling +// The client's money, month by month, for as long as they have been a client. +// The agreement drives a monthly invoice; project and hardware work lands on +// top of it every few months so revenue is not a flat line. +// ------------------------------ +function demoBuildBilling($mysqli, $profile, $index, $client_id, $vendor_id, $context, &$counts) { + + global $config_invoice_prefix, $config_quote_prefix, $config_recurring_invoice_prefix; + + $currency = $context['currency']; + $months = demoClientMonths($profile); + $income_category_id = starterCategoryId($mysqli, 'Managed Services', 'Income'); + $project_category_id = starterCategoryId($mysqli, 'Projects', 'Income') ?: $income_category_id; + $hardware_category_id = starterCategoryId($mysqli, 'Hardware Sales', 'Income') ?: $income_category_id; + + // Bank and card money land in different places + $bank_account_id = $context['accounts']['Operating Checking'] ?? $context['account_id']; + $card_account_id = $context['accounts']['Merchant Settlement'] ?? $bank_account_id; + + // The agreement itself + $recurring_total = 0; + foreach ($profile['agreement'] as $line) { + $recurring_total = $recurring_total + ($line[1] * $line[2]); + } + + $recurring_invoice_id = starterInsert($mysqli, 'recurring_invoices', [ + 'recurring_invoice_prefix' => $config_recurring_invoice_prefix ?? '', + 'recurring_invoice_number' => demoNextNumber($mysqli, 'config_recurring_invoice_next_number'), + 'recurring_invoice_scope' => 'Monthly managed services agreement', + 'recurring_invoice_frequency' => 'month', + 'recurring_invoice_next_date' => date('Y-m-01', strtotime('+1 month')), + 'recurring_invoice_last_sent' => date('Y-m-01'), + 'recurring_invoice_status' => 1, + 'recurring_invoice_amount' => number_format($recurring_total, 2, '.', ''), + 'recurring_invoice_currency_code' => $currency, + 'recurring_invoice_note' => 'Billed on the first of each month in advance.', + // Demo contacts must never be mailed, so the cron notification is off + 'recurring_invoice_email_notify' => 0, + 'recurring_invoice_category_id' => $income_category_id, + 'recurring_invoice_client_id' => $client_id, + 'recurring_invoice_created_at' => demoMonthDateTime($months, 1, 9, 30), + ]); + + foreach ($profile['agreement'] as $item_order => $line) { + demoInvoiceItem($mysqli, 'recurring_invoice_items', 'item_recurring_invoice_id', $recurring_invoice_id, $line, $item_order, demoMonthDateTime($months, 1, 9, 30)); + } + $counts['billing']++; + + // Monthly agreement invoices for every month they have been a client + $payment_methods = ['ACH', 'Credit Card', 'Check']; + $one_off_pool = demoOneOffInvoicePool(); + + for ($month = $months; $month >= 0; $month--) { + + $invoice_date = demoMonthDate($month, 1); + $created_at = demoMonthDateTime($month, 1, 6, 5); + $due_date = date('Y-m-d', strtotime($invoice_date . ' +' . $profile['net_terms'] . ' days')); + + // The current month is still out, and one client is deliberately behind + $outstanding_months = in_array('Past Due', $profile['tags']) ? 3 : 1; + $paid = $month >= $outstanding_months; + $partial = $paid && $month === 5; + + $status = 'Paid'; + if (!$paid) { + $status = $month === 0 ? 'Sent' : 'Viewed'; + } elseif ($partial) { + $status = 'Partial'; + } + + $invoice_id = starterInsert($mysqli, 'invoices', [ + 'invoice_prefix' => $config_invoice_prefix ?? '', + 'invoice_number' => demoNextNumber($mysqli, 'config_invoice_next_number'), + 'invoice_scope' => 'Managed services - ' . date('F Y', strtotime($invoice_date)), + 'invoice_status' => $status, + 'invoice_date' => $invoice_date, + 'invoice_due' => $due_date, + 'invoice_amount' => number_format($recurring_total, 2, '.', ''), + 'invoice_currency_code' => $currency, + 'invoice_url_key' => randomString(32), + 'invoice_category_id' => $income_category_id, + 'invoice_recurring_invoice_id' => $recurring_invoice_id, + 'invoice_client_id' => $client_id, + 'invoice_created_at' => $created_at, + ]); + + foreach ($profile['agreement'] as $item_order => $line) { + demoInvoiceItem($mysqli, 'invoice_items', 'item_invoice_id', $invoice_id, $line, $item_order, $created_at); + } + + starterInsert($mysqli, 'history', [ + 'history_status' => 'Sent', + 'history_description' => 'Invoice emailed to the billing contact', + 'history_invoice_id' => $invoice_id, + 'history_created_at' => demoMonthDateTime($month, 1, 6, 10), + ]); + + if ($paid) { + $method = $payment_methods[($index + $month) % 3]; + $amount = $partial ? round($recurring_total / 2, 2) : $recurring_total; + demoPayment($mysqli, $invoice_id, $amount, $currency, $method, $method === 'Credit Card' ? $card_account_id : $bank_account_id, $month, $profile, $index); + starterInsert($mysqli, 'history', [ + 'history_status' => $partial ? 'Partial' : 'Paid', + 'history_description' => $partial ? 'Part payment received' : 'Payment received in full', + 'history_invoice_id' => $invoice_id, + 'history_created_at' => demoMonthDateTime($month, 12, 14, 0), + ]); + } + + $counts['billing']++; + + // Project and hardware work on top of the agreement every few months + if (($month + $index) % 6 === 3) { + + $one_off = $one_off_pool[($index + $month) % count($one_off_pool)]; + $one_off_total = 0; + foreach ($one_off[1] as $line) { + $one_off_total = $one_off_total + ($line[1] * $line[2]); + } + $one_off_created = demoMonthDateTime($month, 18, 11, 30); + + $one_off_id = starterInsert($mysqli, 'invoices', [ + 'invoice_prefix' => $config_invoice_prefix ?? '', + 'invoice_number' => demoNextNumber($mysqli, 'config_invoice_next_number'), + 'invoice_scope' => $one_off[0], + 'invoice_status' => 'Paid', + 'invoice_date' => demoMonthDate($month, 18), + 'invoice_due' => date('Y-m-d', strtotime(demoMonthDate($month, 18) . ' +' . $profile['net_terms'] . ' days')), + 'invoice_amount' => number_format($one_off_total, 2, '.', ''), + 'invoice_currency_code' => $currency, + 'invoice_url_key' => randomString(32), + 'invoice_category_id' => $one_off[2] === 'hardware' ? $hardware_category_id : $project_category_id, + 'invoice_client_id' => $client_id, + 'invoice_created_at' => $one_off_created, + ]); + + foreach ($one_off[1] as $item_order => $line) { + demoInvoiceItem($mysqli, 'invoice_items', 'item_invoice_id', $one_off_id, $line, $item_order, $one_off_created); + } + + demoPayment($mysqli, $one_off_id, $one_off_total, $currency, 'ACH', $bank_account_id, $month - 1, $profile, $index); + $counts['billing']++; + + } + + } + + // Quotes, roughly one every five months, at every stage of the pipeline + $quote_statuses = ['Invoiced', 'Accepted', 'Declined', 'Sent', 'Draft']; + $quote_slot = 0; + for ($month = $months; $month >= 0; $month = $month - 5) { + + $quote = $profile['quotes'][$quote_slot % count($profile['quotes'])]; + $quote_total = 0; + foreach ($quote[1] as $line) { + $quote_total = $quote_total + ($line[1] * $line[2]); + } + + if (demoIsFuture(demoMonthDate($month, 9))) { + $quote_slot++; + continue; + } + + // Old quotes are settled one way or the other, recent ones are still moving + $status = $month <= 5 ? $quote_statuses[($index + $quote_slot) % 2 + 3] : $quote_statuses[($index + $quote_slot) % 3]; + $quote_created = demoMonthDateTime($month, 9, 11, 20); + + $quote_id = starterInsert($mysqli, 'quotes', [ + 'quote_prefix' => $config_quote_prefix ?? '', + 'quote_number' => demoNextNumber($mysqli, 'config_quote_next_number'), + 'quote_scope' => $quote[0], + 'quote_status' => $status, + 'quote_date' => demoMonthDate($month, 9), + 'quote_expire' => demoMonthDate($month - 1, 9), + 'quote_amount' => number_format($quote_total, 2, '.', ''), + 'quote_currency_code' => $currency, + 'quote_note' => 'Pricing held for 30 days. Lead times quoted at time of order.', + 'quote_url_key' => randomString(32), + 'quote_category_id' => $project_category_id, + 'quote_client_id' => $client_id, + 'quote_created_at' => $quote_created, + ]); + + foreach ($quote[1] as $item_order => $line) { + demoInvoiceItem($mysqli, 'quote_items', 'item_quote_id', $quote_id, $line, $item_order, $quote_created); + } + + starterInsert($mysqli, 'history', [ + 'history_status' => 'Sent', + 'history_description' => 'Quote sent for approval', + 'history_quote_id' => $quote_id, + 'history_created_at' => demoMonthDateTime($month, 9, 11, 30), + ]); + + $quote_slot++; + $counts['billing']++; + + } + + // Client attributable costs, every other month + $expense_slot = 0; + for ($month = $months; $month >= 0; $month = $month - 2) { + $expense = $profile['expenses'][$expense_slot % count($profile['expenses'])]; + if (demoIsFuture(demoMonthDate($month, 14))) { + $expense_slot++; + continue; + } + starterInsert($mysqli, 'expenses', [ + 'expense_description' => $expense[0], + 'expense_amount' => number_format($expense[1], 2, '.', ''), + 'expense_currency_code' => $currency, + 'expense_date' => demoMonthDate($month, 14), + 'expense_reference' => 'DEMO-' . demoSerial($profile['abbreviation'] . $expense[0] . $month, 8), + 'expense_payment_method' => 'Credit Card', + 'expense_vendor_id' => $vendor_id, + 'expense_client_id' => $client_id, + 'expense_category_id' => starterCategoryId($mysqli, $expense[2], 'Expense'), + 'expense_account_id' => $bank_account_id, + 'expense_created_at' => demoMonthDateTime($month, 14, 16, 45), + ]); + $expense_slot++; + $counts['billing']++; + } + + // Income that never went through an invoice + for ($month = $months; $month >= 0; $month = $month - 12) { + if (demoIsFuture(demoMonthDate($month, 22))) { + continue; + } + starterInsert($mysqli, 'revenues', [ + 'revenue_date' => demoMonthDate($month, 22), + 'revenue_amount' => number_format(1200.00 + ($index * 75), 2, '.', ''), + 'revenue_currency_code' => $currency, + 'revenue_payment_method' => 'Check', + 'revenue_reference' => 'DEMO-' . demoSerial($profile['abbreviation'] . 'rev' . $month, 8), + 'revenue_description' => 'Prepaid block of support hours', + 'revenue_category_id' => starterCategoryId($mysqli, 'Support', 'Income') ?: $income_category_id, + 'revenue_account_id' => $bank_account_id, + 'revenue_client_id' => $client_id, + 'revenue_created_at' => demoMonthDateTime($month, 22, 10, 15), + ]); + $counts['billing']++; + } + +} + +// ------------------------------ +// demoInvoiceItem +// invoice_items, quote_items and recurring_invoice_items are the same shape. +// ------------------------------ +function demoInvoiceItem($mysqli, $table, $parent_column, $parent_id, $line, $item_order, $created_at) { + $subtotal = $line[1] * $line[2]; + starterInsert($mysqli, $table, [ + 'item_name' => $line[0], + 'item_description' => $line[3], + 'item_quantity' => $line[1], + 'item_price' => number_format($line[2], 2, '.', ''), + 'item_subtotal' => number_format($subtotal, 2, '.', ''), + 'item_tax' => '0.00', + 'item_total' => number_format($subtotal, 2, '.', ''), + 'item_order' => $item_order + 1, + $parent_column => $parent_id, + 'item_created_at' => $created_at, + ]); +} + +// ------------------------------ +// demoPayment +// ------------------------------ +function demoPayment($mysqli, $invoice_id, $amount, $currency, $method, $account_id, $month, $profile, $index) { + if (!$account_id) { + return; + } + $day = 8 + (($index + $month) % 14); + if (demoIsFuture(demoMonthDate($month, $day))) { + return; + } + starterInsert($mysqli, 'payments', [ + 'payment_date' => demoMonthDate($month, $day), + 'payment_amount' => number_format($amount, 2, '.', ''), + 'payment_currency_code' => $currency, + 'payment_method' => $method, + 'payment_reference' => 'DEMO-' . demoSerial($profile['abbreviation'] . 'pay' . $invoice_id, 8), + 'payment_account_id' => $account_id, + 'payment_invoice_id' => $invoice_id, + 'payment_created_at' => demoMonthDateTime($month, $day, 14, 0), + ]); +} + +// ------------------------------ +// demoOneOffInvoicePool +// Project and hardware work billed outside the agreement. +// ------------------------------ +function demoOneOffInvoicePool() { + return [ + ['Workstation refresh - supply and deployment', [ + ['Business Desktop', 4, 985.00, 'Small form factor desktop, 16GB memory, 512GB solid state drive'], + ['Deployment and Data Migration', 4, 145.00, 'Build, migrate and hand over per machine'], + ], 'hardware'], + ['Firewall replacement and rule migration', [ + ['Next Generation Firewall', 1, 1245.00, 'Edge firewall with three years of security subscription'], + ['Configuration and Cutover', 8, 145.00, 'Rule migration, cutover and post change checks'], + ], 'project'], + ['Wireless coverage extension', [ + ['Wireless Access Point', 3, 245.00, 'Wi-Fi 6 ceiling mounted access point'], + ['Installation and Survey', 6, 145.00, 'Cabling, mounting and post install survey'], + ], 'project'], + ['Server storage expansion', [ + ['Enterprise Solid State Drive', 4, 420.00, 'Mixed use enterprise drive'], + ['Out of Hours Labor', 5, 195.00, 'Installation and array rebuild outside operating hours'], + ], 'hardware'], + ['Mailbox migration and tenant cleanup', [ + ['Migration Labor', 16, 145.00, 'Mailbox migration, cutover and post migration support'], + ['Tenant Hardening', 4, 145.00, 'Conditional access, MFA enforcement and audit logging'], + ], 'project'], + ['Backup platform replacement', [ + ['Backup Appliance', 1, 2850.00, 'Local backup appliance with offsite replication'], + ['Build and Seed', 10, 145.00, 'Build, seed the first full backup and verify a restore'], + ], 'project'], + ]; +} + +// ------------------------------ +// demoBuildCompanyFinancials +// The MSP's own money - two years of operating costs, and the transfers that +// move card settlements into the bank and the tax reserve into savings. None +// of this belongs to a client, so it is marked with a DEMO- reference and the +// purge finds it that way. +// ------------------------------ +function demoBuildCompanyFinancials($mysqli, $context, &$counts) { + + $currency = $context['currency']; + $accounts = $context['accounts']; + $operating_id = $accounts['Operating Checking'] ?? 0; + $savings_id = $accounts['Business Savings'] ?? 0; + $merchant_id = $accounts['Merchant Settlement'] ?? 0; + + if (!$operating_id) { + return; + } + + // Only ever build this once + $row = mysqli_fetch_assoc(mysqli_query($mysqli, "SELECT COUNT(expense_id) AS total FROM expenses WHERE expense_client_id = 0 AND expense_reference LIKE 'DEMO-%'")); + if (intval($row['total'] ?? 0)) { + return; + } + + // description, amount, category, every N months, day of month + $operating_costs = [ + ['Office rent', 1850.00, 'Rent and Utilities', 1, 1], + ['Payroll', 14250.00, 'Payroll', 1, 28], + ['Software and tooling subscriptions', 640.00, 'Software', 1, 6], + ['Internet and phone', 285.00, 'Telecom and Internet', 1, 9], + ['Fuel and vehicle costs', 215.00, 'Vehicle and Fuel', 1, 21], + ['Colocation and connectivity', 495.00, 'Infrastructure', 1, 3], + ['Business insurance', 1450.00, 'Insurance', 3, 12], + ['Accounting and payroll services', 900.00, 'Professional Services', 3, 15], + ['Advertising and sponsorship', 500.00, 'Advertising', 4, 19], + ['Training and certification', 750.00, 'Education', 6, 24], + ['Tools and test equipment', 385.00, 'Tools and Test Equipment', 6, 11], + ]; + + for ($month = 24; $month >= 0; $month--) { + foreach ($operating_costs as $cost) { + + if ($month % $cost[3] !== 0 || demoIsFuture(demoMonthDate($month, $cost[4]))) { + continue; + } + + // Payroll and rent creep up over two years rather than sitting flat + $drift = 1 + ((24 - $month) * 0.004); + + starterInsert($mysqli, 'expenses', [ + 'expense_description' => $cost[0], + 'expense_amount' => number_format($cost[1] * $drift, 2, '.', ''), + 'expense_currency_code' => $currency, + 'expense_date' => demoMonthDate($month, $cost[4]), + 'expense_reference' => 'DEMO-' . demoSerial('company' . $cost[0] . $month, 8), + 'expense_payment_method' => $cost[0] === 'Payroll' ? 'ACH' : 'Credit Card', + 'expense_vendor_id' => 0, + 'expense_client_id' => 0, + 'expense_category_id' => starterCategoryId($mysqli, $cost[2], 'Expense'), + 'expense_account_id' => $operating_id, + 'expense_created_at' => demoMonthDateTime($month, $cost[4], 17, 0), + ]); + $counts['company']++; + + } + } + + // Card settlements landing in the bank, and the quarterly tax reserve + for ($month = 24; $month >= 0; $month--) { + + if ($merchant_id) { + demoTransfer($mysqli, $merchant_id, $operating_id, 4250.00 + (($month % 7) * 315), $currency, 'Merchant payout', 'Card settlements paid out to the operating account', $month, 5, $counts); + } + + if ($savings_id && $month % 3 === 0) { + demoTransfer($mysqli, $operating_id, $savings_id, 6500.00, $currency, 'Bank Transfer', 'Quarterly tax reserve moved to savings', $month, 16, $counts); + } + + } + +} + +// ------------------------------ +// demoTransfer +// A transfer is an expense on one account and a revenue on the other, joined +// by a transfers row - the same three writes the transfer handler makes. +// ------------------------------ +function demoTransfer($mysqli, $from_account_id, $to_account_id, $amount, $currency, $method, $notes, $month, $day, &$counts) { + + $date = demoMonthDate($month, $day); + if (demoIsFuture($date)) { + return; + } + $created_at = demoMonthDateTime($month, $day, 10, 30); + $reference = 'DEMO-' . demoSerial('transfer' . $from_account_id . $to_account_id . $month . $day, 8); + + $expense_id = starterInsert($mysqli, 'expenses', [ + 'expense_date' => $date, + 'expense_amount' => number_format($amount, 2, '.', ''), + 'expense_currency_code' => $currency, + 'expense_reference' => $reference, + 'expense_vendor_id' => 0, + 'expense_category_id' => 0, + 'expense_client_id' => 0, + 'expense_account_id' => $from_account_id, + 'expense_created_at' => $created_at, + ]); + + $revenue_id = starterInsert($mysqli, 'revenues', [ + 'revenue_date' => $date, + 'revenue_amount' => number_format($amount, 2, '.', ''), + 'revenue_currency_code' => $currency, + 'revenue_reference' => $reference, + 'revenue_category_id' => 0, + 'revenue_account_id' => $to_account_id, + 'revenue_client_id' => 0, + 'revenue_created_at' => $created_at, + ]); + + starterInsert($mysqli, 'transfers', [ + 'transfer_method' => $method, + 'transfer_notes' => $notes, + 'transfer_expense_id' => $expense_id, + 'transfer_revenue_id' => $revenue_id, + 'transfer_created_at' => $created_at, + ]); + + $counts['company']++; + +} diff --git a/admin/post/starter_content.php b/admin/post/starter_content.php index 0b68ae537..450a66735 100644 --- a/admin/post/starter_content.php +++ b/admin/post/starter_content.php @@ -45,3 +45,49 @@ if (isset($_POST['load_starter_content'])) { redirect(); } + +if (isset($_POST['load_demo_data'])) { + + validateCSRFToken(); + + require_once 'demo_data_model.php'; + + $counts = demoDataLoad($mysqli); + + if (!$counts['clients']) { + flashAlert("Nothing to add - every demo client is already here", 'info'); + redirect(); + } + + $summary = "{$counts['clients']} clients, {$counts['contacts']} contacts, {$counts['assets']} assets, {$counts['documentation']} documentation records, {$counts['tickets']} tickets, {$counts['projects']} projects, {$counts['billing']} billing records, {$counts['company']} company financial records"; + + logAudit("Demo Data", "Create", "$session_name loaded demo data - $summary"); + + if ($counts['skipped_credentials']) { + flashAlert("Added demo data: $summary. Credentials were skipped - the credential vault did not open for this session", 'warning'); + } else { + flashAlert("Added demo data: $summary"); + } + + redirect(); + +} + +if (isset($_POST['remove_demo_data'])) { + + validateCSRFToken(); + + require_once 'demo_data_model.php'; + + $clients_removed = demoDataRemove($mysqli); + + if ($clients_removed) { + logAudit("Demo Data", "Deleted", "$session_name removed demo data - $clients_removed demo clients and all associated records"); + flashAlert("Removed $clients_removed demo clients and everything associated with them", 'error'); + } else { + flashAlert("No demo clients found - nothing to remove", 'info'); + } + + redirect(); + +} diff --git a/admin/starter_content.php b/admin/starter_content.php index 060f7334a..d35ca73a4 100644 --- a/admin/starter_content.php +++ b/admin/starter_content.php @@ -4,10 +4,14 @@ 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"; $starter_content_packs = starterContentPacks(); $starter_content_status = starterContentStatus($mysqli); +$demo_data_status = demoDataStatus($mysqli); +$demo_data_loaded = count($demo_data_status['loaded']); + $total_missing = 0; foreach ($starter_content_status as $pack_status) { $total_missing = $total_missing + $pack_status['missing']; @@ -106,4 +110,79 @@ foreach ($starter_content_status as $pack_status) { ++ A fictional book of business for demos, training and screenshots - = intval($demo_data_status['total']) ?> clients + with the people, equipment, documentation, tickets and billing a typical MSP would be carrying for them, plus the + bank accounts, operating costs and transfers on your own side of the books. + It is generated across the last two years and dated relative to today, so the dashboard, ticket + queue, aging, profit and loss and every other report have real history behind them rather than a single flat month. +
++ This is sample data, not configuration. Every demo client carries the Demo Data client tag and + everything created hangs off one of them, so removing it takes the whole lot back out again. Contacts use + addresses on the reserved .example domain, which cannot receive mail, and the demo + agreements are created with email notification switched off. +
+ + ++ Add the Categories and Tags packs above first, or the demo tickets, invoices and tags come in bare. +
+ + + + + + ++ = intval($demo_data_loaded) ?> demo client(s) are here: + = escapeHtml(implode(', ', $demo_data_status['loaded'])) ?> +
+ +