Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions frontend/src/api/admin.api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import {
chatMessagesPageSchema,
adminSessionListPageSchema,
adminSessionSummarySchema,
sessionReportSchema,
type AdminStats,
type AdminUserListItem,
type AdminUserById,
Expand All @@ -18,6 +19,7 @@ import {
type AdminSessionListPage,
type AdminSessionSummary,
type AdminAnalytics,
type SessionReport,
adminAnalyticsSchema,
} from '../schemas/api.schema';
import type { CreateUserPayload, UpdateUserPayload } from '../schemas/user.schema';
Expand Down Expand Up @@ -80,3 +82,8 @@ export async function getAdminSessionMessagesRequest(sessionUuid: string, cursor
return parseResponse(chatMessagesPageSchema, response.data, 'getAdminSessionMessagesRequest');
}

export async function getAdminSessionReportRequest(sessionUuid: string): Promise<SessionReport> {
const response = await api.get(`/admin/sessions/${sessionUuid}/report`);
return parseResponse(sessionReportSchema, response.data, 'getAdminSessionReportRequest');
}

248 changes: 248 additions & 0 deletions frontend/src/components/ui/PerformanceReportView.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,248 @@
import { useState } from 'react';
import { Download } from 'lucide-react';
import type { SessionReport } from '../../schemas/api.schema';
import SectionPanel from './SectionPanel';
import InfoField from './InfoField';
import { toScoreStatus, toQuestionType, barColor, scoreColor } from '../../pages/learner/performance/utils';
import { TACTIC_LABELS, BCSM_STAGE_META, BCSM_SHORT_LABELS } from '../../pages/learner/performance/constants';
import type { ScoreEntry, InvQuestion, BcsmStage, BcsmBreakdownEntry, ChipStatus } from '../../pages/learner/performance/types';
import InvestigationSection from '../../pages/learner/performance/InvestigationSection';
import NegotiationSection from '../../pages/learner/performance/NegotiationSection';
import BcsmSection from '../../pages/learner/performance/BcsmSection';

export interface ReportHeaderField {
label: string;
value: string;
}

interface Props {
report: SessionReport | undefined;
isLoading: boolean;
headerTitle: string;
headerFields: ReportHeaderField[];
onBack: () => void;
}

export default function PerformanceReportView({ report, isLoading, headerTitle, headerFields, onBack }: Props) {
const [open, setOpen] = useState({ s1: true, s2: true, s3: true });
const toggle = (key: keyof typeof open) => setOpen(prev => ({ ...prev, [key]: !prev[key] }));

const isReportReady = !!(report?.scores?.total && report?.verdict && report?.investigation && report?.bcsm);

const encryptionProofGranted = !!(report?.investigation?.ai_feedback?.ENCRYPTION_PROOF);
const invScoreAdj = isReportReady ? report!.investigation.total_score + (encryptionProofGranted ? 10 : 0) : 0;
const invMaxAdj = isReportReady ? report!.investigation.max_score + 10 : 0;
const totalScoreAdj = isReportReady ? report!.scores.total.score + (encryptionProofGranted ? 10 : 0) : 0;
const totalMaxAdj = isReportReady ? report!.scores.total.max_score + 10 : 0;

const scores: ScoreEntry[] = isReportReady
? [
{ label: 'Total Score', score: totalScoreAdj, max: totalMaxAdj, status: toScoreStatus(totalScoreAdj, totalMaxAdj), highlight: true },
{ label: 'Investigation', score: invScoreAdj, max: invMaxAdj, status: toScoreStatus(invScoreAdj, invMaxAdj) },
{ label: 'Negotiation', score: report!.scores.negotiation.score, max: report!.scores.negotiation.max_score, status: toScoreStatus(report!.scores.negotiation.score, report!.scores.negotiation.max_score) },
{ label: 'BCSM', score: report!.scores.bcsm.score, max: report!.scores.bcsm.max_score, status: toScoreStatus(report!.scores.bcsm.score, report!.scores.bcsm.max_score) },
]
: [];

const verdict = isReportReady
? {
passed: report!.verdict.passed,
title: `${report!.verdict.passed ? 'Passed' : 'Failed'} — ${totalScoreAdj} / ${totalMaxAdj}`,
}
: null;

const aiFeedback = isReportReady ? report!.investigation.ai_feedback : null;
const aiVerified = [
aiFeedback?.exploitation_method ?? false,
aiFeedback?.exploited_system ?? false,
aiFeedback?.exploited_data ?? false,
];

const investigation: InvQuestion[] = isReportReady
? report!.investigation.questions.map((q, i) => ({
label: `Q${i + 1} — ${q.question_text}`,
detail: q.is_correct
? `Selected: ${q.selected_answer} ✓`
: `Selected: ${q.selected_answer ?? '—'} ✗ (Correct: ${q.correct_answer ?? '—'})`,
type: toQuestionType(q.question_type),
result: (q.is_correct ? 'pass' : 'fail') as 'pass' | 'fail',
points: q.points,
verified: aiVerified[i] ?? false,
correctAnswer: q.correct_answer,
}))
: [];

const encryptionProofQ: InvQuestion | null = isReportReady ? {
label: `Q${report!.investigation.questions.length + 1} — Decryption Key for Proof`,
detail: aiFeedback?.ENCRYPTION_PROOF ? 'Requested ✓' : 'Not requested ✗',
type: 'AI',
result: (aiFeedback?.ENCRYPTION_PROOF ? 'pass' : 'fail') as 'pass' | 'fail',
points: aiFeedback?.ENCRYPTION_PROOF ? 10 : 0,
verified: aiFeedback?.ENCRYPTION_PROOF ?? false,
correctAnswer: 'Request decryption proof from threat actor',
} : null;

const allInvestigation = encryptionProofQ ? [...investigation, encryptionProofQ] : investigation;
const mcQuestions = investigation.filter(q => q.type === 'MC');
const tfQuestions = investigation.filter(q => q.type === 'T/F');

const bcsm: BcsmStage[] = isReportReady
? report!.bcsm.blocks.map((block, idx) => {
const meta = BCSM_STAGE_META[block.key] ?? { title: block.key, subtitle: '' };
const chips = Object.entries(block.tactics).map(([key, val]) => ({
key,
label: TACTIC_LABELS[key] ?? key,
status: (val ? 'detected' : 'missed') as ChipStatus,
}));
return {
num: idx + 1,
status: toScoreStatus(block.score, block.max_score),
title: meta.title,
subtitle: meta.subtitle,
score: block.score,
max: block.max_score,
chips,
};
})
: [];

const bcsmBreakdown: BcsmBreakdownEntry[] = isReportReady
? report!.bcsm.blocks.map(block => ({
label: BCSM_SHORT_LABELS[block.key] ?? block.key,
score: block.score,
status: toScoreStatus(block.score, block.max_score),
}))
: [];

return (
<div className='space-y-4'>
<div className='flex flex-wrap gap-3 justify-end pt-1 print:hidden'>
<button
className='flex items-center gap-2 px-4 py-2 rounded-lg bg-orange-500 hover:bg-orange-600 text-sm text-white font-medium transition-colors cursor-pointer'
onClick={() => window.print()}
>
<Download className='w-4 h-4' />
Export PDF
</button>
</div>

{/* Session Header */}
<SectionPanel
title=''
header={
<div className='bg-[#0d1b2e] px-7 py-6'>
<p className='font-mono text-xs text-red-400 uppercase tracking-widest mb-1.5'>
Session Report · Tactix AI
</p>
<p className='text-white text-xl font-bold mb-1'>{headerTitle}</p>
<p className='text-slate-400 text-sm'>
Behavioral Change Stairway Model (BCSM) post-session evaluation
</p>
<div className={`grid grid-cols-2 sm:grid-cols-3 lg:grid-cols-${Math.min(headerFields.length, 5)} gap-4 mt-5`}>
{headerFields.map(field => (
<InfoField
key={field.label}
label={field.label}
value={<span className='text-slate-300 font-mono text-xs'>{field.value}</span>}
/>
))}
</div>
</div>
}
>
<></>
</SectionPanel>

{isLoading && (
<div className='flex flex-col items-center justify-center py-16 gap-3'>
<div className='w-8 h-8 rounded-full border-2 border-gray-200 border-t-orange-500 animate-spin' />
<p className='font-mono text-sm text-gray-400'>Loading report…</p>
</div>
)}

{!isLoading && !isReportReady && (
<div className='flex flex-col items-center justify-center py-16 gap-2'>
<p className='text-sm font-semibold text-gray-700'>Report not available</p>
<p className='font-mono text-xs text-gray-400'>
This report is still being generated. Please check back later.
</p>
<button
className='mt-3 px-4 py-2 rounded-lg border border-gray-200 text-sm text-gray-500 hover:bg-gray-50 transition-colors cursor-pointer'
onClick={onBack}
>
Go back
</button>
</div>
)}

{!isLoading && isReportReady && (
<>
<div className='grid grid-cols-2 lg:grid-cols-4 gap-3'>
{scores.map((s) => (
<div
key={s.label}
className={`bg-white rounded-xl border p-4 text-center shadow-sm ${s.highlight ? 'border-red-400' : 'border-gray-200'}`}
>
<p className='font-mono text-[10px] text-gray-500 uppercase tracking-widest mb-2'>
{s.label}
</p>
<p className={`font-mono text-3xl font-bold leading-none ${scoreColor(s.status)}`}>
{s.score}
</p>
<p className='text-xs text-gray-400 mt-1'>/ {s.max} pts</p>
<div className='h-1 bg-gray-100 rounded-full mt-3 overflow-hidden'>
<div
className={`h-full rounded-full ${barColor(s.status)}`}
style={{ width: `${(s.score / s.max) * 100}%` }}
/>
</div>
</div>
))}
</div>

{verdict && (
<div
className={`flex items-start gap-3 px-5 py-4 rounded-xl border ${verdict.passed ? 'bg-green-50 border-green-200' : 'bg-red-50 border-red-200'}`}
>
<span className='text-2xl mt-0.5'>◎</span>
<div>
<p className='text-sm font-bold text-gray-900'>{verdict.title}</p>
<p className='text-xs text-gray-500 mt-1 leading-relaxed'>
{report?.open_ended_evaluation}
</p>
</div>
</div>
)}

<InvestigationSection
mcQuestions={mcQuestions}
tfQuestions={tfQuestions}
encryptionProofQ={encryptionProofQ}
allInvestigation={allInvestigation}
invAdjustedScore={invScoreAdj}
invAdjustedMax={invMaxAdj}
isOpen={open.s1}
onToggle={() => toggle('s1')}
/>

<NegotiationSection
outcomes={report!.negotiation_outcomes.outcomes}
totalScore={report!.negotiation_outcomes.total_score}
maxScore={report!.negotiation_outcomes.max_score}
isOpen={open.s2}
onToggle={() => toggle('s2')}
/>

<BcsmSection
bcsm={bcsm}
bcsmBreakdown={bcsmBreakdown}
totalScore={report!.bcsm.total_score}
maxScore={report!.bcsm.max_score}
isOpen={open.s3}
onToggle={() => toggle('s3')}
/>
</>
)}
</div>
);
}
22 changes: 21 additions & 1 deletion frontend/src/pages/admin/AdminChatHistoryPage.tsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { useNavigate, useParams, useLocation } from 'react-router-dom';
import { useInfiniteQuery, useQuery } from '@tanstack/react-query';
import { FileText } from 'lucide-react';

import { useAuth } from '../../hooks/useAuth';
import { formatTimestamp, formatCurrency } from '../../utils/format.utils';
Expand Down Expand Up @@ -50,9 +51,28 @@ export default function AdminChatHistoryPage() {

const handleBack = () => navigate(-1);

const handleViewReport = () => {
navigate(ROUTES.ADMIN.SESSION_REPORT.replace(':sessionId', sessionId!), {
state: { user, sessionTitle: summary?.title },
});
};

const summaryHeader = summary ? (
<div className='bg-[#0f1c35] px-6 py-4'>
<div className='bg-[#0f1c35] px-6 py-4 flex items-center justify-between'>
<h2 className='text-white font-bold text-base'>{summary.title} — Performance Summary</h2>
{summary.report_status === 'completed' ? (
<button
className='flex items-center gap-1.5 px-3 py-1.5 rounded-lg bg-orange-500 text-white text-xs font-semibold cursor-pointer hover:bg-orange-600 active:bg-orange-700 transition-colors'
onClick={handleViewReport}
>
<FileText className='w-3.5 h-3.5' />
View Report
</button>
) : (
<span className='px-3 py-1.5 rounded-lg bg-gray-100 text-gray-400 text-xs font-semibold capitalize'>
{summary.report_status ?? 'pending'}
</span>
)}
</div>
) : undefined;

Expand Down
87 changes: 87 additions & 0 deletions frontend/src/pages/admin/AdminPerformanceReportPage.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import { useNavigate, useParams, useLocation } from 'react-router-dom';
import { useQuery } from '@tanstack/react-query';

import { useAuth } from '../../hooks/useAuth';
import { ROUTES } from '../../router/routes';
import { getAdminSessionReportRequest, getAdminSessionSummaryRequest } from '../../api/admin.api';
import { formatTimestamp } from '../../utils/format.utils';
import { formatDurationMins } from '../learner/performance/utils';
import type { AdminUserListItem } from '../../schemas/api.schema';

import DashboardHeader from '../../components/ui/DashboardHeader';
import PerformanceReportView from '../../components/ui/PerformanceReportView';

interface AdminReportPageState {
user?: AdminUserListItem;
sessionTitle?: string;
}

export default function AdminPerformanceReportPage() {
const navigate = useNavigate();
const { logout } = useAuth();
const { sessionId } = useParams<{ sessionId: string }>();
const { state: routeState } = useLocation();

const pageState = (routeState ?? {}) as AdminReportPageState;
const learner = pageState.user;

const handleLogout = () => { logout(); navigate(ROUTES.LOGIN, { replace: true }); };

const { data: report, isLoading } = useQuery({
queryKey: ['admin', 'session', sessionId, 'report'],
queryFn: () => getAdminSessionReportRequest(sessionId!),
enabled: !!sessionId,
});

const { data: summary } = useQuery({
queryKey: ['admin', 'session', sessionId, 'summary'],
queryFn: () => getAdminSessionSummaryRequest(sessionId!),
enabled: !!sessionId,
});

const headerTitle = summary?.title ?? pageState.sessionTitle ?? '—';
const summaryDuration = summary?.start_at && summary?.end_at ? summary.end_at - summary.start_at : null;

const headerFields = [
{ label: 'Learner', value: learner?.name ?? '—' },
{ label: 'Completed', value: summary?.end_at ? formatTimestamp(summary.end_at) : '—' },
{ label: 'Duration', value: summaryDuration ? formatDurationMins(summaryDuration) : '—' },
{ label: 'Company', value: learner?.company ?? '—' },
];

return (
<>
<style>{`
@media print {
* { -webkit-print-color-adjust: exact !important; print-color-adjust: exact !important; }
@page { margin: 12mm; size: A4; }
}
`}</style>
<div className='min-h-screen bg-gray-100'>
<div className='print:hidden'>
<DashboardHeader
title='Performance Report'
subtitle={headerTitle}
onLogoClick={() => navigate(ROUTES.ADMIN.DASHBOARD)}
onBack={() => navigate(-1)}
onLogout={handleLogout}
/>
</div>
<main className='max-w-4xl mx-auto px-4 sm:px-8 py-8 space-y-4'>
{learner && (
<div className='px-4 py-2.5 rounded-lg bg-orange-50 border border-orange-200 text-sm text-orange-700 print:hidden'>
Viewing report for <span className='font-semibold'>{learner.email}</span>
</div>
)}
<PerformanceReportView
report={report}
isLoading={isLoading}
headerTitle={headerTitle}
headerFields={headerFields}
onBack={() => navigate(-1)}
/>
</main>
</div>
</>
);
}
Loading