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
|
||||
|
||||
640
index.html
640
index.html
@ -1,13 +1,639 @@
|
||||
<!doctype html>
|
||||
<!DOCTYPE html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<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 charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Адаптационная программа стажёров</title>
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<script>
|
||||
tailwind.config = {
|
||||
theme: {
|
||||
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>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.jsx"></script>
|
||||
<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>
|
||||
|
||||
21
package.json
21
package.json
@ -1,23 +1,4 @@
|
||||
{
|
||||
"name": "intern-adaptation",
|
||||
"version": "1.0.0",
|
||||
"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"
|
||||
}
|
||||
"private": true
|
||||
}
|
||||
|
||||
@ -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