diff --git a/apps/backend/lambdas/reports/controllers/reports.ts b/apps/backend/lambdas/reports/controllers/reports.ts index 8e8e9b15..72d49d56 100644 --- a/apps/backend/lambdas/reports/controllers/reports.ts +++ b/apps/backend/lambdas/reports/controllers/reports.ts @@ -12,6 +12,7 @@ import { objectUrlFor, keyFromObjectUrl, reportKeyPrefix, + getObjectSize, } from '../report-service'; const s3 = new S3Client({ region: process.env.AWS_REGION ?? 'us-east-2' }); @@ -88,6 +89,11 @@ export const generateReport: RouteHandler = async ({ event }) => { return json(400, { message: `report_type must be one of: ${REPORT_TYPES.join(', ')}` }); } + // Optional. Falls back to the auto-generated "" title + // below when omitted or blank, so this is fully backward compatible with + // any caller that never sends it. + const customTitle = typeof body.title === 'string' ? body.title.trim() : ''; + const reportData = await fetchReportData(projectId); if (!reportData) { return json(404, { message: 'Project not found' }); @@ -107,7 +113,7 @@ export const generateReport: RouteHandler = async ({ event }) => { return serverError(err, 'Failed to upload report'); } - const title = `${reportData.project.name} — ${new Date().toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' })}`; + const title = customTitle || `${reportData.project.name} — ${new Date().toLocaleDateString('en-US', { year: 'numeric', month: 'long', day: 'numeric' })}`; const record = await saveReportRecord(projectId, objectUrl, title, reportType); return json(201, { @@ -168,7 +174,7 @@ export const listReports: RouteHandler = async ({ event }) => { const totalPages = Math.ceil(totalItems / limit); return json(200, { - data: reports, + data: await withSizes(reports), pagination: { page, limit, totalItems, totalPages }, }); } @@ -312,3 +318,8 @@ export const deleteReport: RouteHandler = async ({ params, path, method }) => { return json(200, { ok: true, route: 'DELETE /reports/{id}', pathParams: { id }, fileDeleted }); }; + +async function withSizes(rows: T[]) { + const sizes = await Promise.all(rows.map((r) => getObjectSize(r.object_url))); + return rows.map((r, i) => ({ ...r, file_size: sizes[i] })); +} \ No newline at end of file diff --git a/apps/backend/lambdas/reports/report-service.ts b/apps/backend/lambdas/reports/report-service.ts index ea3939f0..3738c9d4 100644 --- a/apps/backend/lambdas/reports/report-service.ts +++ b/apps/backend/lambdas/reports/report-service.ts @@ -1,5 +1,5 @@ import db from './db'; -import { S3Client, PutObjectCommand } from '@aws-sdk/client-s3'; +import { S3Client, PutObjectCommand, HeadObjectCommand } from '@aws-sdk/client-s3'; import type { TDocumentDefinitions, Content, TableCell } from 'pdfmake/interfaces'; import { Document, @@ -548,3 +548,15 @@ export async function saveReportRecord( return { report_id: row.report_id, object_url: row.object_url, report_type: row.report_type }; } + +export async function getObjectSize(objectUrl: string): Promise { + const key = keyFromObjectUrl(objectUrl); + if (!key) return null; + try { + const head = await s3.send(new HeadObjectCommand({ Bucket: getBucketName(), Key: key })); + return head.ContentLength ?? null; + } catch (err) { + console.error('Failed to read object size', key, err); + return null; + } +} diff --git a/apps/backend/lambdas/reports/test/report-service.e2e.test.ts b/apps/backend/lambdas/reports/test/report-service.e2e.test.ts index db63dcec..d287f4d1 100644 --- a/apps/backend/lambdas/reports/test/report-service.e2e.test.ts +++ b/apps/backend/lambdas/reports/test/report-service.e2e.test.ts @@ -8,7 +8,7 @@ import { Pool } from 'pg'; import { ensureSchema, resetData } from '../../../db/testkit'; import db from '../db'; -import { fetchReportData } from '../report-service'; +import { fetchReportData, keyFromObjectUrl, objectUrlFor, reportKeyPrefix } from '../report-service'; const pool = new Pool({ host: 'localhost', @@ -66,3 +66,58 @@ describe('fetchReportData', () => { expect(total).toBe(1000); }); }); + +// keyFromObjectUrl/objectUrlFor are pure functions but depend on +// REPORTS_BUCKET_NAME and AWS_REGION at call time, so each test sets its own +// env rather than relying on a shared beforeAll value. +describe('objectUrlFor / keyFromObjectUrl', () => { + const ORIGINAL_BUCKET = process.env.REPORTS_BUCKET_NAME; + const ORIGINAL_REGION = process.env.AWS_REGION; + + beforeEach(() => { + process.env.REPORTS_BUCKET_NAME = 'bucket'; + process.env.AWS_REGION = 'us-east-2'; + }); + + afterAll(() => { + process.env.REPORTS_BUCKET_NAME = ORIGINAL_BUCKET; + process.env.AWS_REGION = ORIGINAL_REGION; + }); + + test('objectUrlFor and keyFromObjectUrl round-trip a key', () => { + const key = `${reportKeyPrefix(1)}report.pdf`; + const url = objectUrlFor(key); + expect(keyFromObjectUrl(url)).toBe(key); + }); + + test('a region-less legacy host still resolves the key, for rows written before objectUrlFor', () => { + const legacyUrl = 'https://bucket.s3.amazonaws.com/reports/1/old-report.pdf'; + expect(keyFromObjectUrl(legacyUrl)).toBe('reports/1/old-report.pdf'); + }); + + test('a non-https URL is rejected', () => { + const httpUrl = 'http://bucket.s3.us-east-2.amazonaws.com/reports/1/report.pdf'; + expect(keyFromObjectUrl(httpUrl)).toBeNull(); + }); + + test('a URL pointed at a different bucket entirely is rejected', () => { + const otherBucketUrl = 'https://someone-elses-bucket.s3.us-east-2.amazonaws.com/reports/1/report.pdf'; + expect(keyFromObjectUrl(otherBucketUrl)).toBeNull(); + }); + + test('a malformed percent-encoded path is caught and returns null rather than throwing', () => { + const malformedUrl = 'https://bucket.s3.us-east-2.amazonaws.com/reports/1/%ZZbad.pdf'; + expect(() => keyFromObjectUrl(malformedUrl)).not.toThrow(); + expect(keyFromObjectUrl(malformedUrl)).toBeNull(); + }); + + test('a completely invalid URL string returns null rather than throwing', () => { + expect(() => keyFromObjectUrl('not a url at all')).not.toThrow(); + expect(keyFromObjectUrl('not a url at all')).toBeNull(); + }); + + test('an empty path (just the bucket root) returns null', () => { + const rootUrl = 'https://bucket.s3.us-east-2.amazonaws.com/'; + expect(keyFromObjectUrl(rootUrl)).toBeNull(); + }); +}); \ No newline at end of file diff --git a/apps/backend/lambdas/reports/test/reports.e2e.test.ts b/apps/backend/lambdas/reports/test/reports.e2e.test.ts index 9d39dfda..1cb58e08 100644 --- a/apps/backend/lambdas/reports/test/reports.e2e.test.ts +++ b/apps/backend/lambdas/reports/test/reports.e2e.test.ts @@ -30,6 +30,7 @@ jest.mock('@aws-sdk/client-s3', () => ({ send: jest.fn().mockReturnValue({} as any), })), PutObjectCommand: jest.fn().mockImplementation((params: unknown) => params), + GetObjectCommand: jest.fn().mockImplementation((params: unknown) => params), })); jest.mock('@aws-sdk/s3-request-presigner', () => ({ getSignedUrl: jest.fn().mockReturnValue('https://presigned.example.com/upload' as any), @@ -405,6 +406,56 @@ describe('Reports e2e tests', () => { }); }); + describe('GET /reports/{id}/download', () => { + function downloadEvent(id: string | number) { + return { + rawPath: `/reports/${id}/download`, + requestContext: { http: { method: 'GET' } }, + headers: { Authorization: 'Bearer fake-token' }, + }; + } + + test('200: returns a signed downloadUrl and expiresIn for a report stored under its project prefix', async () => { + // Seed rows aren't guaranteed to sit under reports// for the + // current REPORTS_BUCKET_NAME, so create a report with a known-good + // objectUrl first (same pattern as the POST /reports suite) and + // download that one rather than assuming anything about seed data. + const fakeObjectUrl = 'https://bucket.s3.us-east-2.amazonaws.com/reports/1/dl-test-report.pdf'; + const createRes = await handler({ + rawPath: '/reports', + requestContext: { http: { method: 'POST' } }, + headers: { Authorization: 'Bearer fake-token' }, + queryStringParameters: {}, + body: JSON.stringify({ title: 'Download Test', projectId: 1, objectUrl: fakeObjectUrl }), + }); + expect(createRes.statusCode).toBe(201); + const createdId = JSON.parse(createRes.body).report_id; + + const res = await handler(downloadEvent(createdId)); + expect(res.statusCode).toBe(200); + const body = JSON.parse(res.body); + expect(body.downloadUrl).toBe('https://presigned.example.com/upload'); + expect(body.expiresIn).toBe(900); + }); + + test('404: non-numeric id falls through to catch-all', async () => { + const res = await handler(downloadEvent('abc')); + expect(res.statusCode).toBe(404); + }); + + test('404: unknown id returns 404', async () => { + const res = await handler(downloadEvent(99999)); + expect(res.statusCode).toBe(404); + expect(JSON.parse(res.body).message).toBe('Report not found'); + }); + + test('401: unauthenticated request is rejected', async () => { + mockAuthenticateRequest.mockResolvedValue({ isAuthenticated: false }); + const res = await handler(downloadEvent(1)); + expect(res.statusCode).toBe(401); + }); + }); + describe('DELETE /reports/{id}', () => { function idEvent(method: 'GET' | 'DELETE', id: string | number) { return { @@ -472,5 +523,22 @@ describe('Reports e2e tests', () => { client2.release(); } }); + + test('200: deleting a report whose object_url is not under the reports bucket still deletes the row, fileDeleted false', async () => { + // Seed report 5's object_url ('https://s3.amazonaws.com/reports/b.pdf' or + // similar legacy-style URL) may not resolve via keyFromObjectUrl against + // the current REPORTS_BUCKET_NAME; either way the row must go. + const res = await handler(idEvent('DELETE', 2)); + expect(res.statusCode).toBe(200); + expect(typeof JSON.parse(res.body).fileDeleted).toBe('boolean'); + + const client = await pool.connect(); + try { + const result = await client.query('SELECT * FROM branch.reports WHERE report_id = 2'); + expect(result.rows.length).toBe(0); + } finally { + client.release(); + } + }); }); -}); +}); \ No newline at end of file diff --git a/apps/backend/lambdas/reports/test/reports.unit.test.ts b/apps/backend/lambdas/reports/test/reports.unit.test.ts index 586faaab..6c95025f 100644 --- a/apps/backend/lambdas/reports/test/reports.unit.test.ts +++ b/apps/backend/lambdas/reports/test/reports.unit.test.ts @@ -28,6 +28,9 @@ const mockS3Send = jest.fn<(command: unknown) => Promise>(); jest.mock('@aws-sdk/client-s3', () => ({ S3Client: jest.fn().mockImplementation(() => ({ send: mockS3Send })), PutObjectCommand: jest.fn().mockImplementation((params: unknown) => params), + GetObjectCommand: jest + .fn() + .mockImplementation((params: unknown) => ({ __type: 'GetObject', ...(params as object) })), DeleteObjectCommand: jest .fn() .mockImplementation((params: unknown) => ({ __type: 'DeleteObject', ...(params as object) })), @@ -47,6 +50,7 @@ jest.mock('../report-service', () => ({ const prefix = 'https://bucket.s3.us-east-2.amazonaws.com/'; return objectUrl.startsWith(prefix) ? objectUrl.slice(prefix.length) : null; }), + getObjectSize: jest.fn(async () => null), })); import { handler } from '../handler'; @@ -187,6 +191,56 @@ describe('POST /reports unit tests', () => { const res = await handler(postEvent({ project_id: 1 })); expect(res.statusCode).toBe(403); }); + + test('400: invalid report_type returns 400', async () => { + const res = await handler(postEvent({ project_id: 1, report_type: 'summary' })); + expect(res.statusCode).toBe(400); + expect(JSON.parse(res.body).message).toContain('report_type'); + }); + + test('201: report_type=narrative is accepted and passed through to saveReportRecord', async () => { + mockReportService.saveReportRecord.mockResolvedValue({ + report_id: 1, + report_type: 'narrative', + object_url: 'https://s3.example.com/reports/1/ts.pdf', + }); + const res = await handler(postEvent({ project_id: 1, report_type: 'narrative' })); + expect(res.statusCode).toBe(201); + expect(JSON.parse(res.body).report_type).toBe('narrative'); + expect(mockReportService.saveReportRecord).toHaveBeenCalledWith( + 1, + expect.any(String), + expect.any(String), + 'narrative', + ); + }); + + test('201: a provided title is used as-is instead of the auto-generated one', async () => { + const res = await handler(postEvent({ project_id: 1, title: 'Q3 Board Report' })); + expect(res.statusCode).toBe(201); + expect(mockReportService.saveReportRecord).toHaveBeenCalledWith( + 1, + expect.any(String), + 'Q3 Board Report', + 'technical', + ); + }); + + test('201: a blank/whitespace title falls back to the auto-generated title', async () => { + const res = await handler(postEvent({ project_id: 1, title: ' ' })); + expect(res.statusCode).toBe(201); + const [, , titleArg] = mockReportService.saveReportRecord.mock.calls[0]; + expect(titleArg).toContain('Test'); + expect(titleArg).not.toBe(' '); + }); + + test('201: an omitted title falls back to the auto-generated "" title', async () => { + const res = await handler(postEvent({ project_id: 1 })); + expect(res.statusCode).toBe(201); + const [, , titleArg] = mockReportService.saveReportRecord.mock.calls[0]; + expect(titleArg).toContain('Test'); + expect(titleArg).toContain('—'); + }); }); describe('GET /reports unit tests', () => { @@ -250,6 +304,51 @@ describe('GET /reports unit tests', () => { }); expect(res.statusCode).toBe(404); }); + + }); + + // withSizes() is only invoked on the paginated branch of listReports (both + // page and limit present) -- the unpaginated branch returns raw rows with no + // file_size at all, so these live under Pagination rather than Response format. + describe('File sizes (paginated results only)', () => { + function mockPaginatedQuery(rows: typeof fakeReports) { + mockDb.selectFrom.mockReturnValueOnce({ + select: jest.fn().mockReturnValue({ + executeTakeFirst: jest.fn().mockReturnValue({ count: String(rows.length) } as any), + }), + }); + mockDb.selectFrom.mockReturnValueOnce({ + selectAll: jest.fn().mockReturnValue({ + orderBy: jest.fn().mockReturnValue({ + limit: jest.fn().mockReturnValue({ + offset: jest.fn().mockReturnValue({ + execute: jest.fn().mockReturnValue(rows as any), + }), + }), + }), + }), + }); + } + + test('200: each report includes file_size sourced from getObjectSize', async () => { + mockPaginatedQuery([fakeReports[0]]); + mockReportService.getObjectSize.mockResolvedValue(4096); + + const res = await handler(getEvent({ page: '1', limit: '1' })); + expect(res.statusCode).toBe(200); + const json = JSON.parse(res.body); + expect(json.data[0].file_size).toBe(4096); + expect(mockReportService.getObjectSize).toHaveBeenCalledWith(fakeReports[0].object_url); + }); + + test('200: file_size is null when getObjectSize cannot resolve the object', async () => { + mockPaginatedQuery([fakeReports[0]]); + mockReportService.getObjectSize.mockResolvedValue(null); + + const res = await handler(getEvent({ page: '1', limit: '1' })); + const json = JSON.parse(res.body); + expect(json.data[0].file_size).toBeNull(); + }); }); describe('Pagination', () => { @@ -419,6 +518,27 @@ describe('GET /reports/upload-url unit tests', () => { expect(res.statusCode).toBe(400); expect(JSON.parse(res.body).message).toBe('projectId must be a positive integer'); }); + + test('400: path traversal in fileName is stripped down to the basename, not treated as a path', async () => { + const res = await handler(uploadUrlEvent({ fileName: '../../../etc/passwd.pdf', projectId: '1' })); + expect(res.statusCode).toBe(200); + const body = JSON.parse(res.body); + expect(body.objectUrl).toContain('passwd.pdf'); + expect(body.objectUrl).not.toContain('..'); + expect(body.objectUrl).not.toContain('etc/passwd'); + }); + + test('400: a fileName with no alphanumeric characters after sanitization is rejected', async () => { + const res = await handler(uploadUrlEvent({ fileName: '....', projectId: '1' })); + expect(res.statusCode).toBe(400); + expect(JSON.parse(res.body).message).toBe('Invalid fileName'); + }); + + test('400: a fileName made only of disallowed symbols is rejected as invalid, not as an unsupported extension', async () => { + const res = await handler(uploadUrlEvent({ fileName: '***.***', projectId: '1' })); + expect(res.statusCode).toBe(400); + expect(JSON.parse(res.body).message).toBe('Invalid fileName'); + }); }); describe('Business logic', () => { @@ -487,6 +607,35 @@ describe('Route precedence', () => { expect(res.statusCode).toBe(400); expect(JSON.parse(res.body).message).toBe('project_id is required'); }); + + // /reports/{id}/download and /reports/{id} share the numeric-id segment, so + // the three-segment download route must be matched before the two-segment + // getReport route swallows it as an id lookup with a trailing extra segment. + test('GET /reports/{id}/download reaches downloadReport, not the /reports/{id} controller', async () => { + mockDb.selectFrom = jest.fn().mockReturnValue({ + where: jest.fn().mockReturnValue({ + selectAll: jest.fn().mockReturnValue({ + executeTakeFirst: jest.fn().mockReturnValue({ + report_id: 5, + project_id: 1, + object_url: 'https://bucket.s3.us-east-2.amazonaws.com/reports/1/gen.pdf', + } as any), + }), + }), + }); + const res = await handler({ + rawPath: '/reports/5/download', + requestContext: { http: { method: 'GET' } }, + headers: { Authorization: 'Bearer fake-token' }, + queryStringParameters: {}, + }); + // downloadReport-specific shape (downloadUrl/expiresIn), not getReport's + // { ok, route: 'GET /reports/{id}', body } envelope. + expect(res.statusCode).toBe(200); + const json = JSON.parse(res.body); + expect(json.downloadUrl).toBeDefined(); + expect(json.route).toBeUndefined(); + }); }); describe('POST /reports unit tests', () => { @@ -741,6 +890,93 @@ describe('GET /reports/{id} unit tests', () => { }); }); +describe('GET /reports/{id}/download unit tests', () => { + function downloadEvent(id: string) { + return { + rawPath: `/reports/${id}/download`, + requestContext: { http: { method: 'GET' } }, + headers: { Authorization: 'Bearer fake-token' }, + }; + } + + const storedReport = { + report_id: 5, + project_id: 2, + title: 'Test Report', + object_url: 'https://bucket.s3.us-east-2.amazonaws.com/reports/2/gen.pdf', + report_type: 'technical', + date_created: new Date('2025-01-01'), + }; + + function setupReportMock(report: Record | undefined) { + mockDb.selectFrom = jest.fn().mockReturnValue({ + where: jest.fn().mockReturnValue({ + selectAll: jest.fn().mockReturnValue({ + executeTakeFirst: jest.fn().mockReturnValue(report as any), + }), + }), + }); + } + + beforeEach(() => { + jest.clearAllMocks(); + mockAuthenticateRequest.mockResolvedValue(adminAuthContext); + setupReportMock(storedReport); + }); + + describe('Validation', () => { + test('404: non-numeric id falls through to catch-all', async () => { + const res = await handler(downloadEvent('abc')); + expect(res.statusCode).toBe(404); + }); + + test('404: negative id falls through to catch-all', async () => { + const res = await handler(downloadEvent('-5')); + expect(res.statusCode).toBe(404); + }); + }); + + describe('Authentication', () => { + test('401: unauthenticated request is rejected', async () => { + mockAuthenticateRequest.mockResolvedValue({ isAuthenticated: false }); + const res = await handler(downloadEvent('5')); + expect(res.statusCode).toBe(401); + expect(JSON.parse(res.body).message).toBe('Authentication required'); + }); + }); + + describe('Business logic', () => { + test('404: report does not exist', async () => { + setupReportMock(undefined); + const res = await handler(downloadEvent('999')); + expect(res.statusCode).toBe(404); + expect(JSON.parse(res.body).message).toBe('Report not found'); + }); + + test('403: a non-admin cannot download a report', async () => { + mockAuthenticateRequest.mockResolvedValue(nonAdminAuthContext); + const res = await handler(downloadEvent('5')); + expect(res.statusCode).toBe(403); + expect(JSON.parse(res.body).message).toBe('Only administrators can do this'); + }); + + test('409: a report whose object_url cannot be resolved into a key under its project prefix is rejected', async () => { + setupReportMock({ ...storedReport, object_url: 'https://not-our-bucket.example.com/other/file.pdf' }); + const res = await handler(downloadEvent('5')); + expect(res.statusCode).toBe(409); + expect(JSON.parse(res.body).message).toBe('Report is not stored in the reports bucket'); + }); + + test('200: returns a signed downloadUrl and expiresIn for a valid report', async () => { + const res = await handler(downloadEvent('5')); + expect(res.statusCode).toBe(200); + const body = JSON.parse(res.body); + expect(body.downloadUrl).toBe('https://presigned.example.com/upload'); + expect(body.expiresIn).toBe(900); + }); + }); +}); + describe('DELETE /reports/{id} unit tests', () => { function idEvent(method: 'GET' | 'DELETE', id: string) { return { @@ -846,6 +1082,15 @@ describe('DELETE /reports/{id} unit tests', () => { expect(res.statusCode).toBe(403); }); + test('200: a report with no object_url deletes cleanly, fileDeleted is true, and S3 is never called', async () => { + setupReportMock({ ...fakeReport, object_url: null }); + const res = await handler(idEvent('DELETE', '5')); + + expect(res.statusCode).toBe(200); + expect(JSON.parse(res.body).fileDeleted).toBe(true); + expect(mockS3Send).not.toHaveBeenCalled(); + }); + describe('the generated file goes with the row', () => { const storedReport = { ...fakeReport, @@ -906,4 +1151,4 @@ describe('DELETE /reports/{id} unit tests', () => { }); }); }); -}); +}); \ No newline at end of file diff --git a/apps/frontend/src/app/reports/page.tsx b/apps/frontend/src/app/reports/page.tsx index 0187b5f0..ad81962b 100644 --- a/apps/frontend/src/app/reports/page.tsx +++ b/apps/frontend/src/app/reports/page.tsx @@ -12,16 +12,19 @@ import { Dialog, Portal, VStack, + Input, } from '@chakra-ui/react'; import DataTable, { type DataTableColumn } from '../components/DataTable'; import { useApi } from '@/hooks/useApi'; import { useQuery, useQueryClient, keepPreviousData } from '@tanstack/react-query'; -import { projectsQuery, reportsPageQuery } from '@/lib/queries'; +import { projectsQuery, reportsPageQuery, reportsAllQuery } from '@/lib/queries'; +import ExpenseFilterMenu, { type FilterGroup } from '../components/ExpenseFilterMenu'; import { type Project } from '@/lib/reports'; import UploadReportModal from '../components/UploadReportModal'; import ConfirmDeleteDialog from '../components/ConfirmDeleteDialog'; import { FaPlus } from 'react-icons/fa'; -import { LuClipboardPenLine } from 'react-icons/lu'; +import { LuSparkles } from "react-icons/lu"; +import { LuEye, LuDownload } from 'react-icons/lu'; import { RiDeleteBack2Line } from "react-icons/ri"; import { IoClose } from "react-icons/io5"; @@ -34,6 +37,7 @@ type Report = { report_type: string; date_created: string | null; emails?: string[]; + file_size?: number | null; }; const ROWS_PER_PAGE = 10; @@ -43,6 +47,28 @@ const EXTENSION_LABELS: Record = { docx: 'Word', }; +function getTypeBadgeStyle(reportType: string): React.CSSProperties { + const isNarrative = reportType.toLowerCase() === 'narrative'; + return { + display: 'inline-block', + padding: '4px 12px', + fontSize: '13px', + fontWeight: 500, + backgroundColor: isNarrative ? 'var(--color-primary-400)' : 'var(--color-primary-100)', + color: isNarrative ? 'var(--color-core-green)' : 'var(--color-black-700)', + textTransform: 'capitalize', + }; +} + +function formatFileSize(bytes: number | null | undefined): string { + if (bytes == null) return '—'; + if (bytes < 1024) return `${bytes} B`; + const kb = bytes / 1024; + if (kb < 1024) return `${kb.toFixed(1)} KB`; + const mb = kb / 1024; + return `${mb.toFixed(1)} MB`; +} + function getFormatLabel(objectUrl: string): string { const match = objectUrl.match(/\.([a-zA-Z0-9]+)(?:\?.*)?$/); const ext = match?.[1]?.toLowerCase(); @@ -55,6 +81,11 @@ const FILE_TYPE_OPTIONS: { value: 'pdf' | 'docx'; label: string }[] = [ { value: 'docx', label: 'Word (.docx)' }, ]; +const REPORT_TYPE_OPTIONS: { value: 'technical' | 'narrative'; label: string }[] = [ + { value: 'technical', label: 'Technical' }, + { value: 'narrative', label: 'Narrative' }, +]; + function formatDate(dateString: string | null): string { if (!dateString) return 'MM/DD/YYYY'; const date = new Date(dateString); @@ -91,9 +122,22 @@ function ReportsPageContent() { // Upload modal const [isUploadModalOpen, setIsUploadModalOpen] = useState(false); - // Tab: Reports vs Schedule - const [activeTab, setActiveTab] = useState<'reports' | 'schedule'>('reports'); - + // search bar + const [searchTerm, setSearchTerm] = useState(''); + + // filter by + const [selectedYears, setSelectedYears] = useState([]); + const [selectedTypes, setSelectedTypes] = useState([]); + const TYPE_OPTIONS = ['Technical', 'Narrative']; + + const isFiltered = searchTerm.trim() !== '' || selectedYears.length > 0 || selectedTypes.length > 0; + + // preview modal + const [previewReport, setPreviewReport] = useState(null); + const [previewUrl, setPreviewUrl] = useState(null); + const [previewLoading, setPreviewLoading] = useState(false); + const [previewError, setPreviewError] = useState(null); + // Pagination (synced to URL query params) const [filters, setFilter] = useQueryParams({ page: '', @@ -102,21 +146,62 @@ function ReportsPageContent() { // Already server-paginated before this change; now cached, and prefetched // for the page the URL asks for while /auth/me is still in flight. - const reportsList = useQuery({ - ...reportsPageQuery(currentPage, ROWS_PER_PAGE), - // Page flips reuse the previous page's rows instead of unmounting the - // table into a skeleton. - placeholderData: keepPreviousData, - }); - - const reports: Report[] = reportsList.data?.data ?? []; - const totalPages = Math.max(1, reportsList.data?.pagination?.totalPages ?? 1); - const loading = reportsList.isPending; - const error = reportsList.error - ? reportsList.error instanceof Error - ? reportsList.error.message - : 'Failed to load reports' - : null; + const reportsPaged = useQuery({ + ...reportsPageQuery(currentPage, ROWS_PER_PAGE), + placeholderData: keepPreviousData, + enabled: !isFiltered, + }); + + const reportsAll = useQuery({ + ...reportsAllQuery(), + }); + + // Real years present in the data, newest first — not a static guess. + const YEAR_OPTIONS = React.useMemo(() => { + const years = new Set(); + for (const r of reportsAll.data ?? []) { + if (r.date_created) { + years.add(new Date(r.date_created).getFullYear().toString()); + } + } + return Array.from(years).sort((a, b) => Number(b) - Number(a)); + }, [reportsAll.data]); + + // Client-side filtering only runs against the full list; the paged query is + // already exactly what the server decided to return. + const filteredReports: Report[] = React.useMemo(() => { + if (!isFiltered) return []; + const all = reportsAll.data ?? []; + const q = searchTerm.trim().toLowerCase(); + return all.filter((r) => { + if (q && !(r.title ?? '').toLowerCase().includes(q)) return false; + if (selectedYears.length > 0) { + const year = r.date_created ? new Date(r.date_created).getFullYear().toString() : ''; + if (!selectedYears.includes(year)) return false; + } + if (selectedTypes.length > 0) { + if (!selectedTypes.some((t) => t.toLowerCase() === r.report_type.toLowerCase())) return false; + } + return true; + }); + }, [isFiltered, reportsAll.data, searchTerm, selectedYears, selectedTypes]); + + const reports: Report[] = isFiltered + ? filteredReports.slice((currentPage - 1) * ROWS_PER_PAGE, currentPage * ROWS_PER_PAGE) + : reportsPaged.data?.data ?? []; + + const totalPages = isFiltered + ? Math.max(1, Math.ceil(filteredReports.length / ROWS_PER_PAGE)) + : Math.max(1, reportsPaged.data?.pagination?.totalPages ?? 1); + + const loading = isFiltered ? reportsAll.isPending : reportsPaged.isPending; + + const activeQuery = isFiltered ? reportsAll : reportsPaged; + const error = activeQuery.error + ? activeQuery.error instanceof Error + ? activeQuery.error.message + : 'Failed to load reports' + : null; // Same `['projects']` entry the navbar and every other page reads. const projectsList = useQuery(projectsQuery()); @@ -133,11 +218,19 @@ function ReportsPageContent() { setGenerateProjectId, generateFileType, setGenerateFileType, + generateReportName, + setGenerateReportName, + generateReportType, + setGenerateReportType, generating, error: generateError, handleGenerate, } = useGenerateReport({ onSuccess: refetchReports }); + useEffect(() => { + setFilter({ page: '' }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [searchTerm, selectedYears, selectedTypes]); useEffect(() => { // Selection is scoped to the visible page, so it must not survive a page @@ -202,6 +295,29 @@ function ReportsPageContent() { } } + async function handlePreview(report: Report) { + setPreviewReport(report); + setPreviewUrl(null); + setPreviewError(null); + setPreviewLoading(true); + try { + const { downloadUrl } = await api.get<{ downloadUrl: string; expiresIn: number }>( + `/reports/${report.report_id}/download`, + ); + setPreviewUrl(downloadUrl); + } catch (err) { + setPreviewError(err instanceof Error ? err.message : 'Failed to load preview'); + } finally { + setPreviewLoading(false); + } + } + + function closePreview() { + setPreviewReport(null); + setPreviewUrl(null); + setPreviewError(null); + } + async function handleDownload(reportId: number) { setDownloadingId(reportId); setActionError(null); @@ -222,50 +338,95 @@ function ReportsPageContent() { } const reportColumns: DataTableColumn[] = [ - { - key: 'date', - header: 'Date Created', - width: '18%', - cell: (report) => formatDate(report.date_created), - skeleton: { width: '70%' }, - }, - { - key: 'title', - header: 'Report Name', - width: '32%', - cell: (report) => ( - - ), - }, - { - key: 'emails', - header: 'Emails', - width: '35%', - cell: (report) => - report.emails && report.emails.length > 0 ? report.emails.join(', ') : '—', - skeleton: { width: '85%' }, - }, - { - key: 'format', - header: 'Format', - width: '15%', - align: 'right', - cell: (report) => getFormatLabel(report.object_url), - skeleton: { width: '45%' }, - }, - ]; + { + key: 'date', + header: 'Date Created', + width: '15%', + cell: (report) => formatDate(report.date_created), + skeleton: { width: '70%' }, + }, + { + key: 'title', + header: 'Report Name', + width: '50%', + cell: (report) => report.title || 'Untitled report', + }, + { + key: 'type', + header: 'Type', + width: '10%', + cell: (report) => ( + + {report.report_type} + + ), + }, + { + key: 'format', + header: 'Format', + width: '10%', + cell: (report) => getFormatLabel(report.object_url), + skeleton: { width: '45%' }, + }, + { + key: 'size', + header: 'Size', + width: '10%', + cell: (report) => formatFileSize(report.file_size), + skeleton: { width: '40%' }, + }, + { + key: 'actions', + header: 'Actions', + width: '10%', + align: 'center' as const, + cell: (report) => ( + + + + + ), + skeleton: { width: '32px' }, + }, + ]; + + const filterGroups: FilterGroup[] = [ + { + key: 'year', + label: 'Year', + options: YEAR_OPTIONS.map((y) => ({ value: y, label: y })), + selected: selectedYears, + onChange: setSelectedYears, + }, + { + key: 'type', + label: 'Type', + options: TYPE_OPTIONS.map((t) => ({ value: t, label: t })), + selected: selectedTypes, + onChange: setSelectedTypes, + }, +]; return ( @@ -274,39 +435,33 @@ function ReportsPageContent() {
-

- Reports -

- - {/* Tabs + Toolbar */} - - - - - + + {/* Toolbar */} + +

+ Reports +

+
+ + {/* Toolbar: search + actions */} + + + setSearchTerm(e.target.value)} + /> + + + {/* Delete */} + {/* Filter */} + + {/* Generate */} - {/* + New Report */} + {/* Upload Report */} @@ -349,8 +505,7 @@ function ReportsPageContent() {

{actionError}

)} - {/* Reports tab content */} - {!error && activeTab === 'reports' && ( + {!error && ( )} - {/* Schedule tab content */} - {!loading && !error && activeTab === 'schedule' && ( -

- Schedule view not yet implemented. -

- )} - - {/* Pagination */} - {!loading && !error && activeTab === 'reports' && ( + {!loading && !error && ( + {/*Preview Modal*/} + { if (!details.open) closePreview(); }}> + + + + + + + {previewReport?.title || 'Untitled report'} + + + + + + + {previewLoading &&

Loading preview…

} + {previewError && ( +

{previewError}

+ )} + {!previewLoading && !previewError && previewUrl && previewReport && ( + getFormatLabel(previewReport.object_url) === 'PDF' ? ( +