added on local at 2026-07-01 12:34:29
| 1 | package MODS::PTMatrix::Wrapper; | |
| 2 | #====================================================================== | |
| 3 | # TaskForge -- page wrapper. | |
| 4 | # | |
| 5 | # Renders the sidebar + topbar shell around a page's main content. | |
| 6 | # Two layouts: | |
| 7 | # tf_wrapper.html -- in-app authenticated layout (sidebar + topbar) | |
| 8 | # tf_marketing.html -- public marketing layout (top nav + footer) | |
| 9 | # | |
| 10 | # Caller pattern: | |
| 11 | # my $body = qq~ ...page body html... ~; | |
| 12 | # $wrap->render({ | |
| 13 | # userinfo => $userinfo, # required for in-app shell | |
| 14 | # page_key => 'kanban', # marks the active sidebar item | |
| 15 | # title => 'Kanban', | |
| 16 | # body => $body, | |
| 17 | # extra_js => ['/assets/javascript/kanban.js'], | |
| 18 | # extra_css=> ['/assets/css/kanban.css'], | |
| 19 | # }); | |
| 20 | #====================================================================== | |
| 21 | ||
| 22 | use strict; | |
| 23 | use warnings; | |
| 24 | ||
| 25 | use MODS::Template; | |
| 26 | use MODS::DBConnect; | |
| 27 | use MODS::PTMatrix::Config; | |
| 28 | use MODS::PTMatrix::Terms; | |
| 29 | ||
| 30 | my $tfile = MODS::Template->new; | |
| 31 | my $config = MODS::PTMatrix::Config->new; | |
| 32 | my $db = MODS::DBConnect->new; | |
| 33 | ||
| 34 | # Sidebar nav: section headers + entries. Each entry has a key | |
| 35 | # matched against $page_key to set "active", an icon name (resolved | |
| 36 | # to inline SVG below), label, href, and optional gates: | |
| 37 | # admin_only = 1 -- hide unless role >= company_admin OR is_super_admin | |
| 38 | # manager_only = 1 -- hide unless role >= manager | |
| 39 | # super_admin_only = 1 -- platform owner only | |
| 40 | my @SIDEBAR = ( | |
| 41 | { section => 'Workspace' }, | |
| 42 | { key => 'dashboard', label => 'Dashboard', href => '/dashboard.cgi', icon => 'dashboard', tip => 'Your home screen. KPIs, next tasks, active projects, team workload.' }, | |
| 43 | { key => 'my_tasks', label => 'My Tasks', href => '/my_tasks.cgi', icon => 'check_circle', tip => 'Every task assigned to you, filterable by status / due date.' }, | |
| 44 | { key => 'tickets', label => 'Tickets', href => '/tickets.cgi', icon => 'tag', tip => 'Jira-style queue: every ticket across every project. Filter by status, priority, type, project, department, assignee.' }, | |
| 45 | { key => 'projects', label => 'Project Matrix', href => '/projects.cgi', icon => 'folder', tip => 'Top-down matrix of every project and its top tasks, connected by lines. Click a project to drill into its status buckets. Switch to Project List at the top for the classic grid.' }, | |
| 46 | { key => 'kanban', label => 'Kanban Boards', href => '/kanban.cgi', icon => 'columns', tip => 'Pick a project, see its board, drag cards between columns.' }, | |
| 47 | { key => 'gantt', label => 'Gantt Charts', href => '/gantt.cgi', icon => 'gantt', tip => 'Timeline view. Drag task bars to reschedule.' }, | |
| 48 | { key => 'calendar', label => 'Calendar', href => '/calendar.cgi', icon => 'calendar', tip => 'Month grid view of every task on its due date.' }, | |
| 49 | { key => 'time', label => 'Time Tracking', href => '/time.cgi', icon => 'clock', tip => 'Hours logged across tasks. Billable vs internal totals.' }, | |
| 50 | ||
| 51 | { section => 'Insights' }, | |
| 52 | { key => 'reports', label => 'Reports', href => '/reports.cgi', icon => 'chart', tip => 'Company KPIs, throughput, per-project rollups, per-person productivity.' }, | |
| 53 | { key => 'cost', label => 'Money View', href => '/cost.cgi', icon => 'dollar', tip => 'Where time becomes dollars. Stacked-area cost charts by billable status and department.' }, | |
| 54 | { key => 'cycle_time', label => 'Cycle Time', href => '/cycle_time.cgi', icon => 'clock', tip => 'Lead-time distribution + scatter chart. Spot slow-moving work and unstable delivery.' }, | |
| 55 | { key => 'portfolio', label => 'Portfolio Gantt', href => '/portfolio.cgi', icon => 'gantt', tip => 'Every active project on one timeline. Slipping projects flagged in red.' }, | |
| 56 | { key => 'forms', label => 'Forms', href => '/forms.cgi', icon => 'form', manager_only => 1, tip => 'Public intake URLs. Each submission becomes a task in the project you pick.' }, | |
| 57 | { key => 'automations', label => 'Automations', href => '/automations.cgi', icon => 'bolt', admin_only => 1, tip => 'Auto-react to task events. When X happens, do Y.' }, | |
| 58 | { key => 'workload', label => 'Team Workload', href => '/workload.cgi', icon => 'users', manager_only => 1, tip => 'Who is loaded, who is free. Spot overdue per person at a glance.' }, | |
| 59 | { key => 'activity', label => 'Activity Feed', href => '/activity.cgi', icon => 'pulse', tip => 'Audit log of every change your team made.' }, | |
| 60 | ||
| 61 | { section => 'Organization', manager_only => 1 }, | |
| 62 | { key => 'people', label => 'People', href => '/people.cgi', icon => 'users', tip => 'Org chart: who works here, who reports to whom, what they do. Click photos to view profiles, search by skill or department.' }, | |
| 63 | { key => 'team', label => 'Team Members', href => '/team.cgi', icon => 'users', admin_only => 1 }, | |
| 64 | { key => 'departments', label => 'Departments', href => '/departments.cgi', icon => 'building', admin_only => 1 }, | |
| 65 | { key => 'task_types', label => 'Task Types', href => '/task_types.cgi', icon => 'tag', admin_only => 1, tip => 'Define task types like Bug, Feature, Chore. Color-codes Kanban cards.' }, | |
| 66 | { key => 'custom_fields', label => 'Custom Fields', href => '/custom_fields.cgi', icon => 'settings', admin_only => 1, tip => 'Define ticket data captured beyond title/status: customer tier, story points, severity, etc.' }, | |
| 67 | { key => 'email_inboxes', label => 'Email Inboxes', href => '/email_inboxes.cgi', icon => 'form', admin_only => 1, tip => 'Connect your mailbox so customer emails become tickets. We poll your IMAP and send replies via your SMTP — never our servers.' }, | |
| 68 | { key => 'sla_policies', label => 'SLA Policies', href => '/sla_policies.cgi', icon => 'tag', admin_only => 1, tip => 'Response + resolution targets per priority / type / department. Breaches fire generic webhook events.' }, | |
| 69 | { key => 'api_keys', label => 'API & Webhooks', href => '/api_keys.cgi', icon => 'admin', admin_only => 1, tip => 'Public REST API keys + outbound webhooks to your own systems (Zapier, n8n, CRM, internal scripts).' }, | |
| 70 | { key => 'source_integrations', label => 'GitHub & Source', href => '/source_integrations.cgi', icon => 'form', admin_only => 1, tip => 'Link GitHub / GitLab / Bitbucket PRs to tickets. Mention TF-123 in a PR to auto-link.' }, | |
| 71 | { key => 'portal_settings', label => 'Customer Portal', href => '/portal_settings.cgi', icon => 'users', admin_only => 1, tip => 'Public-facing portal where your customers view and submit tickets without a PTMatrix account.' }, | |
| 72 | { key => 'kb', label => 'Knowledge Base', href => '/kb.cgi', icon => 'form', tip => 'Internal docs + customer-facing help articles + canned responses for fast replies.' }, | |
| 73 | { key => 'labels', label => 'Labels', href => '/labels.cgi', icon => 'label', admin_only => 1, tip => 'Free-form tags for tasks ("backend", "v2", "design").' }, | |
| 74 | { key => 'permission_groups', label => 'Permission Groups', href => '/permission_groups.cgi', icon => 'admin', admin_only => 1, tip => 'Named permission sets you can assign to users (overrides role defaults).' }, | |
| 75 | { key => 'sso', label => 'Single Sign-On',href => '/sso_setup.cgi', icon => 'admin', admin_only => 1, tip => 'Connect Okta, Entra ID, Google Workspace, LDAP, or direct SAML. Provisioning + auto-deactivation included.' }, | |
| 76 | ||
| 77 | { section => 'Account' }, | |
| 78 | { key => 'profile', label => 'My Profile', href => '/profile.cgi', icon => 'profile' }, | |
| 79 | { key => 'preferences', label => 'Preferences', href => '/preferences.cgi', icon => 'settings' }, | |
| 80 | { key => 'email_preferences', label => 'Email preferences', href => '/email_preferences.cgi', icon => 'messages', tip => 'Choose which emails to receive: lifecycle, product updates, marketing. Transactional always on.' }, | |
| 81 | { key => 'billing', label => 'Billing & Plan',href => '/billing.cgi', icon => 'billing', admin_only => 1 }, | |
| 82 | { key => 'company', label => 'Company', href => '/company.cgi', icon => 'briefcase', admin_only => 1 }, | |
| 83 | ||
| 84 | { section => 'Admin', super_admin_only => 1 }, | |
| 85 | { key => 'admin_funnels', label => 'Conversion Funnel', href => '/admin_funnels.cgi', icon => 'funnel', super_admin_only => 1, tip => 'Signup-to-paid funnel for the whole platform.' }, | |
| 86 | { key => 'admin_traffic', label => 'Traffic Reports', href => '/admin_traffic.cgi', icon => 'analytics', super_admin_only => 1, tip => 'Where visitors are coming from + globe + per-country drill.' }, | |
| 87 | { key => 'admin_companies', label => 'All Companies', href => '/admin_companies.cgi', icon => 'building', super_admin_only => 1 }, | |
| 88 | { key => 'admin_users', label => 'All Users', href => '/admin_users.cgi', icon => 'users', super_admin_only => 1 }, | |
| 89 | { key => 'admin_team', label => 'Team Members', href => '/admin_team.cgi', icon => 'users', super_admin_only => 1 }, | |
| 90 | { key => 'admin_billing', label => 'Platform Billing', href => '/admin_billing.cgi', icon => 'billing', super_admin_only => 1 }, | |
| 91 | { key => 'admin_earnings', label => 'Earnings & Expenses', href => '/admin_earnings.cgi', icon => 'billing', super_admin_only => 1, tip => 'Revenue from paid invoices + manual expense ledger. Net + margin. Date range with this-month default. Use at tax time.' }, | |
| 92 | { key => 'admin_support', label => 'Support queue', href => '/admin_support.cgi', icon => 'messages', super_admin_only => 1, tip => 'Customer-initiated support threads from the topbar Help → Contact support flow. Reply in-thread; close when done.' }, | |
| 93 | { key => 'admin_test_cards',label => 'Test Credit Cards',href => '/admin_test_cards.cgi', icon => 'billing', super_admin_only => 1, tip => 'Stripe test card numbers + decline simulations for QA.' }, | |
| 94 | { key => 'admin_discounts', label => 'Discounts & Deals',href => '/admin_discounts.cgi', icon => 'tag', super_admin_only => 1 }, | |
| 95 | { key => 'admin_metrics', label => 'System Metrics',href => '/admin_metrics.cgi', icon => 'pulse', super_admin_only => 1 }, | |
| 96 | { key => 'admin_seo', label => 'SEO', href => '/admin_seo.cgi', icon => 'seo', super_admin_only => 1, tip => 'Platform-wide page title, description, OG image, Twitter card, robots.' }, | |
| 97 | { key => 'email_setup', label => 'Email Setup', href => '/admin_emails.cgi', icon => 'messages', super_admin_only => 1, tip => 'Email templates, send schedules, and spam-protection controls.' }, | |
| 98 | { key => 'admin_maintenance', label => 'Maintenance', href => '/admin_maintenance.cgi', icon => 'settings', super_admin_only => 1, tip => 'Lock the site for everyone except admins; edit the message users see while locked.' }, { key => 'admin_config', label => 'Configuration', href => '/admin_config.cgi', icon => 'settings', super_admin_only => 1 }, | |
| 99 | ); | |
| 100 | ||
| 101 | sub new { | |
| 102 | my ($class) = @_; | |
| 103 | return bless({}, $class); | |
| 104 | } | |
| 105 | ||
| 106 | sub render { | |
| 107 | my ($self, $opts) = @_; | |
| 108 | $opts ||= {}; | |
| 109 | ||
| 110 | my $userinfo = $opts->{userinfo}; | |
| 111 | my $logged_in = $userinfo && $userinfo->{user_id} ? 1 : 0; | |
| 112 | ||
| 113 | my $tvars = { | |
| 114 | website_title => $config->settings('website_title'), | |
| 115 | brand_name => $config->settings('brand_name'), | |
| 116 | brand_tagline => $config->settings('brand_tagline'), | |
| 117 | assets => $config->settings('assets'), | |
| 118 | assets_css => $config->settings('assets_css'), | |
| 119 | assets_js => $config->settings('assets_js'), | |
| 120 | title => $opts->{title} || 'PTMatrix', | |
| 121 | body => $opts->{body} || '', | |
| 122 | extra_js => _format_scripts($opts->{extra_js}), | |
| 123 | extra_css => _format_styles($opts->{extra_css}), | |
| 124 | meta_tags => _build_meta_tags($opts), | |
| 125 | year => (localtime)[5] + 1900, | |
| 126 | }; | |
| 127 | ||
| 128 | if ($logged_in) { | |
| 129 | my $is_super = $userinfo->{is_super_admin} || $userinfo->{_admin_is_super_admin} || 0; | |
| 130 | my $role = $userinfo->{role} || 'member'; | |
| 131 | my $is_admin = ($role eq 'company_admin' || $is_super) ? 1 : 0; | |
| 132 | my $is_mgr = ($role eq 'manager' || $is_admin) ? 1 : 0; | |
| 133 | ||
| 134 | $tvars->{user_id} = $userinfo->{user_id}; | |
| 135 | $tvars->{display_name} = _h($userinfo->{display_name}); | |
| 136 | $tvars->{email} = _h($userinfo->{email}); | |
| 137 | $tvars->{job_title} = _h($userinfo->{job_title} || ''); | |
| 138 | $tvars->{role} = $role; | |
| 139 | $tvars->{role_label} = _role_label($role); | |
| 140 | $tvars->{is_admin} = $is_admin; | |
| 141 | $tvars->{is_manager} = $is_mgr; | |
| 142 | $tvars->{is_super_admin}= $is_super; | |
| 143 | $tvars->{initials} = _initials($userinfo->{display_name} || $userinfo->{email}); | |
| 144 | $tvars->{avatar_color} = $userinfo->{avatar_color} || '#5aa9ff'; | |
| 145 | $tvars->{company_id} = $userinfo->{company_id} || 0; | |
| 146 | $tvars->{company_name} = _h($userinfo->{company_name} || ''); | |
| 147 | $tvars->{company_brand_color} = $userinfo->{company_brand_color} || '#5aa9ff'; | |
| 148 | $tvars->{company_plan_tier} = ucfirst($userinfo->{company_plan_tier} || 'trial'); | |
| 149 | ||
| 150 | # Trial banner. | |
| 151 | my $trial = ''; | |
| 152 | if ($userinfo->{company_trial_ends_at} && $userinfo->{company_plan_tier} eq 'trial') { | |
| 153 | $trial = $userinfo->{company_trial_ends_at}; | |
| 154 | } | |
| 155 | $tvars->{trial_ends_at} = $trial; | |
| 156 | my $days_left = _days_until($trial); | |
| 157 | $tvars->{trial_days_left} = $days_left; | |
| 158 | $tvars->{is_trialing} = (length $trial && $userinfo->{company_plan_tier} eq 'trial' && $days_left >= 0) ? 1 : 0; | |
| 159 | ||
| 160 | # Urgency tier drives banner color + copy: | |
| 161 | # gentle: 8+ days (blue/info) | |
| 162 | # amber : 2-7 days (yellow/warn) | |
| 163 | # red : 0-1 day (red/critical) | |
| 164 | my $tier = 'gentle'; | |
| 165 | if ($days_left <= 1) { $tier = 'red'; } | |
| 166 | elsif ($days_left <= 7) { $tier = 'amber'; } | |
| 167 | $tvars->{trial_tier_gentle} = ($tier eq 'gentle') ? 1 : 0; | |
| 168 | $tvars->{trial_tier_amber} = ($tier eq 'amber') ? 1 : 0; | |
| 169 | $tvars->{trial_tier_red} = ($tier eq 'red') ? 1 : 0; | |
| 170 | $tvars->{trial_days_label} = ($days_left == 1) ? '1 day' : "$days_left days"; | |
| 171 | ||
| 172 | # Headline copy escalates with urgency. | |
| 173 | $tvars->{trial_headline} = | |
| 174 | $tier eq 'red' ? 'Final day of your trial.' | |
| 175 | : $tier eq 'amber' ? "Trial ends in $tvars->{trial_days_label}." | |
| 176 | : "Free trial: $tvars->{trial_days_label} left."; | |
| 177 | ||
| 178 | $tvars->{sidebar} = _build_sidebar($opts->{page_key} || '', $is_admin, $is_mgr, $is_super, $userinfo); | |
| 179 | $tvars->{dropdown_links} = _build_dropdown_links($is_admin, $is_mgr, $is_super, $userinfo); | |
| 180 | $tvars->{impersonation_banner} = _impersonation_banner($userinfo); | |
| 181 | ||
| 182 | my $unread_notif = _unread_notif_count($userinfo->{user_id}); | |
| 183 | $tvars->{unread_notif_count} = $unread_notif; | |
| 184 | $tvars->{has_unread_notifs} = $unread_notif > 0 ? 1 : 0; | |
| 185 | ||
| 186 | my $unread_support = _unread_support_count($userinfo); | |
| 187 | $tvars->{unread_support_count} = $unread_support; | |
| 188 | $tvars->{has_unread_support} = $unread_support > 0 ? 1 : 0; | |
| 189 | ||
| 190 | my ($collapsed, $tips_off, $card_prefs, $mobile_open) = _user_ui_prefs($userinfo->{user_id}); | |
| 191 | my @cls; | |
| 192 | push @cls, 'sidebar-collapsed' if $collapsed; | |
| 193 | push @cls, 'sidebar-open' if $mobile_open; | |
| 194 | push @cls, 'tips-off' if $tips_off; | |
| 195 | $tvars->{html_class} = join(' ', @cls); | |
| 196 | ||
| 197 | # Per-user card prefs (used by Kanban CSS via inline style block) | |
| 198 | $tvars->{card_gradient_mode} = $card_prefs->{mode} || 'priority'; | |
| 199 | $tvars->{card_gradient_from} = $card_prefs->{from} || '#5aa9ff'; | |
| 200 | $tvars->{card_gradient_to} = $card_prefs->{to} || '#1a2230'; | |
| 201 | $tvars->{board_density} = $card_prefs->{density} || 'comfortable'; | |
| 202 | ||
| 203 | # Onboarding banner / redirect signal — set when company hasn't | |
| 204 | # finished the wizard. Templates can read $needs_onboarding. | |
| 205 | my $onb = _company_onboarding_state($userinfo->{company_id}); | |
| 206 | $tvars->{needs_onboarding} = $onb ? 0 : 1; | |
| 207 | ||
| 208 | # Per-company terminology — every template gets $term_project, etc. | |
| 209 | my %terms = MODS::PTMatrix::Terms::tvars_for($userinfo->{company_id}); | |
| 210 | $tvars->{$_} = $terms{$_} for keys %terms; | |
| 211 | } | |
| 212 | ||
| 213 | my $template_file = $logged_in ? 'tf_wrapper.html' : 'tf_marketing.html'; | |
| 214 | my @html = $tfile->template($template_file, $tvars, $userinfo); | |
| 215 | print "@html"; | |
| 216 | return; | |
| 217 | } | |
| 218 | ||
| 219 | #====================================================================== | |
| 220 | # Helpers | |
| 221 | #====================================================================== | |
| 222 | ||
| 223 | sub _h { | |
| 224 | my $s = shift // ''; | |
| 225 | $s =~ s/&/&/g; $s =~ s/</</g; $s =~ s/>/>/g; | |
| 226 | $s =~ s/"/"/g; $s =~ s/'/'/g; | |
| 227 | return $s; | |
| 228 | } | |
| 229 | ||
| 230 | sub _initials { | |
| 231 | my ($name) = @_; | |
| 232 | return '?' unless $name; | |
| 233 | my @parts = split /[\s\@\.]+/, $name; | |
| 234 | my $i = ''; | |
| 235 | $i .= uc(substr($parts[0], 0, 1)) if $parts[0]; | |
| 236 | $i .= uc(substr($parts[1], 0, 1)) if defined $parts[1]; | |
| 237 | return $i || uc(substr($name, 0, 1)); | |
| 238 | } | |
| 239 | ||
| 240 | sub _role_label { | |
| 241 | my ($role) = @_; | |
| 242 | return 'Owner Admin' if $role eq 'company_admin'; | |
| 243 | return 'Manager' if $role eq 'manager'; | |
| 244 | return 'Observer' if $role eq 'observer'; | |
| 245 | return 'Member'; | |
| 246 | } | |
| 247 | ||
| 248 | sub _days_until { | |
| 249 | my ($ts) = @_; | |
| 250 | return 0 unless $ts; | |
| 251 | my $dbh = $db->db_connect(); | |
| 252 | return 0 unless $dbh; | |
| 253 | my $r = $db->db_readwrite($dbh, qq~ | |
| 254 | SELECT DATEDIFF('$ts', NOW()) AS days | |
| 255 | ~, $ENV{SCRIPT_NAME}, __LINE__); | |
| 256 | $db->db_disconnect($dbh); | |
| 257 | return ($r && defined $r->{days}) ? int($r->{days}) : 0; | |
| 258 | } | |
| 259 | ||
| 260 | sub _format_scripts { | |
| 261 | my ($list) = @_; | |
| 262 | return '' unless $list && ref($list) eq 'ARRAY'; | |
| 263 | return join("\n", map { qq~<script src="$_"></script>~ } @$list); | |
| 264 | } | |
| 265 | ||
| 266 | sub _format_styles { | |
| 267 | my ($list) = @_; | |
| 268 | return '' unless $list && ref($list) eq 'ARRAY'; | |
| 269 | return join("\n", map { qq~<link rel="stylesheet" href="$_">~ } @$list); | |
| 270 | } | |
| 271 | ||
| 272 | our $_sidebar_col_checked; | |
| 273 | our $_sidebar_col_exists; | |
| 274 | ||
| 275 | sub _sidebar_pref { | |
| 276 | my ($user_id) = @_; | |
| 277 | my ($coll, $tips) = _user_ui_prefs($user_id); | |
| 278 | return $coll; | |
| 279 | } | |
| 280 | ||
| 281 | # Returns (sidebar_collapsed, tips_off, card_prefs_hashref, sidebar_mobile_open) for a user. | |
| 282 | # Tips default ON so show_tooltips=1 means tips-off is 0. Mobile drawer defaults | |
| 283 | # CLOSED on first visit so we don't cover the page on a fresh load. | |
| 284 | sub _user_ui_prefs { | |
| 285 | my ($user_id) = @_; | |
| 286 | my $default_card = { mode => 'priority', from => '#5aa9ff', to => '#1a2230', density => 'comfortable' }; | |
| 287 | return (0, 0, $default_card, 0) unless $user_id; | |
| 288 | $user_id =~ s/[^0-9]//g; | |
| 289 | return (0, 0, $default_card, 0) unless $user_id; | |
| 290 | ||
| 291 | my $DB = $config->settings('database_name'); | |
| 292 | my $dbh = $db->db_connect(); | |
| 293 | return (0, 0, $default_card, 0) unless $dbh; | |
| 294 | ||
| 295 | # sidebar_mobile_open was added in a later migration; guard the SELECT | |
| 296 | # so unmigrated installs still load. Falls back to the original column | |
| 297 | # list when the new column is missing. | |
| 298 | my $has_mobile_col = $db->db_readwrite($dbh, qq~ | |
| 299 | SELECT COUNT(*) AS n FROM information_schema.COLUMNS | |
| 300 | WHERE TABLE_SCHEMA = '$DB' | |
| 301 | AND TABLE_NAME = 'user_settings' | |
| 302 | AND COLUMN_NAME = 'sidebar_mobile_open' | |
| 303 | ~, $ENV{SCRIPT_NAME}, __LINE__); | |
| 304 | my $mob_col = ($has_mobile_col && $has_mobile_col->{n}) ? ', sidebar_mobile_open' : ''; | |
| 305 | ||
| 306 | my $r = $db->db_readwrite($dbh, qq~ | |
| 307 | SELECT sidebar_collapsed, show_tooltips, | |
| 308 | card_gradient_mode, card_gradient_from, card_gradient_to, board_density$mob_col | |
| 309 | FROM `${DB}`.user_settings | |
| 310 | WHERE user_id='$user_id' | |
| 311 | LIMIT 1 | |
| 312 | ~, $ENV{SCRIPT_NAME}, __LINE__); | |
| 313 | $db->db_disconnect($dbh); | |
| 314 | ||
| 315 | my $coll = ($r && $r->{sidebar_collapsed}) ? 1 : 0; | |
| 316 | my $mob = ($r && $r->{sidebar_mobile_open}) ? 1 : 0; | |
| 317 | # default to ON (tips visible) if no row exists yet | |
| 318 | my $show = ($r && defined $r->{show_tooltips}) ? ($r->{show_tooltips} + 0) : 1; | |
| 319 | my $card = { | |
| 320 | mode => ($r && $r->{card_gradient_mode}) ? $r->{card_gradient_mode} : 'priority', | |
| 321 | from => ($r && $r->{card_gradient_from}) ? $r->{card_gradient_from} : '#5aa9ff', | |
| 322 | to => ($r && $r->{card_gradient_to}) ? $r->{card_gradient_to} : '#1a2230', | |
| 323 | density => ($r && $r->{board_density}) ? $r->{board_density} : 'comfortable', | |
| 324 | }; | |
| 325 | return ($coll, $show ? 0 : 1, $card, $mob); | |
| 326 | } | |
| 327 | ||
| 328 | # Returns 1 if the company has completed onboarding (or has no company | |
| 329 | # row — super-admin without one). | |
| 330 | sub _company_onboarding_state { | |
| 331 | my ($company_id) = @_; | |
| 332 | return 1 unless $company_id; | |
| 333 | $company_id =~ s/[^0-9]//g; | |
| 334 | return 1 unless $company_id; | |
| 335 | my $DB = $config->settings('database_name'); | |
| 336 | my $dbh = $db->db_connect(); | |
| 337 | return 1 unless $dbh; | |
| 338 | my $r = $db->db_readwrite($dbh, qq~ | |
| 339 | SELECT onboarding_completed_at FROM `${DB}`.companies WHERE id='$company_id' LIMIT 1 | |
| 340 | ~, $ENV{SCRIPT_NAME}, __LINE__); | |
| 341 | $db->db_disconnect($dbh); | |
| 342 | return ($r && $r->{onboarding_completed_at}) ? 1 : 0; | |
| 343 | } | |
| 344 | ||
| 345 | sub _build_sidebar { | |
| 346 | my ($active_key, $is_admin, $is_mgr, $is_super, $userinfo) = @_; | |
| 347 | my $html = ''; | |
| 348 | my $pending_section; | |
| 349 | ||
| 350 | foreach my $row (@SIDEBAR) { | |
| 351 | next if $row->{super_admin_only} && !$is_super; | |
| 352 | next if $row->{admin_only} && !$is_admin; | |
| 353 | next if $row->{manager_only} && !$is_mgr; | |
| 354 | ||
| 355 | if ($row->{section}) { | |
| 356 | $pending_section = $row->{section}; | |
| 357 | next; | |
| 358 | } | |
| 359 | ||
| 360 | if (defined $pending_section) { | |
| 361 | $html .= qq~<div class="sidebar-section-label">$pending_section</div>\n~; | |
| 362 | $pending_section = undef; | |
| 363 | } | |
| 364 | ||
| 365 | my $cls = ($row->{key} && $row->{key} eq $active_key) ? 'sidebar-link active' : 'sidebar-link'; | |
| 366 | my $svg = _icon($row->{icon}); | |
| 367 | my $badge = $row->{badge} ? qq~<span class="badge">$row->{badge}</span>~ : ''; | |
| 368 | my $tip = $row->{tip} ? qq~ data-tip="~ . _h($row->{tip}) . qq~"~ : ''; | |
| 369 | $html .= qq~<a class="$cls" href="$row->{href}"$tip>$svg<span>$row->{label}</span>$badge</a>\n~; | |
| 370 | } | |
| 371 | return $html; | |
| 372 | } | |
| 373 | ||
| 374 | sub _build_dropdown_links { | |
| 375 | my ($is_admin, $is_mgr, $is_super, $userinfo) = @_; | |
| 376 | my $html = ''; | |
| 377 | my $emitted_first = 0; | |
| 378 | my $pending_section; | |
| 379 | my $pending_divider = 0; | |
| 380 | ||
| 381 | foreach my $row (@SIDEBAR) { | |
| 382 | next if $row->{super_admin_only} && !$is_super; | |
| 383 | next if $row->{admin_only} && !$is_admin; | |
| 384 | next if $row->{manager_only} && !$is_mgr; | |
| 385 | ||
| 386 | if ($row->{section}) { | |
| 387 | $pending_section = $row->{section}; | |
| 388 | $pending_divider = $emitted_first ? 1 : 0; | |
| 389 | next; | |
| 390 | } | |
| 391 | ||
| 392 | if (defined $pending_section) { | |
| 393 | $html .= qq~<div class="profile-dropdown-divider"></div>\n~ if $pending_divider; | |
| 394 | $html .= qq~<div class="profile-dropdown-section">$pending_section</div>\n~; | |
| 395 | $emitted_first = 1; | |
| 396 | $pending_section = undef; | |
| 397 | $pending_divider = 0; | |
| 398 | } | |
| 399 | ||
| 400 | my $svg = _icon($row->{icon}); | |
| 401 | my $extra_cls = $row->{super_admin_only} ? ' class="profile-admin"' : ''; | |
| 402 | $html .= qq~<a href="$row->{href}"$extra_cls><span class="dropdown-icon">$svg</span> $row->{label}</a>\n~; | |
| 403 | } | |
| 404 | return $html; | |
| 405 | } | |
| 406 | ||
| 407 | sub _unread_notif_count { | |
| 408 | my ($user_id) = @_; | |
| 409 | return 0 unless $user_id; | |
| 410 | $user_id =~ s/[^0-9]//g; | |
| 411 | return 0 unless $user_id; | |
| 412 | ||
| 413 | my $DB = $config->settings('database_name'); | |
| 414 | my $dbh = $db->db_connect(); | |
| 415 | return 0 unless $dbh; | |
| 416 | my $r = $db->db_readwrite($dbh, qq~ | |
| 417 | SELECT COUNT(*) AS n | |
| 418 | FROM `${DB}`.notifications | |
| 419 | WHERE user_id='$user_id' AND read_at IS NULL | |
| 420 | ~, $ENV{SCRIPT_NAME}, __LINE__); | |
| 421 | $db->db_disconnect($dbh); | |
| 422 | return ($r && $r->{n}) ? int($r->{n}) : 0; | |
| 423 | } | |
| 424 | ||
| 425 | sub _unread_support_count { | |
| 426 | my ($userinfo) = @_; | |
| 427 | return 0 unless $userinfo && $userinfo->{user_id}; | |
| 428 | my $user_id = $userinfo->{user_id}; $user_id =~ s/[^0-9]//g; | |
| 429 | return 0 unless $user_id; | |
| 430 | ||
| 431 | my $DB = $config->settings('database_name'); | |
| 432 | my $dbh = $db->db_connect(); | |
| 433 | return 0 unless $dbh; | |
| 434 | ||
| 435 | my $r; | |
| 436 | if ($userinfo->{is_super_admin} || $userinfo->{is_admin}) { | |
| 437 | # Platform admins: count threads with admin_unread=1 + status open | |
| 438 | $r = $db->db_readwrite($dbh, qq~ | |
| 439 | SELECT COUNT(*) AS n | |
| 440 | FROM `${DB}`.support_threads | |
| 441 | WHERE admin_unread = 1 AND status <> 'closed' | |
| 442 | ~, $ENV{SCRIPT_NAME}, __LINE__); | |
| 443 | } else { | |
| 444 | # Regular user: count their own threads with user_unread=1 | |
| 445 | $r = $db->db_readwrite($dbh, qq~ | |
| 446 | SELECT COUNT(*) AS n | |
| 447 | FROM `${DB}`.support_threads | |
| 448 | WHERE opened_by_user_id = '$user_id' AND user_unread = 1 | |
| 449 | ~, $ENV{SCRIPT_NAME}, __LINE__); | |
| 450 | } | |
| 451 | $db->db_disconnect($dbh); | |
| 452 | return ($r && $r->{n}) ? int($r->{n}) : 0; | |
| 453 | } | |
| 454 | ||
| 455 | sub _impersonation_banner { | |
| 456 | my ($userinfo) = @_; | |
| 457 | return '' unless $userinfo && $userinfo->{_impersonating}; | |
| 458 | ||
| 459 | my $target_email = _h($userinfo->{email} || ''); | |
| 460 | my $target_name = _h($userinfo->{display_name} || $target_email); | |
| 461 | my $admin_email = _h($userinfo->{_admin_email} || ''); | |
| 462 | ||
| 463 | return qq~ | |
| 464 | <div class="impersonation-banner"> | |
| 465 | <div class="impersonation-banner-msg"> | |
| 466 | <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" width="16" height="16"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10Z"/></svg> | |
| 467 | <span><strong>Acting as</strong> $target_name <$target_email> · admin: $admin_email</span> | |
| 468 | </div> | |
| 469 | <form method="POST" action="/admin_action.cgi" class="impersonation-banner-form"> | |
| 470 | <input type="hidden" name="act" value="stop_impersonation"> | |
| 471 | <button type="submit" class="impersonation-banner-stop">Stop & return to admin</button> | |
| 472 | </form> | |
| 473 | </div> | |
| 474 | ~; | |
| 475 | } | |
| 476 | ||
| 477 | # Inline SVG icon library. | |
| 478 | my %ICONS = ( | |
| 479 | dashboard => '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="7" height="9" rx="1"/><rect x="14" y="3" width="7" height="5" rx="1"/><rect x="14" y="12" width="7" height="9" rx="1"/><rect x="3" y="16" width="7" height="5" rx="1"/></svg>', | |
| 480 | check_circle => '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><path d="m9 12 2 2 4-4"/></svg>', | |
| 481 | folder => '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 19a2 2 0 0 1-2 2H4a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h5l2 3h9a2 2 0 0 1 2 2z"/></svg>', | |
| 482 | columns => '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="5" height="18" rx="1"/><rect x="10" y="3" width="5" height="14" rx="1"/><rect x="17" y="3" width="4" height="10" rx="1"/></svg>', | |
| 483 | gantt => '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 6h10"/><path d="M3 12h14"/><path d="M3 18h8"/><circle cx="13" cy="6" r="2"/><circle cx="17" cy="12" r="2"/><circle cx="11" cy="18" r="2"/></svg>', | |
| 484 | calendar => '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="4" width="18" height="18" rx="2"/><line x1="16" y1="2" x2="16" y2="6"/><line x1="8" y1="2" x2="8" y2="6"/><line x1="3" y1="10" x2="21" y2="10"/></svg>', | |
| 485 | clock => '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>', | |
| 486 | chart => '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 3v18h18"/><path d="m7 14 4-4 4 4 5-5"/></svg>', | |
| 487 | users => '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2"/><circle cx="9" cy="7" r="4"/><path d="M23 21v-2a4 4 0 0 0-3-3.87"/><path d="M16 3.13a4 4 0 0 1 0 7.75"/></svg>', | |
| 488 | pulse => '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M22 12h-4l-3 9L9 3l-3 9H2"/></svg>', | |
| 489 | building => '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="2" width="18" height="20" rx="2"/><path d="M9 22V12h6v10"/><path d="M9 6h.01"/><path d="M9 10h.01"/><path d="M15 6h.01"/><path d="M15 10h.01"/></svg>', | |
| 490 | tag => '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M20.59 13.41 13.42 20.58a2 2 0 0 1-2.83 0L2 12V2h10l8.59 8.59a2 2 0 0 1 0 2.82z"/><line x1="7" y1="7" x2="7.01" y2="7"/></svg>', | |
| 491 | label => '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M20.59 13.41 13.42 20.58a2 2 0 0 1-2.83 0L2 12V2h10l8.59 8.59a2 2 0 0 1 0 2.82z"/></svg>', | |
| 492 | profile => '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>', | |
| 493 | settings => '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 1 1-4 0v-.09a1.65 1.65 0 0 0-1-1.51 1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 1 1 0-4h.09a1.65 1.65 0 0 0 1.51-1 1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33 1.65 1.65 0 0 0 1-1.51V3a2 2 0 1 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82v0a1.65 1.65 0 0 0 1.51 1H21a2 2 0 1 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1Z"/></svg>', | |
| 494 | billing => '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="2" y="5" width="20" height="14" rx="2"/><line x1="2" y1="10" x2="22" y2="10"/><line x1="6" y1="15" x2="10" y2="15"/></svg>', | |
| 495 | briefcase => '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="2" y="7" width="20" height="14" rx="2"/><path d="M16 21V5a2 2 0 0 0-2-2h-4a2 2 0 0 0-2 2v16"/></svg>', | |
| 496 | dollar => '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><line x1="12" y1="1" x2="12" y2="23"/><path d="M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6"/></svg>', | |
| 497 | form => '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="4" y="3" width="16" height="18" rx="2"/><line x1="8" y1="8" x2="16" y2="8"/><line x1="8" y1="12" x2="16" y2="12"/><line x1="8" y1="16" x2="13" y2="16"/></svg>', | |
| 498 | bolt => '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><polygon points="13 2 3 14 12 14 11 22 21 10 12 10 13 2"/></svg>', | |
| 499 | messages => '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2Z"/></svg>', | |
| 500 | admin => '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z"/></svg>', | |
| 501 | seo => '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="11" cy="11" r="7"/><path d="m21 21-4.3-4.3"/><path d="M7 11h8"/><path d="M11 7v8"/></svg>', | |
| 502 | visitors => '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M1 12s4-7 11-7 11 7 11 7-4 7-11 7-11-7-11-7Z"/><circle cx="12" cy="12" r="3"/></svg>', | |
| 503 | funnel => '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M22 3H2l8 9.46V19l4 2v-8.54L22 3z"/></svg>', | |
| 504 | analytics => '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 3v18h18"/><path d="m7 14 4-4 4 4 5-5"/></svg>', | |
| 505 | ); | |
| 506 | ||
| 507 | sub _icon { | |
| 508 | my ($name) = @_; | |
| 509 | return $ICONS{$name} || ''; | |
| 510 | } | |
| 511 | ||
| 512 | sub _build_meta_tags { | |
| 513 | my ($opts) = @_; | |
| 514 | return '' unless $opts; | |
| 515 | ||
| 516 | my $title = _h($opts->{title} || ''); | |
| 517 | my $brand = _h($config->settings('seo.title') || 'PTMatrix'); | |
| 518 | my $desc = _h($config->settings('seo.description') || ''); | |
| 519 | my $kw = _h($config->settings('seo.keywords') || ''); | |
| 520 | my $canon = $config->settings('seo.canonical_base') || ''; | |
| 521 | my $req = $ENV{REQUEST_URI} || '/'; | |
| 522 | $req =~ s/[^\x21-\x7e]//g; | |
| 523 | my $url = $canon ? "$canon$req" : ''; | |
| 524 | my $og_site = _h($config->settings('seo.og_site_name') || 'PTMatrix'); | |
| 525 | my $og_image = $config->settings('seo.og_image') || ''; | |
| 526 | my $tw_card = _h($config->settings('seo.twitter_card') || 'summary_large_image'); | |
| 527 | my $tw_site = _h($config->settings('seo.twitter_site') || ''); | |
| 528 | my $robots_raw = $config->settings('seo.robots_index'); | |
| 529 | my $robots_str = (defined $robots_raw && $robots_raw eq '0') ? 'noindex,nofollow' : 'index,follow'; | |
| 530 | ||
| 531 | # Relative og_image paths get prefixed with the canonical base so | |
| 532 | # social scrapers can fetch them absolute. | |
| 533 | if ($og_image && $og_image =~ m{^/} && $canon) { | |
| 534 | my $base = $canon; $base =~ s{/+$}{}; | |
| 535 | $og_image = $base . $og_image; | |
| 536 | } | |
| 537 | $og_image = _h($og_image); | |
| 538 | ||
| 539 | my $full_title = $title ? "$title — $og_site" : $brand; | |
| 540 | ||
| 541 | my $tags = qq~ | |
| 542 | <meta name="description" content="$desc"> | |
| 543 | <meta name="keywords" content="$kw"> | |
| 544 | <meta name="robots" content="$robots_str"> | |
| 545 | <meta property="og:type" content="website"> | |
| 546 | <meta property="og:site_name" content="$og_site"> | |
| 547 | <meta property="og:title" content="$full_title"> | |
| 548 | <meta property="og:description" content="$desc"> | |
| 549 | ~; | |
| 550 | $tags .= qq~<meta property="og:url" content="$url">\n<link rel="canonical" href="$url">\n~ if $url; | |
| 551 | $tags .= qq~<meta property="og:image" content="$og_image">\n~ if length $og_image; | |
| 552 | $tags .= qq~<meta name="twitter:card" content="$tw_card">\n~; | |
| 553 | $tags .= qq~<meta name="twitter:title" content="$full_title">\n~; | |
| 554 | $tags .= qq~<meta name="twitter:description" content="$desc">\n~ if length $desc; | |
| 555 | $tags .= qq~<meta name="twitter:image" content="$og_image">\n~ if length $og_image; | |
| 556 | $tags .= qq~<meta name="twitter:site" content="$tw_site">\n~ if length $tw_site; | |
| 557 | ||
| 558 | # Visitor-tracking pixel. Loaded on every page (marketing + in-app). | |
| 559 | # Guards itself against admin/track URLs. | |
| 560 | $tags .= qq~<script src="/assets/javascript/track.js" defer></script>\n~; | |
| 561 | ||
| 562 | return $tags; | |
| 563 | } | |
| 564 | ||
| 565 | 1; |