V0 : initial import

This commit is contained in:
2026-03-09 07:12:13 +01:00
commit b86ebf9ae4
7810 changed files with 1274400 additions and 0 deletions

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;

View 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;