From effe22c4b5334af3a993213175e47f9ed9d31e90 Mon Sep 17 00:00:00 2001 From: "jorming.chong" Date: Thu, 8 Jan 2026 09:03:40 +0800 Subject: [PATCH] :sparkles: add event report generation --- event_report.xlsx | Bin 31178 -> 31190 bytes src/controllers/ReadingReportController.ts | 121 +++++++++++++++++- src/shared/constants/reading.ts | 27 ++++ src/shared/contracts/readingReportContract.ts | 8 +- 4 files changed, 150 insertions(+), 6 deletions(-) diff --git a/event_report.xlsx b/event_report.xlsx index 3512d84d7844e0ec439a51cf07c9f2ed22f386e9..ef63cdaeacce2f44b107b6f0e9025b1ee3b97030 100644 GIT binary patch delta 2567 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 diff --git a/src/controllers/ReadingReportController.ts b/src/controllers/ReadingReportController.ts index e714825..84fd5e3 100644 --- a/src/controllers/ReadingReportController.ts +++ b/src/controllers/ReadingReportController.ts @@ -20,6 +20,17 @@ interface DeviceData { readings: Record>; } +interface EventData { + building: string; + floor: string; + device_name: string; + event_time: string; + event_type: string; + temperature: string; + humidity: string; + battery: string; +} + interface ReportParams { start_date?: string; end_date?: string; @@ -65,7 +76,7 @@ export class ReadingReportController extends Controller { // ---------------- Title ---------------- worksheet.mergeCells('B1:G2'); const titleCell = worksheet.getCell('B1'); - titleCell.value = 'Event Report'; + titleCell.value = 'Reading Report'; titleCell.alignment = { horizontal: 'center', vertical: 'middle' }; titleCell.font = { bold: true, size: 18 }; @@ -169,10 +180,10 @@ export class ReadingReportController extends Controller { col.width = max + 2; }); - worksheet.autoFilter = { - from: { row: headerRowIndex, column: 1 }, - to: { row: currentRow - 1, column: headers.length }, - }; + // worksheet.autoFilter = { + // from: { row: headerRowIndex, column: 1 }, + // to: { row: currentRow - 1, column: headers.length }, + // }; // ---------------- Save file ---------------- const outputPath = path.resolve('event_report.xlsx'); @@ -183,6 +194,106 @@ export class ReadingReportController extends Controller { return createFileOutput(readStream, 'event_report.xlsx'); }, }, + generateEventsReport: { + handler: async (FullEventSchema) => { + const { data, report_params } = FullEventSchema; + + const workbook = new ExcelJS.Workbook(); + const worksheet = workbook.addWorksheet('Event Records'); + + // ---------------- Logos ---------------- + const jecLogoId = workbook.addImage({ + filename: path.resolve('asset/jec_logo.jpg'), + extension: 'jpeg', + }); + const fastLogoId = workbook.addImage({ + filename: path.resolve('asset/fast_logo.jpg'), + extension: 'jpeg', + }); + + worksheet.addImage(jecLogoId, { + tl: { col: 0, row: 0 }, + ext: { width: 120, height: 120 }, + }); + worksheet.addImage(fastLogoId, { + tl: { col: 7, row: 0 }, + ext: { width: 120, height: 60 }, + }); + + // ---------------- Title ---------------- + worksheet.mergeCells('B1:G2'); + const titleCell = worksheet.getCell('B1'); + titleCell.value = 'Event Report'; + titleCell.alignment = { horizontal: 'center', vertical: 'middle' }; + titleCell.font = { bold: true, size: 18 }; + + // ---------------- Fixed params ---------------- + worksheet.getCell(4, 2).value = 'Start Date:'; + worksheet.getCell(4, 3).value = report_params.start_date; + worksheet.getCell(5, 2).value = 'End Date:'; + worksheet.getCell(5, 3).value = report_params.end_date; + + // ---------------- Headers ---------------- + const headers = [ + 'Building', + 'Floor', + 'Device Name', + 'Event Time', + 'Event Type', + 'Temperature', + 'Humidity', + 'Battery', + ]; + + const headerRowIndex = 7; + const headerRow = worksheet.getRow(headerRowIndex); + headerRow.values = headers; + headerRow.eachCell((cell) => { + cell.font = { bold: true }; + cell.fill = { + type: 'pattern', + pattern: 'solid', + fgColor: { argb: 'FFD9D9D9' }, + }; + }); + + // ---------------- Data ---------------- + let currentRow = headerRowIndex + 1; + + data.forEach((event) => { + const row = worksheet.getRow(currentRow); + row.getCell(1).value = event.building; + row.getCell(2).value = event.floor; + row.getCell(3).value = event.device_name; + row.getCell(4).value = event.event_time; + row.getCell(5).value = event.event_type; + row.getCell(6).value = event.temperature; + row.getCell(7).value = event.humidity; + row.getCell(8).value = event.battery; + + currentRow++; + }); + + // ---------------- Auto-width ---------------- + worksheet.columns.forEach((col) => { + let max = 10; + col.eachCell!({ includeEmpty: true }, (cell) => { + const len = String(cell.value ?? '').length; + max = Math.max(max, len); + }); + col.width = max + 2; + }); + + // ---------------- Save file ---------------- + const outputPath = path.resolve('event_report.xlsx'); + await workbook.xlsx.writeFile(outputPath); + console.log(`Excel report generated: ${outputPath}`); + + // ---------------- Return as stream ---------------- + const readStream = fs.createReadStream(outputPath); + return createFileOutput(readStream, 'event_report.xlsx'); // your helper + }, + }, }), ); } diff --git a/src/shared/constants/reading.ts b/src/shared/constants/reading.ts index 0af326f..71ddf00 100644 --- a/src/shared/constants/reading.ts +++ b/src/shared/constants/reading.ts @@ -51,3 +51,30 @@ export const FullReportInput = z.object({ }); export type FullReportInput = z.infer; + +export const FullEventInput = z.object({ + data: z.array( + z.object({ + building: z.string(), + floor: z.string(), + device_name: z.string(), + event_time: z.string().refine((val) => !isNaN(Date.parse(val)), { + message: 'Invalid event_time', + }), + event_type: z.string(), + temperature: z.string(), + humidity: z.string(), + battery: z.string(), + }), + ), + report_params: z.object({ + start_date: z.string().refine((val) => !isNaN(Date.parse(val)), { + message: 'Invalid start_date', + }), + end_date: z.string().refine((val) => !isNaN(Date.parse(val)), { + message: 'Invalid end_date', + }), + }), +}); + +export type FullEventInput = z.infer; diff --git a/src/shared/contracts/readingReportContract.ts b/src/shared/contracts/readingReportContract.ts index 67a7166..683108b 100644 --- a/src/shared/contracts/readingReportContract.ts +++ b/src/shared/contracts/readingReportContract.ts @@ -1,5 +1,5 @@ import { A, buildContract } from '@jig-software/trest-core'; -import { FullReportInput } from '../constants'; +import { FullReportInput, FullEventInput } from '../constants'; export const readingReportContract = buildContract((c) => c @@ -12,5 +12,11 @@ export const readingReportContract = buildContract((c) => .path('/reading-report') .prepare((p) => p.body.json(FullReportInput).annotate<[query: A]>()) .parse((p) => p.success.file(200)), + generateEventsReport: (e) => + e + .method('POST') + .path('/event-report') + .prepare((p) => p.body.json(FullEventInput).annotate<[query: A]>()) + .parse((p) => p.success.file(200)), }), );