V0 : initial import
This commit is contained in:
16
PS_Report/src/components/Layout.tsx
Normal file
16
PS_Report/src/components/Layout.tsx
Normal file
@@ -0,0 +1,16 @@
|
||||
import { Outlet } from 'react-router-dom';
|
||||
import AppNavbar from './Navbar';
|
||||
import Container from 'react-bootstrap/Container';
|
||||
|
||||
function Layout() {
|
||||
return (
|
||||
<div className="app-layout">
|
||||
<AppNavbar />
|
||||
<Container fluid className="main-content py-4 px-4">
|
||||
<Outlet />
|
||||
</Container>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default Layout;
|
||||
14
PS_Report/src/components/LoadingSpinner.tsx
Normal file
14
PS_Report/src/components/LoadingSpinner.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
import Spinner from 'react-bootstrap/Spinner';
|
||||
import { useI18n } from '../i18n/context';
|
||||
|
||||
function LoadingSpinner() {
|
||||
const { t } = useI18n();
|
||||
return (
|
||||
<div className="d-flex justify-content-center align-items-center py-5">
|
||||
<Spinner animation="border" variant="primary" />
|
||||
<span className="ms-3 text-muted-custom">{t('loading')}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default LoadingSpinner;
|
||||
56
PS_Report/src/components/Navbar.tsx
Normal file
56
PS_Report/src/components/Navbar.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
import { NavLink } from 'react-router-dom';
|
||||
import Navbar from 'react-bootstrap/Navbar';
|
||||
import Nav from 'react-bootstrap/Nav';
|
||||
import Container from 'react-bootstrap/Container';
|
||||
import { useI18n } from '../i18n/context';
|
||||
|
||||
const BASE = import.meta.env.BASE_URL || '/';
|
||||
const logoImg = `${BASE}logo.png`;
|
||||
|
||||
function AppNavbar() {
|
||||
const { lang, setLang, t } = useI18n();
|
||||
|
||||
return (
|
||||
<Navbar bg="dark" variant="dark" expand="lg" className="app-navbar">
|
||||
<Container fluid>
|
||||
<Navbar.Brand as={NavLink} to="/" className="fw-bold d-flex align-items-center gap-2">
|
||||
<img src={logoImg} alt="Logo" className="brand-logo" />
|
||||
<span>
|
||||
<span className="brand-proserve">PROSERVE</span>
|
||||
<span className="brand-report ms-2">Report</span>
|
||||
</span>
|
||||
</Navbar.Brand>
|
||||
<Navbar.Toggle aria-controls="main-nav" />
|
||||
<Navbar.Collapse id="main-nav">
|
||||
<Nav className="me-auto">
|
||||
<Nav.Link as={NavLink} to="/" end>
|
||||
{t('nav.dashboard')}
|
||||
</Nav.Link>
|
||||
<Nav.Link as={NavLink} to="/sessions">
|
||||
{t('nav.sessions')}
|
||||
</Nav.Link>
|
||||
<Nav.Link as={NavLink} to="/users">
|
||||
{t('nav.users')}
|
||||
</Nav.Link>
|
||||
</Nav>
|
||||
<div className="lang-switcher">
|
||||
<button
|
||||
className={`lang-btn ${lang === 'fr' ? 'active' : ''}`}
|
||||
onClick={() => setLang('fr')}
|
||||
>
|
||||
FR
|
||||
</button>
|
||||
<button
|
||||
className={`lang-btn ${lang === 'en' ? 'active' : ''}`}
|
||||
onClick={() => setLang('en')}
|
||||
>
|
||||
EN
|
||||
</button>
|
||||
</div>
|
||||
</Navbar.Collapse>
|
||||
</Container>
|
||||
</Navbar>
|
||||
);
|
||||
}
|
||||
|
||||
export default AppNavbar;
|
||||
25
PS_Report/src/components/PrintHeader.tsx
Normal file
25
PS_Report/src/components/PrintHeader.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import { useI18n } from '../i18n/context';
|
||||
|
||||
const BASE = import.meta.env.BASE_URL || '/';
|
||||
const logoImg = `${BASE}logo.png`;
|
||||
|
||||
interface PrintHeaderProps {
|
||||
subtitle?: string;
|
||||
}
|
||||
|
||||
function PrintHeader({ subtitle }: PrintHeaderProps) {
|
||||
const { t } = useI18n();
|
||||
const now = new Date();
|
||||
const dateStr = now.toLocaleDateString('fr-FR', { day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit' });
|
||||
|
||||
return (
|
||||
<div className="print-header">
|
||||
<img src={logoImg} alt="Logo" />
|
||||
<span className="print-title">PROSERVE Report</span>
|
||||
{subtitle && <span style={{ color: '#333', fontSize: '0.9rem' }}>— {subtitle}</span>}
|
||||
<span className="print-subtitle">{t('print.generatedOn')} {dateStr}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default PrintHeader;
|
||||
19
PS_Report/src/components/ScoreBadge.tsx
Normal file
19
PS_Report/src/components/ScoreBadge.tsx
Normal file
@@ -0,0 +1,19 @@
|
||||
import Badge from 'react-bootstrap/Badge';
|
||||
import { useI18n } from '../i18n/context';
|
||||
|
||||
interface ScoreBadgeProps {
|
||||
success: boolean;
|
||||
score?: number;
|
||||
}
|
||||
|
||||
function ScoreBadge({ success, score }: ScoreBadgeProps) {
|
||||
const { t } = useI18n();
|
||||
return (
|
||||
<Badge bg={success ? 'success' : 'danger'}>
|
||||
{score !== undefined && <span className="me-1">{score}</span>}
|
||||
{success ? t('badge.success') : t('badge.failed')}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
export default ScoreBadge;
|
||||
22
PS_Report/src/components/SessionTypeBadge.tsx
Normal file
22
PS_Report/src/components/SessionTypeBadge.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
import Badge from 'react-bootstrap/Badge';
|
||||
import { SESSION_TYPE_COLORS } from '../types';
|
||||
import { useI18n } from '../i18n/context';
|
||||
import type { TranslationKey } from '../i18n/translations';
|
||||
|
||||
interface SessionTypeBadgeProps {
|
||||
typeId: number;
|
||||
}
|
||||
|
||||
function SessionTypeBadge({ typeId }: SessionTypeBadgeProps) {
|
||||
const { t } = useI18n();
|
||||
const label = t(`sessionType.${typeId}` as TranslationKey);
|
||||
const color = SESSION_TYPE_COLORS[typeId] || '#6c757d';
|
||||
|
||||
return (
|
||||
<Badge style={{ backgroundColor: color }} className="session-type-badge">
|
||||
{label}
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
|
||||
export default SessionTypeBadge;
|
||||
25
PS_Report/src/components/StatCard.tsx
Normal file
25
PS_Report/src/components/StatCard.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import Card from 'react-bootstrap/Card';
|
||||
|
||||
interface StatCardProps {
|
||||
title: string;
|
||||
value: string | number;
|
||||
subtitle?: string;
|
||||
color?: string;
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
function StatCard({ title, value, subtitle, color = '#4a90d9', onClick }: StatCardProps) {
|
||||
return (
|
||||
<Card className={`stat-card h-100${onClick ? ' clickable-row' : ''}`} onClick={onClick} style={onClick ? { cursor: 'pointer' } : undefined}>
|
||||
<Card.Body className="text-center">
|
||||
<Card.Subtitle className="mb-2 text-muted-custom">{title}</Card.Subtitle>
|
||||
<Card.Title className="stat-value" style={{ color }}>
|
||||
{value}
|
||||
</Card.Title>
|
||||
{subtitle && <small className="text-muted-custom">{subtitle}</small>}
|
||||
</Card.Body>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export default StatCard;
|
||||
42
PS_Report/src/components/charts/ActivityChart.tsx
Normal file
42
PS_Report/src/components/charts/ActivityChart.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
import { BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer } from 'recharts';
|
||||
import type { Session } from '../../types';
|
||||
|
||||
interface ActivityChartProps {
|
||||
sessions: Session[];
|
||||
}
|
||||
|
||||
function ActivityChart({ sessions }: ActivityChartProps) {
|
||||
const monthCounts: Record<string, number> = {};
|
||||
|
||||
sessions.forEach((s) => {
|
||||
if (s.sessionDateAsString) {
|
||||
const date = new Date(s.sessionDateAsString);
|
||||
const key = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}`;
|
||||
monthCounts[key] = (monthCounts[key] || 0) + 1;
|
||||
}
|
||||
});
|
||||
|
||||
const data = Object.entries(monthCounts)
|
||||
.sort(([a], [b]) => a.localeCompare(b))
|
||||
.slice(-12)
|
||||
.map(([month, count]) => ({
|
||||
month,
|
||||
sessions: count,
|
||||
}));
|
||||
|
||||
if (data.length === 0) return <p className="text-muted-custom text-center">Aucune donnée</p>;
|
||||
|
||||
return (
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<BarChart data={data}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#333" />
|
||||
<XAxis dataKey="month" stroke="#888" />
|
||||
<YAxis stroke="#888" allowDecimals={false} />
|
||||
<Tooltip contentStyle={{ backgroundColor: '#1a1a2e', border: '1px solid #333' }} />
|
||||
<Bar dataKey="sessions" fill="#4a90d9" name="Sessions" />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
);
|
||||
}
|
||||
|
||||
export default ActivityChart;
|
||||
62
PS_Report/src/components/charts/PrecisionChart.tsx
Normal file
62
PS_Report/src/components/charts/PrecisionChart.tsx
Normal file
@@ -0,0 +1,62 @@
|
||||
import { LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, Legend } from 'recharts';
|
||||
import type { SessionDebriefRow } from '../../types';
|
||||
import { useI18n } from '../../i18n/context';
|
||||
|
||||
interface PrecisionChartProps {
|
||||
debriefRows: SessionDebriefRow[];
|
||||
}
|
||||
|
||||
function PrecisionChart({ debriefRows }: PrecisionChartProps) {
|
||||
const { t, lang } = useI18n();
|
||||
|
||||
// Group by sessionId and compute averages per session
|
||||
const bySession = new Map<number, { date: string; precisions: number[]; reactions: number[] }>();
|
||||
|
||||
debriefRows.forEach((row) => {
|
||||
if (!bySession.has(row.sessionId)) {
|
||||
bySession.set(row.sessionId, { date: row.sessionDate, precisions: [], reactions: [] });
|
||||
}
|
||||
const entry = bySession.get(row.sessionId)!;
|
||||
if (row.hitPrecision > 0) entry.precisions.push(row.hitPrecision);
|
||||
if (row.reactionTime > 0) entry.reactions.push(row.reactionTime);
|
||||
});
|
||||
|
||||
const data = Array.from(bySession.entries())
|
||||
.map(([, val]) => {
|
||||
const avgPrec = val.precisions.length > 0
|
||||
? Math.round((val.precisions.reduce((a, b) => a + b, 0) / val.precisions.length) * 100) / 100
|
||||
: 0;
|
||||
const avgReact = val.reactions.length > 0
|
||||
? Math.round(val.reactions.reduce((a, b) => a + b, 0) / val.reactions.length)
|
||||
: 0;
|
||||
return {
|
||||
date: val.date,
|
||||
dateLabel: new Date(val.date).toLocaleDateString(lang === 'fr' ? 'fr-FR' : 'en-US', { day: '2-digit', month: '2-digit' }),
|
||||
precision: avgPrec,
|
||||
reactionTime: avgReact,
|
||||
};
|
||||
})
|
||||
.sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime());
|
||||
|
||||
if (data.length === 0) return <p className="text-muted-custom text-center">Aucune donnée</p>;
|
||||
|
||||
return (
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<LineChart data={data}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#333" />
|
||||
<XAxis dataKey="dateLabel" stroke="#888" />
|
||||
<YAxis yAxisId="left" stroke="#4a90d9" />
|
||||
<YAxis yAxisId="right" orientation="right" stroke="#f39c12" />
|
||||
<Tooltip
|
||||
contentStyle={{ backgroundColor: '#1a1a2e', border: '1px solid #333' }}
|
||||
labelFormatter={(label) => label}
|
||||
/>
|
||||
<Legend />
|
||||
<Line yAxisId="left" type="monotone" dataKey="precision" stroke="#4a90d9" name={t('chart.precision')} strokeWidth={2} dot={{ r: 3 }} />
|
||||
<Line yAxisId="right" type="monotone" dataKey="reactionTime" stroke="#f39c12" name={t('chart.reactionTime')} strokeWidth={2} dot={{ r: 3 }} />
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
);
|
||||
}
|
||||
|
||||
export default PrecisionChart;
|
||||
50
PS_Report/src/components/charts/SessionsByTypeChart.tsx
Normal file
50
PS_Report/src/components/charts/SessionsByTypeChart.tsx
Normal file
@@ -0,0 +1,50 @@
|
||||
import { PieChart, Pie, Cell, Tooltip, ResponsiveContainer, Legend } from 'recharts';
|
||||
import { SESSION_TYPE_LABELS, SESSION_TYPE_COLORS } from '../../types';
|
||||
import type { Session } from '../../types';
|
||||
|
||||
interface SessionsByTypeChartProps {
|
||||
sessions: Session[];
|
||||
onSliceClick?: (typeId: number) => void;
|
||||
}
|
||||
|
||||
function SessionsByTypeChart({ sessions, onSliceClick }: SessionsByTypeChartProps) {
|
||||
const countByType: Record<number, number> = {};
|
||||
sessions.forEach((s) => {
|
||||
countByType[s.sessionTypeAsInt] = (countByType[s.sessionTypeAsInt] || 0) + 1;
|
||||
});
|
||||
|
||||
const data = Object.entries(countByType).map(([typeId, count]) => ({
|
||||
name: SESSION_TYPE_LABELS[Number(typeId)] || `Type ${typeId}`,
|
||||
value: count,
|
||||
color: SESSION_TYPE_COLORS[Number(typeId)] || '#6c757d',
|
||||
typeId: Number(typeId),
|
||||
}));
|
||||
|
||||
if (data.length === 0) return <p className="text-muted-custom text-center">Aucune donnée</p>;
|
||||
|
||||
return (
|
||||
<ResponsiveContainer width="100%" height={300}>
|
||||
<PieChart>
|
||||
<Pie
|
||||
data={data}
|
||||
dataKey="value"
|
||||
nameKey="name"
|
||||
cx="50%"
|
||||
cy="50%"
|
||||
outerRadius={100}
|
||||
label
|
||||
style={onSliceClick ? { cursor: 'pointer' } : undefined}
|
||||
onClick={onSliceClick ? (_data: unknown, index: number) => onSliceClick(data[index].typeId) : undefined}
|
||||
>
|
||||
{data.map((entry, index) => (
|
||||
<Cell key={index} fill={entry.color} />
|
||||
))}
|
||||
</Pie>
|
||||
<Tooltip contentStyle={{ backgroundColor: '#1a1a2e', border: '1px solid #333' }} />
|
||||
<Legend />
|
||||
</PieChart>
|
||||
</ResponsiveContainer>
|
||||
);
|
||||
}
|
||||
|
||||
export default SessionsByTypeChart;
|
||||
Reference in New Issue
Block a user