This commit is contained in:
2026-08-12 19:30:17 +03:00
parent 40c324d7fe
commit b0b7196518
152 changed files with 24657 additions and 0 deletions

View File

@@ -0,0 +1,109 @@
import { ScoreBreakdown as ScoreBreakdownType } from '../../api/types';
import { cn } from '../../utils/helpers';
import { formatNumber } from '../../utils/formatters';
interface ScoreBreakdownProps {
breakdown: ScoreBreakdownType;
className?: string;
}
export function ScoreBreakdown({ breakdown, className }: ScoreBreakdownProps) {
const total = breakdown.totalAttack + breakdown.totalDefense + breakdown.totalSLA;
const attackPercent = total > 0 ? (breakdown.totalAttack / total) * 100 : 0;
const defensePercent = total > 0 ? (breakdown.totalDefense / total) * 100 : 0;
const slaPercent = total > 0 ? (breakdown.totalSLA / total) * 100 : 0;
return (
<div className={cn('space-y-4', className)}>
{/* Breakdown bars */}
<div className="h-4 overflow-hidden rounded-full bg-muted">
<div className="flex h-full">
<div
className="bg-red-500 transition-all"
style={{ width: `${attackPercent}%` }}
title={`Атака: ${formatNumber(breakdown.totalAttack)}`}
/>
<div
className="bg-blue-500 transition-all"
style={{ width: `${defensePercent}%` }}
title={`Защита: ${formatNumber(breakdown.totalDefense)}`}
/>
<div
className="bg-green-500 transition-all"
style={{ width: `${slaPercent}%` }}
title={`SLA: ${formatNumber(breakdown.totalSLA)}`}
/>
</div>
</div>
{/* Legend */}
<div className="grid grid-cols-3 gap-4 text-center">
<div>
<div className="text-xs text-muted-foreground">Атака</div>
<div className="text-lg font-bold text-red-400">
{formatNumber(breakdown.totalAttack)}
</div>
<div className="text-xs text-muted-foreground">
{attackPercent.toFixed(1)}%
</div>
</div>
<div>
<div className="text-xs text-muted-foreground">Защита</div>
<div className="text-lg font-bold text-blue-400">
{formatNumber(breakdown.totalDefense)}
</div>
<div className="text-xs text-muted-foreground">
{defensePercent.toFixed(1)}%
</div>
</div>
<div>
<div className="text-xs text-muted-foreground">SLA</div>
<div className="text-lg font-bold text-green-400">
{formatNumber(breakdown.totalSLA)}
</div>
<div className="text-xs text-muted-foreground">
{slaPercent.toFixed(1)}%
</div>
</div>
</div>
{/* Penalties and bonuses */}
{(breakdown.penalties !== 0 || breakdown.bonuses !== 0) && (
<div className="flex justify-between border-t border-border pt-4">
{breakdown.penalties !== 0 && (
<div className="text-sm">
<span className="text-muted-foreground">Штрафы: </span>
<span className="text-destructive">-{formatNumber(breakdown.penalties)}</span>
</div>
)}
{breakdown.bonuses !== 0 && (
<div className="text-sm">
<span className="text-muted-foreground">Бонусы: </span>
<span className="text-green-400">+{formatNumber(breakdown.bonuses)}</span>
</div>
)}
</div>
)}
{/* Service breakdown */}
{breakdown.services.length > 0 && (
<div className="space-y-2 border-t border-border pt-4">
<div className="text-sm font-medium">По сервисам:</div>
{breakdown.services.map((service) => (
<div key={service.serviceId} className="flex items-center justify-between text-sm">
<span>{service.serviceName}</span>
<div className="flex items-center gap-3">
<span className="text-xs text-red-400">+{service.attackScore}</span>
<span className="text-xs text-blue-400">+{service.defenseScore}</span>
<span className="text-xs text-green-400">+{service.slaScore}</span>
</div>
</div>
))}
</div>
)}
</div>
);
}
export default ScoreBreakdown;

View File

@@ -0,0 +1,150 @@
import { useMemo } from 'react';
import { ScoreHistory } from '../../api/types';
import { cn } from '../../utils/helpers';
import { getTeamColor } from '../../utils/colors';
interface ScoreChartProps {
history: ScoreHistory[];
height?: number;
showLegend?: boolean;
showGrid?: boolean;
className?: string;
}
export function ScoreChart({
history,
height = 300,
showLegend = true,
showGrid = true,
className,
}: ScoreChartProps) {
// Get all rounds
const allRounds = useMemo(() => {
const rounds = new Set<number>();
history.forEach((h) => {
h.history.forEach((point) => rounds.add(point.round));
});
return Array.from(rounds).sort((a, b) => a - b);
}, [history]);
// Get max score for scaling
const maxScore = useMemo(() => {
let max = 0;
history.forEach((h) => {
h.history.forEach((point) => {
if (point.score > max) max = point.score;
});
});
return max || 100;
}, [history]);
// Generate path for a team
const generatePath = (teamHistory: ScoreHistory) => {
if (allRounds.length === 0 || teamHistory.history.length === 0) return '';
const points = allRounds.map((round) => {
const point = teamHistory.history.find((p) => p.round === round);
const score = point?.score || 0;
const x = (allRounds.indexOf(round) / (allRounds.length - 1 || 1)) * 100;
const y = 100 - (score / maxScore) * 100;
return `${x},${y}`;
});
return points.map((p, i) => (i === 0 ? `M ${p}` : `L ${p}`)).join(' ');
};
if (history.length === 0) {
return (
<div className={cn('flex items-center justify-center', className)} style={{ height }}>
<p className="text-muted-foreground">Нет данных для отображения</p>
</div>
);
}
return (
<div className={cn('space-y-4', className)}>
{/* Chart */}
<div className="relative overflow-hidden rounded-lg border border-border bg-card">
<svg
viewBox="0 0 100 100"
preserveAspectRatio="none"
className="h-full w-full"
style={{ height }}
>
{/* Grid */}
{showGrid && (
<>
{[0, 25, 50, 75, 100].map((y) => (
<line
key={y}
x1="0"
y1={y}
x2="100"
y2={y}
stroke="hsl(var(--border))"
strokeWidth="0.2"
strokeDasharray="2,2"
/>
))}
</>
)}
{/* Lines */}
{history.map((teamHistory, index) => (
<g key={teamHistory.teamId}>
<path
d={generatePath(teamHistory)}
fill="none"
stroke={getTeamColor(index)}
strokeWidth="0.5"
strokeLinecap="round"
strokeLinejoin="round"
/>
{/* Points */}
{teamHistory.history.map((point, i) => {
const x = (allRounds.indexOf(point.round) / (allRounds.length - 1 || 1)) * 100;
const y = 100 - (point.score / maxScore) * 100;
return (
<circle
key={i}
cx={x}
cy={y}
r="1"
fill={getTeamColor(index)}
className="opacity-0 hover:opacity-100 transition-opacity"
/>
);
})}
</g>
))}
</svg>
{/* X-axis labels */}
<div className="absolute bottom-0 left-0 right-0 flex justify-between px-2 text-[10px] text-muted-foreground">
{allRounds.slice(0, 10).map((round) => (
<span key={round}>{round}</span>
))}
{allRounds.length > 10 && <span>...</span>}
{allRounds.length > 1 && <span>{allRounds[allRounds.length - 1]}</span>}
</div>
</div>
{/* Legend */}
{showLegend && (
<div className="flex flex-wrap gap-4">
{history.map((teamHistory, index) => (
<div key={teamHistory.teamId} className="flex items-center gap-2">
<div
className="h-3 w-3 rounded-full"
style={{ backgroundColor: getTeamColor(index) }}
/>
<span className="text-sm">{teamHistory.teamName}</span>
</div>
))}
</div>
)}
</div>
);
}
export default ScoreChart;

View File

@@ -0,0 +1,169 @@
import { useMemo } from 'react';
import { Trophy, TrendingUp, TrendingDown, Minus } from 'lucide-react';
import { ScoreboardEntry } from '../../api/types';
import { cn } from '../../utils/helpers';
import { formatNumber, formatPositionChange, formatSLA } from '../../utils/formatters';
import { getTeamColor } from '../../utils/colors';
interface ScoreboardTableProps {
entries: ScoreboardEntry[];
highlightTeamId?: string;
compact?: boolean;
showBreakdown?: boolean;
className?: string;
}
export function ScoreboardTable({
entries,
highlightTeamId,
compact = false,
showBreakdown = false,
className,
}: ScoreboardTableProps) {
// Sort by position
const sortedEntries = useMemo(() => {
return [...entries].sort((a, b) => a.position - b.position);
}, [entries]);
return (
<div className={cn('overflow-hidden rounded-lg border border-border', className)}>
<table className="w-full">
<thead className="bg-muted/50">
<tr>
<th className={cn('px-4 py-3 text-left text-sm font-medium', compact ? 'w-12' : 'w-16')}>
#
</th>
<th className="px-4 py-3 text-left text-sm font-medium">Команда</th>
<th className={cn('px-4 py-3 text-right text-sm font-medium', compact ? 'w-24' : 'w-32')}>
Очки
</th>
{showBreakdown && (
<>
<th className="hidden px-4 py-3 text-right text-sm font-medium md:table-cell">
Атака
</th>
<th className="hidden px-4 py-3 text-right text-sm font-medium md:table-cell">
Защита
</th>
<th className="hidden px-4 py-3 text-right text-sm font-medium md:table-cell">
SLA
</th>
</>
)}
<th className={cn('px-4 py-3 text-right text-sm font-medium', compact ? 'w-20' : 'w-24')}>
Флаги
</th>
<th className={cn('px-4 py-3 text-right text-sm font-medium', compact ? 'w-16' : 'w-20')}>
SLA
</th>
</tr>
</thead>
<tbody>
{sortedEntries.map((entry, index) => {
const isHighlighted = entry.teamId === highlightTeamId;
const isTop3 = entry.position <= 3;
const positionChange = entry.positionChange || 0;
return (
<tr
key={entry.teamId}
className={cn(
'border-b border-border transition-colors',
isHighlighted && 'bg-primary/5',
!isHighlighted && 'hover:bg-muted/50'
)}
>
{/* Position */}
<td className={cn('px-4 py-3', compact ? 'text-sm' : 'text-base')}>
<div className="flex items-center gap-2">
{isTop3 ? (
<Trophy
className={cn(
'h-5 w-5',
entry.position === 1 && 'text-yellow-400',
entry.position === 2 && 'text-gray-300',
entry.position === 3 && 'text-orange-400'
)}
/>
) : (
<span className="font-mono font-medium">{entry.position}</span>
)}
{positionChange !== 0 && (
<span
className={cn(
'flex items-center text-xs',
positionChange > 0 && 'text-green-400',
positionChange < 0 && 'text-red-400'
)}
>
{positionChange > 0 ? (
<TrendingUp className="h-3 w-3" />
) : (
<TrendingDown className="h-3 w-3" />
)}
</span>
)}
</div>
</td>
{/* Team */}
<td className="px-4 py-3">
<div className="flex items-center gap-3">
<div
className="flex h-8 w-8 items-center justify-center rounded-full text-sm font-bold text-white"
style={{ backgroundColor: getTeamColor(index) }}
>
{entry.teamTag?.slice(0, 2).toUpperCase() || entry.teamName.slice(0, 2)}
</div>
<div>
<p className={cn('font-medium', compact ? 'text-sm' : 'text-base')}>
{entry.teamName}
</p>
{showBreakdown && entry.teamTag && (
<p className="text-xs text-muted-foreground">{entry.teamTag}</p>
)}
</div>
</div>
</td>
{/* Score */}
<td className={cn('px-4 py-3 text-right font-bold', compact ? 'text-lg' : 'text-xl')}>
{formatNumber(entry.score)}
</td>
{/* Breakdown */}
{showBreakdown && (
<>
<td className="hidden px-4 py-3 text-right text-sm md:table-cell">
<span className="text-red-400">+{formatNumber(entry.attackScore)}</span>
</td>
<td className="hidden px-4 py-3 text-right text-sm md:table-cell">
<span className="text-blue-400">+{formatNumber(entry.defenseScore)}</span>
</td>
<td className="hidden px-4 py-3 text-right text-sm md:table-cell">
<span className="text-green-400">+{formatNumber(entry.slaScore)}</span>
</td>
</>
)}
{/* Flags */}
<td className={cn('px-4 py-3 text-right', compact ? 'text-sm' : 'text-base')}>
<span className="text-green-400">{entry.flagsCaptured}</span>
<span className="mx-1 text-muted-foreground">/</span>
<span className="text-red-400">{entry.flagsLost}</span>
</td>
{/* SLA */}
<td className={cn('px-4 py-3 text-right font-medium', compact ? 'text-sm' : 'text-base')}>
{formatSLA(entry.avgSLA)}
</td>
</tr>
);
})}
</tbody>
</table>
</div>
);
}
export default ScoreboardTable;

View File

@@ -0,0 +1,124 @@
import { Trophy, Target, Shield, Activity } from 'lucide-react';
import { ScoreboardEntry } from '../../api/types';
import { cn } from '../../utils/helpers';
import { formatNumber, formatSLA } from '../../utils/formatters';
import { getTeamColor } from '../../utils/colors';
interface TeamScoreCardProps {
entry: ScoreboardEntry;
rank?: number;
compact?: boolean;
className?: string;
onClick?: () => void;
}
export function TeamScoreCard({
entry,
rank,
compact = false,
className,
onClick,
}: TeamScoreCardProps) {
const isTop3 = (rank || entry.position) <= 3;
return (
<div
onClick={onClick}
className={cn(
'group rounded-xl border border-border bg-card p-6 transition-all hover:border-primary/50 hover:shadow-lg',
onClick && 'cursor-pointer',
className
)}
>
{/* Header */}
<div className="mb-4 flex items-center justify-between">
<div className="flex items-center gap-3">
<div
className="flex h-12 w-12 items-center justify-center rounded-full text-lg font-bold text-white"
style={{ backgroundColor: getTeamColor(entry.position - 1) }}
>
{entry.teamTag?.slice(0, 2).toUpperCase() || entry.teamName.slice(0, 2)}
</div>
<div>
<h3 className="font-semibold">{entry.teamName}</h3>
{entry.teamTag && (
<p className="text-sm text-muted-foreground">{entry.teamTag}</p>
)}
</div>
</div>
<div className="flex items-center gap-2">
{isTop3 && <Trophy className="h-6 w-6 text-yellow-400" />}
<span className="text-2xl font-bold">#{entry.position}</span>
</div>
</div>
{/* Main score */}
<div className="mb-6">
<div className="text-sm text-muted-foreground">Общий счёт</div>
<div className="text-4xl font-bold text-primary">{formatNumber(entry.score)}</div>
</div>
{/* Stats grid */}
<div className="grid grid-cols-2 gap-4">
{/* Attack */}
<div className="rounded-lg bg-red-500/10 p-3">
<div className="mb-2 flex items-center gap-2 text-red-400">
<Target className="h-4 w-4" />
<span className="text-sm font-medium">Атака</span>
</div>
<div className="text-xl font-bold text-red-400">
+{formatNumber(entry.attackScore)}
</div>
<div className="text-xs text-muted-foreground">
{entry.flagsCaptured} флагов
</div>
</div>
{/* Defense */}
<div className="rounded-lg bg-blue-500/10 p-3">
<div className="mb-2 flex items-center gap-2 text-blue-400">
<Shield className="h-4 w-4" />
<span className="text-sm font-medium">Защита</span>
</div>
<div className="text-xl font-bold text-blue-400">
+{formatNumber(entry.defenseScore)}
</div>
<div className="text-xs text-muted-foreground">
{entry.flagsLost} потеряно
</div>
</div>
{/* SLA */}
<div className="rounded-lg bg-green-500/10 p-3">
<div className="mb-2 flex items-center gap-2 text-green-400">
<Activity className="h-4 w-4" />
<span className="text-sm font-medium">SLA</span>
</div>
<div className="text-xl font-bold text-green-400">
+{formatNumber(entry.slaScore)}
</div>
<div className="text-xs text-muted-foreground">
{formatSLA(entry.avgSLA)} uptime
</div>
</div>
{/* Trend */}
<div className="rounded-lg bg-purple-500/10 p-3">
<div className="mb-2 flex items-center gap-2 text-purple-400">
<Activity className="h-4 w-4" />
<span className="text-sm font-medium">Тренд</span>
</div>
<div className={cn('text-xl font-bold', entry.trend === 'up' && 'text-green-400', entry.trend === 'down' && 'text-red-400')}>
{entry.positionChange && entry.positionChange > 0 ? '+' : ''}
{entry.positionChange || 0}
</div>
<div className="text-xs text-muted-foreground">
позиций
</div>
</div>
</div>
</div>
);
}
export default TeamScoreCard;

View File

@@ -0,0 +1,5 @@
// Export all scoreboard components
export { ScoreboardTable } from './ScoreboardTable';
export { ScoreChart } from './ScoreChart';
export { TeamScoreCard } from './TeamScoreCard';
export { ScoreBreakdown } from './ScoreBreakdown';