static html version
This commit is contained in:
parent
193aebe369
commit
c6bb22ff3f
@ -1,6 +0,0 @@
|
|||||||
VITE_FIREBASE_API_KEY=your_api_key
|
|
||||||
VITE_FIREBASE_AUTH_DOMAIN=your_project.firebaseapp.com
|
|
||||||
VITE_FIREBASE_PROJECT_ID=your_project_id
|
|
||||||
VITE_FIREBASE_STORAGE_BUCKET=your_project.appspot.com
|
|
||||||
VITE_FIREBASE_MESSAGING_SENDER_ID=your_sender_id
|
|
||||||
VITE_FIREBASE_APP_ID=your_app_id
|
|
||||||
@ -1 +1 @@
|
|||||||
799
|
1444
|
||||||
|
|||||||
646
index.html
646
index.html
@ -1,13 +1,639 @@
|
|||||||
<!doctype html>
|
<!DOCTYPE html>
|
||||||
<html lang="ru">
|
<html lang="ru">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8">
|
||||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
|
||||||
<title>Адаптационная программа стажёров</title>
|
<title>Адаптационная программа стажёров</title>
|
||||||
</head>
|
<script src="https://cdn.tailwindcss.com"></script>
|
||||||
<body>
|
<script>
|
||||||
<div id="root"></div>
|
tailwind.config = {
|
||||||
<script type="module" src="/src/main.jsx"></script>
|
theme: {
|
||||||
</body>
|
extend: {
|
||||||
|
colors: {
|
||||||
|
primary: {
|
||||||
|
50: '#e6f0ff', 100: '#cce0ff', 200: '#99c2ff', 300: '#66a3ff',
|
||||||
|
400: '#3385ff', 500: '#0066ff', 600: '#0052cc', 700: '#003d99',
|
||||||
|
800: '#002966', 900: '#001433'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
<style>
|
||||||
|
.btn-primary { @apply bg-primary-600 hover:bg-primary-700 text-white font-medium py-2 px-4 rounded-lg transition-colors disabled:opacity-50 disabled:cursor-not-allowed; }
|
||||||
|
.btn-secondary { @apply bg-gray-200 hover:bg-gray-300 text-gray-800 font-medium py-2 px-4 rounded-lg transition-colors; }
|
||||||
|
.input-field { @apply w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500 outline-none; }
|
||||||
|
.card { @apply bg-white rounded-xl shadow-md p-6; }
|
||||||
|
.table-container { @apply overflow-x-auto rounded-lg border border-gray-200; }
|
||||||
|
.table { @apply min-w-full divide-y divide-gray-200; }
|
||||||
|
.table th { @apply px-6 py-3 bg-gray-50 text-left text-xs font-medium text-gray-500 uppercase; }
|
||||||
|
.table td { @apply px-6 py-4 whitespace-nowrap text-sm text-gray-900; }
|
||||||
|
.table tr:hover { @apply bg-gray-50; }
|
||||||
|
.nav-link { @apply px-3 py-2 rounded-md text-sm font-medium hover:bg-primary-600; }
|
||||||
|
.nav-link.active { @apply bg-primary-800; }
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body class="min-h-screen bg-gray-50">
|
||||||
|
<header class="bg-primary-700 text-white shadow-lg">
|
||||||
|
<div class="max-w-7xl mx-auto px-4 py-4 flex justify-between items-center">
|
||||||
|
<a href="#" onclick="showPage('home'); return false;" class="text-xl font-bold">📚 Адаптация стажёров</a>
|
||||||
|
<nav class="flex items-center gap-4">
|
||||||
|
<a href="#" onclick="showPage('home'); return false;" class="nav-link" id="nav-home">Главная</a>
|
||||||
|
<a href="#" onclick="showPage('trainer'); return false;" class="nav-link" id="nav-trainer">Кабинет тренера</a>
|
||||||
|
<a href="#" onclick="showPage('progress'); return false;" class="nav-link" id="nav-progress">Прогресс</a>
|
||||||
|
<a href="#" onclick="showPage('exam'); return false;" class="nav-link" id="nav-exam">Экзамен</a>
|
||||||
|
<a href="#" onclick="showPage('whatsapp'); return false;" class="nav-link" id="nav-whatsapp">WhatsApp</a>
|
||||||
|
<span id="userInfo" class="text-sm text-primary-200 hidden"></span>
|
||||||
|
<button id="logoutBtn" onclick="logout()" class="text-sm bg-primary-800 hover:bg-primary-900 px-3 py-2 rounded hidden">Выйти</button>
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main class="max-w-7xl mx-auto px-4 py-8">
|
||||||
|
<!-- HOME PAGE -->
|
||||||
|
<div id="homePage" class="page">
|
||||||
|
<div class="bg-gradient-to-r from-primary-600 to-primary-800 text-white py-12 rounded-xl mb-8 text-center">
|
||||||
|
<h1 id="programTitle" class="text-4xl font-bold mb-2">Адаптационная программа стажёров</h1>
|
||||||
|
<p id="programDesc" class="text-xl text-primary-100">Программа профессиональной адаптации</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card mb-8">
|
||||||
|
<h3 class="text-lg font-semibold mb-4">Путь стажёра</h3>
|
||||||
|
<div class="flex flex-wrap justify-center gap-4">
|
||||||
|
<div class="flex flex-col items-center p-4 bg-primary-100 text-primary-700 rounded-lg">
|
||||||
|
<span class="text-3xl mb-2">📝</span><span class="text-sm">Анкета</span>
|
||||||
|
</div>
|
||||||
|
<div class="w-12 h-1 bg-gray-300 self-center"></div>
|
||||||
|
<div class="flex flex-col items-center p-4 bg-gray-100 text-gray-400 rounded-lg" id="stage2">
|
||||||
|
<span class="text-3xl mb-2">👥</span><span class="text-sm">Группы</span>
|
||||||
|
</div>
|
||||||
|
<div class="w-12 h-1 bg-gray-300 self-center"></div>
|
||||||
|
<div class="flex flex-col items-center p-4 bg-gray-100 text-gray-400 rounded-lg" id="stage3">
|
||||||
|
<span class="text-3xl mb-2">📚</span><span class="text-sm">Обучение</span>
|
||||||
|
</div>
|
||||||
|
<div class="w-12 h-1 bg-gray-300 self-center"></div>
|
||||||
|
<div class="flex flex-col items-center p-4 bg-gray-100 text-gray-400 rounded-lg" id="stage4">
|
||||||
|
<span class="text-3xl mb-2">✅</span><span class="text-sm">Экзамен</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card mb-8">
|
||||||
|
<div class="flex flex-col md:flex-row justify-between items-center gap-6">
|
||||||
|
<div>
|
||||||
|
<h3 id="statusText" class="text-2xl font-bold text-gray-900 mb-2">🟢 Сбор анкет открыт</h3>
|
||||||
|
<p id="statusDate" class="text-gray-600"></p>
|
||||||
|
</div>
|
||||||
|
<a href="#" onclick="showPage('form'); return false;" id="formBtn" class="btn-primary text-lg px-8 py-3">Заполнить анкету</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid md:grid-cols-3 gap-6 mb-8">
|
||||||
|
<div class="card text-center">
|
||||||
|
<span class="text-4xl mb-4 block">📋</span>
|
||||||
|
<h3 class="text-lg font-semibold mb-2">Анкета стажёра</h3>
|
||||||
|
<p class="text-gray-600 text-sm">Анкету заполняет руководитель стажёра</p>
|
||||||
|
</div>
|
||||||
|
<div class="card text-center">
|
||||||
|
<span class="text-4xl mb-4 block">⚖️</span>
|
||||||
|
<h3 class="text-lg font-semibold mb-2">Честное деление</h3>
|
||||||
|
<p class="text-gray-600 text-sm">Группы формируются автоматически</p>
|
||||||
|
</div>
|
||||||
|
<div class="card text-center">
|
||||||
|
<span class="text-4xl mb-4 block">📱</span>
|
||||||
|
<h3 class="text-lg font-semibold mb-2">Уведомление WhatsApp</h3>
|
||||||
|
<p class="text-gray-600 text-sm">Тренер отправит ссылку на чат</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="groupsList" class="card mb-8 hidden">
|
||||||
|
<h3 class="text-2xl font-bold mb-6">Список групп</h3>
|
||||||
|
<div id="groupsContent" class="grid md:grid-cols-2 gap-6"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- FORM PAGE -->
|
||||||
|
<div id="formPage" class="page hidden max-w-2xl mx-auto">
|
||||||
|
<div class="card">
|
||||||
|
<h2 class="text-2xl font-bold mb-6">Анкета стажёра</h2>
|
||||||
|
<form id="traineeForm" class="space-y-4">
|
||||||
|
<div class="grid md:grid-cols-2 gap-4">
|
||||||
|
<input name="lastName" placeholder="Фамилия *" required class="input-field">
|
||||||
|
<input name="firstName" placeholder="Имя *" required class="input-field">
|
||||||
|
</div>
|
||||||
|
<input name="email" type="email" placeholder="Почта *" required class="input-field">
|
||||||
|
<div class="grid md:grid-cols-2 gap-4">
|
||||||
|
<input name="division" placeholder="Подразделение *" required class="input-field">
|
||||||
|
<input name="position" placeholder="Должность *" required class="input-field">
|
||||||
|
</div>
|
||||||
|
<input name="phone" placeholder="Телефон *" required class="input-field">
|
||||||
|
<input name="supervisorName" placeholder="ФИО руководителя *" required class="input-field">
|
||||||
|
<input name="supervisorContact" placeholder="Контакты руководителя *" required class="input-field">
|
||||||
|
<select name="city" required class="input-field">
|
||||||
|
<option value="">Город *</option>
|
||||||
|
<option>Алматы</option><option>Астана</option><option>Шымкент</option>
|
||||||
|
<option>Караганда</option><option>Актобе</option><option>Тараз</option>
|
||||||
|
<option>Павлодар</option><option>Усть-Каменогорск</option><option>Семей</option>
|
||||||
|
<option>Атырау</option><option>Орал</option><option>Кызылорда</option>
|
||||||
|
<option>Костанай</option><option>Петропавловск</option><option>Актау</option>
|
||||||
|
<option>Другой</option>
|
||||||
|
</select>
|
||||||
|
<button type="submit" class="btn-primary w-full">Отправить</button>
|
||||||
|
</form>
|
||||||
|
<div id="formSuccess" class="hidden text-center py-8">
|
||||||
|
<div class="text-6xl mb-4">✅</div>
|
||||||
|
<h3 class="text-2xl font-bold mb-4">Анкета отправлена!</h3>
|
||||||
|
<button onclick="showPage('home')" class="btn-primary">На главную</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- TRAINER PAGE -->
|
||||||
|
<div id="trainerPage" class="page hidden">
|
||||||
|
<div id="loginForm" class="max-w-md mx-auto card">
|
||||||
|
<h2 class="text-2xl font-bold mb-6 text-center">Вход для тренеров</h2>
|
||||||
|
<form onsubmit="login(event)" class="space-y-4">
|
||||||
|
<input id="loginUsername" placeholder="Логин" required class="input-field">
|
||||||
|
<input id="loginPassword" type="password" placeholder="Пароль" required class="input-field">
|
||||||
|
<p id="loginError" class="text-red-600 text-sm hidden"></p>
|
||||||
|
<button type="submit" class="btn-primary w-full">Войти</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="trainerContent" class="hidden">
|
||||||
|
<h1 class="text-3xl font-bold mb-6">Кабинет тренера</h1>
|
||||||
|
<div class="flex gap-2 mb-6 border-b">
|
||||||
|
<button onclick="showTab('settings')" class="tab-btn px-4 py-2 border-b-2 border-primary-600 text-primary-600" data-tab="settings">Настройки</button>
|
||||||
|
<button onclick="showTab('trainers')" class="tab-btn px-4 py-2" data-tab="trainers">Тренеры</button>
|
||||||
|
<button onclick="showTab('rules')" class="tab-btn px-4 py-2" data-tab="rules">Правила</button>
|
||||||
|
<button onclick="showTab('trainees')" class="tab-btn px-4 py-2" data-tab="trainees">Стажёры</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="tab-settings" class="tab-content card space-y-4">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium mb-1">Название программы</label>
|
||||||
|
<input id="settingTitle" onchange="updateProgram()" class="input-field">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium mb-1">Описание</label>
|
||||||
|
<textarea id="settingDesc" onchange="updateProgram()" class="input-field" rows="3"></textarea>
|
||||||
|
</div>
|
||||||
|
<div class="grid md:grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium mb-1">Дата начала</label>
|
||||||
|
<input id="settingStart" type="date" onchange="updateProgram()" class="input-field">
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium mb-1">Дата окончания</label>
|
||||||
|
<input id="settingEnd" type="date" onchange="updateProgram()" class="input-field">
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium mb-1">Количество групп</label>
|
||||||
|
<input id="settingGroups" type="number" min="1" max="10" onchange="updateProgram()" class="input-field w-32">
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
<input id="settingOpen" type="checkbox" onchange="updateProgram()" class="w-4 h-4">
|
||||||
|
<label>Сбор анкет открыт</label>
|
||||||
|
</div>
|
||||||
|
<div class="pt-4 border-t">
|
||||||
|
<button id="formGroupsBtn" onclick="formGroups()" class="btn-primary">Сформировать группы</button>
|
||||||
|
<button id="resetGroupsBtn" onclick="resetGroups()" class="btn-secondary hidden">Открыть заново</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="tab-trainers" class="tab-content card hidden">
|
||||||
|
<h2 class="text-xl font-bold mb-4">Тренеры</h2>
|
||||||
|
<div class="table-container">
|
||||||
|
<table class="table">
|
||||||
|
<thead><tr><th>Имя</th><th>Логин</th><th>Пароль</th></tr></thead>
|
||||||
|
<tbody id="trainersTable"></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="tab-rules" class="tab-content card hidden">
|
||||||
|
<h2 class="text-xl font-bold mb-4">Правила программы</h2>
|
||||||
|
<textarea id="rulesText" onchange="updateRules()" class="input-field w-full" rows="12"></textarea>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="tab-trainees" class="tab-content card hidden">
|
||||||
|
<h2 class="text-xl font-bold mb-4">Стажёры (<span id="traineeCount">0</span>)</h2>
|
||||||
|
<div class="table-container">
|
||||||
|
<table class="table">
|
||||||
|
<thead><tr><th>ФИО</th><th>Почта</th><th>Подразделение</th><th>Город</th><th>Группа</th></tr></thead>
|
||||||
|
<tbody id="traineesTable"></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- PROGRESS PAGE -->
|
||||||
|
<div id="progressPage" class="page hidden">
|
||||||
|
<h1 class="text-3xl font-bold mb-6">Прогресс</h1>
|
||||||
|
<div class="card mb-6">
|
||||||
|
<h2 class="text-xl font-bold mb-4">Добавить занятие</h2>
|
||||||
|
<div class="flex gap-4">
|
||||||
|
<input id="sessionName" placeholder="Название" class="input-field flex-1">
|
||||||
|
<input id="sessionDate" type="date" class="input-field">
|
||||||
|
<button onclick="addSession()" class="btn-primary">Добавить</button>
|
||||||
|
</div>
|
||||||
|
<div id="sessionsList" class="flex flex-wrap gap-2 mt-4"></div>
|
||||||
|
</div>
|
||||||
|
<div id="progressTable" class="card overflow-x-auto hidden">
|
||||||
|
<table class="table" id="progressTableContent"></table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- EXAM PAGE -->
|
||||||
|
<div id="examPage" class="page hidden">
|
||||||
|
<h1 class="text-3xl font-bold mb-6">Итоговый экзамен</h1>
|
||||||
|
<div class="card mb-6">
|
||||||
|
<div class="flex items-center gap-3 mb-4">
|
||||||
|
<input id="examEnabled" type="checkbox" onchange="updateExam()" class="w-4 h-4">
|
||||||
|
<label>Этап экзамена активен</label>
|
||||||
|
</div>
|
||||||
|
<textarea id="examRules" onchange="updateExamRules()" class="input-field w-full" rows="6"></textarea>
|
||||||
|
</div>
|
||||||
|
<div class="card mb-6">
|
||||||
|
<div class="grid md:grid-cols-3 gap-4">
|
||||||
|
<div class="bg-gray-50 p-4 text-center rounded">
|
||||||
|
<div id="statTotal" class="text-3xl font-bold">0</div>
|
||||||
|
<div class="text-sm text-gray-600">Всего стажёров</div>
|
||||||
|
</div>
|
||||||
|
<div class="bg-primary-50 p-4 text-center rounded">
|
||||||
|
<div id="statPassed" class="text-3xl font-bold text-primary-600">0</div>
|
||||||
|
<div class="text-sm text-primary-600">Сдали</div>
|
||||||
|
</div>
|
||||||
|
<div class="bg-green-50 p-4 text-center rounded">
|
||||||
|
<div id="statThreshold" class="text-3xl font-bold text-green-600">0</div>
|
||||||
|
<div class="text-sm text-green-600">Прошли порог (≥9)</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<table class="table">
|
||||||
|
<thead><tr><th>ФИО</th><th>Группа</th><th>Балл</th><th>Статус</th></tr></thead>
|
||||||
|
<tbody id="examTable"></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- WHATSAPP PAGE -->
|
||||||
|
<div id="whatsappPage" class="page hidden">
|
||||||
|
<h1 class="text-3xl font-bold mb-6">Рассылка WhatsApp</h1>
|
||||||
|
<div class="card mb-6">
|
||||||
|
<p class="text-sm text-gray-600 mb-2">Шаблон: {name}, {group}, {title}</p>
|
||||||
|
<textarea id="waTemplate" onchange="updateTemplate()" class="input-field w-full" rows="4"></textarea>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<table class="table">
|
||||||
|
<thead><tr><th>ФИО</th><th>Группа</th><th>Телефон</th><th>Действие</th></tr></thead>
|
||||||
|
<tbody id="waTable"></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
// DATA STORE
|
||||||
|
const CITIES = ['Алматы','Астана','Шымкент','Караганда','Актобе','Тараз','Павлодар','Усть-Каменогорск','Семей','Атырау','Орал','Кызылорда','Костанай','Петропавловск','Актау','Другой'];
|
||||||
|
let data = JSON.parse(localStorage.getItem('internData')) || {
|
||||||
|
program: { title: 'Адаптационная программа стажёров', description: 'Программа профессиональной адаптации', startDate: '', endDate: '', collectionOpen: true, groupsFormed: false, examEnabled: false, numberOfGroups: 2 },
|
||||||
|
trainers: [{id:1,name:'Тренер 1',login:'trainer1',password:'pass1'},{id:2,name:'Тренер 2',login:'trainer2',password:'pass2'}],
|
||||||
|
programRules: 'Правила:\n1. Посещаемость обязательна\n2. ДЗ сдавать вовремя',
|
||||||
|
examRules: 'Экзамен:\n1. 10 вопросов\n2. Порог: 9 баллов (90%)',
|
||||||
|
sessions: [],
|
||||||
|
trainees: [],
|
||||||
|
waTemplate: 'Здравствуйте, {name}! Вы в группе {group} программы "{title}". Чат: https://chat.whatsapp.com/...'
|
||||||
|
};
|
||||||
|
let currentUser = null;
|
||||||
|
|
||||||
|
function saveData() {
|
||||||
|
localStorage.setItem('internData', JSON.stringify(data));
|
||||||
|
renderAll();
|
||||||
|
}
|
||||||
|
|
||||||
|
// NAVIGATION
|
||||||
|
function showPage(page) {
|
||||||
|
document.querySelectorAll('.page').forEach(p => p.classList.add('hidden'));
|
||||||
|
document.querySelectorAll('.nav-link').forEach(l => l.classList.remove('active'));
|
||||||
|
document.getElementById(page + 'Page').classList.remove('hidden');
|
||||||
|
const navLink = document.getElementById('nav-' + page);
|
||||||
|
if (navLink) navLink.classList.add('active');
|
||||||
|
|
||||||
|
if (page === 'trainer') {
|
||||||
|
if (currentUser) {
|
||||||
|
document.getElementById('loginForm').classList.add('hidden');
|
||||||
|
document.getElementById('trainerContent').classList.remove('hidden');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function showTab(tab) {
|
||||||
|
document.querySelectorAll('.tab-content').forEach(t => t.classList.add('hidden'));
|
||||||
|
document.querySelectorAll('.tab-btn').forEach(b => {
|
||||||
|
b.classList.remove('border-b-2', 'border-primary-600', 'text-primary-600');
|
||||||
|
});
|
||||||
|
document.getElementById('tab-' + tab).classList.remove('hidden');
|
||||||
|
document.querySelector('[data-tab="' + tab + '"]').classList.add('border-b-2', 'border-primary-600', 'text-primary-600');
|
||||||
|
}
|
||||||
|
|
||||||
|
// AUTH
|
||||||
|
function login(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
const login = document.getElementById('loginUsername').value;
|
||||||
|
const password = document.getElementById('loginPassword').value;
|
||||||
|
const trainer = data.trainers.find(t => t.login === login && t.password === password);
|
||||||
|
if (trainer) {
|
||||||
|
currentUser = trainer;
|
||||||
|
localStorage.setItem('currentUser', JSON.stringify(trainer));
|
||||||
|
document.getElementById('loginForm').classList.add('hidden');
|
||||||
|
document.getElementById('trainerContent').classList.remove('hidden');
|
||||||
|
document.getElementById('userInfo').textContent = '👤 ' + trainer.name;
|
||||||
|
document.getElementById('userInfo').classList.remove('hidden');
|
||||||
|
document.getElementById('logoutBtn').classList.remove('hidden');
|
||||||
|
} else {
|
||||||
|
document.getElementById('loginError').textContent = 'Неверный логин или пароль';
|
||||||
|
document.getElementById('loginError').classList.remove('hidden');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function logout() {
|
||||||
|
currentUser = null;
|
||||||
|
localStorage.removeItem('currentUser');
|
||||||
|
document.getElementById('trainerContent').classList.add('hidden');
|
||||||
|
document.getElementById('loginForm').classList.remove('hidden');
|
||||||
|
document.getElementById('userInfo').classList.add('hidden');
|
||||||
|
document.getElementById('logoutBtn').classList.add('hidden');
|
||||||
|
showPage('home');
|
||||||
|
}
|
||||||
|
|
||||||
|
// FORM
|
||||||
|
document.getElementById('traineeForm').addEventListener('submit', function(e) {
|
||||||
|
e.preventDefault();
|
||||||
|
if (!data.program.collectionOpen || data.program.groupsFormed) {
|
||||||
|
alert('Приём анкет закрыт');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const formData = new FormData(e.target);
|
||||||
|
const trainee = {
|
||||||
|
id: Date.now(),
|
||||||
|
firstName: formData.get('firstName'),
|
||||||
|
lastName: formData.get('lastName'),
|
||||||
|
email: formData.get('email'),
|
||||||
|
division: formData.get('division'),
|
||||||
|
position: formData.get('position'),
|
||||||
|
phone: formData.get('phone'),
|
||||||
|
supervisorName: formData.get('supervisorName'),
|
||||||
|
supervisorContact: formData.get('supervisorContact'),
|
||||||
|
city: formData.get('city'),
|
||||||
|
groupId: null,
|
||||||
|
attendance: {},
|
||||||
|
homeworkStatus: {},
|
||||||
|
examScore: null
|
||||||
|
};
|
||||||
|
data.trainees.push(trainee);
|
||||||
|
saveData();
|
||||||
|
e.target.classList.add('hidden');
|
||||||
|
document.getElementById('formSuccess').classList.remove('hidden');
|
||||||
|
});
|
||||||
|
|
||||||
|
// RENDER
|
||||||
|
function renderAll() {
|
||||||
|
// Home
|
||||||
|
document.getElementById('programTitle').textContent = data.program.title;
|
||||||
|
document.getElementById('programDesc').textContent = data.program.description;
|
||||||
|
document.getElementById('statusText').textContent = (data.program.collectionOpen && !data.program.groupsFormed) ? '🟢 Сбор анкет открыт' : '🔴 Сбор анкет закрыт';
|
||||||
|
document.getElementById('statusDate').textContent = data.program.endDate ? 'Окончание: ' + new Date(data.program.endDate).toLocaleDateString('ru-RU') : '';
|
||||||
|
document.getElementById('formBtn').style.display = (data.program.collectionOpen && !data.program.groupsFormed) ? 'block' : 'none';
|
||||||
|
|
||||||
|
// Stages
|
||||||
|
const stage = data.program.examEnabled ? 4 : (data.program.groupsFormed ? 3 : (data.program.collectionOpen ? 1 : 2));
|
||||||
|
for (let i = 1; i <= 4; i++) {
|
||||||
|
const el = document.getElementById('stage' + i);
|
||||||
|
if (i <= stage) {
|
||||||
|
el.classList.remove('bg-gray-100', 'text-gray-400');
|
||||||
|
el.classList.add('bg-primary-100', 'text-primary-700');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Groups
|
||||||
|
if (data.program.groupsFormed) {
|
||||||
|
document.getElementById('groupsList').classList.remove('hidden');
|
||||||
|
const groups = {};
|
||||||
|
data.trainees.forEach(t => {
|
||||||
|
const gid = t.groupId || 'Без группы';
|
||||||
|
if (!groups[gid]) groups[gid] = [];
|
||||||
|
groups[gid].push(t);
|
||||||
|
});
|
||||||
|
document.getElementById('groupsContent').innerHTML = Object.entries(groups).map(([gid, members]) =>
|
||||||
|
'<div class="border p-4 rounded"><h4 class="font-bold text-primary-700 mb-2">Группа ' + gid + '</h4>' +
|
||||||
|
members.map(m => '<div class="flex justify-between text-sm"><span>' + m.lastName + ' ' + m.firstName + '</span><span class="text-gray-500">' + m.city + '</span></div>').join('') +
|
||||||
|
'</div>'
|
||||||
|
).join('');
|
||||||
|
} else {
|
||||||
|
document.getElementById('groupsList').classList.add('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Settings
|
||||||
|
document.getElementById('settingTitle').value = data.program.title;
|
||||||
|
document.getElementById('settingDesc').value = data.program.description;
|
||||||
|
document.getElementById('settingStart').value = data.program.startDate;
|
||||||
|
document.getElementById('settingEnd').value = data.program.endDate;
|
||||||
|
document.getElementById('settingGroups').value = data.program.numberOfGroups;
|
||||||
|
document.getElementById('settingOpen').checked = data.program.collectionOpen;
|
||||||
|
document.getElementById('formGroupsBtn').classList.toggle('hidden', data.program.groupsFormed);
|
||||||
|
document.getElementById('resetGroupsBtn').classList.toggle('hidden', !data.program.groupsFormed);
|
||||||
|
|
||||||
|
// Trainers table
|
||||||
|
document.getElementById('trainersTable').innerHTML = data.trainers.map((t, i) =>
|
||||||
|
'<tr>' +
|
||||||
|
'<td><input value="' + t.name + '" onchange="updateTrainer(' + i + ',\'name\',this.value)" class="input-field"></td>' +
|
||||||
|
'<td><input value="' + t.login + '" onchange="updateTrainer(' + i + ',\'login\',this.value)" class="input-field"></td>' +
|
||||||
|
'<td><input value="' + t.password + '" onchange="updateTrainer(' + i + ',\'password\',this.value)" class="input-field"></td>' +
|
||||||
|
'</tr>'
|
||||||
|
).join('');
|
||||||
|
|
||||||
|
// Rules
|
||||||
|
document.getElementById('rulesText').value = data.programRules;
|
||||||
|
|
||||||
|
// Trainees
|
||||||
|
document.getElementById('traineeCount').textContent = data.trainees.length;
|
||||||
|
document.getElementById('traineesTable').innerHTML = data.trainees.map(t =>
|
||||||
|
'<tr><td>' + t.lastName + ' ' + t.firstName + '</td><td>' + t.email + '</td><td>' + t.division + '</td><td>' + t.city + '</td><td>' + (t.groupId ? 'Гр.'+t.groupId : '-') + '</td></tr>'
|
||||||
|
).join('');
|
||||||
|
|
||||||
|
// Sessions
|
||||||
|
document.getElementById('sessionsList').innerHTML = data.sessions.map(s =>
|
||||||
|
'<div class="bg-primary-100 px-3 py-2 rounded flex items-center gap-2">' +
|
||||||
|
'<div><div class="font-medium text-sm">' + s.name + '</div><div class="text-xs text-gray-500">' + s.date + '</div></div>' +
|
||||||
|
'<button onclick="removeSession(' + s.id + ')" class="text-red-600">✕</button></div>'
|
||||||
|
).join('');
|
||||||
|
|
||||||
|
// Progress table
|
||||||
|
if (data.sessions.length > 0 && data.trainees.length > 0) {
|
||||||
|
document.getElementById('progressTable').classList.remove('hidden');
|
||||||
|
let html = '<thead><tr><th>ФИО</th><th>Группа</th>';
|
||||||
|
data.sessions.forEach(s => html += '<th class="text-center">' + s.name.split(' ')[0] + '</th>');
|
||||||
|
html += '<th>Комментарий</th></tr></thead><tbody>';
|
||||||
|
data.trainees.forEach(t => {
|
||||||
|
html += '<tr><td>' + t.lastName + ' ' + t.firstName + '</td><td>' + (t.groupId ? 'Гр.'+t.groupId : '-') + '</td>';
|
||||||
|
data.sessions.forEach(s => {
|
||||||
|
const present = t.attendance[s.id] || false;
|
||||||
|
const hw = t.homeworkStatus[s.id] || '';
|
||||||
|
html += '<td class="text-center"><label class="flex items-center justify-center gap-1 text-xs">' +
|
||||||
|
'<input type="checkbox" ' + (present ? 'checked' : '') + ' onchange="updateAttendance(' + t.id + ',' + s.id + ',this.checked)">' +
|
||||||
|
(present ? '✓' : '✗') + '</label>' +
|
||||||
|
'<select onchange="updateHomework(' + t.id + ',' + s.id + ',this.value)" class="text-xs border rounded px-1">' +
|
||||||
|
'<option value="">ДЗ</option><option value="done" ' + (hw==='done'?'selected':'') + '>✓</option>' +
|
||||||
|
'<option value="late" ' + (hw==='late'?'selected':'') + '>!</option>' +
|
||||||
|
'<option value="missing" ' + (hw==='missing'?'selected':'') + '>✗</option></select></td>';
|
||||||
|
});
|
||||||
|
html += '<td><input value="' + (t.comment||'') + '" onchange="updateComment(' + t.id + ',this.value)" class="input-field text-sm" placeholder="..."></td></tr>';
|
||||||
|
});
|
||||||
|
html += '</tbody>';
|
||||||
|
document.getElementById('progressTableContent').innerHTML = html;
|
||||||
|
} else {
|
||||||
|
document.getElementById('progressTable').classList.add('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Exam
|
||||||
|
document.getElementById('examEnabled').checked = data.program.examEnabled;
|
||||||
|
document.getElementById('examRules').value = data.examRules;
|
||||||
|
const passed = data.trainees.filter(t => t.examScore !== null && t.examScore >= 9).length;
|
||||||
|
const withScore = data.trainees.filter(t => t.examScore !== null).length;
|
||||||
|
document.getElementById('statTotal').textContent = data.trainees.length;
|
||||||
|
document.getElementById('statPassed').textContent = withScore;
|
||||||
|
document.getElementById('statThreshold').textContent = passed;
|
||||||
|
document.getElementById('examTable').innerHTML = data.trainees.map(t => {
|
||||||
|
const status = t.examScore === null ? {text:'Не оценён',color:'text-gray-500'} : (t.examScore >= 9 ? {text:'Сдан',color:'text-green-600'} : {text:'Не сдан',color:'text-red-600'});
|
||||||
|
return '<tr><td>' + t.lastName + ' ' + t.firstName + '</td><td>' + (t.groupId ? 'Гр.'+t.groupId : '-') + '</td>' +
|
||||||
|
'<td><input type="number" min="0" max="10" value="' + (t.examScore||'') + '" onchange="updateExamScore(' + t.id + ',this.value)" class="input-field w-24"></td>' +
|
||||||
|
'<td class="' + status.color + '">' + status.text + '</td></tr>';
|
||||||
|
}).join('');
|
||||||
|
|
||||||
|
// WhatsApp
|
||||||
|
document.getElementById('waTemplate').value = data.waTemplate;
|
||||||
|
document.getElementById('waTable').innerHTML = data.trainees.map(t => {
|
||||||
|
const msg = data.waTemplate.replace(/{name}/g, t.lastName+' '+t.firstName).replace(/{group}/g, t.groupId?'Группа '+t.groupId:'Не распределён').replace(/{title}/g, data.program.title);
|
||||||
|
return '<tr><td>' + t.lastName + ' ' + t.firstName + '</td><td>' + (t.groupId ? 'Гр.'+t.groupId : '-') + '</td><td>' + t.phone + '</td>' +
|
||||||
|
'<td><button onclick="openWa(\'' + t.phone + '\',\'' + encodeURIComponent(msg) + '\')" class="btn-primary text-sm">Написать</button></td></tr>';
|
||||||
|
}).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
// ACTIONS
|
||||||
|
function updateProgram() {
|
||||||
|
data.program.title = document.getElementById('settingTitle').value;
|
||||||
|
data.program.description = document.getElementById('settingDesc').value;
|
||||||
|
data.program.startDate = document.getElementById('settingStart').value;
|
||||||
|
data.program.endDate = document.getElementById('settingEnd').value;
|
||||||
|
data.program.numberOfGroups = parseInt(document.getElementById('settingGroups').value) || 1;
|
||||||
|
data.program.collectionOpen = document.getElementById('settingOpen').checked;
|
||||||
|
saveData();
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateTrainer(index, field, value) {
|
||||||
|
data.trainers[index][field] = value;
|
||||||
|
saveData();
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateRules() {
|
||||||
|
data.programRules = document.getElementById('rulesText').value;
|
||||||
|
saveData();
|
||||||
|
}
|
||||||
|
|
||||||
|
function formGroups() {
|
||||||
|
if (!confirm('Сформировать группы?')) return;
|
||||||
|
const num = data.program.numberOfGroups;
|
||||||
|
const trainees = [...data.trainees].sort((a,b) => a.division.localeCompare(b.division) || a.city.localeCompare(b.city));
|
||||||
|
const groups = Array.from({length: num}, () => []);
|
||||||
|
trainees.forEach((t, i) => groups[i % num].push(t.id));
|
||||||
|
data.trainees = data.trainees.map(t => {
|
||||||
|
const idx = groups.findIndex(g => g.includes(t.id));
|
||||||
|
return {...t, groupId: idx >= 0 ? idx + 1 : null};
|
||||||
|
});
|
||||||
|
data.program.groupsFormed = true;
|
||||||
|
data.program.collectionOpen = false;
|
||||||
|
saveData();
|
||||||
|
}
|
||||||
|
|
||||||
|
function resetGroups() {
|
||||||
|
if (!confirm('Сбросить группы и прогресс?')) return;
|
||||||
|
data.trainees = data.trainees.map(t => ({...t, groupId: null, attendance: {}, homeworkStatus: {}, examScore: null}));
|
||||||
|
data.program.groupsFormed = false;
|
||||||
|
data.program.examEnabled = false;
|
||||||
|
saveData();
|
||||||
|
}
|
||||||
|
|
||||||
|
function addSession() {
|
||||||
|
const name = document.getElementById('sessionName').value;
|
||||||
|
const date = document.getElementById('sessionDate').value;
|
||||||
|
if (name && date) {
|
||||||
|
data.sessions.push({id: Date.now(), name, date});
|
||||||
|
document.getElementById('sessionName').value = '';
|
||||||
|
document.getElementById('sessionDate').value = '';
|
||||||
|
saveData();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeSession(id) {
|
||||||
|
data.sessions = data.sessions.filter(s => s.id !== id);
|
||||||
|
saveData();
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateAttendance(tid, sid, present) {
|
||||||
|
const t = data.trainees.find(x => x.id === tid);
|
||||||
|
if (t) { t.attendance[sid] = present; saveData(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateHomework(tid, sid, status) {
|
||||||
|
const t = data.trainees.find(x => x.id === tid);
|
||||||
|
if (t) { t.homeworkStatus[sid] = status; saveData(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateComment(tid, value) {
|
||||||
|
const t = data.trainees.find(x => x.id === tid);
|
||||||
|
if (t) { t.comment = value; saveData(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateExam() {
|
||||||
|
data.program.examEnabled = document.getElementById('examEnabled').checked;
|
||||||
|
saveData();
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateExamRules() {
|
||||||
|
data.examRules = document.getElementById('examRules').value;
|
||||||
|
saveData();
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateExamScore(tid, score) {
|
||||||
|
const t = data.trainees.find(x => x.id === tid);
|
||||||
|
if (t) { t.examScore = score ? parseInt(score) : null; saveData(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
function updateTemplate() {
|
||||||
|
data.waTemplate = document.getElementById('waTemplate').value;
|
||||||
|
saveData();
|
||||||
|
}
|
||||||
|
|
||||||
|
function openWa(phone, msg) {
|
||||||
|
const clean = phone.replace(/\D/g, '');
|
||||||
|
window.open('https://wa.me/' + clean + '?text=' + msg, '_blank');
|
||||||
|
}
|
||||||
|
|
||||||
|
// INIT
|
||||||
|
const savedUser = localStorage.getItem('currentUser');
|
||||||
|
if (savedUser) {
|
||||||
|
currentUser = JSON.parse(savedUser);
|
||||||
|
document.getElementById('userInfo').textContent = '👤 ' + currentUser.name;
|
||||||
|
document.getElementById('userInfo').classList.remove('hidden');
|
||||||
|
document.getElementById('logoutBtn').classList.remove('hidden');
|
||||||
|
}
|
||||||
|
renderAll();
|
||||||
|
showPage('home');
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
</html>
|
</html>
|
||||||
|
|||||||
21
package.json
21
package.json
@ -1,23 +1,4 @@
|
|||||||
{
|
{
|
||||||
"name": "intern-adaptation",
|
"name": "intern-adaptation",
|
||||||
"version": "1.0.0",
|
"private": true
|
||||||
"private": true,
|
|
||||||
"type": "module",
|
|
||||||
"scripts": {
|
|
||||||
"start": "vite --host 0.0.0.0",
|
|
||||||
"build": "vite build",
|
|
||||||
"preview": "vite preview"
|
|
||||||
},
|
|
||||||
"dependencies": {
|
|
||||||
"react": "^18.3.1",
|
|
||||||
"react-dom": "^18.3.1",
|
|
||||||
"react-router-dom": "^6.28.0"
|
|
||||||
},
|
|
||||||
"devDependencies": {
|
|
||||||
"@vitejs/plugin-react": "^4.3.4",
|
|
||||||
"autoprefixer": "^10.4.20",
|
|
||||||
"postcss": "^8.4.49",
|
|
||||||
"tailwindcss": "^3.4.15",
|
|
||||||
"vite": "^5.4.11"
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,6 +0,0 @@
|
|||||||
export default {
|
|
||||||
plugins: {
|
|
||||||
tailwindcss: {},
|
|
||||||
autoprefixer: {},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
48
src/App.jsx
48
src/App.jsx
@ -1,48 +0,0 @@
|
|||||||
import { Routes, Route, Navigate } from 'react-router-dom'
|
|
||||||
import { useAuth } from './context/StorageContext'
|
|
||||||
import Home from './pages/Home'
|
|
||||||
import Form from './pages/Form'
|
|
||||||
import Trainer from './pages/Trainer'
|
|
||||||
import Progress from './pages/Progress'
|
|
||||||
import Exam from './pages/Exam'
|
|
||||||
import WhatsApp from './pages/WhatsApp'
|
|
||||||
import Header from './components/Header'
|
|
||||||
|
|
||||||
function ProtectedRoute({ children }) {
|
|
||||||
const { user } = useAuth()
|
|
||||||
return user ? children : <Navigate to="/" replace />
|
|
||||||
}
|
|
||||||
|
|
||||||
function App() {
|
|
||||||
return (
|
|
||||||
<div className="min-h-screen bg-gray-50">
|
|
||||||
<Header />
|
|
||||||
<Routes>
|
|
||||||
<Route path="/" element={<Home />} />
|
|
||||||
<Route path="/form" element={<Form />} />
|
|
||||||
<Route path="/trainer" element={
|
|
||||||
<ProtectedRoute>
|
|
||||||
<Trainer />
|
|
||||||
</ProtectedRoute>
|
|
||||||
} />
|
|
||||||
<Route path="/progress" element={
|
|
||||||
<ProtectedRoute>
|
|
||||||
<Progress />
|
|
||||||
</ProtectedRoute>
|
|
||||||
} />
|
|
||||||
<Route path="/exam" element={
|
|
||||||
<ProtectedRoute>
|
|
||||||
<Exam />
|
|
||||||
</ProtectedRoute>
|
|
||||||
} />
|
|
||||||
<Route path="/whatsapp" element={
|
|
||||||
<ProtectedRoute>
|
|
||||||
<WhatsApp />
|
|
||||||
</ProtectedRoute>
|
|
||||||
} />
|
|
||||||
</Routes>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default App
|
|
||||||
@ -1,44 +0,0 @@
|
|||||||
import { useStorage } from '../context/StorageContext'
|
|
||||||
|
|
||||||
export default function GroupsList() {
|
|
||||||
const { data } = useStorage()
|
|
||||||
const { trainees, program } = data
|
|
||||||
|
|
||||||
if (!program.groupsFormed) {
|
|
||||||
return null
|
|
||||||
}
|
|
||||||
|
|
||||||
const groups = {}
|
|
||||||
trainees.forEach(trainee => {
|
|
||||||
const groupId = trainee.groupId || 'Без группы'
|
|
||||||
if (!groups[groupId]) {
|
|
||||||
groups[groupId] = []
|
|
||||||
}
|
|
||||||
groups[groupId].push(trainee)
|
|
||||||
})
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="card mb-8">
|
|
||||||
<h3 className="text-2xl font-bold text-gray-900 mb-6">Список групп</h3>
|
|
||||||
<div className="grid md:grid-cols-2 gap-6">
|
|
||||||
{Object.entries(groups).map(([groupId, members]) => (
|
|
||||||
<div key={groupId} className="border border-gray-200 rounded-lg p-4">
|
|
||||||
<h4 className="text-lg font-semibold text-primary-700 mb-3">
|
|
||||||
Группа {groupId}
|
|
||||||
</h4>
|
|
||||||
<ul className="space-y-2">
|
|
||||||
{members.map(trainee => (
|
|
||||||
<li key={trainee.id} className="flex justify-between text-sm">
|
|
||||||
<span className="font-medium text-gray-900">
|
|
||||||
{trainee.lastName} {trainee.firstName}
|
|
||||||
</span>
|
|
||||||
<span className="text-gray-500">{trainee.city}</span>
|
|
||||||
</li>
|
|
||||||
))}
|
|
||||||
</ul>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@ -1,85 +0,0 @@
|
|||||||
import { Link, useLocation } from 'react-router-dom'
|
|
||||||
import { useAuth } from '../context/StorageContext'
|
|
||||||
|
|
||||||
export default function Header() {
|
|
||||||
const { user, logout } = useAuth()
|
|
||||||
const location = useLocation()
|
|
||||||
|
|
||||||
const isActive = (path) => location.pathname === path
|
|
||||||
|
|
||||||
return (
|
|
||||||
<header className="bg-primary-700 text-white shadow-lg">
|
|
||||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
|
||||||
<div className="flex justify-between items-center h-16">
|
|
||||||
<Link to="/" className="text-xl font-bold">
|
|
||||||
📚 Адаптация стажёров
|
|
||||||
</Link>
|
|
||||||
|
|
||||||
<nav className="flex items-center space-x-4">
|
|
||||||
<Link
|
|
||||||
to="/"
|
|
||||||
className={`px-3 py-2 rounded-md text-sm font-medium transition-colors ${
|
|
||||||
isActive('/') ? 'bg-primary-800' : 'hover:bg-primary-600'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
Главная
|
|
||||||
</Link>
|
|
||||||
|
|
||||||
{user ? (
|
|
||||||
<>
|
|
||||||
<Link
|
|
||||||
to="/trainer"
|
|
||||||
className={`px-3 py-2 rounded-md text-sm font-medium transition-colors ${
|
|
||||||
isActive('/trainer') ? 'bg-primary-800' : 'hover:bg-primary-600'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
Кабинет тренера
|
|
||||||
</Link>
|
|
||||||
<Link
|
|
||||||
to="/progress"
|
|
||||||
className={`px-3 py-2 rounded-md text-sm font-medium transition-colors ${
|
|
||||||
isActive('/progress') ? 'bg-primary-800' : 'hover:bg-primary-600'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
Прогресс
|
|
||||||
</Link>
|
|
||||||
<Link
|
|
||||||
to="/exam"
|
|
||||||
className={`px-3 py-2 rounded-md text-sm font-medium transition-colors ${
|
|
||||||
isActive('/exam') ? 'bg-primary-800' : 'hover:bg-primary-600'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
Экзамен
|
|
||||||
</Link>
|
|
||||||
<Link
|
|
||||||
to="/whatsapp"
|
|
||||||
className={`px-3 py-2 rounded-md text-sm font-medium transition-colors ${
|
|
||||||
isActive('/whatsapp') ? 'bg-primary-800' : 'hover:bg-primary-600'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
WhatsApp
|
|
||||||
</Link>
|
|
||||||
<span className="text-sm text-primary-200">
|
|
||||||
👤 {user.name}
|
|
||||||
</span>
|
|
||||||
<button
|
|
||||||
onClick={logout}
|
|
||||||
className="px-3 py-2 text-sm bg-primary-800 hover:bg-primary-900 rounded-md transition-colors"
|
|
||||||
>
|
|
||||||
Выйти
|
|
||||||
</button>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<Link
|
|
||||||
to="/trainer"
|
|
||||||
className="px-3 py-2 text-sm bg-primary-800 hover:bg-primary-900 rounded-md transition-colors"
|
|
||||||
>
|
|
||||||
Вход для тренеров
|
|
||||||
</Link>
|
|
||||||
)}
|
|
||||||
</nav>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@ -1,14 +0,0 @@
|
|||||||
export default function Hero({ title, description }) {
|
|
||||||
return (
|
|
||||||
<div className="bg-gradient-to-r from-primary-600 to-primary-800 text-white py-16">
|
|
||||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 text-center">
|
|
||||||
<h1 className="text-4xl md:text-5xl font-bold mb-4">
|
|
||||||
{title}
|
|
||||||
</h1>
|
|
||||||
<p className="text-xl md:text-2xl text-primary-100 max-w-3xl mx-auto">
|
|
||||||
{description}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@ -1,35 +0,0 @@
|
|||||||
const cards = [
|
|
||||||
{
|
|
||||||
icon: '📋',
|
|
||||||
title: 'Анкета стажёра',
|
|
||||||
description: 'Анкету заполняет руководитель стажёра, а не сам стажёр. Укажите все контактные данные и подразделение.'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
icon: '⚖️',
|
|
||||||
title: 'Честное деление',
|
|
||||||
description: 'Группы формируются автоматически с учётом подразделения и города для равномерного распределения.'
|
|
||||||
},
|
|
||||||
{
|
|
||||||
icon: '📱',
|
|
||||||
title: 'Уведомление в WhatsApp',
|
|
||||||
description: 'После формирования группы тренер отправит вам ссылку на чат группы в WhatsApp.'
|
|
||||||
}
|
|
||||||
]
|
|
||||||
|
|
||||||
export default function InfoCards() {
|
|
||||||
return (
|
|
||||||
<div className="grid md:grid-cols-3 gap-6 mb-8">
|
|
||||||
{cards.map((card, index) => (
|
|
||||||
<div key={index} className="card text-center">
|
|
||||||
<span className="text-4xl mb-4 block">{card.icon}</span>
|
|
||||||
<h3 className="text-lg font-semibold text-gray-900 mb-2">
|
|
||||||
{card.title}
|
|
||||||
</h3>
|
|
||||||
<p className="text-gray-600 text-sm">
|
|
||||||
{card.description}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@ -1,45 +0,0 @@
|
|||||||
import { useState, useEffect } from 'react'
|
|
||||||
import { Link } from 'react-router-dom'
|
|
||||||
|
|
||||||
export default function StatusCard({ program }) {
|
|
||||||
const [daysLeft, setDaysLeft] = useState(0)
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (program.endDate) {
|
|
||||||
const end = new Date(program.endDate)
|
|
||||||
const now = new Date()
|
|
||||||
const diff = Math.ceil((end - now) / (1000 * 60 * 60 * 24))
|
|
||||||
setDaysLeft(Math.max(0, diff))
|
|
||||||
}
|
|
||||||
}, [program.endDate])
|
|
||||||
|
|
||||||
const isOpen = program.collectionOpen && !program.groupsFormed
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="card mb-8">
|
|
||||||
<div className="flex flex-col md:flex-row justify-between items-center gap-6">
|
|
||||||
<div className="text-center md:text-left">
|
|
||||||
<h3 className="text-2xl font-bold text-gray-900 mb-2">
|
|
||||||
{isOpen ? '🟢 Сбор анкет открыт' : '🔴 Сбор анкет закрыт'}
|
|
||||||
</h3>
|
|
||||||
{program.endDate && (
|
|
||||||
<p className="text-gray-600">
|
|
||||||
Окончание приёма: <span className="font-semibold">{new Date(program.endDate).toLocaleDateString('ru-RU')}</span>
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
{isOpen && daysLeft > 0 && (
|
|
||||||
<p className="text-primary-600 font-medium mt-2">
|
|
||||||
Осталось дней: {daysLeft}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{isOpen && (
|
|
||||||
<Link to="/form" className="btn-primary text-lg px-8 py-3">
|
|
||||||
Заполнить анкету
|
|
||||||
</Link>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@ -1,37 +0,0 @@
|
|||||||
export default function TraineePath({ currentStage }) {
|
|
||||||
const stages = [
|
|
||||||
{ id: 1, name: 'Анкета', icon: '📝' },
|
|
||||||
{ id: 2, name: 'Формирование группы', icon: '👥' },
|
|
||||||
{ id: 3, name: 'Обучение и адаптация', icon: '📚' },
|
|
||||||
{ id: 4, name: 'Итоговый экзамен', icon: '✅' }
|
|
||||||
]
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="card mb-8">
|
|
||||||
<h3 className="text-lg font-semibold text-gray-900 mb-6">Путь стажёра</h3>
|
|
||||||
<div className="flex flex-wrap justify-center items-center gap-4">
|
|
||||||
{stages.map((stage, index) => (
|
|
||||||
<div key={stage.id} className="flex items-center">
|
|
||||||
<div
|
|
||||||
className={`flex flex-col items-center p-4 rounded-lg transition-all ${
|
|
||||||
currentStage >= stage.id
|
|
||||||
? 'bg-primary-100 text-primary-700'
|
|
||||||
: 'bg-gray-100 text-gray-400'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<span className="text-3xl mb-2">{stage.icon}</span>
|
|
||||||
<span className="text-sm font-medium">{stage.name}</span>
|
|
||||||
</div>
|
|
||||||
{index < stages.length - 1 && (
|
|
||||||
<div
|
|
||||||
className={`w-12 h-1 mx-2 rounded ${
|
|
||||||
currentStage > stage.id ? 'bg-primary-500' : 'bg-gray-300'
|
|
||||||
}`}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@ -1,234 +0,0 @@
|
|||||||
import { createContext, useContext, useState, useEffect } from 'react'
|
|
||||||
import { auth, signInWithEmailAndPassword, signOut as firebaseSignOut, onAuthStateChanged } from '../firebase'
|
|
||||||
import { db, collection, getDocs, doc, setDoc, updateDoc } from 'firebase/firestore'
|
|
||||||
|
|
||||||
const StorageContext = createContext(null)
|
|
||||||
|
|
||||||
const CITIES = [
|
|
||||||
'Алматы', 'Астана', 'Шымкент', 'Караганда', 'Актобе', 'Тараз', 'Павлодар',
|
|
||||||
'Усть-Каменогорск', 'Семей', 'Атырау', 'Орал', 'Кызылорда', 'Костанай',
|
|
||||||
'Петропавловск', 'Актау', 'Темиртау', 'Другой'
|
|
||||||
]
|
|
||||||
|
|
||||||
const DEFAULT_DATA = {
|
|
||||||
program: {
|
|
||||||
title: 'Адаптационная программа стажёров',
|
|
||||||
description: 'Программа профессиональной адаптации для новых сотрудников',
|
|
||||||
startDate: '',
|
|
||||||
endDate: '',
|
|
||||||
collectionOpen: false,
|
|
||||||
groupsFormed: false,
|
|
||||||
examEnabled: false,
|
|
||||||
numberOfGroups: 2
|
|
||||||
},
|
|
||||||
trainers: [
|
|
||||||
{ id: 1, name: 'Тренер 1', login: 'trainer1', password: 'pass1' },
|
|
||||||
{ id: 2, name: 'Тренер 2', login: 'trainer2', password: 'pass2' }
|
|
||||||
],
|
|
||||||
programRules: 'Правила программы:\n1. Посещаемость обязательна\n2. Домашние задания сдавать вовремя\n3. Активно участвовать в обсуждениях',
|
|
||||||
examRules: 'Правила экзамена:\n1. 10 вопросов\n2. Порог сдачи: 9 правильных ответов (90%)\n3. Время выполнения: 60 минут',
|
|
||||||
sessions: [],
|
|
||||||
trainees: []
|
|
||||||
}
|
|
||||||
|
|
||||||
export function StorageProvider({ children }) {
|
|
||||||
const [user, setUser] = useState(null)
|
|
||||||
const [data, setData] = useState(DEFAULT_DATA)
|
|
||||||
const [loading, setLoading] = useState(true)
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const unsubscribe = onAuthStateChanged(auth, (user) => {
|
|
||||||
setUser(user)
|
|
||||||
setLoading(false)
|
|
||||||
})
|
|
||||||
return unsubscribe
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
loadData()
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const loadData = async () => {
|
|
||||||
try {
|
|
||||||
const stored = localStorage.getItem('internAdaptationData')
|
|
||||||
if (stored) {
|
|
||||||
setData(JSON.parse(stored))
|
|
||||||
}
|
|
||||||
} catch (error) {
|
|
||||||
console.error('Error loading data:', error)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const saveData = (newData) => {
|
|
||||||
setData(newData)
|
|
||||||
localStorage.setItem('internAdaptationData', JSON.stringify(newData))
|
|
||||||
}
|
|
||||||
|
|
||||||
const updateProgram = (updates) => {
|
|
||||||
const newData = { ...data, program: { ...data.program, ...updates } }
|
|
||||||
saveData(newData)
|
|
||||||
}
|
|
||||||
|
|
||||||
const updateTrainers = (trainers) => {
|
|
||||||
const newData = { ...data, trainers }
|
|
||||||
saveData(newData)
|
|
||||||
}
|
|
||||||
|
|
||||||
const updateProgramRules = (rules) => {
|
|
||||||
const newData = { ...data, programRules: rules }
|
|
||||||
saveData(newData)
|
|
||||||
}
|
|
||||||
|
|
||||||
const updateExamRules = (rules) => {
|
|
||||||
const newData = { ...data, examRules: rules }
|
|
||||||
saveData(newData)
|
|
||||||
}
|
|
||||||
|
|
||||||
const addSession = (session) => {
|
|
||||||
const newSessions = [...data.sessions, { ...session, id: Date.now() }]
|
|
||||||
const newData = { ...data, sessions: newSessions }
|
|
||||||
saveData(newData)
|
|
||||||
}
|
|
||||||
|
|
||||||
const removeSession = (sessionId) => {
|
|
||||||
const newSessions = data.sessions.filter(s => s.id !== sessionId)
|
|
||||||
const newData = { ...data, sessions: newSessions }
|
|
||||||
saveData(newData)
|
|
||||||
}
|
|
||||||
|
|
||||||
const addTrainee = (trainee) => {
|
|
||||||
const newTrainees = [...data.trainees, { ...trainee, id: Date.now(), groupId: null, attendance: {}, homeworkStatus: {}, examScore: null }]
|
|
||||||
const newData = { ...data, trainees: newTrainees }
|
|
||||||
saveData(newData)
|
|
||||||
}
|
|
||||||
|
|
||||||
const updateTrainee = (id, updates) => {
|
|
||||||
const newTrainees = data.trainees.map(t => t.id === id ? { ...t, ...updates } : t)
|
|
||||||
const newData = { ...data, trainees: newTrainees }
|
|
||||||
saveData(newData)
|
|
||||||
}
|
|
||||||
|
|
||||||
const deleteTrainee = (id) => {
|
|
||||||
const newTrainees = data.trainees.filter(t => t.id !== id)
|
|
||||||
const newData = { ...data, trainees: newTrainees }
|
|
||||||
saveData(newData)
|
|
||||||
}
|
|
||||||
|
|
||||||
const formGroups = () => {
|
|
||||||
const { numberOfGroups } = data.program
|
|
||||||
const trainees = [...data.trainees]
|
|
||||||
|
|
||||||
// Группировка по подразделениям и городам
|
|
||||||
const groups = Array.from({ length: numberOfGroups }, () => [])
|
|
||||||
|
|
||||||
// Сортировка для равномерного распределения
|
|
||||||
trainees.sort((a, b) => {
|
|
||||||
if (a.division !== b.division) return a.division.localeCompare(b.division)
|
|
||||||
return a.city.localeCompare(b.city)
|
|
||||||
})
|
|
||||||
|
|
||||||
// Распределение по группам
|
|
||||||
trainees.forEach((trainee, index) => {
|
|
||||||
groups[index % numberOfGroups].push(trainee.id)
|
|
||||||
})
|
|
||||||
|
|
||||||
// Обновление trainees с groupId
|
|
||||||
const updatedTrainees = data.trainees.map(t => {
|
|
||||||
const groupIndex = groups.findIndex(g => g.includes(t.id))
|
|
||||||
return { ...t, groupId: groupIndex >= 0 ? groupIndex + 1 : null }
|
|
||||||
})
|
|
||||||
|
|
||||||
const newData = {
|
|
||||||
...data,
|
|
||||||
trainees: updatedTrainees,
|
|
||||||
program: { ...data.program, groupsFormed: true, collectionOpen: false }
|
|
||||||
}
|
|
||||||
saveData(newData)
|
|
||||||
}
|
|
||||||
|
|
||||||
const resetGroups = () => {
|
|
||||||
const updatedTrainees = data.trainees.map(t => ({
|
|
||||||
...t,
|
|
||||||
groupId: null,
|
|
||||||
attendance: {},
|
|
||||||
homeworkStatus: {},
|
|
||||||
examScore: null
|
|
||||||
}))
|
|
||||||
const newData = {
|
|
||||||
...data,
|
|
||||||
trainees: updatedTrainees,
|
|
||||||
program: { ...data.program, groupsFormed: false, examEnabled: false }
|
|
||||||
}
|
|
||||||
saveData(newData)
|
|
||||||
}
|
|
||||||
|
|
||||||
const login = async (login, password) => {
|
|
||||||
const trainer = data.trainers.find(t => t.login === login && t.password === password)
|
|
||||||
if (!trainer) {
|
|
||||||
throw new Error('Неверный логин или пароль')
|
|
||||||
}
|
|
||||||
// Используем демо-режим с localStorage
|
|
||||||
localStorage.setItem('demoUser', JSON.stringify(trainer))
|
|
||||||
window.dispatchEvent(new Event('storage'))
|
|
||||||
}
|
|
||||||
|
|
||||||
const logout = () => {
|
|
||||||
localStorage.removeItem('demoUser')
|
|
||||||
window.dispatchEvent(new Event('storage'))
|
|
||||||
}
|
|
||||||
|
|
||||||
const value = {
|
|
||||||
user,
|
|
||||||
data,
|
|
||||||
loading,
|
|
||||||
CITIES,
|
|
||||||
updateProgram,
|
|
||||||
updateTrainers,
|
|
||||||
updateProgramRules,
|
|
||||||
updateExamRules,
|
|
||||||
addSession,
|
|
||||||
removeSession,
|
|
||||||
addTrainee,
|
|
||||||
updateTrainee,
|
|
||||||
deleteTrainee,
|
|
||||||
formGroups,
|
|
||||||
resetGroups,
|
|
||||||
login,
|
|
||||||
logout
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<StorageContext.Provider value={value}>
|
|
||||||
{children}
|
|
||||||
</StorageContext.Provider>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useStorage() {
|
|
||||||
const context = useContext(StorageContext)
|
|
||||||
if (!context) {
|
|
||||||
throw new Error('useStorage must be used within StorageProvider')
|
|
||||||
}
|
|
||||||
return context
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useAuth() {
|
|
||||||
const [demoUser, setDemoUser] = useState(null)
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const user = localStorage.getItem('demoUser')
|
|
||||||
if (user) {
|
|
||||||
setDemoUser(JSON.parse(user))
|
|
||||||
}
|
|
||||||
|
|
||||||
const handler = () => {
|
|
||||||
const user = localStorage.getItem('demoUser')
|
|
||||||
setDemoUser(user ? JSON.parse(user) : null)
|
|
||||||
}
|
|
||||||
|
|
||||||
window.addEventListener('storage', handler)
|
|
||||||
return () => window.removeEventListener('storage', handler)
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
return { user: demoUser, loading: false }
|
|
||||||
}
|
|
||||||
@ -1,16 +0,0 @@
|
|||||||
// Демо-режим: Firebase не подключён, используется localStorage
|
|
||||||
// Для подключения Firebase добавь зависимости и настрой firebaseConfig
|
|
||||||
|
|
||||||
export const db = null
|
|
||||||
export const auth = null
|
|
||||||
|
|
||||||
export function collection() { return null }
|
|
||||||
export function addDoc() { return null }
|
|
||||||
export function getDocs() { return null }
|
|
||||||
export function updateDoc() { return null }
|
|
||||||
export function deleteDoc() { return null }
|
|
||||||
export function doc() { return null }
|
|
||||||
export function setDoc() { return null }
|
|
||||||
export function signInWithEmailAndPassword() { return null }
|
|
||||||
export function signOut() { return null }
|
|
||||||
export function onAuthStateChanged() { return () => {} }
|
|
||||||
@ -1,43 +0,0 @@
|
|||||||
@tailwind base;
|
|
||||||
@tailwind components;
|
|
||||||
@tailwind utilities;
|
|
||||||
|
|
||||||
body {
|
|
||||||
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-primary {
|
|
||||||
@apply bg-primary-600 hover:bg-primary-700 text-white font-medium py-2 px-4 rounded-lg transition-colors duration-200 disabled:opacity-50 disabled:cursor-not-allowed;
|
|
||||||
}
|
|
||||||
|
|
||||||
.btn-secondary {
|
|
||||||
@apply bg-gray-200 hover:bg-gray-300 text-gray-800 font-medium py-2 px-4 rounded-lg transition-colors duration-200;
|
|
||||||
}
|
|
||||||
|
|
||||||
.input-field {
|
|
||||||
@apply w-full px-3 py-2 border border-gray-300 rounded-lg focus:ring-2 focus:ring-primary-500 focus:border-primary-500 outline-none transition-all;
|
|
||||||
}
|
|
||||||
|
|
||||||
.card {
|
|
||||||
@apply bg-white rounded-xl shadow-md p-6;
|
|
||||||
}
|
|
||||||
|
|
||||||
.table-container {
|
|
||||||
@apply overflow-x-auto rounded-lg border border-gray-200;
|
|
||||||
}
|
|
||||||
|
|
||||||
.table {
|
|
||||||
@apply min-w-full divide-y divide-gray-200;
|
|
||||||
}
|
|
||||||
|
|
||||||
.table th {
|
|
||||||
@apply px-6 py-3 bg-gray-50 text-left text-xs font-medium text-gray-500 uppercase tracking-wider;
|
|
||||||
}
|
|
||||||
|
|
||||||
.table td {
|
|
||||||
@apply px-6 py-4 whitespace-nowrap text-sm text-gray-900;
|
|
||||||
}
|
|
||||||
|
|
||||||
.table tr:hover {
|
|
||||||
@apply bg-gray-50;
|
|
||||||
}
|
|
||||||
16
src/main.jsx
16
src/main.jsx
@ -1,16 +0,0 @@
|
|||||||
import React from 'react'
|
|
||||||
import ReactDOM from 'react-dom/client'
|
|
||||||
import { BrowserRouter } from 'react-router-dom'
|
|
||||||
import App from './App'
|
|
||||||
import { StorageProvider } from './context/StorageContext'
|
|
||||||
import './index.css'
|
|
||||||
|
|
||||||
ReactDOM.createRoot(document.getElementById('root')).render(
|
|
||||||
<React.StrictMode>
|
|
||||||
<BrowserRouter>
|
|
||||||
<StorageProvider>
|
|
||||||
<App />
|
|
||||||
</StorageProvider>
|
|
||||||
</BrowserRouter>
|
|
||||||
</React.StrictMode>
|
|
||||||
)
|
|
||||||
@ -1,122 +0,0 @@
|
|||||||
import { useStorage } from '../context/StorageContext'
|
|
||||||
|
|
||||||
export default function Exam() {
|
|
||||||
const { data, updateProgram, updateExamRules, updateTrainee } = useStorage()
|
|
||||||
const { program, trainees } = data
|
|
||||||
|
|
||||||
const passed = trainees.filter(t => t.examScore !== null && t.examScore >= 9).length
|
|
||||||
const totalWithScore = trainees.filter(t => t.examScore !== null).length
|
|
||||||
|
|
||||||
const handleScoreChange = (traineeId, score) => {
|
|
||||||
const numScore = parseInt(score) || null
|
|
||||||
updateTrainee(traineeId, { examScore: numScore })
|
|
||||||
}
|
|
||||||
|
|
||||||
const getExamStatus = (score) => {
|
|
||||||
if (score === null) return { text: 'Не оценён', color: 'text-gray-500' }
|
|
||||||
if (score >= 9) return { text: 'Сдан', color: 'text-green-600' }
|
|
||||||
return { text: 'Не сдан', color: 'text-red-600' }
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="max-w-6xl mx-auto px-4 py-8">
|
|
||||||
<h1 className="text-3xl font-bold text-gray-900 mb-6">Итоговый экзамен</h1>
|
|
||||||
|
|
||||||
<div className="card mb-6">
|
|
||||||
<h2 className="text-xl font-semibold text-gray-900 mb-4">Настройки экзамена</h2>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-3 mb-4">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
id="examEnabled"
|
|
||||||
checked={program.examEnabled}
|
|
||||||
onChange={(e) => updateProgram({ examEnabled: e.target.checked })}
|
|
||||||
disabled={!program.groupsFormed}
|
|
||||||
className="w-4 h-4 text-primary-600 rounded"
|
|
||||||
/>
|
|
||||||
<label htmlFor="examEnabled" className="text-sm font-medium text-gray-700">
|
|
||||||
Этап экзамена активен (видно на главной)
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
|
||||||
Правила экзамена
|
|
||||||
</label>
|
|
||||||
<textarea
|
|
||||||
value={data.examRules}
|
|
||||||
onChange={(e) => updateExamRules(e.target.value)}
|
|
||||||
className="input-field w-full"
|
|
||||||
rows={6}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="card mb-6">
|
|
||||||
<h2 className="text-xl font-semibold text-gray-900 mb-4">Статистика</h2>
|
|
||||||
<div className="grid md:grid-cols-3 gap-4">
|
|
||||||
<div className="bg-gray-50 rounded-lg p-4 text-center">
|
|
||||||
<div className="text-3xl font-bold text-gray-900">{trainees.length}</div>
|
|
||||||
<div className="text-sm text-gray-600">Всего стажёров</div>
|
|
||||||
</div>
|
|
||||||
<div className="bg-primary-50 rounded-lg p-4 text-center">
|
|
||||||
<div className="text-3xl font-bold text-primary-600">{totalWithScore}</div>
|
|
||||||
<div className="text-sm text-primary-600">Сдано экзамен</div>
|
|
||||||
</div>
|
|
||||||
<div className="bg-green-50 rounded-lg p-4 text-center">
|
|
||||||
<div className="text-3xl font-bold text-green-600">{passed}</div>
|
|
||||||
<div className="text-sm text-green-600">Прошли порог (≥9)</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="card">
|
|
||||||
<h2 className="text-xl font-semibold text-gray-900 mb-4">Оценки стажёров</h2>
|
|
||||||
<div className="table-container">
|
|
||||||
<table className="table">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>ФИО</th>
|
|
||||||
<th>Группа</th>
|
|
||||||
<th>Балл (0-10)</th>
|
|
||||||
<th>Статус</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{trainees.map(trainee => {
|
|
||||||
const status = getExamStatus(trainee.examScore)
|
|
||||||
return (
|
|
||||||
<tr key={trainee.id}>
|
|
||||||
<td>{trainee.lastName} {trainee.firstName}</td>
|
|
||||||
<td>{trainee.groupId ? `Гр.${trainee.groupId}` : '-'}</td>
|
|
||||||
<td>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
min="0"
|
|
||||||
max="10"
|
|
||||||
value={trainee.examScore || ''}
|
|
||||||
onChange={(e) => handleScoreChange(trainee.id, e.target.value)}
|
|
||||||
className="input-field w-24"
|
|
||||||
placeholder="0-10"
|
|
||||||
/>
|
|
||||||
</td>
|
|
||||||
<td className={status.color}>
|
|
||||||
{status.text}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
)
|
|
||||||
})}
|
|
||||||
{trainees.length === 0 && (
|
|
||||||
<tr>
|
|
||||||
<td colSpan={4} className="text-center text-gray-500 py-8">
|
|
||||||
Стажёров пока нет
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
)}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@ -1,261 +0,0 @@
|
|||||||
import { useState } from 'react'
|
|
||||||
import { useNavigate } from 'react-router-dom'
|
|
||||||
import { useStorage } from '../context/StorageContext'
|
|
||||||
|
|
||||||
const initialForm = {
|
|
||||||
firstName: '',
|
|
||||||
lastName: '',
|
|
||||||
email: '',
|
|
||||||
division: '',
|
|
||||||
position: '',
|
|
||||||
phone: '',
|
|
||||||
supervisorName: '',
|
|
||||||
supervisorContact: '',
|
|
||||||
city: ''
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function Form() {
|
|
||||||
const navigate = useNavigate()
|
|
||||||
const { data, addTrainee, CITIES } = useStorage()
|
|
||||||
const [formData, setFormData] = useState(initialForm)
|
|
||||||
const [honeypot, setHoneypot] = useState('')
|
|
||||||
const [submitted, setSubmitted] = useState(false)
|
|
||||||
const [error, setError] = useState('')
|
|
||||||
|
|
||||||
const isOpen = data.program.collectionOpen && !data.program.groupsFormed
|
|
||||||
|
|
||||||
const handleChange = (e) => {
|
|
||||||
const { name, value } = e.target
|
|
||||||
setFormData(prev => ({ ...prev, [name]: value }))
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleSubmit = (e) => {
|
|
||||||
e.preventDefault()
|
|
||||||
|
|
||||||
if (honeypot) {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!isOpen) {
|
|
||||||
setError('Приём анкет закрыт')
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
addTrainee(formData)
|
|
||||||
setSubmitted(true)
|
|
||||||
setFormData(initialForm)
|
|
||||||
} catch (err) {
|
|
||||||
setError('Ошибка при сохранении анкеты')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!isOpen) {
|
|
||||||
return (
|
|
||||||
<div className="max-w-2xl mx-auto px-4 py-16">
|
|
||||||
<div className="card text-center">
|
|
||||||
<h2 className="text-2xl font-bold text-gray-900 mb-4">
|
|
||||||
🔴 Приём анкет закрыт
|
|
||||||
</h2>
|
|
||||||
<p className="text-gray-600 mb-6">
|
|
||||||
К сожалению, сбор анкет на данный момент не проводится.
|
|
||||||
</p>
|
|
||||||
<button
|
|
||||||
onClick={() => navigate('/')}
|
|
||||||
className="btn-primary"
|
|
||||||
>
|
|
||||||
На главную
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (submitted) {
|
|
||||||
return (
|
|
||||||
<div className="max-w-2xl mx-auto px-4 py-16">
|
|
||||||
<div className="card text-center">
|
|
||||||
<div className="text-6xl mb-4">✅</div>
|
|
||||||
<h2 className="text-2xl font-bold text-gray-900 mb-4">
|
|
||||||
Анкета успешно отправлена!
|
|
||||||
</h2>
|
|
||||||
<p className="text-gray-600 mb-6">
|
|
||||||
Данные сохранены. Тренер рассмотрит вашу заявку.
|
|
||||||
</p>
|
|
||||||
<button
|
|
||||||
onClick={() => navigate('/')}
|
|
||||||
className="btn-primary"
|
|
||||||
>
|
|
||||||
На главную
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="max-w-2xl mx-auto px-4 py-8">
|
|
||||||
<div className="card">
|
|
||||||
<h2 className="text-2xl font-bold text-gray-900 mb-6">
|
|
||||||
Анкета стажёра
|
|
||||||
</h2>
|
|
||||||
|
|
||||||
<form onSubmit={handleSubmit} className="space-y-4">
|
|
||||||
<div className="grid md:grid-cols-2 gap-4">
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
|
||||||
Фамилия *
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
name="lastName"
|
|
||||||
value={formData.lastName}
|
|
||||||
onChange={handleChange}
|
|
||||||
required
|
|
||||||
className="input-field"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
|
||||||
Имя *
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
name="firstName"
|
|
||||||
value={formData.firstName}
|
|
||||||
onChange={handleChange}
|
|
||||||
required
|
|
||||||
className="input-field"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
|
||||||
Электронная почта *
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="email"
|
|
||||||
name="email"
|
|
||||||
value={formData.email}
|
|
||||||
onChange={handleChange}
|
|
||||||
required
|
|
||||||
className="input-field"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid md:grid-cols-2 gap-4">
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
|
||||||
Подразделение *
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
name="division"
|
|
||||||
value={formData.division}
|
|
||||||
onChange={handleChange}
|
|
||||||
required
|
|
||||||
className="input-field"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
|
||||||
Должность *
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
name="position"
|
|
||||||
value={formData.position}
|
|
||||||
onChange={handleChange}
|
|
||||||
required
|
|
||||||
className="input-field"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
|
||||||
Контактный телефон стажёра *
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="tel"
|
|
||||||
name="phone"
|
|
||||||
value={formData.phone}
|
|
||||||
onChange={handleChange}
|
|
||||||
required
|
|
||||||
className="input-field"
|
|
||||||
placeholder="+7 (___) ___-__-__"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
|
||||||
ФИО руководителя *
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
name="supervisorName"
|
|
||||||
value={formData.supervisorName}
|
|
||||||
onChange={handleChange}
|
|
||||||
required
|
|
||||||
className="input-field"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
|
||||||
Контакты руководителя *
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
name="supervisorContact"
|
|
||||||
value={formData.supervisorContact}
|
|
||||||
onChange={handleChange}
|
|
||||||
required
|
|
||||||
className="input-field"
|
|
||||||
placeholder="Телефон или почта"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
|
||||||
Город *
|
|
||||||
</label>
|
|
||||||
<select
|
|
||||||
name="city"
|
|
||||||
value={formData.city}
|
|
||||||
onChange={handleChange}
|
|
||||||
required
|
|
||||||
className="input-field"
|
|
||||||
>
|
|
||||||
<option value="">Выберите город</option>
|
|
||||||
{CITIES.map(city => (
|
|
||||||
<option key={city} value={city}>{city}</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Honeypot spam protection */}
|
|
||||||
<div className="hidden">
|
|
||||||
<label>
|
|
||||||
Не заполняйте это поле
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={honeypot}
|
|
||||||
onChange={(e) => setHoneypot(e.target.value)}
|
|
||||||
/>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{error && (
|
|
||||||
<div className="text-red-600 text-sm">{error}</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<button type="submit" className="btn-primary w-full">
|
|
||||||
Отправить анкету
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@ -1,34 +0,0 @@
|
|||||||
import Hero from '../components/Hero'
|
|
||||||
import TraineePath from '../components/TraineePath'
|
|
||||||
import StatusCard from '../components/StatusCard'
|
|
||||||
import InfoCards from '../components/InfoCards'
|
|
||||||
import GroupsList from '../components/GroupsList'
|
|
||||||
import { useStorage } from '../context/StorageContext'
|
|
||||||
|
|
||||||
export default function Home() {
|
|
||||||
const { data } = useStorage()
|
|
||||||
const { program } = data
|
|
||||||
|
|
||||||
const getCurrentStage = () => {
|
|
||||||
if (program.examEnabled) return 4
|
|
||||||
if (program.groupsFormed) return 3
|
|
||||||
if (program.collectionOpen) return 1
|
|
||||||
return 2
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
|
||||||
<Hero title={program.title} description={program.description} />
|
|
||||||
|
|
||||||
<div className="mt-8">
|
|
||||||
<TraineePath currentStage={getCurrentStage()} />
|
|
||||||
|
|
||||||
<StatusCard program={program} />
|
|
||||||
|
|
||||||
<InfoCards />
|
|
||||||
|
|
||||||
<GroupsList />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@ -1,142 +0,0 @@
|
|||||||
import { useState } from 'react'
|
|
||||||
import { useStorage } from '../context/StorageContext'
|
|
||||||
|
|
||||||
export default function Progress() {
|
|
||||||
const { data, addSession, removeSession, updateTrainee } = useStorage()
|
|
||||||
const [newSession, setNewSession] = useState({ name: '', date: '' })
|
|
||||||
|
|
||||||
const handleAddSession = () => {
|
|
||||||
if (newSession.name && newSession.date) {
|
|
||||||
addSession(newSession)
|
|
||||||
setNewSession({ name: '', date: '' })
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleAttendance = (traineeId, sessionId, present) => {
|
|
||||||
const trainee = data.trainees.find(t => t.id === traineeId)
|
|
||||||
const newAttendance = { ...(trainee?.attendance || {}), [sessionId]: present }
|
|
||||||
updateTrainee(traineeId, { attendance: newAttendance })
|
|
||||||
}
|
|
||||||
|
|
||||||
const handleHomework = (traineeId, sessionId, status) => {
|
|
||||||
const trainee = data.trainees.find(t => t.id === traineeId)
|
|
||||||
const newHomework = { ...(trainee?.homeworkStatus || {}), [sessionId]: status }
|
|
||||||
updateTrainee(traineeId, { homeworkStatus: newHomework })
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="max-w-7xl mx-auto px-4 py-8">
|
|
||||||
<h1 className="text-3xl font-bold text-gray-900 mb-6">Прогресс</h1>
|
|
||||||
|
|
||||||
<div className="card mb-6">
|
|
||||||
<h2 className="text-xl font-semibold text-gray-900 mb-4">Добавить занятие</h2>
|
|
||||||
<div className="flex flex-wrap gap-4">
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
placeholder="Название занятия"
|
|
||||||
value={newSession.name}
|
|
||||||
onChange={(e) => setNewSession(prev => ({ ...prev, name: e.target.value }))}
|
|
||||||
className="input-field flex-1 min-w-64"
|
|
||||||
/>
|
|
||||||
<input
|
|
||||||
type="date"
|
|
||||||
value={newSession.date}
|
|
||||||
onChange={(e) => setNewSession(prev => ({ ...prev, date: e.target.value }))}
|
|
||||||
className="input-field"
|
|
||||||
/>
|
|
||||||
<button onClick={handleAddSession} className="btn-primary">
|
|
||||||
Добавить
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{data.sessions.length > 0 && (
|
|
||||||
<div className="card overflow-x-auto">
|
|
||||||
<h2 className="text-xl font-semibold text-gray-900 mb-4">Список занятий</h2>
|
|
||||||
<div className="flex flex-wrap gap-2 mb-6">
|
|
||||||
{data.sessions.map(session => (
|
|
||||||
<div key={session.id} className="flex items-center gap-2 bg-primary-100 px-3 py-2 rounded-lg">
|
|
||||||
<div>
|
|
||||||
<div className="font-medium text-sm">{session.name}</div>
|
|
||||||
<div className="text-xs text-gray-500">{new Date(session.date).toLocaleDateString('ru-RU')}</div>
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
onClick={() => removeSession(session.id)}
|
|
||||||
className="text-red-600 hover:text-red-800 text-sm"
|
|
||||||
>
|
|
||||||
✕
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="table-container">
|
|
||||||
<table className="table">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>ФИО</th>
|
|
||||||
<th>Группа</th>
|
|
||||||
{data.sessions.map(session => (
|
|
||||||
<th key={session.id} className="text-center">
|
|
||||||
{session.name.split(' ')[0]}
|
|
||||||
</th>
|
|
||||||
))}
|
|
||||||
<th>Комментарий</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{data.trainees.map(trainee => (
|
|
||||||
<tr key={trainee.id}>
|
|
||||||
<td>{trainee.lastName} {trainee.firstName}</td>
|
|
||||||
<td>{trainee.groupId ? `Гр.${trainee.groupId}` : '-'}</td>
|
|
||||||
{data.sessions.map(session => (
|
|
||||||
<td key={session.id} className="text-center">
|
|
||||||
<div className="flex flex-col gap-1">
|
|
||||||
<label className="flex items-center justify-center gap-1 text-xs">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={trainee.attendance?.[session.id] || false}
|
|
||||||
onChange={(e) => handleAttendance(trainee.id, session.id, e.target.checked)}
|
|
||||||
className="w-4 h-4"
|
|
||||||
/>
|
|
||||||
{trainee.attendance?.[session.id] ? '✓' : '✗'}
|
|
||||||
</label>
|
|
||||||
<select
|
|
||||||
value={trainee.homeworkStatus?.[session.id] || ''}
|
|
||||||
onChange={(e) => handleHomework(trainee.id, session.id, e.target.value)}
|
|
||||||
className="text-xs border rounded px-1 py-0.5"
|
|
||||||
>
|
|
||||||
<option value="">ДЗ</option>
|
|
||||||
<option value="done">✓</option>
|
|
||||||
<option value="late">!</option>
|
|
||||||
<option value="missing">✗</option>
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</td>
|
|
||||||
))}
|
|
||||||
<td>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={trainee.comment || ''}
|
|
||||||
onChange={(e) => updateTrainee(trainee.id, { comment: e.target.value })}
|
|
||||||
className="input-field text-sm"
|
|
||||||
placeholder="..."
|
|
||||||
/>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
{data.trainees.length === 0 && (
|
|
||||||
<tr>
|
|
||||||
<td colSpan={data.sessions.length + 3} className="text-center text-gray-500 py-8">
|
|
||||||
Стажёров пока нет
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
)}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@ -1,325 +0,0 @@
|
|||||||
import { useState } from 'react'
|
|
||||||
import { useStorage } from '../context/StorageContext'
|
|
||||||
|
|
||||||
export default function Trainer() {
|
|
||||||
const { user, login, data, updateProgram, updateTrainers, updateProgramRules, formGroups, resetGroups } = useStorage()
|
|
||||||
const [activeTab, setActiveTab] = useState('settings')
|
|
||||||
const [loginForm, setLoginForm] = useState({ login: '', password: '' })
|
|
||||||
const [error, setError] = useState('')
|
|
||||||
|
|
||||||
const handleLogin = (e) => {
|
|
||||||
e.preventDefault()
|
|
||||||
try {
|
|
||||||
login(loginForm.login, loginForm.password)
|
|
||||||
setError('')
|
|
||||||
} catch (err) {
|
|
||||||
setError(err.message)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!user) {
|
|
||||||
return (
|
|
||||||
<div className="max-w-md mx-auto px-4 py-16">
|
|
||||||
<div className="card">
|
|
||||||
<h2 className="text-2xl font-bold text-gray-900 mb-6 text-center">
|
|
||||||
Вход для тренеров
|
|
||||||
</h2>
|
|
||||||
<form onSubmit={handleLogin} className="space-y-4">
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
|
||||||
Логин
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={loginForm.login}
|
|
||||||
onChange={(e) => setLoginForm(prev => ({ ...prev, login: e.target.value }))}
|
|
||||||
className="input-field"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
|
||||||
Пароль
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="password"
|
|
||||||
value={loginForm.password}
|
|
||||||
onChange={(e) => setLoginForm(prev => ({ ...prev, password: e.target.value }))}
|
|
||||||
className="input-field"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{error && <div className="text-red-600 text-sm">{error}</div>}
|
|
||||||
<button type="submit" className="btn-primary w-full">
|
|
||||||
Войти
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
const tabs = [
|
|
||||||
{ id: 'settings', name: 'Настройки сбора' },
|
|
||||||
{ id: 'trainers', name: 'Тренеры' },
|
|
||||||
{ id: 'rules', name: 'Правила программы' },
|
|
||||||
{ id: 'trainees', name: 'Список стажёров' }
|
|
||||||
]
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="max-w-6xl mx-auto px-4 py-8">
|
|
||||||
<h1 className="text-3xl font-bold text-gray-900 mb-6">Кабинет тренера</h1>
|
|
||||||
|
|
||||||
<div className="flex flex-wrap gap-2 mb-6 border-b border-gray-200">
|
|
||||||
{tabs.map(tab => (
|
|
||||||
<button
|
|
||||||
key={tab.id}
|
|
||||||
onClick={() => setActiveTab(tab.id)}
|
|
||||||
className={`px-4 py-2 text-sm font-medium transition-colors ${
|
|
||||||
activeTab === tab.id
|
|
||||||
? 'border-b-2 border-primary-600 text-primary-600'
|
|
||||||
: 'text-gray-600 hover:text-gray-900'
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{tab.name}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{activeTab === 'settings' && (
|
|
||||||
<div className="card space-y-6">
|
|
||||||
<h2 className="text-xl font-semibold text-gray-900">Настройки сбора</h2>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
|
||||||
Название программы
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={data.program.title}
|
|
||||||
onChange={(e) => updateProgram({ title: e.target.value })}
|
|
||||||
className="input-field"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
|
||||||
Описание
|
|
||||||
</label>
|
|
||||||
<textarea
|
|
||||||
value={data.program.description}
|
|
||||||
onChange={(e) => updateProgram({ description: e.target.value })}
|
|
||||||
className="input-field"
|
|
||||||
rows={3}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid md:grid-cols-2 gap-4">
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
|
||||||
Дата начала сбора
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="date"
|
|
||||||
value={data.program.startDate}
|
|
||||||
onChange={(e) => updateProgram({ startDate: e.target.value })}
|
|
||||||
className="input-field"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
|
||||||
Дата окончания сбора
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="date"
|
|
||||||
value={data.program.endDate}
|
|
||||||
onChange={(e) => updateProgram({ endDate: e.target.value })}
|
|
||||||
className="input-field"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div>
|
|
||||||
<label className="block text-sm font-medium text-gray-700 mb-1">
|
|
||||||
Количество групп
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="number"
|
|
||||||
min="1"
|
|
||||||
max="10"
|
|
||||||
value={data.program.numberOfGroups}
|
|
||||||
onChange={(e) => updateProgram({ numberOfGroups: parseInt(e.target.value) || 1 })}
|
|
||||||
className="input-field w-32"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
id="collectionOpen"
|
|
||||||
checked={data.program.collectionOpen}
|
|
||||||
onChange={(e) => updateProgram({ collectionOpen: e.target.checked })}
|
|
||||||
disabled={data.program.groupsFormed}
|
|
||||||
className="w-4 h-4 text-primary-600 rounded"
|
|
||||||
/>
|
|
||||||
<label htmlFor="collectionOpen" className="text-sm font-medium text-gray-700">
|
|
||||||
Сбор анкет открыт
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex gap-4 pt-4 border-t">
|
|
||||||
{!data.program.groupsFormed ? (
|
|
||||||
<button
|
|
||||||
onClick={() => {
|
|
||||||
if (confirm('Сформировать группы? После этого приём анкет закроется.')) {
|
|
||||||
formGroups()
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
disabled={data.trainees.length === 0}
|
|
||||||
className="btn-primary"
|
|
||||||
>
|
|
||||||
Сформировать группу
|
|
||||||
</button>
|
|
||||||
) : (
|
|
||||||
<button
|
|
||||||
onClick={() => {
|
|
||||||
if (confirm('Открыть заново? Это сбросит все группы и прогресс.')) {
|
|
||||||
resetGroups()
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
className="btn-secondary"
|
|
||||||
>
|
|
||||||
Открыть заново
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{activeTab === 'trainers' && (
|
|
||||||
<div className="card">
|
|
||||||
<h2 className="text-xl font-semibold text-gray-900 mb-4">Тренеры</h2>
|
|
||||||
<div className="table-container">
|
|
||||||
<table className="table">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>Имя</th>
|
|
||||||
<th>Логин</th>
|
|
||||||
<th>Пароль</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{data.trainers.map((trainer, index) => (
|
|
||||||
<tr key={trainer.id}>
|
|
||||||
<td>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={trainer.name}
|
|
||||||
onChange={(e) => {
|
|
||||||
const newTrainers = [...data.trainers]
|
|
||||||
newTrainers[index].name = e.target.value
|
|
||||||
updateTrainers(newTrainers)
|
|
||||||
}}
|
|
||||||
className="input-field"
|
|
||||||
/>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={trainer.login}
|
|
||||||
onChange={(e) => {
|
|
||||||
const newTrainers = [...data.trainers]
|
|
||||||
newTrainers[index].login = e.target.value
|
|
||||||
updateTrainers(newTrainers)
|
|
||||||
}}
|
|
||||||
className="input-field"
|
|
||||||
/>
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={trainer.password}
|
|
||||||
onChange={(e) => {
|
|
||||||
const newTrainers = [...data.trainers]
|
|
||||||
newTrainers[index].password = e.target.value
|
|
||||||
updateTrainers(newTrainers)
|
|
||||||
}}
|
|
||||||
className="input-field"
|
|
||||||
/>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{activeTab === 'rules' && (
|
|
||||||
<div className="card">
|
|
||||||
<h2 className="text-xl font-semibold text-gray-900 mb-4">Правила программы</h2>
|
|
||||||
<textarea
|
|
||||||
value={data.programRules}
|
|
||||||
onChange={(e) => updateProgramRules(e.target.value)}
|
|
||||||
className="input-field w-full"
|
|
||||||
rows={12}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{activeTab === 'trainees' && (
|
|
||||||
<div className="card">
|
|
||||||
<h2 className="text-xl font-semibold text-gray-900 mb-4">
|
|
||||||
Список стажёров ({data.trainees.length})
|
|
||||||
</h2>
|
|
||||||
<div className="table-container">
|
|
||||||
<table className="table">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>ФИО</th>
|
|
||||||
<th>Почта</th>
|
|
||||||
<th>Подразделение</th>
|
|
||||||
<th>Должность</th>
|
|
||||||
<th>Город</th>
|
|
||||||
<th>Группа</th>
|
|
||||||
<th>Действия</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{data.trainees.map(trainee => (
|
|
||||||
<tr key={trainee.id}>
|
|
||||||
<td>{trainee.lastName} {trainee.firstName}</td>
|
|
||||||
<td>{trainee.email}</td>
|
|
||||||
<td>{trainee.division}</td>
|
|
||||||
<td>{trainee.position}</td>
|
|
||||||
<td>{trainee.city}</td>
|
|
||||||
<td>{trainee.groupId ? `Группа ${trainee.groupId}` : '-'}</td>
|
|
||||||
<td>
|
|
||||||
<button
|
|
||||||
onClick={() => {
|
|
||||||
if (confirm('Удалить стажёра?')) {
|
|
||||||
// deleteTrainee(trainee.id)
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
className="text-red-600 hover:text-red-800 text-sm"
|
|
||||||
>
|
|
||||||
Удалить
|
|
||||||
</button>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
{data.trainees.length === 0 && (
|
|
||||||
<tr>
|
|
||||||
<td colSpan={7} className="text-center text-gray-500 py-8">
|
|
||||||
Анкет пока нет
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
)}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@ -1,103 +0,0 @@
|
|||||||
import { useState } from 'react'
|
|
||||||
import { useStorage } from '../context/StorageContext'
|
|
||||||
|
|
||||||
export default function WhatsApp() {
|
|
||||||
const { data, updateProgram } = useStorage()
|
|
||||||
const [template, setTemplate] = useState(
|
|
||||||
'Здравствуйте, {name}! Вы зачислены в группу {group} программы "{title}". Ссылка на чат: https://chat.whatsapp.com/...'
|
|
||||||
)
|
|
||||||
|
|
||||||
const generateMessage = (trainee) => {
|
|
||||||
return template
|
|
||||||
.replace(/{name}/g, `${trainee.lastName} ${trainee.firstName}`)
|
|
||||||
.replace(/{group}/g, trainee.groupId ? `Группа ${trainee.groupId}` : 'Не распределён')
|
|
||||||
.replace(/{title}/g, data.program.title)
|
|
||||||
}
|
|
||||||
|
|
||||||
const openWhatsApp = (trainee) => {
|
|
||||||
const message = generateMessage(trainee)
|
|
||||||
const phone = trainee.phone.replace(/\D/g, '')
|
|
||||||
const url = `https://wa.me/${phone}?text=${encodeURIComponent(message)}`
|
|
||||||
window.open(url, '_blank')
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!data.program.groupsFormed) {
|
|
||||||
return (
|
|
||||||
<div className="max-w-2xl mx-auto px-4 py-16">
|
|
||||||
<div className="card text-center">
|
|
||||||
<h2 className="text-2xl font-bold text-gray-900 mb-4">
|
|
||||||
⚠️ Группы ещё не сформированы
|
|
||||||
</h2>
|
|
||||||
<p className="text-gray-600 mb-6">
|
|
||||||
Сначала сформируйте группы в кабинете тренера (вкладка "Настройки сбора").
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="max-w-6xl mx-auto px-4 py-8">
|
|
||||||
<h1 className="text-3xl font-bold text-gray-900 mb-6">Рассылка WhatsApp</h1>
|
|
||||||
|
|
||||||
<div className="card mb-6">
|
|
||||||
<h2 className="text-xl font-semibold text-gray-900 mb-4">Шаблон сообщения</h2>
|
|
||||||
<p className="text-sm text-gray-600 mb-4">
|
|
||||||
Доступные подстановки: {'{name}'}, {'{group}'}, {'{title}'}
|
|
||||||
</p>
|
|
||||||
<textarea
|
|
||||||
value={template}
|
|
||||||
onChange={(e) => setTemplate(e.target.value)}
|
|
||||||
className="input-field w-full"
|
|
||||||
rows={4}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="card">
|
|
||||||
<h2 className="text-xl font-semibold text-gray-900 mb-4">
|
|
||||||
Стажёры ({data.trainees.length})
|
|
||||||
</h2>
|
|
||||||
<div className="table-container">
|
|
||||||
<table className="table">
|
|
||||||
<thead>
|
|
||||||
<tr>
|
|
||||||
<th>ФИО</th>
|
|
||||||
<th>Группа</th>
|
|
||||||
<th>Телефон</th>
|
|
||||||
<th>Предпросмотр</th>
|
|
||||||
<th>Действие</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{data.trainees.map(trainee => (
|
|
||||||
<tr key={trainee.id}>
|
|
||||||
<td>{trainee.lastName} {trainee.firstName}</td>
|
|
||||||
<td>{trainee.groupId ? `Группа ${trainee.groupId}` : '-'}</td>
|
|
||||||
<td>{trainee.phone}</td>
|
|
||||||
<td className="max-w-xs truncate text-sm text-gray-500">
|
|
||||||
{generateMessage(trainee).slice(0, 50)}...
|
|
||||||
</td>
|
|
||||||
<td>
|
|
||||||
<button
|
|
||||||
onClick={() => openWhatsApp(trainee)}
|
|
||||||
className="btn-primary text-sm px-4 py-2"
|
|
||||||
>
|
|
||||||
Написать
|
|
||||||
</button>
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
{data.trainees.length === 0 && (
|
|
||||||
<tr>
|
|
||||||
<td colSpan={5} className="text-center text-gray-500 py-8">
|
|
||||||
Стажёров пока нет
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
)}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@ -1,26 +0,0 @@
|
|||||||
/** @type {import('tailwindcss').Config} */
|
|
||||||
export default {
|
|
||||||
content: [
|
|
||||||
"./index.html",
|
|
||||||
"./src/**/*.{js,ts,jsx,tsx}",
|
|
||||||
],
|
|
||||||
theme: {
|
|
||||||
extend: {
|
|
||||||
colors: {
|
|
||||||
primary: {
|
|
||||||
50: '#e6f0ff',
|
|
||||||
100: '#cce0ff',
|
|
||||||
200: '#99c2ff',
|
|
||||||
300: '#66a3ff',
|
|
||||||
400: '#3385ff',
|
|
||||||
500: '#0066ff',
|
|
||||||
600: '#0052cc',
|
|
||||||
700: '#003d99',
|
|
||||||
800: '#002966',
|
|
||||||
900: '#001433',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
plugins: [],
|
|
||||||
}
|
|
||||||
@ -1,11 +0,0 @@
|
|||||||
import { defineConfig } from 'vite'
|
|
||||||
import react from '@vitejs/plugin-react'
|
|
||||||
|
|
||||||
export default defineConfig({
|
|
||||||
plugins: [react()],
|
|
||||||
server: {
|
|
||||||
port: 3000,
|
|
||||||
host: true
|
|
||||||
},
|
|
||||||
base: './'
|
|
||||||
})
|
|
||||||
Loading…
Reference in New Issue
Block a user