@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.
- package/data/connectwise_api.db +0 -0
- package/data/manage.json +298179 -0
- package/dist/index.d.ts +10 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +116 -0
- package/dist/index.js.map +1 -0
- package/dist/operations/analytics-extended.d.ts +6 -0
- package/dist/operations/analytics-extended.d.ts.map +1 -0
- package/dist/operations/analytics-extended.js +825 -0
- package/dist/operations/analytics-extended.js.map +1 -0
- package/dist/operations/analytics-msp-assets.d.ts +3 -0
- package/dist/operations/analytics-msp-assets.d.ts.map +1 -0
- package/dist/operations/analytics-msp-assets.js +180 -0
- package/dist/operations/analytics-msp-assets.js.map +1 -0
- package/dist/operations/analytics-msp-clients.d.ts +3 -0
- package/dist/operations/analytics-msp-clients.d.ts.map +1 -0
- package/dist/operations/analytics-msp-clients.js +198 -0
- package/dist/operations/analytics-msp-clients.js.map +1 -0
- package/dist/operations/analytics-msp-comms.d.ts +3 -0
- package/dist/operations/analytics-msp-comms.d.ts.map +1 -0
- package/dist/operations/analytics-msp-comms.js +127 -0
- package/dist/operations/analytics-msp-comms.js.map +1 -0
- package/dist/operations/analytics-msp-contracts.d.ts +3 -0
- package/dist/operations/analytics-msp-contracts.d.ts.map +1 -0
- package/dist/operations/analytics-msp-contracts.js +91 -0
- package/dist/operations/analytics-msp-contracts.js.map +1 -0
- package/dist/operations/analytics-msp-financial.d.ts +3 -0
- package/dist/operations/analytics-msp-financial.d.ts.map +1 -0
- package/dist/operations/analytics-msp-financial.js +300 -0
- package/dist/operations/analytics-msp-financial.js.map +1 -0
- package/dist/operations/analytics-msp-procurement.d.ts +3 -0
- package/dist/operations/analytics-msp-procurement.d.ts.map +1 -0
- package/dist/operations/analytics-msp-procurement.js +78 -0
- package/dist/operations/analytics-msp-procurement.js.map +1 -0
- package/dist/operations/analytics-msp-projects.d.ts +3 -0
- package/dist/operations/analytics-msp-projects.d.ts.map +1 -0
- package/dist/operations/analytics-msp-projects.js +190 -0
- package/dist/operations/analytics-msp-projects.js.map +1 -0
- package/dist/operations/analytics-msp-sales.d.ts +3 -0
- package/dist/operations/analytics-msp-sales.d.ts.map +1 -0
- package/dist/operations/analytics-msp-sales.js +99 -0
- package/dist/operations/analytics-msp-sales.js.map +1 -0
- package/dist/operations/analytics-msp-schedule.d.ts +3 -0
- package/dist/operations/analytics-msp-schedule.d.ts.map +1 -0
- package/dist/operations/analytics-msp-schedule.js +339 -0
- package/dist/operations/analytics-msp-schedule.js.map +1 -0
- package/dist/operations/analytics-msp-team.d.ts +3 -0
- package/dist/operations/analytics-msp-team.d.ts.map +1 -0
- package/dist/operations/analytics-msp-team.js +195 -0
- package/dist/operations/analytics-msp-team.js.map +1 -0
- package/dist/operations/analytics-msp-tickets.d.ts +3 -0
- package/dist/operations/analytics-msp-tickets.d.ts.map +1 -0
- package/dist/operations/analytics-msp-tickets.js +578 -0
- package/dist/operations/analytics-msp-tickets.js.map +1 -0
- package/dist/operations/analytics-msp-time.d.ts +3 -0
- package/dist/operations/analytics-msp-time.d.ts.map +1 -0
- package/dist/operations/analytics-msp-time.js +485 -0
- package/dist/operations/analytics-msp-time.js.map +1 -0
- package/dist/operations/analytics-msp-utils.d.ts +49 -0
- package/dist/operations/analytics-msp-utils.d.ts.map +1 -0
- package/dist/operations/analytics-msp-utils.js +157 -0
- package/dist/operations/analytics-msp-utils.js.map +1 -0
- package/dist/operations/analytics.d.ts +9 -0
- package/dist/operations/analytics.d.ts.map +1 -0
- package/dist/operations/analytics.js +742 -0
- package/dist/operations/analytics.js.map +1 -0
- package/dist/operations/executor.d.ts +10 -0
- package/dist/operations/executor.d.ts.map +1 -0
- package/dist/operations/executor.js +243 -0
- package/dist/operations/executor.js.map +1 -0
- package/dist/operations/registry.d.ts +16 -0
- package/dist/operations/registry.d.ts.map +1 -0
- package/dist/operations/registry.js +847 -0
- package/dist/operations/registry.js.map +1 -0
- package/dist/services/api-database.d.ts +38 -0
- package/dist/services/api-database.d.ts.map +1 -0
- package/dist/services/api-database.js +191 -0
- package/dist/services/api-database.js.map +1 -0
- package/dist/services/cache.d.ts +12 -0
- package/dist/services/cache.d.ts.map +1 -0
- package/dist/services/cache.js +32 -0
- package/dist/services/cache.js.map +1 -0
- package/dist/services/connectwise-api.d.ts +43 -0
- package/dist/services/connectwise-api.d.ts.map +1 -0
- package/dist/services/connectwise-api.js +198 -0
- package/dist/services/connectwise-api.js.map +1 -0
- package/dist/services/db-builder.d.ts +11 -0
- package/dist/services/db-builder.d.ts.map +1 -0
- package/dist/services/db-builder.js +237 -0
- package/dist/services/db-builder.js.map +1 -0
- package/dist/services/fast-memory.d.ts +39 -0
- package/dist/services/fast-memory.d.ts.map +1 -0
- package/dist/services/fast-memory.js +147 -0
- package/dist/services/fast-memory.js.map +1 -0
- package/dist/services/load-env.d.ts +15 -0
- package/dist/services/load-env.d.ts.map +1 -0
- package/dist/services/load-env.js +59 -0
- package/dist/services/load-env.js.map +1 -0
- package/dist/tools/batch.d.ts +9 -0
- package/dist/tools/batch.d.ts.map +1 -0
- package/dist/tools/batch.js +159 -0
- package/dist/tools/batch.js.map +1 -0
- package/dist/tools/composite.d.ts +9 -0
- package/dist/tools/composite.d.ts.map +1 -0
- package/dist/tools/composite.js +353 -0
- package/dist/tools/composite.js.map +1 -0
- package/dist/tools/discovery.d.ts +9 -0
- package/dist/tools/discovery.d.ts.map +1 -0
- package/dist/tools/discovery.js +245 -0
- package/dist/tools/discovery.js.map +1 -0
- package/dist/tools/execution.d.ts +9 -0
- package/dist/tools/execution.d.ts.map +1 -0
- package/dist/tools/execution.js +130 -0
- package/dist/tools/execution.js.map +1 -0
- package/dist/tools/memory.d.ts +9 -0
- package/dist/tools/memory.d.ts.map +1 -0
- package/dist/tools/memory.js +152 -0
- package/dist/tools/memory.js.map +1 -0
- package/dist/tools/operations.d.ts +9 -0
- package/dist/tools/operations.d.ts.map +1 -0
- package/dist/tools/operations.js +214 -0
- package/dist/tools/operations.js.map +1 -0
- package/dist/tools/pagination.d.ts +9 -0
- package/dist/tools/pagination.d.ts.map +1 -0
- package/dist/tools/pagination.js +133 -0
- package/dist/tools/pagination.js.map +1 -0
- package/dist/tools/validation.d.ts +9 -0
- package/dist/tools/validation.d.ts.map +1 -0
- package/dist/tools/validation.js +705 -0
- package/dist/tools/validation.js.map +1 -0
- package/dist/types/index.d.ts +145 -0
- package/dist/types/index.d.ts.map +1 -0
- package/dist/types/index.js +3 -0
- package/dist/types/index.js.map +1 -0
- package/dist/types/operations.d.ts +30 -0
- package/dist/types/operations.d.ts.map +1 -0
- package/dist/types/operations.js +3 -0
- package/dist/types/operations.js.map +1 -0
- package/dist/utils/conditions.d.ts +20 -0
- package/dist/utils/conditions.d.ts.map +1 -0
- package/dist/utils/conditions.js +78 -0
- package/dist/utils/conditions.js.map +1 -0
- package/dist/utils/formatters.d.ts +35 -0
- package/dist/utils/formatters.d.ts.map +1 -0
- package/dist/utils/formatters.js +337 -0
- package/dist/utils/formatters.js.map +1 -0
- package/package.json +46 -0
|
@@ -0,0 +1,742 @@
|
|
|
1
|
+
// ConnectWise PSA MCP Server — Analytics Handlers
|
|
2
|
+
//
|
|
3
|
+
// Each handler fetches data from the CW API, computes metrics, and returns
|
|
4
|
+
// pre-formatted markdown. Handlers are registered by name and dispatched
|
|
5
|
+
// from the executor.
|
|
6
|
+
import { getAPI } from '../services/connectwise-api.js';
|
|
7
|
+
import { extendedHandlers } from './analytics-extended.js';
|
|
8
|
+
import { mspTimeHandlers } from './analytics-msp-time.js';
|
|
9
|
+
import { mspTicketHandlers } from './analytics-msp-tickets.js';
|
|
10
|
+
import { mspFinancialHandlers } from './analytics-msp-financial.js';
|
|
11
|
+
import { mspScheduleHandlers } from './analytics-msp-schedule.js';
|
|
12
|
+
import { mspClientHandlers } from './analytics-msp-clients.js';
|
|
13
|
+
import { mspTeamHandlers } from './analytics-msp-team.js';
|
|
14
|
+
import { mspAssetHandlers } from './analytics-msp-assets.js';
|
|
15
|
+
import { mspProjectHandlers } from './analytics-msp-projects.js';
|
|
16
|
+
import { mspSalesHandlers } from './analytics-msp-sales.js';
|
|
17
|
+
import { mspProcurementHandlers } from './analytics-msp-procurement.js';
|
|
18
|
+
import { mspContractHandlers } from './analytics-msp-contracts.js';
|
|
19
|
+
import { mspCommsHandlers } from './analytics-msp-comms.js';
|
|
20
|
+
// Merged MSP handler map for O(1) lookup
|
|
21
|
+
const mspHandlers = {
|
|
22
|
+
...mspTimeHandlers, ...mspTicketHandlers, ...mspFinancialHandlers,
|
|
23
|
+
...mspScheduleHandlers, ...mspClientHandlers, ...mspTeamHandlers,
|
|
24
|
+
...mspAssetHandlers, ...mspProjectHandlers, ...mspSalesHandlers,
|
|
25
|
+
...mspProcurementHandlers, ...mspContractHandlers, ...mspCommsHandlers,
|
|
26
|
+
};
|
|
27
|
+
// ---------------------------------------------------------------------------
|
|
28
|
+
// Helpers
|
|
29
|
+
// ---------------------------------------------------------------------------
|
|
30
|
+
function num(v, fallback = 0) {
|
|
31
|
+
const n = Number(v);
|
|
32
|
+
return Number.isFinite(n) ? n : fallback;
|
|
33
|
+
}
|
|
34
|
+
function nested(obj, field, sub) {
|
|
35
|
+
const child = obj[field];
|
|
36
|
+
if (child && typeof child === 'object' && sub in child) {
|
|
37
|
+
return String(child[sub] ?? 'N/A');
|
|
38
|
+
}
|
|
39
|
+
return 'N/A';
|
|
40
|
+
}
|
|
41
|
+
function daysSince(iso) {
|
|
42
|
+
if (!iso)
|
|
43
|
+
return 0;
|
|
44
|
+
return Math.floor((Date.now() - new Date(iso).getTime()) / 86400000);
|
|
45
|
+
}
|
|
46
|
+
function daysAgo(n) {
|
|
47
|
+
const d = new Date();
|
|
48
|
+
d.setDate(d.getDate() - n);
|
|
49
|
+
return d.toISOString().split('T')[0] + 'T00:00:00Z';
|
|
50
|
+
}
|
|
51
|
+
function today() {
|
|
52
|
+
return new Date().toISOString().split('T')[0] + 'T00:00:00Z';
|
|
53
|
+
}
|
|
54
|
+
function round2(n) {
|
|
55
|
+
return Math.round(n * 100) / 100;
|
|
56
|
+
}
|
|
57
|
+
const handlers = {};
|
|
58
|
+
function register(name, handler) {
|
|
59
|
+
handlers[name] = handler;
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Run an analytics handler by name. Falls back to a stub if the handler
|
|
63
|
+
* is not yet implemented.
|
|
64
|
+
*/
|
|
65
|
+
export async function runAnalytics(op, params) {
|
|
66
|
+
const handler = handlers[op.handler ?? op.name];
|
|
67
|
+
if (handler)
|
|
68
|
+
return handler(op, params);
|
|
69
|
+
// Check extended handlers (analytics-extended.ts)
|
|
70
|
+
const extHandler = extendedHandlers[op.handler ?? op.name];
|
|
71
|
+
if (extHandler)
|
|
72
|
+
return extHandler(params);
|
|
73
|
+
// Check MSP analytics handlers (54 handlers across 12 domain files)
|
|
74
|
+
const mspHandler = mspHandlers[op.handler ?? op.name];
|
|
75
|
+
if (mspHandler)
|
|
76
|
+
return mspHandler(params);
|
|
77
|
+
// Stub for unimplemented handlers
|
|
78
|
+
return [
|
|
79
|
+
`## ${op.description}`,
|
|
80
|
+
'',
|
|
81
|
+
`> **Note:** The "${op.name}" analytics handler is defined but not yet implemented.`,
|
|
82
|
+
'> This operation requires custom computation logic.',
|
|
83
|
+
'> You can achieve similar results using the underlying API calls directly:',
|
|
84
|
+
'>',
|
|
85
|
+
`> - Category: ${op.category}`,
|
|
86
|
+
`> - Tier: ${op.tier}`,
|
|
87
|
+
op.handler ? `> - Handler: ${op.handler}` : '',
|
|
88
|
+
].filter(Boolean).join('\n');
|
|
89
|
+
}
|
|
90
|
+
// ===========================================================================
|
|
91
|
+
// IMPLEMENTED ANALYTICS HANDLERS
|
|
92
|
+
// ===========================================================================
|
|
93
|
+
// ---------------------------------------------------------------------------
|
|
94
|
+
// stale_ticket_report — tickets with no updates in N days
|
|
95
|
+
// ---------------------------------------------------------------------------
|
|
96
|
+
register('stale_ticket_report', async (_op, params) => {
|
|
97
|
+
const api = getAPI();
|
|
98
|
+
const days = num(params['days'], 14);
|
|
99
|
+
const cutoff = daysAgo(days);
|
|
100
|
+
const result = await api.paginatedFetch('/service/tickets', `closedFlag=false AND lastUpdated<=[${cutoff}]`, 'id,summary,status/name,board/name,priority/name,company/name,lastUpdated,resources', 500);
|
|
101
|
+
const tickets = result.items;
|
|
102
|
+
if (tickets.length === 0)
|
|
103
|
+
return `No stale tickets found (threshold: ${days} days).`;
|
|
104
|
+
const lines = [
|
|
105
|
+
`## Stale Ticket Report (no updates in ${days}+ days)`,
|
|
106
|
+
'',
|
|
107
|
+
`Found **${tickets.length}** stale ticket(s):`,
|
|
108
|
+
'',
|
|
109
|
+
'| # | Summary | Board | Priority | Company | Last Updated | Days Stale |',
|
|
110
|
+
'|---|---------|-------|----------|---------|-------------|------------|',
|
|
111
|
+
];
|
|
112
|
+
for (const t of tickets.slice(0, 50)) {
|
|
113
|
+
const stale = daysSince(t.lastUpdated);
|
|
114
|
+
lines.push(`| ${t.id} | ${String(t.summary ?? '').substring(0, 50)} | ${nested(t, 'board', 'name')} | ${nested(t, 'priority', 'name')} | ${nested(t, 'company', 'name')} | ${String(t.lastUpdated ?? '').substring(0, 10)} | ${stale}d |`);
|
|
115
|
+
}
|
|
116
|
+
if (tickets.length > 50)
|
|
117
|
+
lines.push(`\n*... and ${tickets.length - 50} more*`);
|
|
118
|
+
return lines.join('\n');
|
|
119
|
+
});
|
|
120
|
+
// ---------------------------------------------------------------------------
|
|
121
|
+
// open_ticket_age_distribution — bucket open tickets by age
|
|
122
|
+
// ---------------------------------------------------------------------------
|
|
123
|
+
register('open_ticket_age_distribution', async () => {
|
|
124
|
+
const api = getAPI();
|
|
125
|
+
const result = await api.paginatedFetch('/service/tickets', 'closedFlag=false', 'id,dateEntered,priority/name,board/name', 2000);
|
|
126
|
+
const buckets = {
|
|
127
|
+
'0-1 days': 0, '2-3 days': 0, '4-7 days': 0, '8-14 days': 0,
|
|
128
|
+
'15-30 days': 0, '31-60 days': 0, '60+ days': 0,
|
|
129
|
+
};
|
|
130
|
+
for (const t of result.items) {
|
|
131
|
+
const age = daysSince(t.dateEntered);
|
|
132
|
+
if (age <= 1)
|
|
133
|
+
buckets['0-1 days']++;
|
|
134
|
+
else if (age <= 3)
|
|
135
|
+
buckets['2-3 days']++;
|
|
136
|
+
else if (age <= 7)
|
|
137
|
+
buckets['4-7 days']++;
|
|
138
|
+
else if (age <= 14)
|
|
139
|
+
buckets['8-14 days']++;
|
|
140
|
+
else if (age <= 30)
|
|
141
|
+
buckets['15-30 days']++;
|
|
142
|
+
else if (age <= 60)
|
|
143
|
+
buckets['31-60 days']++;
|
|
144
|
+
else
|
|
145
|
+
buckets['60+ days']++;
|
|
146
|
+
}
|
|
147
|
+
const total = result.items.length;
|
|
148
|
+
const lines = [
|
|
149
|
+
`## Open Ticket Age Distribution (${total} tickets)`,
|
|
150
|
+
'',
|
|
151
|
+
'| Age Bracket | Count | % |',
|
|
152
|
+
'|-------------|-------|---|',
|
|
153
|
+
];
|
|
154
|
+
for (const [bracket, count] of Object.entries(buckets)) {
|
|
155
|
+
const pct = total > 0 ? round2((count / total) * 100) : 0;
|
|
156
|
+
lines.push(`| ${bracket} | ${count} | ${pct}% |`);
|
|
157
|
+
}
|
|
158
|
+
return lines.join('\n');
|
|
159
|
+
});
|
|
160
|
+
// ---------------------------------------------------------------------------
|
|
161
|
+
// team_workload_balance — open tickets per member
|
|
162
|
+
// ---------------------------------------------------------------------------
|
|
163
|
+
register('team_workload_balance', async () => {
|
|
164
|
+
const api = getAPI();
|
|
165
|
+
const result = await api.paginatedFetch('/service/tickets', 'closedFlag=false', 'id,resources,priority/name,board/name', 2000);
|
|
166
|
+
const memberCounts = {};
|
|
167
|
+
let unassigned = 0;
|
|
168
|
+
for (const t of result.items) {
|
|
169
|
+
const resources = String(t.resources ?? '');
|
|
170
|
+
if (!resources.trim()) {
|
|
171
|
+
unassigned++;
|
|
172
|
+
continue;
|
|
173
|
+
}
|
|
174
|
+
// Resources is a comma-separated string of identifiers
|
|
175
|
+
for (const member of resources.split(',').map(s => s.trim()).filter(Boolean)) {
|
|
176
|
+
if (!memberCounts[member])
|
|
177
|
+
memberCounts[member] = { total: 0, high: 0 };
|
|
178
|
+
memberCounts[member].total++;
|
|
179
|
+
const priority = nested(t, 'priority', 'name').toLowerCase();
|
|
180
|
+
if (priority.includes('critical') || priority.includes('high') || priority.includes('emergency')) {
|
|
181
|
+
memberCounts[member].high++;
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
const sorted = Object.entries(memberCounts).sort((a, b) => b[1].total - a[1].total);
|
|
186
|
+
const lines = [
|
|
187
|
+
`## Team Workload Balance (${result.items.length} open tickets)`,
|
|
188
|
+
'',
|
|
189
|
+
'| Member | Open Tickets | High/Critical | Balance |',
|
|
190
|
+
'|--------|-------------|---------------|---------|',
|
|
191
|
+
];
|
|
192
|
+
const avg = sorted.length > 0 ? result.items.length / sorted.length : 0;
|
|
193
|
+
for (const [member, counts] of sorted) {
|
|
194
|
+
const balance = avg > 0 ? (counts.total / avg * 100 - 100).toFixed(0) : '0';
|
|
195
|
+
const indicator = Number(balance) > 30 ? 'OVER' : Number(balance) < -30 ? 'UNDER' : 'OK';
|
|
196
|
+
lines.push(`| ${member} | ${counts.total} | ${counts.high} | ${indicator} (${Number(balance) > 0 ? '+' : ''}${balance}%) |`);
|
|
197
|
+
}
|
|
198
|
+
if (unassigned > 0)
|
|
199
|
+
lines.push(`| *Unassigned* | ${unassigned} | - | - |`);
|
|
200
|
+
lines.push(`\n**Average:** ${round2(avg)} tickets/member`);
|
|
201
|
+
return lines.join('\n');
|
|
202
|
+
});
|
|
203
|
+
// ---------------------------------------------------------------------------
|
|
204
|
+
// open_ticket_summary — by board, priority, and company
|
|
205
|
+
// ---------------------------------------------------------------------------
|
|
206
|
+
register('open_ticket_summary', async () => {
|
|
207
|
+
const api = getAPI();
|
|
208
|
+
const result = await api.paginatedFetch('/service/tickets', 'closedFlag=false', 'id,board/name,priority/name,company/name,status/name', 2000);
|
|
209
|
+
const byBoard = {};
|
|
210
|
+
const byPriority = {};
|
|
211
|
+
const byCompany = {};
|
|
212
|
+
for (const t of result.items) {
|
|
213
|
+
const board = nested(t, 'board', 'name');
|
|
214
|
+
const priority = nested(t, 'priority', 'name');
|
|
215
|
+
const company = nested(t, 'company', 'name');
|
|
216
|
+
byBoard[board] = (byBoard[board] ?? 0) + 1;
|
|
217
|
+
byPriority[priority] = (byPriority[priority] ?? 0) + 1;
|
|
218
|
+
byCompany[company] = (byCompany[company] ?? 0) + 1;
|
|
219
|
+
}
|
|
220
|
+
const lines = [`## Open Ticket Summary (${result.items.length} total)\n`];
|
|
221
|
+
lines.push('### By Board');
|
|
222
|
+
lines.push('| Board | Count |');
|
|
223
|
+
lines.push('|-------|-------|');
|
|
224
|
+
for (const [k, v] of Object.entries(byBoard).sort((a, b) => b[1] - a[1])) {
|
|
225
|
+
lines.push(`| ${k} | ${v} |`);
|
|
226
|
+
}
|
|
227
|
+
lines.push('\n### By Priority');
|
|
228
|
+
lines.push('| Priority | Count |');
|
|
229
|
+
lines.push('|----------|-------|');
|
|
230
|
+
for (const [k, v] of Object.entries(byPriority).sort((a, b) => b[1] - a[1])) {
|
|
231
|
+
lines.push(`| ${k} | ${v} |`);
|
|
232
|
+
}
|
|
233
|
+
lines.push('\n### Top 15 Companies');
|
|
234
|
+
lines.push('| Company | Open Tickets |');
|
|
235
|
+
lines.push('|---------|-------------|');
|
|
236
|
+
for (const [k, v] of Object.entries(byCompany).sort((a, b) => b[1] - a[1]).slice(0, 15)) {
|
|
237
|
+
lines.push(`| ${k} | ${v} |`);
|
|
238
|
+
}
|
|
239
|
+
return lines.join('\n');
|
|
240
|
+
});
|
|
241
|
+
// ---------------------------------------------------------------------------
|
|
242
|
+
// unassigned_ticket_report
|
|
243
|
+
// ---------------------------------------------------------------------------
|
|
244
|
+
register('unassigned_ticket_report', async () => {
|
|
245
|
+
const api = getAPI();
|
|
246
|
+
const result = await api.paginatedFetch('/service/tickets', "closedFlag=false AND resources=null", 'id,summary,board/name,priority/name,company/name,dateEntered', 500);
|
|
247
|
+
if (result.items.length === 0)
|
|
248
|
+
return 'No unassigned tickets found.';
|
|
249
|
+
const lines = [
|
|
250
|
+
`## Unassigned Tickets (${result.items.length})`,
|
|
251
|
+
'',
|
|
252
|
+
'| # | Summary | Board | Priority | Company | Age |',
|
|
253
|
+
'|---|---------|-------|----------|---------|-----|',
|
|
254
|
+
];
|
|
255
|
+
for (const t of result.items.slice(0, 50)) {
|
|
256
|
+
const age = daysSince(t.dateEntered);
|
|
257
|
+
lines.push(`| ${t.id} | ${String(t.summary ?? '').substring(0, 50)} | ${nested(t, 'board', 'name')} | ${nested(t, 'priority', 'name')} | ${nested(t, 'company', 'name')} | ${age}d |`);
|
|
258
|
+
}
|
|
259
|
+
return lines.join('\n');
|
|
260
|
+
});
|
|
261
|
+
// ---------------------------------------------------------------------------
|
|
262
|
+
// ticket_aging_report
|
|
263
|
+
// ---------------------------------------------------------------------------
|
|
264
|
+
register('ticket_aging_report', async () => {
|
|
265
|
+
const api = getAPI();
|
|
266
|
+
const result = await api.paginatedFetch('/service/tickets', 'closedFlag=false', 'id,summary,board/name,priority/name,company/name,dateEntered,lastUpdated,resources', 1000);
|
|
267
|
+
const tickets = result.items.sort((a, b) => new Date(a.dateEntered).getTime() - new Date(b.dateEntered).getTime());
|
|
268
|
+
if (tickets.length === 0)
|
|
269
|
+
return 'No open tickets found.';
|
|
270
|
+
const lines = [
|
|
271
|
+
`## Ticket Aging Report (${tickets.length} open tickets, oldest first)`,
|
|
272
|
+
'',
|
|
273
|
+
'| # | Summary | Board | Priority | Company | Age | Last Update |',
|
|
274
|
+
'|---|---------|-------|----------|---------|-----|------------|',
|
|
275
|
+
];
|
|
276
|
+
for (const t of tickets.slice(0, 50)) {
|
|
277
|
+
const age = daysSince(t.dateEntered);
|
|
278
|
+
const lastUpdate = daysSince(t.lastUpdated);
|
|
279
|
+
lines.push(`| ${t.id} | ${String(t.summary ?? '').substring(0, 40)} | ${nested(t, 'board', 'name')} | ${nested(t, 'priority', 'name')} | ${nested(t, 'company', 'name')} | ${age}d | ${lastUpdate}d ago |`);
|
|
280
|
+
}
|
|
281
|
+
if (tickets.length > 50)
|
|
282
|
+
lines.push(`\n*... and ${tickets.length - 50} more*`);
|
|
283
|
+
return lines.join('\n');
|
|
284
|
+
});
|
|
285
|
+
// ---------------------------------------------------------------------------
|
|
286
|
+
// priority_distribution
|
|
287
|
+
// ---------------------------------------------------------------------------
|
|
288
|
+
register('priority_distribution', async () => {
|
|
289
|
+
const api = getAPI();
|
|
290
|
+
const result = await api.paginatedFetch('/service/tickets', 'closedFlag=false', 'id,priority/name,priority/id', 2000);
|
|
291
|
+
const dist = {};
|
|
292
|
+
for (const t of result.items) {
|
|
293
|
+
const p = nested(t, 'priority', 'name');
|
|
294
|
+
dist[p] = (dist[p] ?? 0) + 1;
|
|
295
|
+
}
|
|
296
|
+
const total = result.items.length;
|
|
297
|
+
const lines = [
|
|
298
|
+
`## Priority Distribution (${total} open tickets)`,
|
|
299
|
+
'',
|
|
300
|
+
'| Priority | Count | % |',
|
|
301
|
+
'|----------|-------|---|',
|
|
302
|
+
];
|
|
303
|
+
for (const [k, v] of Object.entries(dist).sort((a, b) => b[1] - a[1])) {
|
|
304
|
+
lines.push(`| ${k} | ${v} | ${round2((v / total) * 100)}% |`);
|
|
305
|
+
}
|
|
306
|
+
return lines.join('\n');
|
|
307
|
+
});
|
|
308
|
+
// ---------------------------------------------------------------------------
|
|
309
|
+
// ticket_volume_trends — daily ticket creation over N days
|
|
310
|
+
// ---------------------------------------------------------------------------
|
|
311
|
+
register('ticket_volume_trends', async (_op, params) => {
|
|
312
|
+
const api = getAPI();
|
|
313
|
+
const days = num(params['days'], 30);
|
|
314
|
+
const start = daysAgo(days);
|
|
315
|
+
const result = await api.paginatedFetch('/service/tickets', `dateEntered>=[${start}]`, 'id,dateEntered', 5000);
|
|
316
|
+
// Group by date
|
|
317
|
+
const byDate = {};
|
|
318
|
+
for (let i = 0; i < days; i++) {
|
|
319
|
+
const d = new Date();
|
|
320
|
+
d.setDate(d.getDate() - (days - 1 - i));
|
|
321
|
+
byDate[d.toISOString().split('T')[0]] = 0;
|
|
322
|
+
}
|
|
323
|
+
for (const t of result.items) {
|
|
324
|
+
const date = String(t.dateEntered ?? '').substring(0, 10);
|
|
325
|
+
if (date in byDate)
|
|
326
|
+
byDate[date]++;
|
|
327
|
+
}
|
|
328
|
+
const values = Object.values(byDate);
|
|
329
|
+
const avg = values.length > 0 ? round2(values.reduce((a, b) => a + b, 0) / values.length) : 0;
|
|
330
|
+
const max = Math.max(...values);
|
|
331
|
+
const lines = [
|
|
332
|
+
`## Ticket Volume Trends (last ${days} days)`,
|
|
333
|
+
'',
|
|
334
|
+
`**Total:** ${result.items.length} | **Daily Avg:** ${avg} | **Peak:** ${max}`,
|
|
335
|
+
'',
|
|
336
|
+
'| Date | Created |',
|
|
337
|
+
'|------|---------|',
|
|
338
|
+
];
|
|
339
|
+
// Show last 14 days in table, rest in summary
|
|
340
|
+
const dates = Object.entries(byDate).slice(-14);
|
|
341
|
+
for (const [date, count] of dates) {
|
|
342
|
+
const bar = '|'.repeat(Math.min(count, 30));
|
|
343
|
+
lines.push(`| ${date} | ${count} ${bar} |`);
|
|
344
|
+
}
|
|
345
|
+
if (days > 14)
|
|
346
|
+
lines.push(`\n*Showing last 14 of ${days} days*`);
|
|
347
|
+
return lines.join('\n');
|
|
348
|
+
});
|
|
349
|
+
// ---------------------------------------------------------------------------
|
|
350
|
+
// ticket_source_analysis
|
|
351
|
+
// ---------------------------------------------------------------------------
|
|
352
|
+
register('ticket_source_analysis', async (_op, params) => {
|
|
353
|
+
const api = getAPI();
|
|
354
|
+
const days = num(params['days'], 30);
|
|
355
|
+
const start = daysAgo(days);
|
|
356
|
+
const result = await api.paginatedFetch('/service/tickets', `dateEntered>=[${start}]`, 'id,source/name', 5000);
|
|
357
|
+
const bySrc = {};
|
|
358
|
+
for (const t of result.items) {
|
|
359
|
+
const src = nested(t, 'source', 'name') || 'Unknown';
|
|
360
|
+
bySrc[src] = (bySrc[src] ?? 0) + 1;
|
|
361
|
+
}
|
|
362
|
+
const total = result.items.length;
|
|
363
|
+
const lines = [
|
|
364
|
+
`## Ticket Source Analysis (last ${days} days, ${total} tickets)`,
|
|
365
|
+
'',
|
|
366
|
+
'| Source | Count | % |',
|
|
367
|
+
'|--------|-------|---|',
|
|
368
|
+
];
|
|
369
|
+
for (const [k, v] of Object.entries(bySrc).sort((a, b) => b[1] - a[1])) {
|
|
370
|
+
lines.push(`| ${k} | ${v} | ${round2((v / total) * 100)}% |`);
|
|
371
|
+
}
|
|
372
|
+
return lines.join('\n');
|
|
373
|
+
});
|
|
374
|
+
// ---------------------------------------------------------------------------
|
|
375
|
+
// ticket_resolution_analytics
|
|
376
|
+
// ---------------------------------------------------------------------------
|
|
377
|
+
register('ticket_resolution_analytics', async (_op, params) => {
|
|
378
|
+
const api = getAPI();
|
|
379
|
+
const days = num(params['days'], 30);
|
|
380
|
+
const start = daysAgo(days);
|
|
381
|
+
const result = await api.paginatedFetch('/service/tickets', `closedFlag=true AND closedDate>=[${start}]`, 'id,board/name,priority/name,dateEntered,closedDate', 2000);
|
|
382
|
+
const byBoard = {};
|
|
383
|
+
const byPriority = {};
|
|
384
|
+
for (const t of result.items) {
|
|
385
|
+
const entered = new Date(t.dateEntered).getTime();
|
|
386
|
+
const closed = new Date(t.closedDate).getTime();
|
|
387
|
+
const hours = (closed - entered) / 3600000;
|
|
388
|
+
if (hours < 0)
|
|
389
|
+
continue;
|
|
390
|
+
const board = nested(t, 'board', 'name');
|
|
391
|
+
const priority = nested(t, 'priority', 'name');
|
|
392
|
+
(byBoard[board] ??= []).push(hours);
|
|
393
|
+
(byPriority[priority] ??= []).push(hours);
|
|
394
|
+
}
|
|
395
|
+
const avg = (arr) => arr.length > 0 ? round2(arr.reduce((a, b) => a + b, 0) / arr.length) : 0;
|
|
396
|
+
const median = (arr) => {
|
|
397
|
+
if (arr.length === 0)
|
|
398
|
+
return 0;
|
|
399
|
+
const sorted = [...arr].sort((a, b) => a - b);
|
|
400
|
+
const mid = Math.floor(sorted.length / 2);
|
|
401
|
+
return round2(sorted.length % 2 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2);
|
|
402
|
+
};
|
|
403
|
+
const lines = [
|
|
404
|
+
`## Ticket Resolution Analytics (last ${days} days, ${result.items.length} closed tickets)`,
|
|
405
|
+
'',
|
|
406
|
+
'### By Board',
|
|
407
|
+
'| Board | Tickets | Avg Hours | Median Hours |',
|
|
408
|
+
'|-------|---------|-----------|-------------|',
|
|
409
|
+
];
|
|
410
|
+
for (const [k, v] of Object.entries(byBoard).sort((a, b) => b[1].length - a[1].length)) {
|
|
411
|
+
lines.push(`| ${k} | ${v.length} | ${avg(v)}h | ${median(v)}h |`);
|
|
412
|
+
}
|
|
413
|
+
lines.push('\n### By Priority');
|
|
414
|
+
lines.push('| Priority | Tickets | Avg Hours | Median Hours |');
|
|
415
|
+
lines.push('|----------|---------|-----------|-------------|');
|
|
416
|
+
for (const [k, v] of Object.entries(byPriority).sort((a, b) => b[1].length - a[1].length)) {
|
|
417
|
+
lines.push(`| ${k} | ${v.length} | ${avg(v)}h | ${median(v)}h |`);
|
|
418
|
+
}
|
|
419
|
+
return lines.join('\n');
|
|
420
|
+
});
|
|
421
|
+
// ---------------------------------------------------------------------------
|
|
422
|
+
// board_performance_comparison
|
|
423
|
+
// ---------------------------------------------------------------------------
|
|
424
|
+
register('board_performance_comparison', async () => {
|
|
425
|
+
const api = getAPI();
|
|
426
|
+
const [openResult, closedResult] = await Promise.all([
|
|
427
|
+
api.paginatedFetch('/service/tickets', 'closedFlag=false', 'id,board/name,dateEntered,priority/name', 2000),
|
|
428
|
+
api.paginatedFetch('/service/tickets', `closedFlag=true AND closedDate>=[${daysAgo(30)}]`, 'id,board/name', 2000),
|
|
429
|
+
]);
|
|
430
|
+
const boards = {};
|
|
431
|
+
for (const t of openResult.items) {
|
|
432
|
+
const board = nested(t, 'board', 'name');
|
|
433
|
+
if (!boards[board])
|
|
434
|
+
boards[board] = { open: 0, closed30d: 0, avgAge: [], high: 0 };
|
|
435
|
+
boards[board].open++;
|
|
436
|
+
boards[board].avgAge.push(daysSince(t.dateEntered));
|
|
437
|
+
const p = nested(t, 'priority', 'name').toLowerCase();
|
|
438
|
+
if (p.includes('critical') || p.includes('high'))
|
|
439
|
+
boards[board].high++;
|
|
440
|
+
}
|
|
441
|
+
for (const t of closedResult.items) {
|
|
442
|
+
const board = nested(t, 'board', 'name');
|
|
443
|
+
if (!boards[board])
|
|
444
|
+
boards[board] = { open: 0, closed30d: 0, avgAge: [], high: 0 };
|
|
445
|
+
boards[board].closed30d++;
|
|
446
|
+
}
|
|
447
|
+
const lines = [
|
|
448
|
+
'## Board Performance Comparison',
|
|
449
|
+
'',
|
|
450
|
+
'| Board | Open | Closed (30d) | Avg Age | High/Critical |',
|
|
451
|
+
'|-------|------|-------------|---------|---------------|',
|
|
452
|
+
];
|
|
453
|
+
for (const [name, data] of Object.entries(boards).sort((a, b) => b[1].open - a[1].open)) {
|
|
454
|
+
const avgAge = data.avgAge.length > 0 ? round2(data.avgAge.reduce((a, b) => a + b, 0) / data.avgAge.length) : 0;
|
|
455
|
+
lines.push(`| ${name} | ${data.open} | ${data.closed30d} | ${avgAge}d | ${data.high} |`);
|
|
456
|
+
}
|
|
457
|
+
return lines.join('\n');
|
|
458
|
+
});
|
|
459
|
+
// ---------------------------------------------------------------------------
|
|
460
|
+
// first_response_time_analytics
|
|
461
|
+
// ---------------------------------------------------------------------------
|
|
462
|
+
register('first_response_time_analytics', async (_op, params) => {
|
|
463
|
+
const api = getAPI();
|
|
464
|
+
const days = num(params['days'], 30);
|
|
465
|
+
// Get recent tickets with notes to calculate first response
|
|
466
|
+
const tickets = await api.paginatedFetch('/service/tickets', `dateEntered>=[${daysAgo(days)}]`, 'id,dateEntered,board/name,priority/name', 500);
|
|
467
|
+
if (tickets.items.length === 0)
|
|
468
|
+
return `No tickets found in the last ${days} days.`;
|
|
469
|
+
// Sample first 100 tickets for response time analysis
|
|
470
|
+
const sample = tickets.items.slice(0, 100);
|
|
471
|
+
const responseTimes = [];
|
|
472
|
+
const byBoard = {};
|
|
473
|
+
const noteResults = await Promise.allSettled(sample.map(async (t) => {
|
|
474
|
+
const notes = await api.request({
|
|
475
|
+
path: `/service/tickets/${t.id}/notes`,
|
|
476
|
+
method: 'GET',
|
|
477
|
+
params: { pageSize: 5, orderBy: 'dateCreated asc' },
|
|
478
|
+
});
|
|
479
|
+
return { ticket: t, notes: notes.data };
|
|
480
|
+
}));
|
|
481
|
+
for (const r of noteResults) {
|
|
482
|
+
if (r.status !== 'fulfilled' || !r.value.notes.length)
|
|
483
|
+
continue;
|
|
484
|
+
const { ticket, notes } = r.value;
|
|
485
|
+
const created = new Date(ticket.dateEntered).getTime();
|
|
486
|
+
const firstNote = new Date(notes[0].dateCreated).getTime();
|
|
487
|
+
const minutes = (firstNote - created) / 60000;
|
|
488
|
+
if (minutes >= 0 && minutes < 10080) { // within 7 days
|
|
489
|
+
responseTimes.push(minutes);
|
|
490
|
+
const board = nested(ticket, 'board', 'name');
|
|
491
|
+
(byBoard[board] ??= []).push(minutes);
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
if (responseTimes.length === 0)
|
|
495
|
+
return 'Not enough data for first response time analysis.';
|
|
496
|
+
const avg = round2(responseTimes.reduce((a, b) => a + b, 0) / responseTimes.length);
|
|
497
|
+
const sorted = [...responseTimes].sort((a, b) => a - b);
|
|
498
|
+
const med = round2(sorted[Math.floor(sorted.length / 2)]);
|
|
499
|
+
const lines = [
|
|
500
|
+
`## First Response Time Analytics (last ${days} days, ${responseTimes.length} tickets sampled)`,
|
|
501
|
+
'',
|
|
502
|
+
`**Average:** ${avg} min (${round2(avg / 60)}h) | **Median:** ${med} min (${round2(med / 60)}h)`,
|
|
503
|
+
'',
|
|
504
|
+
'### By Board',
|
|
505
|
+
'| Board | Samples | Avg (min) | Median (min) |',
|
|
506
|
+
'|-------|---------|-----------|-------------|',
|
|
507
|
+
];
|
|
508
|
+
for (const [board, times] of Object.entries(byBoard).sort((a, b) => b[1].length - a[1].length)) {
|
|
509
|
+
const boardAvg = round2(times.reduce((a, b) => a + b, 0) / times.length);
|
|
510
|
+
const boardSorted = [...times].sort((a, b) => a - b);
|
|
511
|
+
const boardMed = round2(boardSorted[Math.floor(boardSorted.length / 2)]);
|
|
512
|
+
lines.push(`| ${board} | ${times.length} | ${boardAvg} | ${boardMed} |`);
|
|
513
|
+
}
|
|
514
|
+
return lines.join('\n');
|
|
515
|
+
});
|
|
516
|
+
// ---------------------------------------------------------------------------
|
|
517
|
+
// company_360_view — complete company overview
|
|
518
|
+
// ---------------------------------------------------------------------------
|
|
519
|
+
register('company_360_view', async (_op, params) => {
|
|
520
|
+
const api = getAPI();
|
|
521
|
+
const companyId = num(params['company_id']);
|
|
522
|
+
if (!companyId)
|
|
523
|
+
return 'Error: company_id is required.';
|
|
524
|
+
const [company, contacts, openTickets, agreements, configs, timeEntries] = await Promise.all([
|
|
525
|
+
api.request({ path: `/company/companies/${companyId}`, method: 'GET' }).catch(() => ({ data: null })),
|
|
526
|
+
api.paginatedFetch('/company/contacts', `company/id=${companyId}`, 'id,firstName,lastName,title,communicationItems', 100),
|
|
527
|
+
api.paginatedFetch('/service/tickets', `company/id=${companyId} AND closedFlag=false`, 'id,summary,status/name,priority/name,dateEntered', 100),
|
|
528
|
+
api.paginatedFetch('/finance/agreements', `company/id=${companyId}`, 'id,name,type/name,startDate,endDate,cancelledFlag,billAmount', 50),
|
|
529
|
+
api.paginatedFetch('/company/configurations', `company/id=${companyId}`, 'id,name,type/name,status/name', 100),
|
|
530
|
+
api.paginatedFetch('/time/entries', `company/id=${companyId} AND dateEntered>=[${daysAgo(90)}]`, 'id,actualHours,billableOption,member/identifier', 500),
|
|
531
|
+
]);
|
|
532
|
+
const c = company.data;
|
|
533
|
+
const totalHours = timeEntries.items.reduce((s, e) => s + num(e.actualHours), 0);
|
|
534
|
+
const billableHours = timeEntries.items.filter(e => String(e.billableOption ?? '').toLowerCase().includes('billable')).reduce((s, e) => s + num(e.actualHours), 0);
|
|
535
|
+
const lines = [];
|
|
536
|
+
if (c) {
|
|
537
|
+
lines.push(`## ${c.name ?? 'Company'} (ID: ${companyId})`);
|
|
538
|
+
lines.push(`**Status:** ${nested(c, 'status', 'name')} | **Type:** ${String(c.types?.[0]?.name ?? 'N/A')} | **Phone:** ${c.phoneNumber ?? 'N/A'}`);
|
|
539
|
+
}
|
|
540
|
+
else {
|
|
541
|
+
lines.push(`## Company #${companyId}`);
|
|
542
|
+
}
|
|
543
|
+
lines.push(`\n### Summary`);
|
|
544
|
+
lines.push(`| Metric | Value |`);
|
|
545
|
+
lines.push(`|--------|-------|`);
|
|
546
|
+
lines.push(`| Contacts | ${contacts.items.length} |`);
|
|
547
|
+
lines.push(`| Open Tickets | ${openTickets.items.length} |`);
|
|
548
|
+
lines.push(`| Agreements | ${agreements.items.length} (${agreements.items.filter(a => !a.cancelledFlag).length} active) |`);
|
|
549
|
+
lines.push(`| Configurations | ${configs.items.length} |`);
|
|
550
|
+
lines.push(`| Hours (90d) | ${round2(totalHours)}h (${round2(billableHours)}h billable) |`);
|
|
551
|
+
if (openTickets.items.length > 0) {
|
|
552
|
+
lines.push(`\n### Open Tickets (${openTickets.items.length})`);
|
|
553
|
+
lines.push('| # | Summary | Priority | Age |');
|
|
554
|
+
lines.push('|---|---------|----------|-----|');
|
|
555
|
+
for (const t of openTickets.items.slice(0, 15)) {
|
|
556
|
+
lines.push(`| ${t.id} | ${String(t.summary ?? '').substring(0, 50)} | ${nested(t, 'priority', 'name')} | ${daysSince(t.dateEntered)}d |`);
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
if (agreements.items.length > 0) {
|
|
560
|
+
lines.push(`\n### Agreements (${agreements.items.length})`);
|
|
561
|
+
lines.push('| Name | Type | Bill Amount | Active |');
|
|
562
|
+
lines.push('|------|------|-------------|--------|');
|
|
563
|
+
for (const a of agreements.items) {
|
|
564
|
+
lines.push(`| ${a.name} | ${nested(a, 'type', 'name')} | $${a.billAmount ?? 0} | ${a.cancelledFlag ? 'No' : 'Yes'} |`);
|
|
565
|
+
}
|
|
566
|
+
}
|
|
567
|
+
return lines.join('\n');
|
|
568
|
+
});
|
|
569
|
+
// ---------------------------------------------------------------------------
|
|
570
|
+
// daily_operations_briefing
|
|
571
|
+
// ---------------------------------------------------------------------------
|
|
572
|
+
register('daily_operations_briefing', async () => {
|
|
573
|
+
const api = getAPI();
|
|
574
|
+
const todayStr = today();
|
|
575
|
+
const [newTickets, openTickets, closedToday, timeToday] = await Promise.all([
|
|
576
|
+
api.paginatedFetch('/service/tickets', `dateEntered>=[${todayStr}]`, 'id,summary,priority/name,company/name', 100),
|
|
577
|
+
api.count('/service/tickets', 'closedFlag=false'),
|
|
578
|
+
api.paginatedFetch('/service/tickets', `closedDate>=[${todayStr}]`, 'id', 500),
|
|
579
|
+
api.paginatedFetch('/time/entries', `dateEntered>=[${todayStr}]`, 'id,actualHours,member/identifier', 500),
|
|
580
|
+
]);
|
|
581
|
+
const totalHoursToday = timeToday.items.reduce((s, e) => s + num(e.actualHours), 0);
|
|
582
|
+
const memberHours = {};
|
|
583
|
+
for (const e of timeToday.items) {
|
|
584
|
+
const m = nested(e, 'member', 'identifier');
|
|
585
|
+
memberHours[m] = (memberHours[m] ?? 0) + num(e.actualHours);
|
|
586
|
+
}
|
|
587
|
+
const lines = [
|
|
588
|
+
`## Daily Operations Briefing (${new Date().toISOString().split('T')[0]})`,
|
|
589
|
+
'',
|
|
590
|
+
'| Metric | Value |',
|
|
591
|
+
'|--------|-------|',
|
|
592
|
+
`| New Tickets Today | ${newTickets.items.length} |`,
|
|
593
|
+
`| Total Open Tickets | ${openTickets} |`,
|
|
594
|
+
`| Closed Today | ${closedToday.items.length} |`,
|
|
595
|
+
`| Hours Logged Today | ${round2(totalHoursToday)}h |`,
|
|
596
|
+
`| Active Members | ${Object.keys(memberHours).length} |`,
|
|
597
|
+
];
|
|
598
|
+
if (newTickets.items.length > 0) {
|
|
599
|
+
lines.push('\n### New Tickets');
|
|
600
|
+
lines.push('| # | Summary | Priority | Company |');
|
|
601
|
+
lines.push('|---|---------|----------|---------|');
|
|
602
|
+
for (const t of newTickets.items.slice(0, 20)) {
|
|
603
|
+
lines.push(`| ${t.id} | ${String(t.summary ?? '').substring(0, 50)} | ${nested(t, 'priority', 'name')} | ${nested(t, 'company', 'name')} |`);
|
|
604
|
+
}
|
|
605
|
+
}
|
|
606
|
+
if (Object.keys(memberHours).length > 0) {
|
|
607
|
+
lines.push('\n### Hours by Member');
|
|
608
|
+
lines.push('| Member | Hours |');
|
|
609
|
+
lines.push('|--------|-------|');
|
|
610
|
+
for (const [m, h] of Object.entries(memberHours).sort((a, b) => b[1] - a[1])) {
|
|
611
|
+
lines.push(`| ${m} | ${round2(h)}h |`);
|
|
612
|
+
}
|
|
613
|
+
}
|
|
614
|
+
return lines.join('\n');
|
|
615
|
+
});
|
|
616
|
+
// ---------------------------------------------------------------------------
|
|
617
|
+
// overdue_ticket_report
|
|
618
|
+
// ---------------------------------------------------------------------------
|
|
619
|
+
register('overdue_ticket_report', async () => {
|
|
620
|
+
const api = getAPI();
|
|
621
|
+
const result = await api.paginatedFetch('/service/tickets', `closedFlag=false AND requiredDate<[${today()}]`, 'id,summary,board/name,priority/name,company/name,requiredDate,resources', 500);
|
|
622
|
+
if (result.items.length === 0)
|
|
623
|
+
return 'No overdue tickets found.';
|
|
624
|
+
const lines = [
|
|
625
|
+
`## Overdue Tickets (${result.items.length})`,
|
|
626
|
+
'',
|
|
627
|
+
'| # | Summary | Board | Priority | Company | Due Date | Days Overdue |',
|
|
628
|
+
'|---|---------|-------|----------|---------|----------|-------------|',
|
|
629
|
+
];
|
|
630
|
+
for (const t of result.items.slice(0, 50)) {
|
|
631
|
+
const overdue = daysSince(t.requiredDate);
|
|
632
|
+
lines.push(`| ${t.id} | ${String(t.summary ?? '').substring(0, 40)} | ${nested(t, 'board', 'name')} | ${nested(t, 'priority', 'name')} | ${nested(t, 'company', 'name')} | ${String(t.requiredDate ?? '').substring(0, 10)} | ${overdue}d |`);
|
|
633
|
+
}
|
|
634
|
+
return lines.join('\n');
|
|
635
|
+
});
|
|
636
|
+
// ---------------------------------------------------------------------------
|
|
637
|
+
// monthly_recurring_revenue
|
|
638
|
+
// ---------------------------------------------------------------------------
|
|
639
|
+
register('monthly_recurring_revenue', async () => {
|
|
640
|
+
const api = getAPI();
|
|
641
|
+
const result = await api.paginatedFetch('/finance/agreements', 'cancelledFlag=false', 'id,name,company/name,type/name,billAmount,billCycle/name,startDate,endDate', 500);
|
|
642
|
+
let totalMRR = 0;
|
|
643
|
+
const byType = {};
|
|
644
|
+
for (const a of result.items) {
|
|
645
|
+
const amount = num(a.billAmount);
|
|
646
|
+
const cycle = nested(a, 'billCycle', 'name').toLowerCase();
|
|
647
|
+
let monthly = amount;
|
|
648
|
+
if (cycle.includes('year') || cycle.includes('annual'))
|
|
649
|
+
monthly = amount / 12;
|
|
650
|
+
else if (cycle.includes('quarter'))
|
|
651
|
+
monthly = amount / 3;
|
|
652
|
+
else if (cycle.includes('semi'))
|
|
653
|
+
monthly = amount / 6;
|
|
654
|
+
totalMRR += monthly;
|
|
655
|
+
const type = nested(a, 'type', 'name');
|
|
656
|
+
if (!byType[type])
|
|
657
|
+
byType[type] = { count: 0, mrr: 0 };
|
|
658
|
+
byType[type].count++;
|
|
659
|
+
byType[type].mrr += monthly;
|
|
660
|
+
}
|
|
661
|
+
const lines = [
|
|
662
|
+
`## Monthly Recurring Revenue`,
|
|
663
|
+
'',
|
|
664
|
+
`**Total MRR:** $${round2(totalMRR)} | **ARR:** $${round2(totalMRR * 12)} | **Active Agreements:** ${result.items.length}`,
|
|
665
|
+
'',
|
|
666
|
+
'### By Agreement Type',
|
|
667
|
+
'| Type | Count | MRR | % |',
|
|
668
|
+
'|------|-------|-----|---|',
|
|
669
|
+
];
|
|
670
|
+
for (const [type, data] of Object.entries(byType).sort((a, b) => b[1].mrr - a[1].mrr)) {
|
|
671
|
+
lines.push(`| ${type} | ${data.count} | $${round2(data.mrr)} | ${round2((data.mrr / totalMRR) * 100)}% |`);
|
|
672
|
+
}
|
|
673
|
+
return lines.join('\n');
|
|
674
|
+
});
|
|
675
|
+
// ---------------------------------------------------------------------------
|
|
676
|
+
// bulk_status_summary
|
|
677
|
+
// ---------------------------------------------------------------------------
|
|
678
|
+
register('bulk_status_summary', async () => {
|
|
679
|
+
const api = getAPI();
|
|
680
|
+
const [ticketCount, projectCount, oppCount] = await Promise.all([
|
|
681
|
+
api.paginatedFetch('/service/tickets', 'closedFlag=false', 'id,status/name', 2000),
|
|
682
|
+
api.paginatedFetch('/project/projects', "status/name='Open'", 'id,status/name', 500),
|
|
683
|
+
api.paginatedFetch('/sales/opportunities', "closedFlag=false", 'id,status/name', 500),
|
|
684
|
+
]);
|
|
685
|
+
const countBy = (items) => {
|
|
686
|
+
const counts = {};
|
|
687
|
+
for (const i of items) {
|
|
688
|
+
const s = nested(i, 'status', 'name');
|
|
689
|
+
counts[s] = (counts[s] ?? 0) + 1;
|
|
690
|
+
}
|
|
691
|
+
return counts;
|
|
692
|
+
};
|
|
693
|
+
const lines = ['## Cross-Domain Status Summary\n'];
|
|
694
|
+
lines.push(`### Tickets (${ticketCount.items.length} open)`);
|
|
695
|
+
lines.push('| Status | Count |');
|
|
696
|
+
lines.push('|--------|-------|');
|
|
697
|
+
for (const [s, c] of Object.entries(countBy(ticketCount.items)).sort((a, b) => b[1] - a[1])) {
|
|
698
|
+
lines.push(`| ${s} | ${c} |`);
|
|
699
|
+
}
|
|
700
|
+
lines.push(`\n### Projects (${projectCount.items.length} open)`);
|
|
701
|
+
lines.push('| Status | Count |');
|
|
702
|
+
lines.push('|--------|-------|');
|
|
703
|
+
for (const [s, c] of Object.entries(countBy(projectCount.items)).sort((a, b) => b[1] - a[1])) {
|
|
704
|
+
lines.push(`| ${s} | ${c} |`);
|
|
705
|
+
}
|
|
706
|
+
lines.push(`\n### Opportunities (${oppCount.items.length} open)`);
|
|
707
|
+
lines.push('| Status | Count |');
|
|
708
|
+
lines.push('|--------|-------|');
|
|
709
|
+
for (const [s, c] of Object.entries(countBy(oppCount.items)).sort((a, b) => b[1] - a[1])) {
|
|
710
|
+
lines.push(`| ${s} | ${c} |`);
|
|
711
|
+
}
|
|
712
|
+
return lines.join('\n');
|
|
713
|
+
});
|
|
714
|
+
// ---------------------------------------------------------------------------
|
|
715
|
+
// weekly_operations_report
|
|
716
|
+
// ---------------------------------------------------------------------------
|
|
717
|
+
register('weekly_operations_report', async () => {
|
|
718
|
+
const api = getAPI();
|
|
719
|
+
const weekAgo = daysAgo(7);
|
|
720
|
+
const [newTickets, closedTickets, timeEntries, openCount] = await Promise.all([
|
|
721
|
+
api.paginatedFetch('/service/tickets', `dateEntered>=[${weekAgo}]`, 'id,board/name,priority/name', 1000),
|
|
722
|
+
api.paginatedFetch('/service/tickets', `closedDate>=[${weekAgo}]`, 'id', 1000),
|
|
723
|
+
api.paginatedFetch('/time/entries', `dateEntered>=[${weekAgo}]`, 'id,actualHours,billableOption,member/identifier', 2000),
|
|
724
|
+
api.count('/service/tickets', 'closedFlag=false'),
|
|
725
|
+
]);
|
|
726
|
+
const totalHours = timeEntries.items.reduce((s, e) => s + num(e.actualHours), 0);
|
|
727
|
+
const billableHours = timeEntries.items.filter(e => String(e.billableOption ?? '').toLowerCase().includes('billable')).reduce((s, e) => s + num(e.actualHours), 0);
|
|
728
|
+
const lines = [
|
|
729
|
+
`## Weekly Operations Report (${daysAgo(7).substring(0, 10)} to ${today().substring(0, 10)})`,
|
|
730
|
+
'',
|
|
731
|
+
'| Metric | Value |',
|
|
732
|
+
'|--------|-------|',
|
|
733
|
+
`| New Tickets | ${newTickets.items.length} |`,
|
|
734
|
+
`| Closed Tickets | ${closedTickets.items.length} |`,
|
|
735
|
+
`| Current Open | ${openCount} |`,
|
|
736
|
+
`| Net Change | ${newTickets.items.length - closedTickets.items.length > 0 ? '+' : ''}${newTickets.items.length - closedTickets.items.length} |`,
|
|
737
|
+
`| Total Hours | ${round2(totalHours)}h |`,
|
|
738
|
+
`| Billable Hours | ${round2(billableHours)}h (${totalHours > 0 ? round2((billableHours / totalHours) * 100) : 0}%) |`,
|
|
739
|
+
];
|
|
740
|
+
return lines.join('\n');
|
|
741
|
+
});
|
|
742
|
+
//# sourceMappingURL=analytics.js.map
|