@goxtechnologies/connectwise-psa-mcp 1.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (147) hide show
  1. package/data/connectwise_api.db +0 -0
  2. package/data/manage.json +298179 -0
  3. package/dist/index.d.ts +10 -0
  4. package/dist/index.d.ts.map +1 -0
  5. package/dist/index.js +116 -0
  6. package/dist/index.js.map +1 -0
  7. package/dist/operations/analytics-extended.d.ts +6 -0
  8. package/dist/operations/analytics-extended.d.ts.map +1 -0
  9. package/dist/operations/analytics-extended.js +825 -0
  10. package/dist/operations/analytics-extended.js.map +1 -0
  11. package/dist/operations/analytics-msp-assets.d.ts +3 -0
  12. package/dist/operations/analytics-msp-assets.d.ts.map +1 -0
  13. package/dist/operations/analytics-msp-assets.js +180 -0
  14. package/dist/operations/analytics-msp-assets.js.map +1 -0
  15. package/dist/operations/analytics-msp-clients.d.ts +3 -0
  16. package/dist/operations/analytics-msp-clients.d.ts.map +1 -0
  17. package/dist/operations/analytics-msp-clients.js +198 -0
  18. package/dist/operations/analytics-msp-clients.js.map +1 -0
  19. package/dist/operations/analytics-msp-comms.d.ts +3 -0
  20. package/dist/operations/analytics-msp-comms.d.ts.map +1 -0
  21. package/dist/operations/analytics-msp-comms.js +127 -0
  22. package/dist/operations/analytics-msp-comms.js.map +1 -0
  23. package/dist/operations/analytics-msp-contracts.d.ts +3 -0
  24. package/dist/operations/analytics-msp-contracts.d.ts.map +1 -0
  25. package/dist/operations/analytics-msp-contracts.js +91 -0
  26. package/dist/operations/analytics-msp-contracts.js.map +1 -0
  27. package/dist/operations/analytics-msp-financial.d.ts +3 -0
  28. package/dist/operations/analytics-msp-financial.d.ts.map +1 -0
  29. package/dist/operations/analytics-msp-financial.js +300 -0
  30. package/dist/operations/analytics-msp-financial.js.map +1 -0
  31. package/dist/operations/analytics-msp-procurement.d.ts +3 -0
  32. package/dist/operations/analytics-msp-procurement.d.ts.map +1 -0
  33. package/dist/operations/analytics-msp-procurement.js +78 -0
  34. package/dist/operations/analytics-msp-procurement.js.map +1 -0
  35. package/dist/operations/analytics-msp-projects.d.ts +3 -0
  36. package/dist/operations/analytics-msp-projects.d.ts.map +1 -0
  37. package/dist/operations/analytics-msp-projects.js +190 -0
  38. package/dist/operations/analytics-msp-projects.js.map +1 -0
  39. package/dist/operations/analytics-msp-sales.d.ts +3 -0
  40. package/dist/operations/analytics-msp-sales.d.ts.map +1 -0
  41. package/dist/operations/analytics-msp-sales.js +99 -0
  42. package/dist/operations/analytics-msp-sales.js.map +1 -0
  43. package/dist/operations/analytics-msp-schedule.d.ts +3 -0
  44. package/dist/operations/analytics-msp-schedule.d.ts.map +1 -0
  45. package/dist/operations/analytics-msp-schedule.js +339 -0
  46. package/dist/operations/analytics-msp-schedule.js.map +1 -0
  47. package/dist/operations/analytics-msp-team.d.ts +3 -0
  48. package/dist/operations/analytics-msp-team.d.ts.map +1 -0
  49. package/dist/operations/analytics-msp-team.js +195 -0
  50. package/dist/operations/analytics-msp-team.js.map +1 -0
  51. package/dist/operations/analytics-msp-tickets.d.ts +3 -0
  52. package/dist/operations/analytics-msp-tickets.d.ts.map +1 -0
  53. package/dist/operations/analytics-msp-tickets.js +578 -0
  54. package/dist/operations/analytics-msp-tickets.js.map +1 -0
  55. package/dist/operations/analytics-msp-time.d.ts +3 -0
  56. package/dist/operations/analytics-msp-time.d.ts.map +1 -0
  57. package/dist/operations/analytics-msp-time.js +485 -0
  58. package/dist/operations/analytics-msp-time.js.map +1 -0
  59. package/dist/operations/analytics-msp-utils.d.ts +49 -0
  60. package/dist/operations/analytics-msp-utils.d.ts.map +1 -0
  61. package/dist/operations/analytics-msp-utils.js +157 -0
  62. package/dist/operations/analytics-msp-utils.js.map +1 -0
  63. package/dist/operations/analytics.d.ts +9 -0
  64. package/dist/operations/analytics.d.ts.map +1 -0
  65. package/dist/operations/analytics.js +742 -0
  66. package/dist/operations/analytics.js.map +1 -0
  67. package/dist/operations/executor.d.ts +10 -0
  68. package/dist/operations/executor.d.ts.map +1 -0
  69. package/dist/operations/executor.js +243 -0
  70. package/dist/operations/executor.js.map +1 -0
  71. package/dist/operations/registry.d.ts +16 -0
  72. package/dist/operations/registry.d.ts.map +1 -0
  73. package/dist/operations/registry.js +847 -0
  74. package/dist/operations/registry.js.map +1 -0
  75. package/dist/services/api-database.d.ts +38 -0
  76. package/dist/services/api-database.d.ts.map +1 -0
  77. package/dist/services/api-database.js +191 -0
  78. package/dist/services/api-database.js.map +1 -0
  79. package/dist/services/cache.d.ts +12 -0
  80. package/dist/services/cache.d.ts.map +1 -0
  81. package/dist/services/cache.js +32 -0
  82. package/dist/services/cache.js.map +1 -0
  83. package/dist/services/connectwise-api.d.ts +43 -0
  84. package/dist/services/connectwise-api.d.ts.map +1 -0
  85. package/dist/services/connectwise-api.js +198 -0
  86. package/dist/services/connectwise-api.js.map +1 -0
  87. package/dist/services/db-builder.d.ts +11 -0
  88. package/dist/services/db-builder.d.ts.map +1 -0
  89. package/dist/services/db-builder.js +237 -0
  90. package/dist/services/db-builder.js.map +1 -0
  91. package/dist/services/fast-memory.d.ts +39 -0
  92. package/dist/services/fast-memory.d.ts.map +1 -0
  93. package/dist/services/fast-memory.js +147 -0
  94. package/dist/services/fast-memory.js.map +1 -0
  95. package/dist/services/load-env.d.ts +15 -0
  96. package/dist/services/load-env.d.ts.map +1 -0
  97. package/dist/services/load-env.js +59 -0
  98. package/dist/services/load-env.js.map +1 -0
  99. package/dist/tools/batch.d.ts +9 -0
  100. package/dist/tools/batch.d.ts.map +1 -0
  101. package/dist/tools/batch.js +159 -0
  102. package/dist/tools/batch.js.map +1 -0
  103. package/dist/tools/composite.d.ts +9 -0
  104. package/dist/tools/composite.d.ts.map +1 -0
  105. package/dist/tools/composite.js +353 -0
  106. package/dist/tools/composite.js.map +1 -0
  107. package/dist/tools/discovery.d.ts +9 -0
  108. package/dist/tools/discovery.d.ts.map +1 -0
  109. package/dist/tools/discovery.js +245 -0
  110. package/dist/tools/discovery.js.map +1 -0
  111. package/dist/tools/execution.d.ts +9 -0
  112. package/dist/tools/execution.d.ts.map +1 -0
  113. package/dist/tools/execution.js +130 -0
  114. package/dist/tools/execution.js.map +1 -0
  115. package/dist/tools/memory.d.ts +9 -0
  116. package/dist/tools/memory.d.ts.map +1 -0
  117. package/dist/tools/memory.js +152 -0
  118. package/dist/tools/memory.js.map +1 -0
  119. package/dist/tools/operations.d.ts +9 -0
  120. package/dist/tools/operations.d.ts.map +1 -0
  121. package/dist/tools/operations.js +214 -0
  122. package/dist/tools/operations.js.map +1 -0
  123. package/dist/tools/pagination.d.ts +9 -0
  124. package/dist/tools/pagination.d.ts.map +1 -0
  125. package/dist/tools/pagination.js +133 -0
  126. package/dist/tools/pagination.js.map +1 -0
  127. package/dist/tools/validation.d.ts +9 -0
  128. package/dist/tools/validation.d.ts.map +1 -0
  129. package/dist/tools/validation.js +705 -0
  130. package/dist/tools/validation.js.map +1 -0
  131. package/dist/types/index.d.ts +145 -0
  132. package/dist/types/index.d.ts.map +1 -0
  133. package/dist/types/index.js +3 -0
  134. package/dist/types/index.js.map +1 -0
  135. package/dist/types/operations.d.ts +30 -0
  136. package/dist/types/operations.d.ts.map +1 -0
  137. package/dist/types/operations.js +3 -0
  138. package/dist/types/operations.js.map +1 -0
  139. package/dist/utils/conditions.d.ts +20 -0
  140. package/dist/utils/conditions.d.ts.map +1 -0
  141. package/dist/utils/conditions.js +78 -0
  142. package/dist/utils/conditions.js.map +1 -0
  143. package/dist/utils/formatters.d.ts +35 -0
  144. package/dist/utils/formatters.d.ts.map +1 -0
  145. package/dist/utils/formatters.js +337 -0
  146. package/dist/utils/formatters.js.map +1 -0
  147. package/package.json +46 -0
@@ -0,0 +1,825 @@
1
+ // ConnectWise PSA MCP Server — Extended Analytics Handlers (52 handlers)
2
+ // Split from analytics.ts for manageability. Imported and registered at load time.
3
+ import { getAPI } from '../services/connectwise-api.js';
4
+ function num(v, fallback = 0) { const n = Number(v); return Number.isFinite(n) ? n : fallback; }
5
+ function nested(obj, field, sub) { const c = obj[field]; if (c && typeof c === 'object' && sub in c)
6
+ return String(c[sub] ?? 'N/A'); return 'N/A'; }
7
+ function daysSince(iso) { if (!iso)
8
+ return 0; return Math.floor((Date.now() - new Date(iso).getTime()) / 86400000); }
9
+ function daysAgo(n) { const d = new Date(); d.setDate(d.getDate() - n); return d.toISOString().split('T')[0] + 'T00:00:00Z'; }
10
+ function today() { return new Date().toISOString().split('T')[0] + 'T00:00:00Z'; }
11
+ function round2(n) { return Math.round(n * 100) / 100; }
12
+ /** All extended handlers keyed by name. Merged into the main registry by analytics.ts. */
13
+ export const extendedHandlers = {};
14
+ function reg(name, handler) { extendedHandlers[name] = handler; }
15
+ // ===========================================================================
16
+ // ADVANCED ANALYTICS
17
+ // ===========================================================================
18
+ reg('member_utilization_report', async (params) => {
19
+ const api = getAPI();
20
+ const days = num(params['days'], 30);
21
+ const [members, timeEntries] = await Promise.all([
22
+ api.paginatedFetch('/system/members', 'inactiveFlag=false', 'id,identifier,firstName,lastName', 200),
23
+ api.paginatedFetch('/time/entries', `dateEntered>=[${daysAgo(days)}]`, 'id,actualHours,billableOption,member/identifier', 5000),
24
+ ]);
25
+ const md = {};
26
+ for (const m of members.items)
27
+ md[String(m.identifier)] = { total: 0, billable: 0, name: `${m.firstName} ${m.lastName}` };
28
+ for (const e of timeEntries.items) {
29
+ const id = nested(e, 'member', 'identifier');
30
+ if (!md[id])
31
+ md[id] = { total: 0, billable: 0, name: id };
32
+ md[id].total += num(e.actualHours);
33
+ if (String(e.billableOption ?? '').toLowerCase().includes('billable'))
34
+ md[id].billable += num(e.actualHours);
35
+ }
36
+ const wh = days * 8 * 5 / 7;
37
+ const lines = [`## Member Utilization Report (last ${days} days)\n`, '| Member | Total | Billable | Utilization | Billable % |', '|--------|-------|----------|-------------|------------|'];
38
+ for (const [id, d] of Object.entries(md).filter(([, d]) => d.total > 0).sort((a, b) => b[1].total - a[1].total))
39
+ lines.push(`| ${d.name} (${id}) | ${round2(d.total)}h | ${round2(d.billable)}h | ${round2((d.total / wh) * 100)}% | ${d.total > 0 ? round2((d.billable / d.total) * 100) : 0}% |`);
40
+ return lines.join('\n');
41
+ });
42
+ reg('timesheet_summary', async () => {
43
+ const api = getAPI();
44
+ const entries = await api.paginatedFetch('/time/entries', `dateEntered>=[${daysAgo(14)}]`, 'id,actualHours,member/identifier,dateEntered,billableOption', 5000);
45
+ const bm = {};
46
+ for (const e of entries.items) {
47
+ const m = nested(e, 'member', 'identifier');
48
+ if (!bm[m])
49
+ bm[m] = { total: 0, billable: 0, days: new Set() };
50
+ bm[m].total += num(e.actualHours);
51
+ if (String(e.billableOption ?? '').toLowerCase().includes('billable'))
52
+ bm[m].billable += num(e.actualHours);
53
+ bm[m].days.add(String(e.dateEntered ?? '').substring(0, 10));
54
+ }
55
+ const lines = ['## Timesheet Summary (last 14 days)\n', '| Member | Total | Billable | Days | Avg/Day |', '|--------|-------|----------|------|---------|'];
56
+ for (const [m, d] of Object.entries(bm).sort((a, b) => b[1].total - a[1].total))
57
+ lines.push(`| ${m} | ${round2(d.total)}h | ${round2(d.billable)}h | ${d.days.size} | ${d.days.size > 0 ? round2(d.total / d.days.size) : 0}h |`);
58
+ return lines.join('\n');
59
+ });
60
+ reg('unbilled_time_report', async () => {
61
+ const api = getAPI();
62
+ const r = await api.paginatedFetch('/time/entries', "billableOption='Billable' AND invoiceFlag=false", 'id,actualHours,company/name,member/identifier', 2000);
63
+ if (r.items.length === 0)
64
+ return 'No unbilled time entries found.';
65
+ const bc = {};
66
+ for (const e of r.items) {
67
+ const co = nested(e, 'company', 'name');
68
+ if (!bc[co])
69
+ bc[co] = { hours: 0, entries: 0 };
70
+ bc[co].hours += num(e.actualHours);
71
+ bc[co].entries++;
72
+ }
73
+ const tot = r.items.reduce((s, e) => s + num(e.actualHours), 0);
74
+ const lines = [`## Unbilled Time Report (${r.items.length} entries, ${round2(tot)}h)\n`, '| Company | Hours | Entries |', '|---------|-------|---------|'];
75
+ for (const [co, d] of Object.entries(bc).sort((a, b) => b[1].hours - a[1].hours))
76
+ lines.push(`| ${co} | ${round2(d.hours)}h | ${d.entries} |`);
77
+ return lines.join('\n');
78
+ });
79
+ reg('agreement_utilization_tracker', async () => {
80
+ const api = getAPI();
81
+ const agr = await api.paginatedFetch('/finance/agreements', 'cancelledFlag=false', 'id,name,company/name,type/name,budgetHours', 200);
82
+ const batch = agr.items.slice(0, 30);
83
+ const res = await Promise.allSettled(batch.map(async (a) => { const t = await api.paginatedFetch('/time/entries', `agreement/id=${a.id}`, 'id,actualHours', 500); return { a, hours: t.items.reduce((s, e) => s + num(e.actualHours), 0) }; }));
84
+ const lines = ['## Agreement Utilization Tracker\n', '| Agreement | Company | Budget | Used | % | Status |', '|-----------|---------|--------|------|---|--------|'];
85
+ for (const r of res) {
86
+ if (r.status !== 'fulfilled')
87
+ continue;
88
+ const { a, hours } = r.value;
89
+ const b = num(a.budgetHours);
90
+ const p = b > 0 ? round2((hours / b) * 100) : 0;
91
+ const st = b === 0 ? 'N/A' : p > 100 ? 'OVER' : p > 80 ? 'At risk' : 'OK';
92
+ lines.push(`| ${a.name} | ${nested(a, 'company', 'name')} | ${b}h | ${round2(hours)}h | ${p}% | ${st} |`);
93
+ }
94
+ return lines.join('\n');
95
+ });
96
+ reg('repeat_caller_analysis', async (params) => {
97
+ const api = getAPI();
98
+ const days = num(params['days'], 90);
99
+ const r = await api.paginatedFetch('/service/tickets', `dateEntered>=[${daysAgo(days)}]`, 'id,contact/name,contact/id,company/name', 2000);
100
+ const bc = {};
101
+ for (const t of r.items) {
102
+ const cid = String(nested(t, 'contact', 'id'));
103
+ if (cid === 'N/A')
104
+ continue;
105
+ if (!bc[cid])
106
+ bc[cid] = { name: nested(t, 'contact', 'name'), company: nested(t, 'company', 'name'), count: 0 };
107
+ bc[cid].count++;
108
+ }
109
+ const rp = Object.values(bc).filter(c => c.count >= 3).sort((a, b) => b.count - a.count);
110
+ if (rp.length === 0)
111
+ return `No repeat callers (3+) in the last ${days} days.`;
112
+ const lines = [`## Repeat Caller Analysis (last ${days} days)\n`, '| Contact | Company | Tickets |', '|---------|---------|---------|'];
113
+ for (const c of rp.slice(0, 30))
114
+ lines.push(`| ${c.name} | ${c.company} | ${c.count} |`);
115
+ return lines.join('\n');
116
+ });
117
+ reg('ticket_category_trends', async () => {
118
+ const api = getAPI();
119
+ const r = await api.paginatedFetch('/service/tickets', `dateEntered>=[${daysAgo(30)}]`, 'id,type/name,subType/name', 2000);
120
+ const bt = {};
121
+ for (const t of r.items) {
122
+ const k = nested(t, 'subType', 'name') !== 'N/A' ? `${nested(t, 'type', 'name')} > ${nested(t, 'subType', 'name')}` : nested(t, 'type', 'name');
123
+ bt[k] = (bt[k] ?? 0) + 1;
124
+ }
125
+ const lines = [`## Ticket Category Trends (30 days, ${r.items.length} tickets)\n`, '| Category | Count | % |', '|----------|-------|---|'];
126
+ for (const [k, v] of Object.entries(bt).sort((a, b) => b[1] - a[1]).slice(0, 25))
127
+ lines.push(`| ${k} | ${v} | ${round2((v / r.items.length) * 100)}% |`);
128
+ return lines.join('\n');
129
+ });
130
+ reg('time_entry_anomaly_detection', async () => {
131
+ const api = getAPI();
132
+ const entries = await api.paginatedFetch('/time/entries', `dateEntered>=[${daysAgo(30)}]`, 'id,actualHours,member/identifier,dateEntered,notes', 5000);
133
+ const anomalies = [];
134
+ for (const e of entries.items) {
135
+ const h = num(e.actualHours);
136
+ if (h > 12)
137
+ anomalies.push({ type: 'Excessive', detail: `${h}h`, e });
138
+ const dow = new Date(String(e.dateEntered ?? '').substring(0, 10)).getDay();
139
+ if ((dow === 0 || dow === 6) && h > 0)
140
+ anomalies.push({ type: 'Weekend', detail: `${h}h`, e });
141
+ }
142
+ if (anomalies.length === 0)
143
+ return 'No anomalies detected.';
144
+ const lines = [`## Time Entry Anomalies (${anomalies.length} found)\n`, '| Type | Member | Hours | Date |', '|------|--------|-------|------|'];
145
+ for (const a of anomalies.slice(0, 50))
146
+ lines.push(`| ${a.type} | ${nested(a.e, 'member', 'identifier')} | ${a.e.actualHours}h | ${String(a.e.dateEntered ?? '').substring(0, 10)} |`);
147
+ return lines.join('\n');
148
+ });
149
+ reg('billing_leakage_detection', async () => {
150
+ const api = getAPI();
151
+ const [unbilled, noAgr] = await Promise.all([
152
+ api.paginatedFetch('/time/entries', "billableOption='Billable' AND invoiceFlag=false", 'id,actualHours', 1000),
153
+ api.paginatedFetch('/time/entries', `billableOption='Billable' AND agreement=null AND dateEntered>=[${daysAgo(30)}]`, 'id,actualHours', 500),
154
+ ]);
155
+ return ['## Billing Leakage Detection\n', '| Leak Type | Entries | Hours |', '|-----------|---------|-------|', `| Unbilled billable | ${unbilled.items.length} | ${round2(unbilled.items.reduce((s, e) => s + num(e.actualHours), 0))}h |`, `| Billable no agreement (30d) | ${noAgr.items.length} | ${round2(noAgr.items.reduce((s, e) => s + num(e.actualHours), 0))}h |`].join('\n');
156
+ });
157
+ reg('project_burn_rate', async (params) => {
158
+ const api = getAPI();
159
+ const pid = num(params['project_id']);
160
+ if (!pid)
161
+ return 'Error: project_id required.';
162
+ const [proj, time] = await Promise.all([api.request({ path: `/project/projects/${pid}`, method: 'GET' }), api.paginatedFetch('/time/entries', `chargeToType='Project' AND chargeToId=${pid}`, 'id,actualHours', 2000)]);
163
+ const b = num(proj.data.budgetHours);
164
+ const t = time.items.reduce((s, e) => s + num(e.actualHours), 0);
165
+ return [`## Project Burn Rate: ${proj.data.name} (#${pid})\n`, '| Metric | Value |', '|--------|-------|', `| Budget | ${b}h |`, `| Consumed | ${round2(t)}h (${b > 0 ? round2((t / b) * 100) : 0}%) |`, `| Remaining | ${round2(b - t)}h |`].join('\n');
166
+ });
167
+ reg('agreement_renewal_forecast', async () => {
168
+ const api = getAPI();
169
+ const r = await api.paginatedFetch('/finance/agreements', `cancelledFlag=false AND endDate>=[${today()}]`, 'id,name,company/name,endDate,billAmount', 200);
170
+ const expiring = r.items.filter(a => a.endDate && -daysSince(a.endDate) <= 90);
171
+ if (expiring.length === 0)
172
+ return 'No agreements expiring in the next 90 days.';
173
+ const lines = [`## Agreement Renewal Forecast (${expiring.length} in next 90 days)\n`, '| Agreement | Company | Expires | MRR |', '|-----------|---------|---------|-----|'];
174
+ for (const a of expiring.sort((x, y) => String(x.endDate ?? '').localeCompare(String(y.endDate ?? ''))))
175
+ lines.push(`| ${a.name} | ${nested(a, 'company', 'name')} | ${String(a.endDate ?? '').substring(0, 10)} | $${num(a.billAmount)} |`);
176
+ return lines.join('\n');
177
+ });
178
+ reg('resource_capacity_planning', async () => {
179
+ const api = getAPI();
180
+ const [members, time, sched] = await Promise.all([api.paginatedFetch('/system/members', 'inactiveFlag=false', 'id,identifier,firstName,lastName', 200), api.paginatedFetch('/time/entries', `dateEntered>=[${daysAgo(14)}]`, 'id,actualHours,member/identifier', 5000), api.paginatedFetch('/schedule/entries', `dateStart>=[${daysAgo(14)}]`, 'id,hours,member/identifier', 2000)]);
181
+ const d = {};
182
+ for (const m of members.items)
183
+ d[String(m.identifier)] = { name: `${m.firstName} ${m.lastName}`, logged: 0, sched: 0 };
184
+ for (const e of time.items) {
185
+ const id = nested(e, 'member', 'identifier');
186
+ if (d[id])
187
+ d[id].logged += num(e.actualHours);
188
+ }
189
+ for (const e of sched.items) {
190
+ const id = nested(e, 'member', 'identifier');
191
+ if (d[id])
192
+ d[id].sched += num(e.hours);
193
+ }
194
+ const cap = 80;
195
+ const lines = ['## Resource Capacity (14 days)\n', '| Member | Logged | Scheduled | Available |', '|--------|--------|-----------|-----------|'];
196
+ for (const [id, v] of Object.entries(d).filter(([, v]) => v.logged > 0).sort((a, b) => b[1].logged - a[1].logged))
197
+ lines.push(`| ${v.name} (${id}) | ${round2(v.logged)}h | ${round2(v.sched)}h | ${round2(cap - v.logged)}h |`);
198
+ return lines.join('\n');
199
+ });
200
+ reg('mean_time_metrics', async (params) => {
201
+ const api = getAPI();
202
+ const days = num(params['days'], 30);
203
+ const r = await api.paginatedFetch('/service/tickets', `closedFlag=true AND closedDate>=[${daysAgo(days)}]`, 'id,board/name,dateEntered,closedDate', 2000);
204
+ const bb = {};
205
+ for (const t of r.items) {
206
+ const b = nested(t, 'board', 'name');
207
+ (bb[b] ??= []).push((new Date(t.closedDate).getTime() - new Date(t.dateEntered).getTime()) / 3600000);
208
+ }
209
+ const avg = (a) => a.length > 0 ? round2(a.reduce((x, y) => x + y, 0) / a.length) : 0;
210
+ const lines = [`## Mean Time Metrics (last ${days} days)\n`, '| Board | MTTR (hours) | Tickets |', '|-------|-------------|---------|'];
211
+ for (const [b, times] of Object.entries(bb).sort((a, b) => b[1].length - a[1].length))
212
+ lines.push(`| ${b} | ${avg(times)}h | ${times.length} |`);
213
+ return lines.join('\n');
214
+ });
215
+ reg('top_time_consumers', async (params) => {
216
+ const api = getAPI();
217
+ const days = num(params['days'], 30);
218
+ const e = await api.paginatedFetch('/time/entries', `dateEntered>=[${daysAgo(days)}]`, 'id,actualHours,company/name,chargeToId,chargeToType', 5000);
219
+ const bc = {};
220
+ const bt = {};
221
+ for (const x of e.items) {
222
+ bc[nested(x, 'company', 'name')] = (bc[nested(x, 'company', 'name')] ?? 0) + num(x.actualHours);
223
+ if (x.chargeToType === 'ServiceTicket')
224
+ bt[String(x.chargeToId)] = (bt[String(x.chargeToId)] ?? 0) + num(x.actualHours);
225
+ }
226
+ const lines = [`## Top Time Consumers (${days} days)\n`, '### By Company', '| Company | Hours |', '|---------|-------|'];
227
+ for (const [k, v] of Object.entries(bc).sort((a, b) => b[1] - a[1]).slice(0, 15))
228
+ lines.push(`| ${k} | ${round2(v)}h |`);
229
+ lines.push('\n### By Ticket', '| Ticket | Hours |', '|--------|-------|');
230
+ for (const [k, v] of Object.entries(bt).sort((a, b) => b[1] - a[1]).slice(0, 15))
231
+ lines.push(`| #${k} | ${round2(v)}h |`);
232
+ return lines.join('\n');
233
+ });
234
+ reg('agreement_coverage_gaps', async () => {
235
+ const api = getAPI();
236
+ const [cos, agrs] = await Promise.all([api.paginatedFetch('/company/companies', "status/name='Active'", 'id,name', 500), api.paginatedFetch('/finance/agreements', 'cancelledFlag=false', 'id,company/id', 500)]);
237
+ const covered = new Set(agrs.items.map(a => String(nested(a, 'company', 'id'))));
238
+ const uncovered = cos.items.filter(c => !covered.has(String(c.id)));
239
+ const lines = [`## Agreement Coverage Gaps\n`, `**Uncovered:** ${uncovered.length} of ${cos.items.length} active companies\n`];
240
+ if (uncovered.length > 0) {
241
+ lines.push('| Company |', '|---------|');
242
+ for (const c of uncovered.slice(0, 30))
243
+ lines.push(`| ${c.name} |`);
244
+ }
245
+ return lines.join('\n');
246
+ });
247
+ reg('weekend_work_report', async (params) => {
248
+ const api = getAPI();
249
+ const days = num(params['days'], 30);
250
+ const e = await api.paginatedFetch('/time/entries', `dateEntered>=[${daysAgo(days)}]`, 'id,actualHours,member/identifier,dateEntered', 5000);
251
+ const wk = e.items.filter(x => { const d = new Date(String(x.dateEntered ?? '').substring(0, 10)).getDay(); return d === 0 || d === 6; });
252
+ if (wk.length === 0)
253
+ return `No weekend work in the last ${days} days.`;
254
+ const bm = {};
255
+ for (const x of wk) {
256
+ const m = nested(x, 'member', 'identifier');
257
+ bm[m] = (bm[m] ?? 0) + num(x.actualHours);
258
+ }
259
+ const lines = [`## Weekend Work (${days} days, ${wk.length} entries, ${round2(wk.reduce((s, x) => s + num(x.actualHours), 0))}h)\n`, '| Member | Hours |', '|--------|-------|'];
260
+ for (const [m, h] of Object.entries(bm).sort((a, b) => b[1] - a[1]))
261
+ lines.push(`| ${m} | ${round2(h)}h |`);
262
+ return lines.join('\n');
263
+ });
264
+ reg('sla_compliance_dashboard', async () => {
265
+ const api = getAPI();
266
+ const r = await api.paginatedFetch('/service/tickets', `closedFlag=true AND closedDate>=[${daysAgo(30)}]`, 'id,board/name,dateEntered,closedDate', 2000);
267
+ const bb = {};
268
+ for (const t of r.items) {
269
+ const b = nested(t, 'board', 'name');
270
+ if (!bb[b])
271
+ bb[b] = { total: 0, ok: 0 };
272
+ bb[b].total++;
273
+ if ((new Date(t.closedDate).getTime() - new Date(t.dateEntered).getTime()) / 3600000 < 72)
274
+ bb[b].ok++;
275
+ }
276
+ const lines = ['## SLA Compliance (30 days)\n', '| Board | Total | Within SLA | % |', '|-------|-------|-----------|---|'];
277
+ for (const [b, d] of Object.entries(bb).sort((a, b) => b[1].total - a[1].total))
278
+ lines.push(`| ${b} | ${d.total} | ${d.ok} | ${d.total > 0 ? round2((d.ok / d.total) * 100) : 0}% |`);
279
+ return lines.join('\n');
280
+ });
281
+ reg('sla_breach_alerts', async () => {
282
+ const api = getAPI();
283
+ const r = await api.paginatedFetch('/service/tickets', 'closedFlag=false', 'id,summary,priority/name,company/name,dateEntered', 1000);
284
+ const atRisk = r.items.filter(t => { const age = daysSince(t.dateEntered); const p = nested(t, 'priority', 'name').toLowerCase(); return (p.includes('critical') && age > 1) || (p.includes('high') && age > 3) || age > 7; });
285
+ if (atRisk.length === 0)
286
+ return 'No SLA breach risks.';
287
+ const lines = [`## SLA Breach Alerts (${atRisk.length})\n`, '| # | Summary | Priority | Age | Risk |', '|---|---------|----------|-----|------|'];
288
+ for (const t of atRisk.slice(0, 30)) {
289
+ const age = daysSince(t.dateEntered);
290
+ const p = nested(t, 'priority', 'name').toLowerCase();
291
+ lines.push(`| ${t.id} | ${String(t.summary ?? '').substring(0, 40)} | ${nested(t, 'priority', 'name')} | ${age}d | ${p.includes('critical') ? 'BREACH' : 'HIGH'} |`);
292
+ }
293
+ return lines.join('\n');
294
+ });
295
+ reg('dispatch_optimizer', async () => {
296
+ const api = getAPI();
297
+ const [unassigned, time] = await Promise.all([api.paginatedFetch('/service/tickets', "closedFlag=false AND resources=null", 'id,summary,priority/name,board/name', 100), api.paginatedFetch('/time/entries', `dateEntered>=[${daysAgo(7)}]`, 'id,actualHours,member/identifier', 2000)]);
298
+ const wl = {};
299
+ for (const e of time.items) {
300
+ const m = nested(e, 'member', 'identifier');
301
+ wl[m] = (wl[m] ?? 0) + num(e.actualHours);
302
+ }
303
+ const lines = [`## Dispatch Optimizer\n`, `**Unassigned:** ${unassigned.items.length}\n`];
304
+ const least = Object.entries(wl).sort((a, b) => a[1] - b[1]).slice(0, 5);
305
+ if (least.length > 0) {
306
+ lines.push('### Most Available (7d)', '| Member | Hours |', '|--------|-------|');
307
+ for (const [m, h] of least)
308
+ lines.push(`| ${m} | ${round2(h)}h |`);
309
+ }
310
+ if (unassigned.items.length > 0) {
311
+ lines.push('\n### Unassigned', '| # | Summary | Priority |', '|---|---------|----------|');
312
+ for (const t of unassigned.items.slice(0, 20))
313
+ lines.push(`| ${t.id} | ${String(t.summary ?? '').substring(0, 40)} | ${nested(t, 'priority', 'name')} |`);
314
+ }
315
+ return lines.join('\n');
316
+ });
317
+ reg('csat_report', async (params) => {
318
+ const api = getAPI();
319
+ const days = num(params['days'], 90);
320
+ const r = await api.paginatedFetch('/service/surveys/results', `dateCreated>=[${daysAgo(days)}]`, undefined, 500);
321
+ if (r.items.length === 0)
322
+ return `No survey results in the last ${days} days.`;
323
+ const scores = r.items.map(x => num(x.totalPoints)).filter(s => s > 0);
324
+ return [`## CSAT Report (${days} days)\n`, `**Responses:** ${scores.length} | **Avg Score:** ${scores.length > 0 ? round2(scores.reduce((a, b) => a + b, 0) / scores.length) : 0}`].join('\n');
325
+ });
326
+ reg('cross_board_ticket_flow', async () => {
327
+ const api = getAPI();
328
+ const r = await api.paginatedFetch('/service/tickets', `closedDate>=[${daysAgo(30)}]`, 'id,board/name', 2000);
329
+ const bc = {};
330
+ for (const t of r.items) {
331
+ const b = nested(t, 'board', 'name');
332
+ bc[b] = (bc[b] ?? 0) + 1;
333
+ }
334
+ const lines = ['## Cross-Board Flow (30 days)\n', '| Board | Closed | % |', '|-------|--------|---|'];
335
+ for (const [b, c] of Object.entries(bc).sort((a, b) => b[1] - a[1]))
336
+ lines.push(`| ${b} | ${c} | ${round2((c / r.items.length) * 100)}% |`);
337
+ return lines.join('\n');
338
+ });
339
+ // ===========================================================================
340
+ // INTELLIGENCE
341
+ // ===========================================================================
342
+ reg('ticket_triage_assist', async (params) => {
343
+ const api = getAPI();
344
+ const tid = num(params['ticket_id']);
345
+ if (!tid)
346
+ return 'Error: ticket_id required.';
347
+ const t = (await api.request({ path: `/service/tickets/${tid}`, method: 'GET' })).data;
348
+ const s = String(t.summary ?? '').toLowerCase();
349
+ let pri = 'Medium';
350
+ if (s.match(/down|outage|emergency/))
351
+ pri = 'Critical';
352
+ else if (s.match(/error|slow|not working/))
353
+ pri = 'High';
354
+ else if (s.match(/question|how to|request/))
355
+ pri = 'Low';
356
+ let cat = 'General';
357
+ if (s.match(/network|vpn|wifi/))
358
+ cat = 'Network';
359
+ else if (s.match(/email|outlook|365/))
360
+ cat = 'Email/Cloud';
361
+ else if (s.match(/printer|print/))
362
+ cat = 'Printer';
363
+ else if (s.match(/password|login|access/))
364
+ cat = 'Access';
365
+ else if (s.match(/server|backup/))
366
+ cat = 'Infrastructure';
367
+ return [`## Triage: #${tid} -- ${t.summary}\n`, '| Field | Current | Suggested |', '|-------|---------|-----------|', `| Priority | ${nested(t, 'priority', 'name')} | ${pri} |`, `| Category | ${nested(t, 'type', 'name')} | ${cat} |`, `| Board | ${nested(t, 'board', 'name')} | -- |`].join('\n');
368
+ });
369
+ reg('company_risk_assessment', async (params) => {
370
+ const api = getAPI();
371
+ const cid = num(params['company_id']);
372
+ if (!cid)
373
+ return 'Error: company_id required.';
374
+ const [open, agrs, time] = await Promise.all([api.count('/service/tickets', `company/id=${cid} AND closedFlag=false`), api.count('/finance/agreements', `company/id=${cid} AND cancelledFlag=false`), api.paginatedFetch('/time/entries', `company/id=${cid} AND dateEntered>=[${daysAgo(90)}]`, 'id,actualHours,billableOption', 1000)]);
375
+ let score = 100;
376
+ const factors = [];
377
+ if (open > 10) {
378
+ score -= 15;
379
+ factors.push(`${open} open tickets`);
380
+ }
381
+ if (agrs === 0) {
382
+ score -= 20;
383
+ factors.push('No agreements');
384
+ }
385
+ const th = time.items.reduce((s, e) => s + num(e.actualHours), 0);
386
+ const bh = time.items.filter(e => String(e.billableOption ?? '').toLowerCase().includes('billable')).reduce((s, e) => s + num(e.actualHours), 0);
387
+ if (th > 0 && bh / th < 0.5) {
388
+ score -= 10;
389
+ factors.push('Low billable ratio');
390
+ }
391
+ const risk = score >= 80 ? 'LOW' : score >= 60 ? 'MEDIUM' : 'HIGH';
392
+ const lines = [`## Risk Assessment: Company #${cid}\n`, `**Score:** ${Math.max(0, score)}/100 | **Level:** ${risk}\n`, '| Metric | Value |', '|--------|-------|', `| Open Tickets | ${open} |`, `| Agreements | ${agrs} |`, `| Hours (90d) | ${round2(th)}h (${round2(bh)}h billable) |`];
393
+ if (factors.length > 0) {
394
+ lines.push('\n### Risk Factors');
395
+ for (const f of factors)
396
+ lines.push(`- ${f}`);
397
+ }
398
+ return lines.join('\n');
399
+ });
400
+ reg('revenue_leakage_scan', async () => {
401
+ const api = getAPI();
402
+ const [ub, exp, na] = await Promise.all([api.paginatedFetch('/time/entries', "billableOption='Billable' AND invoiceFlag=false", 'id,actualHours', 1000), api.paginatedFetch('/finance/agreements', `cancelledFlag=false AND endDate<[${today()}]`, 'id,billAmount', 200), api.paginatedFetch('/time/entries', `billableOption='Billable' AND agreement=null AND dateEntered>=[${daysAgo(30)}]`, 'id,actualHours', 500)]);
403
+ return ['## Revenue Leakage Scan\n', '| Source | Volume | Impact |', '|--------|--------|--------|', `| Unbilled hours | ${round2(ub.items.reduce((s, e) => s + num(e.actualHours), 0))}h | High |`, `| Expired agreements | ${exp.items.length} ($${round2(exp.items.reduce((s, a) => s + num(a.billAmount), 0))}/mo) | High |`, `| No-agreement billable (30d) | ${round2(na.items.reduce((s, e) => s + num(e.actualHours), 0))}h | Medium |`].join('\n');
404
+ });
405
+ reg('recurring_issue_detector', async (params) => {
406
+ const api = getAPI();
407
+ const cid = num(params['company_id']);
408
+ if (!cid)
409
+ return 'Error: company_id required.';
410
+ const r = await api.paginatedFetch('/service/tickets', `company/id=${cid} AND dateEntered>=[${daysAgo(180)}]`, 'id,summary,type/name', 500);
411
+ const bt = {};
412
+ for (const t of r.items) {
413
+ const type = nested(t, 'type', 'name');
414
+ bt[type] = (bt[type] ?? 0) + 1;
415
+ }
416
+ const lines = [`## Recurring Issues: Company #${cid} (${r.items.length} tickets, 180 days)\n`, '| Type | Count |', '|------|-------|'];
417
+ for (const [k, v] of Object.entries(bt).sort((a, b) => b[1] - a[1]))
418
+ lines.push(`| ${k} | ${v} |`);
419
+ return lines.join('\n');
420
+ });
421
+ reg('company_onboarding_checklist', async (params) => {
422
+ const api = getAPI();
423
+ const cid = num(params['company_id']);
424
+ if (!cid)
425
+ return 'Error: company_id required.';
426
+ const [co, contacts, sites, configs, agrs] = await Promise.all([api.request({ path: `/company/companies/${cid}`, method: 'GET' }).catch(() => ({ data: {} })), api.count('/company/contacts', `company/id=${cid}`), api.paginatedFetch(`/company/companies/${cid}/sites`, undefined, 'id', 10), api.count('/company/configurations', `company/id=${cid}`), api.count('/finance/agreements', `company/id=${cid} AND cancelledFlag=false`)]);
427
+ const checks = [['Company record', co.data.id ? 'PASS' : 'FAIL'], ['Contacts', contacts > 0 ? 'PASS' : 'FAIL'], ['Sites', sites.items.length > 0 ? 'PASS' : 'FAIL'], ['Configurations', configs > 0 ? 'WARN' : 'WARN'], ['Agreement', agrs > 0 ? 'PASS' : 'FAIL']];
428
+ const pass = checks.filter(c => c[1] === 'PASS').length;
429
+ const lines = [`## Onboarding: ${co.data.name ?? `#${cid}`} (${pass}/${checks.length})\n`, '| Check | Status |', '|-------|--------|'];
430
+ for (const [item, status] of checks)
431
+ lines.push(`| ${item} | ${status} |`);
432
+ return lines.join('\n');
433
+ });
434
+ reg('similar_ticket_finder', async (params) => {
435
+ const api = getAPI();
436
+ const tid = num(params['ticket_id']);
437
+ if (!tid)
438
+ return 'Error: ticket_id required.';
439
+ const t = (await api.request({ path: `/service/tickets/${tid}`, method: 'GET' })).data;
440
+ const words = String(t.summary ?? '').split(/\s+/).filter(w => w.length > 4).slice(0, 3);
441
+ if (words.length === 0)
442
+ return 'Summary too short.';
443
+ const cond = words.map(w => `summary like '%${w}%'`).join(' OR ');
444
+ const r = await api.paginatedFetch('/service/tickets', `(${cond}) AND id!=${tid}`, 'id,summary,status/name,company/name,dateEntered', 50);
445
+ if (r.items.length === 0)
446
+ return 'No similar tickets found.';
447
+ const lines = [`## Similar to #${tid}: ${t.summary}\n`, '| # | Summary | Status | Company |', '|---|---------|--------|---------|'];
448
+ for (const s of r.items.slice(0, 20))
449
+ lines.push(`| ${s.id} | ${String(s.summary ?? '').substring(0, 50)} | ${nested(s, 'status', 'name')} | ${nested(s, 'company', 'name')} |`);
450
+ return lines.join('\n');
451
+ });
452
+ reg('escalation_risk_scorer', async () => {
453
+ const api = getAPI();
454
+ const r = await api.paginatedFetch('/service/tickets', 'closedFlag=false', 'id,summary,priority/name,dateEntered,lastUpdated,resources', 1000);
455
+ const scored = r.items.map(t => { let risk = 0; const age = daysSince(t.dateEntered); const stale = daysSince(t.lastUpdated); const p = nested(t, 'priority', 'name').toLowerCase(); if (p.includes('critical'))
456
+ risk += 40;
457
+ else if (p.includes('high'))
458
+ risk += 25; if (age > 14)
459
+ risk += 20;
460
+ else if (age > 7)
461
+ risk += 10; if (stale > 7)
462
+ risk += 20; if (!t.resources)
463
+ risk += 15; return { t, risk: Math.min(risk, 100) }; }).filter(s => s.risk >= 30).sort((a, b) => b.risk - a.risk);
464
+ if (scored.length === 0)
465
+ return 'No escalation risks.';
466
+ const lines = [`## Escalation Risk (${scored.length} tickets)\n`, '| # | Summary | Priority | Risk |', '|---|---------|----------|------|'];
467
+ for (const s of scored.slice(0, 30))
468
+ lines.push(`| ${s.t.id} | ${String(s.t.summary ?? '').substring(0, 40)} | ${nested(s.t, 'priority', 'name')} | ${s.risk}/100 |`);
469
+ return lines.join('\n');
470
+ });
471
+ reg('schedule_gap_analysis', async (params) => {
472
+ const api = getAPI();
473
+ const days = num(params['days'], 14);
474
+ const e = await api.paginatedFetch('/schedule/entries', `dateStart>=[${today()}]`, 'id,member/identifier,hours', 2000);
475
+ const bm = {};
476
+ for (const x of e.items) {
477
+ const m = nested(x, 'member', 'identifier');
478
+ bm[m] = (bm[m] ?? 0) + num(x.hours);
479
+ }
480
+ const exp = days * 8 * 5 / 7;
481
+ const lines = [`## Schedule Gaps (next ${days} days)\n`, '| Member | Scheduled | Expected | Gap |', '|--------|-----------|----------|-----|'];
482
+ for (const [m, h] of Object.entries(bm).sort((a, b) => a[1] - b[1])) {
483
+ const g = round2(exp - h);
484
+ lines.push(`| ${m} | ${round2(h)}h | ${round2(exp)}h | ${g > 0 ? g + 'h under' : Math.abs(g) + 'h over'} |`);
485
+ }
486
+ return lines.join('\n');
487
+ });
488
+ reg('after_hours_impact_report', async () => {
489
+ const api = getAPI();
490
+ const r = await api.paginatedFetch('/service/tickets', `dateEntered>=[${daysAgo(30)}]`, 'id,summary,dateEntered,priority/name,company/name', 2000);
491
+ const ah = r.items.filter(t => { const h = new Date(t.dateEntered).getHours(); return h < 7 || h >= 18; });
492
+ if (ah.length === 0)
493
+ return 'No after-hours tickets (30 days).';
494
+ const lines = [`## After-Hours (${ah.length} of ${r.items.length}, ${round2((ah.length / r.items.length) * 100)}%)\n`, '| # | Summary | Priority | Created |', '|---|---------|----------|---------|'];
495
+ for (const t of ah.slice(0, 25))
496
+ lines.push(`| ${t.id} | ${String(t.summary ?? '').substring(0, 40)} | ${nested(t, 'priority', 'name')} | ${String(t.dateEntered ?? '').substring(0, 16)} |`);
497
+ return lines.join('\n');
498
+ });
499
+ reg('proactive_maintenance_alerts', async () => {
500
+ const api = getAPI();
501
+ const [configs, tickets] = await Promise.all([api.paginatedFetch('/company/configurations', 'activeFlag=true', 'id,name,company/name,type/name', 500), api.paginatedFetch('/service/tickets', `dateEntered>=[${daysAgo(90)}]`, 'id,configurations', 2000)]);
502
+ const ct = {};
503
+ for (const t of tickets.items) {
504
+ const cfgs = t.configurations;
505
+ if (Array.isArray(cfgs))
506
+ for (const c of cfgs)
507
+ ct[String(c.id)] = (ct[String(c.id)] ?? 0) + 1;
508
+ }
509
+ const atRisk = configs.items.filter(c => (ct[String(c.id)] ?? 0) >= 3).sort((a, b) => (ct[String(b.id)] ?? 0) - (ct[String(a.id)] ?? 0));
510
+ if (atRisk.length === 0)
511
+ return 'No configs with recurring issues.';
512
+ const lines = [`## Maintenance Alerts (${atRisk.length} configs, 3+ tickets/90d)\n`, '| Config | Company | Type | Tickets |', '|--------|---------|------|---------|'];
513
+ for (const c of atRisk.slice(0, 25))
514
+ lines.push(`| ${c.name} | ${nested(c, 'company', 'name')} | ${nested(c, 'type', 'name')} | ${ct[String(c.id)]} |`);
515
+ return lines.join('\n');
516
+ });
517
+ reg('knowledge_article_recommendation', async (params) => {
518
+ const api = getAPI();
519
+ const tid = num(params['ticket_id']);
520
+ if (!tid)
521
+ return 'Error: ticket_id required.';
522
+ const t = (await api.request({ path: `/service/tickets/${tid}`, method: 'GET' })).data;
523
+ const words = String(t.summary ?? '').split(/\s+/).filter(w => w.length > 3).slice(0, 4);
524
+ if (words.length === 0)
525
+ return 'Summary too short for KB search.';
526
+ const cond = words.map(w => `title like '%${w}%'`).join(' OR ');
527
+ const r = await api.paginatedFetch('/service/knowledgeBaseArticles', cond, 'id,title', 20);
528
+ if (r.items.length === 0)
529
+ return `No KB articles matching #${tid}.`;
530
+ const lines = [`## KB Recommendations for #${tid}\n`, '| ID | Title |', '|----|-------|'];
531
+ for (const a of r.items)
532
+ lines.push(`| ${a.id} | ${a.title} |`);
533
+ return lines.join('\n');
534
+ });
535
+ reg('resource_skill_matcher', async (params) => {
536
+ const api = getAPI();
537
+ const tid = num(params['ticket_id']);
538
+ if (!tid)
539
+ return 'Error: ticket_id required.';
540
+ const [t, members] = await Promise.all([api.request({ path: `/service/tickets/${tid}`, method: 'GET' }), api.paginatedFetch('/system/members', 'inactiveFlag=false', 'id,identifier,firstName,lastName,defaultDepartment/name', 200)]);
541
+ const lines = [`## Skill Match: #${tid}\n`, `**Board:** ${nested(t.data, 'board', 'name')} | **Type:** ${nested(t.data, 'type', 'name')}\n`, '| Member | Department |', '|--------|-----------|'];
542
+ for (const m of members.items.slice(0, 20))
543
+ lines.push(`| ${m.firstName} ${m.lastName} (${m.identifier}) | ${nested(m, 'defaultDepartment', 'name')} |`);
544
+ return lines.join('\n');
545
+ });
546
+ reg('configuration_lifecycle_tracker', async (params) => {
547
+ const api = getAPI();
548
+ const cid = params['company_id'] ? num(params['company_id']) : undefined;
549
+ const cond = cid ? `company/id=${cid} AND activeFlag=true` : 'activeFlag=true';
550
+ const r = await api.paginatedFetch('/company/configurations', cond, 'id,name,company/name,type/name,status/name,installationDate', 500);
551
+ const lines = [`## Configuration Lifecycle${cid ? ` (Company #${cid})` : ''}\n`, '| Name | Company | Type | Age |', '|------|---------|------|-----|'];
552
+ for (const c of r.items.slice(0, 50))
553
+ lines.push(`| ${c.name} | ${nested(c, 'company', 'name')} | ${nested(c, 'type', 'name')} | ${c.installationDate ? daysSince(c.installationDate) + 'd' : 'N/A'} |`);
554
+ return lines.join('\n');
555
+ });
556
+ reg('time_prediction_model', async (params) => {
557
+ const api = getAPI();
558
+ const tid = num(params['ticket_id']);
559
+ if (!tid)
560
+ return 'Error: ticket_id required.';
561
+ const t = (await api.request({ path: `/service/tickets/${tid}`, method: 'GET' })).data;
562
+ const type = nested(t, 'type', 'name');
563
+ const board = nested(t, 'board', 'name');
564
+ const sim = await api.paginatedFetch('/service/tickets', `closedFlag=true AND type/name='${type}' AND board/name='${board}'`, 'id,actualHours', 200);
565
+ const hrs = sim.items.map(s => num(s.actualHours)).filter(h => h > 0);
566
+ if (hrs.length < 3)
567
+ return `Not enough data (${hrs.length} similar tickets).`;
568
+ const avg = round2(hrs.reduce((a, b) => a + b, 0) / hrs.length);
569
+ const sorted = [...hrs].sort((a, b) => a - b);
570
+ const med = round2(sorted[Math.floor(sorted.length / 2)]);
571
+ return [`## Time Prediction: #${tid}\n`, `Based on ${hrs.length} similar (${type}, ${board}):\n`, '| Metric | Hours |', '|--------|-------|', `| Average | ${avg}h |`, `| Median | ${med}h |`, `| **Prediction** | **${med}h** |`].join('\n');
572
+ });
573
+ // ===========================================================================
574
+ // PREMIUM ANALYTICS
575
+ // ===========================================================================
576
+ reg('agreement_profitability_analysis', async () => {
577
+ const api = getAPI();
578
+ const agrs = await api.paginatedFetch('/finance/agreements', 'cancelledFlag=false', 'id,name,company/name,billAmount', 200);
579
+ const res = await Promise.allSettled(agrs.items.slice(0, 30).map(async (a) => { const t = await api.paginatedFetch('/time/entries', `agreement/id=${a.id} AND dateEntered>=[${daysAgo(90)}]`, 'id,actualHours', 500); return { a, hours: t.items.reduce((s, e) => s + num(e.actualHours), 0), rev: num(a.billAmount) * 3 }; }));
580
+ const lines = ['## Agreement Profitability (90 days)\n', '| Agreement | Company | Revenue | Hours | $/Hour |', '|-----------|---------|---------|-------|--------|'];
581
+ for (const r of res) {
582
+ if (r.status !== 'fulfilled')
583
+ continue;
584
+ const { a, hours, rev } = r.value;
585
+ lines.push(`| ${a.name} | ${nested(a, 'company', 'name')} | $${round2(rev)} | ${round2(hours)}h | $${hours > 0 ? round2(rev / hours) : 0} |`);
586
+ }
587
+ return lines.join('\n');
588
+ });
589
+ reg('client_health_score', async (params) => {
590
+ const api = getAPI();
591
+ const cid = num(params['company_id']);
592
+ if (!cid)
593
+ return 'Error: company_id required.';
594
+ const [open, agrs, time] = await Promise.all([api.count('/service/tickets', `company/id=${cid} AND closedFlag=false`), api.count('/finance/agreements', `company/id=${cid} AND cancelledFlag=false`), api.paginatedFetch('/time/entries', `company/id=${cid} AND dateEntered>=[${daysAgo(90)}]`, 'id,actualHours', 1000)]);
595
+ let score = 70;
596
+ if (agrs > 0)
597
+ score += 15;
598
+ else
599
+ score -= 10;
600
+ if (open <= 5)
601
+ score += 5;
602
+ else if (open > 15)
603
+ score -= 15;
604
+ score = Math.max(0, Math.min(100, score));
605
+ return [`## Client Health: #${cid}\n`, `**Score:** ${score}/100 | **Level:** ${score >= 80 ? 'HEALTHY' : score >= 60 ? 'WATCH' : 'AT RISK'}\n`, '| Metric | Value |', '|--------|-------|', `| Open Tickets | ${open} |`, `| Agreements | ${agrs} |`, `| Hours (90d) | ${round2(time.items.reduce((s, e) => s + num(e.actualHours), 0))}h |`].join('\n');
606
+ });
607
+ reg('effective_hourly_rate', async (params) => {
608
+ const api = getAPI();
609
+ const days = num(params['days'], 90);
610
+ const [agrs, time] = await Promise.all([api.paginatedFetch('/finance/agreements', 'cancelledFlag=false', 'id,name,company/name,billAmount', 200), api.paginatedFetch('/time/entries', `dateEntered>=[${daysAgo(days)}]`, 'id,actualHours,agreement/id', 5000)]);
611
+ const ah = {};
612
+ for (const e of time.items) {
613
+ const aid = e.agreement?.id;
614
+ if (aid)
615
+ ah[aid] = (ah[aid] ?? 0) + num(e.actualHours);
616
+ }
617
+ const lines = [`## Effective Hourly Rate (${days} days)\n`, '| Agreement | Company | MRR | Hours | $/Hour |', '|-----------|---------|-----|-------|--------|'];
618
+ for (const a of agrs.items.slice(0, 30)) {
619
+ const h = ah[a.id] ?? 0;
620
+ if (h === 0)
621
+ continue;
622
+ const rev = num(a.billAmount) * (days / 30);
623
+ lines.push(`| ${a.name} | ${nested(a, 'company', 'name')} | $${num(a.billAmount)} | ${round2(h)}h | $${round2(rev / h)} |`);
624
+ }
625
+ return lines.join('\n');
626
+ });
627
+ reg('qbr_data_pack', async (params) => {
628
+ const api = getAPI();
629
+ const cid = num(params['company_id']);
630
+ if (!cid)
631
+ return 'Error: company_id required.';
632
+ const [tickets, closed, time, agrs] = await Promise.all([api.count('/service/tickets', `company/id=${cid} AND dateEntered>=[${daysAgo(90)}]`), api.count('/service/tickets', `company/id=${cid} AND closedDate>=[${daysAgo(90)}]`), api.paginatedFetch('/time/entries', `company/id=${cid} AND dateEntered>=[${daysAgo(90)}]`, 'id,actualHours,billableOption', 2000), api.paginatedFetch('/finance/agreements', `company/id=${cid} AND cancelledFlag=false`, 'id,billAmount', 50)]);
633
+ const th = time.items.reduce((s, e) => s + num(e.actualHours), 0);
634
+ const bh = time.items.filter(e => String(e.billableOption ?? '').toLowerCase().includes('billable')).reduce((s, e) => s + num(e.actualHours), 0);
635
+ const mrr = agrs.items.reduce((s, a) => s + num(a.billAmount), 0);
636
+ return [`## QBR Data: Company #${cid} (90 days)\n`, '| Metric | Value |', '|--------|-------|', `| Tickets | ${tickets} |`, `| Closed | ${closed} |`, `| Hours | ${round2(th)}h (${round2(bh)}h billable) |`, `| MRR | $${round2(mrr)} |`, `| Quarterly Rev | $${round2(mrr * 3)} |`].join('\n');
637
+ });
638
+ reg('pipeline_revenue_forecast', async () => {
639
+ const api = getAPI();
640
+ const r = await api.paginatedFetch('/sales/opportunities', 'closedFlag=false', 'id,name,stage/name,totalSalesValue', 500);
641
+ const bs = {};
642
+ for (const o of r.items) {
643
+ const s = nested(o, 'stage', 'name');
644
+ if (!bs[s])
645
+ bs[s] = { count: 0, value: 0 };
646
+ bs[s].count++;
647
+ bs[s].value += num(o.totalSalesValue);
648
+ }
649
+ const total = r.items.reduce((s, o) => s + num(o.totalSalesValue), 0);
650
+ const lines = [`## Pipeline Forecast (${r.items.length} opps, $${round2(total)} total)\n`, '| Stage | Count | Value |', '|-------|-------|-------|'];
651
+ for (const [s, d] of Object.entries(bs).sort((a, b) => b[1].value - a[1].value))
652
+ lines.push(`| ${s} | ${d.count} | $${round2(d.value)} |`);
653
+ return lines.join('\n');
654
+ });
655
+ reg('project_portfolio_health', async () => {
656
+ const api = getAPI();
657
+ const r = await api.paginatedFetch('/project/projects', "status/name='Open'", 'id,name,company/name,budgetHours,actualHours,estimatedEnd', 200);
658
+ const lines = ['## Project Portfolio Health\n', '| Project | Company | Budget | Actual | % | Overdue |', '|---------|---------|--------|--------|---|---------|'];
659
+ for (const p of r.items) {
660
+ const b = num(p.budgetHours);
661
+ const a = num(p.actualHours);
662
+ const pct = b > 0 ? round2((a / b) * 100) : 0;
663
+ const od = p.estimatedEnd && new Date(p.estimatedEnd) < new Date() ? 'YES' : 'No';
664
+ lines.push(`| ${p.name} | ${nested(p, 'company', 'name')} | ${b}h | ${round2(a)}h | ${pct}% | ${od} |`);
665
+ }
666
+ return lines.join('\n');
667
+ });
668
+ reg('revenue_per_employee', async (params) => {
669
+ const api = getAPI();
670
+ const days = num(params['days'], 90);
671
+ const [members, entries] = await Promise.all([api.count('/system/members', 'inactiveFlag=false'), api.paginatedFetch('/time/entries', `dateEntered>=[${daysAgo(days)}] AND billableOption='Billable'`, 'id,actualHours,member/identifier', 5000)]);
672
+ const bm = {};
673
+ for (const e of entries.items) {
674
+ const m = nested(e, 'member', 'identifier');
675
+ bm[m] = (bm[m] ?? 0) + num(e.actualHours);
676
+ }
677
+ const tot = entries.items.reduce((s, e) => s + num(e.actualHours), 0);
678
+ const lines = [`## Revenue Per Employee (${days} days)\n`, `**Members:** ${members} | **Billable:** ${round2(tot)}h | **Avg:** ${members > 0 ? round2(tot / members) : 0}h\n`, '| Member | Billable |', '|--------|----------|'];
679
+ for (const [m, h] of Object.entries(bm).sort((a, b) => b[1] - a[1]).slice(0, 20))
680
+ lines.push(`| ${m} | ${round2(h)}h |`);
681
+ return lines.join('\n');
682
+ });
683
+ reg('service_margin_analysis', async () => {
684
+ const api = getAPI();
685
+ const agrs = await api.paginatedFetch('/finance/agreements', 'cancelledFlag=false', 'id,type/name,billAmount', 200);
686
+ const bt = {};
687
+ for (const a of agrs.items) {
688
+ const t = nested(a, 'type', 'name');
689
+ if (!bt[t])
690
+ bt[t] = { count: 0, mrr: 0 };
691
+ bt[t].count++;
692
+ bt[t].mrr += num(a.billAmount);
693
+ }
694
+ const lines = ['## Service Margin Analysis\n', '| Type | Count | MRR | Avg |', '|------|-------|-----|-----|'];
695
+ for (const [t, d] of Object.entries(bt).sort((a, b) => b[1].mrr - a[1].mrr))
696
+ lines.push(`| ${t} | ${d.count} | $${round2(d.mrr)} | $${round2(d.mrr / d.count)} |`);
697
+ return lines.join('\n');
698
+ });
699
+ reg('customer_lifetime_value', async (params) => {
700
+ const api = getAPI();
701
+ const cid = num(params['company_id']);
702
+ if (!cid)
703
+ return 'Error: company_id required.';
704
+ const [agrs, inv] = await Promise.all([api.paginatedFetch('/finance/agreements', `company/id=${cid}`, 'id,billAmount,startDate,cancelledFlag', 100), api.paginatedFetch('/finance/invoices', `company/id=${cid}`, 'id,total', 500)]);
705
+ const totalInv = inv.items.reduce((s, i) => s + num(i.total), 0);
706
+ const mrr = agrs.items.filter(a => !a.cancelledFlag).reduce((s, a) => s + num(a.billAmount), 0);
707
+ const oldest = agrs.items.map(a => a.startDate).filter(Boolean).sort()[0];
708
+ const months = oldest ? Math.max(1, Math.floor(daysSince(oldest) / 30)) : 1;
709
+ return [`## CLV: Company #${cid}\n`, '| Metric | Value |', '|--------|-------|', `| Total Invoiced | $${round2(totalInv)} |`, `| Months | ${months} |`, `| MRR | $${round2(mrr)} |`, `| Avg Monthly | $${round2(totalInv / months)} |`].join('\n');
710
+ });
711
+ reg('company_offboarding_audit', async (params) => {
712
+ const api = getAPI();
713
+ const cid = num(params['company_id']);
714
+ if (!cid)
715
+ return 'Error: company_id required.';
716
+ const [open, agrs, configs, ub] = await Promise.all([api.count('/service/tickets', `company/id=${cid} AND closedFlag=false`), api.count('/finance/agreements', `company/id=${cid} AND cancelledFlag=false`), api.count('/company/configurations', `company/id=${cid} AND activeFlag=true`), api.paginatedFetch('/time/entries', `company/id=${cid} AND billableOption='Billable' AND invoiceFlag=false`, 'id,actualHours', 500)]);
717
+ const ubh = ub.items.reduce((s, e) => s + num(e.actualHours), 0);
718
+ const checks = [['Open tickets', open > 0 ? 'BLOCK' : 'CLEAR', open > 0 ? `Close ${open}` : 'None'], ['Agreements', agrs > 0 ? 'BLOCK' : 'CLEAR', agrs > 0 ? `Cancel ${agrs}` : 'None'], ['Configs', configs > 0 ? 'WARN' : 'CLEAR', configs > 0 ? `Deactivate ${configs}` : 'None'], ['Unbilled time', ubh > 0 ? 'BLOCK' : 'CLEAR', ubh > 0 ? `Invoice ${round2(ubh)}h` : 'None']];
719
+ const blockers = checks.filter(c => c[1] === 'BLOCK').length;
720
+ const lines = [`## Offboarding: #${cid}\n`, `**Status:** ${blockers > 0 ? 'BLOCKED' : 'READY'}\n`, '| Check | Status | Action |', '|-------|--------|--------|'];
721
+ for (const [item, st, act] of checks)
722
+ lines.push(`| ${item} | ${st} | ${act} |`);
723
+ return lines.join('\n');
724
+ });
725
+ reg('procurement_spend_analysis', async (params) => {
726
+ const api = getAPI();
727
+ const days = num(params['days'], 365);
728
+ const r = await api.paginatedFetch('/procurement/purchaseorders', `dateEntered>=[${daysAgo(days)}]`, 'id,vendorCompany/name,total', 1000);
729
+ const bv = {};
730
+ for (const po of r.items) {
731
+ const v = nested(po, 'vendorCompany', 'name');
732
+ if (!bv[v])
733
+ bv[v] = { total: 0, count: 0 };
734
+ bv[v].total += num(po.total);
735
+ bv[v].count++;
736
+ }
737
+ const gt = Object.values(bv).reduce((s, d) => s + d.total, 0);
738
+ const lines = [`## Procurement Spend (${days} days, $${round2(gt)})\n`, '| Vendor | POs | Spend | % |', '|--------|-----|-------|---|'];
739
+ for (const [v, d] of Object.entries(bv).sort((a, b) => b[1].total - a[1].total).slice(0, 20))
740
+ lines.push(`| ${v} | ${d.count} | $${round2(d.total)} | ${round2((d.total / gt) * 100)}% |`);
741
+ return lines.join('\n');
742
+ });
743
+ reg('client_retention_analysis', async () => {
744
+ const api = getAPI();
745
+ const [active, agrs, recent] = await Promise.all([api.count('/company/companies', "status/name='Active'"), api.paginatedFetch('/finance/agreements', 'cancelledFlag=false', 'id,company/id', 500), api.paginatedFetch('/service/tickets', `dateEntered>=[${daysAgo(90)}]`, 'id,company/id', 2000)]);
746
+ const wa = new Set(agrs.items.map(a => nested(a, 'company', 'id'))).size;
747
+ const wr = new Set(recent.items.map(t => nested(t, 'company', 'id'))).size;
748
+ return ['## Client Retention\n', '| Metric | Value |', '|--------|-------|', `| Active Companies | ${active} |`, `| With Agreement | ${wa} (${round2((wa / active) * 100)}%) |`, `| Active (90d) | ${wr} (${round2((wr / active) * 100)}%) |`, `| Inactive | ${active - wr} |`].join('\n');
749
+ });
750
+ reg('profitability_leaderboard', async () => {
751
+ const api = getAPI();
752
+ const [agrs, time] = await Promise.all([api.paginatedFetch('/finance/agreements', 'cancelledFlag=false', 'id,company/id,company/name,billAmount', 500), api.paginatedFetch('/time/entries', `dateEntered>=[${daysAgo(90)}]`, 'id,actualHours,company/id,company/name', 5000)]);
753
+ const cd = {};
754
+ for (const a of agrs.items) {
755
+ const c = String(nested(a, 'company', 'id'));
756
+ if (!cd[c])
757
+ cd[c] = { name: nested(a, 'company', 'name'), mrr: 0, hours: 0 };
758
+ cd[c].mrr += num(a.billAmount);
759
+ }
760
+ for (const e of time.items) {
761
+ const c = String(nested(e, 'company', 'id'));
762
+ if (!cd[c])
763
+ cd[c] = { name: nested(e, 'company', 'name'), mrr: 0, hours: 0 };
764
+ cd[c].hours += num(e.actualHours);
765
+ }
766
+ const ranked = Object.values(cd).filter(d => d.mrr > 0 && d.hours > 0).map(d => ({ ...d, rate: round2((d.mrr * 3) / d.hours) })).sort((a, b) => b.rate - a.rate);
767
+ const lines = ['## Profitability Leaderboard (90 days)\n', '| Rank | Company | MRR | Hours | $/Hour |', '|------|---------|-----|-------|--------|'];
768
+ ranked.slice(0, 20).forEach((d, i) => lines.push(`| ${i + 1} | ${d.name} | $${round2(d.mrr)} | ${round2(d.hours)}h | $${d.rate} |`));
769
+ return lines.join('\n');
770
+ });
771
+ // ===========================================================================
772
+ // CROSS-DOMAIN
773
+ // ===========================================================================
774
+ reg('entity_relationship_map', async (params) => {
775
+ const api = getAPI();
776
+ const cid = num(params['company_id']);
777
+ if (!cid)
778
+ return 'Error: company_id required.';
779
+ const [co, contacts, tickets, agrs, configs, projects] = await Promise.all([api.request({ path: `/company/companies/${cid}`, method: 'GET' }).catch(() => ({ data: {} })), api.count('/company/contacts', `company/id=${cid}`), api.count('/service/tickets', `company/id=${cid}`), api.count('/finance/agreements', `company/id=${cid}`), api.count('/company/configurations', `company/id=${cid}`), api.count('/project/projects', `company/id=${cid}`)]);
780
+ return [`## Entity Map: ${co.data.name ?? `#${cid}`}\n`, '```', `${co.data.name ?? cid}`, ` +-- Contacts: ${contacts}`, ` +-- Tickets: ${tickets}`, ` +-- Agreements: ${agrs}`, ` +-- Configs: ${configs}`, ` +-- Projects: ${projects}`, '```'].join('\n');
781
+ });
782
+ reg('ticket_handoff_analysis', async (params) => {
783
+ const api = getAPI();
784
+ const days = num(params['days'], 30);
785
+ const r = await api.paginatedFetch('/service/tickets', `closedDate>=[${daysAgo(days)}]`, 'id,resources', 2000);
786
+ const multi = r.items.filter(t => String(t.resources ?? '').includes(','));
787
+ return [`## Ticket Handoffs (${days} days)\n`, `**Closed:** ${r.items.length} | **Multi-assigned:** ${multi.length} (${round2((multi.length / Math.max(r.items.length, 1)) * 100)}%)`].join('\n');
788
+ });
789
+ reg('company_financial_summary', async (params) => {
790
+ const api = getAPI();
791
+ const cid = num(params['company_id']);
792
+ if (!cid)
793
+ return 'Error: company_id required.';
794
+ const [agrs, inv, time] = await Promise.all([api.paginatedFetch('/finance/agreements', `company/id=${cid} AND cancelledFlag=false`, 'id,billAmount', 50), api.paginatedFetch('/finance/invoices', `company/id=${cid} AND date>=[${daysAgo(90)}]`, 'id,total', 200), api.paginatedFetch('/time/entries', `company/id=${cid} AND dateEntered>=[${daysAgo(90)}]`, 'id,actualHours', 1000)]);
795
+ return [`## Financial Summary: #${cid} (90 days)\n`, '| Metric | Value |', '|--------|-------|', `| MRR | $${round2(agrs.items.reduce((s, a) => s + num(a.billAmount), 0))} |`, `| Agreements | ${agrs.items.length} |`, `| Invoiced | $${round2(inv.items.reduce((s, i) => s + num(i.total), 0))} |`, `| Hours | ${round2(time.items.reduce((s, e) => s + num(e.actualHours), 0))}h |`].join('\n');
796
+ });
797
+ reg('new_client_onboarding_status', async (params) => {
798
+ const api = getAPI();
799
+ const days = num(params['days'], 30);
800
+ const r = await api.paginatedFetch('/company/companies', `dateEntered>=[${daysAgo(days)}]`, 'id,name,status/name,dateEntered', 100);
801
+ if (r.items.length === 0)
802
+ return `No new companies in ${days} days.`;
803
+ const lines = [`## New Clients (${days} days, ${r.items.length})\n`, '| Company | Status | Added | Age |', '|---------|--------|-------|-----|'];
804
+ for (const c of r.items)
805
+ lines.push(`| ${c.name} | ${nested(c, 'status', 'name')} | ${String(c.dateEntered ?? '').substring(0, 10)} | ${daysSince(c.dateEntered)}d |`);
806
+ return lines.join('\n');
807
+ });
808
+ reg('board_health_dashboard', async (params) => {
809
+ const api = getAPI();
810
+ const bid = num(params['board_id']);
811
+ if (!bid)
812
+ return 'Error: board_id required.';
813
+ const [board, open, closed] = await Promise.all([api.request({ path: `/service/boards/${bid}`, method: 'GET' }).catch(() => ({ data: {} })), api.paginatedFetch('/service/tickets', `board/id=${bid} AND closedFlag=false`, 'id,priority/name,dateEntered', 1000), api.paginatedFetch('/service/tickets', `board/id=${bid} AND closedDate>=[${daysAgo(30)}]`, 'id', 1000)]);
814
+ const avgAge = open.items.length > 0 ? round2(open.items.reduce((s, t) => s + daysSince(t.dateEntered), 0) / open.items.length) : 0;
815
+ const bp = {};
816
+ for (const t of open.items) {
817
+ const p = nested(t, 'priority', 'name');
818
+ bp[p] = (bp[p] ?? 0) + 1;
819
+ }
820
+ const lines = [`## Board Health: ${board.data.name ?? `#${bid}`}\n`, '| Metric | Value |', '|--------|-------|', `| Open | ${open.items.length} |`, `| Closed (30d) | ${closed.items.length} |`, `| Avg Age | ${avgAge}d |`, '', '### By Priority', '| Priority | Count |', '|----------|-------|'];
821
+ for (const [p, c] of Object.entries(bp).sort((a, b) => b[1] - a[1]))
822
+ lines.push(`| ${p} | ${c} |`);
823
+ return lines.join('\n');
824
+ });
825
+ //# sourceMappingURL=analytics-extended.js.map