From 02b68337f2d415319f2eb40eb77a59d72c862d08 Mon Sep 17 00:00:00 2001 From: Kiro Agent <244629292+kiro-agent@users.noreply.github.com> Date: Fri, 29 May 2026 04:02:47 +0000 Subject: [PATCH] feat(reports): PDF report generator wired end-to-end (auth-gated, downloadable) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit BACKEND - New Report model + ReportType / ReportFormat enums; back-relations on User and Industry. Indexed on (createdAt DESC), (industryId, createdAt DESC). - services/reportService.ts: pdfkit-based generation, ~2 MB dependency (vs Puppeteer's ~300 MB). Brand palette + dashed separators match the frontend's sketch-on-paper aesthetic. KPI grid + striped data tables with status color-coding (green/amber/red). Auto-paginates long lists. - AQI section is gated behind a runtime model probe — this PR is independent of PR #7. Once #7 is merged, reports auto-include an AQI summary; otherwise the section is silently skipped. - POST /api/v1/reports/generate create + persist report + write PDF - GET /api/v1/reports list (scoped: INDUSTRY -> own only) - GET /api/v1/reports/:id/download stream PDF with auth + ownership - DELETE /api/v1/reports/:id remove DB row + best-effort file - All routes auth-protected via requireAuth middleware (PR #5). - Files stored under backend/files/reports/.pdf, .gitignored. - New deps: pdfkit, @types/pdfkit. FRONTEND - src/lib/reports.ts: typed list / generate / delete / download helpers, uiTypeToBackend mapper for the existing UI label strings, formatBytes. - App.tsx Reports component: -> Loads real reports from /api/v1/reports on mount (with loading state) -> 'GENERATE REPORT' button now hits backend; period selector mapped to ISO date range (Current Month, Last Month, Q1/Q2 2024, Full Year) -> Report cards show real metadata (title, generatedAt, sizeBytes, pageCount, industry) -> 'DOWNLOAD' button per row uses authenticated fetch + Blob trigger (browsers can't send Authorization on plain anchor downloads) -> Inline error band for both load and generate failures -> Removed the localStorage S.get/S.set scaffolding for reports Migration required after pulling: cd backend && npm install npx prisma migrate dev --name add_reports Independent of PR #7 (realtime AQI). Either merge order works. AQI section of the PDF appears once both this PR and #7 are merged. --- backend/.gitignore | 3 + backend/package.json | 2 + backend/prisma/schema.prisma | 50 ++ backend/src/controllers/report.controller.ts | 160 +++++ backend/src/routes/index.ts | 2 + backend/src/routes/report.routes.ts | 18 + backend/src/services/reportService.ts | 624 +++++++++++++++++++ src/App.tsx | 104 +++- src/lib/reports.ts | 123 ++++ 9 files changed, 1065 insertions(+), 21 deletions(-) create mode 100644 backend/src/controllers/report.controller.ts create mode 100644 backend/src/routes/report.routes.ts create mode 100644 backend/src/services/reportService.ts create mode 100644 src/lib/reports.ts diff --git a/backend/.gitignore b/backend/.gitignore index c2f7419..c2cf08e 100644 --- a/backend/.gitignore +++ b/backend/.gitignore @@ -6,3 +6,6 @@ dist/ *.log coverage/ .DS_Store + +# Generated PDF reports & other ephemeral artifacts +files/ diff --git a/backend/package.json b/backend/package.json index 749d72b..cc1c40b 100644 --- a/backend/package.json +++ b/backend/package.json @@ -27,6 +27,7 @@ "express": "^4.21.2", "helmet": "^8.0.0", "jsonwebtoken": "^9.0.2", + "pdfkit": "^0.15.1", "pino": "^9.6.0", "pino-http": "^10.4.0", "zod": "^3.24.1" @@ -37,6 +38,7 @@ "@types/express": "^4.17.21", "@types/jsonwebtoken": "^9.0.7", "@types/node": "^22.10.2", + "@types/pdfkit": "^0.13.9", "pino-pretty": "^13.0.0", "prisma": "^6.1.0", "tsx": "^4.19.2", diff --git a/backend/prisma/schema.prisma b/backend/prisma/schema.prisma index f172edf..a549188 100644 --- a/backend/prisma/schema.prisma +++ b/backend/prisma/schema.prisma @@ -105,6 +105,7 @@ model User { // Audit linkage emissionLogsSubmitted EmissionLog[] @relation("EmissionLogSubmitter") noticesIssued ComplianceNotice[] @relation("NoticeIssuer") + reportsGenerated Report[] @relation("ReportsGenerated") createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@ -155,6 +156,7 @@ model Industry { inspectorAssignments InspectorAssignment[] emissionLogs EmissionLog[] complianceNotices ComplianceNotice[] + reports Report[] createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@ -314,3 +316,51 @@ model ComplianceNotice { @@index([issuedAt]) @@map("compliance_notices") } + + + +// ----------------------------------------------------------------------------- +// REPORTS — auto-generated PDFs (and future CSVs) stored on local disk. +// ----------------------------------------------------------------------------- + +enum ReportType { + EMISSIONS_MONTHLY + EMISSIONS_QUARTERLY + COMPLIANCE_QUARTERLY + ANNUAL_REPORT + FACILITY_AUDIT + AI_INSIGHTS + CUSTOM +} + +enum ReportFormat { + PDF + CSV +} + +model Report { + id String @id @default(cuid()) + type ReportType + format ReportFormat @default(PDF) + title String + periodStart DateTime? + periodEnd DateTime? + + // Local file artifact: relative path under backend/files/ (e.g. "reports/abc.pdf"). + storageKey String + sizeBytes Int + pageCount Int? + + generatedById String? + generatedBy User? @relation("ReportsGenerated", fields: [generatedById], references: [id], onDelete: SetNull) + + industryId String? + industry Industry? @relation(fields: [industryId], references: [id], onDelete: SetNull) + + createdAt DateTime @default(now()) + + @@index([createdAt(sort: Desc)]) + @@index([generatedById]) + @@index([industryId, createdAt(sort: Desc)]) + @@map("reports") +} diff --git a/backend/src/controllers/report.controller.ts b/backend/src/controllers/report.controller.ts new file mode 100644 index 0000000..17a7b95 --- /dev/null +++ b/backend/src/controllers/report.controller.ts @@ -0,0 +1,160 @@ +import fs from 'node:fs'; +import path from 'node:path'; +import { z } from 'zod'; +import { ReportType } from '@prisma/client'; +import { asyncHandler } from '../utils/asyncHandler'; +import { ApiError } from '../utils/ApiError'; +import { prisma } from '../prisma/client'; +import { generateReport, REPORTS_DIR } from '../services/reportService'; + +const generateSchema = z.object({ + type: z.nativeEnum(ReportType).default(ReportType.EMISSIONS_MONTHLY), + periodStart: z.string().datetime().optional(), + periodEnd: z.string().datetime().optional(), + industryId: z.string().min(1).optional(), +}); + +/** POST /api/v1/reports/generate — create a new PDF report */ +export const generate = asyncHandler(async (req, res) => { + const input = generateSchema.parse(req.body); + + // INDUSTRY users can only generate for their own industry — silently force it. + let industryId = input.industryId; + if (req.user?.role === 'INDUSTRY') { + industryId = req.user.industryId ?? undefined; + } + + const report = await generateReport({ + type: input.type, + periodStart: input.periodStart ? new Date(input.periodStart) : undefined, + periodEnd: input.periodEnd ? new Date(input.periodEnd) : undefined, + industryId: industryId ?? null, + generatedById: req.user?.id ?? null, + }); + + res.status(201).json(toPublic(report)); +}); + +/** GET /api/v1/reports — list recent reports (scoped per role) */ +export const list = asyncHandler(async (req, res) => { + const where = + req.user?.role === 'INDUSTRY' && req.user.industryId + ? { industryId: req.user.industryId } + : {}; + + const reports = await prisma.report.findMany({ + where, + orderBy: { createdAt: 'desc' }, + take: 100, + include: { + generatedBy: { select: { fullName: true, email: true } }, + industry: { select: { name: true, sector: true } }, + }, + }); + + res.json({ + count: reports.length, + reports: reports.map((r) => ({ + ...toPublic(r), + generatedByName: r.generatedBy?.fullName ?? null, + industryName: r.industry?.name ?? null, + })), + }); +}); + +/** GET /api/v1/reports/:id/download — stream the PDF (auth + ownership) */ +export const download = asyncHandler(async (req, res) => { + const { id } = req.params as { id: string }; + const report = await prisma.report.findUnique({ where: { id } }); + if (!report) throw ApiError.notFound('Report not found'); + + if ( + req.user?.role === 'INDUSTRY' && + report.industryId && + report.industryId !== req.user.industryId + ) { + throw ApiError.forbidden('Cannot access reports from other industries'); + } + + const filepath = path.join(REPORTS_DIR, path.basename(report.storageKey)); + if (!fs.existsSync(filepath)) { + throw ApiError.notFound('Report file is missing on disk'); + } + + const safeName = + report.title.replace(/[^\w\-. ]+/g, '_').slice(0, 80) + '.pdf'; + + res.setHeader('Content-Type', 'application/pdf'); + res.setHeader('Content-Length', String(report.sizeBytes)); + res.setHeader( + 'Content-Disposition', + `attachment; filename="${safeName}"`, + ); + + const stream = fs.createReadStream(filepath); + stream.on('error', () => { + if (!res.headersSent) { + res.status(500).json({ + error: { code: 'STREAM_ERROR', message: 'Failed to stream report' }, + }); + } else { + res.end(); + } + }); + stream.pipe(res); +}); + +/** DELETE /api/v1/reports/:id — remove DB row + best-effort file cleanup */ +export const remove = asyncHandler(async (req, res) => { + const { id } = req.params as { id: string }; + const report = await prisma.report.findUnique({ where: { id } }); + if (!report) throw ApiError.notFound('Report not found'); + + if ( + req.user?.role === 'INDUSTRY' && + report.industryId && + report.industryId !== req.user.industryId + ) { + throw ApiError.forbidden('Cannot delete reports from other industries'); + } + + await prisma.report.delete({ where: { id } }); + + // Best-effort file cleanup. Failure here doesn't bubble up — the row is gone. + fs.promises + .unlink(path.join(REPORTS_DIR, path.basename(report.storageKey))) + .catch(() => {}); + + res.status(204).send(); +}); + +// ----------------------------------------------------------------------------- + +function toPublic(r: { + id: string; + type: ReportType; + format: string; + title: string; + periodStart: Date | null; + periodEnd: Date | null; + sizeBytes: number; + pageCount: number | null; + industryId: string | null; + generatedById: string | null; + createdAt: Date; +}) { + return { + id: r.id, + type: r.type, + format: r.format, + title: r.title, + periodStart: r.periodStart, + periodEnd: r.periodEnd, + sizeBytes: r.sizeBytes, + pageCount: r.pageCount, + industryId: r.industryId, + generatedById: r.generatedById, + generatedAt: r.createdAt, + downloadUrl: `/api/v1/reports/${r.id}/download`, + }; +} diff --git a/backend/src/routes/index.ts b/backend/src/routes/index.ts index 3403b96..9204f84 100644 --- a/backend/src/routes/index.ts +++ b/backend/src/routes/index.ts @@ -1,6 +1,7 @@ import { Router } from 'express'; import { healthRouter } from './health.routes'; import { authRouter } from './auth.routes'; +import { reportRouter } from './report.routes'; import publicHeatmapRouter from './publicHeatmap'; /** @@ -11,4 +12,5 @@ export const apiRouter = Router(); apiRouter.use('/health', healthRouter); apiRouter.use('/auth', authRouter); +apiRouter.use('/reports', reportRouter); apiRouter.use('/public', publicHeatmapRouter); diff --git a/backend/src/routes/report.routes.ts b/backend/src/routes/report.routes.ts new file mode 100644 index 0000000..7f97300 --- /dev/null +++ b/backend/src/routes/report.routes.ts @@ -0,0 +1,18 @@ +import { Router } from 'express'; +import { requireAuth } from '../middleware/requireAuth'; +import { + generate, + list, + download, + remove, +} from '../controllers/report.controller'; + +export const reportRouter = Router(); + +// All report endpoints require an authenticated user. +reportRouter.use(requireAuth); + +reportRouter.post('/generate', generate); +reportRouter.get('/', list); +reportRouter.get('/:id/download', download); +reportRouter.delete('/:id', remove); diff --git a/backend/src/services/reportService.ts b/backend/src/services/reportService.ts new file mode 100644 index 0000000..07922fd --- /dev/null +++ b/backend/src/services/reportService.ts @@ -0,0 +1,624 @@ +/** + * Report generation service — produces PDFs from emission + AQI data and + * writes them under `backend/files/reports/.pdf`. The DB row in `reports` + * is the source of truth; the file is the artifact. + * + * Tier-1 design notes: + * - Uses `pdfkit` (~2 MB, no Chromium). Built-in Helvetica is enough for an + * internal compliance report; brand fonts can be loaded later. + * - AQI section is gated behind a runtime check on prisma.airQualityReading + * so this PR is independent of PR #7 (realtime AQI). When PR #7 is merged, + * reports auto-include an AQI summary; otherwise the section is skipped. + * - All numeric values from Prisma `Decimal` columns are coerced via Number() + * to avoid Decimal.js stringification leaking into the PDF. + */ +import fs from 'node:fs'; +import path from 'node:path'; +import { randomUUID } from 'node:crypto'; +import PDFDocument from 'pdfkit'; +import { + Prisma, + ReportFormat, + ReportType, + type Report as ReportRow, +} from '@prisma/client'; +import { prisma } from '../prisma/client'; +import { logger } from '../utils/logger'; + +export const REPORTS_DIR = path.resolve(process.cwd(), 'files', 'reports'); +fs.mkdirSync(REPORTS_DIR, { recursive: true }); + +// Brand palette (matches the sketch-on-paper frontend aesthetic). +const C = { + ink: '#1a1410', + ink2: '#2e2618', + ink3: '#3d3020', + muted: '#6b6357', + paper: '#f0ebe0', + paper2: '#e8e2d4', + red: '#c0392b', + amber: '#b8860b', + green: '#1e8449', +}; + +export interface GenerateOpts { + type: ReportType; + periodStart?: Date; + periodEnd?: Date; + industryId?: string | null; + generatedById?: string | null; +} + +export async function generateReport(opts: GenerateOpts): Promise { + const periodStart = opts.periodStart ?? startOfMonth(new Date()); + const periodEnd = opts.periodEnd ?? new Date(); + + // Industry context (if scoped). + const industry = opts.industryId + ? await prisma.industry.findUnique({ where: { id: opts.industryId } }) + : null; + + // Emission logs in window. + const emissions = await prisma.emissionLog.findMany({ + where: { + ...(opts.industryId && { industryId: opts.industryId }), + recordedAt: { gte: periodStart, lte: periodEnd }, + }, + include: { industry: true }, + orderBy: { recordedAt: 'desc' }, + take: 200, + }); + + // AQI is optional (only present once PR #7 is merged). + const aqi = await safeLatestAqi(periodStart, periodEnd); + + const id = randomUUID(); + const filename = `${id}.pdf`; + const filepath = path.join(REPORTS_DIR, filename); + const storageKey = `reports/${filename}`; + const title = titleFor(opts.type, periodStart, periodEnd, industry?.name); + + const pageCount = await renderPdf({ + filepath, + title, + type: opts.type, + periodStart, + periodEnd, + industry, + emissions, + aqi, + }); + + const stats = fs.statSync(filepath); + const report = await prisma.report.create({ + data: { + type: opts.type, + format: ReportFormat.PDF, + title, + periodStart, + periodEnd, + storageKey, + sizeBytes: stats.size, + pageCount, + generatedById: opts.generatedById ?? null, + industryId: opts.industryId ?? null, + }, + }); + + logger.info( + { reportId: report.id, sizeBytes: stats.size, pages: pageCount }, + 'PDF report generated', + ); + return report; +} + +// ----------------------------------------------------------------------------- +// PDF layout +// ----------------------------------------------------------------------------- + +interface RenderArgs { + filepath: string; + title: string; + type: ReportType; + periodStart: Date; + periodEnd: Date; + industry: Awaited> | null; + emissions: Array< + Prisma.EmissionLogGetPayload<{ include: { industry: true } }> + >; + aqi: Array<{ + city: string; + parameter: string; + value: number; + unit: string; + recordedAt: Date; + }>; +} + +async function renderPdf(args: RenderArgs): Promise { + return new Promise((resolve, reject) => { + const doc = new PDFDocument({ + size: 'A4', + margins: { top: 56, bottom: 64, left: 50, right: 50 }, + info: { + Title: args.title, + Author: 'ClimaCore', + Subject: 'Emission compliance report', + Producer: 'ClimaCore API', + }, + }); + + let pageCount = 1; + doc.on('pageAdded', () => { + pageCount++; + }); + + const stream = fs.createWriteStream(args.filepath); + doc.pipe(stream); + + drawHeader(doc, args); + drawSummary(doc, args); + drawEmissionsTable(doc, args); + drawAqiSection(doc, args); + drawFooter(doc); + + doc.end(); + + stream.on('finish', () => resolve(pageCount)); + stream.on('error', reject); + }); +} + +function drawHeader(doc: PDFKit.PDFDocument, args: RenderArgs) { + // Brand bar + doc + .save() + .rect(0, 0, doc.page.width, 8) + .fillColor(C.ink3) + .fill() + .restore(); + + doc.moveDown(0.5); + doc + .font('Helvetica-Bold') + .fontSize(22) + .fillColor(C.ink) + .text('CLIMACORE', { continued: false }); + doc + .font('Helvetica') + .fontSize(8) + .fillColor(C.muted) + .text('AI-POWERED EMISSION INTELLIGENCE'); + + // Dashed separator + doc.moveDown(0.3); + const sepY = doc.y + 2; + doc + .save() + .strokeColor(C.ink3) + .lineWidth(1.2) + .dash(3, { space: 3 }) + .moveTo(50, sepY) + .lineTo(doc.page.width - 50, sepY) + .stroke() + .undash() + .restore(); + doc.y = sepY + 8; + + // Title block + doc.font('Helvetica-Bold').fontSize(16).fillColor(C.ink).text(args.title); + doc + .font('Helvetica') + .fontSize(9) + .fillColor(C.muted) + .text( + `Period: ${fmtDate(args.periodStart)} → ${fmtDate(args.periodEnd)}`, + ) + .text(`Generated: ${fmtDate(new Date())}`) + .text(`Report type: ${humanType(args.type)}`); + + if (args.industry) { + doc.text( + `Industry: ${args.industry.name} (${args.industry.sector}, ${args.industry.city})`, + ); + } else { + doc.text('Scope: All industries'); + } + doc.moveDown(0.6); +} + +function drawSummary(doc: PDFKit.PDFDocument, args: RenderArgs) { + sectionTitle(doc, 'SUMMARY'); + + const totalCo2 = sum(args.emissions.map((e) => num(e.co2Kg))); + const totalNox = sum(args.emissions.map((e) => num(e.noxKg))); + const totalSox = sum(args.emissions.map((e) => num(e.soxKg))); + const violations = args.emissions.filter( + (e) => e.overallStatus === 'VIOLATION', + ).length; + const warnings = args.emissions.filter( + (e) => e.overallStatus === 'WARNING', + ).length; + const compliant = args.emissions.filter( + (e) => e.overallStatus === 'COMPLIANT', + ).length; + + const cells: Array<[string, string, string?]> = [ + ['Emission records', `${args.emissions.length}`], + ['AQI readings', `${args.aqi.length}`], + ['Total CO₂', `${fmt(totalCo2)} kg`], + ['Total NOₓ', `${fmt(totalNox)} kg`], + ['Total SOₓ', `${fmt(totalSox)} kg`], + ['Compliant', `${compliant}`, C.green], + ['Warning', `${warnings}`, C.amber], + ['Violation', `${violations}`, C.red], + ]; + + drawKpiGrid(doc, cells); + doc.moveDown(0.6); +} + +function drawEmissionsTable(doc: PDFKit.PDFDocument, args: RenderArgs) { + sectionTitle(doc, 'EMISSION RECORDS'); + + if (args.emissions.length === 0) { + doc + .font('Helvetica-Oblique') + .fontSize(10) + .fillColor(C.muted) + .text('No emission records were submitted in this period.', { + align: 'center', + }); + doc.moveDown(0.6); + return; + } + + const rows: string[][] = [ + ['Recorded', 'Industry', 'CO₂ (kg)', 'NOₓ (kg)', 'SOₓ (kg)', 'Status'], + ]; + for (const e of args.emissions.slice(0, 30)) { + rows.push([ + fmtDate(e.recordedAt), + e.industry?.name ?? '—', + fmt(num(e.co2Kg)), + fmt(num(e.noxKg)), + fmt(num(e.soxKg)), + e.overallStatus, + ]); + } + drawTable(doc, rows, [90, 130, 70, 70, 70, 65]); + + if (args.emissions.length > 30) { + doc.moveDown(0.3); + doc + .font('Helvetica-Oblique') + .fontSize(8) + .fillColor(C.muted) + .text(`... and ${args.emissions.length - 30} more records.`); + } + doc.moveDown(0.6); +} + +function drawAqiSection(doc: PDFKit.PDFDocument, args: RenderArgs) { + if (args.aqi.length === 0) return; // section skipped if no AQI ingested + + sectionTitle(doc, 'AIR QUALITY (LATEST PER CITY)'); + + // Collapse to one row per (city, parameter) keeping latest value. + const byCity = new Map< + string, + Record + >(); + for (const r of args.aqi) { + let entry = byCity.get(r.city); + if (!entry) { + entry = {}; + byCity.set(r.city, entry); + } + if (!entry[r.parameter]) entry[r.parameter] = { value: r.value, unit: r.unit }; + } + + const rows: string[][] = [['City', 'PM2.5', 'PM10', 'NO₂', 'SO₂', 'CO']]; + for (const [city, params] of Array.from(byCity.entries()).slice(0, 20)) { + rows.push([ + city, + params.pm25 ? `${fmt(params.pm25.value)} ${params.pm25.unit}` : '—', + params.pm10 ? `${fmt(params.pm10.value)} ${params.pm10.unit}` : '—', + params.no2 ? `${fmt(params.no2.value)} ${params.no2.unit}` : '—', + params.so2 ? `${fmt(params.so2.value)} ${params.so2.unit}` : '—', + params.co ? `${fmt(params.co.value)} ${params.co.unit}` : '—', + ]); + } + drawTable(doc, rows, [120, 75, 75, 75, 75, 75]); + doc.moveDown(0.6); +} + +function drawFooter(doc: PDFKit.PDFDocument) { + // Reserve room and draw the strapline at the bottom of the current page. + const range = doc.bufferedPageRange(); + for (let i = 0; i < range.count; i++) { + doc.switchToPage(range.start + i); + doc + .font('Helvetica-Oblique') + .fontSize(7.5) + .fillColor(C.muted) + .text( + 'CLIMACORE · AI-Powered Carbon Footprint & Compliance Monitoring · Auto-generated report', + 50, + doc.page.height - 40, + { + align: 'center', + width: doc.page.width - 100, + }, + ); + } +} + +// ----------------------------------------------------------------------------- +// Drawing primitives +// ----------------------------------------------------------------------------- + +function sectionTitle(doc: PDFKit.PDFDocument, label: string) { + doc + .font('Helvetica-Bold') + .fontSize(11) + .fillColor(C.ink) + .text(label, { characterSpacing: 1.5 }); + + // Hatched accent under the title + const y = doc.y + 1; + doc + .save() + .strokeColor(C.ink3) + .lineWidth(1) + .dash(4, { space: 4 }) + .moveTo(50, y) + .lineTo(doc.page.width - 50, y) + .stroke() + .undash() + .restore(); + doc.y = y + 6; +} + +function drawKpiGrid( + doc: PDFKit.PDFDocument, + cells: Array<[string, string, string?]>, +) { + const cols = 4; + const cellW = (doc.page.width - 100) / cols; + const cellH = 38; + const startX = 50; + let row = 0; + cells.forEach((cell, idx) => { + const col = idx % cols; + if (col === 0 && idx > 0) row++; + const x = startX + col * cellW; + const y = doc.y + row * cellH; + doc + .save() + .rect(x + 2, y, cellW - 4, cellH - 4) + .fillAndStroke(C.paper2, C.ink3) + .restore(); + doc + .font('Helvetica') + .fontSize(7.5) + .fillColor(C.muted) + .text(cell[0].toUpperCase(), x + 8, y + 6, { width: cellW - 16 }); + doc + .font('Helvetica-Bold') + .fontSize(15) + .fillColor(cell[2] ?? C.ink) + .text(cell[1], x + 8, y + 16, { width: cellW - 16 }); + }); + doc.y = doc.y + Math.ceil(cells.length / cols) * cellH; +} + +function drawTable( + doc: PDFKit.PDFDocument, + rows: string[][], + widths: number[], +) { + if (rows.length === 0) return; + const startX = 50; + const rowH = 18; + const padX = 5; + const totalW = widths.reduce((a, b) => a + b, 0); + + const ensurePageRoom = () => { + if (doc.y + rowH > doc.page.height - 80) doc.addPage(); + }; + + // Header row + ensurePageRoom(); + let y = doc.y; + doc.save().rect(startX, y, totalW, rowH).fillColor(C.ink3).fill().restore(); + doc.font('Helvetica-Bold').fontSize(9).fillColor(C.paper); + let x = startX; + rows[0].forEach((cell, i) => { + doc.text(cell, x + padX, y + 5, { + width: widths[i] - padX * 2, + ellipsis: true, + lineBreak: false, + }); + x += widths[i]; + }); + doc.y = y + rowH; + + // Data rows + for (let r = 1; r < rows.length; r++) { + ensurePageRoom(); + y = doc.y; + if (r % 2 === 0) { + doc + .save() + .rect(startX, y, totalW, rowH) + .fillColor(C.paper2) + .fillOpacity(0.5) + .fill() + .fillOpacity(1) + .restore(); + } + doc.font('Helvetica').fontSize(9).fillColor(C.ink); + x = startX; + rows[r].forEach((cell, i) => { + // Color-code status column if it's the last one and matches + let color: string = C.ink; + const cellLower = cell.toUpperCase(); + if (cellLower === 'VIOLATION') color = C.red; + else if (cellLower === 'WARNING') color = C.amber; + else if (cellLower === 'COMPLIANT') color = C.green; + doc + .fillColor(color) + .text(cell, x + padX, y + 5, { + width: widths[i] - padX * 2, + ellipsis: true, + lineBreak: false, + }); + x += widths[i]; + }); + doc.fillColor(C.ink); + doc.y = y + rowH; + } + + // Border around the table + doc + .save() + .strokeColor(C.ink3) + .lineWidth(0.8) + .rect(startX, doc.y - rows.length * rowH, totalW, rows.length * rowH) + .stroke() + .restore(); +} + +// ----------------------------------------------------------------------------- +// Helpers +// ----------------------------------------------------------------------------- + +function num(d: Prisma.Decimal | number | null | undefined): number { + if (d === null || d === undefined) return 0; + if (typeof d === 'number') return d; + return Number(d.toString()); +} + +function sum(xs: number[]): number { + return xs.reduce((a, b) => a + b, 0); +} + +function fmt(n: number): string { + if (!Number.isFinite(n)) return '—'; + if (Math.abs(n) >= 1000) return n.toFixed(0); + if (Math.abs(n) >= 1) return n.toFixed(2); + return n.toFixed(3); +} + +function fmtDate(d: Date): string { + return d.toISOString().slice(0, 16).replace('T', ' '); +} + +function startOfMonth(d: Date): Date { + return new Date(d.getFullYear(), d.getMonth(), 1); +} + +function humanType(t: ReportType): string { + switch (t) { + case 'EMISSIONS_MONTHLY': + return 'Monthly Emissions'; + case 'EMISSIONS_QUARTERLY': + return 'Quarterly Emissions'; + case 'COMPLIANCE_QUARTERLY': + return 'Quarterly Compliance'; + case 'ANNUAL_REPORT': + return 'Annual Report'; + case 'FACILITY_AUDIT': + return 'Facility Audit'; + case 'AI_INSIGHTS': + return 'AI Insights'; + default: + return 'Custom'; + } +} + +function titleFor( + t: ReportType, + start: Date, + end: Date, + industryName?: string | null, +): string { + const monthYear = `${monthName(start)} ${start.getFullYear()}`; + const base = (() => { + switch (t) { + case 'EMISSIONS_MONTHLY': + return `Monthly Emissions — ${monthYear}`; + case 'EMISSIONS_QUARTERLY': + return `Quarterly Emissions — ${monthYear}`; + case 'COMPLIANCE_QUARTERLY': + return `Quarterly Compliance — ${monthYear}`; + case 'ANNUAL_REPORT': + return `Annual Report — ${start.getFullYear()}`; + case 'FACILITY_AUDIT': + return `Facility Audit — ${monthYear}`; + case 'AI_INSIGHTS': + return `AI Insights — ${monthYear}`; + default: + return `Report — ${monthYear}`; + } + })(); + return industryName ? `${base} · ${industryName}` : base; +} + +function monthName(d: Date): string { + return [ + 'Jan', + 'Feb', + 'Mar', + 'Apr', + 'May', + 'Jun', + 'Jul', + 'Aug', + 'Sep', + 'Oct', + 'Nov', + 'Dec', + ][d.getMonth()]!; +} + +// ----------------------------------------------------------------------------- +// Cross-PR compatibility: AQI fetch is a no-op if the model doesn't exist yet. +// (PR #7 introduces `airQualityReading`. This PR is independent of that one.) +// ----------------------------------------------------------------------------- + +async function safeLatestAqi( + start: Date, + end: Date, +): Promise< + Array<{ + city: string; + parameter: string; + value: number; + unit: string; + recordedAt: Date; + }> +> { + const p = prisma as unknown as Record; + if (!p.airQualityReading || typeof p.airQualityReading.findMany !== 'function') { + return []; + } + try { + const rows = (await p.airQualityReading.findMany({ + where: { recordedAt: { gte: start, lte: end } }, + orderBy: { recordedAt: 'desc' }, + take: 500, + })) as Array<{ + city: string; + parameter: string; + value: number; + unit: string; + recordedAt: Date; + }>; + return rows; + } catch (err) { + logger.warn({ err }, 'AQI fetch for report failed (non-fatal)'); + return []; + } +} diff --git a/src/App.tsx b/src/App.tsx index 1c3a746..59828d4 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -8,6 +8,7 @@ import { } from "recharts"; import CapabilitiesModal from "./components/CapabilitiesModal"; import { registerUser, loginUser, logoutUser, bootstrapUser } from "./lib/auth"; +import { listReports, generateReport as generateReportApi, deleteReport as deleteReportApi, downloadReport, uiTypeToBackend, formatBytes } from "./lib/reports"; // ── STORAGE ────────────────────────────────────────────────────────────────── const S = { @@ -1267,28 +1268,82 @@ const Compliance = ({ emissions }) => { // ── REPORTS ─────────────────────────────────────────────────────────────────── const Reports = ({ user, emissions }) => { - const initR = [ - { id: 1, name: 'Monthly Emissions — May 2024', date: '2024-05-20', size: '2.4 MB', type: 'Monthly Emissions', records: 0 }, - { id: 2, name: 'Quarterly Compliance — Q2 2024', date: '2024-05-15', size: '5.8 MB', type: 'Quarterly Compliance', records: 0 }, - ]; - const [reports, setReports] = useState(() => S.get('reports', initR)); + const [reports, setReports] = useState([]); const [rtype, setRtype] = useState('Monthly Emissions'); const [period, setPeriod] = useState('Current Month'); const [gen, setGen] = useState(false); - + const [genError, setGenError] = useState(''); + const [loadError, setLoadError] = useState(''); + const [loading, setLoading] = useState(true); + const sizes = { 'Monthly Emissions': '2.4 MB', 'Quarterly Compliance': '5.8 MB', 'Annual Report': '12.3 MB', 'Facility Audit': '3.1 MB', 'AI Insights': '1.8 MB' }; - - const generate = () => { - if (gen) return; setGen(true); - setTimeout(() => { - const entry = { id: Date.now(), name: `${rtype} — ${period}`, date: new Date().toISOString().split('T')[0], size: sizes[rtype] || '2.0 MB', type: rtype, records: emissions.length }; - const next = [entry, ...reports]; setReports(next); S.set('reports', next); + + // Load reports from backend on mount + useEffect(() => { + let cancelled = false; + listReports() + .then(r => { if (!cancelled) { setReports(r.reports); setLoadError(''); } }) + .catch(e => { if (!cancelled) setLoadError(e?.message || 'Failed to load reports'); }) + .finally(() => { if (!cancelled) setLoading(false); }); + return () => { cancelled = true; }; + }, []); + + // Map UI period selector → ISO date range + const periodToRange = (label) => { + const now = new Date(); + const startOfMonth = (d) => new Date(d.getFullYear(), d.getMonth(), 1); + const endOfMonth = (d) => new Date(d.getFullYear(), d.getMonth() + 1, 0, 23, 59, 59); + if (label === 'Current Month') return [startOfMonth(now), now]; + if (label === 'Last Month') { + const prev = new Date(now.getFullYear(), now.getMonth() - 1, 1); + return [prev, endOfMonth(prev)]; + } + if (label === 'Q2 2024') return [new Date('2024-04-01'), new Date('2024-06-30T23:59:59')]; + if (label === 'Q1 2024') return [new Date('2024-01-01'), new Date('2024-03-31T23:59:59')]; + if (label === 'Full Year 2024') return [new Date('2024-01-01'), new Date('2024-12-31T23:59:59')]; + return [startOfMonth(now), now]; + }; + + const generate = async () => { + if (gen) return; + setGen(true); + setGenError(''); + try { + const [periodStart, periodEnd] = periodToRange(period); + const created = await generateReportApi({ + type: uiTypeToBackend(rtype), + periodStart: periodStart.toISOString(), + periodEnd: periodEnd.toISOString(), + }); + // Refresh the list so the new report appears with backend metadata. + const fresh = await listReports(); + setReports(fresh.reports); + } catch (e) { + setGenError(e?.message || 'Failed to generate report'); + } finally { setGen(false); - }, 2400); + } }; - - const delR = id => { const next = reports.filter(r => r.id !== id); setReports(next); S.set('reports', next); }; - + + const delR = async (id) => { + const prev = reports; + setReports(reports.filter(r => r.id !== id)); + try { + await deleteReportApi(id); + } catch (e) { + setReports(prev); + setGenError(e?.message || 'Failed to delete report'); + } + }; + + const handleDownload = async (r) => { + try { + await downloadReport(r.id, r.title || 'report'); + } catch (e) { + setGenError(e?.message || 'Download failed'); + } + }; + const monthBar = () => { const m = {}; emissions.forEach(e => { const mo = e.date?.slice(0, 7) || '?'; if (!m[mo]) m[mo] = { mo: mo.slice(5), co2: 0, n: 0 }; m[mo].co2 += e.co2; m[mo].n++; }); @@ -1352,20 +1407,27 @@ const Reports = ({ user, emissions }) => {
Report Archive // {reports.length} FILES
- {reports.length > 0 && }
- {reports.length === 0 ? ( + {genError && ( +
⚠ {genError}
+ )} + {loading ? ( +
LOADING REPORTS...
+ ) : loadError ? ( +
⚠ {loadError}
+ ) : reports.length === 0 ? (
— NO REPORTS GENERATED YET —
) : reports.map((r, i) => (
{ e.currentTarget.style.borderColor = 'var(--ink3)'; e.currentTarget.style.background = 'rgba(26,20,16,0.04)'; e.currentTarget.style.transform = 'translate(-1px,-1px)'; e.currentTarget.style.boxShadow = '2px 2px 0 var(--ink5)'; }} onMouseLeave={e => { e.currentTarget.style.borderColor = 'var(--ink5)'; e.currentTarget.style.background = 'rgba(240,235,224,0.5)'; e.currentTarget.style.transform = ''; e.currentTarget.style.boxShadow = ''; }}> -
📄
+
PDF
-
{r.name}
-
{r.date} · {r.size}{r.records ? ` · ${r.records} records` : ''}
+
{r.title}
+
{new Date(r.generatedAt).toLocaleString()} · {formatBytes(r.sizeBytes)}{r.pageCount ? ` · ${r.pageCount} pg` : ''}{r.industryName ? ` · ${r.industryName}` : ''}
READY +
))} diff --git a/src/lib/reports.ts b/src/lib/reports.ts new file mode 100644 index 0000000..873cde3 --- /dev/null +++ b/src/lib/reports.ts @@ -0,0 +1,123 @@ +import { api, tokens } from './api'; + +export type ReportType = + | 'EMISSIONS_MONTHLY' + | 'EMISSIONS_QUARTERLY' + | 'COMPLIANCE_QUARTERLY' + | 'ANNUAL_REPORT' + | 'FACILITY_AUDIT' + | 'AI_INSIGHTS' + | 'CUSTOM'; + +export interface ReportSummary { + id: string; + type: ReportType; + format: 'PDF' | 'CSV'; + title: string; + periodStart: string | null; + periodEnd: string | null; + sizeBytes: number; + pageCount: number | null; + industryId: string | null; + generatedById: string | null; + generatedAt: string; + generatedByName?: string | null; + industryName?: string | null; + downloadUrl: string; +} + +export interface ListResponse { + count: number; + reports: ReportSummary[]; +} + +export interface GenerateInput { + type: ReportType; + periodStart?: string; + periodEnd?: string; + industryId?: string; +} + +export async function listReports(): Promise { + return api('/reports', { auth: true }); +} + +export async function generateReport( + input: GenerateInput, +): Promise { + return api('/reports/generate', { + method: 'POST', + body: input, + auth: true, + }); +} + +export async function deleteReport(id: string): Promise { + await api(`/reports/${id}`, { method: 'DELETE', auth: true }); +} + +/** + * Downloads a PDF via authenticated fetch + Blob trigger. + * Browsers won't send the Authorization header on a plain ``, so we + * have to fetch the bytes ourselves and synthesize a download click. + */ +export async function downloadReport( + id: string, + filename = 'report.pdf', +): Promise { + const base = + ((import.meta as unknown as { env?: { VITE_API_URL?: string } }).env + ?.VITE_API_URL ?? 'http://localhost:4000') + '/api/v1'; + + const res = await fetch(`${base}/reports/${id}/download`, { + headers: tokens.getAccess() + ? { Authorization: `Bearer ${tokens.getAccess()}` } + : {}, + }); + + if (!res.ok) { + let message = `Download failed (${res.status})`; + try { + const j = await res.json(); + message = j?.error?.message ?? message; + } catch { + /* ignore */ + } + throw new Error(message); + } + + const blob = await res.blob(); + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = url; + a.download = filename.endsWith('.pdf') ? filename : `${filename}.pdf`; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + // Give the browser a tick before revoking, otherwise some browsers cancel. + setTimeout(() => URL.revokeObjectURL(url), 1000); +} + +/** Map the existing UI's report-type strings to backend enum values. */ +export function uiTypeToBackend(uiLabel: string): ReportType { + switch (uiLabel) { + case 'Monthly Emissions': + return 'EMISSIONS_MONTHLY'; + case 'Quarterly Compliance': + return 'COMPLIANCE_QUARTERLY'; + case 'Annual Report': + return 'ANNUAL_REPORT'; + case 'Facility Audit': + return 'FACILITY_AUDIT'; + case 'AI Insights': + return 'AI_INSIGHTS'; + default: + return 'CUSTOM'; + } +} + +export function formatBytes(n: number): string { + if (n < 1024) return `${n} B`; + if (n < 1024 * 1024) return `${(n / 1024).toFixed(1)} KB`; + return `${(n / (1024 * 1024)).toFixed(2)} MB`; +}