Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ flowchart LR

- `App.tsx` orchestrates auth state, active section, view switching, and modal/toast state.
- `components/*` implements pages and shared UI.
- `components/reports/*` contains the branded session PDF document used by the dashboard export flow.
- Navigation is app-state driven rather than router-driven.

### 2. Hook Layer
Expand All @@ -65,6 +66,7 @@ flowchart LR
- `profiles` for app roles and user metadata
- `members` for member records
- `marks` for per-member attendance and scores
- `services/reporting/sessionReport.ts` derives PDF-ready session reporting aggregates from loaded section data.
- `services/settings.ts` handles section-level settings, updating the seeded `company` and `junior` rows in place.

## State Model
Expand All @@ -77,6 +79,7 @@ Sources of truth:
- React component and hook state for loaded records and view state

The app does not maintain an offline cache or a separate backend API.
The branded session PDF generator also runs fully client-side in the browser.

## Security Model

Expand Down
Binary file added assets/branding/bb-background.jpg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added assets/branding/bb-logo.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added assets/branding/company-logo.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added assets/branding/junior-logo.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
41 changes: 37 additions & 4 deletions components/DashboardPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,13 @@
* providing a high-level overview for reporting and comparison.
*/

import React, { useMemo } from 'react';
import React, { Suspense, useMemo } from 'react';
import { Boy, Squad, Section, JuniorSquad, Mark } from '../types';
import { StarIcon, ChartBarIcon } from './Icons';
import { StarIcon, ChartBarIcon, ClipboardDocumentListIcon } from './Icons';
import BarChart from './BarChart';

const SessionReportModal = React.lazy(() => import('./SessionReportModal'));


interface DashboardPageProps {
boys: Boy[];
Expand Down Expand Up @@ -38,6 +40,7 @@ const SQUAD_CHART_COLORS: Record<string, string> = {
}

const DashboardPage: React.FC<DashboardPageProps> = ({ boys, activeSection }) => {
const [isReportModalOpen, setIsReportModalOpen] = React.useState(false);
const isCompany = activeSection === 'company';
const SQUAD_COLORS = isCompany ? COMPANY_SQUAD_COLORS : JUNIOR_SQUAD_COLORS;

Expand Down Expand Up @@ -165,7 +168,26 @@ const DashboardPage: React.FC<DashboardPageProps> = ({ boys, activeSection }) =>

return (
<div className="space-y-8">
<h1 className="text-3xl font-bold tracking-tight text-slate-900">Dashboard</h1>
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div>
<h1 className="text-3xl font-bold tracking-tight text-slate-900">Dashboard</h1>
<p className="mt-2 max-w-2xl text-sm leading-6 text-slate-600">
Review session performance across marks and attendance, then export a single master BB report for the active section.
</p>
</div>
<button
type="button"
onClick={() => setIsReportModalOpen(true)}
className={`inline-flex items-center justify-center rounded-md px-4 py-2 text-sm font-medium text-white shadow-sm focus:outline-none focus:ring-2 focus:ring-offset-2 ${
isCompany
? 'bg-company-blue hover:brightness-95 focus:ring-company-blue'
: 'bg-junior-blue hover:brightness-95 focus:ring-junior-blue'
}`}
>
<ClipboardDocumentListIcon className="mr-2 h-5 w-5" />
Generate Master PDF
</button>
</div>

{/* Visualizations Grid */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
Expand Down Expand Up @@ -274,8 +296,19 @@ const DashboardPage: React.FC<DashboardPageProps> = ({ boys, activeSection }) =>
</div>
</div>
</div>

{isReportModalOpen && (
<Suspense fallback={null}>
<SessionReportModal
boys={boys}
activeSection={activeSection}
isOpen={isReportModalOpen}
onClose={() => setIsReportModalOpen(false)}
/>
</Suspense>
)}
</div>
);
};

export default DashboardPage;
export default DashboardPage;
173 changes: 173 additions & 0 deletions components/SessionReportModal.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,173 @@
import React, { useMemo, useState } from 'react';
import { PDFDownloadLink } from '@react-pdf/renderer';

import Modal from './Modal';
import SessionReportDocument from './reports/SessionReportDocument';
import { buildSessionReportData, getSectionDateRange } from '../services/reporting/sessionReport';
import type { Boy, Section } from '../types';

interface SessionReportModalProps {
boys: Boy[];
activeSection: Section;
isOpen: boolean;
onClose: () => void;
}

const formatDate = (value: string) =>
new Date(`${value}T00:00:00`).toLocaleDateString(undefined, {
day: 'numeric',
month: 'short',
year: 'numeric',
});

const SessionReportModal: React.FC<SessionReportModalProps> = ({
boys,
activeSection,
isOpen,
onClose,
}) => {
const sectionRange = useMemo(() => getSectionDateRange(boys), [boys]);
const [startDate, setStartDate] = useState(sectionRange?.startDate ?? '');
const [endDate, setEndDate] = useState(sectionRange?.endDate ?? '');
const inputBrandClasses =
activeSection === 'company'
? 'focus:border-company-blue focus:ring-company-blue'
: 'focus:border-junior-blue focus:ring-junior-blue';
const buttonBrandClasses =
activeSection === 'company'
? 'bg-company-blue focus:ring-company-blue'
: 'bg-junior-blue focus:ring-junior-blue';

React.useEffect(() => {
setStartDate(sectionRange?.startDate ?? '');
setEndDate(sectionRange?.endDate ?? '');
}, [sectionRange, isOpen]);

const hasValidRange = Boolean(startDate && endDate && startDate <= endDate);
const report = useMemo(() => {
if (!hasValidRange) {
return null;
}

return buildSessionReportData({
boys,
section: activeSection,
range: { startDate, endDate },
});
}, [activeSection, boys, endDate, hasValidRange, startDate]);

const hasDataForRange = Boolean(report && report.headlineStats.meetingCount > 0);
const sectionLabel = activeSection === 'company' ? 'Company Section' : 'Junior Section';
const filename = `${activeSection}-session-report-${startDate || 'start'}-to-${endDate || 'end'}.pdf`;
const estimatedPageCount = report
? 1 + 1 + 1 + Math.max(1, Math.ceil(report.meetings.length / 18)) + 1 + Math.max(1, Math.ceil(report.members.length / (activeSection === 'junior' ? 14 : 16))) + report.members.reduce((sum, member) => sum + 1 + Math.ceil(Math.max(member.meetings.length - 14, 0) / 22), 0)
: 0;

return (
<Modal isOpen={isOpen} onClose={onClose} title="Master Session PDF" size="lg">
{!sectionRange ? (
<div className="space-y-3">
<p className="text-slate-700">
There are no recorded marks in this section yet, so there is nothing to export.
</p>
</div>
) : (
<div className="space-y-6">
<div className="rounded-xl border border-slate-200 bg-slate-50 p-4">
<h3 className="text-lg font-semibold text-slate-900">Single Master Report</h3>
<p className="mt-2 text-sm leading-6 text-slate-600">
This export produces one branded end-of-session PDF for the active section using the current BB logo
and photography already used in the app. The document includes the section summary, attendance and marks
trends, squad breakdowns, and a page for every member in the selected date range.
</p>
</div>

<div className="grid gap-4 sm:grid-cols-2">
<label className="block">
<span className="mb-2 block text-sm font-medium text-slate-700">Session Start</span>
<input
type="date"
value={startDate}
min={sectionRange.startDate}
max={sectionRange.endDate}
onChange={(event) => setStartDate(event.target.value)}
className={`w-full rounded-md border border-slate-300 px-3 py-2 text-slate-900 shadow-sm focus:outline-none focus:ring-2 ${inputBrandClasses}`}
/>
</label>
<label className="block">
<span className="mb-2 block text-sm font-medium text-slate-700">Session End</span>
<input
type="date"
value={endDate}
min={sectionRange.startDate}
max={sectionRange.endDate}
onChange={(event) => setEndDate(event.target.value)}
className={`w-full rounded-md border border-slate-300 px-3 py-2 text-slate-900 shadow-sm focus:outline-none focus:ring-2 ${inputBrandClasses}`}
/>
</label>
</div>

{hasValidRange ? (
hasDataForRange && report ? (
<div className="rounded-xl border border-slate-200 bg-white p-4 shadow-sm">
<div className="grid gap-4 sm:grid-cols-4">
<div>
<p className="text-xs font-semibold uppercase tracking-wide text-slate-500">Section</p>
<p className="mt-1 text-lg font-semibold text-slate-900">{sectionLabel}</p>
</div>
<div>
<p className="text-xs font-semibold uppercase tracking-wide text-slate-500">Meetings</p>
<p className="mt-1 text-lg font-semibold text-slate-900">{report.headlineStats.meetingCount}</p>
</div>
<div>
<p className="text-xs font-semibold uppercase tracking-wide text-slate-500">Attendance</p>
<p className="mt-1 text-lg font-semibold text-slate-900">{report.headlineStats.attendanceRate}%</p>
</div>
<div>
<p className="text-xs font-semibold uppercase tracking-wide text-slate-500">Pages</p>
<p className="mt-1 text-lg font-semibold text-slate-900">{estimatedPageCount}</p>
</div>
</div>
<p className="mt-4 text-sm text-slate-600">
{`Report range: ${formatDate(startDate)} to ${formatDate(endDate)}. This will export ${report.members.length} member detail pages in addition to the summary pages.`}
</p>
</div>
) : (
<div className="rounded-xl border border-amber-200 bg-amber-50 p-4 text-sm text-amber-900">
There are no recorded marks inside that range. Pick dates that include at least one saved meeting.
</div>
)
) : (
<div className="rounded-xl border border-red-200 bg-red-50 p-4 text-sm text-red-700">
Choose a valid date range where the start date is on or before the end date.
</div>
)}

<div className="flex items-center justify-between gap-3 border-t border-slate-200 pt-4">
<button
type="button"
onClick={onClose}
className="rounded-md bg-slate-100 px-4 py-2 text-sm font-medium text-slate-700 hover:bg-slate-200 focus:outline-none focus:ring-2 focus:ring-slate-400 focus:ring-offset-2"
>
Close
</button>

{hasDataForRange && report ? (
<PDFDownloadLink
document={<SessionReportDocument report={report} />}
fileName={filename}
className={`inline-flex items-center rounded-md px-4 py-2 text-sm font-medium text-white hover:brightness-95 focus:outline-none focus:ring-2 focus:ring-offset-2 ${buttonBrandClasses}`}
>
{({ loading }) => (loading ? 'Preparing PDF...' : 'Download Master PDF')}
</PDFDownloadLink>
) : (
<span className="text-sm text-slate-500">PDF download becomes available once the range is valid.</span>
)}
</div>
</div>
)}
</Modal>
);
};

export default SessionReportModal;
Loading
Loading