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

23
PS_Report/src/App.tsx Normal file
View File

@@ -0,0 +1,23 @@
import { Routes, Route } from 'react-router-dom';
import Layout from './components/Layout';
import Dashboard from './pages/Dashboard';
import Sessions from './pages/Sessions';
import SessionDetail from './pages/SessionDetail';
import Users from './pages/Users';
import UserDetail from './pages/UserDetail';
function App() {
return (
<Routes>
<Route element={<Layout />}>
<Route path="/" element={<Dashboard />} />
<Route path="/sessions" element={<Sessions />} />
<Route path="/sessions/:id" element={<SessionDetail />} />
<Route path="/users" element={<Users />} />
<Route path="/users/:id" element={<UserDetail />} />
</Route>
</Routes>
);
}
export default App;

View File

@@ -0,0 +1,91 @@
import type { Session, User, DebriefRow, Participation, UserGlobalStats, SessionDebriefRow } from '../types';
// In dev mode, the Vite proxy forwards /proserve to localhost
// In production (built), the API is on the same host
const API_BASE = '/proserve';
async function callApi<T>(endpoint: string, params: Record<string, string | number> = {}): Promise<T> {
const formData = new URLSearchParams();
for (const [key, value] of Object.entries(params)) {
formData.append(key, String(value));
}
const response = await fetch(`${API_BASE}/${endpoint}`, {
method: 'POST',
headers: {
'Content-Type': 'application/x-www-form-urlencoded',
},
body: formData.toString(),
});
if (!response.ok) {
throw new Error(`API error: ${response.status} ${response.statusText}`);
}
return response.json();
}
// Sessions
export async function getAllSessions(typeId = -1): Promise<Session[]> {
const data = await callApi<{ status: boolean; stats: Session[] }>('lists/all_sessions.php', { typeId });
return data.stats || [];
}
export async function getSessionsForUser(userId: number, typeId = -1): Promise<Session[]> {
const data = await callApi<{ status: boolean; stats: Session[] }>('lists/sessions_for_user.php', { userId, typeId });
return data.stats || [];
}
export async function getSession(sessionId: number): Promise<Session | null> {
const data = await callApi<{ status: boolean; session: Session }>('session/get.php', { sessionId });
return data.session || null;
}
// Users
export async function getAllUsers(): Promise<User[]> {
const data = await callApi<{ status: boolean; stats: User[] }>('lists/all_users.php');
return data.stats || [];
}
export async function getUser(userId: number): Promise<User | null> {
const data = await callApi<{ status: boolean; user: User }>('user/get.php', { userId });
return data.user || null;
}
export async function getUsersInSession(sessionId: number): Promise<User[]> {
const data = await callApi<{ status: boolean; stats: User[] }>('lists/users_in_session.php', { sessionId });
return data.stats || [];
}
// Stats & Debrief
export async function getDebrief(sessionId: number, userId = -1): Promise<DebriefRow[]> {
const data = await callApi<{ status: boolean; stats: DebriefRow[] }>('stats/get.php', {
sessionId,
userId,
});
return data.stats || [];
}
export async function getSessionStats(sessionId: number, userId = -1, sessionType = -1): Promise<SessionDebriefRow[]> {
const params: Record<string, string | number> = { sessionId };
if (userId > 0) params.userId = userId;
if (sessionType >= 0) params.sessionType = sessionType;
const data = await callApi<{ status: boolean; stats: SessionDebriefRow[] }>('stats/get.php', params);
return data.stats || [];
}
export async function getObjectives(sessionId: number, userId = -1): Promise<Participation | null> {
const params: Record<string, string | number> = { sessionId };
if (userId > 0) params.userId = userId;
const data = await callApi<{ status: boolean; participation: Participation }>('session/getobjectives.php', params);
return data.participation || null;
}
export async function getUserHistory(userId: number, quickMode = true): Promise<UserGlobalStats | null> {
const data = await callApi<{ status: boolean; stats: UserGlobalStats[] }>('stats/userhistory.php', {
userId,
sessionId: -1,
quickMode: quickMode ? 'true' : 'false',
});
return data.stats?.[0] || null;
}

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;

View File

@@ -0,0 +1,38 @@
import { createContext, useContext, useState, useCallback, type ReactNode } from 'react';
import { translations, type Lang, type TranslationKey } from './translations';
interface I18nContextType {
lang: Lang;
setLang: (lang: Lang) => void;
t: (key: TranslationKey) => string;
}
const I18nContext = createContext<I18nContextType | null>(null);
export function I18nProvider({ children }: { children: ReactNode }) {
const [lang, setLang] = useState<Lang>(() => {
const saved = localStorage.getItem('proserve-lang');
return (saved === 'en' || saved === 'fr') ? saved : 'fr';
});
const changeLang = useCallback((newLang: Lang) => {
setLang(newLang);
localStorage.setItem('proserve-lang', newLang);
}, []);
const t = useCallback((key: TranslationKey): string => {
return translations[key]?.[lang] || key;
}, [lang]);
return (
<I18nContext.Provider value={{ lang, setLang: changeLang, t }}>
{children}
</I18nContext.Provider>
);
}
export function useI18n() {
const context = useContext(I18nContext);
if (!context) throw new Error('useI18n must be used within I18nProvider');
return context;
}

View File

@@ -0,0 +1,173 @@
export type Lang = 'fr' | 'en';
export const translations = {
// Navbar
'nav.dashboard': { fr: 'Dashboard', en: 'Dashboard' },
'nav.sessions': { fr: 'Sessions', en: 'Sessions' },
'nav.users': { fr: 'Utilisateurs', en: 'Users' },
// Dashboard
'dashboard.title': { fr: 'Dashboard', en: 'Dashboard' },
'dashboard.totalSessions': { fr: 'Sessions', en: 'Sessions' },
'dashboard.totalUsers': { fr: 'Utilisateurs', en: 'Users' },
'dashboard.successRate': { fr: 'Taux de réussite', en: 'Success Rate' },
'dashboard.avgPrecision': { fr: 'Précision moy.', en: 'Avg. Precision' },
'dashboard.sessionsByType': { fr: 'Sessions par type', en: 'Sessions by Type' },
'dashboard.monthlyActivity': { fr: 'Activité mensuelle', en: 'Monthly Activity' },
'dashboard.recentSessions': { fr: 'Sessions récentes', en: 'Recent Sessions' },
'dashboard.topPrecision': { fr: 'Top Précision', en: 'Top Precision' },
// Sessions page
'sessions.title': { fr: 'Sessions', en: 'Sessions' },
'sessions.sessionType': { fr: 'Type de session', en: 'Session Type' },
'sessions.allTypes': { fr: 'Tous les types', en: 'All Types' },
'sessions.result': { fr: 'Résultat', en: 'Result' },
'sessions.all': { fr: 'Tous', en: 'All' },
'sessions.success': { fr: 'Réussi', en: 'Success' },
'sessions.failed': { fr: 'Échoué', en: 'Failed' },
'sessions.search': { fr: 'Scénario, map, nom...', en: 'Scenario, map, name...' },
'sessions.noSession': { fr: 'Aucune session trouvée', en: 'No session found' },
// Session detail
'session.backToSessions': { fr: 'Retour aux sessions', en: 'Back to Sessions' },
'session.notFound': { fr: 'Session non trouvée', en: 'Session not found' },
'session.score': { fr: 'Score', en: 'Score' },
'session.duration': { fr: 'Durée', en: 'Duration' },
'session.enemiesHit': { fr: 'Ennemis touchés', en: 'Enemies Hit' },
'session.civiliansHit': { fr: 'Civils touchés', en: 'Civilians Hit' },
'session.damageTaken': { fr: 'Dégâts reçus', en: 'Damage Taken' },
'session.participants': { fr: 'Participants', en: 'Participants' },
'session.objectives': { fr: 'Objectifs', en: 'Objectives' },
'session.hitDistribution': { fr: 'Répartition des impacts', en: 'Hit Distribution' },
'session.shotDetails': { fr: 'Détail des tirs', en: 'Shot Details' },
// Table headers
'table.date': { fr: 'Date', en: 'Date' },
'table.type': { fr: 'Type', en: 'Type' },
'table.scenario': { fr: 'Scénario', en: 'Scenario' },
'table.map': { fr: 'Map', en: 'Map' },
'table.score': { fr: 'Score', en: 'Score' },
'table.enemies': { fr: 'Ennemis', en: 'Enemies' },
'table.civilians': { fr: 'Civils', en: 'Civilians' },
'table.duration': { fr: 'Durée', en: 'Duration' },
'table.result': { fr: 'Résultat', en: 'Result' },
'table.user': { fr: 'Utilisateur', en: 'User' },
'table.shotsFired': { fr: 'Tirs effectués', en: 'Shots Fired' },
'table.shotsMissed': { fr: 'Tirs manqués', en: 'Shots Missed' },
'table.enemiesHit': { fr: 'Ennemis touchés', en: 'Enemies Hit' },
'table.civiliansHit': { fr: 'Civils touchés', en: 'Civilians Hit' },
'table.avgPrecision': { fr: 'Précision moy.', en: 'Avg. Precision' },
'table.reactionTime': { fr: 'Temps réaction', en: 'Reaction Time' },
'table.hitsReceivedIA': { fr: 'Tirs reçus (IA)', en: 'Hits from IA' },
'table.enemiesKilled': { fr: 'Ennemis tués', en: 'Enemies Killed' },
'table.civiliansKilled': { fr: 'Civils tués', en: 'Civilians Killed' },
'table.username': { fr: 'Username', en: 'Username' },
'table.name': { fr: 'Nom', en: 'Name' },
'table.avgReaction': { fr: 'Réaction moy.', en: 'Avg. Reaction' },
'table.lastConnection': { fr: 'Dernière connexion', en: 'Last Connection' },
// Shot detail table
'shot.index': { fr: '#', en: '#' },
'shot.shooter': { fr: 'Tireur', en: 'Shooter' },
'shot.impactType': { fr: 'Type impact', en: 'Impact Type' },
'shot.target': { fr: 'Cible', en: 'Target' },
'shot.boneZone': { fr: 'Os/Zone', en: 'Bone/Zone' },
'shot.precision': { fr: 'Précision', en: 'Precision' },
'shot.distance': { fr: 'Distance', en: 'Distance' },
'shot.reaction': { fr: 'Réaction', en: 'Reaction' },
'shot.killed': { fr: 'Tué', en: 'Killed' },
'shot.time': { fr: 'Temps', en: 'Time' },
// Users page
'users.title': { fr: 'Utilisateurs', en: 'Users' },
'users.search': { fr: 'Rechercher par nom, prénom ou username...', en: 'Search by name or username...' },
'users.noUser': { fr: 'Aucun utilisateur trouvé', en: 'No user found' },
// User detail
'user.backToUsers': { fr: 'Retour aux utilisateurs', en: 'Back to Users' },
'user.notFound': { fr: 'Utilisateur non trouvé', en: 'User not found' },
'user.male': { fr: 'Homme', en: 'Male' },
'user.female': { fr: 'Femme', en: 'Female' },
'user.leftHanded': { fr: 'Gaucher', en: 'Left-handed' },
'user.rightHanded': { fr: 'Droitier', en: 'Right-handed' },
'user.height': { fr: 'Taille', en: 'Height' },
'user.avatar': { fr: 'Avatar', en: 'Avatar' },
'user.weapon': { fr: 'Arme', en: 'Weapon' },
'user.sessions': { fr: 'Sessions', en: 'Sessions' },
'user.totalTime': { fr: 'Temps total', en: 'Total Time' },
'user.avgPrecision': { fr: 'Précision moy.', en: 'Avg. Precision' },
'user.avgReaction': { fr: 'Réaction moy.', en: 'Avg. Reaction' },
'user.shotsFired': { fr: 'Tirs effectués', en: 'Shots Fired' },
'user.enemiesKilled': { fr: 'Ennemis tués', en: 'Enemies Killed' },
'user.detailedStats': { fr: 'Statistiques globales détaillées', en: 'Detailed Global Statistics' },
'user.shots': { fr: 'Tirs', en: 'Shots' },
'user.hitsReceived': { fr: 'Tirs reçus', en: 'Hits Received' },
'user.eliminations': { fr: 'Éliminations', en: 'Eliminations' },
'user.precisionEvolution': { fr: 'Évolution Précision / Temps de réaction', en: 'Precision / Reaction Time Evolution' },
'user.sessionTypeDistrib': { fr: 'Répartition par type de session', en: 'Distribution by Session Type' },
'user.sessionHistory': { fr: 'Historique des sessions', en: 'Session History' },
'user.noSession': { fr: 'Aucune session', en: 'No sessions' },
// Detailed stats
'stats.shotsFired': { fr: 'Tirs effectués', en: 'Shots fired' },
'stats.shotsMissed': { fr: 'Tirs manqués', en: 'Shots missed' },
'stats.enemiesHit': { fr: 'Ennemis touchés', en: 'Enemies hit' },
'stats.civiliansHit': { fr: 'Civils touchés', en: 'Civilians hit' },
'stats.policeHit': { fr: 'Police touchée', en: 'Police hit' },
'stats.fromEnemyIA': { fr: 'Depuis IA ennemie', en: 'From enemy AI' },
'stats.fromEnemyUsers': { fr: 'Depuis joueurs ennemis', en: 'From enemy players' },
'stats.fromPolice': { fr: 'Depuis police (tir ami)', en: 'From police (friendly fire)' },
'stats.enemiesKilled': { fr: 'Ennemis tués', en: 'Enemies killed' },
'stats.civiliansKilled': { fr: 'Civils tués', en: 'Civilians killed' },
'stats.policeKilled': { fr: 'Police tuée', en: 'Police killed' },
// Objectives
'obj.civilian': { fr: 'Protection civils', en: 'Civilian Protection' },
'obj.time': { fr: 'Temps', en: 'Time' },
'obj.enemy': { fr: 'Ennemis', en: 'Enemies' },
'obj.health': { fr: 'Santé', en: 'Health' },
'obj.precision': { fr: 'Précision', en: 'Precision' },
'obj.reactTime': { fr: 'Temps de réaction', en: 'Reaction Time' },
'obj.ammoLimit': { fr: 'Munitions', en: 'Ammo' },
'obj.target': { fr: 'Cibles', en: 'Targets' },
'obj.overall': { fr: 'Global', en: 'Overall' },
// Session types
'sessionType.0': { fr: 'Stand de Tir', en: 'Fire Range' },
'sessionType.1': { fr: 'Challenge', en: 'Challenge' },
'sessionType.2': { fr: 'Protection', en: 'Protect' },
'sessionType.3': { fr: 'Désescalade', en: 'De-Escalation' },
'sessionType.4': { fr: 'Terrorisme', en: 'Terrorism' },
'sessionType.5': { fr: 'Incendie', en: 'Fire Extinction' },
'sessionType.6': { fr: 'Récupération', en: 'Recovering' },
'sessionType.7': { fr: 'Tir Longue Distance', en: 'Long Range' },
// Hit types
'hitType.enemy': { fr: 'Ennemi', en: 'Enemy' },
'hitType.civilian': { fr: 'Civil', en: 'Civilian' },
'hitType.police': { fr: 'Police', en: 'Police' },
'hitType.object': { fr: 'Objet', en: 'Object' },
'hitType.paperTarget': { fr: 'Cible Papier', en: 'Paper Target' },
'hitType.target': { fr: 'Cible', en: 'Target' },
'hitType.deadBody': { fr: 'Corps', en: 'Dead Body' },
// Badges
'badge.success': { fr: 'Réussi', en: 'Success' },
'badge.failed': { fr: 'Échoué', en: 'Failed' },
'badge.killed': { fr: 'Tué', en: 'Killed' },
// Charts
'chart.sessions': { fr: 'Sessions', en: 'Sessions' },
'chart.precision': { fr: 'Précision', en: 'Precision' },
'chart.reactionTime': { fr: 'Temps réaction (ms)', en: 'Reaction Time (ms)' },
// Print
'print.btn': { fr: 'Imprimer', en: 'Print' },
'print.generatedOn': { fr: 'Généré le', en: 'Generated on' },
// Misc
'loading': { fr: 'Chargement...', en: 'Loading...' },
'noData': { fr: 'Aucune donnée', en: 'No data' },
} as const;
export type TranslationKey = keyof typeof translations;

17
PS_Report/src/main.tsx Normal file
View File

@@ -0,0 +1,17 @@
import React from 'react';
import ReactDOM from 'react-dom/client';
import { HashRouter } from 'react-router-dom';
import { I18nProvider } from './i18n/context';
import App from './App';
import 'bootstrap/dist/css/bootstrap.min.css';
import './styles/custom.css';
ReactDOM.createRoot(document.getElementById('root')!).render(
<React.StrictMode>
<I18nProvider>
<HashRouter>
<App />
</HashRouter>
</I18nProvider>
</React.StrictMode>
);

View File

@@ -0,0 +1,163 @@
import { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import Row from 'react-bootstrap/Row';
import Col from 'react-bootstrap/Col';
import Card from 'react-bootstrap/Card';
import Table from 'react-bootstrap/Table';
import { getAllSessions, getAllUsers } from '../api/client';
import type { Session, User } from '../types';
import StatCard from '../components/StatCard';
import ScoreBadge from '../components/ScoreBadge';
import SessionTypeBadge from '../components/SessionTypeBadge';
import LoadingSpinner from '../components/LoadingSpinner';
import SessionsByTypeChart from '../components/charts/SessionsByTypeChart';
import ActivityChart from '../components/charts/ActivityChart';
import { useI18n } from '../i18n/context';
function formatDuration(seconds: number): string {
const m = Math.floor(seconds / 60);
const s = Math.floor(seconds % 60);
return `${m}m ${s}s`;
}
function formatDate(dateStr: string, lang: string): string {
if (!dateStr) return '-';
const d = new Date(dateStr);
return d.toLocaleDateString(lang === 'fr' ? 'fr-FR' : 'en-US', { day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit' });
}
function Dashboard() {
const { t, lang } = useI18n();
const navigate = useNavigate();
const [sessions, setSessions] = useState<Session[]>([]);
const [users, setUsers] = useState<User[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
Promise.all([getAllSessions(), getAllUsers()])
.then(([sessionsData, usersData]) => {
setSessions(sessionsData);
setUsers(usersData);
})
.finally(() => setLoading(false));
}, []);
if (loading) return <LoadingSpinner />;
const totalSessions = sessions.length;
const totalUsers = users.length;
const successRate = totalSessions > 0
? Math.round((sessions.filter((s) => s.success).length / totalSessions) * 100)
: 0;
const avgPrecision = users.length > 0
? Math.round(users.reduce((acc, u) => acc + u.avgPrecision, 0) / users.length * 100) / 100
: 0;
const recentSessions = sessions.slice(0, 10);
const topUsers = [...users].sort((a, b) => b.avgPrecision - a.avgPrecision).slice(0, 5);
return (
<>
<h2 className="mb-4">{t('dashboard.title')}</h2>
<Row className="mb-4 g-3">
<Col xs={6} md={3}>
<StatCard title={t('dashboard.totalSessions')} value={totalSessions} color="#4a90d9" onClick={() => navigate('/sessions')} />
</Col>
<Col xs={6} md={3}>
<StatCard title={t('dashboard.totalUsers')} value={totalUsers} color="#9b59b6" onClick={() => navigate('/users')} />
</Col>
<Col xs={6} md={3}>
<StatCard title={t('dashboard.successRate')} value={`${successRate}%`} color="#27ae60" />
</Col>
<Col xs={6} md={3}>
<StatCard title={t('dashboard.avgPrecision')} value={avgPrecision.toFixed(1)} color="#f39c12" />
</Col>
</Row>
<Row className="mb-4 g-3">
<Col md={6}>
<Card className="chart-card">
<Card.Body>
<Card.Title>{t('dashboard.sessionsByType')}</Card.Title>
<SessionsByTypeChart sessions={sessions} onSliceClick={(typeId) => navigate(`/sessions?type=${typeId}`)} />
</Card.Body>
</Card>
</Col>
<Col md={6}>
<Card className="chart-card">
<Card.Body>
<Card.Title>{t('dashboard.monthlyActivity')}</Card.Title>
<ActivityChart sessions={sessions} />
</Card.Body>
</Card>
</Col>
</Row>
<Row className="g-3">
<Col md={8}>
<Card className="data-card">
<Card.Body>
<Card.Title>{t('dashboard.recentSessions')}</Card.Title>
<Table hover responsive className="data-table mb-0">
<thead>
<tr>
<th>{t('table.date')}</th>
<th>{t('table.type')}</th>
<th>{t('table.scenario')}</th>
<th>{t('table.score')}</th>
<th>{t('table.duration')}</th>
<th>{t('table.result')}</th>
</tr>
</thead>
<tbody>
{recentSessions.map((session) => (
<tr key={session.id} className="clickable-row" onClick={() => navigate(`/sessions/${session.id}`)}>
<td>{formatDate(session.sessionDateAsString, lang)}</td>
<td><SessionTypeBadge typeId={session.sessionTypeAsInt} /></td>
<td>{session.scenarioName || '-'}</td>
<td>{session.score}</td>
<td>{formatDuration(session.timeToFinish)}</td>
<td><ScoreBadge success={session.success} /></td>
</tr>
))}
</tbody>
</Table>
</Card.Body>
</Card>
</Col>
<Col md={4}>
<Card className="data-card">
<Card.Body>
<Card.Title>{t('dashboard.topPrecision')}</Card.Title>
<Table hover className="data-table mb-0">
<thead>
<tr>
<th>#</th>
<th>{t('table.user')}</th>
<th>{t('chart.precision')}</th>
</tr>
</thead>
<tbody>
{topUsers.map((user, i) => (
<tr key={user.id} className="clickable-row" onClick={() => navigate(`/users/${user.id}`)}>
<td>{i + 1}</td>
<td>
{user.firstName && user.lastName
? `${user.firstName} ${user.lastName}`
: user.username}
</td>
<td>{user.avgPrecision.toFixed(1)}</td>
</tr>
))}
</tbody>
</Table>
</Card.Body>
</Card>
</Col>
</Row>
</>
);
}
export default Dashboard;

View File

@@ -0,0 +1,352 @@
import { useState, useEffect } from 'react';
import { useParams, Link } from 'react-router-dom';
import Row from 'react-bootstrap/Row';
import Col from 'react-bootstrap/Col';
import Card from 'react-bootstrap/Card';
import Table from 'react-bootstrap/Table';
import Badge from 'react-bootstrap/Badge';
import ProgressBar from 'react-bootstrap/ProgressBar';
import { getSession, getUsersInSession, getDebrief, getObjectives, getSessionStats } from '../api/client';
import type { Session, User, DebriefRow, Participation, ObjectiveResults, SessionDebriefRow } from '../types';
import { REACT_EVENT_TYPE_LABELS, SessionType } from '../types';
import SessionTypeBadge from '../components/SessionTypeBadge';
import ScoreBadge from '../components/ScoreBadge';
import StatCard from '../components/StatCard';
import LoadingSpinner from '../components/LoadingSpinner';
import PrintHeader from '../components/PrintHeader';
import { PieChart, Pie, Cell, Tooltip, ResponsiveContainer, Legend } from 'recharts';
import { useI18n } from '../i18n/context';
import type { TranslationKey } from '../i18n/translations';
function formatDuration(seconds: number): string {
const m = Math.floor(seconds / 60);
const s = Math.floor(seconds % 60);
return `${m}m ${s}s`;
}
function formatDate(dateStr: string, lang: string): string {
if (!dateStr) return '-';
const d = new Date(dateStr);
return d.toLocaleDateString(lang === 'fr' ? 'fr-FR' : 'en-US', { day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit' });
}
const HIT_TYPE_COLORS: Record<string, string> = {
'enemy': '#27ae60',
'civilian': '#e74c3c',
'police': '#f39c12',
'object': '#6c757d',
'target': '#4a90d9',
'paperTarget': '#2980b9',
'deadBody': '#95a5a6',
};
/** Maps French REACT_EVENT_TYPE_LABELS values to internal hit-type keys */
const FRENCH_LABEL_TO_HIT_KEY: Record<string, string> = {
'Ennemi': 'enemy',
'Civil': 'civilian',
'Police': 'police',
'Objet': 'object',
'Cible': 'target',
'Cible Papier': 'paperTarget',
'Corps': 'deadBody',
};
/** Maps internal hit-type keys to TranslationKey */
const HIT_KEY_TO_TRANSLATION: Record<string, TranslationKey> = {
'enemy': 'hitType.enemy',
'civilian': 'hitType.civilian',
'police': 'hitType.police',
'object': 'hitType.object',
'target': 'hitType.target',
'paperTarget': 'hitType.paperTarget',
'deadBody': 'hitType.deadBody',
};
function SessionDetail() {
const { id } = useParams<{ id: string }>();
const sessionId = Number(id);
const { t, lang } = useI18n();
const [session, setSession] = useState<Session | null>(null);
const [users, setUsers] = useState<User[]>([]);
const [debrief, setDebrief] = useState<DebriefRow[]>([]);
const [objectives, setObjectives] = useState<Participation | null>(null);
const [shotDetails, setShotDetails] = useState<SessionDebriefRow[]>([]);
const [loading, setLoading] = useState(true);
useEffect(() => {
if (!sessionId) return;
Promise.all([
getSession(sessionId).catch(() => null),
getUsersInSession(sessionId).catch(() => []),
getDebrief(sessionId).catch(() => []),
getObjectives(sessionId).catch(() => null),
])
.then(([sessionData, usersData, debriefData, objectivesData]) => {
setSession(sessionData as Session | null);
setUsers(usersData as User[]);
setDebrief(debriefData as DebriefRow[]);
setObjectives(objectivesData as Participation | null);
// Load shot details for firerange/challenge types
if (sessionData) {
const st = sessionData.sessionTypeAsInt;
if (st === SessionType.FireRange || st === SessionType.Challenge || st === SessionType.LongRange) {
getSessionStats(sessionId, -1, st).then(setShotDetails);
}
}
})
.finally(() => setLoading(false));
}, [sessionId]);
if (loading) return <LoadingSpinner />;
if (!session) return <p className="text-center text-muted-custom py-5">{t('session.notFound')}</p>;
// Parse objectives
let parsedObjectives: ObjectiveResults | null = null;
if (objectives?.resultsAsString) {
try {
parsedObjectives = JSON.parse(objectives.resultsAsString);
} catch {
// ignore parse errors
}
}
// Build hit distribution data from debrief
const hitDistribution: Record<string, number> = {};
debrief.forEach((row) => {
if (row.nbEnemyHitsByUser > 0) hitDistribution['enemy'] = (hitDistribution['enemy'] || 0) + row.nbEnemyHitsByUser;
if (row.nbCivilHitsByUser > 0) hitDistribution['civilian'] = (hitDistribution['civilian'] || 0) + row.nbCivilHitsByUser;
if (row.nbPoliceHitsByUser > 0) hitDistribution['police'] = (hitDistribution['police'] || 0) + row.nbPoliceHitsByUser;
if (row.nbObjectHitsByUser > 0) hitDistribution['object'] = (hitDistribution['object'] || 0) + row.nbObjectHitsByUser;
});
const hitChartData = Object.entries(hitDistribution).map(([key, value]) => ({
name: t(HIT_KEY_TO_TRANSLATION[key]),
value,
color: HIT_TYPE_COLORS[key] || '#6c757d',
}));
// Map userId to username
const userMap = new Map(users.map((u) => [u.id, u]));
return (
<>
<PrintHeader subtitle={session.sessionName || session.scenarioName || `Session #${session.id}`} />
<div className="d-flex justify-content-between align-items-center mb-3">
<Link to="/sessions" className="text-muted-custom no-print">&larr; {t('session.backToSessions')}</Link>
<button className="print-btn no-print" onClick={() => window.print()}>
{t('print.btn')}
</button>
</div>
{/* Session Header */}
<Card className="data-card mb-4">
<Card.Body>
<Row className="align-items-center">
<Col>
<h3 className="mb-1">
{session.sessionName || session.scenarioName || `Session #${session.id}`}
</h3>
<div className="d-flex gap-3 align-items-center flex-wrap">
<SessionTypeBadge typeId={session.sessionTypeAsInt} />
<span className="text-muted-custom">{formatDate(session.sessionDateAsString, lang)}</span>
<span className="text-muted-custom">Map: {session.mapName || '-'}</span>
<span className="text-muted-custom">Scenario: {session.scenarioName || '-'}</span>
</div>
</Col>
<Col xs="auto">
<ScoreBadge success={session.success} score={session.score} />
</Col>
</Row>
</Card.Body>
</Card>
{/* KPI Cards */}
<Row className="mb-4 g-3">
<Col xs={6} md={2}>
<StatCard title={t('session.score')} value={session.score} color="#4a90d9" />
</Col>
<Col xs={6} md={2}>
<StatCard title={t('session.duration')} value={formatDuration(session.timeToFinish)} color="#9b59b6" />
</Col>
<Col xs={6} md={2}>
<StatCard title={t('session.enemiesHit')} value={session.nbEnemyHit} color="#27ae60" />
</Col>
<Col xs={6} md={2}>
<StatCard title={t('session.civiliansHit')} value={session.nbCivilsHit} color={session.nbCivilsHit > 0 ? '#e74c3c' : '#27ae60'} />
</Col>
<Col xs={6} md={2}>
<StatCard title={t('session.damageTaken')} value={Math.round(session.damageTaken)} color="#f39c12" />
</Col>
<Col xs={6} md={2}>
<StatCard title={t('session.participants')} value={users.length} color="#1abc9c" />
</Col>
</Row>
{/* Participants */}
<Card className="data-card mb-4">
<Card.Body>
<Card.Title>{t('session.participants')}</Card.Title>
<Table hover responsive className="data-table mb-0">
<thead>
<tr>
<th>{t('table.user')}</th>
<th>{t('table.shotsFired')}</th>
<th>{t('table.shotsMissed')}</th>
<th>{t('table.enemiesHit')}</th>
<th>{t('table.civiliansHit')}</th>
<th>{t('table.avgPrecision')}</th>
<th>{t('table.reactionTime')}</th>
<th>{t('table.hitsReceivedIA')}</th>
<th>{t('table.enemiesKilled')}</th>
<th>{t('table.civiliansKilled')}</th>
</tr>
</thead>
<tbody>
{debrief.map((row, i) => {
const user = userMap.get(row.userId);
return (
<tr key={i}>
<td>
<Link to={`/users/${row.userId}`} className="table-link">
{user ? (user.firstName && user.lastName ? `${user.firstName} ${user.lastName}` : user.username) : `User #${row.userId}`}
</Link>
</td>
<td>{row.nbFiredShotsByUser}</td>
<td>{row.nbMissedShotsByUser}</td>
<td>{row.nbEnemyHitsByUser}</td>
<td className={row.nbCivilHitsByUser > 0 ? 'text-danger' : ''}>{row.nbCivilHitsByUser}</td>
<td>{row.averagePrecision != null ? `${(row.averagePrecision * 100).toFixed(1)}%` : '-'}</td>
<td>{row.averageReactionTime != null && row.averageReactionTime > 0 ? `${row.averageReactionTime.toFixed(0)} ms` : '-'}</td>
<td>{row.nbReceivedHitsFromEnemyIA ?? 0}</td>
<td>{row.totalEnemyKilled ?? 0}</td>
<td className={(row.totalCivilKilled ?? 0) > 0 ? 'text-danger' : ''}>{row.totalCivilKilled ?? 0}</td>
</tr>
);
})}
{debrief.length === 0 && (
<tr><td colSpan={10} className="text-center text-muted-custom">{t('noData')}</td></tr>
)}
</tbody>
</Table>
</Card.Body>
</Card>
<Row className="mb-4 g-3">
{/* Objectives */}
{parsedObjectives && (
<Col md={6}>
<Card className="data-card h-100">
<Card.Body>
<Card.Title>{t('session.objectives')}</Card.Title>
{Object.entries(parsedObjectives).map(([key, obj]) => {
if (!obj || typeof obj !== 'object') return null;
const pct = obj.maxScore > 0 ? (obj.score / obj.maxScore) * 100 : 0;
return (
<div key={key} className="mb-3">
<div className="d-flex justify-content-between mb-1">
<span>{t(`obj.${key}` as TranslationKey)}</span>
<span>
{obj.score}/{obj.maxScore}
{obj.success !== undefined && (
<Badge bg={obj.success ? 'success' : 'danger'} className="ms-2">
{obj.success ? 'OK' : 'X'}
</Badge>
)}
</span>
</div>
<ProgressBar
now={pct}
variant={obj.success ? 'success' : 'danger'}
className="objective-bar"
/>
</div>
);
})}
</Card.Body>
</Card>
</Col>
)}
{/* Hit Distribution Chart */}
{hitChartData.length > 0 && (
<Col md={parsedObjectives ? 6 : 12}>
<Card className="chart-card h-100">
<Card.Body>
<Card.Title>{t('session.hitDistribution')}</Card.Title>
<ResponsiveContainer width="100%" height={250}>
<PieChart>
<Pie data={hitChartData} dataKey="value" nameKey="name" cx="50%" cy="50%" outerRadius={80} label>
{hitChartData.map((entry, index) => (
<Cell key={index} fill={entry.color} />
))}
</Pie>
<Tooltip contentStyle={{ backgroundColor: '#1a1a2e', border: '1px solid #333' }} />
<Legend />
</PieChart>
</ResponsiveContainer>
</Card.Body>
</Card>
</Col>
)}
</Row>
{/* Shot Details for FireRange/Challenge */}
{shotDetails.length > 0 && (
<Card className="data-card mb-4">
<Card.Body>
<Card.Title>{t('session.shotDetails')}</Card.Title>
<Table hover responsive className="data-table mb-0" size="sm">
<thead>
<tr>
<th>{t('shot.index')}</th>
<th>{t('shot.shooter')}</th>
<th>{t('shot.impactType')}</th>
<th>{t('shot.target')}</th>
<th>{t('shot.boneZone')}</th>
<th>{t('shot.precision')}</th>
<th>{t('shot.distance')}</th>
<th>{t('shot.reaction')}</th>
<th>{t('shot.killed')}</th>
<th>{t('shot.time')}</th>
</tr>
</thead>
<tbody>
{shotDetails.map((shot, i) => {
const frenchLabel = REACT_EVENT_TYPE_LABELS[shot.reactTypeId] || '';
const hitKey = FRENCH_LABEL_TO_HIT_KEY[frenchLabel] || '';
const translatedLabel = hitKey && HIT_KEY_TO_TRANSLATION[hitKey]
? t(HIT_KEY_TO_TRANSLATION[hitKey])
: shot.reactType || '-';
const badgeColor = HIT_TYPE_COLORS[hitKey] || '#6c757d';
return (
<tr key={i}>
<td>{shot.shotIndex}</td>
<td>{shot.shooterName || `#${shot.shooterId}`}</td>
<td>
<Badge bg="secondary" style={{ backgroundColor: badgeColor }}>
{translatedLabel}
</Badge>
</td>
<td>{shot.targetName || shot.targetUserName || '-'}</td>
<td>{shot.targetBoneName || shot.hitLocationTag || '-'}</td>
<td>{shot.hitPrecision != null ? `${(shot.hitPrecision * 100).toFixed(1)}%` : '-'}</td>
<td>{shot.hitTargetDistance != null && shot.hitTargetDistance > 0 ? `${shot.hitTargetDistance.toFixed(1)}m` : '-'}</td>
<td>{shot.reactionTime != null && shot.reactionTime > 0 ? `${shot.reactionTime.toFixed(0)}ms` : '-'}</td>
<td>{shot.targetKilled ? <Badge bg="danger">{t('badge.killed')}</Badge> : '-'}</td>
<td>{shot.timeStamp != null ? `${shot.timeStamp.toFixed(1)}s` : '-'}</td>
</tr>
);
})}
</tbody>
</Table>
</Card.Body>
</Card>
)}
</>
);
}
export default SessionDetail;

View File

@@ -0,0 +1,189 @@
import { useState, useEffect, useMemo } from 'react';
import { useNavigate, useSearchParams } from 'react-router-dom';
import Row from 'react-bootstrap/Row';
import Col from 'react-bootstrap/Col';
import Card from 'react-bootstrap/Card';
import Table from 'react-bootstrap/Table';
import Form from 'react-bootstrap/Form';
import Pagination from 'react-bootstrap/Pagination';
import { getAllSessions } from '../api/client';
import type { Session } from '../types';
import SessionTypeBadge from '../components/SessionTypeBadge';
import ScoreBadge from '../components/ScoreBadge';
import LoadingSpinner from '../components/LoadingSpinner';
import PrintHeader from '../components/PrintHeader';
import { useI18n } from '../i18n/context';
import type { TranslationKey } from '../i18n/translations';
const PAGE_SIZE = 20;
type SortKey = 'sessionDateAsString' | 'score' | 'timeToFinish' | 'scenarioName' | 'sessionTypeAsInt';
type SortDir = 'asc' | 'desc';
function formatDuration(seconds: number): string {
const m = Math.floor(seconds / 60);
const s = Math.floor(seconds % 60);
return `${m}m ${s}s`;
}
function formatDate(dateStr: string, lang: string): string {
if (!dateStr) return '-';
const d = new Date(dateStr);
return d.toLocaleDateString(lang === 'fr' ? 'fr-FR' : 'en-US', { day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit' });
}
function Sessions() {
const { t, lang } = useI18n();
const navigate = useNavigate();
const [searchParams, setSearchParams] = useSearchParams();
const [sessions, setSessions] = useState<Session[]>([]);
const [loading, setLoading] = useState(true);
const initialType = searchParams.get('type');
const [typeFilter, setTypeFilter] = useState<number>(initialType !== null ? Number(initialType) : -1);
const [successFilter, setSuccessFilter] = useState<string>('all');
const [searchQuery, setSearchQuery] = useState('');
const [sortKey, setSortKey] = useState<SortKey>('sessionDateAsString');
const [sortDir, setSortDir] = useState<SortDir>('desc');
const [currentPage, setCurrentPage] = useState(1);
useEffect(() => {
getAllSessions().then(setSessions).finally(() => setLoading(false));
}, []);
const filtered = useMemo(() => {
let result = sessions;
if (typeFilter >= 0) result = result.filter((s) => s.sessionTypeAsInt === typeFilter);
if (successFilter === 'success') result = result.filter((s) => s.success);
else if (successFilter === 'failed') result = result.filter((s) => !s.success);
if (searchQuery.trim()) {
const q = searchQuery.toLowerCase();
result = result.filter((s) => s.scenarioName.toLowerCase().includes(q) || s.mapName.toLowerCase().includes(q) || s.sessionName.toLowerCase().includes(q));
}
return [...result].sort((a, b) => {
const aVal = a[sortKey], bVal = b[sortKey];
if (typeof aVal === 'string' && typeof bVal === 'string') return sortDir === 'asc' ? aVal.localeCompare(bVal) : bVal.localeCompare(aVal);
return sortDir === 'asc' ? Number(aVal) - Number(bVal) : Number(bVal) - Number(aVal);
});
}, [sessions, typeFilter, successFilter, searchQuery, sortKey, sortDir]);
const totalPages = Math.ceil(filtered.length / PAGE_SIZE);
const paginated = filtered.slice((currentPage - 1) * PAGE_SIZE, currentPage * PAGE_SIZE);
function handleSort(key: SortKey) {
if (sortKey === key) setSortDir(sortDir === 'asc' ? 'desc' : 'asc');
else { setSortKey(key); setSortDir('desc'); }
}
function sortIndicator(key: SortKey) {
if (sortKey !== key) return '';
return sortDir === 'asc' ? ' ▲' : ' ▼';
}
if (loading) return <LoadingSpinner />;
const sessionTypes = [...new Set(sessions.map((s) => s.sessionTypeAsInt))].sort();
return (
<>
<PrintHeader subtitle={t('sessions.title')} />
<div className="d-flex justify-content-between align-items-center mb-4">
<h2 className="mb-0">{t('sessions.title')} ({filtered.length})</h2>
<button className="print-btn no-print" onClick={() => window.print()}>
{t('print.btn')}
</button>
</div>
<Card className="data-card mb-4 no-print">
<Card.Body>
<Row className="g-3">
<Col md={3}>
<Form.Group>
<Form.Label className="text-muted-custom">{t('sessions.sessionType')}</Form.Label>
<Form.Select value={typeFilter} onChange={(e) => { const val = Number(e.target.value); setTypeFilter(val); setCurrentPage(1); if (val >= 0) setSearchParams({ type: String(val) }); else setSearchParams({}); }} className="filter-select">
<option value={-1}>{t('sessions.allTypes')}</option>
{sessionTypes.map((st) => (
<option key={st} value={st}>{t(`sessionType.${st}` as TranslationKey)}</option>
))}
</Form.Select>
</Form.Group>
</Col>
<Col md={3}>
<Form.Group>
<Form.Label className="text-muted-custom">{t('sessions.result')}</Form.Label>
<Form.Select value={successFilter} onChange={(e) => { setSuccessFilter(e.target.value); setCurrentPage(1); }} className="filter-select">
<option value="all">{t('sessions.all')}</option>
<option value="success">{t('sessions.success')}</option>
<option value="failed">{t('sessions.failed')}</option>
</Form.Select>
</Form.Group>
</Col>
<Col md={6}>
<Form.Group>
<Form.Label className="text-muted-custom">{t('sessions.search')}</Form.Label>
<Form.Control type="text" placeholder={t('sessions.search')} value={searchQuery} onChange={(e) => { setSearchQuery(e.target.value); setCurrentPage(1); }} className="filter-input" />
</Form.Group>
</Col>
</Row>
</Card.Body>
</Card>
<Card className="data-card">
<Card.Body className="p-0">
<Table hover responsive className="data-table mb-0">
<thead>
<tr>
<th className="sortable" onClick={() => handleSort('sessionDateAsString')}>{t('table.date')}{sortIndicator('sessionDateAsString')}</th>
<th className="sortable" onClick={() => handleSort('sessionTypeAsInt')}>{t('table.type')}{sortIndicator('sessionTypeAsInt')}</th>
<th className="sortable" onClick={() => handleSort('scenarioName')}>{t('table.scenario')}{sortIndicator('scenarioName')}</th>
<th>{t('table.map')}</th>
<th className="sortable" onClick={() => handleSort('score')}>{t('table.score')}{sortIndicator('score')}</th>
<th>{t('table.enemies')}</th>
<th>{t('table.civilians')}</th>
<th className="sortable" onClick={() => handleSort('timeToFinish')}>{t('table.duration')}{sortIndicator('timeToFinish')}</th>
<th>{t('table.result')}</th>
</tr>
</thead>
<tbody>
{paginated.map((session) => (
<tr key={session.id} className="clickable-row" onClick={() => navigate(`/sessions/${session.id}`)}>
<td>{formatDate(session.sessionDateAsString, lang)}</td>
<td><SessionTypeBadge typeId={session.sessionTypeAsInt} /></td>
<td>{session.scenarioName || '-'}</td>
<td>{session.mapName || '-'}</td>
<td className="fw-bold">{session.score}</td>
<td>{session.nbEnemyHit}</td>
<td className={session.nbCivilsHit > 0 ? 'text-danger' : ''}>{session.nbCivilsHit}</td>
<td>{formatDuration(session.timeToFinish)}</td>
<td><ScoreBadge success={session.success} /></td>
</tr>
))}
{paginated.length === 0 && (
<tr><td colSpan={9} className="text-center text-muted-custom py-4">{t('sessions.noSession')}</td></tr>
)}
</tbody>
</Table>
</Card.Body>
</Card>
{totalPages > 1 && (
<div className="d-flex justify-content-center mt-3">
<Pagination className="custom-pagination">
<Pagination.First onClick={() => setCurrentPage(1)} disabled={currentPage === 1} />
<Pagination.Prev onClick={() => setCurrentPage(currentPage - 1)} disabled={currentPage === 1} />
{Array.from({ length: Math.min(totalPages, 5) }, (_, i) => {
const startPage = Math.max(1, Math.min(currentPage - 2, totalPages - 4));
const page = startPage + i;
if (page > totalPages) return null;
return <Pagination.Item key={page} active={page === currentPage} onClick={() => setCurrentPage(page)}>{page}</Pagination.Item>;
})}
<Pagination.Next onClick={() => setCurrentPage(currentPage + 1)} disabled={currentPage === totalPages} />
<Pagination.Last onClick={() => setCurrentPage(totalPages)} disabled={currentPage === totalPages} />
</Pagination>
</div>
)}
</>
);
}
export default Sessions;

View File

@@ -0,0 +1,246 @@
import { useState, useEffect } from 'react';
import { useParams, Link } from 'react-router-dom';
import Row from 'react-bootstrap/Row';
import Col from 'react-bootstrap/Col';
import Card from 'react-bootstrap/Card';
import Table from 'react-bootstrap/Table';
import { getUser, getSessionsForUser, getUserHistory } from '../api/client';
import type { User, Session, UserGlobalStats } from '../types';
import StatCard from '../components/StatCard';
import SessionTypeBadge from '../components/SessionTypeBadge';
import ScoreBadge from '../components/ScoreBadge';
import LoadingSpinner from '../components/LoadingSpinner';
import PrintHeader from '../components/PrintHeader';
import PrecisionChart from '../components/charts/PrecisionChart';
import { useI18n } from '../i18n/context';
function formatDuration(seconds: number): string {
if (seconds < 60) return `${Math.floor(seconds)}s`;
if (seconds < 3600) return `${Math.floor(seconds / 60)}m ${Math.floor(seconds % 60)}s`;
const h = Math.floor(seconds / 3600);
const m = Math.floor((seconds % 3600) / 60);
return `${h}h ${m}m`;
}
function formatDate(dateStr: string, lang: string): string {
if (!dateStr) return '-';
const d = new Date(dateStr);
const locale = lang === 'fr' ? 'fr-FR' : 'en-US';
return d.toLocaleDateString(locale, { day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit' });
}
function UserDetail() {
const { id } = useParams<{ id: string }>();
const userId = Number(id);
const { t, lang } = useI18n();
const [user, setUser] = useState<User | null>(null);
const [sessions, setSessions] = useState<Session[]>([]);
const [history, setHistory] = useState<UserGlobalStats | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
if (!userId) return;
Promise.all([
getUser(userId).catch(() => null),
getSessionsForUser(userId).catch(() => []),
getUserHistory(userId, true).catch(() => null),
])
.then(([userData, sessionsData, historyData]) => {
setUser(userData);
setSessions(sessionsData as Session[]);
setHistory(historyData);
})
.finally(() => setLoading(false));
}, [userId]);
if (loading) return <LoadingSpinner />;
if (!user) return <p className="text-center text-muted-custom py-5">{t('user.notFound')}</p>;
const fullName = user.firstName && user.lastName
? `${user.firstName} ${user.lastName}`
: user.username;
const totals = history?.totals;
const sessionTypeDistrib: Record<number, number> = {};
sessions.forEach((s) => {
sessionTypeDistrib[s.sessionTypeAsInt] = (sessionTypeDistrib[s.sessionTypeAsInt] || 0) + 1;
});
return (
<>
<PrintHeader subtitle={fullName} />
<div className="d-flex justify-content-between align-items-center mb-3">
<Link to="/users" className="text-muted-custom no-print">&larr; {t('user.backToUsers')}</Link>
<button className="print-btn no-print" onClick={() => window.print()}>
{t('print.btn')}
</button>
</div>
{/* User Header */}
<Card className="data-card mb-4">
<Card.Body>
<Row>
<Col>
<h3 className="mb-1">{fullName}</h3>
<div className="d-flex gap-3 flex-wrap text-muted-custom">
<span>@{user.username}</span>
<span>{user.maleGender ? t('user.male') : t('user.female')}</span>
<span>{user.leftHanded ? t('user.leftHanded') : t('user.rightHanded')}</span>
{user.size > 0 && <span>{t('user.height')}: {user.size} cm</span>}
{user.charSkinAssetName && <span>{t('user.avatar')}: {user.charSkinAssetName}</span>}
{user.weaponAssetName && <span>{t('user.weapon')}: {user.weaponAssetName}</span>}
</div>
</Col>
</Row>
</Card.Body>
</Card>
{/* Stats Cards */}
<Row className="mb-4 g-3">
<Col xs={6} md={2}>
<StatCard title={t('user.sessions')} value={history?.nbSessions || sessions.length} color="#4a90d9" />
</Col>
<Col xs={6} md={2}>
<StatCard
title={t('user.totalTime')}
value={formatDuration(history?.totalDuration || 0)}
color="#9b59b6"
/>
</Col>
<Col xs={6} md={2}>
<StatCard title={t('user.avgPrecision')} value={user.avgPrecision != null ? user.avgPrecision.toFixed(1) : '-'} color="#27ae60" />
</Col>
<Col xs={6} md={2}>
<StatCard
title={t('user.avgReaction')}
value={user.avgReaction != null && user.avgReaction > 0 ? `${user.avgReaction.toFixed(0)}ms` : '-'}
color="#f39c12"
/>
</Col>
<Col xs={6} md={2}>
<StatCard title={t('user.shotsFired')} value={totals?.nbFiredShotsByUser || 0} color="#1abc9c" />
</Col>
<Col xs={6} md={2}>
<StatCard
title={t('user.enemiesKilled')}
value={totals?.totalEnemyKilled || 0}
color="#e74c3c"
/>
</Col>
</Row>
{/* Totals detail */}
{totals && (
<Card className="data-card mb-4">
<Card.Body>
<Card.Title>{t('user.detailedStats')}</Card.Title>
<Row className="g-3">
<Col md={4}>
<h6 className="text-muted-custom">{t('user.shots')}</h6>
<Table size="sm" className="data-table mb-0">
<tbody>
<tr><td>{t('stats.shotsFired')}</td><td className="fw-bold">{totals.nbFiredShotsByUser}</td></tr>
<tr><td>{t('stats.shotsMissed')}</td><td>{totals.nbMissedShotsByUser}</td></tr>
<tr><td>{t('stats.enemiesHit')}</td><td className="text-success">{totals.nbEnemyHitsByUser}</td></tr>
<tr><td>{t('stats.civiliansHit')}</td><td className={totals.nbCivilHitsByUser > 0 ? 'text-danger' : ''}>{totals.nbCivilHitsByUser}</td></tr>
<tr><td>{t('stats.policeHit')}</td><td className={totals.nbPoliceHitsByUser > 0 ? 'text-warning' : ''}>{totals.nbPoliceHitsByUser}</td></tr>
</tbody>
</Table>
</Col>
<Col md={4}>
<h6 className="text-muted-custom">{t('user.hitsReceived')}</h6>
<Table size="sm" className="data-table mb-0">
<tbody>
<tr><td>{t('stats.fromEnemyIA')}</td><td>{totals.nbReceivedHitsFromEnemyIA}</td></tr>
<tr><td>{t('stats.fromEnemyUsers')}</td><td>{totals.nbReceivedHitsFromEnemyUser}</td></tr>
<tr><td>{t('stats.fromPolice')}</td><td className={totals.nbReceivedHitsFromPoliceUser > 0 ? 'text-warning' : ''}>{totals.nbReceivedHitsFromPoliceUser}</td></tr>
</tbody>
</Table>
</Col>
<Col md={4}>
<h6 className="text-muted-custom">{t('user.eliminations')}</h6>
<Table size="sm" className="data-table mb-0">
<tbody>
<tr><td>{t('stats.enemiesKilled')}</td><td className="text-success">{totals.totalEnemyKilled}</td></tr>
<tr><td>{t('stats.civiliansKilled')}</td><td className={totals.totalCivilKilled > 0 ? 'text-danger fw-bold' : ''}>{totals.totalCivilKilled}</td></tr>
<tr><td>{t('stats.policeKilled')}</td><td className={totals.totalPoliceKilled > 0 ? 'text-warning' : ''}>{totals.totalPoliceKilled}</td></tr>
</tbody>
</Table>
</Col>
</Row>
</Card.Body>
</Card>
)}
{/* Precision Evolution Chart */}
{history?.sessionDebriefRows && history.sessionDebriefRows.length > 1 && (
<Card className="chart-card mb-4">
<Card.Body>
<Card.Title>{t('user.precisionEvolution')}</Card.Title>
<PrecisionChart debriefRows={history.sessionDebriefRows} />
</Card.Body>
</Card>
)}
{/* Session Type Distribution */}
{Object.keys(sessionTypeDistrib).length > 0 && (
<Card className="data-card mb-4">
<Card.Body>
<Card.Title>{t('user.sessionTypeDistrib')}</Card.Title>
<div className="d-flex gap-3 flex-wrap">
{Object.entries(sessionTypeDistrib).map(([typeId, count]) => (
<div key={typeId} className="text-center">
<SessionTypeBadge typeId={Number(typeId)} />
<div className="fw-bold mt-1">{count}</div>
</div>
))}
</div>
</Card.Body>
</Card>
)}
{/* Sessions History */}
<Card className="data-card">
<Card.Body>
<Card.Title>{t('user.sessionHistory')} ({sessions.length})</Card.Title>
<Table hover responsive className="data-table mb-0">
<thead>
<tr>
<th>{t('table.date')}</th>
<th>{t('table.type')}</th>
<th>{t('table.scenario')}</th>
<th>{t('table.score')}</th>
<th>{t('table.duration')}</th>
<th>{t('table.result')}</th>
</tr>
</thead>
<tbody>
{sessions.map((session) => (
<tr key={session.id}>
<td>
<Link to={`/sessions/${session.id}`} className="table-link">
{formatDate(session.sessionDateAsString, lang)}
</Link>
</td>
<td><SessionTypeBadge typeId={session.sessionTypeAsInt} /></td>
<td>{session.scenarioName || '-'}</td>
<td className="fw-bold">{session.score}</td>
<td>{formatDuration(session.timeToFinish)}</td>
<td><ScoreBadge success={session.success} /></td>
</tr>
))}
{sessions.length === 0 && (
<tr><td colSpan={6} className="text-center text-muted-custom">{t('user.noSession')}</td></tr>
)}
</tbody>
</Table>
</Card.Body>
</Card>
</>
);
}
export default UserDetail;

View File

@@ -0,0 +1,148 @@
import { useState, useEffect, useMemo } from 'react';
import { useNavigate } from 'react-router-dom';
import Card from 'react-bootstrap/Card';
import Table from 'react-bootstrap/Table';
import Form from 'react-bootstrap/Form';
import { getAllUsers } from '../api/client';
import type { User } from '../types';
import LoadingSpinner from '../components/LoadingSpinner';
import PrintHeader from '../components/PrintHeader';
import { useI18n } from '../i18n/context';
type SortKey = 'username' | 'firstName' | 'avgPrecision' | 'avgReaction' | 'lastConnection';
type SortDir = 'asc' | 'desc';
function formatDate(dateStr: string, lang: string): string {
if (!dateStr) return '-';
const d = new Date(dateStr);
const locale = lang === 'fr' ? 'fr-FR' : 'en-US';
return d.toLocaleDateString(locale, { day: '2-digit', month: '2-digit', year: 'numeric', hour: '2-digit', minute: '2-digit' });
}
function Users() {
const { t, lang } = useI18n();
const navigate = useNavigate();
const [users, setUsers] = useState<User[]>([]);
const [loading, setLoading] = useState(true);
const [searchQuery, setSearchQuery] = useState('');
const [sortKey, setSortKey] = useState<SortKey>('avgPrecision');
const [sortDir, setSortDir] = useState<SortDir>('desc');
useEffect(() => {
getAllUsers().then(setUsers).finally(() => setLoading(false));
}, []);
const filtered = useMemo(() => {
let result = users;
if (searchQuery.trim()) {
const q = searchQuery.toLowerCase();
result = result.filter(
(u) =>
u.username.toLowerCase().includes(q) ||
u.firstName.toLowerCase().includes(q) ||
u.lastName.toLowerCase().includes(q)
);
}
return [...result].sort((a, b) => {
const aVal = a[sortKey];
const bVal = b[sortKey];
if (typeof aVal === 'string' && typeof bVal === 'string') {
return sortDir === 'asc' ? aVal.localeCompare(bVal) : bVal.localeCompare(aVal);
}
return sortDir === 'asc' ? Number(aVal) - Number(bVal) : Number(bVal) - Number(aVal);
});
}, [users, searchQuery, sortKey, sortDir]);
function handleSort(key: SortKey) {
if (sortKey === key) {
setSortDir(sortDir === 'asc' ? 'desc' : 'asc');
} else {
setSortKey(key);
setSortDir('desc');
}
}
function sortIndicator(key: SortKey) {
if (sortKey !== key) return '';
return sortDir === 'asc' ? ' ▲' : ' ▼';
}
if (loading) return <LoadingSpinner />;
return (
<>
<PrintHeader subtitle={t('users.title')} />
<div className="d-flex justify-content-between align-items-center mb-4">
<h2 className="mb-0">{t('users.title')} ({filtered.length})</h2>
<button className="print-btn no-print" onClick={() => window.print()}>
{t('print.btn')}
</button>
</div>
<Card className="data-card mb-4 no-print">
<Card.Body>
<Form.Group>
<Form.Control
type="text"
placeholder={t('users.search')}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="filter-input"
/>
</Form.Group>
</Card.Body>
</Card>
<Card className="data-card">
<Card.Body className="p-0">
<Table hover responsive className="data-table mb-0">
<thead>
<tr>
<th className="sortable" onClick={() => handleSort('username')}>
{t('table.username')}{sortIndicator('username')}
</th>
<th className="sortable" onClick={() => handleSort('firstName')}>
{t('table.name')}{sortIndicator('firstName')}
</th>
<th className="sortable" onClick={() => handleSort('avgPrecision')}>
{t('table.avgPrecision')}{sortIndicator('avgPrecision')}
</th>
<th className="sortable" onClick={() => handleSort('avgReaction')}>
{t('table.avgReaction')}{sortIndicator('avgReaction')}
</th>
<th className="sortable" onClick={() => handleSort('lastConnection')}>
{t('table.lastConnection')}{sortIndicator('lastConnection')}
</th>
</tr>
</thead>
<tbody>
{filtered.map((user) => (
<tr key={user.id} className="clickable-row" onClick={() => navigate(`/users/${user.id}`)}>
<td>{user.username}</td>
<td>
{user.firstName || user.lastName
? `${user.firstName} ${user.lastName}`.trim()
: '-'}
</td>
<td className="fw-bold">{user.avgPrecision.toFixed(1)}</td>
<td>{user.avgReaction > 0 ? `${user.avgReaction.toFixed(0)} ms` : '-'}</td>
<td>{formatDate(user.lastConnection, lang)}</td>
</tr>
))}
{filtered.length === 0 && (
<tr>
<td colSpan={5} className="text-center text-muted-custom py-4">{t('users.noUser')}</td>
</tr>
)}
</tbody>
</Table>
</Card.Body>
</Card>
</>
);
}
export default Users;

View File

@@ -0,0 +1,651 @@
/* ========== FONT ========== */
@font-face {
font-family: 'Pirulen';
src: url('/fonts/pirulen.otf') format('opentype');
font-weight: normal;
font-style: normal;
font-display: swap;
}
/* ========== PROSERVE DARK THEME ========== */
:root {
--bg-primary: #0f0f1a;
--bg-secondary: #1a1a2e;
--bg-tertiary: #16213e;
--bg-card: #1a1a2e;
--bg-hover: #252545;
--text-primary: #e0e0e0;
--text-secondary: #a0a0b0;
--text-muted: #6c6c80;
--accent-blue: #4a90d9;
--accent-orange: #f39c12;
--accent-green: #27ae60;
--accent-red: #e74c3c;
--border-color: #2a2a40;
}
/* ========== GLOBAL ========== */
body {
background-color: var(--bg-primary) !important;
color: var(--text-primary) !important;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
}
body::before {
content: '';
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-image: url('/ProserveReport/Background.png');
background-size: cover;
background-position: center;
background-repeat: no-repeat;
opacity: 0.1;
pointer-events: none;
z-index: 0;
}
a {
color: var(--accent-blue);
text-decoration: none;
}
a:hover {
color: #6bb3f0;
}
/* ========== NAVBAR ========== */
.app-navbar {
background-color: var(--bg-secondary) !important;
border-bottom: 1px solid var(--border-color);
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.3);
}
.app-navbar .navbar-brand {
font-size: 1.3rem;
letter-spacing: 1px;
}
.brand-logo {
height: 44px;
width: auto;
}
.brand-proserve {
font-family: 'Pirulen', sans-serif;
color: #ffffff !important;
font-size: 1.25rem;
letter-spacing: 2px;
}
.brand-report {
color: var(--text-secondary);
font-size: 0.85rem;
font-weight: 400;
letter-spacing: 1px;
}
.text-primary-accent {
color: var(--accent-blue) !important;
}
.app-navbar .nav-link {
color: var(--text-secondary) !important;
font-weight: 500;
padding: 0.5rem 1rem !important;
border-radius: 6px;
margin: 0 2px;
transition: all 0.2s;
}
.app-navbar .nav-link:hover,
.app-navbar .nav-link.active {
color: var(--text-primary) !important;
background-color: var(--bg-hover);
}
/* ========== MAIN CONTENT ========== */
.main-content {
min-height: calc(100vh - 56px);
}
h2, h3 {
color: var(--text-primary);
}
/* ========== CARDS ========== */
.stat-card,
.data-card,
.chart-card {
background-color: var(--bg-card) !important;
border: 1px solid var(--border-color) !important;
border-radius: 10px !important;
color: var(--text-primary);
}
.stat-card .card-body {
padding: 1.2rem;
}
.stat-value {
font-size: 2rem;
font-weight: 700;
margin: 0.3rem 0;
}
.card-title {
color: var(--text-primary) !important;
font-weight: 600;
margin-bottom: 1rem;
}
.card-subtitle {
font-size: 0.8rem;
text-transform: uppercase;
letter-spacing: 0.5px;
}
/* ========== TABLES ========== */
.data-table {
color: var(--text-primary) !important;
--bs-table-bg: transparent;
--bs-table-color: var(--text-primary);
--bs-table-hover-bg: var(--bg-hover);
--bs-table-hover-color: var(--text-primary);
--bs-table-striped-bg: rgba(255, 255, 255, 0.02);
--bs-table-striped-color: var(--text-primary);
}
.data-table thead th {
background-color: var(--bg-tertiary) !important;
color: var(--accent-blue) !important;
border-bottom: 2px solid var(--border-color) !important;
font-size: 0.85rem;
text-transform: uppercase;
letter-spacing: 0.3px;
padding: 0.75rem;
white-space: nowrap;
}
.data-table tbody td {
color: var(--text-primary) !important;
border-color: var(--border-color) !important;
padding: 0.65rem 0.75rem;
vertical-align: middle;
}
.data-table tbody tr:hover {
background-color: var(--bg-hover) !important;
}
.data-table tbody tr:hover td {
color: #ffffff !important;
}
.clickable-row {
cursor: pointer;
}
.clickable-row:hover td {
color: #ffffff !important;
}
.sortable {
cursor: pointer;
user-select: none;
}
.sortable:hover {
color: var(--accent-blue) !important;
}
.table-link {
color: var(--accent-blue) !important;
font-weight: 500;
}
.table-link:hover {
color: #6bb3f0 !important;
text-decoration: underline;
}
/* ========== BOOTSTRAP DARK OVERRIDES ========== */
.table {
color: var(--text-primary) !important;
}
.card {
color: var(--text-primary) !important;
}
.form-control,
.form-select {
color: var(--text-primary) !important;
}
.form-label {
color: var(--text-secondary) !important;
}
/* ========== MUTED TEXT ========== */
.text-muted-custom {
color: var(--text-muted) !important;
}
/* ========== FILTERS ========== */
.filter-select,
.filter-input {
background-color: var(--bg-tertiary) !important;
border-color: var(--border-color) !important;
color: var(--text-primary) !important;
}
.filter-select:focus,
.filter-input:focus {
border-color: var(--accent-blue) !important;
box-shadow: 0 0 0 0.2rem rgba(74, 144, 217, 0.25) !important;
}
.filter-select option {
background-color: var(--bg-secondary);
color: var(--text-primary);
}
/* ========== BADGES ========== */
.session-type-badge {
font-size: 0.75rem;
font-weight: 600;
padding: 0.35rem 0.65rem;
border-radius: 4px;
}
/* ========== PAGINATION ========== */
.custom-pagination .page-item .page-link {
background-color: var(--bg-secondary);
border-color: var(--border-color);
color: var(--text-secondary);
}
.custom-pagination .page-item .page-link:hover {
background-color: var(--bg-hover);
color: var(--text-primary);
}
.custom-pagination .page-item.active .page-link {
background-color: var(--accent-blue);
border-color: var(--accent-blue);
color: white;
}
.custom-pagination .page-item.disabled .page-link {
background-color: var(--bg-primary);
border-color: var(--border-color);
color: var(--text-muted);
}
/* ========== LANGUAGE SWITCHER ========== */
.lang-switcher {
display: flex;
gap: 2px;
background-color: var(--bg-tertiary);
border-radius: 6px;
padding: 2px;
}
.lang-btn {
background: transparent;
border: none;
color: var(--text-muted);
font-size: 0.8rem;
font-weight: 600;
padding: 4px 10px;
border-radius: 4px;
cursor: pointer;
transition: all 0.2s;
}
.lang-btn:hover {
color: var(--text-primary);
}
.lang-btn.active {
background-color: var(--accent-blue);
color: white;
}
/* ========== PROGRESS BARS ========== */
.objective-bar {
height: 8px;
background-color: var(--bg-tertiary);
border-radius: 4px;
}
/* ========== SPINNER ========== */
.spinner-border {
color: var(--accent-blue) !important;
}
/* ========== RECHARTS OVERRIDES ========== */
.recharts-text {
fill: var(--text-secondary) !important;
}
.recharts-legend-item-text {
color: var(--text-secondary) !important;
}
.recharts-cartesian-grid line {
stroke: var(--border-color) !important;
}
.recharts-tooltip-wrapper .recharts-default-tooltip {
background-color: var(--bg-secondary) !important;
border: 1px solid var(--border-color) !important;
border-radius: 6px !important;
color: var(--text-primary) !important;
}
.recharts-tooltip-wrapper .recharts-default-tooltip .recharts-tooltip-label {
color: var(--text-primary) !important;
}
.recharts-tooltip-wrapper .recharts-default-tooltip .recharts-tooltip-item {
color: var(--text-primary) !important;
}
/* ========== SCROLLBAR ========== */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: var(--bg-primary);
}
::-webkit-scrollbar-thumb {
background: var(--border-color);
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: var(--text-muted);
}
/* ========== PRINT BUTTON ========== */
.print-btn {
background: transparent;
border: 1px solid var(--border-color);
color: var(--text-secondary);
padding: 6px 14px;
border-radius: 6px;
cursor: pointer;
font-size: 0.85rem;
transition: all 0.2s;
display: inline-flex;
align-items: center;
gap: 6px;
}
.print-btn:hover {
color: var(--text-primary);
border-color: var(--accent-blue);
background-color: var(--bg-hover);
}
/* ========== PRINT HEADER (hidden on screen) ========== */
.print-header {
display: none;
}
/* ========== RESPONSIVE ========== */
@media (max-width: 768px) {
.stat-value {
font-size: 1.4rem;
}
.main-content {
padding: 1rem !important;
}
}
/* ========== PRINT STYLES ========== */
@media print {
/* Show print header */
.print-header {
display: flex !important;
align-items: center;
gap: 12px;
margin-bottom: 16px;
padding-bottom: 12px;
border-bottom: 2px solid #333;
}
.print-header img {
height: 36px;
filter: brightness(0);
}
.print-header .print-title {
font-family: 'Pirulen', sans-serif;
font-size: 1.1rem;
letter-spacing: 2px;
color: #000 !important;
}
.print-header .print-subtitle {
font-size: 0.8rem;
color: #666 !important;
margin-left: auto;
}
/* Light theme overrides */
* {
color-adjust: exact !important;
-webkit-print-color-adjust: exact !important;
print-color-adjust: exact !important;
}
body {
background: #ffffff !important;
color: #000000 !important;
font-size: 11px !important;
}
/* Hide non-printable elements */
.app-navbar,
.print-btn,
.lang-switcher,
.filter-select,
.filter-input,
.filter-label,
.custom-pagination,
.no-print {
display: none !important;
}
.main-content {
padding: 0 !important;
min-height: auto !important;
}
/* Page titles */
h2, h3 {
color: #000 !important;
font-size: 1.2rem !important;
margin-bottom: 8px !important;
}
/* Cards - remove dark backgrounds */
.card,
.stat-card,
.data-card,
.chart-card {
background: #ffffff !important;
border: 1px solid #ccc !important;
color: #000 !important;
box-shadow: none !important;
break-inside: avoid;
margin-bottom: 10px !important;
}
.card-title {
color: #000 !important;
font-size: 0.95rem !important;
}
.card-subtitle {
color: #555 !important;
}
/* Stat cards compact */
.stat-card .card-body {
padding: 8px !important;
}
.stat-value {
font-size: 1.3rem !important;
color: #000 !important;
}
/* Tables - light theme */
.table,
.data-table {
color: #000 !important;
--bs-table-bg: #ffffff;
--bs-table-color: #000;
--bs-table-hover-bg: transparent;
--bs-table-hover-color: #000;
font-size: 10px !important;
}
.data-table thead th {
background-color: #f0f0f0 !important;
color: #333 !important;
border-bottom: 2px solid #999 !important;
font-size: 9px !important;
padding: 4px 6px !important;
}
.data-table tbody td {
color: #000 !important;
border-color: #ddd !important;
padding: 3px 6px !important;
}
.data-table tbody tr:hover,
.data-table tbody tr:hover td {
background: transparent !important;
color: #000 !important;
}
.table-link {
color: #000 !important;
text-decoration: none !important;
}
/* Badges - grayscale print friendly */
.badge {
border: 1px solid #999 !important;
font-size: 9px !important;
padding: 2px 5px !important;
}
.badge.bg-success {
background-color: #ffffff !important;
color: #000 !important;
border-color: #333 !important;
}
.badge.bg-danger {
background-color: #e0e0e0 !important;
color: #000 !important;
border-color: #666 !important;
}
.session-type-badge {
background-color: #e8e8e8 !important;
color: #000 !important;
border: 1px solid #999 !important;
}
/* Text colors for print */
.text-muted-custom {
color: #666 !important;
}
.text-danger {
color: #000 !important;
font-weight: bold !important;
}
.fw-bold {
font-weight: 700 !important;
}
/* Progress bars */
.progress,
.objective-bar {
background-color: #e0e0e0 !important;
height: 6px !important;
}
.progress-bar {
background-color: #555 !important;
}
/* Charts - let them print as-is but ensure visibility */
.recharts-text {
fill: #333 !important;
}
.recharts-legend-item-text {
color: #333 !important;
}
.recharts-cartesian-grid line {
stroke: #ddd !important;
}
/* Page breaks */
.page-break-before {
break-before: page;
}
.card {
break-inside: avoid;
}
tr {
break-inside: avoid;
}
/* Links as plain text */
a, a:visited {
color: #000 !important;
text-decoration: none !important;
}
}

View File

@@ -0,0 +1,227 @@
// Session types enum
export enum SessionType {
FireRange = 0,
Challenge = 1,
Protect = 2,
DeEscalation = 3,
Terrorism = 4,
FireExtinction = 5,
Recovering = 6,
LongRange = 7,
}
export const SESSION_TYPE_LABELS: Record<number, string> = {
[SessionType.FireRange]: 'Stand de Tir',
[SessionType.Challenge]: 'Challenge',
[SessionType.Protect]: 'Protection',
[SessionType.DeEscalation]: 'Désescalade',
[SessionType.Terrorism]: 'Terrorisme',
[SessionType.FireExtinction]: 'Incendie',
[SessionType.Recovering]: 'Récupération',
[SessionType.LongRange]: 'Tir Longue Distance',
};
export const SESSION_TYPE_COLORS: Record<number, string> = {
[SessionType.FireRange]: '#4a90d9',
[SessionType.Challenge]: '#9b59b6',
[SessionType.Protect]: '#27ae60',
[SessionType.DeEscalation]: '#f39c12',
[SessionType.Terrorism]: '#e74c3c',
[SessionType.FireExtinction]: '#e67e22',
[SessionType.Recovering]: '#1abc9c',
[SessionType.LongRange]: '#2980b9',
};
// React event types
export enum ReactEventType {
EnemyHit = 0,
CivilianHit = 1,
PoliceHit = 2,
ObjectHit = 3,
PaperTargetHit = 4,
TargetHit = 5,
DeadBodyHit = 6,
}
export const REACT_EVENT_TYPE_LABELS: Record<number, string> = {
[ReactEventType.EnemyHit]: 'Ennemi',
[ReactEventType.CivilianHit]: 'Civil',
[ReactEventType.PoliceHit]: 'Police',
[ReactEventType.ObjectHit]: 'Objet',
[ReactEventType.PaperTargetHit]: 'Cible Papier',
[ReactEventType.TargetHit]: 'Cible',
[ReactEventType.DeadBodyHit]: 'Corps',
};
// User roles
export enum UserRole {
Police = 0,
Enemy = 1,
Civil = 2,
IA = 3,
}
export const USER_ROLE_LABELS: Record<number, string> = {
[UserRole.Police]: 'Police',
[UserRole.Enemy]: 'Ennemi',
[UserRole.Civil]: 'Civil',
[UserRole.IA]: 'IA',
};
// User status
export enum UserStatus {
Alive = 0,
LightlyInjured = 1,
SeriouslyInjured = 2,
Dead = 3,
}
export const USER_STATUS_LABELS: Record<number, string> = {
[UserStatus.Alive]: 'Vivant',
[UserStatus.LightlyInjured]: 'Blessé léger',
[UserStatus.SeriouslyInjured]: 'Blessé grave',
[UserStatus.Dead]: 'Mort',
};
export const USER_STATUS_COLORS: Record<number, string> = {
[UserStatus.Alive]: '#27ae60',
[UserStatus.LightlyInjured]: '#f39c12',
[UserStatus.SeriouslyInjured]: '#e67e22',
[UserStatus.Dead]: '#e74c3c',
};
// Data interfaces matching API responses
export interface Session {
id: number;
sessionTypeAsInt: number;
sessionName: string;
sessionDateAsString: string;
mapName: string;
scenarioName: string;
success: boolean;
timeToFinish: number;
score: number;
nbEnemyHit: number;
nbCivilsHit: number;
damageTaken: number;
replayFileName: string;
}
export interface User {
id: number;
username: string;
firstName: string;
lastName: string;
leftHanded: boolean;
maleGender: boolean;
charSkinAssetName: string;
weaponAssetName: string;
lastConnection: string;
avgPrecision: number;
avgReaction: number;
avgFault: number;
avgRapidity: number;
size: number;
}
export interface Participation {
userId: number;
sessionId: number;
score: number;
firePrecision: number;
reactionTime: number;
nbEnemyHit: number;
nbCivilsHit: number;
damageTaken: number;
endStatus: number;
avatar: string;
weapon: string;
roleId: number;
resultsAsString: string;
}
export interface DebriefRow {
userId: number;
sessionId: number;
nbFiredShotsByUser: number;
nbMissedShotsByUser: number;
nbEnemyHitsByUser: number;
nbCivilHitsByUser: number;
nbPoliceHitsByUser: number;
nbObjectHitsByUser: number;
nbDeadBodyHitsByUser: number;
nbReceivedHitsFromEnemyIA: number;
nbReceivedHitsFromEnemyUser: number;
nbReceivedHitsFromPoliceUser: number;
totalEnemyKilled: number;
totalCivilKilled: number;
totalPoliceKilled: number;
averagePrecision: number;
averageReactionTime: number;
}
export interface SessionDebriefRow {
sessionId: number;
sessionTypeId: number;
sessionType: string;
sessionName: string;
sessionDate: string;
mapName: string;
scenarioName: string;
sessionSuccessful: number;
sessionDuration: number;
shooterId: number;
shooterName: string;
shooterRoleId: number;
shooterRole: string;
shotIndex: number;
reactId: number;
reactModeId: number;
reactMode: string;
reactTypeId: number;
reactType: string;
targetUserId: number;
targetUserName: string;
targetRoleId: number;
targetRole: string;
targetName: string;
targetBoneName: string;
targetKilled: number;
hitLocationX: number;
hitLocationY: number;
hitLocationTag: string;
hitPrecision: number;
hitTargetDistance: number;
reactionTime: number;
timeStamp: number;
nbHit: number;
nbKilled: number;
}
export interface UserGlobalStats {
totals: DebriefRow;
nbSessions: number;
totalDuration: number;
firstSession: string;
lastSession: string;
sessionDebriefRows: SessionDebriefRow[];
}
export interface ObjectiveResults {
civilian?: { score: number; maxScore: number; success: boolean };
time?: { score: number; maxScore: number; success: boolean };
enemy?: { score: number; maxScore: number; success: boolean };
health?: { score: number; maxScore: number; success: boolean };
precision?: { score: number; maxScore: number; success: boolean };
reactTime?: { score: number; maxScore: number; success: boolean };
ammoLimit?: { score: number; maxScore: number; success: boolean };
target?: { score: number; maxScore: number; success: boolean };
overall?: { score: number; maxScore: number; success: boolean };
}
// API response wrapper
export interface ApiResponse<T> {
status: boolean;
message: string;
[key: string]: T | boolean | string;
}

1
PS_Report/src/vite-env.d.ts vendored Normal file
View File

@@ -0,0 +1 @@
/// <reference types="vite/client" />