Compare commits
2 Commits
bfc63e4847
...
97b980a05a
| Author | SHA1 | Date | |
|---|---|---|---|
| 97b980a05a | |||
| 2b9d532627 |
@ -18,7 +18,25 @@
|
||||
"Bash(curl -s -X POST \"http://localhost/proserve/lists/all_sessions.php\" -d \"\")",
|
||||
"Bash(curl -s -X POST \"http://localhost/proserve/user/get.php\" -d \"userId=3\")",
|
||||
"Bash(curl -s -X POST \"http://localhost/proserve/stats/userhistory.php\" -d \"userId=3&quickMode=true\")",
|
||||
"Bash(npx vite build)"
|
||||
"Bash(npx vite build)",
|
||||
"Bash(find C:ASTERIONGITPS_ProserveReport -type f \\\\\\(-name *.js -o -name *.jsx -o -name *.ts -o -name *.tsx \\\\\\) ! -path */node_modules/*)",
|
||||
"Bash(find C:ASTERIONGITPS_ProserveReportPS_Report -type f -name *.jsx -o -name *.js -o -name *.tsx)",
|
||||
"Bash(python -c \"import sys,json; d=json.load\\(sys.stdin\\); print\\(''''Status:'''', d.get\\(''''status''''\\)\\); stats=d.get\\(''''stats'''',[]\\); print\\(''''Nombre de sessions retournées:'''', len\\(stats\\)\\); [print\\(f'''' ID={s[\"\"id\"\"]} | {s[\"\"scenarioName\"\"]} | type={s[\"\"sessionType\"\"]} | durée={s[\"\"timeToFinish\"\"]}s''''\\) for s in stats]\")",
|
||||
"Bash(mysql -u root ProserveAPI -e \"SELECT COUNT\\(*\\) as total FROM sessions; SELECT COUNT\\(*\\) as calibration FROM sessions WHERE ScenarioName LIKE ''%Calibration%''; SELECT COUNT\\(*\\) as courtes FROM sessions WHERE timeToFinish <= 10 AND timeToFinish != 0 AND ScenarioName NOT LIKE ''%Calibration%''; SELECT id, ScenarioName, timeToFinish FROM sessions ORDER BY id DESC;\")",
|
||||
"Bash(C:/xampp/mysql/bin/mysql.exe -u root ProserveAPI -e \"SELECT COUNT\\(*\\) as total FROM sessions; SELECT COUNT\\(*\\) as calibration FROM sessions WHERE ScenarioName LIKE ''%Calibration%''; SELECT COUNT\\(*\\) as courtes FROM sessions WHERE timeToFinish <= 10 AND timeToFinish != 0 AND ScenarioName NOT LIKE ''%Calibration%''; SELECT id, ScenarioName, timeToFinish FROM sessions ORDER BY id DESC;\")",
|
||||
"Bash(C:/xampp/mysql/bin/mysql.exe -u root ProserveAPI -e \"SELECT COUNT\\(*\\) as courtes FROM sessions WHERE timeToFinish <= 10 AND timeToFinish != 0 AND ScenarioName NOT LIKE ''%Calibration%''\")",
|
||||
"Bash(C:/xampp/mysql/bin/mysql.exe -u root ProserveAPI -e \"SELECT id, ScenarioName, timeToFinish FROM sessions ORDER BY id DESC\")",
|
||||
"Bash(C:/xampp/mysql/bin/mysql.exe -u root ProserveAPI -e \"SELECT SD.id, SD.ScenarioName, SD.timeToFinish FROM sessions SD WHERE SD.ScenarioName NOT LIKE ''%Calibration%'' AND \\(SD.timeToFinish > 10 OR SD.timeToFinish = 0\\) AND SD.id NOT IN \\(SELECT sessionId FROM participates\\) ORDER BY SD.id DESC\")",
|
||||
"Bash(curl -s -X POST http://localhost/proserve/lists/all_sessions.php -H \"Content-Type: application/x-www-form-urlencoded\" -d \"typeId=-1\")",
|
||||
"Bash(python -c \"import sys,json; d=json.load\\(sys.stdin\\); sessions=d.get\\(''''stats'''',[]\\); s=sessions[0] if sessions else {}; print\\(json.dumps\\(s, indent=2\\)\\)\")",
|
||||
"Bash(curl -s -X POST http://localhost/proserve/stats/get.php -H \"Content-Type: application/x-www-form-urlencoded\" -d \"sessionId=70&sessionType=0\")",
|
||||
"Bash(python -c \"import sys,json; d=json.load\\(sys.stdin\\); print\\(json.dumps\\(d, indent=2\\)\\)\")",
|
||||
"Bash(curl -s -X POST \"http://localhost/proserve/lists/all_sessions.php\" -d \"typeId=-1\")",
|
||||
"Bash(python -c \"import sys,json; data=json.load\\(sys.stdin\\); print\\(json.dumps\\(data[''''stats''''][0], indent=2\\)\\)\")",
|
||||
"Bash(curl -s -X POST http://localhost/proserve/lists/all_sessions.php -d typeId=-1)",
|
||||
"Bash(curl -s -X POST \"http://localhost/proserve/stats/get.php\" -d \"sessionId=73&userId=-1\")",
|
||||
"Bash(git add:*)",
|
||||
"Bash(git commit:*)"
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
BIN
FireRange.jpg
Normal file
BIN
FireRange.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 98 KiB |
BIN
Nouveau dossier/other.png
Normal file
BIN
Nouveau dossier/other.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 308 KiB |
BIN
PS_Report/dist/HumanTarget.PNG
vendored
Normal file
BIN
PS_Report/dist/HumanTarget.PNG
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 460 KiB |
File diff suppressed because one or more lines are too long
143
PS_Report/dist/assets/index-CSeKVt42.js
vendored
143
PS_Report/dist/assets/index-CSeKVt42.js
vendored
File diff suppressed because one or more lines are too long
143
PS_Report/dist/assets/index-Hi0hQkPy.js
vendored
Normal file
143
PS_Report/dist/assets/index-Hi0hQkPy.js
vendored
Normal file
File diff suppressed because one or more lines are too long
4
PS_Report/dist/index.html
vendored
4
PS_Report/dist/index.html
vendored
@ -4,8 +4,8 @@
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Proserve Report</title>
|
||||
<script type="module" crossorigin src="/ProserveReport/assets/index-CSeKVt42.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/ProserveReport/assets/index-DLRhV40F.css">
|
||||
<script type="module" crossorigin src="/ProserveReport/assets/index-Hi0hQkPy.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/ProserveReport/assets/index-BJVnQUvh.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
|
||||
BIN
PS_Report/dist/longRangeTarget.PNG
vendored
Normal file
BIN
PS_Report/dist/longRangeTarget.PNG
vendored
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.7 MiB |
BIN
PS_Report/public/HumanTarget.PNG
Normal file
BIN
PS_Report/public/HumanTarget.PNG
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 460 KiB |
BIN
PS_Report/public/longRangeTarget.PNG
Normal file
BIN
PS_Report/public/longRangeTarget.PNG
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 2.7 MiB |
@ -9,7 +9,7 @@ interface ScoreBadgeProps {
|
||||
function ScoreBadge({ success, score }: ScoreBadgeProps) {
|
||||
const { t } = useI18n();
|
||||
return (
|
||||
<Badge bg={success ? 'success' : 'danger'}>
|
||||
<Badge bg={success ? 'success' : 'danger'} style={{ fontSize: '1rem', padding: '0.5rem 1rem' }}>
|
||||
{score !== undefined && <span className="me-1">{score}</span>}
|
||||
{success ? t('badge.success') : t('badge.failed')}
|
||||
</Badge>
|
||||
|
||||
80
PS_Report/src/components/TargetVisualization.tsx
Normal file
80
PS_Report/src/components/TargetVisualization.tsx
Normal file
@ -0,0 +1,80 @@
|
||||
interface Shot {
|
||||
index: number;
|
||||
x: number; // 0-1 normalized
|
||||
y: number; // 0-1 normalized
|
||||
precision: number;
|
||||
}
|
||||
|
||||
interface TargetVisualizationProps {
|
||||
shots: Shot[];
|
||||
variant?: 'human' | 'longRange';
|
||||
}
|
||||
|
||||
const TARGETS = {
|
||||
human: { image: 'HumanTarget.png', ratio: 0.7183098591549296, maxW: 320 },
|
||||
longRange: { image: 'longRangeTarget.PNG', ratio: 1, maxW: 480 },
|
||||
};
|
||||
|
||||
function TargetVisualization({ shots, variant = 'human' }: TargetVisualizationProps) {
|
||||
const { image, ratio, maxW } = TARGETS[variant];
|
||||
const VB_H = 100;
|
||||
const VB_W = VB_H * ratio;
|
||||
|
||||
return (
|
||||
<div style={{
|
||||
width: '100%',
|
||||
maxWidth: maxW,
|
||||
margin: '0 auto',
|
||||
aspectRatio: `${ratio}`,
|
||||
}}>
|
||||
<svg
|
||||
viewBox={`0 0 ${VB_W} ${VB_H}`}
|
||||
width="100%"
|
||||
height="100%"
|
||||
style={{ display: 'block' }}
|
||||
>
|
||||
{/* Background target image — fills the entire viewBox */}
|
||||
<image
|
||||
href={`${import.meta.env.BASE_URL}${image}`}
|
||||
x="0"
|
||||
y="0"
|
||||
width={VB_W}
|
||||
height={VB_H}
|
||||
preserveAspectRatio="none"
|
||||
/>
|
||||
{/* Shot markers */}
|
||||
{shots.map((shot) => {
|
||||
const cx = shot.x * VB_W;
|
||||
const cy = shot.y * VB_H;
|
||||
return (
|
||||
<g key={shot.index}>
|
||||
<circle
|
||||
cx={cx}
|
||||
cy={cy}
|
||||
r={1.2}
|
||||
fill="#00e5ff"
|
||||
stroke="#000"
|
||||
strokeWidth={0.3}
|
||||
opacity={0.9}
|
||||
/>
|
||||
<text
|
||||
x={cx + 2}
|
||||
y={cy + 1}
|
||||
fill="#00e5ff"
|
||||
fontSize={2.8}
|
||||
fontWeight="bold"
|
||||
stroke="#000"
|
||||
strokeWidth={0.15}
|
||||
paintOrder="stroke"
|
||||
>
|
||||
{shot.index}
|
||||
</text>
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default TargetVisualization;
|
||||
60
PS_Report/src/hooks/useComputedSuccess.ts
Normal file
60
PS_Report/src/hooks/useComputedSuccess.ts
Normal file
@ -0,0 +1,60 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { getDebrief } from '../api/client';
|
||||
import type { Session, DebriefRow } from '../types';
|
||||
|
||||
/**
|
||||
* Computes success/failure for a list of sessions based on debrief data.
|
||||
* A session is failed if any: civilKilled > 0, policeKilled > 0, or hitsReceived > 0.
|
||||
* Only fetches debriefs for the given sessions (use with paginated/visible sessions).
|
||||
* Returns a Map<sessionId, computedSuccess>.
|
||||
*/
|
||||
export function useComputedSuccess(sessions: Session[]): Map<number, boolean> {
|
||||
const [successMap, setSuccessMap] = useState<Map<number, boolean>>(new Map());
|
||||
const fetchedRef = useRef<Set<number>>(new Set());
|
||||
|
||||
useEffect(() => {
|
||||
if (sessions.length === 0) return;
|
||||
|
||||
// Only fetch sessions we haven't already fetched
|
||||
const toFetch = sessions.filter(s => !fetchedRef.current.has(s.id));
|
||||
if (toFetch.length === 0) return;
|
||||
|
||||
Promise.all(
|
||||
toFetch.map(async (session) => {
|
||||
try {
|
||||
const debrief = await getDebrief(session.id);
|
||||
return { id: session.id, success: computeSuccess(session.success, debrief) };
|
||||
} catch {
|
||||
return { id: session.id, success: session.success };
|
||||
}
|
||||
}),
|
||||
).then((results) => {
|
||||
setSuccessMap(prev => {
|
||||
const next = new Map(prev);
|
||||
for (const r of results) {
|
||||
next.set(r.id, r.success);
|
||||
fetchedRef.current.add(r.id);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
});
|
||||
}, [sessions]);
|
||||
|
||||
return successMap;
|
||||
}
|
||||
|
||||
/** Compute success from debrief rows */
|
||||
export function computeSuccess(originalSuccess: boolean, debrief: DebriefRow[]): boolean {
|
||||
const totalCivilKilled = debrief.reduce((s, r) => s + (Number(r.totalCivilKilled) || 0), 0);
|
||||
const totalPoliceKilled = debrief.reduce((s, r) => s + (Number(r.totalPoliceKilled) || 0), 0);
|
||||
const totalHitsReceived = debrief.reduce(
|
||||
(s, r) => s + (Number(r.nbReceivedHitsFromEnemyIA) || 0) + (Number(r.nbReceivedHitsFromEnemyUser) || 0) + (Number(r.nbReceivedHitsFromPoliceUser) || 0),
|
||||
0,
|
||||
);
|
||||
return originalSuccess && totalCivilKilled === 0 && totalPoliceKilled === 0 && totalHitsReceived === 0;
|
||||
}
|
||||
|
||||
/** Helper: get computed success for a single session from the map, fallback to session.success */
|
||||
export function getComputedSuccess(successMap: Map<number, boolean>, session: Session): boolean {
|
||||
return successMap.has(session.id) ? successMap.get(session.id)! : session.success;
|
||||
}
|
||||
@ -40,6 +40,13 @@ export const translations = {
|
||||
'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' },
|
||||
'session.global': { fr: 'Global', en: 'Global' },
|
||||
'session.personalStats': { fr: 'Statistiques personnelles', en: 'Personal Statistics' },
|
||||
'session.globalStats': { fr: 'Statistiques globales', en: 'Global Statistics' },
|
||||
'session.friendlyFire': { fr: 'Tirs amis', en: 'Friendly Fire' },
|
||||
'session.hitsReceived': { fr: 'Tirs reçus', en: 'Hits Received' },
|
||||
'session.shotsInSession': { fr: 'Tirs durant la session', en: 'Shots In Session' },
|
||||
'session.missed': { fr: 'Manqués', en: 'Missed' },
|
||||
|
||||
// Table headers
|
||||
'table.date': { fr: 'Date', en: 'Date' },
|
||||
@ -156,6 +163,17 @@ export const translations = {
|
||||
'badge.failed': { fr: 'Échoué', en: 'Failed' },
|
||||
'badge.killed': { fr: 'Tué', en: 'Killed' },
|
||||
|
||||
// FireRange detail
|
||||
'firerange.personalStats': { fr: 'Statistiques personnelles', en: 'Personal Statistics' },
|
||||
'firerange.shotsFired': { fr: 'Tirs effectués', en: 'Shots Fired' },
|
||||
'firerange.shotsMissed': { fr: 'Tirs manqués', en: 'Missed Shots' },
|
||||
'firerange.avgPrecision': { fr: 'Précision moyenne', en: 'Average Precision' },
|
||||
'firerange.precisionChart': { fr: 'Précision des tirs durant la session', en: 'Shots Precision During Session' },
|
||||
'firerange.targetView': { fr: 'Impacts sur la cible', en: 'Target Hits' },
|
||||
'firerange.targetsHit': { fr: 'Cibles touchées', en: 'Targets Hit' },
|
||||
'firerange.avgReaction': { fr: 'Réaction moyenne', en: 'Avg. Reaction Time' },
|
||||
'firerange.reactionChart': { fr: 'Temps de réaction durant la session', en: 'Reaction Time During Session' },
|
||||
|
||||
// Charts
|
||||
'chart.sessions': { fr: 'Sessions', en: 'Sessions' },
|
||||
'chart.precision': { fr: 'Précision', en: 'Precision' },
|
||||
|
||||
@ -6,6 +6,7 @@ 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 { useComputedSuccess, getComputedSuccess } from '../hooks/useComputedSuccess';
|
||||
import StatCard from '../components/StatCard';
|
||||
import ScoreBadge from '../components/ScoreBadge';
|
||||
import SessionTypeBadge from '../components/SessionTypeBadge';
|
||||
@ -42,12 +43,14 @@ function Dashboard() {
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
const successMap = useComputedSuccess(sessions);
|
||||
|
||||
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)
|
||||
? Math.round((sessions.filter((s) => getComputedSuccess(successMap, s)).length / totalSessions) * 100)
|
||||
: 0;
|
||||
const avgPrecision = users.length > 0
|
||||
? Math.round(users.reduce((acc, u) => acc + u.avgPrecision, 0) / users.length * 100) / 100
|
||||
@ -118,7 +121,7 @@ function Dashboard() {
|
||||
<td>{session.scenarioName || '-'}</td>
|
||||
<td>{session.score}</td>
|
||||
<td>{formatDuration(session.timeToFinish)}</td>
|
||||
<td><ScoreBadge success={session.success} /></td>
|
||||
<td><ScoreBadge success={getComputedSuccess(successMap, session)} /></td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
|
||||
@ -3,20 +3,22 @@ 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 Form from 'react-bootstrap/Form';
|
||||
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 { 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 { Tooltip, ResponsiveContainer, Legend, LineChart, Line, XAxis, YAxis, CartesianGrid, BarChart, Bar } from 'recharts';
|
||||
import { useI18n } from '../i18n/context';
|
||||
import type { TranslationKey } from '../i18n/translations';
|
||||
import TargetVisualization from '../components/TargetVisualization';
|
||||
import { computeSuccess } from '../hooks/useComputedSuccess';
|
||||
|
||||
function formatDuration(seconds: number): string {
|
||||
const m = Math.floor(seconds / 60);
|
||||
@ -30,37 +32,6 @@ function formatDate(dateStr: string, lang: string): string {
|
||||
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 }>();
|
||||
@ -73,6 +44,7 @@ function SessionDetail() {
|
||||
const [objectives, setObjectives] = useState<Participation | null>(null);
|
||||
const [shotDetails, setShotDetails] = useState<SessionDebriefRow[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [selectedUserId, setSelectedUserId] = useState<number>(-1);
|
||||
|
||||
useEffect(() => {
|
||||
if (!sessionId) return;
|
||||
@ -85,16 +57,15 @@ function SessionDetail() {
|
||||
])
|
||||
.then(([sessionData, usersData, debriefData, objectivesData]) => {
|
||||
setSession(sessionData as Session | null);
|
||||
setUsers(usersData as User[]);
|
||||
const uList = usersData as User[];
|
||||
setUsers(uList);
|
||||
if (uList.length === 1) setSelectedUserId(uList[0].id);
|
||||
setDebrief(debriefData as DebriefRow[]);
|
||||
setObjectives(objectivesData as Participation | null);
|
||||
|
||||
// Load shot details for firerange/challenge types
|
||||
// Load shot details for all session types
|
||||
if (sessionData) {
|
||||
const st = sessionData.sessionTypeAsInt;
|
||||
if (st === SessionType.FireRange || st === SessionType.Challenge || st === SessionType.LongRange) {
|
||||
getSessionStats(sessionId, -1, st).then(setShotDetails);
|
||||
}
|
||||
getSessionStats(sessionId, -1, sessionData.sessionTypeAsInt).then(setShotDetails);
|
||||
}
|
||||
})
|
||||
.finally(() => setLoading(false));
|
||||
@ -113,23 +84,16 @@ function SessionDetail() {
|
||||
}
|
||||
}
|
||||
|
||||
// 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]));
|
||||
|
||||
const isFireRange = session.sessionTypeAsInt === SessionType.FireRange
|
||||
|| session.sessionTypeAsInt === SessionType.Challenge
|
||||
|| session.sessionTypeAsInt === SessionType.LongRange;
|
||||
const isChallenge = session.sessionTypeAsInt === SessionType.Challenge;
|
||||
|
||||
const computedSuccess = computeSuccess(session.success, debrief);
|
||||
|
||||
return (
|
||||
<>
|
||||
<PrintHeader subtitle={session.sessionName || session.scenarioName || `Session #${session.id}`} />
|
||||
@ -144,101 +108,315 @@ function SessionDetail() {
|
||||
{/* Session Header */}
|
||||
<Card className="data-card mb-4">
|
||||
<Card.Body>
|
||||
<Row className="align-items-center">
|
||||
<Row className="align-items-start">
|
||||
<Col>
|
||||
<h3 className="mb-1">
|
||||
<h3 className="mb-2">
|
||||
{session.sessionName || session.scenarioName || `Session #${session.id}`}
|
||||
</h3>
|
||||
<div className="d-flex gap-3 align-items-center flex-wrap">
|
||||
<div className="d-flex align-items-center gap-3">
|
||||
<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>
|
||||
<div className="text-muted-custom">{formatDate(session.sessionDateAsString, lang)} Map: {session.mapName || '-'}</div>
|
||||
<div className="text-muted-custom">Scenario: {session.scenarioName || '-'}</div>
|
||||
</div>
|
||||
</div>
|
||||
</Col>
|
||||
<Col xs="auto">
|
||||
<ScoreBadge success={session.success} score={session.score} />
|
||||
{users.length > 0 && (
|
||||
<Col xs="auto" style={{ minWidth: 200 }}>
|
||||
<h3 className="mb-2">{t('session.participants')}</h3>
|
||||
<Form.Select
|
||||
size="sm"
|
||||
value={selectedUserId}
|
||||
onChange={e => setSelectedUserId(Number(e.target.value))}
|
||||
style={{ backgroundColor: '#1a1a2e', color: '#e0e0e0', borderColor: '#333' }}
|
||||
>
|
||||
<option value={-1}>{t('session.global')}</option>
|
||||
{users.map(u => (
|
||||
<option key={u.id} value={u.id}>
|
||||
{u.firstName && u.lastName ? `${u.firstName} ${u.lastName}` : u.username}
|
||||
</option>
|
||||
))}
|
||||
</Form.Select>
|
||||
</Col>
|
||||
)}
|
||||
<Col className="flex-grow-1" />
|
||||
<Col xs="auto" className="align-self-center">
|
||||
<ScoreBadge success={computedSuccess} />
|
||||
</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>
|
||||
{/* Standard sessions: stats left + bar chart right */}
|
||||
{!isFireRange && (() => {
|
||||
// Compute filtered debrief stats
|
||||
const filteredDebrief = selectedUserId === -1
|
||||
? debrief
|
||||
: debrief.filter(r => r.userId === selectedUserId);
|
||||
const sumField = (field: keyof DebriefRow) =>
|
||||
filteredDebrief.reduce((sum, r) => sum + (Number(r[field]) || 0), 0);
|
||||
const avgPrecision = filteredDebrief.length > 0
|
||||
? filteredDebrief.reduce((sum, r) => sum + (r.averagePrecision || 0), 0) / filteredDebrief.length
|
||||
: 0;
|
||||
|
||||
{/* 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>
|
||||
// Bar chart data: per participant
|
||||
const barData = debrief.map(row => {
|
||||
const user = userMap.get(row.userId);
|
||||
const name = user ? (user.firstName || user.username) : `#${row.userId}`;
|
||||
return {
|
||||
name,
|
||||
[t('hitType.enemy')]: row.nbEnemyHitsByUser,
|
||||
[t('hitType.civilian')]: row.nbCivilHitsByUser,
|
||||
[t('hitType.police')]: row.nbPoliceHitsByUser,
|
||||
[t('session.missed')]: row.nbMissedShotsByUser,
|
||||
};
|
||||
});
|
||||
|
||||
<Row className="mb-4 g-3">
|
||||
{/* Objectives */}
|
||||
{parsedObjectives && (
|
||||
<Col md={6}>
|
||||
<Card className="data-card h-100">
|
||||
return (
|
||||
<Row className="mb-4 g-3 align-items-stretch">
|
||||
{/* Col 1: KPIs */}
|
||||
<Col lg={2} className="d-flex">
|
||||
<div className="d-flex flex-column justify-content-between flex-grow-1" style={{ gap: '0.5rem' }}>
|
||||
<Card className="data-card flex-grow-1">
|
||||
<Card.Body className="py-3 px-3 d-flex flex-column justify-content-center text-center">
|
||||
<div style={{ fontSize: '0.9rem', color: '#999' }}>{t('session.duration')}</div>
|
||||
<div style={{ fontSize: '1.6rem', fontWeight: 'bold' }}>{formatDuration(session.timeToFinish)}</div>
|
||||
</Card.Body>
|
||||
</Card>
|
||||
<Card className="data-card flex-grow-1">
|
||||
<Card.Body className="py-3 px-3 d-flex flex-column justify-content-center text-center">
|
||||
<div style={{ fontSize: '0.9rem', color: '#999' }}>{t('firerange.shotsFired')}</div>
|
||||
<div style={{ fontSize: '1.6rem', fontWeight: 'bold' }}>{sumField('nbFiredShotsByUser')}</div>
|
||||
</Card.Body>
|
||||
</Card>
|
||||
<Card className="data-card flex-grow-1">
|
||||
<Card.Body className="py-3 px-3 d-flex flex-column justify-content-center text-center">
|
||||
<div style={{ fontSize: '0.9rem', color: '#999' }}>{t('firerange.shotsMissed')}</div>
|
||||
<div style={{ fontSize: '1.6rem', fontWeight: 'bold' }}>{sumField('nbMissedShotsByUser')}</div>
|
||||
</Card.Body>
|
||||
</Card>
|
||||
<Card className="data-card flex-grow-1">
|
||||
<Card.Body className="py-3 px-3 d-flex flex-column justify-content-center text-center">
|
||||
<div style={{ fontSize: '0.9rem', color: '#999' }}>{t('firerange.avgPrecision')}</div>
|
||||
<div style={{ fontSize: '1.6rem', fontWeight: 'bold' }}>{avgPrecision > 0 ? `${(avgPrecision * 100).toFixed(2)}%` : '-'}</div>
|
||||
</Card.Body>
|
||||
</Card>
|
||||
</div>
|
||||
</Col>
|
||||
|
||||
{/* Col 2: Global Stats + Personal Stats */}
|
||||
<Col lg={3} className="d-flex">
|
||||
<div className="d-flex flex-column justify-content-between flex-grow-1">
|
||||
<Card className="data-card mb-2 flex-grow-1">
|
||||
<Card.Body className="py-2 px-3">
|
||||
<h6 className="mb-2" style={{ fontSize: '0.95rem' }}>{t('session.globalStats')}</h6>
|
||||
<table className="w-100" style={{ fontSize: '0.9rem' }}>
|
||||
<tbody>
|
||||
<tr><td className="py-1" style={{ color: '#27ae60' }}>{t('stats.enemiesKilled')}</td><td className="text-end fw-bold">{sumField('totalEnemyKilled')}</td></tr>
|
||||
<tr><td className="py-1" style={{ color: '#e74c3c' }}>{t('stats.civiliansKilled')}</td><td className="text-end fw-bold">{sumField('totalCivilKilled')}</td></tr>
|
||||
<tr><td className="py-1" style={{ color: '#1abc9c' }}>{t('stats.policeKilled')}</td><td className="text-end fw-bold">{sumField('totalPoliceKilled')}</td></tr>
|
||||
<tr><td className="py-1" style={{ color: '#f39c12' }}>{t('session.hitsReceived')}</td><td className="text-end fw-bold">{sumField('nbReceivedHitsFromEnemyIA') + sumField('nbReceivedHitsFromEnemyUser') + sumField('nbReceivedHitsFromPoliceUser')}</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</Card.Body>
|
||||
</Card>
|
||||
|
||||
<Card className="data-card flex-grow-1">
|
||||
<Card.Body className="py-2 px-3">
|
||||
<h6 className="mb-2" style={{ fontSize: '0.95rem' }}>{t('session.personalStats')}</h6>
|
||||
<table className="w-100" style={{ fontSize: '0.9rem' }}>
|
||||
<tbody>
|
||||
<tr><td className="py-1" style={{ color: '#27ae60' }}>{t('session.enemiesHit')}</td><td className="text-end fw-bold">{sumField('nbEnemyHitsByUser')}</td></tr>
|
||||
<tr><td className="py-1" style={{ color: '#e74c3c' }}>{t('session.civiliansHit')}</td><td className="text-end fw-bold">{sumField('nbCivilHitsByUser')}</td></tr>
|
||||
<tr><td className="py-1" style={{ color: '#1abc9c' }}>{t('session.friendlyFire')}</td><td className="text-end fw-bold">{sumField('nbPoliceHitsByUser')}</td></tr>
|
||||
<tr><td className="py-1" style={{ color: '#f39c12' }}>{t('session.hitsReceived')}</td><td className="text-end fw-bold">{sumField('nbReceivedHitsFromEnemyIA') + sumField('nbReceivedHitsFromEnemyUser') + sumField('nbReceivedHitsFromPoliceUser')}</td></tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</Card.Body>
|
||||
</Card>
|
||||
</div>
|
||||
</Col>
|
||||
|
||||
{/* Col 3: Bar chart */}
|
||||
<Col lg={7} className="d-flex">
|
||||
<Card className="data-card flex-grow-1">
|
||||
<Card.Body className="d-flex flex-column">
|
||||
<Card.Title className="text-center">{t('session.shotsInSession')}</Card.Title>
|
||||
<div className="d-flex flex-grow-1 align-items-center">
|
||||
<ResponsiveContainer width="100%" height={350}>
|
||||
<BarChart data={barData} margin={{ top: 10, right: 20, left: 10, bottom: 5 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#333" />
|
||||
<XAxis dataKey="name" stroke="#999" tick={false} />
|
||||
<YAxis stroke="#999" />
|
||||
<Tooltip contentStyle={{ backgroundColor: '#1a1a2e', border: '1px solid #333' }} />
|
||||
<Legend />
|
||||
<Bar dataKey={t('hitType.enemy')} fill="#ff6b8a" />
|
||||
<Bar dataKey={t('hitType.civilian')} fill="#f39c12" />
|
||||
<Bar dataKey={t('hitType.police')} fill="#1abc9c" />
|
||||
<Bar dataKey={t('session.missed')} fill="#ff9ec4" />
|
||||
</BarChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
</Card.Body>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
);
|
||||
})()}
|
||||
|
||||
{/* FireRange / Challenge / LongRange - KPIs left + Chart/Target right */}
|
||||
{isFireRange && shotDetails.length > 0 && (() => {
|
||||
// Handle both PascalCase (API) and camelCase (TS interface) property names
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const getVal = (shot: any, ...keys: string[]): number => {
|
||||
for (const k of keys) { if (shot[k] != null) return shot[k] as number; }
|
||||
return 0;
|
||||
};
|
||||
|
||||
const hitsOnly = shotDetails.filter(s => getVal(s, 'ReactId', 'reactId') >= 0);
|
||||
const missedCount = shotDetails.filter(s => getVal(s, 'ReactId', 'reactId') < 0).length;
|
||||
const avgPrecisionStr = (() => {
|
||||
if (hitsOnly.length === 0) return '-';
|
||||
const avg = hitsOnly.reduce((sum, s) => sum + getVal(s, 'HitPrecision', 'hitPrecision'), 0) / hitsOnly.length;
|
||||
return `${(avg * 100).toFixed(1)}%`;
|
||||
})();
|
||||
const avgReactionStr = (() => {
|
||||
const withReaction = shotDetails.filter(s => getVal(s, 'ReactionTime', 'reactionTime') > 0);
|
||||
if (withReaction.length === 0) return '-';
|
||||
const avg = withReaction.reduce((sum, s) => sum + getVal(s, 'ReactionTime', 'reactionTime'), 0) / withReaction.length;
|
||||
return `${(avg / 1000).toFixed(3)} s`;
|
||||
})();
|
||||
|
||||
// Precision chart data
|
||||
const precisionData = shotDetails.map(s => ({
|
||||
shot: getVal(s, 'ShotIndex', 'shotIndex'),
|
||||
precision: getVal(s, 'ReactId', 'reactId') >= 0
|
||||
? Math.round(getVal(s, 'HitPrecision', 'hitPrecision') * 100)
|
||||
: 0,
|
||||
}));
|
||||
|
||||
// Reaction time chart data (for Challenge)
|
||||
const reactionData = hitsOnly
|
||||
.filter(s => getVal(s, 'ReactionTime', 'reactionTime') > 0)
|
||||
.map(s => ({
|
||||
shot: getVal(s, 'ShotIndex', 'shotIndex'),
|
||||
reaction: Math.round(getVal(s, 'ReactionTime', 'reactionTime')),
|
||||
}));
|
||||
|
||||
// Target hit positions (only shots that hit)
|
||||
const targetShots = hitsOnly.map(s => ({
|
||||
index: getVal(s, 'ShotIndex', 'shotIndex'),
|
||||
x: getVal(s, 'HitLocationX', 'hitLocationX'),
|
||||
y: getVal(s, 'HitLocationY', 'hitLocationY'),
|
||||
precision: getVal(s, 'HitPrecision', 'hitPrecision'),
|
||||
}));
|
||||
|
||||
return (
|
||||
<Row className="mb-4 g-3">
|
||||
{/* Left: KPI cards vertical */}
|
||||
<Col lg={3}>
|
||||
<Row className="g-3">
|
||||
<Col xs={6} lg={isChallenge ? 6 : 12}>
|
||||
<StatCard title={t('session.duration')} value={formatDuration(session.timeToFinish)} color="#9b59b6" />
|
||||
</Col>
|
||||
<Col xs={6} lg={isChallenge ? 6 : 12}>
|
||||
<StatCard title={t('firerange.shotsFired')} value={shotDetails.length} color="#1abc9c" />
|
||||
</Col>
|
||||
{isChallenge && (
|
||||
<Col xs={6} lg={6}>
|
||||
<StatCard title={t('firerange.targetsHit')} value={hitsOnly.length} color="#4a90d9" />
|
||||
</Col>
|
||||
)}
|
||||
<Col xs={6} lg={isChallenge ? 6 : 12}>
|
||||
<StatCard title={t('firerange.shotsMissed')} value={missedCount} color="#e74c3c" />
|
||||
</Col>
|
||||
<Col xs={6} lg={isChallenge ? 6 : 12}>
|
||||
<StatCard title={t('firerange.avgPrecision')} value={avgPrecisionStr} color="#27ae60" />
|
||||
</Col>
|
||||
{isChallenge && (
|
||||
<Col xs={6} lg={6}>
|
||||
<StatCard title={t('firerange.avgReaction')} value={avgReactionStr} color="#f39c12" />
|
||||
</Col>
|
||||
)}
|
||||
</Row>
|
||||
</Col>
|
||||
|
||||
{/* Middle: Target (not for Challenge) */}
|
||||
{!isChallenge && (
|
||||
<Col lg={4}>
|
||||
<Card className="data-card h-100">
|
||||
<Card.Body className="d-flex flex-column">
|
||||
<Card.Title className="text-center">{t('firerange.targetView')}</Card.Title>
|
||||
<div className="d-flex flex-grow-1 align-items-center justify-content-center">
|
||||
<TargetVisualization shots={targetShots} variant={session.sessionTypeAsInt === SessionType.LongRange ? 'longRange' : 'human'} />
|
||||
</div>
|
||||
</Card.Body>
|
||||
</Card>
|
||||
</Col>
|
||||
)}
|
||||
|
||||
{/* Right: Chart */}
|
||||
<Col lg={isChallenge ? 9 : 5}>
|
||||
<Card className="data-card h-100">
|
||||
<Card.Body className="d-flex flex-column">
|
||||
<Card.Title className="text-center">
|
||||
{isChallenge ? t('firerange.reactionChart') : t('firerange.precisionChart')}
|
||||
</Card.Title>
|
||||
<div className="d-flex flex-grow-1 align-items-center">
|
||||
{isChallenge ? (
|
||||
<ResponsiveContainer width="100%" height={250}>
|
||||
<LineChart data={reactionData} margin={{ top: 10, right: 20, left: 10, bottom: 5 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#333" />
|
||||
<XAxis dataKey="shot" stroke="#999" />
|
||||
<YAxis stroke="#999" width={50} tickFormatter={v => `${v}`} />
|
||||
<Tooltip
|
||||
contentStyle={{ backgroundColor: '#1a1a2e', border: '1px solid #333' }}
|
||||
formatter={(value: number) => [`${value} ms`, t('chart.reactionTime')]}
|
||||
/>
|
||||
<Line
|
||||
type="linear"
|
||||
dataKey="reaction"
|
||||
stroke="#f39c12"
|
||||
strokeWidth={2}
|
||||
dot={{ fill: '#f39c12', r: 4 }}
|
||||
activeDot={{ r: 6 }}
|
||||
/>
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
) : (
|
||||
<ResponsiveContainer width="100%" height={250}>
|
||||
<LineChart data={precisionData} margin={{ top: 10, right: 20, left: 10, bottom: 5 }}>
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="#333" />
|
||||
<XAxis dataKey="shot" stroke="#999" />
|
||||
<YAxis domain={[0, 100]} stroke="#999" width={50} tickFormatter={v => `${v}`} />
|
||||
<Tooltip
|
||||
contentStyle={{ backgroundColor: '#1a1a2e', border: '1px solid #333' }}
|
||||
formatter={(value: number) => [`${value}%`, t('chart.precision')]}
|
||||
/>
|
||||
<Line
|
||||
type="linear"
|
||||
dataKey="precision"
|
||||
stroke="#ff6b8a"
|
||||
strokeWidth={2}
|
||||
dot={{ fill: '#ff6b8a', r: 4 }}
|
||||
activeDot={{ r: 6 }}
|
||||
/>
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
)}
|
||||
</div>
|
||||
</Card.Body>
|
||||
</Card>
|
||||
</Col>
|
||||
</Row>
|
||||
);
|
||||
})()}
|
||||
|
||||
{/* Objectives (hidden for FireRange/Challenge) */}
|
||||
{parsedObjectives && !isFireRange && (
|
||||
<Row className="mb-4 g-3">
|
||||
<Col md={12}>
|
||||
<Card className="data-card">
|
||||
<Card.Body>
|
||||
<Card.Title>{t('session.objectives')}</Card.Title>
|
||||
{Object.entries(parsedObjectives).map(([key, obj]) => {
|
||||
@ -268,82 +446,7 @@ function SessionDetail() {
|
||||
</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>
|
||||
</Row>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
@ -8,6 +8,7 @@ import Form from 'react-bootstrap/Form';
|
||||
import Pagination from 'react-bootstrap/Pagination';
|
||||
import { getAllSessions } from '../api/client';
|
||||
import type { Session } from '../types';
|
||||
import { useComputedSuccess, getComputedSuccess } from '../hooks/useComputedSuccess';
|
||||
import SessionTypeBadge from '../components/SessionTypeBadge';
|
||||
import ScoreBadge from '../components/ScoreBadge';
|
||||
import LoadingSpinner from '../components/LoadingSpinner';
|
||||
@ -50,11 +51,13 @@ function Sessions() {
|
||||
getAllSessions().then(setSessions).finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
const successMap = useComputedSuccess(sessions);
|
||||
|
||||
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 (successFilter === 'success') result = result.filter((s) => getComputedSuccess(successMap, s));
|
||||
else if (successFilter === 'failed') result = result.filter((s) => !getComputedSuccess(successMap, s));
|
||||
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));
|
||||
@ -64,7 +67,7 @@ function Sessions() {
|
||||
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]);
|
||||
}, [sessions, successMap, typeFilter, successFilter, searchQuery, sortKey, sortDir]);
|
||||
|
||||
const totalPages = Math.ceil(filtered.length / PAGE_SIZE);
|
||||
const paginated = filtered.slice((currentPage - 1) * PAGE_SIZE, currentPage * PAGE_SIZE);
|
||||
@ -155,7 +158,7 @@ function Sessions() {
|
||||
<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>
|
||||
<td><ScoreBadge success={getComputedSuccess(successMap, session)} /></td>
|
||||
</tr>
|
||||
))}
|
||||
{paginated.length === 0 && (
|
||||
|
||||
@ -6,6 +6,7 @@ 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 { useComputedSuccess, getComputedSuccess } from '../hooks/useComputedSuccess';
|
||||
import StatCard from '../components/StatCard';
|
||||
import SessionTypeBadge from '../components/SessionTypeBadge';
|
||||
import ScoreBadge from '../components/ScoreBadge';
|
||||
@ -38,6 +39,7 @@ function UserDetail() {
|
||||
const [sessions, setSessions] = useState<Session[]>([]);
|
||||
const [history, setHistory] = useState<UserGlobalStats | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const successMap = useComputedSuccess(sessions);
|
||||
|
||||
useEffect(() => {
|
||||
if (!userId) return;
|
||||
@ -229,7 +231,7 @@ function UserDetail() {
|
||||
<td>{session.scenarioName || '-'}</td>
|
||||
<td className="fw-bold">{session.score}</td>
|
||||
<td>{formatDuration(session.timeToFinish)}</td>
|
||||
<td><ScoreBadge success={session.success} /></td>
|
||||
<td><ScoreBadge success={getComputedSuccess(successMap, session)} /></td>
|
||||
</tr>
|
||||
))}
|
||||
{sessions.length === 0 && (
|
||||
|
||||
@ -18,7 +18,7 @@
|
||||
--bg-hover: #252545;
|
||||
--text-primary: #e0e0e0;
|
||||
--text-secondary: #a0a0b0;
|
||||
--text-muted: #6c6c80;
|
||||
--text-muted: #9090a8;
|
||||
--accent-blue: #4a90d9;
|
||||
--accent-orange: #f39c12;
|
||||
--accent-green: #27ae60;
|
||||
@ -233,6 +233,10 @@ h2, h3 {
|
||||
color: var(--text-primary) !important;
|
||||
}
|
||||
|
||||
.form-select {
|
||||
background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3e%3cpath fill='none' stroke='%23a0a0b0' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='m2 5 6 6 6-6'/%3e%3c/svg%3e") !important;
|
||||
}
|
||||
|
||||
.form-label {
|
||||
color: var(--text-secondary) !important;
|
||||
}
|
||||
|
||||
@ -1 +1 @@
|
||||
{"root":["./src/app.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/api/client.ts","./src/components/layout.tsx","./src/components/loadingspinner.tsx","./src/components/navbar.tsx","./src/components/printheader.tsx","./src/components/scorebadge.tsx","./src/components/sessiontypebadge.tsx","./src/components/statcard.tsx","./src/components/charts/activitychart.tsx","./src/components/charts/precisionchart.tsx","./src/components/charts/sessionsbytypechart.tsx","./src/i18n/context.tsx","./src/i18n/translations.ts","./src/pages/dashboard.tsx","./src/pages/sessiondetail.tsx","./src/pages/sessions.tsx","./src/pages/userdetail.tsx","./src/pages/users.tsx","./src/types/index.ts"],"version":"5.6.3"}
|
||||
{"root":["./src/app.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/api/client.ts","./src/components/layout.tsx","./src/components/loadingspinner.tsx","./src/components/navbar.tsx","./src/components/printheader.tsx","./src/components/scorebadge.tsx","./src/components/sessiontypebadge.tsx","./src/components/statcard.tsx","./src/components/targetvisualization.tsx","./src/components/charts/activitychart.tsx","./src/components/charts/precisionchart.tsx","./src/components/charts/sessionsbytypechart.tsx","./src/hooks/usecomputedsuccess.ts","./src/i18n/context.tsx","./src/i18n/translations.ts","./src/pages/dashboard.tsx","./src/pages/sessiondetail.tsx","./src/pages/sessions.tsx","./src/pages/userdetail.tsx","./src/pages/users.tsx","./src/types/index.ts"],"version":"5.6.3"}
|
||||
Loading…
x
Reference in New Issue
Block a user