Files
WFL/templates/match_workspace copy.html

2909 lines
97 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<title>Рабочее окно матча</title>
<style>
:root {
--bg: #0f1115;
--panel: #171a21;
--panel-2: #1d222b;
--border: #2b3240;
--text: #e8ecf3;
--muted: #9aa4b2;
--accent: #4f8cff;
--accent-hover: #3e78e6;
--success: #2ea043;
--danger: #dc3545;
--warning: #f2c94c;
--shadow: 0 10px 30px rgba(0, 0, 0, 0.35);
--radius: 16px;
}
* {
box-sizing: border-box;
}
body {
margin: 0;
background: var(--bg);
color: var(--text);
font-family: Arial, sans-serif;
}
.page {
min-height: 100vh;
}
.topbar {
position: sticky;
top: 0;
z-index: 100;
background: rgba(15, 17, 21, 0.96);
backdrop-filter: blur(8px);
border-bottom: 1px solid var(--border);
box-shadow: var(--shadow);
}
.topbar-inner {
max-width: 1600px;
margin: 0 auto;
padding: 16px 24px;
}
.match-header {
display: grid;
grid-template-columns: 1fr auto 1fr auto;
gap: 20px;
align-items: center;
}
.team-box {
display: flex;
align-items: center;
gap: 14px;
}
.team-box.right {
justify-content: flex-end;
text-align: right;
}
.team-logo {
width: 56px;
height: 56px;
border-radius: 12px;
background: var(--panel-2);
border: 1px solid var(--border);
object-fit: contain;
padding: 6px;
}
.team-name {
font-size: 26px;
font-weight: 700;
line-height: 1.1;
}
.match-meta {
text-align: center;
min-width: 320px;
}
.match-tour {
font-size: 13px;
color: var(--muted);
margin-bottom: 6px;
text-transform: uppercase;
letter-spacing: 0.4px;
}
.match-time {
font-size: 20px;
font-weight: 700;
margin-bottom: 4px;
}
.match-place {
font-size: 14px;
color: var(--muted);
}
.header-actions {
display: flex;
flex-direction: column;
gap: 10px;
align-items: stretch;
min-width: 220px;
}
.btn {
min-height: 42px;
border: none;
border-radius: 10px;
padding: 0 14px;
font-weight: 600;
cursor: pointer;
transition: background 0.15s ease, transform 0.05s ease;
}
.btn:hover {
transform: translateY(-1px);
}
.btn-primary {
background: var(--accent);
color: white;
}
.btn-primary:hover {
background: var(--accent-hover);
}
.btn-secondary {
background: transparent;
color: var(--text);
border: 1px solid var(--border);
}
.btn-secondary:hover {
background: rgba(255, 255, 255, 0.04);
}
.content {
max-width: 1600px;
margin: 0 auto;
padding: 22px 24px 32px;
}
.tabs {
display: flex;
gap: 10px;
flex-wrap: wrap;
margin-bottom: 22px;
}
.tab-link {
display: inline-flex;
align-items: center;
min-height: 40px;
padding: 0 14px;
border-radius: 999px;
background: var(--panel);
border: 1px solid var(--border);
color: var(--text);
text-decoration: none;
font-size: 14px;
font-weight: 600;
}
.tab-link:hover {
background: var(--panel-2);
text-decoration: none;
}
.tab-link.active {
background: var(--accent);
border-color: var(--accent);
color: white;
}
.panel {
background: var(--panel);
border: 1px solid var(--border);
border-radius: var(--radius);
box-shadow: var(--shadow);
padding: 18px;
}
.game-layout {
display: grid;
grid-template-columns: minmax(0, 1fr) 440px;
gap: 20px;
align-items: start;
}
.game-main {
display: flex;
flex-direction: column;
gap: 20px;
}
.lineups-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 20px;
}
.match-toolbar {
display: flex;
justify-content: space-between;
align-items: center;
gap: 16px;
flex-wrap: wrap;
}
.match-toolbar-left,
.match-toolbar-right {
display: flex;
gap: 10px;
align-items: center;
flex-wrap: wrap;
}
.timer-box {
min-width: 122px;
height: 46px;
border-radius: 12px;
display: inline-flex;
align-items: center;
justify-content: center;
background: var(--panel-2);
border: 1px solid var(--border);
font-size: 24px;
font-weight: 800;
letter-spacing: 1px;
}
.period-badge {
display: inline-flex;
align-items: center;
min-height: 36px;
padding: 0 12px;
border-radius: 999px;
background: rgba(79, 140, 255, 0.14);
color: var(--accent);
font-size: 13px;
font-weight: 700;
}
.team-panel-title {
font-size: 18px;
font-weight: 700;
margin-bottom: 14px;
}
.section-title {
margin: 18px 0 10px;
font-size: 15px;
color: var(--muted);
text-transform: uppercase;
letter-spacing: 0.4px;
}
.section-title:first-child {
margin-top: 0;
}
table {
width: 100%;
border-collapse: collapse;
overflow: hidden;
border-radius: 12px;
background: var(--panel-2);
table-layout: auto;
}
th,
td {
padding: 10px 12px;
border-bottom: 1px solid var(--border);
text-align: left;
font-size: 14px;
vertical-align: middle;
}
th {
color: var(--muted);
font-weight: 600;
background: rgba(255, 255, 255, 0.02);
}
tr:last-child td {
border-bottom: none;
}
tr:hover td {
background: rgba(255, 255, 255, 0.03);
}
.num-col {
width: 60px;
text-align: center;
}
.num-cell {
text-align: center;
font-weight: 600;
}
.col-last {
width: 36%;
}
.col-first {
width: 22%;
}
.actions-col {
width: 230px;
text-align: right;
}
.player-name,
.player-first {
white-space: normal;
overflow: hidden;
text-overflow: unset;
}
.player-name {
font-weight: 600;
}
.player-first {
color: var(--muted);
}
.gk-badge,
.captain-badge {
display: inline-block;
margin-left: 6px;
padding: 2px 6px;
font-size: 10px;
border-radius: 6px;
font-weight: 700;
letter-spacing: 0.3px;
}
.gk-badge {
background: rgba(79, 140, 255, 0.2);
color: #8ab4ff;
}
.captain-badge {
background: rgba(255, 215, 0, 0.18);
color: #ffd54a;
}
.row-actions {
display: flex;
gap: 6px;
justify-content: flex-end;
flex-wrap: wrap;
}
.icon-btn {
width: 28px;
height: 28px;
border: 1px solid var(--border);
border-radius: 8px;
background: rgba(255, 255, 255, 0.03);
color: var(--text);
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
padding: 0;
font-size: 13px;
font-weight: 700;
transition: transform 0.12s ease, background 0.12s ease, border-color 0.12s ease;
}
.icon-btn:hover {
transform: translateY(-1px);
background: rgba(255, 255, 255, 0.08);
border-color: rgba(255, 255, 255, 0.18);
}
.icon-btn.goal {
font-size: 15px;
}
.icon-btn.yellow-card,
.icon-btn.red-card {
position: relative;
overflow: hidden;
}
.icon-btn.yellow-card::before,
.icon-btn.red-card::before {
content: "";
width: 12px;
height: 16px;
border-radius: 2px;
display: block;
}
.icon-btn.yellow-card::before {
background: var(--warning);
}
.icon-btn.red-card::before {
background: var(--danger);
}
.icon-btn.sub {
position: relative;
font-size: 16px;
}
.icon-btn.sub::before {
content: "↗";
position: absolute;
left: 4px;
top: 2px;
color: #22c55e;
font-size: 10px;
font-weight: 900;
}
.icon-btn.sub::after {
content: "↘";
position: absolute;
right: 4px;
bottom: 2px;
color: #ef4444;
font-size: 10px;
font-weight: 900;
}
.empty-box {
padding: 18px;
border-radius: 12px;
background: var(--panel-2);
color: var(--muted);
border: 1px dashed var(--border);
}
.small-meta {
font-size: 13px;
color: var(--muted);
margin-top: 4px;
}
.coaches-block {
margin-bottom: 12px;
padding-bottom: 8px;
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
}
.coaches-block-right {
text-align: right;
}
.coach-line {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
margin-bottom: 8px;
}
.coach-line:last-child {
margin-bottom: 0;
}
.coach-left,
.coach-right {
display: inline-flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.coach-name {
font-weight: 600;
color: var(--text);
font-size: 13px;
}
.coach-badge {
display: inline-block;
padding: 3px 8px;
font-size: 11px;
border-radius: 8px;
font-weight: 600;
background: rgba(255, 255, 255, 0.08);
color: var(--text);
}
.coach-actions {
display: inline-flex;
gap: 6px;
}
.events-panel {
position: sticky;
top: 110px;
padding: 16px;
}
.panel-title-row {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 12px;
margin-bottom: 12px;
}
.panel-title {
font-size: 18px;
font-weight: 700;
margin: 0;
}
.events-list {
display: flex;
flex-direction: column;
gap: 10px;
max-height: calc(100vh - 220px);
overflow-y: auto;
padding-right: 4px;
}
.event-row {
display: grid;
grid-template-columns: 1fr 56px 1fr;
gap: 10px;
align-items: stretch;
}
.event-side {
min-height: 64px;
}
.event-card {
border: 1px solid var(--border);
border-radius: 12px;
background: var(--panel-2);
padding: 10px 12px;
min-height: 64px;
}
.event-card.home {
text-align: right;
}
.event-card.away {
text-align: left;
}
.event-time {
display: flex;
align-items: center;
justify-content: center;
border-radius: 12px;
background: rgba(79, 140, 255, 0.12);
color: var(--accent);
font-size: 14px;
font-weight: 800;
border: 1px solid rgba(79, 140, 255, 0.18);
}
.event-title {
font-size: 14px;
font-weight: 700;
margin-bottom: 4px;
}
.event-sub {
font-size: 12px;
color: var(--muted);
line-height: 1.4;
}
.event-badge {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 26px;
height: 20px;
padding: 0 6px;
border-radius: 999px;
background: rgba(255, 255, 255, 0.08);
color: var(--text);
font-size: 11px;
font-weight: 700;
margin-bottom: 6px;
}
@media (max-width: 1320px) {
.game-layout {
grid-template-columns: 1fr;
}
.events-panel {
position: static;
}
.events-list {
max-height: none;
}
}
@media (max-width: 980px) {
.match-header {
grid-template-columns: 1fr;
}
.team-box.right {
justify-content: flex-start;
text-align: left;
}
.lineups-grid {
grid-template-columns: 1fr;
}
}
.lineups-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 20px;
}
.team-panel-title {
font-size: 18px;
font-weight: 700;
margin-bottom: 14px;
}
.section-title {
margin: 18px 0 10px;
font-size: 15px;
color: var(--muted);
text-transform: uppercase;
letter-spacing: 0.4px;
}
table {
width: 100%;
border-collapse: collapse;
overflow: hidden;
border-radius: 12px;
background: var(--panel-2);
}
th,
td {
padding: 10px 12px;
border-bottom: 1px solid var(--border);
text-align: left;
font-size: 14px;
}
th {
color: var(--muted);
font-weight: 600;
background: rgba(255, 255, 255, 0.02);
}
tr:last-child td {
border-bottom: none;
}
tr:hover td {
background: rgba(255, 255, 255, 0.03);
}
.num-col {
width: 70px;
}
.empty-box {
padding: 18px;
border-radius: 12px;
background: var(--panel-2);
color: var(--muted);
border: 1px dashed var(--border);
}
.small-meta {
font-size: 13px;
color: var(--muted);
margin-top: 4px;
}
.num-col {
width: 60px;
text-align: center;
}
.num-cell {
text-align: center;
font-weight: 600;
}
.player-name {
font-weight: 600;
}
.player-first {
color: var(--muted);
}
.gk-badge {
display: inline-block;
margin-left: 6px;
padding: 2px 6px;
font-size: 10px;
border-radius: 6px;
background: rgba(79, 140, 255, 0.2);
color: #8ab4ff;
font-weight: 700;
letter-spacing: 0.3px;
}
.table-compact th,
.table-compact td {
padding: 8px 10px;
}
.status-badge {
display: inline-block;
padding: 4px 8px;
font-size: 11px;
font-weight: 700;
border-radius: 8px;
letter-spacing: 0.3px;
white-space: normal;
}
.status-scheduled {
background: rgba(154, 164, 178, 0.18);
color: #c2cad5;
}
.status-live {
background: rgba(79, 140, 255, 0.18);
color: #8ab4ff;
}
.status-finished {
background: rgba(46, 160, 67, 0.18);
color: #57d17a;
}
.col-last {
width: 45%;
}
.col-first {
width: 45%;
}
.num-col {
width: 60px;
}
table {
table-layout: auto;
}
.player-name,
.player-first {
white-space: normal;
overflow: hidden;
text-overflow: unset;
}
.tour-table {
table-layout: auto;
}
.tour-date-col {
width: 1%;
white-space: normal;
}
.tour-match-col {
width: 52%;
}
.tour-meta-col {
width: 1%;
white-space: normal;
}
.tour-stadium-col {
width: auto;
}
.tour-date-cell {
white-space: normal;
color: var(--text);
}
.tour-match-cell {
font-weight: 600;
}
.tour-meta-cell {
white-space: normal;
}
.tour-meta-wrap {
display: inline-flex;
align-items: center;
gap: 8px;
white-space: normal;
}
.score-badge {
display: inline-block;
min-width: 48px;
text-align: center;
padding: 4px 8px;
font-size: 12px;
font-weight: 700;
border-radius: 8px;
background: rgba(255, 255, 255, 0.06);
color: var(--text);
}
.stadium-cell {
color: var(--muted);
overflow: hidden;
text-overflow: unset;
}
.standings-table .st-pos-col {
width: 50px;
text-align: center;
}
.standings-table .st-logo-col {
width: 54px;
text-align: center;
}
.standings-table .st-team-col {
width: auto;
}
.standings-table .st-stat-col,
.standings-table .st-gfga-col,
.standings-table .st-points-col {
width: 64px;
text-align: center;
white-space: normal;
}
.standings-logo {
width: 28px;
height: 28px;
object-fit: contain;
display: inline-block;
vertical-align: middle;
}
.standings-row-home td {
background: #FF00FF !important;
color: #ffffff;
}
.standings-row-away td {
background: #6E00A5 !important;
color: #ffffff;
}
.standings-row-home:hover td,
.standings-row-away:hover td {
background: inherit !important;
color: #ffffff;
}
.standings-row-home .player-name,
.standings-row-away .player-name {
color: #ffffff;
}
.standings-row-home img,
.standings-row-away img {
filter: drop-shadow(0 0 2px rgba(0, 0, 0, 0.35));
}
.captain-badge {
display: inline-block;
margin-left: 6px;
padding: 2px 6px;
font-size: 10px;
border-radius: 6px;
background: rgba(255, 215, 0, 0.18);
color: #ffd54a;
font-weight: 700;
letter-spacing: 0.3px;
}
.coaches-block {
margin-bottom: 10px;
}
.coach-line {
font-size: 13px;
line-height: 1.3;
color: var(--muted);
}
.coach-name {
font-weight: 600;
color: var(--text);
}
.coach-role {
color: var(--muted);
}
.coaches-block-right {
text-align: right;
}
.coaches-block {
margin-bottom: 10px;
padding-bottom: 6px;
border-bottom: 1px solid rgba(255, 255, 255, 0.06);
}
.coach-badge {
display: inline-block;
margin-left: 6px;
padding: 3px 8px;
font-size: 11px;
border-radius: 8px;
font-weight: 600;
letter-spacing: 0.2px;
white-space: normal;
background: rgba(255, 255, 255, 0.08);
color: var(--text);
}
.formation-toolbar {
display: flex;
gap: 10px;
align-items: center;
margin-bottom: 14px;
flex-wrap: wrap;
}
.formation-select {
min-height: 40px;
padding: 0 12px;
border-radius: 10px;
border: 1px solid var(--border);
background: var(--panel-2);
color: var(--text);
}
.pitch-half {
position: relative;
width: 100%;
height: 980px;
border-radius: 10px;
overflow: hidden;
border: 2px solid rgba(255, 255, 255, 0.18);
background: #4b0b68;
}
/* Контур поля */
.pitch-half::before {
content: "";
position: absolute;
inset: 14px;
border: 3px solid rgba(255, 255, 255, 0.6);
border-bottom: none;
pointer-events: none;
}
.pitch-half::after {
content: "";
position: absolute;
top: 14px;
left: 50%;
transform: translateX(-50%);
width: 180px;
height: 72px;
border: 3px solid rgba(255, 255, 255, 0.6);
border-top: none;
pointer-events: none;
}
/* Центральный круг нижней части */
.pitch-center-circle {
position: absolute;
left: 50%;
bottom: 64px;
transform: translateX(-50%);
width: 180px;
height: 180px;
border: 3px solid rgba(255, 255, 255, 0.22);
border-radius: 50%;
pointer-events: none;
}
.pitch-center-line {
position: absolute;
left: 14px;
right: 14px;
bottom: 154px;
height: 0;
border-top: 3px solid rgba(255, 255, 255, 0.35);
pointer-events: none;
}
/* Игрок */
.player-node {
position: absolute;
width: 150px;
margin-left: -95px;
transform: translateY(-50%);
z-index: 5;
user-select: none;
cursor: grab;
}
.player-node.dragging {
cursor: grabbing;
z-index: 20;
}
.player-photo {
width: 110px;
height: 138px;
margin: 0 auto;
display: block;
object-fit: contain;
filter: drop-shadow(0 8px 18px rgba(0, 0, 0, 0.35));
pointer-events: none;
}
.player-photo,
.player-silhouette {
width: 95px;
height: 120px;
}
.player-silhouette {
position: relative;
width: 110px;
height: 138px;
margin: 0 auto;
border-radius: 18px 18px 8px 8px;
background: linear-gradient(180deg, #f2f2f2 0%, #d9d9d9 100%);
box-shadow: 0 8px 18px rgba(0, 0, 0, 0.35);
pointer-events: none;
}
.player-silhouette::before {
content: "";
position: absolute;
top: 18px;
left: 50%;
transform: translateX(-50%);
width: 34px;
height: 34px;
border-radius: 50%;
background: #b8b8b8;
}
.player-silhouette::after {
content: "";
position: absolute;
left: 50%;
bottom: 18px;
transform: translateX(-50%);
width: 66px;
height: 62px;
border-radius: 36px 36px 12px 12px;
background: #b8b8b8;
}
.player-label {
margin: -6px auto 0;
width: 150px;
min-height: 28px;
padding: 4px 8px;
background: #97ef1f;
color: #2a2440;
font-size: 11px;
font-weight: 700;
line-height: 1.1;
text-transform: uppercase;
text-align: center;
box-shadow: 0 4px 10px rgba(0, 0, 0, 0.25);
}
.player-label-text {
display: block;
white-space: normal;
overflow: hidden;
text-overflow: unset;
}
.match-lineup-table th {
font-size: 12px;
}
.match-lineup-table td {
font-size: 13px;
}
.player-fullname {
font-size: 13px;
line-height: 1.15;
}
.player-last {
font-size: 13px;
font-weight: 600;
}
.player-first {
font-size: 12px;
opacity: 0.85;
}
.num-cell {
font-size: 12px;
}
.actions-col {
font-size: 12px;
}
.match-lineup-table th,
.match-lineup-table td {
padding: 7px 10px; /* было 10+ */
}
.row-actions .icon-btn {
width: 24px;
height: 24px;
font-size: 12px;
}
.row-actions {
display: flex;
justify-content: flex-end;
align-items: center;
flex-wrap: nowrap; /* ❗ главное */
gap: 4px;
}
.actions-col {
white-space: nowrap; /* ❗ чтобы колонка не ломалась */
}
.row-actions .icon-btn {
width: 22px;
height: 22px;
font-size: 11px;
padding: 0;
}
/* === Game tab: one beautiful player column === */
.game-lineup-table {
width: 100%;
table-layout: fixed;
}
.game-lineup-table .num-col {
width: 56px;
text-align: center;
}
.game-lineup-table .player-col {
width: auto;
}
.game-lineup-table .actions-col {
width: 150px;
text-align: right;
white-space: nowrap;
}
.game-lineup-table th,
.game-lineup-table td {
padding: 8px 10px;
vertical-align: middle;
}
.game-lineup-table td.player-cell {
min-width: 0;
}
.player-inline {
display: flex;
align-items: center;
gap: 6px;
min-width: 0;
flex-wrap: nowrap;
}
.player-inline-text {
min-width: 0;
flex: 1 1 auto;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
font-size: 13px;
line-height: 1.15;
}
.player-inline-text .last-name {
font-weight: 700;
color: var(--text);
}
.player-inline-text .first-name {
font-weight: 400;
color: var(--text);
opacity: 0.92;
}
.player-inline .badges {
display: inline-flex;
align-items: center;
gap: 4px;
flex: 0 0 auto;
white-space: nowrap;
}
.player-inline .gk-badge,
.player-inline .captain-badge {
margin-left: 0;
vertical-align: middle;
}
.game-lineup-table .row-actions {
display: flex;
justify-content: flex-end;
align-items: center;
gap: 4px;
flex-wrap: nowrap;
}
.game-lineup-table .icon-btn {
width: 22px;
height: 22px;
font-size: 11px;
padding: 0;
background: transparent;
border: none;
box-shadow: none;
}
.game-lineup-table .icon-btn:hover {
background: rgba(255, 255, 255, 0.06);
border: none;
}
.score-box {
min-width: 110px;
height: 46px;
border-radius: 12px;
display: inline-flex;
align-items: center;
justify-content: center;
background: var(--panel-2);
border: 1px solid var(--border);
font-size: 22px;
font-weight: 800;
letter-spacing: 1px;
padding: 0 14px;
}
.extra-time-wrap {
display: inline-flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
}
.extra-time-input {
width: 76px;
min-height: 38px;
padding: 0 10px;
border-radius: 10px;
border: 1px solid var(--border);
background: var(--panel-2);
color: var(--text);
font-size: 14px;
font-weight: 600;
}
.bench-pick-hint {
display: none;
margin: 6px 0 10px;
font-size: 12px;
color: var(--accent);
}
.bench-pick-active {
background: rgba(79, 140, 255, 0.12) !important;
border-radius: 6px;
}
.lineup-row-sub-in td {
background: rgba(46, 160, 67, 0.14) !important;
}
.lineup-row-sub-out td {
background: rgba(220, 53, 69, 0.14) !important;
}
.event-card {
position: relative;
}
.event-delete-btn {
position: absolute;
top: 8px;
right: 8px;
width: 18px;
height: 18px;
border: none;
border-radius: 999px;
background: rgba(255, 255, 255, 0.08);
color: var(--muted);
cursor: pointer;
font-size: 11px;
line-height: 1;
display: inline-flex;
align-items: center;
justify-content: center;
}
.event-delete-btn:hover {
background: rgba(220, 53, 69, 0.18);
color: #ffd7dc;
}
.event-title {
font-size: 13px;
font-weight: 600;
line-height: 1.2;
padding-right: 18px;
}
</style>
</head>
<body>
<div class="page">
<div class="topbar">
<div class="topbar-inner">
<div class="match-header">
<div class="team-box">
{% if session[15] %}
<img src="{{ session[15] }}" alt="{{ session[14] }}" class="team-logo">
{% endif %}
<div>
<div class="team-name">{{ session[14] }}</div>
</div>
</div>
<div class="match-meta">
<div class="match-tour">{{ session[10] or "Тур не указан" }}</div>
<div class="match-time">{{ session[9] or "Время не указано" }}</div>
<div class="match-place">{{ session[12] or "Стадион не указан" }}</div>
</div>
<div class="team-box right">
<div>
<div class="team-name">{{ session[17] }}</div>
</div>
{% if session[18] %}
<img src="{{ session[18] }}" alt="{{ session[17] }}" class="team-logo">
{% endif %}
</div>
<div class="header-actions">
<form method="post" action="/admin/session/{{ session[3] }}/load-match-data">
<button type="submit" class="btn btn-primary" style="width:100%;">
Загрузить данные по матчу
</button>
</form>
<form method="post" action="/admin/session/{{ session[3] }}/close">
<button type="submit" class="btn btn-secondary" style="width:100%;">
Закрыть сессию
</button>
</form>
</div>
</div>
</div>
</div>
<div class="content">
<div class="tabs">
<a class="tab-link {% if tab == 'game' %}active{% endif %}" href="/admin/session/{{ session[3] }}?tab=game">Игра</a>
<a class="tab-link {% if tab == 'formations' %}active{% endif %}" href="/admin/session/{{ session[3] }}?tab=formations">Расстановки команд</a>
<a class="tab-link {% if tab == 'referees' %}active{% endif %}" href="/admin/session/{{ session[3] }}?tab=referees">Судьи</a>
<a class="tab-link {% if tab == 'tour_schedule' %}active{% endif %}" href="/admin/session/{{ session[3] }}?tab=tour_schedule">Расписание тура</a>
<a class="tab-link {% if tab == 'standings' %}active{% endif %}" href="/admin/session/{{ session[3] }}?tab=standings">Турнирная таблица</a>
</div>
{% if tab == 'game' %}
<div class="game-layout">
<div class="game-main">
<div class="panel">
<div class="match-toolbar">
<div class="match-toolbar-left">
<button type="button" class="btn btn-primary" onclick="setMatchPeriod('1H')">1-й тайм</button>
<button type="button" class="btn btn-secondary" onclick="setMatchPeriod('HT')">Перерыв</button>
<button type="button" class="btn btn-primary" onclick="setMatchPeriod('2H')">2-й тайм</button>
<button type="button" class="btn btn-secondary" onclick="setMatchPeriod('FT')">Завершить</button>
</div>
<div class="match-toolbar-right">
<span class="period-badge" id="periodBadge">Матч не начат</span>
<div class="timer-box" id="matchTimer">00:00</div>
<div class="score-box" id="matchScore">0 : 0</div>
<div class="extra-time-wrap">
<input type="number" min="0" step="1" class="extra-time-input" id="extraTimeMinutes" placeholder="+мин">
<button type="button" class="btn btn-secondary" onclick="applyExtraTime()">Доп. время</button>
</div>
</div>
</div>
</div>
<div class="lineups-grid">
<div class="panel">
{% if coaches.home %}
<div class="coaches-block">
{% for c in coaches.home %}
<div class="coach-line">
<div class="coach-left">
<span class="coach-name">{{ c.coach_name }}</span>
{% if c.role %}
<span class="coach-badge">{{ c.role }}</span>
{% endif %}
</div>
<div class="coach-actions">
<button type="button" class="icon-btn yellow-card" title="Жёлтая карточка"
onclick='addCoachEvent("home", "yellow", {{ c.coach_name | tojson }})'></button>
<button type="button" class="icon-btn red-card" title="Красная карточка"
onclick='addCoachEvent("home", "red", {{ c.coach_name | tojson }})'></button>
</div>
</div>
{% endfor %}
</div>
{% endif %}
<div class="section-title">Основной состав</div>
{% if lineups.home_starting %}
<table class="game-lineup-table">
<thead>
<tr>
<th class="num-col">#</th>
<th class="player-col">Игрок</th>
<th class="actions-col">Действия</th>
</tr>
</thead>
<tbody>
{% for p in lineups.home_starting %}
<tr class="lineup-row" data-side="home" data-role="starting"
data-player-id="{{ p.player_id | default(p.number) }}"
data-player-name="{{ ((p.last_name or p.player_name or "") ~ " " ~ (p.first_name or "")) | trim }}"
data-player-last="{{ p.last_name or p.player_name or "" }}"
data-player-first="{{ p.first_name or "" }}"
data-player-number="{{ p.number or "" }}"
data-player-position="{{ p.position or "" }}"
data-player-captain="{{ 1 if p.is_captain else 0 }}">
<td class="num-cell">{{ p.number }}</td>
<td class="player-cell">
<div class="player-inline">
<div class="player-inline-text">
<span class="last-name">{{ p.last_name or p.player_name }}</span>
{% if p.first_name %}<span class="first-name"> {{ p.first_name }}</span>{% endif %}
</div>
<span class="badges">
{% if p.position and p.position|lower in ['вр.', 'вр', 'вратарь', 'gk', 'goalkeeper'] %}
<span class="gk-badge">ВР</span>
{% endif %}
{% if p.is_captain %}
<span class="captain-badge">К</span>
{% endif %}
</span>
</div>
</td>
<td class="actions-col">
<div class="row-actions">
<button type="button" class="icon-btn goal" title="Гол"
onclick='addPlayerEvent("home", "goal", {{ p.player_id | default(p.number) | tojson }}, {{ ((p.last_name or p.player_name or "") ~ " " ~ (p.first_name or "")) | trim | tojson }})'></button>
<button type="button" class="icon-btn" title="Автогол"
onclick='addPlayerEvent("home", "own_goal", {{ p.player_id | default(p.number) | tojson }}, {{ ((p.last_name or p.player_name or "") ~ " " ~ (p.first_name or "")) | trim | tojson }})'>АГ</button>
<button type="button" class="icon-btn" title="Пенальти"
onclick='addPlayerEvent("home", "penalty", {{ p.player_id | default(p.number) | tojson }}, {{ ((p.last_name or p.player_name or "") ~ " " ~ (p.first_name or "")) | trim | tojson }})'>П</button>
<button type="button" class="icon-btn sub" title="Замена"
onclick='beginSubstitution("home", {{ p.player_id | default(p.number) | tojson }}, {{ ((p.last_name or p.player_name or "") ~ " " ~ (p.first_name or "")) | trim | tojson }}, this)'></button>
<button type="button" class="icon-btn yellow-card" title="Жёлтая карточка"
onclick='addPlayerEvent("home", "yellow", {{ p.player_id | default(p.number) | tojson }}, {{ ((p.last_name or p.player_name or "") ~ " " ~ (p.first_name or "")) | trim | tojson }})'></button>
<button type="button" class="icon-btn red-card" title="Красная карточка"
onclick='addPlayerEvent("home", "red", {{ p.player_id | default(p.number) | tojson }}, {{ ((p.last_name or p.player_name or "") ~ " " ~ (p.first_name or "")) | trim | tojson }})'></button>
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<div class="empty-box">Основной состав пока не загружен.</div>
{% endif %}
<div class="section-title">Запасные</div>
<div class="bench-pick-hint" id="homeBenchHint">Выбери игрока из запасных для завершения замены</div>
{% if lineups.home_bench %}
<table class="game-lineup-table">
<thead>
<tr>
<th class="num-col">#</th>
<th class="player-col">Игрок</th>
<th class="actions-col">Действия</th>
</tr>
</thead>
<tbody>
{% for p in lineups.home_bench %}
<tr class="lineup-row" data-side="home" data-role="bench"
data-player-id="{{ p.player_id | default(p.number) }}"
data-player-name="{{ ((p.last_name or p.player_name or "") ~ " " ~ (p.first_name or "")) | trim }}"
data-player-last="{{ p.last_name or p.player_name or "" }}"
data-player-first="{{ p.first_name or "" }}"
data-player-number="{{ p.number or "" }}"
data-player-position="{{ p.position or "" }}"
data-player-captain="{{ 1 if p.is_captain else 0 }}">
<td class="num-cell">{{ p.number }}</td>
<td class="player-cell">
<div class="player-inline">
<div class="player-inline-text">
<span class="last-name">{{ p.last_name or p.player_name }}</span>
{% if p.first_name %}<span class="first-name"> {{ p.first_name }}</span>{% endif %}
</div>
<span class="badges">
{% if p.position and p.position|lower in ['вр.', 'вр', 'вратарь', 'gk', 'goalkeeper'] %}
<span class="gk-badge">ВР</span>
{% endif %}
{% if p.is_captain %}
<span class="captain-badge">К</span>
{% endif %}
</span>
</div>
</td>
<td class="actions-col">
<div class="row-actions">
<button type="button" class="icon-btn sub" title="Замена"
onclick='selectBenchPlayer("home", {{ p.player_id | default(p.number) | tojson }}, {{ ((p.last_name or p.player_name or "") ~ " " ~ (p.first_name or "")) | trim | tojson }}, this)'></button>
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<div class="empty-box">Запасные пока не загружены.</div>
{% endif %}
</div>
<div class="panel">
{% if coaches.away %}
<div class="coaches-block coaches-block-right">
{% for c in coaches.away %}
<div class="coach-line">
<div class="coach-left">
<span class="coach-name">{{ c.coach_name }}</span>
{% if c.role %}
<span class="coach-badge">{{ c.role }}</span>
{% endif %}
</div>
<div class="coach-actions">
<button type="button" class="icon-btn yellow-card" title="Жёлтая карточка"
onclick='addCoachEvent("away", "yellow", {{ c.coach_name | tojson }})'></button>
<button type="button" class="icon-btn red-card" title="Красная карточка"
onclick='addCoachEvent("away", "red", {{ c.coach_name | tojson }})'></button>
</div>
</div>
{% endfor %}
</div>
{% endif %}
<div class="section-title">Основной состав</div>
{% if lineups.away_starting %}
<table class="game-lineup-table">
<thead>
<tr>
<th class="num-col">#</th>
<th class="player-col">Игрок</th>
<th class="actions-col">Действия</th>
</tr>
</thead>
<tbody>
{% for p in lineups.away_starting %}
<tr class="lineup-row" data-side="away" data-role="starting"
data-player-id="{{ p.player_id | default(p.number) }}"
data-player-name="{{ ((p.last_name or p.player_name or "") ~ " " ~ (p.first_name or "")) | trim }}"
data-player-last="{{ p.last_name or p.player_name or "" }}"
data-player-first="{{ p.first_name or "" }}"
data-player-number="{{ p.number or "" }}"
data-player-position="{{ p.position or "" }}"
data-player-captain="{{ 1 if p.is_captain else 0 }}">
<td class="num-cell">{{ p.number }}</td>
<td class="player-cell">
<div class="player-inline">
<div class="player-inline-text">
<span class="last-name">{{ p.last_name or p.player_name }}</span>
{% if p.first_name %}<span class="first-name"> {{ p.first_name }}</span>{% endif %}
</div>
<span class="badges">
{% if p.position and p.position|lower in ['вр.', 'вр', 'вратарь', 'gk', 'goalkeeper'] %}
<span class="gk-badge">ВР</span>
{% endif %}
{% if p.is_captain %}
<span class="captain-badge">К</span>
{% endif %}
</span>
</div>
</td>
<td class="actions-col">
<div class="row-actions">
<button type="button" class="icon-btn goal" title="Гол"
onclick='addPlayerEvent("away", "goal", {{ p.player_id | default(p.number) | tojson }}, {{ ((p.last_name or p.player_name or "") ~ " " ~ (p.first_name or "")) | trim | tojson }})'></button>
<button type="button" class="icon-btn" title="Автогол"
onclick='addPlayerEvent("away", "own_goal", {{ p.player_id | default(p.number) | tojson }}, {{ ((p.last_name or p.player_name or "") ~ " " ~ (p.first_name or "")) | trim | tojson }})'>АГ</button>
<button type="button" class="icon-btn" title="Пенальти"
onclick='addPlayerEvent("away", "penalty", {{ p.player_id | default(p.number) | tojson }}, {{ ((p.last_name or p.player_name or "") ~ " " ~ (p.first_name or "")) | trim | tojson }})'>П</button>
<button type="button" class="icon-btn sub" title="Замена"
onclick='beginSubstitution("away", {{ p.player_id | default(p.number) | tojson }}, {{ ((p.last_name or p.player_name or "") ~ " " ~ (p.first_name or "")) | trim | tojson }}, this)'></button>
<button type="button" class="icon-btn yellow-card" title="Жёлтая карточка"
onclick='addPlayerEvent("away", "yellow", {{ p.player_id | default(p.number) | tojson }}, {{ ((p.last_name or p.player_name or "") ~ " " ~ (p.first_name or "")) | trim | tojson }})'></button>
<button type="button" class="icon-btn red-card" title="Красная карточка"
onclick='addPlayerEvent("away", "red", {{ p.player_id | default(p.number) | tojson }}, {{ ((p.last_name or p.player_name or "") ~ " " ~ (p.first_name or "")) | trim | tojson }})'></button>
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<div class="empty-box">Основной состав пока не загружен.</div>
{% endif %}
<div class="section-title">Запасные</div>
<div class="bench-pick-hint" id="awayBenchHint">Выбери игрока из запасных для завершения замены</div>
{% if lineups.away_bench %}
<table class="game-lineup-table">
<thead>
<tr>
<th class="num-col">#</th>
<th class="player-col">Игрок</th>
<th class="actions-col">Действия</th>
</tr>
</thead>
<tbody>
{% for p in lineups.away_bench %}
<tr class="lineup-row" data-side="away" data-role="bench"
data-player-id="{{ p.player_id | default(p.number) }}"
data-player-name="{{ ((p.last_name or p.player_name or "") ~ " " ~ (p.first_name or "")) | trim }}"
data-player-last="{{ p.last_name or p.player_name or "" }}"
data-player-first="{{ p.first_name or "" }}"
data-player-number="{{ p.number or "" }}"
data-player-position="{{ p.position or "" }}"
data-player-captain="{{ 1 if p.is_captain else 0 }}">
<td class="num-cell">{{ p.number }}</td>
<td class="player-cell">
<div class="player-inline">
<div class="player-inline-text">
<span class="last-name">{{ p.last_name or p.player_name }}</span>
{% if p.first_name %}<span class="first-name"> {{ p.first_name }}</span>{% endif %}
</div>
<span class="badges">
{% if p.position and p.position|lower in ['вр.', 'вр', 'вратарь', 'gk', 'goalkeeper'] %}
<span class="gk-badge">ВР</span>
{% endif %}
{% if p.is_captain %}
<span class="captain-badge">К</span>
{% endif %}
</span>
</div>
</td>
<td class="actions-col">
<div class="row-actions">
<button type="button" class="icon-btn sub" title="Замена"
onclick='selectBenchPlayer("away", {{ p.player_id | default(p.number) | tojson }}, {{ ((p.last_name or p.player_name or "") ~ " " ~ (p.first_name or "")) | trim | tojson }}, this)'></button>
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<div class="empty-box">Запасные пока не загружены.</div>
{% endif %}
</div>
</div>
</div>
<div class="panel events-panel">
<div class="panel-title-row">
<div>
<div class="panel-title">События матча</div>
<div class="small-meta">Слева хозяева, справа гости. Время идёт сверху вниз по общей шкале.</div>
</div>
<button type="button" class="btn btn-secondary" onclick="clearMatchEvents()">Очистить</button>
</div>
<div class="events-list" id="eventsList">
<div class="empty-box">Событий пока нет.</div>
</div>
</div>
</div>
{% elif tab == 'referees' %}
<div class="panel">
<div class="team-panel-title">Судейская бригада</div>
{% if referees %}
<table class="table-compact">
<thead>
<tr>
<th>Роль</th>
<th>ФИО</th>
</tr>
</thead>
<tbody>
{% for r in referees %}
<tr>
<td>{{ r.role or "—" }}</td>
<td class="player-name">{{ r.referee_name }}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<div class="empty-box">Судьи не найдены.</div>
{% endif %}
</div>
{% elif tab == 'tour_schedule' %}
<div class="panel">
<div class="team-panel-title">Расписание тура</div>
{% set month_names = {
1: 'января', 2: 'февраля', 3: 'марта', 4: 'апреля',
5: 'мая', 6: 'июня', 7: 'июля', 8: 'августа',
9: 'сентября', 10: 'октября', 11: 'ноября', 12: 'декабря'
} %}
{% if tour_schedule %}
<table class="table-compact tour-table">
<thead>
<tr>
<th class="tour-date-col">Дата</th>
<th class="tour-match-col">Матч</th>
<th class="tour-meta-col">Счёт / статус</th>
<th class="tour-stadium-col">Стадион</th>
</tr>
</thead>
<tbody>
{% for m in tour_schedule %}
<tr>
<td class="tour-date-cell">
{% if m.match_date %}
{{ m.match_date.day }} {{ month_names[m.match_date.month] }} {{ m.match_date.year }}
{% if m.match_date.hour != 0 or m.match_date.minute != 0 %}
{{ "%02d:%02d"|format(m.match_date.hour, m.match_date.minute) }}
{% endif %}
{% else %}
{% endif %}
</td>
<td class="tour-match-cell">
{{ m.home_team_name }} — {{ m.away_team_name }}
{% if m.match_external_id == session[8] %}
<span class="gk-badge">ТЕКУЩИЙ</span>
{% endif %}
</td>
<td class="tour-meta-cell">
<div class="tour-meta-wrap">
{% if m.status == 'finished' and m.home_score is not none and m.away_score is not
none %}
<span class="score-badge">{{ m.home_score }} : {{ m.away_score }}</span>
{% endif %}
{% if m.status == 'finished' %}
<span class="status-badge status-finished">Завершён</span>
{% elif m.status == 'live' %}
<span class="status-badge status-live">Идёт</span>
{% elif m.status == 'scheduled' %}
<span class="status-badge status-scheduled">Не начался</span>
{% else %}
<span class="status-badge status-scheduled">{{ m.status or "—" }}</span>
{% endif %}
</div>
</td>
<td class="stadium-cell">
{{ m.stadium_name or "—" }}
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<div class="empty-box">Расписание тура не найдено.</div>
{% endif %}
</div>
{% elif tab == 'standings' %}
<div class="panel">
<div class="team-panel-title">Турнирная таблица</div>
{% if standings %}
<table class="table-compact standings-table">
<thead>
<tr>
<th class="st-pos-col">#</th>
<th class="st-logo-col"></th>
<th class="st-team-col">Команда</th>
<th class="st-stat-col">И</th>
<th class="st-stat-col">В</th>
<th class="st-stat-col">Н</th>
<th class="st-stat-col">П</th>
<th class="st-gfga-col">ЗМ</th>
<th class="st-gfga-col">ПМ</th>
<th class="st-points-col">О</th>
</tr>
</thead>
<tbody>
{% for row in standings %}
<tr class="
{% if row.team_name == session[14] %}
standings-row-home
{% elif row.team_name == session[17] %}
standings-row-away
{% endif %}
">
<td class="num-cell">{{ row.position }}</td>
<td class="num-cell">
{% if row.team_logo %}
<img src="{{ row.team_logo }}" alt="{{ row.team_name }}" class="standings-logo">
{% endif %}
</td>
<td class="player-name">
{{ row.team_name }}
</td>
<td class="num-cell">{{ row.played }}</td>
<td class="num-cell">{{ row.wins }}</td>
<td class="num-cell">{{ row.draws }}</td>
<td class="num-cell">{{ row.losses }}</td>
<td class="num-cell">{{ row.goals_for }}</td>
<td class="num-cell">{{ row.goals_against }}</td>
<td class="num-cell"><strong>{{ row.points }}</strong></td>
</tr>
{% endfor %}
</tbody>
</table>
{% else %}
<div class="empty-box">Турнирная таблица пока не загружена.</div>
{% endif %}
</div>
{% elif tab == 'formations' %}
<div class="lineups-grid">
<div class="panel">
<div class="team-panel-title">Расстановка — {{ session[14] }}</div>
<div class="formation-toolbar">
<select id="home-preset" class="formation-select">
<option value="4-4-2">4-4-2</option>
<option value="4-3-3">4-3-3</option>
<option value="4-2-3-1">4-2-3-1</option>
<option value="3-5-2">3-5-2</option>
<option value="5-3-2">5-3-2</option>
</select>
<button class="btn btn-secondary" onclick="applyPreset('home')">Применить расстановку</button>
<button class="btn btn-primary" onclick="saveFormation('home')">Сохранить</button>
</div>
<div class="pitch-half" id="pitch-home">
<div class="pitch-center-line"></div>
<div class="pitch-center-circle"></div>
</div>
</div>
<div class="panel">
<div class="team-panel-title">Расстановка — {{ session[17] }}</div>
<div class="formation-toolbar">
<select id="away-preset" class="formation-select">
<option value="4-4-2">4-4-2</option>
<option value="4-3-3">4-3-3</option>
<option value="4-2-3-1">4-2-3-1</option>
<option value="3-5-2">3-5-2</option>
<option value="5-3-2">5-3-2</option>
</select>
<button class="btn btn-secondary" onclick="applyPreset('away')">Применить расстановку</button>
<button class="btn btn-primary" onclick="saveFormation('away')">Сохранить</button>
</div>
<div class="pitch-half" id="pitch-away">
<div class="pitch-center-line"></div>
<div class="pitch-center-circle"></div>
</div>
</div>
</div>
{% else %}
<div class="panel">
<div class="empty-box">Эта вкладка пока в разработке.</div>
</div>
{% endif %}
</div>
</div>
<!-- prettier-ignore -->
<script>
const TEAM_NAMES = {
home: {{ session[14] | tojson }},
away: {{ session[17] | tojson }}
};
const SESSION_TOKEN = {{ session[3] | tojson }};
let currentPeriod = null;
let matchClockSeconds = 0;
let timerId = null;
let eventSeq = 1;
let matchEvents = [];
let scoreState = { home: 0, away: 0 };
let activeSubstitution = null;
let extraTimeMinutes = { first: 0, second: 0 };
let originalLineupState = null;
const STORAGE_KEY = `match_admin_state_${window.location.pathname}`;
function escapeHtml(value) {
return String(value ?? "")
.replaceAll("&", "&amp;")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#039;");
}
function formatClock(totalSeconds) {
const minutes = String(Math.floor(totalSeconds / 60)).padStart(2, "0");
const seconds = String(totalSeconds % 60).padStart(2, "0");
return `${minutes}:${seconds}`;
}
function getFootballMinute(totalSeconds) {
return Math.floor(totalSeconds / 60) + 1;
}
function updateTimerUI() {
const timer = document.getElementById("matchTimer");
if (timer) {
timer.textContent = formatClock(matchClockSeconds);
}
}
function updateScoreUI() {
const score = document.getElementById("matchScore");
if (score) {
score.textContent = `${scoreState.home} : ${scoreState.away}`;
}
}
function updatePeriodBadge(text) {
const badge = document.getElementById("periodBadge");
if (badge) {
badge.textContent = text;
}
}
function stopTimer() {
if (timerId) {
clearInterval(timerId);
timerId = null;
}
saveMatchState();
}
function startTimer() {
stopTimer();
timerId = setInterval(() => {
matchClockSeconds += 1;
updateTimerUI();
saveMatchState();
}, 1000);
saveMatchState();
}
function getPersistedState() {
try {
const raw = localStorage.getItem(STORAGE_KEY);
return raw ? JSON.parse(raw) : null;
} catch (e) {
return null;
}
}
async function saveEventToServer(eventData) {
if (!SESSION_TOKEN) return null;
try {
const res = await fetch(`/admin/session/${SESSION_TOKEN}/event`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(eventData)
});
if (!res.ok) return null;
return await res.json();
} catch (e) {
console.warn("Не удалось сохранить событие в БД", e);
return null;
}
}
async function pushEvent(side, type, personName, meta = "", playerId = null) {
let eventSide = side;
if (type === "own_goal") {
eventSide = getOppositeSide(side);
}
if (type === "goal" || type === "penalty" || type === "own_goal") {
scoreState[eventSide] += 1;
updateScoreUI();
}
const minuteLabel = formatMinuteLabel(matchClockSeconds);
const numericMinute =
parseInt(String(minuteLabel).split("+").pop().replace("'", ""), 10) ||
getFootballMinute(matchClockSeconds);
const item = {
id: null,
side: eventSide,
type,
icon: getEventIcon(type),
title: getEventTitle(type, personName),
teamName: TEAM_NAMES[eventSide] || "Команда",
minute: minuteLabel,
seconds: matchClockSeconds,
meta,
player_name: personName
};
matchEvents.push(item);
matchEvents.sort((a, b) => {
if (a.seconds !== b.seconds) return a.seconds - b.seconds;
return (a.id || 0) - (b.id || 0);
});
renderEvents();
saveMatchState();
const saved = await saveEventToServer({
side: eventSide,
type,
player_name: personName,
player_id: playerId,
minute: numericMinute,
seconds: matchClockSeconds,
meta
});
if (saved && saved.id) {
item.id = saved.id;
renderEvents();
saveMatchState();
}
}
async function loadEventsFromServer() {
if (!SESSION_TOKEN) return;
try {
const res = await fetch(`/admin/session/${SESSION_TOKEN}/events`);
if (!res.ok) return;
const data = await res.json();
if (!Array.isArray(data)) return;
matchEvents = data.map((event) => ({
...event,
icon: getEventIcon(event.type),
title: getEventTitle(event.type, event.player_name),
minute: typeof event.minute === "string" && event.minute.includes("'")
? event.minute
: `${event.minute || 1}'`
}));
scoreState = { home: 0, away: 0 };
matchEvents.forEach((event) => {
if (["goal", "penalty", "own_goal"].includes(event.type)) {
scoreState[event.side] = (scoreState[event.side] || 0) + 1;
}
});
updateScoreUI();
renderEvents();
} catch (e) {
console.warn("Не удалось загрузить события из БД", e);
}
}
async function deleteEvent(eventId) {
if (!eventId) return;
try {
const res = await fetch(`/admin/session/${SESSION_TOKEN}/event/${eventId}`, {
method: "DELETE"
});
if (!res.ok) return;
} catch (e) {
console.warn("Не удалось удалить событие", e);
return;
}
matchEvents = matchEvents.filter((event) => Number(event.id) !== Number(eventId));
scoreState = { home: 0, away: 0 };
matchEvents.forEach((event) => {
if (["goal", "penalty", "own_goal"].includes(event.type)) {
scoreState[event.side] = (scoreState[event.side] || 0) + 1;
}
});
updateScoreUI();
renderEvents();
saveMatchState();
}
async function clearEventsOnServer() {
if (!SESSION_TOKEN) return;
try {
await fetch(`/admin/session/${SESSION_TOKEN}/events`, {
method: "DELETE"
});
} catch (e) {
console.warn("Не удалось очистить события в БД", e);
}
}
function saveMatchState() {
try {
const payload = {
currentPeriod,
matchClockSeconds,
timerRunning: !!timerId,
savedAt: Date.now(),
eventSeq,
matchEvents,
scoreState,
extraTimeMinutes,
originalLineupState
};
localStorage.setItem(STORAGE_KEY, JSON.stringify(payload));
} catch (e) {
console.warn("Не удалось сохранить состояние матча", e);
}
}
function restoreMatchState() {
const state = getPersistedState();
if (!state) return;
currentPeriod = state.currentPeriod ?? null;
matchClockSeconds = Number(state.matchClockSeconds || 0);
eventSeq = Number(state.eventSeq || 1);
matchEvents = Array.isArray(state.matchEvents) ? state.matchEvents : [];
scoreState = state.scoreState || { home: 0, away: 0 };
extraTimeMinutes = state.extraTimeMinutes || { first: 0, second: 0 };
originalLineupState = state.originalLineupState || originalLineupState;
if (state.timerRunning && state.savedAt) {
const elapsed = Math.max(0, Math.floor((Date.now() - Number(state.savedAt)) / 1000));
matchClockSeconds += elapsed;
}
if (currentPeriod === "1H") {
updatePeriodBadge(extraTimeMinutes.first ? `1-й тайм +${extraTimeMinutes.first}` : "1-й тайм");
} else if (currentPeriod === "HT") {
updatePeriodBadge(extraTimeMinutes.first ? `Перерыв · +${extraTimeMinutes.first}` : "Перерыв");
} else if (currentPeriod === "2H") {
updatePeriodBadge(extraTimeMinutes.second ? `2-й тайм +${extraTimeMinutes.second}` : "2-й тайм");
} else if (currentPeriod === "FT") {
updatePeriodBadge("Матч завершён");
saveMatchState();
}
updateTimerUI();
updateScoreUI();
renderEvents();
if (state.timerRunning && (currentPeriod === "1H" || currentPeriod === "2H")) {
startTimer();
}
if (!originalLineupState) {
originalLineupState = captureCurrentLineupState();
}
}
function setMatchPeriod(period) {
currentPeriod = period;
if (period === "1H") {
matchClockSeconds = 0;
updateTimerUI();
updatePeriodBadge(extraTimeMinutes.first ? `1-й тайм +${extraTimeMinutes.first}` : "1-й тайм");
startTimer();
saveMatchState();
return;
}
if (period === "HT") {
stopTimer();
if (matchClockSeconds < 45 * 60) {
matchClockSeconds = 45 * 60;
updateTimerUI();
}
updatePeriodBadge(extraTimeMinutes.first ? `Перерыв · +${extraTimeMinutes.first}` : "Перерыв");
saveMatchState();
return;
}
if (period === "2H") {
if (matchClockSeconds < 45 * 60) {
matchClockSeconds = 45 * 60;
}
updateTimerUI();
updatePeriodBadge(extraTimeMinutes.second ? `2-й тайм +${extraTimeMinutes.second}` : "2-й тайм");
startTimer();
saveMatchState();
return;
}
if (period === "FT") {
stopTimer();
updatePeriodBadge("Матч завершён");
saveMatchState();
}
}
function applyExtraTime() {
const input = document.getElementById("extraTimeMinutes");
const value = Math.max(0, Number(input ? input.value : 0));
if (currentPeriod === "1H" || currentPeriod === "HT") {
extraTimeMinutes.first = value;
updatePeriodBadge(value ? `1-й тайм +${value}` : "1-й тайм");
saveMatchState();
return;
}
if (currentPeriod === "2H" || currentPeriod === "FT") {
extraTimeMinutes.second = value;
updatePeriodBadge(value ? `2-й тайм +${value}` : "2-й тайм");
saveMatchState();
}
}
function getOppositeSide(side) {
return side === "home" ? "away" : "home";
}
function getEventIcon(type) {
const map = {
goal: "⚽",
own_goal: "АГ",
penalty: "П",
sub: "⇄",
yellow: "🟨",
red: "🟥",
coach_yellow: "🟨",
coach_red: "🟥"
};
return map[type] || "•";
}
function getEventTitle(type, name) {
const map = {
goal: "Гол",
own_goal: "Автогол",
penalty: "Пенальти",
sub: "Замена",
yellow: "Жёлтая карточка",
red: "Красная карточка",
coach_yellow: "Жёлтая карточка тренеру",
coach_red: "Красная карточка тренеру"
};
return `${map[type] || "Событие"}${name ? " — " + name : ""}`;
}
function formatMinuteLabel(totalSeconds) {
const minute = getFootballMinute(totalSeconds);
if ((currentPeriod === "1H" || currentPeriod === "HT") && minute > 45 && extraTimeMinutes.first > 0) {
return `45+${Math.min(minute - 45, extraTimeMinutes.first)}'`;
}
if ((currentPeriod === "2H" || currentPeriod === "FT") && minute > 90 && extraTimeMinutes.second > 0) {
return `90+${Math.min(minute - 90, extraTimeMinutes.second)}'`;
}
return `${minute}'`;
}
function setBenchHint(side, visible) {
const hint = document.getElementById(side === "home" ? "homeBenchHint" : "awayBenchHint");
if (hint) hint.style.display = visible ? "block" : "none";
}
function clearBenchSelection() {
document.querySelectorAll(".bench-pick-active").forEach(el => el.classList.remove("bench-pick-active"));
setBenchHint("home", false);
setBenchHint("away", false);
}
function beginSubstitution(side, playerId, playerName, btn) {
const row = btn ? btn.closest("tr.lineup-row") : null;
activeSubstitution = {
side,
outPlayerId: playerId,
outPlayerName: playerName,
outRow: row
};
clearBenchSelection();
setBenchHint(side, true);
}
async function selectBenchPlayer(side, playerId, playerName, btn) {
if (!activeSubstitution || activeSubstitution.side !== side) return;
clearBenchSelection();
if (btn) btn.classList.add("bench-pick-active");
const outName = activeSubstitution.outPlayerName;
const inName = playerName;
const benchRow = btn ? btn.closest("tr.lineup-row") : null;
const outRow = activeSubstitution.outRow || null;
if (outRow && benchRow) {
swapLineupPlayers(outRow, benchRow);
}
const minuteLabel = formatMinuteLabel(matchClockSeconds);
const numericMinute =
parseInt(String(minuteLabel).split("+").pop().replace("'", ""), 10) ||
getFootballMinute(matchClockSeconds);
const item = {
id: null,
side,
type: "sub",
icon: getEventIcon("sub"),
title: `${outName}${inName}`,
minute: minuteLabel,
seconds: matchClockSeconds,
player_name: `${outName}${inName}`
};
matchEvents.push(item);
renderEvents();
saveMatchState();
const saved = await saveEventToServer({
side,
type: "sub",
player_name: `${outName}${inName}`,
player_out_id: activeSubstitution.outPlayerId,
player_in_id: playerId,
minute: numericMinute,
seconds: matchClockSeconds,
meta: null
});
if (saved && saved.id) {
item.id = saved.id;
renderEvents();
saveMatchState();
}
activeSubstitution = null;
clearBenchSelection();
}
async function pushEvent(side, type, personName, meta = "", playerId = null) {
let eventSide = side;
if (type === "own_goal") {
eventSide = getOppositeSide(side);
}
if (type === "goal" || type === "penalty" || type === "own_goal") {
scoreState[eventSide] += 1;
updateScoreUI();
}
const minuteLabel = formatMinuteLabel(matchClockSeconds);
const numericMinute = parseInt(String(minuteLabel).split("+").pop().replace("'", ""), 10) || getFootballMinute(matchClockSeconds);
const item = {
id: null,
side: eventSide,
type,
icon: getEventIcon(type),
title: getEventTitle(type, personName),
teamName: TEAM_NAMES[eventSide] || "Команда",
minute: minuteLabel,
seconds: matchClockSeconds,
meta,
player_name: personName
};
matchEvents.push(item);
matchEvents.sort((a, b) => {
if (a.seconds !== b.seconds) return a.seconds - b.seconds;
return (a.id || 0) - (b.id || 0);
});
renderEvents();
saveMatchState();
const saved = await saveEventToServer({
side: eventSide,
type,
player_name: personName,
player_id: playerId, // 🔥 ВОТ ЭТО
minute: numericMinute,
seconds: matchClockSeconds,
meta
});
if (saved && saved.id) {
item.id = saved.id;
renderEvents();
saveMatchState();
}
}
function addPlayerEvent(side, type, playerId, playerName) {
pushEvent(side, type, playerName, `Игрок #${playerId ?? ""}`, playerId);
}
function addSubEvent(side, playerId, playerName, isBench) {
if (isBench) {
selectBenchPlayer(side, playerId, playerName, null);
} else {
beginSubstitution(side, playerId, playerName);
}
}
function addCoachEvent(side, cardType, coachName) {
const type = cardType === "yellow" ? "coach_yellow" : "coach_red";
pushEvent(side, type, coachName, "Тренер", null);
}
function renderEvents() {
const list = document.getElementById("eventsList");
if (!list) return;
if (!matchEvents.length) {
list.innerHTML = '<div class="empty-box">Событий пока нет.</div>';
return;
}
list.innerHTML = matchEvents.map((event) => {
const compactTitle = String(event.title || "").includes(" — ")
? String(event.title).split(" — ").slice(1).join(" — ")
: String(event.title || "");
const deleteHtml = event.id
? `<button type="button" class="event-delete-btn" title="Удалить событие" onclick="deleteEvent(${Number(event.id)})">×</button>`
: "";
const cardHtml = `
<div class="event-card ${event.side}">
${deleteHtml}
<div class="event-badge">${escapeHtml(event.icon)}</div>
<div class="event-title">${escapeHtml(compactTitle)}</div>
</div>
`;
return `
<div class="event-row">
<div class="event-side">${event.side === "home" ? cardHtml : ""}</div>
<div class="event-time">${escapeHtml(event.minute)}</div>
<div class="event-side">${event.side === "away" ? cardHtml : ""}</div>
</div>
`;
}).join("");
}
async function clearMatchEvents() {
matchEvents = [];
scoreState = { home: 0, away: 0 };
eventSeq = 1;
activeSubstitution = null;
clearBenchSelection();
restoreOriginalLineupRows();
updateScoreUI();
renderEvents();
saveMatchState();
await clearEventsOnServer();
}
restoreMatchState();
updateTimerUI();
updateScoreUI();
renderEvents();
function escapeJsString(value) {
return String(value ?? "")
.replaceAll("\\", "\\\\")
.replaceAll("'", "\\'");
}
function isGoalkeeperPosition(position) {
const pos = String(position || "").trim().toLowerCase();
return ["вр.", "вр", "вратарь", "gk", "goalkeeper"].includes(pos);
}
function getRowPlayer(row) {
return {
id: row.dataset.playerId || "",
name: row.dataset.playerName || "",
lastName: row.dataset.playerLast || "",
firstName: row.dataset.playerFirst || "",
number: row.dataset.playerNumber || "",
position: row.dataset.playerPosition || "",
isCaptain: row.dataset.playerCaptain === "1"
};
}
function setRowPlayer(row, player) {
row.dataset.playerId = player.id ?? "";
row.dataset.playerName = player.name ?? "";
row.dataset.playerLast = player.lastName ?? "";
row.dataset.playerFirst = player.firstName ?? "";
row.dataset.playerNumber = player.number ?? "";
row.dataset.playerPosition = player.position ?? "";
row.dataset.playerCaptain = player.isCaptain ? "1" : "0";
}
function renderLineupRow(row) {
const side = row.dataset.side;
const role = row.dataset.role;
const player = getRowPlayer(row);
const fullName = (player.name || `${player.lastName || ""} ${player.firstName || ""}`).trim();
const gkBadge = isGoalkeeperPosition(player.position) ? '<span class="gk-badge">ВР</span>' : '';
const captainBadge = player.isCaptain ? '<span class="captain-badge">К</span>' : '';
const firstNameHtml = player.firstName ? `<span class="first-name"> ${escapeHtml(player.firstName)}</span>` : '';
const idJs = JSON.stringify(player.id || player.number || "");
const nameJs = JSON.stringify(fullName);
let actionsHtml = '';
if (role === "starting") {
actionsHtml = `
<div class="row-actions">
<button type="button" class="icon-btn goal" title="Гол"
onclick='addPlayerEvent("${side}", "goal", ${idJs}, ${nameJs})'>⚽</button>
<button type="button" class="icon-btn" title="Автогол"
onclick='addPlayerEvent("${side}", "own_goal", ${idJs}, ${nameJs})'>АГ</button>
<button type="button" class="icon-btn" title="Пенальти"
onclick='addPlayerEvent("${side}", "penalty", ${idJs}, ${nameJs})'>П</button>
<button type="button" class="icon-btn sub" title="Замена"
onclick='beginSubstitution("${side}", ${idJs}, ${nameJs}, this)'>⇄</button>
<button type="button" class="icon-btn yellow-card" title="Жёлтая карточка"
onclick='addPlayerEvent("${side}", "yellow", ${idJs}, ${nameJs})'></button>
<button type="button" class="icon-btn red-card" title="Красная карточка"
onclick='addPlayerEvent("${side}", "red", ${idJs}, ${nameJs})'></button>
</div>
`;
} else {
actionsHtml = `
<div class="row-actions">
<button type="button" class="icon-btn sub" title="Замена"
onclick='selectBenchPlayer("${side}", ${idJs}, ${nameJs}, this)'>⇄</button>
</div>
`;
}
row.innerHTML = `
<td class="num-cell">${escapeHtml(player.number)}</td>
<td class="player-cell">
<div class="player-inline">
<div class="player-inline-text">
<span class="last-name">${escapeHtml(player.lastName || player.name)}</span>${firstNameHtml}
</div>
<span class="badges">${gkBadge}${captainBadge}</span>
</div>
</td>
<td class="actions-col">${actionsHtml}</td>
`;
}
function flashSubstitutionRows(inRow, outRow) {
document.querySelectorAll(".lineup-row-sub-in, .lineup-row-sub-out").forEach(el => {
el.classList.remove("lineup-row-sub-in", "lineup-row-sub-out");
});
if (inRow) inRow.classList.add("lineup-row-sub-in");
if (outRow) outRow.classList.add("lineup-row-sub-out");
}
function swapLineupPlayers(startingRow, benchRow) {
const startingPlayer = getRowPlayer(startingRow);
const benchPlayer = getRowPlayer(benchRow);
setRowPlayer(startingRow, benchPlayer);
setRowPlayer(benchRow, startingPlayer);
renderLineupRow(startingRow);
renderLineupRow(benchRow);
sortLineupRows(startingRow.dataset.side, "starting");
sortLineupRows(startingRow.dataset.side, "bench");
flashSubstitutionRows(startingRow, benchRow);
saveMatchState();
}
function sortLineupRows(side, role) {
const rows = Array.from(document.querySelectorAll(
`tr.lineup-row[data-side="${side}"][data-role="${role}"]`
));
const tbody = rows[0]?.closest("tbody");
if (!tbody) return;
rows.sort((a, b) => {
const pa = getRowPlayer(a);
const pb = getRowPlayer(b);
const gkA = isGoalkeeperPosition(pa.position) ? 1 : 0;
const gkB = isGoalkeeperPosition(pb.position) ? 1 : 0;
if (gkA !== gkB) return gkB - gkA;
const numA = parseInt(pa.number, 10);
const numB = parseInt(pb.number, 10);
const safeA = Number.isFinite(numA) ? numA : 999;
const safeB = Number.isFinite(numB) ? numB : 999;
return safeA - safeB;
});
rows.forEach(row => tbody.appendChild(row));
}
function captureCurrentLineupState() {
const rows = Array.from(document.querySelectorAll("tr.lineup-row"));
return rows.map((row, index) => ({
index,
side: row.dataset.side || "",
role: row.dataset.role || "",
player: getRowPlayer(row)
}));
}
function restoreOriginalLineupRows() {
if (!Array.isArray(originalLineupState) || !originalLineupState.length) return;
const rows = Array.from(document.querySelectorAll("tr.lineup-row"));
originalLineupState.forEach((item) => {
const row = rows[item.index];
if (!row) return;
setRowPlayer(row, item.player);
renderLineupRow(row);
row.classList.remove("lineup-row-sub-in", "lineup-row-sub-out", "bench-pick-active");
});
sortLineupRows("home", "starting");
sortLineupRows("home", "bench");
sortLineupRows("away", "starting");
sortLineupRows("away", "bench");
}
const formationState = {
home: {{ home_formations | tojson }},
away: {{ away_formations | tojson }},
};
const fallbackLineups = {
home: {{ lineups.home_starting | tojson }},
away: {{ lineups.away_starting | tojson }},
};
function ensureInitialState(side) {
if (!formationState[side] || formationState[side].length === 0) {
formationState[side] = fallbackLineups[side].slice(0, 11).map((p, idx) => ({
player_id: p.player_id || null,
player_name: p.player_name || "",
last_name: p.last_name || "",
number: p.number || "",
position: p.position || "",
is_captain: !!p.is_captain,
photo: p.photo || "",
x: 50,
y: 12 + idx * 6,
}));
}
}
function extractLastName(player) {
return String(player.last_name || "").trim().toUpperCase();
}
function buildPlayerSuffix(player) {
const pos = String(player.position || "").trim().toLowerCase();
const isGoalkeeper = ["вр.", "вр", "вратарь", "gk", "goalkeeper"].includes(pos);
const isCaptain = !!player.is_captain;
if (isGoalkeeper && isCaptain) return " (ВР, К)";
if (isGoalkeeper) return " (ВР)";
if (isCaptain) return " (К)";
return "";
}
function buildPlayerLabel(player) {
const number = player.number || "";
const lastName = extractLastName(player);
return `${number} ${lastName}${buildPlayerSuffix(player)}`.trim();
}
function createPlayerNode(player, side) {
const el = document.createElement("div");
el.className = "player-node";
el.dataset.playerId = player.player_id ?? "";
el.dataset.side = side;
el.style.left = `${player.x}%`;
el.style.top = `${player.y}%`;
const visual = player.photo
? `<img src="${player.photo}" alt="${player.player_name || ""}" class="player-photo">`
: `<div class="player-silhouette"></div>`;
el.innerHTML = `
${visual}
<div class="player-label">
<span class="player-label-text">${buildPlayerLabel(player)}</span>
</div>
`;
makeDraggable(el);
return el;
}
function renderFormation(side) {
ensureInitialState(side);
const pitch = document.getElementById(`pitch-${side}`);
if (!pitch) return;
const overlays = pitch.querySelectorAll(".player-node");
overlays.forEach(node => node.remove());
formationState[side].forEach(player => {
pitch.appendChild(createPlayerNode(player, side));
});
}
function makeDraggable(el) {
let dragging = false;
el.addEventListener("mousedown", (e) => {
dragging = true;
el.classList.add("dragging");
e.preventDefault();
});
document.addEventListener("mouseup", () => {
if (dragging) {
dragging = false;
el.classList.remove("dragging");
}
});
document.addEventListener("mousemove", (e) => {
if (!dragging) return;
const pitch = el.parentElement;
if (!pitch) return;
const rect = pitch.getBoundingClientRect();
let x = ((e.clientX - rect.left) / rect.width) * 100;
let y = ((e.clientY - rect.top) / rect.height) * 100;
x = Math.max(8, Math.min(92, x));
y = Math.max(8, Math.min(92, y));
el.style.left = `${x}%`;
el.style.top = `${y}%`;
const side = el.dataset.side;
const playerId = el.dataset.playerId;
const item = formationState[side].find(
p => String(p.player_id ?? "") === String(playerId)
);
if (item) {
item.x = +x.toFixed(2);
item.y = +y.toFixed(2);
}
});
}
async function applyPreset(side) {
const preset = document.getElementById(`${side}-preset`).value;
const response = await fetch(`${window.location.pathname}/formations/apply`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ side, preset })
});
const data = await response.json();
formationState[side] = data.players || [];
renderFormation(side);
}
async function saveFormation(side) {
const response = await fetch(`${window.location.pathname}/formations/save`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
side,
players: formationState[side]
})
});
const data = await response.json();
if (data.success) {
alert("Расстановка сохранена");
} else {
alert("Ошибка сохранения");
}
}
window.addEventListener("beforeunload", saveMatchState);
renderFormation("home");
renderFormation("away");
sortLineupRows("home", "starting");
sortLineupRows("home", "bench");
sortLineupRows("away", "starting");
sortLineupRows("away", "bench");
if (!originalLineupState) {
originalLineupState = captureCurrentLineupState();
saveMatchState();
}
loadEventsFromServer();
</script>
</body>
</html>