From ea75dcefb51e4189c7a8051485240c90b032a8e0 Mon Sep 17 00:00:00 2001 From: mehanana Date: Tue, 7 Jul 2026 19:10:21 -0400 Subject: [PATCH 1/2] created reports page with get /reports to populate the table and post reports endpoints for generating a report --- apps/frontend/src/app/reports/page.tsx | 489 +++++++++++++++++++++++++ 1 file changed, 489 insertions(+) create mode 100644 apps/frontend/src/app/reports/page.tsx diff --git a/apps/frontend/src/app/reports/page.tsx b/apps/frontend/src/app/reports/page.tsx new file mode 100644 index 0000000..737c63f --- /dev/null +++ b/apps/frontend/src/app/reports/page.tsx @@ -0,0 +1,489 @@ +'use client'; +import React, { useEffect, useState, Suspense } from 'react'; +import { useQueryParams } from '@/hooks/useQueryParams'; +import Header from '../components/Header'; +import Pagination from '../components/Pagination'; +import { + HStack, + Button, + Table, + Checkbox, + NativeSelect, + Dialog, + Portal, + VStack, +} from '@chakra-ui/react'; +import { apiFetch } from '@/lib/api'; +import { FaPlus } from 'react-icons/fa'; +import { LuClipboardPenLine } from 'react-icons/lu'; +import { RiDeleteBack2Line } from "react-icons/ri"; +import { IoClose } from "react-icons/io5"; + + +type Report = { + report_id: number; + project_id: number; + title: string; + object_url: string; + report_type: string; + date_created: string | null; + emails?: string[]; +}; + +type Project = { + project_id: number; + name: string; +}; + +const ROWS_PER_PAGE = 10; + +const EXTENSION_LABELS: Record = { + pdf: 'PDF', + docx: 'Word', +}; + +function getFormatLabel(objectUrl: string): string { + const match = objectUrl.match(/\.([a-zA-Z0-9]+)(?:\?.*)?$/); + const ext = match?.[1]?.toLowerCase(); + if (!ext) return '—'; + return EXTENSION_LABELS[ext] ?? ext.toUpperCase(); +} + +const FILE_TYPE_OPTIONS: { value: 'pdf' | 'docx'; label: string }[] = [ + { value: 'pdf', label: 'PDF' }, + { value: 'docx', label: 'Word (.docx)' }, +]; + +function formatDate(dateString: string | null): string { + if (!dateString) return 'MM/DD/YYYY'; + const date = new Date(dateString); + if (Number.isNaN(date.getTime())) return 'MM/DD/YYYY'; + const mm = String(date.getMonth() + 1).padStart(2, '0'); + const dd = String(date.getDate()).padStart(2, '0'); + const yyyy = date.getFullYear(); + return `${mm}/${dd}/${yyyy}`; +} + +export default function ReportsPage() { + return ( + + + + ); +} + +function ReportsPageContent() { + // Data + const [reports, setReports] = useState([]); + const [projects, setProjects] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + // Selected rows (checkboxes) for bulk delete + const [selectedIds, setSelectedIds] = useState([]); + + // Tab: Reports vs Schedule + const [activeTab, setActiveTab] = useState<'reports' | 'schedule'>('reports'); + + // Generate controls — POST /reports/generate requires project_id + file_type + const [generateProjectId, setGenerateProjectId] = useState(''); + const [generateFileType, setGenerateFileType] = useState<'pdf' | 'docx'>('pdf'); + const [generating, setGenerating] = useState(false); + + // Generate modal visibility + const [showGenerateModal, setShowGenerateModal] = useState(false); + + // Pagination (synced to URL query params) + const [filters, setFilter] = useQueryParams({ + page: '', + }); + const currentPage = parseInt(filters.page, 10) || 1; + + const token = typeof window !== 'undefined' ? localStorage.getItem('branch_access_token') ?? '' : ''; + + // Fetch reports + async function fetchReports() { + try { + const json = await apiFetch<{ data: Report[] }>('/reports', { token }); + setReports(json.data ?? []); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to load reports'); + } finally { + setLoading(false); + } + } + + // Fetch projects (used to resolve project_id -> name if needed elsewhere) + async function fetchProjects() { + try { + const json = await apiFetch('/projects', { token }); + const list = Array.isArray(json) ? json : []; + setProjects(list); + if (list.length > 0) { + setGenerateProjectId(String(list[0].project_id)); + } + } catch { + // Projects fetch failure is non-critical + } + } + + useEffect(() => { + fetchReports(); + fetchProjects(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + // Pagination + const totalPages = Math.max(1, Math.ceil(reports.length / ROWS_PER_PAGE)); + const paginatedData = reports.slice( + (currentPage - 1) * ROWS_PER_PAGE, + currentPage * ROWS_PER_PAGE, + ); + + // Selection helpers (scoped to the currently visible page of rows) + const allSelected = paginatedData.length > 0 && paginatedData.every((r) => selectedIds.includes(r.report_id)); + const someSelected = paginatedData.some((r) => selectedIds.includes(r.report_id)) && !allSelected; + + function toggleAll() { + if (allSelected) { + setSelectedIds((prev) => prev.filter((id) => !paginatedData.some((r) => r.report_id === id))); + } else { + const pageIds = paginatedData.map((r) => r.report_id); + setSelectedIds((prev) => Array.from(new Set([...prev, ...pageIds]))); + } + } + + function toggleOne(id: number) { + setSelectedIds((prev) => + prev.includes(id) ? prev.filter((existing) => existing !== id) : [...prev, id], + ); + } + + + // Bulk delete handler + // NOTE: DELETE /reports endpoint doesn't exist + async function handleDeleteSelected() { + console.log('Delete isn\'t available yet — no DELETE /reports endpoint exists on the backend.'); + /* await apiFetch('/reports', { + token, + method: 'DELETE', + body: JSON.stringify({ ids: selectedIds }), + }); + setSelectedIds([]); + await fetchReports(); + */ + } + + // Generate handler — POST /reports/generate { project_id, file_type } + async function handleGenerate() { + if (!generateProjectId) { + setError('Select a project before generating a report'); + return; + } + setGenerating(true); + setError(null); + try { + await apiFetch('/reports/generate', { + token, + method: 'POST', + body: JSON.stringify({ + project_id: parseInt(generateProjectId, 10), + file_type: generateFileType, + }), + }); + await fetchReports(); + setShowGenerateModal(false); + } catch (err) { + setError(err instanceof Error ? err.message : 'Failed to generate report'); + } finally { + setGenerating(false); + } + } + + // New Report handler + // NOTE: no create/upload flow defined yet — stub until that's scoped. + async function handleNewReport() { + console.log('New Report clicked — needs a create/upload flow defined'); + } + + + return ( +
+
+
+
+

+ Reports +

+ + {/* Tabs + Toolbar */} + + + + + + + + {/* Delete */} + + + {/* Generate */} + + + {/* + New Report */} + + + + + {/* Loading / Error */} + {loading &&

Loading reports...

} + {error &&

{error}

} + + {/* Reports tab content */} + {!loading && !error && activeTab === 'reports' && ( + + + + + + + + + + + Date Created + + + Report Name + + + Emails + + + Format + + + + + {paginatedData.length === 0 && ( + + + No reports found. + + + )} + {paginatedData.map((report) => ( + + + toggleOne(report.report_id)} + > + + + + + {formatDate(report.date_created)} + {report.title || 'Untitled report'} + + {report.emails && report.emails.length > 0 ? report.emails.join(', ') : '—'} + + + {getFormatLabel(report.object_url)} + + + ))} + + + )} + + {/* Schedule tab content */} + {!loading && !error && activeTab === 'schedule' && ( +

+ Schedule view not yet implemented. +

+ )} + + {/* Pagination */} + {!loading && !error && activeTab === 'reports' && ( + setFilter({ page: String(p) })} + /> + )} +
+ + + {/* Generate New Report modal — matches Figma */} + setShowGenerateModal(details.open)} + > + + + + + + + Generate New Report + + + + + + + + + +
+ + + setGenerateProjectId(e.target.value)} + > + {projects.length === 0 && } + {projects.map((p) => ( + + ))} + + + +
+ +
+ + + setGenerateFileType(e.target.value as 'pdf' | 'docx')} + > + {FILE_TYPE_OPTIONS.map((opt) => ( + + ))} + + + +
+
+
+ + + + +
+
+
+
+
+
+ ); +} \ No newline at end of file From 329124dc7f2c7dfe4117d5182c637b49d1c4ccc2 Mon Sep 17 00:00:00 2001 From: mehanana Date: Tue, 7 Jul 2026 19:10:27 -0400 Subject: [PATCH 2/2] tests for reports page --- .../test/components/ReportsPage.test.tsx | 227 ++++++++++++++++++ 1 file changed, 227 insertions(+) create mode 100644 apps/frontend/test/components/ReportsPage.test.tsx diff --git a/apps/frontend/test/components/ReportsPage.test.tsx b/apps/frontend/test/components/ReportsPage.test.tsx new file mode 100644 index 0000000..1d07e83 --- /dev/null +++ b/apps/frontend/test/components/ReportsPage.test.tsx @@ -0,0 +1,227 @@ +import { render, screen, waitFor, within } from '../utils'; +import userEvent from '@testing-library/user-event'; +import ReportsPage from '@/app/reports/page'; +import { apiFetch } from '@/lib/api'; + +jest.mock('../../src/lib/api', () => ({ + apiFetch: jest.fn(), +})); + +jest.mock('../../src/hooks/useQueryParams', () => ({ + useQueryParams: jest.fn(() => [{ page: '' }, jest.fn()]), +})); + +// apiFetch is cast inline at each call site below, matching AddExpenseModal.test.tsx convention + +const mockReports = [ + { + report_id: 1, + project_id: 1, + title: 'Clinician Communication Study Report', + object_url: 'https://s3.amazonaws.com/branch-reports/report-1.pdf', + report_type: 'technical', + date_created: '2026-06-01', + }, + { + report_id: 2, + project_id: 2, + title: 'Health Education Initiative Report', + object_url: 'https://s3.amazonaws.com/branch-reports/report-2.docx', + report_type: 'narrative', + date_created: '2026-06-02', + }, +]; + +const mockProjects = [ + { project_id: 1, name: 'Clinician Communication Study' }, + { project_id: 2, name: 'Health Education Initiative' }, +]; + +function mockApiFetchImplementation({ + reports = mockReports, + projects = mockProjects, +}: { reports?: typeof mockReports; projects?: typeof mockProjects } = {}) { + (apiFetch as jest.Mock).mockImplementation((endpoint: string) => { + if (endpoint === '/reports') { + return Promise.resolve({ data: reports }); + } + if (endpoint === '/projects') { + return Promise.resolve(projects); + } + return Promise.resolve({}); + }); +} + +beforeEach(() => { + jest.clearAllMocks(); + window.localStorage.setItem('branch_access_token', 'test-token'); +}); + +describe('ReportsPage', () => { + it('renders the heading', async () => { + mockApiFetchImplementation(); + render(); + expect(screen.getByRole('heading', { name: 'Reports', level: 1 })).toBeInTheDocument(); + await waitFor(() => expect(apiFetch).toHaveBeenCalledWith('/reports', expect.anything())); + }); + + it('renders a row for each fetched report', async () => { + mockApiFetchImplementation(); + render(); + + expect(await screen.findByText('Clinician Communication Study Report')).toBeInTheDocument(); + expect(screen.getByText('Health Education Initiative Report')).toBeInTheDocument(); + }); + + it('shows "No reports found." when the reports list is empty', async () => { + mockApiFetchImplementation({ reports: [] }); + render(); + + expect(await screen.findByText('No reports found.')).toBeInTheDocument(); + }); + + it('shows an error message when fetching reports fails', async () => { + (apiFetch as jest.Mock).mockImplementation((endpoint: string) => { + if (endpoint === '/reports') { + return Promise.reject(new Error('Failed to load reports')); + } + return Promise.resolve(mockProjects); + }); + render(); + + expect(await screen.findByText('Failed to load reports')).toBeInTheDocument(); + }); + + it('derives the Format column from the object_url extension', async () => { + mockApiFetchImplementation(); + render(); + + await screen.findByText('Clinician Communication Study Report'); + + expect(screen.getByText('PDF')).toBeInTheDocument(); + expect(screen.getByText('Word')).toBeInTheDocument(); + }); + + it('disables the Delete button until at least one row is selected', async () => { + const user = userEvent.setup(); + mockApiFetchImplementation(); + render(); + + await screen.findByText('Clinician Communication Study Report'); + + const deleteButton = screen.getByRole('button', { name: /delete/i }); + expect(deleteButton).toBeDisabled(); + + const checkboxes = screen.getAllByRole('checkbox'); + // First checkbox in the header is "select all"; row checkboxes follow. + await user.click(checkboxes[1]); + + expect(deleteButton).not.toBeDisabled(); + }); + + it('selects all rows on the current page when the header checkbox is checked', async () => { + const user = userEvent.setup(); + mockApiFetchImplementation(); + render(); + + await screen.findByText('Clinician Communication Study Report'); + + const checkboxes = screen.getAllByRole('checkbox'); + const headerCheckbox = checkboxes[0]; + await user.click(headerCheckbox); + + const deleteButton = screen.getByRole('button', { name: /delete/i }); + expect(deleteButton).not.toBeDisabled(); + }); + + it('opens the Generate New Report modal when Generate is clicked', async () => { + const user = userEvent.setup(); + mockApiFetchImplementation(); + render(); + + await screen.findByText('Clinician Communication Study Report'); + + await user.click(screen.getByRole('button', { name: /^generate$/i })); + + const dialog = await screen.findByRole('dialog'); + expect(within(dialog).getByText('Generate New Report')).toBeInTheDocument(); + expect(within(dialog).getByText('Project')).toBeInTheDocument(); + expect(within(dialog).getByText('Format')).toBeInTheDocument(); + }); + + it('closes the Generate modal when Cancel is clicked', async () => { + const user = userEvent.setup(); + mockApiFetchImplementation(); + render(); + + await screen.findByText('Clinician Communication Study Report'); + + await user.click(screen.getByRole('button', { name: /^generate$/i })); + const dialog = await screen.findByRole('dialog'); + expect(within(dialog).getByText('Generate New Report')).toBeInTheDocument(); + + await user.click(within(dialog).getByRole('button', { name: /cancel/i })); + + await waitFor(() => { + expect(screen.queryByRole('dialog')).not.toBeInTheDocument(); + }); + }); + + it('calls POST /reports/generate with the selected project and file type', async () => { + const user = userEvent.setup(); + (apiFetch as jest.Mock).mockImplementation((endpoint: string) => { + if (endpoint === '/reports') return Promise.resolve({ data: mockReports }); + if (endpoint === '/projects') return Promise.resolve(mockProjects); + if (endpoint === '/reports/generate') return Promise.resolve({ ok: true }); + return Promise.resolve({}); + }); + + render(); + await screen.findByText('Clinician Communication Study Report'); + + await user.click(screen.getByRole('button', { name: /^generate$/i })); + const dialog = await screen.findByRole('dialog'); + + await user.click(within(dialog).getByRole('button', { name: /^generate$/i })); + + await waitFor(() => { + expect(apiFetch).toHaveBeenCalledWith( + '/reports/generate', + expect.objectContaining({ + method: 'POST', + body: JSON.stringify({ project_id: 1, file_type: 'pdf' }), + }), + ); + }); + }); + + it('logs a stub message when New Report is clicked', async () => { + const user = userEvent.setup(); + const consoleSpy = jest.spyOn(console, 'log').mockImplementation(() => {}); + mockApiFetchImplementation(); + render(); + + await screen.findByText('Clinician Communication Study Report'); + + await user.click(screen.getByRole('button', { name: /new report/i })); + + expect(consoleSpy).toHaveBeenCalledWith( + expect.stringContaining('New Report clicked'), + ); + + consoleSpy.mockRestore(); + }); + + it('switches to the Schedule tab and shows the not-implemented message', async () => { + const user = userEvent.setup(); + mockApiFetchImplementation(); + render(); + + await screen.findByText('Clinician Communication Study Report'); + + await user.click(screen.getByRole('button', { name: /schedule/i })); + + expect(await screen.findByText('Schedule view not yet implemented.')).toBeInTheDocument(); + expect(screen.queryByText('Clinician Communication Study Report')).not.toBeInTheDocument(); + }); +}); \ No newline at end of file