V0 : initial import
This commit is contained in:
23
PS_Report/src/App.tsx
Normal file
23
PS_Report/src/App.tsx
Normal 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;
|
||||
91
PS_Report/src/api/client.ts
Normal file
91
PS_Report/src/api/client.ts
Normal 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;
|
||||
}
|
||||
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;
|
||||
38
PS_Report/src/i18n/context.tsx
Normal file
38
PS_Report/src/i18n/context.tsx
Normal 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;
|
||||
}
|
||||
173
PS_Report/src/i18n/translations.ts
Normal file
173
PS_Report/src/i18n/translations.ts
Normal 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
17
PS_Report/src/main.tsx
Normal 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>
|
||||
);
|
||||
163
PS_Report/src/pages/Dashboard.tsx
Normal file
163
PS_Report/src/pages/Dashboard.tsx
Normal 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;
|
||||
352
PS_Report/src/pages/SessionDetail.tsx
Normal file
352
PS_Report/src/pages/SessionDetail.tsx
Normal 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">← {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;
|
||||
189
PS_Report/src/pages/Sessions.tsx
Normal file
189
PS_Report/src/pages/Sessions.tsx
Normal 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;
|
||||
246
PS_Report/src/pages/UserDetail.tsx
Normal file
246
PS_Report/src/pages/UserDetail.tsx
Normal 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">← {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;
|
||||
148
PS_Report/src/pages/Users.tsx
Normal file
148
PS_Report/src/pages/Users.tsx
Normal 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;
|
||||
651
PS_Report/src/styles/custom.css
Normal file
651
PS_Report/src/styles/custom.css
Normal 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;
|
||||
}
|
||||
}
|
||||
227
PS_Report/src/types/index.ts
Normal file
227
PS_Report/src/types/index.ts
Normal 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
1
PS_Report/src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
Reference in New Issue
Block a user