@measurequick/measurequick-report-generator 1.5.180 → 1.5.181

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.
@@ -0,0 +1,8 @@
1
+ // Heat Pump Heating Vitals Report Template
2
+ // TODO: Replace with actual PDF template containing heat pump heating labels:
3
+ // - Page 1 measurements: Temp Rise, LLT, COP, OAT, Static Pressure, Airflow
4
+ // - Page 2 factors: Static Pressure (20pts), Air-Side Heat Delivery (20pts),
5
+ // Capacity Delivery (20pts), System Balance (20pts), Efficiency/COP (20pts)
6
+ //
7
+ // For now, this re-exports the cooling template as a placeholder
8
+ export { base64 } from "./mq-vitals-cooling.js";
package/index.js CHANGED
@@ -7,6 +7,7 @@ import * as photosReport from "./report-gen-scripts/photos-report.js";
7
7
  import * as photosReportS3 from "./report-gen-scripts/photos-report-s3.js";
8
8
  import * as ptcsHeatPumpReport from "./report-gen-scripts/ptcs-heat-pump-report.js";
9
9
  import * as vitalsCoolingReport from "./report-gen-scripts/vitals-cooling-report.js";
10
+ import * as vitalsHeatPumpHeatingReport from "./report-gen-scripts/vitals-heatpump-heating-report.js";
10
11
  import * as vitalsHeatingReport from "./report-gen-scripts/vitals-heating-report.js";
11
12
  import * as vitalsHeatingReportNoCombustion from "./report-gen-scripts/vitals-heating-report-no-combustion.js";
12
13
  import * as ductScreeningReport from "./report-gen-scripts/nci-report.js";
@@ -17,6 +18,7 @@ import * as energyStarCertificate from "./report-gen-scripts/energy-star-certifi
17
18
  import * as franklinEnergyReport from "./report-gen-scripts/franklin-energy-report.js";
18
19
  import * as nciCustomerReport from "./report-gen-scripts/nci-report-customer.js";
19
20
  import * as nciContractorReport from "./report-gen-scripts/nci-report-contractor.js";
21
+ import * as mqAiSummaryReport from "./report-gen-scripts/mq-ai-summary-report.js";
20
22
 
21
23
  export function getReport(payload) {
22
24
  switch (payload.meta.report_type) {
@@ -38,6 +40,8 @@ export function getReport(payload) {
38
40
  return ptcsHeatPumpReport.getReport(payload);
39
41
  case "VitalsCoolingReport":
40
42
  return vitalsCoolingReport.getReport(payload, false);
43
+ case "VitalsHeatPumpHeatingReport":
44
+ return vitalsHeatPumpHeatingReport.getReport(payload, false);
41
45
  case "VitalsHeatingReport":
42
46
  return vitalsHeatingReport.getReport(payload, false);
43
47
  case "VitalsHeatingReportNoCombustion":
@@ -61,5 +65,7 @@ export function getReport(payload) {
61
65
  return nciCustomerReport.getReport(payload);
62
66
  case "NCIReportContractor":
63
67
  return nciContractorReport.getReport(payload);
68
+ case "MqAiSummaryReport":
69
+ return mqAiSummaryReport.getReport(payload);
64
70
  }
65
71
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@measurequick/measurequick-report-generator",
3
- "version": "1.5.180",
3
+ "version": "1.5.181",
4
4
  "description": "Generates PDF documents for various measureQuick applications.",
5
5
  "main": "index.js",
6
6
  "scripts": {
@@ -0,0 +1,293 @@
1
+ import { PDFDocument, rgb, StandardFonts } from 'pdf-lib';
2
+ import * as base64 from '../base-64/icons.js';
3
+ import * as util from '../util.js';
4
+
5
+ // Color definitions matching MeasureQuick brand
6
+ const colors = {
7
+ headerBg: rgb(0.18, 0.31, 0.47), // Dark blue header
8
+ scoreGreen: rgb(0.18, 0.62, 0.35), // Green for A grades
9
+ scoreYellow: rgb(0.91, 0.73, 0.18), // Yellow for B grades
10
+ scoreOrange: rgb(0.91, 0.49, 0.18), // Orange for C grades
11
+ scoreRed: rgb(0.78, 0.22, 0.22), // Red for D/F grades
12
+ lightGray: rgb(0.95, 0.95, 0.95), // Light gray background
13
+ mediumGray: rgb(0.7, 0.7, 0.7), // Medium gray for borders
14
+ darkGray: rgb(0.3, 0.3, 0.3), // Dark gray for text
15
+ black: rgb(0, 0, 0),
16
+ white: rgb(1, 1, 1)
17
+ };
18
+
19
+ function getScoreColor(grade) {
20
+ if (!grade) return colors.mediumGray;
21
+ if (grade.includes('A')) return colors.scoreGreen;
22
+ if (grade.includes('B')) return colors.scoreYellow;
23
+ if (grade.includes('C')) return colors.scoreOrange;
24
+ return colors.scoreRed;
25
+ }
26
+
27
+ /**
28
+ * Add AI Summary page(s) to an existing PDF document
29
+ * @param {PDFDocument} pdfDoc - The existing PDF document to add pages to
30
+ * @param {Object} payload - The report payload containing AI summary and test data
31
+ * @returns {Promise<void>}
32
+ */
33
+ export async function addAiSummaryPages(pdfDoc, payload) {
34
+ // Check if we should show the AI summary
35
+ if (!util.shouldShowAiSummary(payload)) {
36
+ return;
37
+ }
38
+
39
+ const aiSummary = util.getAiSummary(payload);
40
+ if (!aiSummary) return;
41
+
42
+ // Embed fonts
43
+ const fontRegular = await pdfDoc.embedFont(StandardFonts.Helvetica);
44
+ const fontBold = await pdfDoc.embedFont(StandardFonts.HelveticaBold);
45
+
46
+ // Page dimensions (Letter size: 612 x 792 points)
47
+ const pageWidth = 612;
48
+ const pageHeight = 792;
49
+ const margin = 36;
50
+ const contentWidth = pageWidth - (margin * 2);
51
+
52
+ // Create the AI summary page
53
+ let page = pdfDoc.addPage([pageWidth, pageHeight]);
54
+ let yPos = pageHeight - margin;
55
+
56
+ // Load mQ logo
57
+ const mqLogoBytes = util._base64ToArrayBuffer(base64.mqLogo);
58
+ const mqLogo = await pdfDoc.embedPng(mqLogoBytes);
59
+
60
+ // Load company logo if available
61
+ let companyImage = null;
62
+ const companyImageType = util.checkCompanyLogo(payload.meta);
63
+ if (companyImageType === 'jpg') {
64
+ companyImage = await pdfDoc.embedJpg(util._base64ToArrayBuffer(payload.meta.profile_settings.companyLogoBase64));
65
+ } else if (companyImageType === 'png') {
66
+ companyImage = await pdfDoc.embedPng(util._base64ToArrayBuffer(payload.meta.profile_settings.companyLogoBase64));
67
+ }
68
+
69
+ // === HEADER SECTION ===
70
+ const headerHeight = 45;
71
+ page.drawRectangle({
72
+ x: margin,
73
+ y: yPos - headerHeight,
74
+ width: contentWidth,
75
+ height: headerHeight,
76
+ color: colors.headerBg
77
+ });
78
+
79
+ // Header title
80
+ page.drawText('mQ AI Summary', {
81
+ x: margin + 15,
82
+ y: yPos - 28,
83
+ size: 16,
84
+ font: fontBold,
85
+ color: colors.white
86
+ });
87
+
88
+ // Draw logo in header (right side)
89
+ const logoToUse = companyImage || mqLogo;
90
+ if (logoToUse) {
91
+ const logoHeight = 30;
92
+ const logoWidth = logoHeight * (logoToUse.width / logoToUse.height);
93
+ page.drawImage(logoToUse, {
94
+ x: pageWidth - margin - logoWidth - 10,
95
+ y: yPos - headerHeight + 7,
96
+ width: logoWidth,
97
+ height: logoHeight
98
+ });
99
+ }
100
+
101
+ yPos -= headerHeight + 15;
102
+
103
+ // === VITALS SCORES AND DATE/TIME SECTION ===
104
+ const hasTest1 = payload.tests && payload.tests[0] && payload.tests[0].testInfo && payload.tests[0].testInfo.data;
105
+ const hasTest2 = payload.tests && payload.tests[1] && payload.tests[1].testInfo && payload.tests[1].testInfo.data;
106
+ const hasTwoTests = hasTest1 && hasTest2;
107
+
108
+ // Get date/time from test
109
+ let dateTimeStr = '';
110
+ if (hasTest1 && payload.tests[0].testInfo.data.datetimeStart) {
111
+ const date = new Date(payload.tests[0].testInfo.data.datetimeStart);
112
+ dateTimeStr = date.toLocaleDateString('en-US', {
113
+ year: 'numeric',
114
+ month: 'short',
115
+ day: 'numeric',
116
+ hour: 'numeric',
117
+ minute: '2-digit'
118
+ });
119
+ }
120
+
121
+ // Draw score and date/time in a compact row
122
+ const rowHeight = 35;
123
+
124
+ if (hasTest1) {
125
+ const score1 = payload.tests[0].testInfo.data.vitals_score;
126
+ const scorePercent1 = score1 ? Math.round(score1) + '%' : '--';
127
+ const grade1 = score1 ? util.getGradeFromScore(Math.round(score1)) : '';
128
+ const scoreColor1 = getScoreColor(grade1);
129
+ const scoreLabel1 = hasTwoTests ? 'Test In:' : 'Score:';
130
+ const scoreText1 = scorePercent1 + (grade1 ? ' ' + grade1 : '');
131
+
132
+ // Score label and value
133
+ page.drawText(scoreLabel1, {
134
+ x: margin,
135
+ y: yPos - 12,
136
+ size: 10,
137
+ font: fontRegular,
138
+ color: colors.darkGray
139
+ });
140
+
141
+ page.drawText(scoreText1, {
142
+ x: margin + 50,
143
+ y: yPos - 12,
144
+ size: 14,
145
+ font: fontBold,
146
+ color: scoreColor1
147
+ });
148
+
149
+ // Test Out score if exists
150
+ if (hasTwoTests) {
151
+ const score2 = payload.tests[1].testInfo.data.vitals_score;
152
+ const scorePercent2 = score2 ? Math.round(score2) + '%' : '--';
153
+ const grade2 = score2 ? util.getGradeFromScore(Math.round(score2)) : '';
154
+ const scoreColor2 = getScoreColor(grade2);
155
+ const scoreText2 = scorePercent2 + (grade2 ? ' ' + grade2 : '');
156
+
157
+ page.drawText('Test Out:', {
158
+ x: margin + 130,
159
+ y: yPos - 12,
160
+ size: 10,
161
+ font: fontRegular,
162
+ color: colors.darkGray
163
+ });
164
+
165
+ page.drawText(scoreText2, {
166
+ x: margin + 180,
167
+ y: yPos - 12,
168
+ size: 14,
169
+ font: fontBold,
170
+ color: scoreColor2
171
+ });
172
+ }
173
+
174
+ // Date/time on the right
175
+ if (dateTimeStr) {
176
+ const dateWidth = fontRegular.widthOfTextAtSize(dateTimeStr, 10);
177
+ page.drawText(dateTimeStr, {
178
+ x: pageWidth - margin - dateWidth,
179
+ y: yPos - 12,
180
+ size: 10,
181
+ font: fontRegular,
182
+ color: colors.darkGray
183
+ });
184
+ }
185
+
186
+ yPos -= rowHeight;
187
+ }
188
+
189
+ // Draw separator line
190
+ page.drawLine({
191
+ start: { x: margin, y: yPos },
192
+ end: { x: pageWidth - margin, y: yPos },
193
+ thickness: 1,
194
+ color: colors.mediumGray
195
+ });
196
+ yPos -= 15;
197
+
198
+ // === AI SUMMARY CONTENT ===
199
+ const lineHeight = 14;
200
+ const summaryLines = util.wrapTextWithNewlines(aiSummary, fontRegular, 10, contentWidth);
201
+
202
+ for (const line of summaryLines) {
203
+ if (yPos < margin + 50) {
204
+ // Add new page if needed
205
+ page = pdfDoc.addPage([pageWidth, pageHeight]);
206
+ yPos = pageHeight - margin;
207
+
208
+ // Add continuation header
209
+ page.drawText('mQ AI Summary (continued)', {
210
+ x: margin,
211
+ y: yPos,
212
+ size: 12,
213
+ font: fontBold,
214
+ color: colors.headerBg
215
+ });
216
+ yPos -= 20;
217
+
218
+ page.drawLine({
219
+ start: { x: margin, y: yPos },
220
+ end: { x: pageWidth - margin, y: yPos },
221
+ thickness: 1,
222
+ color: colors.mediumGray
223
+ });
224
+ yPos -= 15;
225
+ }
226
+
227
+ if (line) {
228
+ page.drawText(line, {
229
+ x: margin,
230
+ y: yPos,
231
+ size: 10,
232
+ font: fontRegular,
233
+ color: colors.black
234
+ });
235
+ }
236
+ yPos -= lineHeight;
237
+ }
238
+
239
+ // === NOTES SECTION (if exists) ===
240
+ const notes = util.getAiNotes(payload);
241
+ if (notes && notes.trim().length > 0) {
242
+ yPos -= 15;
243
+
244
+ if (yPos < margin + 100) {
245
+ page = pdfDoc.addPage([pageWidth, pageHeight]);
246
+ yPos = pageHeight - margin;
247
+ }
248
+
249
+ page.drawText('Notes', {
250
+ x: margin,
251
+ y: yPos,
252
+ size: 12,
253
+ font: fontBold,
254
+ color: colors.headerBg
255
+ });
256
+ yPos -= 5;
257
+
258
+ page.drawLine({
259
+ start: { x: margin, y: yPos },
260
+ end: { x: pageWidth - margin, y: yPos },
261
+ thickness: 1,
262
+ color: colors.headerBg
263
+ });
264
+ yPos -= 15;
265
+
266
+ const notesLines = util.wrapTextWithNewlines(notes, fontRegular, 10, contentWidth);
267
+
268
+ for (const line of notesLines) {
269
+ if (yPos < margin + 50) {
270
+ page = pdfDoc.addPage([pageWidth, pageHeight]);
271
+ yPos = pageHeight - margin;
272
+ }
273
+
274
+ if (line) {
275
+ page.drawText(line, {
276
+ x: margin,
277
+ y: yPos,
278
+ size: 10,
279
+ font: fontRegular,
280
+ color: colors.black
281
+ });
282
+ }
283
+ yPos -= lineHeight;
284
+ }
285
+ }
286
+ }
287
+
288
+ /**
289
+ * Check if AI summary should be shown based on payload settings
290
+ */
291
+ export function shouldShow(payload) {
292
+ return util.shouldShowAiSummary(payload);
293
+ }
@@ -622,7 +622,10 @@ export async function getReport(payload) {
622
622
  ) {
623
623
  let v = measurement.value;
624
624
  if (typeof v == "string") v = parseFloat(v);
625
- if (v > 9) rangeIcon = embeddedIcons.iconRangeHigh;
625
+ if (v !== null && v !== undefined && !isNaN(v)) {
626
+ if (v > 9) rangeIcon = embeddedIcons.iconRangeHigh;
627
+ else rangeIcon = embeddedIcons.iconRangeNormal;
628
+ }
626
629
  } else
627
630
  rangeIcon = util.getRangeIcon(
628
631
  test,
@@ -0,0 +1,490 @@
1
+ import { PDFDocument, rgb, StandardFonts } from 'pdf-lib';
2
+ import fontkit from '@pdf-lib/fontkit';
3
+ import * as base64 from '../base-64/icons.js';
4
+ import * as util from '../util.js';
5
+
6
+ // Check if project is CoolMaxx or HeatMaxx (NCI branded)
7
+ function isNCIProject(payload) {
8
+ if (payload && payload.project && payload.project.specialProjectType) {
9
+ const type = payload.project.specialProjectType;
10
+ return type === 'CoolMaxx' || type === 'HeatMaxx';
11
+ }
12
+ return false;
13
+ }
14
+
15
+ // Color definitions matching MeasureQuick brand
16
+ const colors = {
17
+ headerBg: rgb(0.18, 0.31, 0.47), // Dark blue header
18
+ scoreGreen: rgb(0.18, 0.62, 0.35), // Green for A grades
19
+ scoreYellow: rgb(0.91, 0.73, 0.18), // Yellow for B grades
20
+ scoreOrange: rgb(0.91, 0.49, 0.18), // Orange for C grades
21
+ scoreRed: rgb(0.78, 0.22, 0.22), // Red for D/F grades
22
+ lightGray: rgb(0.95, 0.95, 0.95), // Light gray background
23
+ mediumGray: rgb(0.7, 0.7, 0.7), // Medium gray for borders
24
+ darkGray: rgb(0.3, 0.3, 0.3), // Dark gray for text
25
+ black: rgb(0, 0, 0),
26
+ white: rgb(1, 1, 1)
27
+ };
28
+
29
+ function getScoreColor(grade) {
30
+ if (grade.includes('A')) return colors.scoreGreen;
31
+ if (grade.includes('B')) return colors.scoreYellow;
32
+ if (grade.includes('C')) return colors.scoreOrange;
33
+ return colors.scoreRed;
34
+ }
35
+
36
+ // Wrap text to fit within a given width
37
+ function wrapText(text, font, fontSize, maxWidth) {
38
+ if (!text) return [];
39
+ const words = text.split(' ');
40
+ const lines = [];
41
+ let currentLine = '';
42
+
43
+ for (const word of words) {
44
+ const testLine = currentLine ? currentLine + ' ' + word : word;
45
+ const testWidth = font.widthOfTextAtSize(testLine, fontSize);
46
+
47
+ if (testWidth > maxWidth && currentLine) {
48
+ lines.push(currentLine);
49
+ currentLine = word;
50
+ } else {
51
+ currentLine = testLine;
52
+ }
53
+ }
54
+
55
+ if (currentLine) {
56
+ lines.push(currentLine);
57
+ }
58
+
59
+ return lines;
60
+ }
61
+
62
+ // Handle newlines and wrap text
63
+ function wrapTextWithNewlines(text, font, fontSize, maxWidth) {
64
+ if (!text) return [];
65
+ const paragraphs = text.split('\n');
66
+ const allLines = [];
67
+
68
+ for (const paragraph of paragraphs) {
69
+ if (paragraph.trim() === '') {
70
+ allLines.push('');
71
+ } else {
72
+ const wrappedLines = wrapText(paragraph, font, fontSize, maxWidth);
73
+ allLines.push(...wrappedLines);
74
+ }
75
+ }
76
+
77
+ return allLines;
78
+ }
79
+
80
+ export async function getReport(payload) {
81
+ try {
82
+ // Create a new PDF document
83
+ const pdfDoc = await PDFDocument.create();
84
+ pdfDoc.registerFontkit(fontkit);
85
+
86
+ // Embed fonts
87
+ const fontRegular = await pdfDoc.embedFont(StandardFonts.Helvetica);
88
+ const fontBold = await pdfDoc.embedFont(StandardFonts.HelveticaBold);
89
+
90
+ // Page dimensions (Letter size: 612 x 792 points)
91
+ const pageWidth = 612;
92
+ const pageHeight = 792;
93
+ const margin = 36; // 0.5 inch margins
94
+ const contentWidth = pageWidth - (margin * 2);
95
+
96
+ // Create first page
97
+ let page = pdfDoc.addPage([pageWidth, pageHeight]);
98
+
99
+ // Load mQ logo
100
+ const mqLogoBytes = util._base64ToArrayBuffer(base64.mqLogo);
101
+ const mqLogo = await pdfDoc.embedPng(mqLogoBytes);
102
+
103
+ // Load NCI logo if applicable
104
+ let nciLogo = null;
105
+ const isNCI = isNCIProject(payload) && base64.nciLogo;
106
+ if (isNCI) {
107
+ const nciLogoBytes = util._base64ToArrayBuffer(base64.nciLogo);
108
+ nciLogo = await pdfDoc.embedPng(nciLogoBytes);
109
+ }
110
+
111
+ // Load company logo and profile picture
112
+ let profileImage = null, companyImage = null;
113
+ const profileImageType = util.checkProfilePicture(payload.meta);
114
+ const companyImageType = util.checkCompanyLogo(payload.meta);
115
+
116
+ if (profileImageType) {
117
+ let profileData;
118
+ if (profileImageType.shape === 'square') {
119
+ profileData = payload.meta.profile_settings.profilePicSquareBase64;
120
+ } else if (profileImageType.shape === 'circle') {
121
+ profileData = payload.meta.profile_settings.profilePicCircleBase64;
122
+ }
123
+ if (profileData) {
124
+ if (profileImageType.type === 'jpg') {
125
+ profileImage = await pdfDoc.embedJpg(util._base64ToArrayBuffer(profileData));
126
+ } else if (profileImageType.type === 'png') {
127
+ profileImage = await pdfDoc.embedPng(util._base64ToArrayBuffer(profileData));
128
+ }
129
+ }
130
+ }
131
+
132
+ if (companyImageType === 'jpg') {
133
+ companyImage = await pdfDoc.embedJpg(util._base64ToArrayBuffer(payload.meta.profile_settings.companyLogoBase64));
134
+ } else if (companyImageType === 'png') {
135
+ companyImage = await pdfDoc.embedPng(util._base64ToArrayBuffer(payload.meta.profile_settings.companyLogoBase64));
136
+ }
137
+
138
+ let yPos = pageHeight - margin;
139
+
140
+ // === HEADER SECTION ===
141
+ const headerHeight = 50;
142
+ page.drawRectangle({
143
+ x: margin,
144
+ y: yPos - headerHeight,
145
+ width: contentWidth,
146
+ height: headerHeight,
147
+ color: colors.headerBg
148
+ });
149
+
150
+ // Header title
151
+ page.drawText('mQ AI Summary & Notes', {
152
+ x: margin + 15,
153
+ y: yPos - 32,
154
+ size: 18,
155
+ font: fontBold,
156
+ color: colors.white
157
+ });
158
+
159
+ // Draw logo in header (right side)
160
+ const logoToUse = isNCI ? nciLogo : (companyImage || mqLogo);
161
+ if (logoToUse) {
162
+ const logoHeight = 35;
163
+ const logoWidth = logoHeight * (logoToUse.width / logoToUse.height);
164
+ page.drawImage(logoToUse, {
165
+ x: pageWidth - margin - logoWidth - 10,
166
+ y: yPos - headerHeight + 7,
167
+ width: logoWidth,
168
+ height: logoHeight
169
+ });
170
+ }
171
+
172
+ yPos -= headerHeight + 15;
173
+
174
+ // === CUSTOMER INFO SECTION ===
175
+ const customerName = payload.project && payload.project.customer_name ? payload.project.customer_name : '';
176
+ const address1 = payload.project && payload.project.address ? payload.project.address : '';
177
+ const city = payload.project && payload.project.city ? payload.project.city : '';
178
+ const state = payload.project && payload.project.state ? payload.project.state : '';
179
+ const zip = payload.project && payload.project.zip ? payload.project.zip : '';
180
+ const cityStateZip = [city, state, zip].filter(x => x).join(', ');
181
+
182
+ if (customerName) {
183
+ page.drawText(customerName, { x: margin, y: yPos, size: 11, font: fontBold, color: colors.darkGray });
184
+ yPos -= 14;
185
+ }
186
+ if (address1) {
187
+ page.drawText(address1, { x: margin, y: yPos, size: 10, font: fontRegular, color: colors.darkGray });
188
+ yPos -= 12;
189
+ }
190
+ if (cityStateZip) {
191
+ page.drawText(cityStateZip, { x: margin, y: yPos, size: 10, font: fontRegular, color: colors.darkGray });
192
+ yPos -= 12;
193
+ }
194
+
195
+ // Date of service
196
+ let dateStr = '';
197
+ if (payload.tests && payload.tests[0] && payload.tests[0].testInfo && payload.tests[0].testInfo.data && payload.tests[0].testInfo.data.datetimeStart) {
198
+ const date = new Date(payload.tests[0].testInfo.data.datetimeStart);
199
+ dateStr = date.toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' });
200
+ }
201
+ if (dateStr) {
202
+ page.drawText('Date: ' + dateStr, { x: margin, y: yPos, size: 10, font: fontRegular, color: colors.darkGray });
203
+ }
204
+
205
+ // Tech name (right aligned)
206
+ const techFirstName = payload.meta && payload.meta.profile_settings && payload.meta.profile_settings.techFirstName ? payload.meta.profile_settings.techFirstName : '';
207
+ const techLastName = payload.meta && payload.meta.profile_settings && payload.meta.profile_settings.techLastName ? payload.meta.profile_settings.techLastName : '';
208
+ const techName = [techFirstName, techLastName].filter(x => x).join(' ');
209
+ if (techName) {
210
+ const techText = 'Technician: ' + techName;
211
+ const techWidth = fontRegular.widthOfTextAtSize(techText, 10);
212
+ page.drawText(techText, { x: pageWidth - margin - techWidth, y: yPos, size: 10, font: fontRegular, color: colors.darkGray });
213
+ }
214
+
215
+ yPos -= 25;
216
+
217
+ // === VITALS SCORES SECTION ===
218
+ const hasTest1 = payload.tests && payload.tests[0] && payload.tests[0].testInfo && payload.tests[0].testInfo.data;
219
+ const hasTest2 = payload.tests && payload.tests[1] && payload.tests[1].testInfo && payload.tests[1].testInfo.data;
220
+ const hasTwoTests = hasTest1 && hasTest2;
221
+
222
+ const scoreBoxWidth = hasTwoTests ? (contentWidth - 20) / 2 : contentWidth;
223
+ const scoreBoxHeight = 60;
224
+
225
+ // Draw score box(es)
226
+ if (hasTest1) {
227
+ const score1 = payload.tests[0].testInfo.data.vitals_score;
228
+ const scorePercent1 = score1 ? Math.round(score1) + '%' : '--';
229
+ const grade1 = score1 ? util.getGradeFromScore(Math.round(score1)) : '';
230
+ const scoreColor1 = score1 ? getScoreColor(grade1) : colors.mediumGray;
231
+ const scoreLabel1 = hasTwoTests ? 'Test In Score' : 'System Vitals Score';
232
+
233
+ // Score box background
234
+ page.drawRectangle({
235
+ x: margin,
236
+ y: yPos - scoreBoxHeight,
237
+ width: scoreBoxWidth,
238
+ height: scoreBoxHeight,
239
+ color: colors.lightGray,
240
+ borderColor: scoreColor1,
241
+ borderWidth: 2
242
+ });
243
+
244
+ // Score label
245
+ page.drawText(scoreLabel1, {
246
+ x: margin + 10,
247
+ y: yPos - 18,
248
+ size: 10,
249
+ font: fontRegular,
250
+ color: colors.darkGray
251
+ });
252
+
253
+ // Score value
254
+ const scoreText1 = scorePercent1 + (grade1 ? ' ' + grade1 : '');
255
+ page.drawText(scoreText1, {
256
+ x: margin + 10,
257
+ y: yPos - 45,
258
+ size: 24,
259
+ font: fontBold,
260
+ color: scoreColor1
261
+ });
262
+ }
263
+
264
+ if (hasTwoTests) {
265
+ const score2 = payload.tests[1].testInfo.data.vitals_score;
266
+ const scorePercent2 = score2 ? Math.round(score2) + '%' : '--';
267
+ const grade2 = score2 ? util.getGradeFromScore(Math.round(score2)) : '';
268
+ const scoreColor2 = score2 ? getScoreColor(grade2) : colors.mediumGray;
269
+
270
+ // Score box background
271
+ page.drawRectangle({
272
+ x: margin + scoreBoxWidth + 20,
273
+ y: yPos - scoreBoxHeight,
274
+ width: scoreBoxWidth,
275
+ height: scoreBoxHeight,
276
+ color: colors.lightGray,
277
+ borderColor: scoreColor2,
278
+ borderWidth: 2
279
+ });
280
+
281
+ // Score label
282
+ page.drawText('Test Out Score', {
283
+ x: margin + scoreBoxWidth + 30,
284
+ y: yPos - 18,
285
+ size: 10,
286
+ font: fontRegular,
287
+ color: colors.darkGray
288
+ });
289
+
290
+ // Score value
291
+ const scoreText2 = scorePercent2 + (grade2 ? ' ' + grade2 : '');
292
+ page.drawText(scoreText2, {
293
+ x: margin + scoreBoxWidth + 30,
294
+ y: yPos - 45,
295
+ size: 24,
296
+ font: fontBold,
297
+ color: scoreColor2
298
+ });
299
+ }
300
+
301
+ yPos -= scoreBoxHeight + 20;
302
+
303
+ // === AI SUMMARY SECTION ===
304
+ page.drawText('AI Summary', {
305
+ x: margin,
306
+ y: yPos,
307
+ size: 14,
308
+ font: fontBold,
309
+ color: colors.headerBg
310
+ });
311
+ yPos -= 5;
312
+
313
+ // Draw line under header
314
+ page.drawLine({
315
+ start: { x: margin, y: yPos },
316
+ end: { x: pageWidth - margin, y: yPos },
317
+ thickness: 1,
318
+ color: colors.headerBg
319
+ });
320
+ yPos -= 15;
321
+
322
+ // AI Summary content
323
+ const aiSummary = payload.ai_summary || payload.project && payload.project.ai_summary || 'No AI summary available.';
324
+ const summaryLines = wrapTextWithNewlines(aiSummary, fontRegular, 10, contentWidth);
325
+ const lineHeight = 14;
326
+
327
+ for (const line of summaryLines) {
328
+ if (yPos < margin + 100) {
329
+ // Add new page if needed
330
+ page = pdfDoc.addPage([pageWidth, pageHeight]);
331
+ yPos = pageHeight - margin;
332
+ }
333
+
334
+ if (line) {
335
+ page.drawText(line, {
336
+ x: margin,
337
+ y: yPos,
338
+ size: 10,
339
+ font: fontRegular,
340
+ color: colors.black
341
+ });
342
+ }
343
+ yPos -= lineHeight;
344
+ }
345
+
346
+ yPos -= 20;
347
+
348
+ // === NOTES SECTION ===
349
+ if (yPos < margin + 150) {
350
+ page = pdfDoc.addPage([pageWidth, pageHeight]);
351
+ yPos = pageHeight - margin;
352
+ }
353
+
354
+ page.drawText('Notes', {
355
+ x: margin,
356
+ y: yPos,
357
+ size: 14,
358
+ font: fontBold,
359
+ color: colors.headerBg
360
+ });
361
+ yPos -= 5;
362
+
363
+ // Draw line under header
364
+ page.drawLine({
365
+ start: { x: margin, y: yPos },
366
+ end: { x: pageWidth - margin, y: yPos },
367
+ thickness: 1,
368
+ color: colors.headerBg
369
+ });
370
+ yPos -= 15;
371
+
372
+ // Notes content - check multiple possible sources
373
+ let notes = payload.notes || '';
374
+ if (!notes && payload.project && payload.project.notes) {
375
+ notes = payload.project.notes;
376
+ }
377
+ if (!notes && payload.tests && payload.tests[0] && payload.tests[0].testInfo && payload.tests[0].testInfo.notes) {
378
+ notes = payload.tests[0].testInfo.notes;
379
+ }
380
+
381
+ if (!notes) {
382
+ notes = 'No notes available.';
383
+ }
384
+
385
+ const notesLines = wrapTextWithNewlines(notes, fontRegular, 10, contentWidth);
386
+
387
+ for (const line of notesLines) {
388
+ if (yPos < margin + 50) {
389
+ // Add new page if needed
390
+ page = pdfDoc.addPage([pageWidth, pageHeight]);
391
+ yPos = pageHeight - margin;
392
+ }
393
+
394
+ if (line) {
395
+ page.drawText(line, {
396
+ x: margin,
397
+ y: yPos,
398
+ size: 10,
399
+ font: fontRegular,
400
+ color: colors.black
401
+ });
402
+ }
403
+ yPos -= lineHeight;
404
+ }
405
+
406
+ // === FOOTER SECTION ===
407
+ const footerY = margin;
408
+
409
+ // Draw footer on all pages
410
+ const pages = pdfDoc.getPages();
411
+ for (let i = 0; i < pages.length; i++) {
412
+ const p = pages[i];
413
+
414
+ // Footer line
415
+ p.drawLine({
416
+ start: { x: margin, y: footerY + 25 },
417
+ end: { x: pageWidth - margin, y: footerY + 25 },
418
+ thickness: 0.5,
419
+ color: colors.mediumGray
420
+ });
421
+
422
+ // Profile picture (if available and not NCI)
423
+ if (!isNCI && profileImage) {
424
+ const profileSize = 20;
425
+ p.drawImage(profileImage, {
426
+ x: margin,
427
+ y: footerY,
428
+ width: profileSize,
429
+ height: profileSize
430
+ });
431
+
432
+ // Tech name next to profile
433
+ if (techName) {
434
+ p.drawText(techName, {
435
+ x: margin + profileSize + 5,
436
+ y: footerY + 6,
437
+ size: 8,
438
+ font: fontRegular,
439
+ color: colors.darkGray
440
+ });
441
+ }
442
+ } else if (techName) {
443
+ p.drawText(techName, {
444
+ x: margin,
445
+ y: footerY + 6,
446
+ size: 8,
447
+ font: fontRegular,
448
+ color: colors.darkGray
449
+ });
450
+ }
451
+
452
+ // Page number (center)
453
+ const pageNumText = 'Page ' + (i + 1) + ' of ' + pages.length;
454
+ const pageNumWidth = fontRegular.widthOfTextAtSize(pageNumText, 8);
455
+ p.drawText(pageNumText, {
456
+ x: (pageWidth - pageNumWidth) / 2,
457
+ y: footerY + 6,
458
+ size: 8,
459
+ font: fontRegular,
460
+ color: colors.darkGray
461
+ });
462
+
463
+ // mQ logo or company logo (right side)
464
+ const footerLogo = companyImage || mqLogo;
465
+ if (footerLogo) {
466
+ const footerLogoHeight = 18;
467
+ const footerLogoWidth = footerLogoHeight * (footerLogo.width / footerLogo.height);
468
+ p.drawImage(footerLogo, {
469
+ x: pageWidth - margin - footerLogoWidth,
470
+ y: footerY,
471
+ width: footerLogoWidth,
472
+ height: footerLogoHeight
473
+ });
474
+ }
475
+ }
476
+
477
+ // Save and return
478
+ return {
479
+ status: 200,
480
+ data: await pdfDoc.saveAsBase64({ dataUri: true })
481
+ };
482
+
483
+ } catch (error) {
484
+ console.error('Error generating AI Summary report:', error);
485
+ return {
486
+ status: 400,
487
+ data: error.message || error
488
+ };
489
+ }
490
+ }
@@ -3,6 +3,7 @@ import * as base64 from "../base-64/icons.js";
3
3
  import * as pdf from "../base-64/mq-vitals-cooling.js";
4
4
  import * as systemInfoPage from "./system-info-page.js";
5
5
  import * as util from "../util.js";
6
+ import * as aiSummaryPage from "./ai-summary-page.js";
6
7
 
7
8
  // Check if project is CoolMaxx or HeatMaxx (NCI branded)
8
9
  function isNCIProject(payload) {
@@ -420,8 +421,12 @@ export async function getReport(payload, _test) {
420
421
  pdfDoc.insertPage(pdfDoc.getPages().length, _systemInfoPageDoc);
421
422
  }
422
423
 
423
- // flatten and return as base64
424
+ // flatten form fields before adding AI summary pages
424
425
  form.flatten();
426
+
427
+ // Add AI summary page if enabled and summary exists
428
+ await aiSummaryPage.addAiSummaryPages(pdfDoc, payload);
429
+
425
430
  return { status: 200, data: await pdfDoc.saveAsBase64({ dataUri: true }) };
426
431
  } catch (error) {
427
432
  return { status: 400, data: error };
@@ -2,8 +2,8 @@ import { PDFDocument } from "pdf-lib";
2
2
  import * as base64 from "../base-64/icons.js";
3
3
  import * as pdf from "../base-64/mq-vitals-heating.js";
4
4
  import * as systemInfoPage from "./system-info-page.js";
5
-
6
5
  import * as util from "../util.js";
6
+ import * as aiSummaryPage from "./ai-summary-page.js";
7
7
 
8
8
  // Check if project is CoolMaxx or HeatMaxx (NCI branded)
9
9
  function isNCIProject(payload) {
@@ -628,8 +628,12 @@ export async function getReport(payload, _test) {
628
628
  pdfDoc.insertPage(pdfDoc.getPages().length, _systemInfoPageDoc);
629
629
  }
630
630
 
631
- // flatten and return as base64
631
+ // flatten form fields before adding AI summary pages
632
632
  form.flatten();
633
+
634
+ // Add AI summary page if enabled and summary exists
635
+ await aiSummaryPage.addAiSummaryPages(pdfDoc, payload);
636
+
633
637
  return { status: 200, data: await pdfDoc.saveAsBase64({ dataUri: true }) };
634
638
  } catch (error) {
635
639
  return { status: 400, data: error };
@@ -0,0 +1,339 @@
1
+ import { PDFDocument, rgb, StandardFonts } from "pdf-lib";
2
+ import * as base64 from "../base-64/icons.js";
3
+ import * as pdf from "../base-64/mq-vitals-heatpump-heating.js";
4
+ import * as systemInfoPage from "./system-info-page.js";
5
+ import * as util from "../util.js";
6
+ import * as aiSummaryPage from "./ai-summary-page.js";
7
+
8
+ // Check if project is CoolMaxx or HeatMaxx (NCI branded)
9
+ function isNCIProject(payload) {
10
+ if (payload && payload.project && payload.project.specialProjectType) {
11
+ const type = payload.project.specialProjectType;
12
+ return type === 'CoolMaxx' || type === 'HeatMaxx';
13
+ }
14
+ return false;
15
+ }
16
+
17
+ export async function getReport(payload, _test) {
18
+ try {
19
+ // fetch and load form
20
+ const formPdfBytes = util._base64ToArrayBuffer(pdf.base64);
21
+ const pdfDoc = await PDFDocument.load(formPdfBytes);
22
+
23
+ // Embed font for custom text overlays
24
+ const helveticaBold = await pdfDoc.embedFont(StandardFonts.HelveticaBold);
25
+ const helvetica = await pdfDoc.embedFont(StandardFonts.Helvetica);
26
+
27
+ // fetch resources
28
+ const iconRangeGreenBytes = util._base64ToArrayBuffer(base64.iconRangeGreen);
29
+ const iconRangeRedBytes = util._base64ToArrayBuffer(base64.iconRangeRed);
30
+ const iconFlagBlackBytes = util._base64ToArrayBuffer(base64.iconFlagBlack);
31
+ const iconFlagGreenBytes = util._base64ToArrayBuffer(base64.iconFlagGreen);
32
+ const iconFlagRedBytes = util._base64ToArrayBuffer(base64.iconFlagRed);
33
+ const iconFlagYellowBytes = util._base64ToArrayBuffer(base64.iconFlagYellow);
34
+ const iconStabilityBytes = util._base64ToArrayBuffer(base64.iconStability);
35
+ const iconThumbprintGreenBytes = util._base64ToArrayBuffer(base64.iconThumbprintGreen);
36
+ const iconThumbprintRedBytes = util._base64ToArrayBuffer(base64.iconThumbprintRed);
37
+ const mqLogoBytes = util._base64ToArrayBuffer(base64.mqLogo);
38
+
39
+ // load resources
40
+ const iconRangeGreen = await pdfDoc.embedPng(iconRangeGreenBytes);
41
+ const iconRangeRed = await pdfDoc.embedPng(iconRangeRedBytes);
42
+ const iconFlagBlack = await pdfDoc.embedPng(iconFlagBlackBytes);
43
+ const iconFlagGreen = await pdfDoc.embedPng(iconFlagGreenBytes);
44
+ const iconFlagRed = await pdfDoc.embedPng(iconFlagRedBytes);
45
+ const iconFlagYellow = await pdfDoc.embedPng(iconFlagYellowBytes);
46
+ const iconStability = await pdfDoc.embedPng(iconStabilityBytes);
47
+ const iconThumbprintGreen = await pdfDoc.embedPng(iconThumbprintGreenBytes);
48
+ const iconThumbprintRed = await pdfDoc.embedPng(iconThumbprintRedBytes);
49
+ const mqLogo = await pdfDoc.embedPng(mqLogoBytes);
50
+
51
+ // embed company logo and profile picture
52
+ let profileImage, companyImage;
53
+ let profileImageType = util.checkProfilePicture(payload.meta);
54
+ let companyImageType = util.checkCompanyLogo(payload.meta);
55
+ if (profileImageType) {
56
+ if (profileImageType.shape == "square")
57
+ profileImage = payload.meta.profile_settings.profilePicSquareBase64;
58
+ else if (profileImageType.shape == "circle")
59
+ profileImage = payload.meta.profile_settings.profilePicCircleBase64;
60
+ if (profileImageType.type == "jpg")
61
+ profileImage = await pdfDoc.embedJpg(util._base64ToArrayBuffer(profileImage));
62
+ else if (profileImageType.type == "png")
63
+ profileImage = await pdfDoc.embedPng(util._base64ToArrayBuffer(profileImage));
64
+ }
65
+ if (companyImageType == "jpg")
66
+ companyImage = await pdfDoc.embedJpg(
67
+ util._base64ToArrayBuffer(payload.meta.profile_settings.companyLogoBase64)
68
+ );
69
+ else if (companyImageType == "png")
70
+ companyImage = await pdfDoc.embedPng(
71
+ util._base64ToArrayBuffer(payload.meta.profile_settings.companyLogoBase64)
72
+ );
73
+
74
+ // get form for filling
75
+ const form = pdfDoc.getForm();
76
+
77
+ // print system diagnostics section
78
+ let t = payload.tests[0].testInfo;
79
+ if (_test) t = _test.testInfo;
80
+ if (
81
+ t &&
82
+ t.diagnostics &&
83
+ t.diagnostics.multi_point_analysis &&
84
+ t.diagnostics.multi_point_analysis.length > 0
85
+ ) {
86
+ for (let i = 0; i < 9; i++) {
87
+ let d = t.diagnostics.multi_point_analysis[i];
88
+ if (d) {
89
+ let diagnosticIcon = iconFlagBlack;
90
+ if (d.type === "stability") diagnosticIcon = iconStability;
91
+ else if (d.score > 1 && d.score < 10) diagnosticIcon = iconFlagRed;
92
+ else if (d.score >= 10 && d.score < 15) diagnosticIcon = iconFlagYellow;
93
+ form.getButton(`ImageDiagnostics${i + 1}_af_image`).setImage(diagnosticIcon);
94
+ form.getTextField(`SystemDiagnostics${i + 1}`).setText(`${d.title}`);
95
+ if (diagnosticIcon !== iconFlagYellow)
96
+ form.getTextField(`SystemDiagnostics${i + 1}a`).setText(`${d.score}`);
97
+ }
98
+ }
99
+ } else {
100
+ form.getButton("ImageDiagnostics1_af_image").setImage(iconFlagGreen);
101
+ form.getTextField("SystemDiagnostics1").setText("No system-wide issues were detected.");
102
+ }
103
+
104
+ // Set header for heat pump heating
105
+ let header = payload.meta.header ? payload.meta.header : "Heat Pump Heating Vitals";
106
+ form.getTextField("Header").setText(header);
107
+
108
+ // Get text fields for heat pump heating
109
+ let textFields = getTextFields(payload, t);
110
+
111
+ // For NCI projects, handle logo placement
112
+ let isNCI = isNCIProject(payload) && base64.nciLogo;
113
+ if (isNCI) {
114
+ const nciLogoImage = await pdfDoc.embedPng(util._base64ToArrayBuffer(base64.nciLogo));
115
+ const pages = pdfDoc.getPages();
116
+ const profilePictureButton = form.getButton("ProfilePicture");
117
+ const widgets = profilePictureButton.acroField.getWidgets();
118
+ for (let i = 0; i < widgets.length; i++) {
119
+ const rect = widgets[i].getRectangle();
120
+ const page = pages[i] || pages[0];
121
+ const logoWidth = rect.width * 1.15;
122
+ const logoHeight = logoWidth * (nciLogoImage.height / nciLogoImage.width);
123
+ const x = rect.x + (rect.width - logoWidth) / 2;
124
+ const y = rect.y + (rect.height - logoHeight) / 2;
125
+ page.drawImage(nciLogoImage, { x, y, width: logoWidth, height: logoHeight });
126
+ }
127
+ if (companyImage) {
128
+ form.getButton("HalfWidthLogo").setImage(companyImage);
129
+ } else {
130
+ form.getButton("HalfWidthLogo").setImage(mqLogo);
131
+ }
132
+ if (payload.meta.profile_settings.techFirstName || payload.meta.profile_settings.techLastName)
133
+ form.getTextField("TechName").setText(
134
+ `${payload.meta.profile_settings.techFirstName} ${payload.meta.profile_settings.techLastName}`
135
+ );
136
+ }
137
+
138
+ // print company logo and profile picture
139
+ if (!isNCI && profileImage && companyImage) {
140
+ form.getButton("ProfilePicture").setImage(profileImage);
141
+ form.getButton("HalfWidthLogo").setImage(companyImage);
142
+ if (payload.meta.profile_settings.techFirstName || payload.meta.profile_settings.techLastName)
143
+ form.getTextField("TechName").setText(
144
+ `${payload.meta.profile_settings.techFirstName} ${payload.meta.profile_settings.techLastName}`
145
+ );
146
+ } else if (!isNCI && profileImage) {
147
+ form.getButton("ProfilePicture").setImage(profileImage);
148
+ form.getButton("HalfWidthLogo").setImage(mqLogo);
149
+ if (payload.meta.profile_settings.techFirstName || payload.meta.profile_settings.techLastName)
150
+ form.getTextField("TechName").setText(
151
+ `${payload.meta.profile_settings.techFirstName} ${payload.meta.profile_settings.techLastName}`
152
+ );
153
+ } else if (!isNCI) {
154
+ if (companyImage) form.getButton("FullWidthLogo").setImage(companyImage);
155
+ else form.getButton("FullWidthLogo").setImage(mqLogo);
156
+ }
157
+
158
+ // print customer data
159
+ let date = new Date();
160
+ if (textFields.cName && textFields.address && textFields.city && textFields.state && textFields.zip) {
161
+ form.getTextField("Name").setText(`${textFields.cName}`);
162
+ form.getTextField("Address1").setText(`${textFields.address}`);
163
+ if (textFields.address2) {
164
+ form.getTextField("Address2").setText(`${textFields.address2}`);
165
+ form.getTextField("CityStateZip").setText(`${textFields.city}${textFields.state}${textFields.zip}`);
166
+ } else {
167
+ form.getTextField("Address2").setText(`${textFields.city}${textFields.state}${textFields.zip}`);
168
+ }
169
+ form.getTextField("DateOfServiceLabelUpper").setText("");
170
+ form.getTextField("TimeOfServiceLabelUpper").setText("");
171
+ form.getTextField("DateOfServiceLabelLower").setText("Date of Service:");
172
+ form.getTextField("TimeOfServiceLabelLower").setText("Time of Service:");
173
+ form.getTextField("DateOfServiceLower").setText(date.toLocaleDateString("en-US"));
174
+ form.getTextField("TimeOfServiceLower").setText(date.toLocaleTimeString("en-US"));
175
+ } else {
176
+ form.getTextField("DateOfServiceLabelLower").setText("");
177
+ form.getTextField("TimeOfServiceLabelLower").setText("");
178
+ form.getTextField("DateOfServiceLabelUpper").setText("Date of Service:");
179
+ form.getTextField("TimeOfServiceLabelUpper").setText("Time of Service:");
180
+ form.getTextField("DateOfServiceUpper").setText(date.toLocaleDateString("en-US"));
181
+ form.getTextField("TimeOfServiceUpper").setText(date.toLocaleTimeString("en-US"));
182
+ }
183
+
184
+ // System score
185
+ let systemScorePercentage = t.data.vitals_score ? +t.data.vitals_score.toFixed(0) : 0;
186
+ let systemScoreGrade = util.getGradeFromScore(systemScorePercentage);
187
+ let systemScoreColor = util.getColorFromGrade(systemScoreGrade);
188
+ if (!t.data.vitals_score) {
189
+ systemScorePercentage = "--";
190
+ systemScoreGrade = "";
191
+ } else {
192
+ systemScorePercentage += "%";
193
+ }
194
+
195
+ // Print system score
196
+ form.getTextField(`YourSystemScore${systemScoreColor}`).setText(`${systemScorePercentage} ${systemScoreGrade}`);
197
+
198
+ // Heat pump heating measurements (using existing form fields with different data)
199
+ form.getTextField("Superheat").setText(`${textFields.tempRise} °${payload.units.temperature} Rise`);
200
+ form.getTextField("Subcooling").setText(`${textFields.llt} °${payload.units.temperature} LLT`);
201
+ form.getTextField("CondenserApproach").setText(`${textFields.cop} COP`);
202
+ form.getTextField("TemperatureSplit").setText(`${textFields.oat} °${payload.units.temperature} OAT`);
203
+ form.getTextField("TotalExternalStaticPressure").setText(`${textFields.tesp} inH2O`);
204
+ form.getTextField("FilterFaceVelocity").setText(`${textFields.airflow} CFM`);
205
+
206
+ // Heat pump heating scoring factors (points-based, not losses)
207
+ let ptsFilter = textFields.ptsFilter;
208
+ let ptsA = textFields.ptsA;
209
+ let ptsB = textFields.ptsB;
210
+ let ptsC = textFields.ptsC;
211
+ let ptsD = textFields.ptsD;
212
+
213
+ // Determine colors based on points (green if >= 15, red otherwise)
214
+ let filterColor = ptsFilter !== "--" && +ptsFilter >= 15 ? "Green" : "Red";
215
+ let aColor = ptsA !== "--" && +ptsA >= 15 ? "Green" : "Red";
216
+ let bColor = ptsB !== "--" && +ptsB >= 15 ? "Green" : "Red";
217
+ let cColor = ptsC !== "--" && +ptsC >= 15 ? "Green" : "Red";
218
+ let dColor = ptsD !== "--" && +ptsD >= 15 ? "Green" : "Red";
219
+
220
+ // Set scoring factor values
221
+ form.getTextField(`AgeLosses${filterColor}`).setText(`${ptsFilter}/20`);
222
+ form.getTextField(`TemperatureSplitLosses${aColor}`).setText(`${ptsA}/20`);
223
+ form.getTextField(`StaticPressureLosses${bColor}`).setText(`${ptsB}/20`);
224
+ form.getTextField(`ApproachLosses${cColor}`).setText(`${ptsC}/20`);
225
+ form.getTextField(`RefrigerantChargeIssues${dColor}`).setText(`${ptsD}/20`);
226
+
227
+ form.getTextField("YourSystemScorePage2").setText(`${systemScorePercentage} ${systemScoreGrade}`);
228
+
229
+ // Get pages for label overlays
230
+ const pages = pdfDoc.getPages();
231
+
232
+ // Overlay custom labels for heat pump heating on page 2 (factors page)
233
+ // Note: These positions may need adjustment based on actual PDF layout
234
+ if (pages.length >= 2) {
235
+ const page2 = pages[1];
236
+ const labelFontSize = 9;
237
+ const labelColor = rgb(0.2, 0.2, 0.2);
238
+
239
+ // Draw white rectangles to cover original labels, then draw new labels
240
+ // Factor 1: Static Pressure (was Age & Efficiency)
241
+ page2.drawRectangle({ x: 45, y: 635, width: 150, height: 12, color: rgb(1, 1, 1) });
242
+ page2.drawText("Static Pressure", { x: 47, y: 637, size: labelFontSize, font: helveticaBold, color: labelColor });
243
+
244
+ // Factor 2: Air-Side Heat Delivery (was Temperature Split)
245
+ page2.drawRectangle({ x: 45, y: 580, width: 150, height: 12, color: rgb(1, 1, 1) });
246
+ page2.drawText("Air-Side Heat Delivery", { x: 47, y: 582, size: labelFontSize, font: helveticaBold, color: labelColor });
247
+
248
+ // Factor 3: Capacity Delivery (was Static Pressure)
249
+ page2.drawRectangle({ x: 45, y: 525, width: 150, height: 12, color: rgb(1, 1, 1) });
250
+ page2.drawText("Capacity Delivery", { x: 47, y: 527, size: labelFontSize, font: helveticaBold, color: labelColor });
251
+
252
+ // Factor 4: System Balance (was Approach)
253
+ page2.drawRectangle({ x: 45, y: 470, width: 150, height: 12, color: rgb(1, 1, 1) });
254
+ page2.drawText("System Balance", { x: 47, y: 472, size: labelFontSize, font: helveticaBold, color: labelColor });
255
+
256
+ // Factor 5: Efficiency (COP) (was Refrigerant Charge)
257
+ page2.drawRectangle({ x: 45, y: 415, width: 150, height: 12, color: rgb(1, 1, 1) });
258
+ page2.drawText("Efficiency (COP)", { x: 47, y: 417, size: labelFontSize, font: helveticaBold, color: labelColor });
259
+ }
260
+
261
+ // Print targets and range icons (using appropriate values for heat pump heating)
262
+ const measureLabels = ["Superheat", "Subcooling", "Condenser", "TempSplit", "Tesp", "FilterFace"];
263
+ for (let i = 0; i < measureLabels.length; i++) {
264
+ // For heat pump heating, show green icons for valid measurements
265
+ let icon = iconRangeGreen;
266
+ let iconPlacement = "Mid";
267
+ try {
268
+ form.getButton(`Image${measureLabels[i]}${iconPlacement}_af_image`).setImage(icon);
269
+ } catch (e) {
270
+ // Field may not exist
271
+ }
272
+ // Clear target zone text for heat pump heating (different targets apply)
273
+ try {
274
+ form.getTextField(`${measureLabels[i]}Target`).setText("");
275
+ } catch (e) {
276
+ // Field may not exist
277
+ }
278
+ }
279
+
280
+ // Skip pass/fail subsystem review for heat pump heating
281
+ form.getTextField("SSR1").setText("Heat Pump Heating Mode");
282
+
283
+ // Add system info page
284
+ if (payload.meta.report_type != "FullReport") {
285
+ let systemInfoPageBase64 = await systemInfoPage.getReport(payload);
286
+ const systemInfoPageDoc = systemInfoPageBase64
287
+ ? await PDFDocument.load(util._base64ToArrayBuffer(systemInfoPageBase64.data))
288
+ : false;
289
+ const [_systemInfoPageDoc] = systemInfoPageDoc
290
+ ? await pdfDoc.copyPages(systemInfoPageDoc, [0])
291
+ : false;
292
+ if (_systemInfoPageDoc) pdfDoc.insertPage(pdfDoc.getPages().length, _systemInfoPageDoc);
293
+ }
294
+
295
+ // flatten form fields before adding AI summary pages
296
+ form.flatten();
297
+
298
+ // Add AI summary page if enabled and summary exists
299
+ await aiSummaryPage.addAiSummaryPages(pdfDoc, payload);
300
+
301
+ return { status: 200, data: await pdfDoc.saveAsBase64({ dataUri: true }) };
302
+ } catch (error) {
303
+ console.error("[VitalsHeatPumpHeatingReport] Error:", error);
304
+ return { status: 400, data: error };
305
+ }
306
+ }
307
+
308
+ function getTextFields(payload, test) {
309
+ // Calculate temperature rise
310
+ let tempRise = "--";
311
+ if (test.data.temperature_leaving_dry_bulb !== undefined && test.data.temperature_entering_dry_bulb !== undefined) {
312
+ tempRise = (test.data.temperature_leaving_dry_bulb - test.data.temperature_entering_dry_bulb).toFixed(1);
313
+ }
314
+
315
+ return {
316
+ cName:
317
+ payload.site.customer.first_name && payload.site.customer.last_name
318
+ ? `${payload.site.customer.first_name} ${payload.site.customer.last_name}`
319
+ : "",
320
+ address: payload.site.location.address ? payload.site.location.address : "",
321
+ address2: payload.site.location.address2 ? payload.site.location.address2 : "",
322
+ city: payload.site.location.city ? payload.site.location.city : "",
323
+ state: payload.site.location.state ? `, ${payload.site.location.state}` : "",
324
+ zip: payload.site.location.zip ? ` ${payload.site.location.zip}` : "",
325
+ // Heat pump heating specific fields
326
+ tempRise: tempRise,
327
+ llt: test.data.temperature_liquid_line !== undefined ? (+test.data.temperature_liquid_line).toFixed(1) : "--",
328
+ cop: test.data.cop !== undefined ? (+test.data.cop).toFixed(2) : "--",
329
+ oat: test.data.temperature_outdoor_dry_bulb !== undefined ? (+test.data.temperature_outdoor_dry_bulb).toFixed(1) : "--",
330
+ tesp: test.data.pressure_static_total_external !== undefined ? (+test.data.pressure_static_total_external).toFixed(2) : "--",
331
+ airflow: test.data.airflow !== undefined ? (+test.data.airflow).toFixed(0) : "--",
332
+ // Heat pump heating scoring factors (points earned, not losses)
333
+ ptsFilter: test.data.hp_heating_pts_filter !== undefined ? (+test.data.hp_heating_pts_filter).toFixed(0) : "--",
334
+ ptsA: test.data.hp_heating_pts_a !== undefined ? (+test.data.hp_heating_pts_a).toFixed(0) : "--",
335
+ ptsB: test.data.hp_heating_pts_b !== undefined ? (+test.data.hp_heating_pts_b).toFixed(0) : "--",
336
+ ptsC: test.data.hp_heating_pts_c !== undefined ? (+test.data.hp_heating_pts_c).toFixed(0) : "--",
337
+ ptsD: test.data.hp_heating_pts_d !== undefined ? (+test.data.hp_heating_pts_d).toFixed(0) : "--",
338
+ };
339
+ }
package/util.js CHANGED
@@ -1006,3 +1006,74 @@ function prettifyData(val, type, units) {
1006
1006
  // reader.readAsDataURL(file);
1007
1007
  // }
1008
1008
  // }
1009
+
1010
+ // AI Summary helper functions
1011
+ export function wrapText(text, font, fontSize, maxWidth) {
1012
+ if (!text) return [];
1013
+ const words = text.split(' ');
1014
+ const lines = [];
1015
+ let currentLine = '';
1016
+
1017
+ for (const word of words) {
1018
+ const testLine = currentLine ? currentLine + ' ' + word : word;
1019
+ const testWidth = font.widthOfTextAtSize(testLine, fontSize);
1020
+
1021
+ if (testWidth > maxWidth && currentLine) {
1022
+ lines.push(currentLine);
1023
+ currentLine = word;
1024
+ } else {
1025
+ currentLine = testLine;
1026
+ }
1027
+ }
1028
+
1029
+ if (currentLine) {
1030
+ lines.push(currentLine);
1031
+ }
1032
+
1033
+ return lines;
1034
+ }
1035
+
1036
+ export function wrapTextWithNewlines(text, font, fontSize, maxWidth) {
1037
+ if (!text) return [];
1038
+ const paragraphs = text.split('\n');
1039
+ const allLines = [];
1040
+
1041
+ for (const paragraph of paragraphs) {
1042
+ if (paragraph.trim() === '') {
1043
+ allLines.push('');
1044
+ } else {
1045
+ const wrappedLines = wrapText(paragraph, font, fontSize, maxWidth);
1046
+ allLines.push(...wrappedLines);
1047
+ }
1048
+ }
1049
+
1050
+ return allLines;
1051
+ }
1052
+
1053
+ export function shouldShowAiSummary(payload) {
1054
+ // Check if toggle is enabled in pdf_settings
1055
+ if (payload.meta && payload.meta.pdf_settings && payload.meta.pdf_settings.showAiSummaryOnReport === false) {
1056
+ return false;
1057
+ }
1058
+ // Check if AI summary exists
1059
+ const aiSummary = getAiSummary(payload);
1060
+ return aiSummary && aiSummary.trim().length > 0 && aiSummary !== 'No AI summary available.';
1061
+ }
1062
+
1063
+ export function getAiSummary(payload) {
1064
+ // Check multiple possible sources for AI summary
1065
+ if (payload.ai_summary) return payload.ai_summary;
1066
+ if (payload.project && payload.project.ai_summary) return payload.project.ai_summary;
1067
+ if (payload.project && payload.project.mq_ai_summary) return payload.project.mq_ai_summary;
1068
+ return null;
1069
+ }
1070
+
1071
+ export function getAiNotes(payload) {
1072
+ // Check multiple possible sources for notes
1073
+ if (payload.notes) return payload.notes;
1074
+ if (payload.project && payload.project.notes) return payload.project.notes;
1075
+ if (payload.tests && payload.tests[0] && payload.tests[0].testInfo && payload.tests[0].testInfo.notes) {
1076
+ return payload.tests[0].testInfo.notes;
1077
+ }
1078
+ return null;
1079
+ }