feat: динамические формы обучения + вкладка группы в админке
This commit is contained in:
@@ -0,0 +1,67 @@
|
|||||||
|
package com.magistr.app.controller;
|
||||||
|
|
||||||
|
import com.magistr.app.model.EducationForm;
|
||||||
|
import com.magistr.app.model.StudentGroup;
|
||||||
|
import com.magistr.app.repository.EducationFormRepository;
|
||||||
|
import com.magistr.app.repository.GroupRepository;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/education-forms")
|
||||||
|
public class EducationFormController {
|
||||||
|
|
||||||
|
private final EducationFormRepository educationFormRepository;
|
||||||
|
private final GroupRepository groupRepository;
|
||||||
|
|
||||||
|
public EducationFormController(EducationFormRepository educationFormRepository,
|
||||||
|
GroupRepository groupRepository) {
|
||||||
|
this.educationFormRepository = educationFormRepository;
|
||||||
|
this.groupRepository = groupRepository;
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping
|
||||||
|
public List<Map<String, Object>> getAll() {
|
||||||
|
return educationFormRepository.findAll().stream()
|
||||||
|
.map(ef -> Map.<String, Object>of("id", ef.getId(), "name", ef.getName()))
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping
|
||||||
|
public ResponseEntity<?> create(@RequestBody Map<String, String> body) {
|
||||||
|
String name = body.get("name");
|
||||||
|
if (name == null || name.isBlank()) {
|
||||||
|
return ResponseEntity.badRequest().body(Map.of("message", "Название обязательно"));
|
||||||
|
}
|
||||||
|
name = name.trim();
|
||||||
|
if (educationFormRepository.findByName(name).isPresent()) {
|
||||||
|
return ResponseEntity.badRequest().body(Map.of("message", "Такая форма обучения уже существует"));
|
||||||
|
}
|
||||||
|
|
||||||
|
EducationForm ef = new EducationForm();
|
||||||
|
ef.setName(name);
|
||||||
|
educationFormRepository.save(ef);
|
||||||
|
|
||||||
|
return ResponseEntity.ok(Map.of("id", ef.getId(), "name", ef.getName()));
|
||||||
|
}
|
||||||
|
|
||||||
|
@DeleteMapping("/{id}")
|
||||||
|
public ResponseEntity<?> delete(@PathVariable Long id) {
|
||||||
|
if (!educationFormRepository.existsById(id)) {
|
||||||
|
return ResponseEntity.notFound().build();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if any groups use this education form
|
||||||
|
List<StudentGroup> linked = groupRepository.findByEducationFormId(id);
|
||||||
|
if (!linked.isEmpty()) {
|
||||||
|
return ResponseEntity.badRequest().body(Map.of(
|
||||||
|
"message", "Невозможно удалить: есть привязанные группы (" + linked.size() + ")"));
|
||||||
|
}
|
||||||
|
|
||||||
|
educationFormRepository.deleteById(id);
|
||||||
|
return ResponseEntity.ok(Map.of("message", "Форма обучения удалена"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
package com.magistr.app.controller;
|
||||||
|
|
||||||
|
import com.magistr.app.dto.CreateGroupRequest;
|
||||||
|
import com.magistr.app.dto.GroupResponse;
|
||||||
|
import com.magistr.app.model.EducationForm;
|
||||||
|
import com.magistr.app.model.StudentGroup;
|
||||||
|
import com.magistr.app.repository.EducationFormRepository;
|
||||||
|
import com.magistr.app.repository.GroupRepository;
|
||||||
|
import org.springframework.http.ResponseEntity;
|
||||||
|
import org.springframework.web.bind.annotation.*;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Map;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
@RestController
|
||||||
|
@RequestMapping("/api/groups")
|
||||||
|
public class GroupController {
|
||||||
|
|
||||||
|
private final GroupRepository groupRepository;
|
||||||
|
private final EducationFormRepository educationFormRepository;
|
||||||
|
|
||||||
|
public GroupController(GroupRepository groupRepository,
|
||||||
|
EducationFormRepository educationFormRepository) {
|
||||||
|
this.groupRepository = groupRepository;
|
||||||
|
this.educationFormRepository = educationFormRepository;
|
||||||
|
}
|
||||||
|
|
||||||
|
@GetMapping
|
||||||
|
public List<GroupResponse> getAllGroups() {
|
||||||
|
return groupRepository.findAll().stream()
|
||||||
|
.map(g -> new GroupResponse(
|
||||||
|
g.getId(),
|
||||||
|
g.getName(),
|
||||||
|
g.getEducationForm().getId(),
|
||||||
|
g.getEducationForm().getName()))
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
@PostMapping
|
||||||
|
public ResponseEntity<?> createGroup(@RequestBody CreateGroupRequest request) {
|
||||||
|
if (request.getName() == null || request.getName().isBlank()) {
|
||||||
|
return ResponseEntity.badRequest().body(Map.of("message", "Название группы обязательно"));
|
||||||
|
}
|
||||||
|
if (groupRepository.findByName(request.getName().trim()).isPresent()) {
|
||||||
|
return ResponseEntity.badRequest().body(Map.of("message", "Группа с таким названием уже существует"));
|
||||||
|
}
|
||||||
|
if (request.getEducationFormId() == null) {
|
||||||
|
return ResponseEntity.badRequest().body(Map.of("message", "Форма обучения обязательна"));
|
||||||
|
}
|
||||||
|
|
||||||
|
Optional<EducationForm> efOpt = educationFormRepository.findById(request.getEducationFormId());
|
||||||
|
if (efOpt.isEmpty()) {
|
||||||
|
return ResponseEntity.badRequest().body(Map.of("message", "Форма обучения не найдена"));
|
||||||
|
}
|
||||||
|
|
||||||
|
StudentGroup group = new StudentGroup();
|
||||||
|
group.setName(request.getName().trim());
|
||||||
|
group.setEducationForm(efOpt.get());
|
||||||
|
groupRepository.save(group);
|
||||||
|
|
||||||
|
return ResponseEntity.ok(new GroupResponse(
|
||||||
|
group.getId(),
|
||||||
|
group.getName(),
|
||||||
|
group.getEducationForm().getId(),
|
||||||
|
group.getEducationForm().getName()));
|
||||||
|
}
|
||||||
|
|
||||||
|
@DeleteMapping("/{id}")
|
||||||
|
public ResponseEntity<?> deleteGroup(@PathVariable Long id) {
|
||||||
|
if (!groupRepository.existsById(id)) {
|
||||||
|
return ResponseEntity.notFound().build();
|
||||||
|
}
|
||||||
|
groupRepository.deleteById(id);
|
||||||
|
return ResponseEntity.ok(Map.of("message", "Группа удалена"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
package com.magistr.app.dto;
|
||||||
|
|
||||||
|
public class CreateGroupRequest {
|
||||||
|
|
||||||
|
private String name;
|
||||||
|
private Long educationFormId;
|
||||||
|
|
||||||
|
public String getName() {
|
||||||
|
return name;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setName(String name) {
|
||||||
|
this.name = name;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Long getEducationFormId() {
|
||||||
|
return educationFormId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setEducationFormId(Long educationFormId) {
|
||||||
|
this.educationFormId = educationFormId;
|
||||||
|
}
|
||||||
|
}
|
||||||
32
backend/src/main/java/com/magistr/app/dto/GroupResponse.java
Normal file
32
backend/src/main/java/com/magistr/app/dto/GroupResponse.java
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
package com.magistr.app.dto;
|
||||||
|
|
||||||
|
public class GroupResponse {
|
||||||
|
|
||||||
|
private Long id;
|
||||||
|
private String name;
|
||||||
|
private Long educationFormId;
|
||||||
|
private String educationFormName;
|
||||||
|
|
||||||
|
public GroupResponse(Long id, String name, Long educationFormId, String educationFormName) {
|
||||||
|
this.id = id;
|
||||||
|
this.name = name;
|
||||||
|
this.educationFormId = educationFormId;
|
||||||
|
this.educationFormName = educationFormName;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Long getId() {
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getName() {
|
||||||
|
return name;
|
||||||
|
}
|
||||||
|
|
||||||
|
public Long getEducationFormId() {
|
||||||
|
return educationFormId;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getEducationFormName() {
|
||||||
|
return educationFormName;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
package com.magistr.app.model;
|
||||||
|
|
||||||
|
import jakarta.persistence.*;
|
||||||
|
|
||||||
|
@Entity
|
||||||
|
@Table(name = "education_forms")
|
||||||
|
public class EducationForm {
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||||
|
private Long id;
|
||||||
|
|
||||||
|
@Column(unique = true, nullable = false, length = 100)
|
||||||
|
private String name;
|
||||||
|
|
||||||
|
public EducationForm() {
|
||||||
|
}
|
||||||
|
|
||||||
|
public Long getId() {
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setId(Long id) {
|
||||||
|
this.id = id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getName() {
|
||||||
|
return name;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setName(String name) {
|
||||||
|
this.name = name;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
package com.magistr.app.model;
|
||||||
|
|
||||||
|
import jakarta.persistence.*;
|
||||||
|
|
||||||
|
@Entity
|
||||||
|
@Table(name = "student_groups")
|
||||||
|
public class StudentGroup {
|
||||||
|
|
||||||
|
@Id
|
||||||
|
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||||
|
private Long id;
|
||||||
|
|
||||||
|
@Column(unique = true, nullable = false, length = 100)
|
||||||
|
private String name;
|
||||||
|
|
||||||
|
@ManyToOne(optional = false)
|
||||||
|
@JoinColumn(name = "education_form_id", nullable = false)
|
||||||
|
private EducationForm educationForm;
|
||||||
|
|
||||||
|
public StudentGroup() {
|
||||||
|
}
|
||||||
|
|
||||||
|
public Long getId() {
|
||||||
|
return id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setId(Long id) {
|
||||||
|
this.id = id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String getName() {
|
||||||
|
return name;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setName(String name) {
|
||||||
|
this.name = name;
|
||||||
|
}
|
||||||
|
|
||||||
|
public EducationForm getEducationForm() {
|
||||||
|
return educationForm;
|
||||||
|
}
|
||||||
|
|
||||||
|
public void setEducationForm(EducationForm educationForm) {
|
||||||
|
this.educationForm = educationForm;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
package com.magistr.app.repository;
|
||||||
|
|
||||||
|
import com.magistr.app.model.EducationForm;
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
public interface EducationFormRepository extends JpaRepository<EducationForm, Long> {
|
||||||
|
|
||||||
|
Optional<EducationForm> findByName(String name);
|
||||||
|
}
|
||||||
@@ -0,0 +1,14 @@
|
|||||||
|
package com.magistr.app.repository;
|
||||||
|
|
||||||
|
import com.magistr.app.model.StudentGroup;
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import java.util.Optional;
|
||||||
|
|
||||||
|
public interface GroupRepository extends JpaRepository<StudentGroup, Long> {
|
||||||
|
|
||||||
|
Optional<StudentGroup> findByName(String name);
|
||||||
|
|
||||||
|
List<StudentGroup> findByEducationFormId(Long educationFormId);
|
||||||
|
}
|
||||||
@@ -11,3 +11,17 @@ CREATE TABLE IF NOT EXISTS users (
|
|||||||
INSERT INTO users (username, password, role)
|
INSERT INTO users (username, password, role)
|
||||||
VALUES ('admin', crypt('admin', gen_salt('bf', 10)), 'ADMIN')
|
VALUES ('admin', crypt('admin', gen_salt('bf', 10)), 'ADMIN')
|
||||||
ON CONFLICT (username) DO NOTHING;
|
ON CONFLICT (username) DO NOTHING;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS education_forms (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
name VARCHAR(100) UNIQUE NOT NULL
|
||||||
|
);
|
||||||
|
|
||||||
|
INSERT INTO education_forms (name) VALUES ('Бакалавриат'), ('Магистратура'), ('Специалитет')
|
||||||
|
ON CONFLICT (name) DO NOTHING;
|
||||||
|
|
||||||
|
CREATE TABLE IF NOT EXISTS student_groups (
|
||||||
|
id BIGSERIAL PRIMARY KEY,
|
||||||
|
name VARCHAR(100) UNIQUE NOT NULL,
|
||||||
|
education_form_id BIGINT NOT NULL REFERENCES education_forms(id)
|
||||||
|
);
|
||||||
|
|||||||
@@ -339,6 +339,86 @@ tbody tr:hover {
|
|||||||
color: var(--success);
|
color: var(--success);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ===== Education Form Badge ===== */
|
||||||
|
.badge-ef {
|
||||||
|
background: rgba(99, 102, 241, 0.15);
|
||||||
|
color: var(--accent-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== Card Header Row ===== */
|
||||||
|
.card-header-row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.75rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-header-row h2 {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-row label {
|
||||||
|
font-size: 0.78rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.04em;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-row select {
|
||||||
|
padding: 0.45rem 2rem 0.45rem 0.7rem;
|
||||||
|
background: var(--bg-input);
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: var(--radius-sm);
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-family: inherit;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
outline: none;
|
||||||
|
cursor: pointer;
|
||||||
|
appearance: none;
|
||||||
|
background-image: url("data:image/svg+xml,%3Csvg width='12' height='8' viewBox='0 0 12 8' fill='none' xmlns='http://www.w3.org/2000/svg'%3E%3Cpath d='M1 1.5L6 6.5L11 1.5' stroke='%239ca3af' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'/%3E%3C/svg%3E");
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
background-position: right 0.6rem center;
|
||||||
|
transition: background var(--transition), border-color var(--transition), box-shadow var(--transition);
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-row select:focus {
|
||||||
|
background-color: var(--bg-input-focus);
|
||||||
|
border-color: var(--accent);
|
||||||
|
box-shadow: 0 0 0 3px var(--accent-glow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-row select option {
|
||||||
|
background: #1a1a2e;
|
||||||
|
color: var(--text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ===== Tab Content ===== */
|
||||||
|
.tab-content {
|
||||||
|
animation: fadeIn 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes fadeIn {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(4px);
|
||||||
|
}
|
||||||
|
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* ===== Delete Button ===== */
|
/* ===== Delete Button ===== */
|
||||||
.btn-delete {
|
.btn-delete {
|
||||||
padding: 0.35rem 0.7rem;
|
padding: 0.35rem 0.7rem;
|
||||||
|
|||||||
@@ -9,36 +9,103 @@
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const tbody = document.getElementById('users-tbody');
|
// ---- DOM refs ----
|
||||||
const createForm = document.getElementById('create-form');
|
const pageTitle = document.getElementById('page-title');
|
||||||
const createAlert = document.getElementById('create-alert');
|
|
||||||
const btnLogout = document.getElementById('btn-logout');
|
const btnLogout = document.getElementById('btn-logout');
|
||||||
const menuToggle = document.getElementById('menu-toggle');
|
const menuToggle = document.getElementById('menu-toggle');
|
||||||
const sidebar = document.querySelector('.sidebar');
|
const sidebar = document.querySelector('.sidebar');
|
||||||
const sidebarOverlay = document.getElementById('sidebar-overlay');
|
const sidebarOverlay = document.getElementById('sidebar-overlay');
|
||||||
|
|
||||||
// ---- Mobile Menu ----
|
// Users
|
||||||
function toggleSidebar() {
|
const usersTbody = document.getElementById('users-tbody');
|
||||||
sidebar.classList.toggle('open');
|
const createForm = document.getElementById('create-form');
|
||||||
sidebarOverlay.classList.toggle('open');
|
const createAlert = document.getElementById('create-alert');
|
||||||
|
|
||||||
|
// Groups
|
||||||
|
const groupsTbody = document.getElementById('groups-tbody');
|
||||||
|
const createGroupForm = document.getElementById('create-group-form');
|
||||||
|
const createGroupAlert = document.getElementById('create-group-alert');
|
||||||
|
const newGroupEfSelect = document.getElementById('new-group-ef');
|
||||||
|
const filterEfSelect = document.getElementById('filter-ef');
|
||||||
|
|
||||||
|
// Education Forms
|
||||||
|
const efTbody = document.getElementById('ef-tbody');
|
||||||
|
const createEfForm = document.getElementById('create-ef-form');
|
||||||
|
const createEfAlert = document.getElementById('create-ef-alert');
|
||||||
|
|
||||||
|
const navItems = document.querySelectorAll('.nav-item[data-tab]');
|
||||||
|
const tabContents = document.querySelectorAll('.tab-content');
|
||||||
|
|
||||||
|
// ---- State ----
|
||||||
|
let allGroups = [];
|
||||||
|
let allEducationForms = [];
|
||||||
|
|
||||||
|
// ---- Tab Switching ----
|
||||||
|
const TAB_TITLES = {
|
||||||
|
users: 'Управление пользователями',
|
||||||
|
groups: 'Управление группами',
|
||||||
|
'edu-forms': 'Формы обучения',
|
||||||
|
};
|
||||||
|
|
||||||
|
navItems.forEach(item => {
|
||||||
|
item.addEventListener('click', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
switchTab(item.dataset.tab);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function switchTab(tab) {
|
||||||
|
navItems.forEach(n => n.classList.remove('active'));
|
||||||
|
document.querySelector(`.nav-item[data-tab="${tab}"]`)?.classList.add('active');
|
||||||
|
|
||||||
|
tabContents.forEach(tc => tc.style.display = 'none');
|
||||||
|
const target = document.getElementById('tab-' + tab);
|
||||||
|
if (target) target.style.display = '';
|
||||||
|
|
||||||
|
pageTitle.textContent = TAB_TITLES[tab] || '';
|
||||||
|
|
||||||
|
if (tab === 'users') loadUsers();
|
||||||
|
if (tab === 'groups') { loadEducationForms().then(() => loadGroups()); }
|
||||||
|
if (tab === 'edu-forms') loadEducationForms();
|
||||||
|
|
||||||
|
sidebar.classList.remove('open');
|
||||||
|
sidebarOverlay.classList.remove('open');
|
||||||
}
|
}
|
||||||
|
|
||||||
menuToggle.addEventListener('click', toggleSidebar);
|
// ---- Mobile Menu ----
|
||||||
sidebarOverlay.addEventListener('click', toggleSidebar);
|
menuToggle.addEventListener('click', () => {
|
||||||
|
sidebar.classList.toggle('open');
|
||||||
|
sidebarOverlay.classList.toggle('open');
|
||||||
|
});
|
||||||
|
sidebarOverlay.addEventListener('click', () => {
|
||||||
|
sidebar.classList.remove('open');
|
||||||
|
sidebarOverlay.classList.remove('open');
|
||||||
|
});
|
||||||
|
|
||||||
const ROLE_LABELS = {
|
// ---- Helpers ----
|
||||||
ADMIN: 'Администратор',
|
const ROLE_LABELS = { ADMIN: 'Администратор', TEACHER: 'Преподаватель', STUDENT: 'Студент' };
|
||||||
TEACHER: 'Преподаватель',
|
const ROLE_BADGE = { ADMIN: 'badge-admin', TEACHER: 'badge-teacher', STUDENT: 'badge-student' };
|
||||||
STUDENT: 'Студент',
|
|
||||||
};
|
|
||||||
|
|
||||||
const ROLE_BADGE = {
|
function escapeHtml(str) {
|
||||||
ADMIN: 'badge-admin',
|
const div = document.createElement('div');
|
||||||
TEACHER: 'badge-teacher',
|
div.textContent = str;
|
||||||
STUDENT: 'badge-student',
|
return div.innerHTML;
|
||||||
};
|
}
|
||||||
|
|
||||||
|
function showAlert(el, msg, type) {
|
||||||
|
el.className = 'form-alert ' + type;
|
||||||
|
el.textContent = msg;
|
||||||
|
}
|
||||||
|
|
||||||
|
function hideAlert(el) {
|
||||||
|
el.className = 'form-alert';
|
||||||
|
el.textContent = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// USERS
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
// ---- Load Users ----
|
|
||||||
async function loadUsers() {
|
async function loadUsers() {
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/users', {
|
const res = await fetch('/api/users', {
|
||||||
@@ -47,112 +114,241 @@
|
|||||||
const users = await res.json();
|
const users = await res.json();
|
||||||
renderUsers(users);
|
renderUsers(users);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
tbody.innerHTML = '<tr><td colspan="4" class="loading-row">Ошибка загрузки</td></tr>';
|
usersTbody.innerHTML = '<tr><td colspan="4" class="loading-row">Ошибка загрузки</td></tr>';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderUsers(users) {
|
function renderUsers(users) {
|
||||||
if (!users.length) {
|
if (!users.length) {
|
||||||
tbody.innerHTML = '<tr><td colspan="4" class="loading-row">Нет пользователей</td></tr>';
|
usersTbody.innerHTML = '<tr><td colspan="4" class="loading-row">Нет пользователей</td></tr>';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
usersTbody.innerHTML = users.map(u => `
|
||||||
tbody.innerHTML = users.map(u => `
|
|
||||||
<tr>
|
<tr>
|
||||||
<td>${u.id}</td>
|
<td>${u.id}</td>
|
||||||
<td>${escapeHtml(u.username)}</td>
|
<td>${escapeHtml(u.username)}</td>
|
||||||
<td><span class="badge ${ROLE_BADGE[u.role] || ''}">${ROLE_LABELS[u.role] || u.role}</span></td>
|
<td><span class="badge ${ROLE_BADGE[u.role] || ''}">${ROLE_LABELS[u.role] || u.role}</span></td>
|
||||||
<td><button class="btn-delete" data-id="${u.id}">Удалить</button></td>
|
<td><button class="btn-delete" data-id="${u.id}">Удалить</button></td>
|
||||||
</tr>
|
</tr>`).join('');
|
||||||
`).join('');
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function escapeHtml(str) {
|
|
||||||
const div = document.createElement('div');
|
|
||||||
div.textContent = str;
|
|
||||||
return div.innerHTML;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ---- Create User ----
|
|
||||||
createForm.addEventListener('submit', async (e) => {
|
createForm.addEventListener('submit', async (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
hideAlert();
|
hideAlert(createAlert);
|
||||||
|
|
||||||
const username = document.getElementById('new-username').value.trim();
|
const username = document.getElementById('new-username').value.trim();
|
||||||
const password = document.getElementById('new-password').value;
|
const password = document.getElementById('new-password').value;
|
||||||
const role = document.getElementById('new-role').value;
|
const role = document.getElementById('new-role').value;
|
||||||
|
if (!username || !password) { showAlert(createAlert, 'Заполните все поля', 'error'); return; }
|
||||||
if (!username || !password) {
|
|
||||||
showAlert('Заполните все поля', 'error');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/users', {
|
const res = await fetch('/api/users', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {
|
headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + token },
|
||||||
'Content-Type': 'application/json',
|
|
||||||
'Authorization': 'Bearer ' + token,
|
|
||||||
},
|
|
||||||
body: JSON.stringify({ username, password, role }),
|
body: JSON.stringify({ username, password, role }),
|
||||||
});
|
});
|
||||||
|
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
|
|
||||||
if (res.ok) {
|
if (res.ok) {
|
||||||
showAlert(`Пользователь "${data.username}" создан`, 'success');
|
showAlert(createAlert, `Пользователь "${data.username}" создан`, 'success');
|
||||||
createForm.reset();
|
createForm.reset();
|
||||||
loadUsers();
|
loadUsers();
|
||||||
} else {
|
} else {
|
||||||
showAlert(data.message || 'Ошибка создания', 'error');
|
showAlert(createAlert, data.message || 'Ошибка создания', 'error');
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) { showAlert(createAlert, 'Ошибка соединения', 'error'); }
|
||||||
showAlert('Ошибка соединения', 'error');
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// ---- Delete User ----
|
usersTbody.addEventListener('click', async (e) => {
|
||||||
tbody.addEventListener('click', async (e) => {
|
|
||||||
const btn = e.target.closest('.btn-delete');
|
const btn = e.target.closest('.btn-delete');
|
||||||
if (!btn) return;
|
if (!btn) return;
|
||||||
|
|
||||||
const id = btn.dataset.id;
|
|
||||||
if (!confirm('Удалить пользователя?')) return;
|
if (!confirm('Удалить пользователя?')) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await fetch('/api/users/' + id, {
|
const res = await fetch('/api/users/' + btn.dataset.id, {
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
headers: { 'Authorization': 'Bearer ' + token },
|
headers: { 'Authorization': 'Bearer ' + token },
|
||||||
});
|
});
|
||||||
|
if (res.ok) loadUsers();
|
||||||
if (res.ok) {
|
else alert('Ошибка удаления');
|
||||||
loadUsers();
|
} catch (e) { alert('Ошибка соединения'); }
|
||||||
} else {
|
|
||||||
alert('Ошибка удаления');
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
alert('Ошибка соединения');
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// ---- Logout ----
|
// ============================================================
|
||||||
|
// EDUCATION FORMS
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
async function loadEducationForms() {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/education-forms', {
|
||||||
|
headers: { 'Authorization': 'Bearer ' + token },
|
||||||
|
});
|
||||||
|
allEducationForms = await res.json();
|
||||||
|
renderEfTable(allEducationForms);
|
||||||
|
populateEfSelects(allEducationForms);
|
||||||
|
} catch (e) {
|
||||||
|
efTbody.innerHTML = '<tr><td colspan="3" class="loading-row">Ошибка загрузки</td></tr>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderEfTable(forms) {
|
||||||
|
if (!forms.length) {
|
||||||
|
efTbody.innerHTML = '<tr><td colspan="3" class="loading-row">Нет форм обучения</td></tr>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
efTbody.innerHTML = forms.map(ef => `
|
||||||
|
<tr>
|
||||||
|
<td>${ef.id}</td>
|
||||||
|
<td>${escapeHtml(ef.name)}</td>
|
||||||
|
<td><button class="btn-delete" data-id="${ef.id}">Удалить</button></td>
|
||||||
|
</tr>`).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function populateEfSelects(forms) {
|
||||||
|
// Group creation select
|
||||||
|
const currentVal = newGroupEfSelect.value;
|
||||||
|
newGroupEfSelect.innerHTML = forms.map(ef =>
|
||||||
|
`<option value="${ef.id}">${escapeHtml(ef.name)}</option>`
|
||||||
|
).join('');
|
||||||
|
if (currentVal && forms.find(f => f.id == currentVal)) {
|
||||||
|
newGroupEfSelect.value = currentVal;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter select
|
||||||
|
const currentFilter = filterEfSelect.value;
|
||||||
|
filterEfSelect.innerHTML = '<option value="">Все формы</option>' +
|
||||||
|
forms.map(ef =>
|
||||||
|
`<option value="${ef.id}">${escapeHtml(ef.name)}</option>`
|
||||||
|
).join('');
|
||||||
|
if (currentFilter) filterEfSelect.value = currentFilter;
|
||||||
|
}
|
||||||
|
|
||||||
|
createEfForm.addEventListener('submit', async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
hideAlert(createEfAlert);
|
||||||
|
const name = document.getElementById('new-ef-name').value.trim();
|
||||||
|
if (!name) { showAlert(createEfAlert, 'Введите название', 'error'); return; }
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/education-forms', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + token },
|
||||||
|
body: JSON.stringify({ name }),
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
if (res.ok) {
|
||||||
|
showAlert(createEfAlert, `Форма "${data.name}" создана`, 'success');
|
||||||
|
createEfForm.reset();
|
||||||
|
loadEducationForms();
|
||||||
|
} else {
|
||||||
|
showAlert(createEfAlert, data.message || 'Ошибка создания', 'error');
|
||||||
|
}
|
||||||
|
} catch (e) { showAlert(createEfAlert, 'Ошибка соединения', 'error'); }
|
||||||
|
});
|
||||||
|
|
||||||
|
efTbody.addEventListener('click', async (e) => {
|
||||||
|
const btn = e.target.closest('.btn-delete');
|
||||||
|
if (!btn) return;
|
||||||
|
if (!confirm('Удалить форму обучения?')) return;
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/education-forms/' + btn.dataset.id, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: { 'Authorization': 'Bearer ' + token },
|
||||||
|
});
|
||||||
|
if (res.ok) {
|
||||||
|
loadEducationForms();
|
||||||
|
} else {
|
||||||
|
const data = await res.json();
|
||||||
|
alert(data.message || 'Ошибка удаления');
|
||||||
|
}
|
||||||
|
} catch (e) { alert('Ошибка соединения'); }
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// GROUPS
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
|
async function loadGroups() {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/groups', {
|
||||||
|
headers: { 'Authorization': 'Bearer ' + token },
|
||||||
|
});
|
||||||
|
allGroups = await res.json();
|
||||||
|
applyGroupFilter();
|
||||||
|
} catch (e) {
|
||||||
|
groupsTbody.innerHTML = '<tr><td colspan="4" class="loading-row">Ошибка загрузки</td></tr>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyGroupFilter() {
|
||||||
|
const filterId = filterEfSelect.value;
|
||||||
|
const filtered = filterId
|
||||||
|
? allGroups.filter(g => g.educationFormId == filterId)
|
||||||
|
: allGroups;
|
||||||
|
renderGroups(filtered);
|
||||||
|
}
|
||||||
|
|
||||||
|
filterEfSelect.addEventListener('change', applyGroupFilter);
|
||||||
|
|
||||||
|
function renderGroups(groups) {
|
||||||
|
if (!groups.length) {
|
||||||
|
groupsTbody.innerHTML = '<tr><td colspan="4" class="loading-row">Нет групп</td></tr>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
groupsTbody.innerHTML = groups.map(g => `
|
||||||
|
<tr>
|
||||||
|
<td>${g.id}</td>
|
||||||
|
<td>${escapeHtml(g.name)}</td>
|
||||||
|
<td><span class="badge badge-ef">${escapeHtml(g.educationFormName)}</span></td>
|
||||||
|
<td><button class="btn-delete" data-id="${g.id}">Удалить</button></td>
|
||||||
|
</tr>`).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
createGroupForm.addEventListener('submit', async (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
hideAlert(createGroupAlert);
|
||||||
|
const name = document.getElementById('new-group-name').value.trim();
|
||||||
|
const educationFormId = newGroupEfSelect.value;
|
||||||
|
if (!name) { showAlert(createGroupAlert, 'Введите название группы', 'error'); return; }
|
||||||
|
if (!educationFormId) { showAlert(createGroupAlert, 'Выберите форму обучения', 'error'); return; }
|
||||||
|
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/groups', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json', 'Authorization': 'Bearer ' + token },
|
||||||
|
body: JSON.stringify({ name, educationFormId: Number(educationFormId) }),
|
||||||
|
});
|
||||||
|
const data = await res.json();
|
||||||
|
if (res.ok) {
|
||||||
|
showAlert(createGroupAlert, `Группа "${data.name}" создана`, 'success');
|
||||||
|
createGroupForm.reset();
|
||||||
|
loadGroups();
|
||||||
|
} else {
|
||||||
|
showAlert(createGroupAlert, data.message || 'Ошибка создания', 'error');
|
||||||
|
}
|
||||||
|
} catch (e) { showAlert(createGroupAlert, 'Ошибка соединения', 'error'); }
|
||||||
|
});
|
||||||
|
|
||||||
|
groupsTbody.addEventListener('click', async (e) => {
|
||||||
|
const btn = e.target.closest('.btn-delete');
|
||||||
|
if (!btn) return;
|
||||||
|
if (!confirm('Удалить группу?')) return;
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/groups/' + btn.dataset.id, {
|
||||||
|
method: 'DELETE',
|
||||||
|
headers: { 'Authorization': 'Bearer ' + token },
|
||||||
|
});
|
||||||
|
if (res.ok) loadGroups();
|
||||||
|
else alert('Ошибка удаления');
|
||||||
|
} catch (e) { alert('Ошибка соединения'); }
|
||||||
|
});
|
||||||
|
|
||||||
|
// ============================================================
|
||||||
|
// LOGOUT & INIT
|
||||||
|
// ============================================================
|
||||||
|
|
||||||
btnLogout.addEventListener('click', () => {
|
btnLogout.addEventListener('click', () => {
|
||||||
localStorage.removeItem('token');
|
localStorage.removeItem('token');
|
||||||
localStorage.removeItem('role');
|
localStorage.removeItem('role');
|
||||||
window.location.href = '/';
|
window.location.href = '/';
|
||||||
});
|
});
|
||||||
|
|
||||||
// ---- Helpers ----
|
|
||||||
function showAlert(msg, type) {
|
|
||||||
createAlert.className = 'form-alert ' + type;
|
|
||||||
createAlert.textContent = msg;
|
|
||||||
}
|
|
||||||
|
|
||||||
function hideAlert() {
|
|
||||||
createAlert.className = 'form-alert';
|
|
||||||
createAlert.textContent = '';
|
|
||||||
}
|
|
||||||
|
|
||||||
// Init
|
|
||||||
loadUsers();
|
loadUsers();
|
||||||
})();
|
})();
|
||||||
|
|||||||
@@ -31,7 +31,7 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<nav class="sidebar-nav">
|
<nav class="sidebar-nav">
|
||||||
<a href="/admin/" class="nav-item active">
|
<a href="#" class="nav-item active" data-tab="users">
|
||||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
|
||||||
stroke-linecap="round" stroke-linejoin="round">
|
stroke-linecap="round" stroke-linejoin="round">
|
||||||
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2" />
|
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2" />
|
||||||
@@ -41,6 +41,24 @@
|
|||||||
</svg>
|
</svg>
|
||||||
Пользователи
|
Пользователи
|
||||||
</a>
|
</a>
|
||||||
|
<a href="#" class="nav-item" data-tab="groups">
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
|
||||||
|
stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M2 3h6a4 4 0 0 1 4 4v14a3 3 0 0 0-3-3H2z" />
|
||||||
|
<path d="M22 3h-6a4 4 0 0 0-4 4v14a3 3 0 0 1 3-3h7z" />
|
||||||
|
</svg>
|
||||||
|
Группы
|
||||||
|
</a>
|
||||||
|
<a href="#" class="nav-item" data-tab="edu-forms">
|
||||||
|
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
|
||||||
|
stroke-linecap="round" stroke-linejoin="round">
|
||||||
|
<path d="M4 19.5A2.5 2.5 0 0 1 6.5 17H20" />
|
||||||
|
<path d="M6.5 2H20v20H6.5A2.5 2.5 0 0 1 4 19.5v-15A2.5 2.5 0 0 1 6.5 2z" />
|
||||||
|
<line x1="9" y1="7" x2="17" y2="7" />
|
||||||
|
<line x1="9" y1="11" x2="15" y2="11" />
|
||||||
|
</svg>
|
||||||
|
Формы обучения
|
||||||
|
</a>
|
||||||
</nav>
|
</nav>
|
||||||
<div class="sidebar-footer">
|
<div class="sidebar-footer">
|
||||||
<button class="btn-logout" id="btn-logout">
|
<button class="btn-logout" id="btn-logout">
|
||||||
@@ -69,11 +87,11 @@
|
|||||||
<line x1="3" y1="18" x2="21" y2="18" />
|
<line x1="3" y1="18" x2="21" y2="18" />
|
||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
<h1>Управление пользователями</h1>
|
<h1 id="page-title">Управление пользователями</h1>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
<section class="content">
|
<!-- ===== Users Tab ===== -->
|
||||||
<!-- Create User Card -->
|
<section class="content tab-content" id="tab-users">
|
||||||
<div class="card create-card">
|
<div class="card create-card">
|
||||||
<h2>Новый пользователь</h2>
|
<h2>Новый пользователь</h2>
|
||||||
<form id="create-form">
|
<form id="create-form">
|
||||||
@@ -99,8 +117,6 @@
|
|||||||
<div class="form-alert" id="create-alert" role="alert"></div>
|
<div class="form-alert" id="create-alert" role="alert"></div>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Users Table -->
|
|
||||||
<div class="card">
|
<div class="card">
|
||||||
<h2>Все пользователи</h2>
|
<h2>Все пользователи</h2>
|
||||||
<div class="table-wrap">
|
<div class="table-wrap">
|
||||||
@@ -122,6 +138,93 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<!-- ===== Groups Tab ===== -->
|
||||||
|
<section class="content tab-content" id="tab-groups" style="display:none;">
|
||||||
|
<div class="card create-card">
|
||||||
|
<h2>Новая группа</h2>
|
||||||
|
<form id="create-group-form">
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="new-group-name">Название группы</label>
|
||||||
|
<input type="text" id="new-group-name" placeholder="ИВТ-21-1" required>
|
||||||
|
</div>
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="new-group-ef">Форма обучения</label>
|
||||||
|
<select id="new-group-ef">
|
||||||
|
<option value="">Загрузка...</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn-create">Создать</button>
|
||||||
|
</div>
|
||||||
|
<div class="form-alert" id="create-group-alert" role="alert"></div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header-row">
|
||||||
|
<h2>Все группы</h2>
|
||||||
|
<div class="filter-row">
|
||||||
|
<label for="filter-ef">Фильтр:</label>
|
||||||
|
<select id="filter-ef">
|
||||||
|
<option value="">Все формы</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="table-wrap">
|
||||||
|
<table id="groups-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>ID</th>
|
||||||
|
<th>Название</th>
|
||||||
|
<th>Форма обучения</th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="groups-tbody">
|
||||||
|
<tr>
|
||||||
|
<td colspan="4" class="loading-row">Загрузка...</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<!-- ===== Education Forms Tab ===== -->
|
||||||
|
<section class="content tab-content" id="tab-edu-forms" style="display:none;">
|
||||||
|
<div class="card create-card">
|
||||||
|
<h2>Новая форма обучения</h2>
|
||||||
|
<form id="create-ef-form">
|
||||||
|
<div class="form-row">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="new-ef-name">Название</label>
|
||||||
|
<input type="text" id="new-ef-name" placeholder="Бакалавриат" required>
|
||||||
|
</div>
|
||||||
|
<button type="submit" class="btn-create">Создать</button>
|
||||||
|
</div>
|
||||||
|
<div class="form-alert" id="create-ef-alert" role="alert"></div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<div class="card">
|
||||||
|
<h2>Все формы обучения</h2>
|
||||||
|
<div class="table-wrap">
|
||||||
|
<table id="ef-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>ID</th>
|
||||||
|
<th>Название</th>
|
||||||
|
<th></th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="ef-tbody">
|
||||||
|
<tr>
|
||||||
|
<td colspan="3" class="loading-row">Загрузка...</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
</main>
|
</main>
|
||||||
|
|
||||||
<script src="admin.js"></script>
|
<script src="admin.js"></script>
|
||||||
|
|||||||
Reference in New Issue
Block a user