3 Commits

Author SHA1 Message Date
da7c90fa6a Merge pull request 'Redesign' (#12) from Redesign into main
All checks were successful
Build and Push Docker Images / build-and-push-backend (push) Successful in 50s
Build and Push Docker Images / build-and-push-frontend (push) Successful in 14s
Build and Push Docker Images / deploy-to-k8s (push) Successful in 3m49s
Reviewed-on: #12
2026-06-01 09:04:35 +00:00
3217586657 редизайн кнопок 1st version 2026-06-01 02:55:06 +03:00
dipatrik10
30972db9ee Исправил часть косяков в конструкторе правил 2026-05-31 21:36:23 +03:00
63 changed files with 1541 additions and 552 deletions

BIN
audit-buttons-01-login.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 126 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 149 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 150 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 151 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 33 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 38 KiB

BIN
audit-buttons-08-modal.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 248 KiB

BIN
audit-buttons-calendar.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 170 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 130 KiB

BIN
audit-buttons-groups.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 98 KiB

BIN
audit-buttons-rules.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 179 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 82 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 58 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 140 KiB

BIN
audit-buttons-structure.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 85 KiB

BIN
audit-buttons-subjects.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 140 KiB

BIN
audit-buttons-users.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 148 KiB

BIN
audit-buttons-workload.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 125 KiB

420
docs/BUTTON_STYLING_PLAN.md Normal file
View File

@@ -0,0 +1,420 @@
# План стилизации кнопок
Дата аудита: 01.06.2026
Среда аудита: `https://localhost`
Метод: визуальный обход через Playwright, фиксация скриншотов и computed styles.
## 1. Краткое резюме
Интерфейс уже имеет узнаваемую базу: тёмная админ-панель, фиолетовый акцент, компактные таблицы и отдельные публичные кабинеты преподавателя/студента. Главная проблема не в отдельных кнопках, а в отсутствии единого контракта между смыслом действия и его визуальным видом.
Сейчас одинаковые классы могут выглядеть по-разному в разных SPA, а одинаковые действия могут получать разные цвета, размеры и плотность. Из-за этого пользователь каждый раз заново считывает интерфейс: где главное действие, где безопасное второстепенное, а где рискованное.
Цель новой структуры: закрепить единую кнопку как системный компонент с понятными ролями, размерами и правилами использования.
## 2. Что было проверено
| Раздел | Проверенные сценарии | Скриншот |
|---|---|---|
| Авторизация | Вход, показ пароля, переключение темы | [`audit-buttons-01-login.png`](../audit-buttons-01-login.png) |
| Дашборд админки | Быстрые переходы, recheck, сайдбар | [`audit-buttons-02-dashboard.png`](../audit-buttons-02-dashboard.png) |
| Пользователи | Создание пользователя, архивирование | [`audit-buttons-03-users-form.png`](../audit-buttons-03-users-form.png) |
| Меню настроек | Выпадающее меню, выход, переход в настройки | [`audit-buttons-04-settings-menu.png`](../audit-buttons-04-settings-menu.png) |
| Настройки | Навигация отдельной SPA | [`audit-buttons-05-settings-general.png`](../audit-buttons-05-settings-general.png) |
| Кабинет преподавателя | Недели, сегодня, выход, тема | [`audit-buttons-06-teacher.png`](../audit-buttons-06-teacher.png) |
| Кабинет студента | Недели, сегодня, выход, тема | [`audit-buttons-07-student.png`](../audit-buttons-07-student.png) |
| Модальное окно | Создание ручной сетки, отмена, закрытие | [`audit-buttons-08-modal.png`](../audit-buttons-08-modal.png) |
Дополнительно были сохранены скриншоты отдельных разделов: календарный график, аудитории, группы, дисциплины, расписание, формы обучения, база данных, временные слоты.
## 3. Текущее состояние
### 3.1. Авторизация
![Экран авторизации](../audit-buttons-01-login.png)
Найдены кнопки:
| Кнопка | Текущая роль | Комментарий |
|---|---|---|
| `Войти` | Primary | Корректно считывается как главное действие экрана. |
| `Показать пароль` | Icon utility | Нужен единый icon-only стиль и обязательный `aria-label`. |
| `Переключить тему` | Icon utility | Отличается от такой же кнопки в кабинетах преподавателя и студента. |
Вывод: экран логина близок к правильной иерархии, но icon-only controls нужно привести к общей форме и размеру.
### 3.2. Дашборд админки
![Дашборд админки](../audit-buttons-02-dashboard.png)
Найдены кнопки и ссылки-кнопки:
| Кнопка | Текущий вид | Проблема |
|---|---|---|
| `Конструктор расписания` | Primary link | Хороший кандидат на основной CTA блока. |
| `Точечные изменения / Просмотр` | Secondary link | Выглядит как второстепенное действие, это корректно. |
| `Перепроверить` | `btn-primary`, маленькая высота | Primary используется для сервисного действия, хотя это не главное действие страницы. |
| `Настройки` | Sidebar button | Визуально близко к навигации, но реализовано как кнопка. |
| `Переключить тему` | Круглая icon button | Отличается от публичных кабинетов. |
Основной вывод: `btn-primary` сейчас означает и главный CTA, и компактную служебную кнопку. Это размывает иерархию.
### 3.3. Формы и таблицы админки
![Форма пользователей](../audit-buttons-03-users-form.png)
Типичные кнопки:
| Кнопка | Смысл | Текущее впечатление |
|---|---|---|
| `Создать` | Подтверждение формы | Главная кнопка, но размер и стиль отличаются между основной админкой и настройками. |
| `Архивировать` | Рискованное, но часто обратимое действие | Визуально слишком близко к удалению. |
| `Изменить` | Второстепенное действие строки | В разных разделах бывает фиолетовой или нейтральной. |
| `Удалить` | Деструктивное действие | Встречается в разных размерах и оттенках красного. |
Проблема: таблицы используют кнопки как плотный action bar, но размеры скачут от `24x28` до `113x28` и `96x40`. Это создаёт визуальный шум в строках.
### 3.4. Меню настроек и выход
![Меню настроек](../audit-buttons-04-settings-menu.png)
Кнопка `Выйти` находится рядом с навигационными пунктами и выглядит почти как обычный пункт меню. Семантически это сессионное действие с риском потерять текущий контекст, поэтому ему нужен отдельный quiet-danger стиль.
Рекомендация: `Выйти` не должен выглядеть как обычная навигация. Достаточно `Danger subtle`: красный текст на спокойной поверхности без агрессивной заливки.
### 3.5. Отдельная SPA настроек
![Общие настройки](../audit-buttons-05-settings-general.png)
В настройках виден тот же сайдбарный паттерн, но кнопки внутри разделов получают другую визуальную систему.
Пример различий:
| Класс | Основная админка | Настройки |
|---|---|---|
| `btn-primary` | Часто прозрачный фон, белый текст, визуальная заливка может приходить из другого слоя | Фиолетовая заливка `rgb(139, 92, 246)`, высота `40px`, жирность `700` |
| `btn-delete` | Красный tint, высота часто `28px`, font-weight `400` | Красный tint, высота `40px`, font-weight `700` |
| `btn-secondary` | Фиолетовый tint или светлая surface-кнопка | Нейтральная surface-кнопка |
Вывод: одинаковое имя класса не является дизайн-контрактом. Компонент кнопки нужно нормализовать на уровне токенов и модификаторов.
### 3.6. Кабинеты преподавателя и студента
![Кабинет преподавателя](../audit-buttons-06-teacher.png)
![Кабинет студента](../audit-buttons-07-student.png)
В публичных кабинетах используется отдельная светлая система:
| Элемент | Преподаватель | Студент |
|---|---|---|
| `Сегодня` | Primary blue `rgb(29, 78, 216)` | Primary teal `rgb(15, 118, 110)` |
| `Выйти` | Outline, белый фон, radius `8px` | Outline, белый фон, radius `8px` |
| `` / `` | Outline icon-like buttons | Outline icon-like buttons |
| `Переключить тему` | Текстовый символ `◐` | Текстовый символ `◐` |
Проблема: одно действие `Сегодня` меняет цвет в зависимости от кабинета. Это допустимо для брендинга ролей, но плохо для системных кнопок. Ролевые цвета лучше использовать в расписании, бейджах и карточках, а не в базовой кнопочной иерархии.
### 3.7. Модальные окна
![Модальное окно создания ручной сетки](../audit-buttons-08-modal.png)
Найдены:
| Кнопка | Роль | Рекомендация |
|---|---|---|
| `Создать сетку` | Primary | Оставить primary. |
| `Отмена` | Secondary/Ghost | Сделать ghost или secondary без акцента. |
| `Закрыть` (`×`) | Icon close | Привести к icon-only button `40x40` или `36x36`, с `aria-label="Закрыть"`. |
Модальная структура в целом правильная, но её нужно подчинить тем же токенам, что и остальные кнопки.
## 4. Семантическая группировка найденных кнопок
### Primary: главное действие
Примеры: `Войти`, `Создать`, `Добавить`, `Сохранить правило`, `Сохранить год`, `Сохранить семестр`, `Сохранить график`, `Сохранить сетку`, `Сохранить слот`, `Создать сетку`.
Правило: одна primary-кнопка на форму, модальное окно или смысловой блок. Если на экране несколько форм, primary может быть в каждой форме, но не должна превращаться в оформление всех кнопок подряд.
### Secondary: обычное второстепенное действие
Примеры: `Изменить`, `Обновить`, `Показать`, `Тест`, `Сменить статус`, `Преподаватели`, `Загрузить сетку`, `Заполнить диапазон`.
Правило: использовать для действий, которые помогают выполнить задачу, но не являются главным подтверждением.
### Tertiary / Ghost: тихое действие
Примеры: `Очистить`, `Очистить форму`, `Отмена`, `Назад в панель`, `Свернуть`, `Из формы`, `Все`, `Точечные изменения / Просмотр`.
Правило: использовать там, где действие должно быть доступно, но не конкурировать с сохранением или созданием.
### Danger: деструктивное действие
Примеры: `Удалить`.
Правило: только для необратимых или почти необратимых операций. Красный цвет должен быть зарезервирован именно под риск.
### Danger subtle: рискованное, но не удаление
Примеры: `Архив`, `Архивировать`, `Удалить сетку`, `Выйти`.
Правило: использовать для действий с негативным или сессионным эффектом, но без визуальной паники solid-red кнопки.
### Icon button: компактное служебное действие
Примеры: `Переключить тему`, `Показать пароль`, `Скрыть панель`, `Закрыть`, ``, ``.
Правило: фиксированный квадратный размер, единое состояние hover/focus, обязательный доступный текст через `aria-label`.
### Navigation item: навигация, а не кнопка действия
Примеры: пункты сайдбара `Дашборд`, `Конструктор правил`, `Календарный график`, `Пользователи`, `Общие настройки`.
Правило: не смешивать навигацию с action button. У неё отдельные active, hover и focus-состояния.
## 5. Предлагаемая дизайн-система кнопок
### 5.1. Токены
| Токен | Светлая тема | Тёмная тема | Назначение |
|---|---|---|---|
| `--button-primary-bg` | `#4F46E5` | `#8B5CF6` | Основное действие |
| `--button-primary-bg-hover` | `#4338CA` | `#7C3AED` | Hover primary |
| `--button-primary-text` | `#FFFFFF` | `#FFFFFF` | Текст primary |
| `--button-secondary-bg` | `#FFFFFF` | `rgba(255,255,255,0.04)` | Второстепенная поверхность |
| `--button-secondary-bg-hover` | `#F1F5F9` | `rgba(255,255,255,0.08)` | Hover secondary |
| `--button-secondary-border` | `#CBD5E1` | `rgba(255,255,255,0.10)` | Граница secondary |
| `--button-secondary-text` | `#334155` | `#F8FAFC` | Текст secondary |
| `--button-ghost-bg-hover` | `#F8FAFC` | `rgba(255,255,255,0.06)` | Hover ghost |
| `--button-danger-bg` | `#DC2626` | `#DC2626` | Удаление |
| `--button-danger-bg-hover` | `#B91C1C` | `#B91C1C` | Hover удаления |
| `--button-danger-subtle-bg` | `#FEF2F2` | `rgba(239,68,68,0.13)` | Риск без solid-заливки |
| `--button-danger-subtle-text` | `#B91C1C` | `#FECACA` | Текст risk subtle |
| `--button-focus-ring` | `rgba(79,70,229,0.35)` | `rgba(139,92,246,0.45)` | Focus outline |
Примечание: для всего продукта лучше выбрать один primary-акцент. Для текущей тёмной админки логично оставить фиолетовый `#8B5CF6`, а для светлой темы использовать более читаемый `#4F46E5`. Ролевые цвета преподавателя и студента лучше вынести из кнопок в расписание и бейджи.
### 5.2. Размеры
| Размер | Высота | Padding | Шрифт | Где применять |
|---|---:|---|---|---|
| `sm` | `32px` | `0 12px` | `13px / 600` | Таблицы, компактные действия строк |
| `md` | `40px` | `0 16px` | `14px / 600` | Формы, фильтры, модальные окна |
| `lg` | `48px` | `0 20px` | `15px / 700` | Вход, главный CTA экрана |
| `icon-sm` | `32x32` | `0` | icon `18px` | Табличные иконки |
| `icon-md` | `40x40` | `0` | icon `20px` | Верхние панели, модалки, навигация недель |
Базовый `border-radius`: `8px`.
Почему `8px`: интерфейс административный и рабочий. Радиус `8px` сохраняет мягкость, но выглядит собраннее, чем повсеместные `10px`, особенно в таблицах и плотных формах.
### 5.3. Варианты
#### Primary
Использовать для главного подтверждения.
| Состояние | Стиль |
|---|---|
| Default | Фон primary, белый текст, без видимой границы |
| Hover | Фон темнее на 8-12% |
| Focus | Видимый ring `2px` |
| Disabled | Нейтральный фон, muted-текст, `cursor: not-allowed` |
Примеры: `Войти`, `Создать`, `Сохранить правило`, `Создать сетку`.
#### Secondary
Использовать для обычных действий рядом с primary.
| Состояние | Стиль |
|---|---|
| Default | Surface-фон, тонкая граница, основной текст |
| Hover | Чуть более светлая/контрастная поверхность |
| Focus | Такой же focus ring, как у primary |
| Disabled | Opacity `0.5` |
Примеры: `Изменить`, `Обновить`, `Показать`, `Тест`.
#### Ghost
Использовать для тихих действий.
| Состояние | Стиль |
|---|---|
| Default | Прозрачный фон, muted/regular text |
| Hover | Лёгкий surface-hover |
| Focus | Ring без изменения иерархии |
Примеры: `Отмена`, `Очистить`, `Назад в панель`, `Свернуть`.
#### Danger
Использовать только для удаления.
| Состояние | Стиль |
|---|---|
| Default | Красная заливка, белый текст |
| Hover | Более тёмный красный |
| Focus | Красный или общий focus ring |
Примеры: `Удалить`.
#### Danger subtle
Использовать для рискованных действий без полного удаления.
| Состояние | Стиль |
|---|---|
| Default | Красный tint-фон, красный текст, мягкая border |
| Hover | Более насыщенный tint |
| Focus | Видимый ring |
Примеры: `Архивировать`, `Архив`, `Выйти`, `Удалить сетку`.
#### Icon button
Использовать для действий, где текст заменён иконкой.
| Параметр | Правило |
|---|---|
| Размер | `32x32` в таблицах, `40x40` в тулбарах |
| Иконка | 18-20px, один набор иконок |
| Текст | Только через `aria-label` |
| Форма | Квадрат с radius `8px`, не круг, кроме специальных случаев avatar/fab |
Примеры: `Переключить тему`, `Показать пароль`, `Закрыть`, `Предыдущая неделя`, `Следующая неделя`.
## 6. Маппинг текущих кнопок на новую систему
| Текущие кнопки | Новый тип | Размер |
|---|---|---|
| `Войти` | `Primary` | `lg` |
| `Создать`, `Добавить` | `Primary` | `md` |
| `Сохранить правило`, `Сохранить год`, `Сохранить семестр`, `Сохранить график`, `Сохранить сетку`, `Сохранить слот` | `Primary` | `md` |
| `Создать сетку` | `Primary` | `md` |
| `Изменить`, `Обновить`, `Показать`, `Тест`, `Сменить статус` | `Secondary` | `sm` в таблицах, `md` в формах |
| `Преподаватели`, `Подгруппы`, `Календарь`, `Сетка` | `Secondary` | `sm` |
| `Очистить`, `Очистить форму`, `Отмена`, `Свернуть`, `Из формы`, `Все` | `Ghost` | `sm` или `md` |
| `Точечные изменения / Просмотр`, `Назад в панель` | `Ghost navigation` | `md` |
| `Удалить` | `Danger` | `sm` в таблицах, `md` в модалках |
| `Архив`, `Архивировать`, `Выйти`, `Удалить сетку` | `Danger subtle` | `sm` или `md` |
| `Переключить тему`, `Показать пароль`, `Скрыть панель`, `Закрыть`, ``, `` | `Icon button` | `icon-sm` или `icon-md` |
| Пункты сайдбара | `Nav item` | отдельный nav-компонент |
## 7. Правила композиции
### Формы
Primary-кнопка должна стоять в конце формы или action row. Рядом с ней допустимы secondary/ghost-действия, но не ещё одна primary без причины.
Рекомендуемый порядок:
| Сценарий | Порядок |
|---|---|
| Форма создания | `Очистить` как ghost, `Создать` как primary |
| Форма редактирования | `Отмена` как ghost, `Сохранить` как primary |
| Фильтр | `Сбросить` как ghost, `Показать` как secondary или primary, если это единственное действие блока |
### Таблицы
В строках таблицы использовать только `sm`.
Рекомендуемый порядок:
1. Основное безопасное действие: `Изменить`, `Преподаватели`, `Подгруппы`.
2. Дополнительное действие: `Календарь`, `Сетка`.
3. Рискованное действие: `Архивировать` или `Удалить`.
Удаление лучше отделять визуально от остальных кнопок небольшим gap.
### Модальные окна
В модалке primary должен соответствовать заголовку и цели модалки.
Пример:
| Заголовок | Primary | Secondary/Ghost |
|---|---|---|
| `Новая ручная сетка` | `Создать сетку` | `Отмена` |
| `Удалить слот` | `Удалить` как danger | `Отмена` |
| `Редактировать слот` | `Сохранить слот` | `Отмена` |
Кнопка закрытия всегда icon-only с `aria-label="Закрыть"`.
### Навигация
Сайдбарные пункты не должны наследовать стили action-кнопок. У них отдельная логика:
| Состояние | Стиль |
|---|---|
| Default | Прозрачный фон, muted-текст |
| Hover | Нейтральный surface-hover |
| Active | Акцентный tint-фон, accent-текст |
| Focus | Видимый ring/outline |
## 8. Главные исправления по приоритету
| Приоритет | Что сделать | Почему важно |
|---|---|---|
| P1 | Нормализовать `btn-primary`, `btn-secondary`, `btn-delete` между основной админкой и настройками | Один класс должен означать один визуальный контракт |
| P1 | Развести `Danger` и `Danger subtle` | `Удалить` и `Архивировать` не должны выглядеть одинаково опасными |
| P1 | Ввести размерную шкалу `sm/md/lg/icon` | Таблицы и формы станут визуально ровнее |
| P2 | Унифицировать `Переключить тему`, `Закрыть`, ``, ``, `Показать пароль` как icon buttons | Повысит предсказуемость и доступность |
| P2 | Убрать ролевые primary-цвета из student/teacher кнопок | Одинаковые действия будут считываться одинаково |
| P3 | Заменить текстовые символы `◐`, `×`, ``, `` на иконки единого набора | Интерфейс станет аккуратнее и доступнее |
## 9. Визуальный референс поведения
```text
Primary
[ Сохранить правило ]
Фон: акцентный, текст белый. Только главное действие.
Secondary
[ Изменить ]
Фон: поверхность, тонкая граница. Обычное действие.
Ghost
[ Отмена ]
Фон: прозрачный. Тихое действие без конкуренции.
Danger
[ Удалить ]
Фон: красный. Только необратимое действие.
Danger subtle
[ Архивировать ]
Фон: красный tint. Рискованное, но не полноценное удаление.
Icon
[ icon ]
Фиксированный квадрат. Доступное название через aria-label.
```
## 10. Почему это решение улучшит интерфейс
Единая кнопочная система снижает когнитивную нагрузку. Пользователь перестаёт разгадывать каждый экран и начинает быстро узнавать роли: главное действие, второстепенное действие, отмена, удаление, навигация.
Разделение `Danger` и `Danger subtle` особенно важно для админ-панели. Сейчас `Архивировать` визуально конкурирует с `Удалить`, хотя риск у этих действий разный. После унификации красный solid будет означать только настоящее удаление.
Единая размерная шкала сделает таблицы спокойнее. В строках не будет скачков высоты и плотности, а формы и модалки начнут выглядеть как части одной системы.
Общий primary-цвет вернёт продукту целостность. Ролевые цвета можно сохранить там, где они помогают считывать расписание и принадлежность данных, но базовые действия должны оставаться стабильными.
## 11. Приложение: дополнительные скриншоты
| Раздел | Скриншот |
|---|---|
| Конструктор правил | ![Конструктор правил](../audit-buttons-rules.png) |
| Календарный график | ![Календарный график](../audit-buttons-calendar.png) |
| Просмотр расписаний | ![Просмотр расписаний](../audit-buttons-schedule-view.png) |
| Аудиторный фонд | ![Аудиторный фонд](../audit-buttons-classrooms.png) |
| Группы обучения | ![Группы обучения](../audit-buttons-groups.png) |
| Структура вуза | ![Структура вуза](../audit-buttons-structure.png) |
| Дисциплины | ![Дисциплины](../audit-buttons-subjects.png) |
| Пользователи | ![Пользователи](../audit-buttons-users.png) |
| Временные слоты | ![Временные слоты](../audit-buttons-settings-time-slots.png) |
| Формы обучения | ![Формы обучения](../audit-buttons-settings-study-forms.png) |
| База данных | ![База данных](../audit-buttons-settings-database.png) |

View File

@@ -146,14 +146,13 @@ frontend/
### Особенности админских вкладок ### Особенности админских вкладок
- Вкладка `groups` загружает кафедры, специальности, профили, учебные годы и календарные графики. Список групп открывается через `/api/groups?includeArchived=true`, поэтому в таблице видны активные, будущие, завершившие обучение и архивные группы со статусом. Группа создаётся через `/api/groups` с `specialtyId` и `specialtyProfileId`, блок подгрупп использует `/api/subgroups` и `/api/groups/{id}/subgroups`, а блок назначений использует `/api/groups/{id}/calendar-assignments`. В селекты подгрупп и назначений попадают только группы с `active=true`. Действия группы выводятся в `group-actions` с фиксированными колонками, чтобы активные и архивные строки не смещали кнопки. Кнопка восстановления архивной группы использует стиль `btn-restore-group`: размеры и поведение как у архивной кнопки, но с жёлтым warning-акцентом. - Вкладка `groups` загружает кафедры, специальности, профили, учебные годы и календарные графики. Список групп открывается через `/api/groups?includeArchived=true`, поэтому в таблице видны активные, будущие, завершившие обучение и архивные группы со статусом. Группа создаётся через `/api/groups` с `specialtyId` и `specialtyProfileId`, блок подгрупп использует `/api/subgroups` и `/api/groups/{id}/subgroups`, а блок назначений использует `/api/groups/{id}/calendar-assignments`. В селекты подгрупп и назначений попадают только группы с `active=true`.
- Вкладка `department-workspace` в блоке преподавателей объединяет данные `/api/department/teachers` и `/api/workload/teachers`: каждый преподаватель показывается одной компактной карточкой с должностью и нагрузкой за выбранный период, преподаватели без занятий получают нулевую нагрузку, а преподаватели из расписания добавляются без дублей. Блоки загрузки дисциплин и преподавателей стоят в равных по высоте колонках; загрузка дисциплин использует фиксированную колонку кода и равномерные отступы до названия, а у карточки преподавателей задан стабильный зазор между заголовком и нагрузкой. Если дата начала периода выбрана позже даты окончания, поле окончания очищается, а расчёт нагрузки ждёт корректный период. - Вкладка `department-workspace` в блоке преподавателей объединяет данные `/api/department/teachers` и `/api/workload/teachers`: каждый преподаватель показывается одной карточкой с должностью и нагрузкой за выбранный период, преподаватели без занятий получают нулевую нагрузку, а преподаватели из расписания добавляются без дублей. Если дата начала периода выбрана позже даты окончания, поле окончания очищается, а расчёт нагрузки ждёт корректный период.
- Вкладка `schedule-view` показывает найденные занятия в режиме одной активной таблицы. Пользователь выбирает, что смотреть: группу, преподавателя, аудиторию или кафедру; frontend запрашивает двухнедельный диапазон от понедельника выбранной даты и собирает найденные расписания в переключатель результатов. На странице не выводится стек таблиц: виден один выбранный результат, а остальные доступны через чипы и кнопки предыдущего/следующего расписания; чипы результатов переносятся и отделены от счётчика стабильным отступом. Для режима кафедры и роли `DEPARTMENT` расписание ограничивается кафедрой пользователя; преподавательские и студенческие отдельные страницы пока остаются самостоятельными. Таблица строится как строки пар и столбцы дней недели. Нечётная неделя отображается в верхней половине ячейки, чётная — в нижней, а одинаковые занятия в обе недели схлопываются в цельную ячейку. На мобильной ширине вместо широкой недельной матрицы показывается один день активного расписания с переключателем дней. - Вкладка `schedule-view` показывает найденные занятия в режиме одной активной таблицы. Пользователь выбирает, что смотреть: группу, преподавателя, аудиторию или кафедру; frontend запрашивает двухнедельный диапазон от понедельника выбранной даты и собирает найденные расписания в переключатель результатов. На странице не выводится стек таблиц: виден один выбранный результат, а остальные доступны через чипы и кнопки предыдущего/следующего расписания; чипы результатов переносятся и отделены от счётчика стабильным отступом. Для режима кафедры и роли `DEPARTMENT` расписание ограничивается кафедрой пользователя; преподавательские и студенческие отдельные страницы пока остаются самостоятельными. Таблица строится как строки пар и столбцы дней недели. Нечётная неделя отображается в верхней половине ячейки, чётная — в нижней, а одинаковые занятия в обе недели схлопываются в цельную ячейку. Бейдж диапазона недель скрывается для занятий на весь семестр, а для занятий до конца семестра показывает только неделю начала в формате `(с 5 нед.)`. На мобильной ширине вместо широкой недельной матрицы показывается один день активного расписания с переключателем дней.
- Вкладка `auditorium-workload` стала общей вкладкой `Загруженность`: в поле «Что смотреть» выбираются аудитории, преподаватели или кафедры. Сводная матрица по выбранной дате использует одинаковую структуру: строки — выбранный тип сущности, столбцы — эффективные временные слоты дня из `/api/admin/time-slots/effective`, занятость собирается из динамического расписания `/api/schedule` по группам. Кафедральная матрица группирует занятия по кафедре преподавателя. Для аудиторий доступны фильтры корпуса, вместимости и оборудования. В поле «Отображение» можно выбрать конкретную аудиторию, преподавателя или кафедру; тогда сводная матрица заменяется одной таблицей по дням недели и времени для двухнедельного периода от выбранной даты. Таблица выбранной сущности растягивается до нижней части экрана. Ячейка делится вертикально только если верхняя и нижняя недели отличаются: нечётная неделя отображается сверху, чётная — снизу. Если состояние или занятие одинаковое, ячейка остаётся цельной. Чётность берётся из расписания, а для свободных дней рассчитывается по семестрам из `/api/admin/calendar/years`. - Вкладка `auditorium-workload` стала общей вкладкой `Загруженность`: в поле «Что смотреть» выбираются аудитории, преподаватели или кафедры. Сводная матрица по выбранной дате использует одинаковую структуру: строки — выбранный тип сущности, столбцы — эффективные временные слоты дня из `/api/admin/time-slots/effective`, занятость собирается из динамического расписания `/api/schedule` по группам. Кафедральная матрица группирует занятия по кафедре преподавателя. Для аудиторий доступны фильтры корпуса, вместимости и оборудования. В поле «Отображение» можно выбрать конкретную аудиторию, преподавателя или кафедру; тогда сводная матрица заменяется одной таблицей по дням недели и времени для двухнедельного периода от выбранной даты. Таблица выбранной сущности растягивается до нижней части экрана. Ячейка делится вертикально только если верхняя и нижняя недели отличаются: нечётная неделя отображается сверху, чётная — снизу. Если состояние или занятие одинаковое, ячейка остаётся цельной. Чётность берётся из расписания, а для свободных дней рассчитывается по семестрам из `/api/admin/calendar/years`.
- Вкладка `profiles` выделена под профили обучения: администратор выбирает специальность, создаёт профиль, редактирует описание и удаляет неиспользуемые профили. - Вкладка `profiles` выделена под профили обучения: администратор выбирает специальность, создаёт профиль, редактирует описание и удаляет неиспользуемые профили.
- Вкладка `schedule` не обращается к старым `lessons` API. Создание и редактирование расписания выполняется через правила `/api/admin/schedule-rules`, где каждое правило содержит группы, отдельные часы и недели начала для лекций, лабораторных и практик, а также набор базовых слотов. Типы занятий в селекте слотов сортируются в том же порядке, что и блоки часов: лекция, лабораторная, практика. Группы выбираются через выпадающий мультиселект. Поле подгруппы появляется только при выборе лабораторной работы; для лекций и практик оно не отображается. Если в правиле выбрана одна группа, селект подгруппы содержит пункт `Вся группа`; если выбрано несколько групп, лабораторный слот показывает мультиселект подгрупп, чтобы выбрать разные подгруппы разных групп. Список слотов отображается без внутреннего вертикального скролла: при добавлении строк форма расширяется вниз, а кнопка сохранения остаётся отдельным блоком под слотами. Из календарной системы здесь используется список семестров для выбора периода действия правила. Справа доступна сворачиваемая визуальная матрица: столбцы — выбранные в фильтре группы, строки — только день и время, где есть активные пары, ячейки показывают дисциплину, диапазон недель, тип, формат, преподавателя, аудиторию и подгруппы. Если нечётная и чётная недели отличаются, ячейка делится на две половины; одинаковые занятия схлопываются в цельную ячейку. - Вкладка `schedule` не обращается к старым `lessons` API. Создание и редактирование расписания выполняется через правила `/api/admin/schedule-rules`, где каждое правило содержит группы, отдельные часы и недели начала для лекций, лабораторных и практик, а также набор базовых слотов. Группы выбираются через выпадающий мультиселект. Поле подгруппы появляется только при выборе лабораторной работы; для лекций и практик оно не отображается. Если в правиле выбрана одна группа, селект подгруппы содержит пункт `Вся группа`; если выбрано несколько групп, лабораторный слот показывает мультиселект подгрупп, чтобы выбрать разные подгруппы разных групп. Типы занятий в слоте сортируются в порядке: лекция, лабораторная работа, практика. Список слотов отображается без внутреннего вертикального скролла: при добавлении строк форма расширяется вниз, а кнопка сохранения остаётся отдельным блоком под слотами. Из календарной системы здесь используется список семестров для выбора периода действия правила. Справа доступна сворачиваемая визуальная матрица: пользователь выбирает учебный год, семестр и группы, после чего матрица строится только по правилам выбранного семестра. Столбцы — выбранные в фильтре группы, строки — только день и время, где есть активные пары, ячейки показывают дисциплину, диапазон недель, тип, формат, преподавателя, аудиторию и подгруппы. Период недель не показывается для занятия на весь семестр; если занятие идёт до конца семестра не с первой недели, выводится только неделя начала в формате `(с 5 нед.)`, а ограниченный диапазон — как `(с 1 по 3 нед.)`. Если нечётная и чётная недели отличаются, ячейка делится на две половины; одинаковые занятия схлопываются в цельную ячейку. Кнопка с тремя точками в правой части карточки пары закрывает матрицу и открывает соответствующее правило в форме редактирования. В списке правил действия отображаются едиными кнопками одинакового размера с отступами между ними.
- Вкладка `academic-calendar` полностью отделяет календарную систему от расписания занятий: администратор создаёт учебные годы и семестры, заводит календарные графики, выбирает форму обучения из общего справочника `/api/education-forms`, заполняет дневную сетку по курсам и кодам активностей, назначает ручную временную сетку на конкретную дату, а сохранение сетки идёт через `/api/admin/academic-calendars/{id}/grid`. - Вкладка `academic-calendar` полностью отделяет календарную систему от расписания занятий: администратор создаёт учебные годы и семестры, заводит календарные графики, выбирает форму обучения из общего справочника `/api/education-forms`, заполняет дневную сетку по курсам и кодам активностей, назначает ручную временную сетку на конкретную дату, а сохранение сетки идёт через `/api/admin/academic-calendars/{id}/grid`.
- В таблице учебных годов семестры показываются компактными отдельными плашками в горизонтальной сетке: осенний и весенний семестры стоят рядом, у каждого есть собственная кнопка редактирования, а название года, период и действия учебного года центрируются относительно строки семестров. После нажатия заголовок формы уточняет, какой семестр открыт. Периоды учебных годов и семестров отображаются в формате `дд-мм-гггг — дд-мм-гггг`.
- Редактор годового графика во вкладке `academic-calendar` показывает компактную табличную сетку: курсы раскрываются отдельными секциями со стрелкой, семестры внутри курса на широком экране идут рядом в две равные колонки одинаковой высоты, столбцы подписаны номерами недель учебного года, строки — днями недели, а ячейки содержат буквенный код активности. В рамках одного курса семестровые таблицы получают одинаковое число недельных колонок: недостающие колонки заполняются пустыми ячейками, поэтому левая и правая части занимают всю ширину секции курса без внешней пустоты. Подробная расшифровка и изменение кода открываются в компактном модальном окне по клику на ячейку; при наведении отображается кастомная подсказка с датой, курсом, кодом активности и временной сеткой, а код активности в подсказке центрируется внутри квадратного индикатора. Ячейки можно выделять протяжкой мышью и менять код активности через ту же модалку, выбранные ячейки подсвечиваются мягкой заливкой, а кнопка «Применить» закрывает окно. - Редактор годового графика во вкладке `academic-calendar` показывает компактную табличную сетку: курсы раскрываются отдельными секциями со стрелкой, семестры внутри курса на широком экране идут рядом в две равные колонки одинаковой высоты, столбцы подписаны номерами недель учебного года, строки — днями недели, а ячейки содержат буквенный код активности. В рамках одного курса семестровые таблицы получают одинаковое число недельных колонок: недостающие колонки заполняются пустыми ячейками, поэтому левая и правая части занимают всю ширину секции курса без внешней пустоты. Подробная расшифровка и изменение кода открываются в компактном модальном окне по клику на ячейку; при наведении отображается кастомная подсказка с датой, курсом, кодом активности и временной сеткой, а код активности в подсказке центрируется внутри квадратного индикатора. Ячейки можно выделять протяжкой мышью и менять код активности через ту же модалку, выбранные ячейки подсвечиваются мягкой заливкой, а кнопка «Применить» закрывает окно.
- Вкладка `departments-data` использует модальные формы редактирования и маршруты `PUT/DELETE /api/departments/{id}` и `PUT/DELETE /api/specialties/{id}`. - Вкладка `departments-data` использует модальные формы редактирования и маршруты `PUT/DELETE /api/departments/{id}` и `PUT/DELETE /api/specialties/{id}`.
- Вкладка `classrooms` теперь показывает архивные аудитории через `includeArchived=true`. Кнопка удаления заменена на архивирование: аудитория выводится из эксплуатации, но остаётся в историческом расписании. Для архивных аудиторий доступно восстановление. - Вкладка `classrooms` теперь показывает архивные аудитории через `includeArchived=true`. Кнопка удаления заменена на архивирование: аудитория выводится из эксплуатации, но остаётся в историческом расписании. Для архивных аудиторий доступно восстановление.
@@ -231,8 +230,6 @@ export const api = {
- `TEACHER``/teacher/` - `TEACHER``/teacher/`
- `STUDENT``/student/` - `STUDENT``/student/`
При неуспешной авторизации `script.js` оставляет введённый логин, очищает поле пароля и возвращает фокус в него.
### Проверка авторизации ### Проверка авторизации
На каждой странице проверяется наличие токена и роли: На каждой странице проверяется наличие токена и роли:
@@ -312,10 +309,6 @@ export function isAuthenticatedAsAdmin() {
4. **`modals.css`** — Модальные окна 4. **`modals.css`** — Модальные окна
5. **`departments-data.css`** — Стили создания кафедры/специальности 5. **`departments-data.css`** — Стили создания кафедры/специальности
Мультиселекты с чекбоксами используют общий паттерн `.checkbox-item` + `.checkmark` + `.checkbox-label`; вкладка `classrooms` применяет его для выбора оборудования в создании и редактировании аудитории.
Дашборд использует классы `dashboard-container`, `dashboard-metrics-grid`, `dashboard-middle` и `dashboard-side-stack`: средний ряд растягивается по высоте самого высокого столбца, а правые компактные карточки выравниваются одинаковыми строками.
### Темизация ### Темизация
CSS-переменные позволяют поддерживать светлую/тёмную тему: CSS-переменные позволяют поддерживать светлую/тёмную тему:
@@ -324,16 +317,27 @@ CSS-переменные позволяют поддерживать светл
:root { :root {
--bg-primary: #ffffff; --bg-primary: #ffffff;
--text-primary: #1a1a2e; --text-primary: #1a1a2e;
--accent: #6366f1; --accent: #4F46E5;
} }
[data-theme="dark"] { [data-theme="dark"] {
--bg-primary: #0f0f23; --bg-primary: #0f0f23;
--text-primary: #e2e8f0; --text-primary: #e2e8f0;
--accent: #818cf8; --accent: #8B5CF6;
} }
``` ```
### Система кнопок
Кнопки админ-панели, отдельной SPA настроек, страницы входа и личных кабинетов используют единую семантическую модель:
- `btn` — обязательная база для всех кнопок и ссылок-кнопок.
- Размеры: `btn-sm` — 32px для табличных строк, `btn-md` — 40px для форм и панелей, `btn-lg` — 48px для входа и главных широких действий.
- Иконки: `btn-icon-sm` — 32x32, `btn-icon-md` — 40x40. Все icon-only кнопки должны иметь `aria-label` на русском языке.
- Варианты: `btn-primary` для основного действия блока, `btn-secondary` для редактирования/обновления/показа, `btn-ghost` для очистки/отмены/сворачивания, `btn-danger` для удаления, `btn-danger-subtle` для архивирования, выхода и других рискованных действий без немедленного удаления.
- Единый радиус кнопок — `8px`. Локальные переопределения `.btn-primary`, `.btn-secondary`, `.btn-delete` в feature CSS запрещены: новые экраны должны наследовать токены из `frontend/admin/css/components.css`.
- Primary-акцент общий для продукта: `#4F46E5` в светлой теме и `#8B5CF6` в тёмной. Ролевые цвета кнопок в кабинетах преподавателя и студента не используются.
Переключение — через `theme-toggle.js`. Переключение — через `theme-toggle.js`.
--- ---
@@ -345,8 +349,6 @@ CSS-переменные позволяют поддерживать светл
- **Мобильные** (`≤768px`): sidebar скрывается за кнопкой-гамбургер, выезжает как overlay с затемнением - **Мобильные** (`≤768px`): sidebar скрывается за кнопкой-гамбургер, выезжает как overlay с затемнением
- **Dropdown «Настройки»** в footer sidebar — содержит ссылку на страницу настроек и кнопку выхода - **Dropdown «Настройки»** в footer sidebar — содержит ссылку на страницу настроек и кнопку выхода
- **Секционные подписи sidebar** (`Расписание`, `Ресурсы`, `Система`) оформляются в `layout.css`, а их контраст для светлой и тёмной темы задаётся переменными `--sidebar-section-title` и `--sidebar-section-divider` в `main.css`.
--- ---
## OpenTelemetry (`otel.js`) ## OpenTelemetry (`otel.js`)

View File

@@ -654,32 +654,6 @@
gap: 0.45rem; gap: 0.45rem;
} }
.schedule-view-shell .btn-secondary {
min-height: 42px;
padding: 0.65rem 0.9rem;
border: 1px solid var(--bg-card-border);
border-radius: var(--radius-sm);
background: var(--bg-input);
color: var(--text-primary);
font: inherit;
font-size: 0.9rem;
font-weight: 600;
cursor: pointer;
transition: background var(--transition), border-color var(--transition), color var(--transition), transform var(--transition);
}
.schedule-view-shell .btn-secondary:hover:not(:disabled) {
background: var(--bg-hover);
border-color: var(--accent);
color: var(--accent-hover);
transform: translateY(-1px);
}
.schedule-view-shell .btn-secondary:disabled {
cursor: default;
opacity: 0.45;
}
.schedule-view-extra-filters { .schedule-view-extra-filters {
margin-top: 0.9rem; margin-top: 0.9rem;
border-top: 1px solid var(--bg-card-border); border-top: 1px solid var(--bg-card-border);
@@ -944,8 +918,8 @@
width: 100%; width: 100%;
} }
.schedule-view-period-buttons .btn-secondary, .schedule-view-period-buttons .btn,
.schedule-view-result-actions .btn-secondary { .schedule-view-result-actions .btn {
flex: 1; flex: 1;
} }

View File

@@ -1,3 +1,45 @@
/* ===== Button Tokens ===== */
:root {
--button-primary-bg: #8B5CF6;
--button-primary-bg-hover: #7C3AED;
--button-primary-text: #FFFFFF;
--button-secondary-bg: rgba(255, 255, 255, 0.04);
--button-secondary-bg-hover: rgba(255, 255, 255, 0.08);
--button-secondary-border: rgba(255, 255, 255, 0.10);
--button-secondary-text: #F8FAFC;
--button-ghost-bg-hover: rgba(255, 255, 255, 0.06);
--button-danger-bg: #DC2626;
--button-danger-bg-hover: #B91C1C;
--button-danger-subtle-bg: rgba(239, 68, 68, 0.13);
--button-danger-subtle-bg-hover: rgba(239, 68, 68, 0.20);
--button-danger-subtle-border: rgba(239, 68, 68, 0.25);
--button-danger-subtle-text: #FECACA;
--button-focus-ring: rgba(139, 92, 246, 0.45);
--button-disabled-bg: rgba(148, 163, 184, 0.16);
--button-disabled-text: #94A3B8;
--button-radius: 8px;
}
[data-theme="light"] {
--button-primary-bg: #4F46E5;
--button-primary-bg-hover: #4338CA;
--button-primary-text: #FFFFFF;
--button-secondary-bg: #FFFFFF;
--button-secondary-bg-hover: #F1F5F9;
--button-secondary-border: #CBD5E1;
--button-secondary-text: #334155;
--button-ghost-bg-hover: #F8FAFC;
--button-danger-bg: #DC2626;
--button-danger-bg-hover: #B91C1C;
--button-danger-subtle-bg: #FEF2F2;
--button-danger-subtle-bg-hover: #FEE2E2;
--button-danger-subtle-border: #FECACA;
--button-danger-subtle-text: #B91C1C;
--button-focus-ring: rgba(79, 70, 229, 0.35);
--button-disabled-bg: #CBD5E1;
--button-disabled-text: #64748B;
}
/* ===== Cards ===== */ /* ===== Cards ===== */
.card { .card {
background: var(--bg-card); background: var(--bg-card);
@@ -191,17 +233,17 @@
} }
.dashboard-action-link.btn-secondary { .dashboard-action-link.btn-secondary {
color: var(--accent-hover); color: var(--button-secondary-text);
background: var(--bg-input); background: var(--button-secondary-bg);
border: 1px solid var(--bg-card-border); border: 1px solid var(--button-secondary-border);
border-radius: var(--radius-sm); border-radius: var(--button-radius);
box-shadow: none; box-shadow: none;
} }
.dashboard-action-link.btn-secondary:hover { .dashboard-action-link.btn-secondary:hover {
color: var(--text-primary); color: var(--button-secondary-text);
background: var(--bg-hover); background: var(--button-secondary-bg-hover);
border-color: var(--accent); border-color: var(--button-secondary-border);
} }
@media (max-width: 1200px) { @media (max-width: 1200px) {
@@ -517,6 +559,65 @@
clear: both; clear: both;
} }
.schedule-rule-table-actions {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 0.5rem;
min-width: 188px;
}
.schedule-rule-table-button {
display: inline-flex;
align-items: center;
justify-content: center;
min-width: 86px;
min-height: 32px;
padding: 0.38rem 0.72rem;
border: 1px solid var(--bg-card-border);
border-radius: var(--radius-sm);
background: var(--bg-input);
color: var(--text-primary);
font: inherit;
font-size: 0.82rem;
font-weight: 700;
line-height: 1.1;
cursor: pointer;
transition: background var(--transition), border-color var(--transition), color var(--transition), transform var(--transition), box-shadow var(--transition);
}
.schedule-rule-table-button:hover,
.schedule-rule-table-button:focus-visible {
outline: none;
transform: translateY(-1px);
}
.schedule-rule-table-button-edit {
border-color: color-mix(in srgb, var(--accent) 34%, var(--bg-card-border));
background: color-mix(in srgb, var(--accent) 12%, var(--bg-input));
color: var(--accent-hover);
}
.schedule-rule-table-button-edit:hover,
.schedule-rule-table-button-edit:focus-visible {
border-color: var(--accent);
background: color-mix(in srgb, var(--accent) 22%, var(--bg-input));
box-shadow: 0 4px 14px color-mix(in srgb, var(--accent-glow) 60%, transparent);
}
.schedule-rule-table-button-delete {
border-color: color-mix(in srgb, var(--error) 34%, var(--bg-card-border));
background: color-mix(in srgb, var(--error) 10%, var(--bg-input));
color: var(--error);
}
.schedule-rule-table-button-delete:hover,
.schedule-rule-table-button-delete:focus-visible {
border-color: var(--error);
background: color-mix(in srgb, var(--error) 18%, var(--bg-input));
box-shadow: 0 4px 14px rgba(248, 113, 113, 0.14);
}
.slot-subgroup-multi .select-text { .slot-subgroup-multi .select-text {
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
@@ -629,34 +730,33 @@
top: 50%; top: 50%;
right: 0; right: 0;
z-index: 905; z-index: 905;
width: 42px; width: 40px;
height: 74px; height: 40px;
border: 1px solid var(--bg-card-border); border: 1px solid var(--button-secondary-border);
border-right: 0; border-right: 0;
border-radius: 10px 0 0 10px; border-radius: var(--button-radius) 0 0 var(--button-radius);
background: color-mix(in srgb, var(--bg-card) 92%, var(--accent)); background: var(--button-secondary-bg);
color: var(--text-primary); color: var(--button-secondary-text);
font: inherit;
font-size: 1.8rem;
font-weight: 800;
line-height: 1;
cursor: pointer;
box-shadow: -8px 0 24px rgba(0, 0, 0, 0.2); box-shadow: -8px 0 24px rgba(0, 0, 0, 0.2);
transform: translateY(-50%); transform: translateY(-50%);
transition: right var(--transition), background var(--transition), border-color var(--transition), color var(--transition); transition: right var(--transition), background var(--transition), border-color var(--transition), color var(--transition), box-shadow var(--transition);
} }
.schedule-visual-handle:hover, .schedule-visual-handle:hover,
.schedule-visual-handle.open { .schedule-visual-handle.open {
border-color: var(--accent); border-color: var(--button-secondary-border);
background: color-mix(in srgb, var(--accent) 20%, var(--bg-card)); background: var(--button-secondary-bg-hover);
color: var(--accent-hover); color: var(--button-secondary-text);
} }
.schedule-visual-handle.open { .schedule-visual-handle.open {
right: min(92vw, 1180px); right: min(92vw, 1180px);
} }
.schedule-visual-handle.open .schedule-visual-handle-icon {
transform: rotate(180deg);
}
.schedule-visual-backdrop { .schedule-visual-backdrop {
position: fixed; position: fixed;
inset: 0; inset: 0;
@@ -714,6 +814,17 @@
gap: 0.55rem; gap: 0.55rem;
} }
.schedule-visual-period-grid {
display: grid;
grid-template-columns: repeat(2, minmax(180px, 1fr));
gap: 0.65rem;
align-items: end;
}
.schedule-visual-period-grid .form-group {
min-width: 0;
}
.schedule-visual-filter-head { .schedule-visual-filter-head {
align-items: center; align-items: center;
} }
@@ -950,9 +1061,10 @@
} }
.schedule-visual-lesson { .schedule-visual-lesson {
position: relative;
display: grid; display: grid;
gap: 0.12rem; gap: 0.12rem;
padding: 0.34rem; padding: 0.34rem 1.65rem 0.34rem 0.34rem;
border: 1px solid var(--bg-card-border); border: 1px solid var(--bg-card-border);
border-radius: var(--radius-sm); border-radius: var(--radius-sm);
background: var(--bg-card); background: var(--bg-card);
@@ -976,6 +1088,37 @@
line-height: 1.14; line-height: 1.14;
} }
.schedule-visual-edit-rule {
position: absolute;
top: 0.26rem;
right: 0.26rem;
display: inline-flex;
align-items: center;
justify-content: center;
width: 1.1rem;
height: 1.1rem;
padding: 0;
border: 1px solid color-mix(in srgb, var(--accent) 36%, var(--bg-card-border));
border-radius: 50%;
background: color-mix(in srgb, var(--accent) 12%, var(--bg-card));
color: var(--accent-hover);
font: inherit;
font-size: 0.82rem;
font-weight: 800;
line-height: 1;
cursor: pointer;
transition: background var(--transition), border-color var(--transition), color var(--transition), transform var(--transition);
}
.schedule-visual-edit-rule:hover,
.schedule-visual-edit-rule:focus-visible {
border-color: var(--accent);
background: color-mix(in srgb, var(--accent) 22%, var(--bg-card));
color: var(--text-primary);
outline: none;
transform: translateY(-1px);
}
.schedule-visual-free { .schedule-visual-free {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -1005,6 +1148,10 @@
display: grid; display: grid;
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.schedule-visual-period-grid {
grid-template-columns: 1fr;
}
} }
.calendar-fill-panel { .calendar-fill-panel {
@@ -2047,49 +2194,164 @@ input[type="number"] {
} }
/* ===== Buttons ===== */ /* ===== Buttons ===== */
.btn-primary { .btn {
position: relative; display: inline-flex;
overflow: hidden; align-items: center;
padding: 0.75rem 1.75rem; justify-content: center;
background: linear-gradient(135deg, var(--accent), var(--accent-secondary)); gap: 0.45rem;
border: none; min-width: 0;
border-radius: var(--radius-sm); border: 1px solid transparent;
color: #fff; border-radius: var(--button-radius);
background: transparent;
color: inherit;
font-family: inherit; font-family: inherit;
font-size: 0.95rem; font-size: 0.875rem;
font-weight: 600; font-weight: 600;
letter-spacing: 0.02em; line-height: 1;
cursor: pointer; text-align: center;
text-decoration: none;
white-space: nowrap; white-space: nowrap;
transition: all var(--transition); cursor: pointer;
box-shadow: 0 4px 15px var(--accent-glow); user-select: none;
transition:
background var(--transition),
border-color var(--transition),
color var(--transition),
box-shadow var(--transition),
transform var(--transition);
} }
.btn-primary::before { .btn:hover:not(:disabled) {
content: ''; transform: translateY(-1px);
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: linear-gradient(rgba(255, 255, 255, 0.2), transparent);
border-radius: inherit;
opacity: 0;
transition: opacity var(--transition);
} }
.btn-primary:hover { .btn:focus-visible {
transform: translateY(-2px); outline: none;
box-shadow: 0 8px 25px var(--accent-glow); box-shadow: 0 0 0 3px var(--button-focus-ring);
} }
.btn-primary:hover::before { .btn:active:not(:disabled) {
opacity: 1; transform: translateY(0);
} }
.btn-primary:active { .btn:disabled,
transform: translateY(1px); .btn[aria-disabled="true"] {
box-shadow: 0 2px 10px var(--accent-glow); background: var(--button-disabled-bg);
border-color: transparent;
color: var(--button-disabled-text);
cursor: not-allowed;
opacity: 0.75;
transform: none;
}
.btn-sm {
min-height: 32px;
padding: 0 12px;
font-size: 13px;
font-weight: 600;
}
.btn-md {
min-height: 40px;
padding: 0 16px;
font-size: 14px;
font-weight: 600;
}
.btn-lg {
min-height: 48px;
padding: 0 20px;
font-size: 15px;
font-weight: 700;
}
.btn-icon-sm,
.btn-icon-md {
display: inline-flex;
align-items: center;
justify-content: center;
flex: 0 0 auto;
padding: 0;
border-radius: var(--button-radius);
line-height: 1;
}
.btn-icon-sm {
width: 32px;
height: 32px;
}
.btn-icon-md {
width: 40px;
height: 40px;
}
.btn-icon-sm svg {
width: 18px;
height: 18px;
}
.btn-icon-md svg {
width: 20px;
height: 20px;
}
.btn-primary {
background: var(--button-primary-bg);
border-color: transparent;
color: var(--button-primary-text);
box-shadow: none;
}
.btn-primary:hover:not(:disabled) {
background: var(--button-primary-bg-hover);
color: var(--button-primary-text);
}
.btn-secondary {
background: var(--button-secondary-bg);
border-color: var(--button-secondary-border);
color: var(--button-secondary-text);
}
.btn-secondary:hover:not(:disabled) {
background: var(--button-secondary-bg-hover);
border-color: var(--button-secondary-border);
color: var(--button-secondary-text);
}
.btn-ghost {
background: transparent;
border-color: transparent;
color: var(--text-secondary);
}
.btn-ghost:hover:not(:disabled) {
background: var(--button-ghost-bg-hover);
color: var(--text-primary);
}
.btn-danger {
background: var(--button-danger-bg);
border-color: transparent;
color: #FFFFFF;
}
.btn-danger:hover:not(:disabled) {
background: var(--button-danger-bg-hover);
color: #FFFFFF;
}
.btn-danger-subtle {
background: var(--button-danger-subtle-bg);
border-color: var(--button-danger-subtle-border);
color: var(--button-danger-subtle-text);
}
.btn-danger-subtle:hover:not(:disabled) {
background: var(--button-danger-subtle-bg-hover);
border-color: var(--button-danger-subtle-border);
color: var(--button-danger-subtle-text);
} }
.group-actions-cell { .group-actions-cell {
@@ -2106,40 +2368,22 @@ input[type="number"] {
} }
.group-action-btn { .group-action-btn {
display: inline-flex;
align-items: center;
justify-content: center;
width: 100%; width: 100%;
min-height: 28px;
padding: 0.35rem 0.65rem;
font-size: 0.82rem;
line-height: 1;
white-space: nowrap;
} }
.btn-delete,
.btn-restore-group { .btn-restore-group {
padding: 0.35rem 0.7rem; min-height: 32px;
border-radius: var(--radius-sm); padding: 0 12px;
border-radius: var(--button-radius);
font-family: inherit; font-family: inherit;
font-size: 0.8rem; font-size: 13px;
font-weight: 600;
cursor: pointer; cursor: pointer;
transition: background var(--transition), transform var(--transition); transition: background var(--transition), transform var(--transition);
position: relative; position: relative;
overflow: hidden; overflow: hidden;
} }
.btn-delete {
background: rgba(248, 113, 113, 0.1);
border: 1px solid rgba(248, 113, 113, 0.2);
color: var(--error);
}
.btn-delete:hover {
background: rgba(248, 113, 113, 0.2);
transform: scale(1.05);
}
.btn-restore-group { .btn-restore-group {
background: rgba(245, 158, 11, 0.1); background: rgba(245, 158, 11, 0.1);
border: 1px solid rgba(245, 158, 11, 0.24); border: 1px solid rgba(245, 158, 11, 0.24);
@@ -2152,47 +2396,18 @@ input[type="number"] {
} }
.btn-icon-toggle { .btn-icon-toggle {
background: transparent; width: 32px;
border: 1px solid var(--bg-card-border); height: 32px;
color: var(--text-secondary);
width: 28px;
height: 28px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 6px;
cursor: pointer;
transition: all 0.2s cubic-bezier(0.4, 0, 0.2, 1);
padding: 0;
} }
.btn-icon-toggle:hover { .btn-icon-toggle:hover {
background: var(--bg-card);
border-color: var(--accent);
color: var(--accent);
transform: rotate(45deg);
box-shadow: 0 0 10px var(--accent-glow);
}
.btn-edit-classroom {
padding: 0.35rem 0.7rem;
background: rgba(99, 102, 241, 0.1);
border: 1px solid rgba(99, 102, 241, 0.2);
border-radius: var(--radius-sm);
color: var(--accent-hover);
cursor: pointer;
transition: all var(--transition);
}
.btn-edit-classroom:hover {
background: rgba(99, 102, 241, 0.2);
transform: translateY(-1px); transform: translateY(-1px);
} }
.group-actions .group-action-btn { .group-actions .group-action-btn {
min-height: 28px; min-height: 32px;
padding: 0.35rem 0.65rem; padding: 0 12px;
font-size: 0.82rem; font-size: 13px;
} }
/* ===== Alerts ===== */ /* ===== Alerts ===== */
@@ -2566,20 +2781,19 @@ tbody tr:hover {
/* ===== Theme Toggle Button ===== */ /* ===== Theme Toggle Button ===== */
.theme-toggle { .theme-toggle {
width: 42px; width: 40px;
height: 42px; height: 40px;
border: none; border-radius: var(--button-radius);
border-radius: 50%; background: var(--button-secondary-bg);
background: var(--bg-card); border: 1px solid var(--button-secondary-border);
border: 1px solid var(--bg-card-border); color: var(--button-secondary-text);
color: var(--text-primary); display: inline-flex;
cursor: pointer;
display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
backdrop-filter: blur(12px); backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px); -webkit-backdrop-filter: blur(12px);
transition: background 0.3s ease, transform 0.3s ease, box-shadow 0.3s ease; padding: 0;
transition: background var(--transition), border-color var(--transition), color var(--transition), box-shadow var(--transition), transform var(--transition);
z-index: 100; z-index: 100;
flex-shrink: 0; flex-shrink: 0;
} }
@@ -2591,12 +2805,18 @@ tbody tr:hover {
} }
.theme-toggle:hover { .theme-toggle:hover {
transform: scale(1.1); background: var(--button-secondary-bg-hover);
box-shadow: 0 4px 16px var(--accent-glow); border-color: var(--button-secondary-border);
transform: translateY(-1px);
} }
.theme-toggle:active { .theme-toggle:active {
transform: scale(0.95); transform: translateY(0);
}
.theme-toggle:focus-visible {
outline: none;
box-shadow: 0 0 0 3px var(--button-focus-ring);
} }
.theme-toggle--fixed { .theme-toggle--fixed {

View File

@@ -25,15 +25,7 @@
} }
.sidebar-close-btn { .sidebar-close-btn {
background: transparent;
border: none;
color: var(--text-secondary); color: var(--text-secondary);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
padding: 0.25rem;
border-radius: var(--radius-sm);
transition: all var(--transition); transition: all var(--transition);
} }
@@ -56,21 +48,6 @@
padding: 0.75rem; padding: 0.75rem;
} }
.sidebar-section-divider {
height: 1px;
background: var(--sidebar-section-divider);
margin: 0.5rem 1.5rem;
}
.sidebar-section-title {
padding: 0.5rem 1.25rem 0.25rem;
color: var(--sidebar-section-title);
font-size: 0.7rem;
font-weight: 700;
letter-spacing: 0.05em;
text-transform: uppercase;
}
.nav-item { .nav-item {
display: flex; display: flex;
align-items: center; align-items: center;
@@ -322,14 +299,6 @@
overflow: visible; overflow: visible;
} }
.sidebar.collapsed .sidebar-section-title {
display: none;
}
.sidebar.collapsed .sidebar-section-divider {
margin: 0.5rem;
}
.sidebar.collapsed .btn-settings { .sidebar.collapsed .btn-settings {
justify-content: center; justify-content: center;
padding: 0.65rem 0; padding: 0.65rem 0;
@@ -378,12 +347,7 @@
/* ===== Mobile Menu Toggle ===== */ /* ===== Mobile Menu Toggle ===== */
.menu-toggle { .menu-toggle {
display: none; display: none;
padding: 0.4rem;
background: none;
border: none;
color: var(--text-primary); color: var(--text-primary);
cursor: pointer;
border-radius: var(--radius-sm);
transition: background var(--transition); transition: background var(--transition);
} }

View File

@@ -16,8 +16,6 @@
--bg-input: rgba(255, 255, 255, 0.04); --bg-input: rgba(255, 255, 255, 0.04);
--bg-input-focus: rgba(255, 255, 255, 0.08); --bg-input-focus: rgba(255, 255, 255, 0.08);
--bg-hover: rgba(255, 255, 255, 0.06); --bg-hover: rgba(255, 255, 255, 0.06);
--sidebar-section-title: rgba(248, 250, 252, 0.45);
--sidebar-section-divider: rgba(229, 231, 235, 0.1);
/* Typography */ /* Typography */
--text-primary: #f8fafc; --text-primary: #f8fafc;
@@ -50,14 +48,12 @@
--bg-input: rgba(0, 0, 0, 0.03); --bg-input: rgba(0, 0, 0, 0.03);
--bg-input-focus: rgba(0, 0, 0, 0.06); --bg-input-focus: rgba(0, 0, 0, 0.06);
--bg-hover: rgba(0, 0, 0, 0.05); --bg-hover: rgba(0, 0, 0, 0.05);
--sidebar-section-title: rgba(51, 65, 85, 0.78);
--sidebar-section-divider: rgba(148, 163, 184, 0.32);
--text-primary: #0f172a; --text-primary: #0f172a;
--text-secondary: #475569; --text-secondary: #475569;
--text-placeholder: #94a3b8; --text-placeholder: #94a3b8;
--accent: #6366f1; --accent: #4F46E5;
--accent-hover: #4f46e5; --accent-hover: #4338CA;
--accent-glow: rgba(99, 102, 241, 0.3); --accent-glow: rgba(79, 70, 229, 0.3);
--accent-secondary: #d946ef; --accent-secondary: #d946ef;
--error: #ef4444; --error: #ef4444;

View File

@@ -16,6 +16,19 @@
<link rel="stylesheet" href="css/modals.css"> <link rel="stylesheet" href="css/modals.css">
<link rel="stylesheet" href="css/departments-data.css"> <link rel="stylesheet" href="css/departments-data.css">
<link rel="stylesheet" href="css/auditorium-workload.css"> <link rel="stylesheet" href="css/auditorium-workload.css">
<style>
.sidebar.collapsed .sidebar-section-title {
display: none !important;
}
.sidebar-section-divider {
height: 1px;
background: rgba(229, 231, 235, 0.1);
margin: 0.5rem 1.5rem;
}
.sidebar.collapsed .sidebar-section-divider {
margin: 0.5rem 0.5rem;
}
</style>
</head> </head>
<body> <body>
@@ -36,7 +49,7 @@
</svg> </svg>
<span>Magistr</span> <span>Magistr</span>
</div> </div>
<button class="sidebar-close-btn" id="sidebar-close-btn" aria-label="Скрыть панель"> <button class="btn btn-icon-sm btn-ghost sidebar-close-btn" id="sidebar-close-btn" aria-label="Скрыть панель">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polyline points="15 18 9 12 15 6" /> <polyline points="15 18 9 12 15 6" />
</svg> </svg>
@@ -68,7 +81,7 @@
<!-- Раздел: Расписание --> <!-- Раздел: Расписание -->
<div class="sidebar-section-divider"></div> <div class="sidebar-section-divider"></div>
<div class="sidebar-section-title">Расписание</div> <div class="sidebar-section-title" style="font-size: 0.7rem; font-weight: 700; color: rgba(255,255,255,0.4); padding: 0.5rem 1.25rem 0.25rem; text-transform: uppercase; letter-spacing: 0.05em;">Расписание</div>
<a href="#" class="nav-item" data-tab="schedule"> <a href="#" class="nav-item" data-tab="schedule">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
@@ -108,7 +121,7 @@
<!-- Раздел: Субъекты и ресурсы --> <!-- Раздел: Субъекты и ресурсы -->
<div class="sidebar-section-divider"></div> <div class="sidebar-section-divider"></div>
<div class="sidebar-section-title">Ресурсы</div> <div class="sidebar-section-title" style="font-size: 0.7rem; font-weight: 700; color: rgba(255,255,255,0.4); padding: 0.5rem 1.25rem 0.25rem; text-transform: uppercase; letter-spacing: 0.05em;">Ресурсы</div>
<a href="#" class="nav-item" data-tab="classrooms"> <a href="#" class="nav-item" data-tab="classrooms">
<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"
@@ -143,7 +156,7 @@
<!-- Раздел: Система --> <!-- Раздел: Система -->
<div class="sidebar-section-divider"></div> <div class="sidebar-section-divider"></div>
<div class="sidebar-section-title">Система</div> <div class="sidebar-section-title" style="font-size: 0.7rem; font-weight: 700; color: rgba(255,255,255,0.4); padding: 0.5rem 1.25rem 0.25rem; text-transform: uppercase; letter-spacing: 0.05em;">Система</div>
<a href="#" class="nav-item" data-tab="users"> <a href="#" class="nav-item" 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"
@@ -180,7 +193,7 @@
Общие настройки Общие настройки
</a> </a>
<div class="settings-menu-divider"></div> <div class="settings-menu-divider"></div>
<button class="settings-menu-item settings-menu-item--danger" id="btn-logout"> <button class="btn btn-md btn-danger-subtle btn-logout settings-menu-item settings-menu-item--danger" id="btn-logout">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" <svg width="18" height="18" 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="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" /> <path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" />
@@ -200,7 +213,7 @@
<!-- Main --> <!-- Main -->
<main class="main"> <main class="main">
<header class="topbar"> <header class="topbar">
<button class="menu-toggle" id="menu-toggle" aria-label="Меню"> <button class="btn btn-icon-md btn-ghost menu-toggle" id="menu-toggle" aria-label="Меню">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" <svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
stroke-linecap="round" stroke-linejoin="round"> stroke-linecap="round" stroke-linejoin="round">
<line x1="3" y1="6" x2="21" y2="6" /> <line x1="3" y1="6" x2="21" y2="6" />

View File

@@ -27,7 +27,7 @@ export function hideAlert(elementId) {
export function applyRippleEffect() { export function applyRippleEffect() {
document.addEventListener('click', function (e) { document.addEventListener('click', function (e) {
const btn = e.target.closest('.btn-primary, .btn-delete, .btn-logout'); const btn = e.target.closest('.btn-primary, .btn-danger, .btn-danger-subtle, .btn-logout');
if (!btn) return; if (!btn) return;
const rect = btn.getBoundingClientRect(); const rect = btn.getBoundingClientRect();

View File

@@ -222,8 +222,8 @@ export async function initAcademicCalendar() {
<td class="academic-year-semesters-cell">${renderSemesterList(year.semesters || [])}</td> <td class="academic-year-semesters-cell">${renderSemesterList(year.semesters || [])}</td>
<td class="academic-year-actions-cell"> <td class="academic-year-actions-cell">
<div class="academic-year-actions"> <div class="academic-year-actions">
<button class="btn-edit-classroom btn-edit-year" data-id="${year.id}">Изменить</button> <button class="btn btn-sm btn-secondary btn-edit-year" data-id="${year.id}">Изменить</button>
<button class="btn-delete btn-delete-year" data-id="${year.id}">Удалить</button> <button class="btn btn-sm btn-danger btn-delete-year" data-id="${year.id}">Удалить</button>
</div> </div>
</td> </td>
</tr> </tr>
@@ -241,7 +241,7 @@ export async function initAcademicCalendar() {
<span class="academic-year-semester-type">${escapeHtml(title)}</span> <span class="academic-year-semester-type">${escapeHtml(title)}</span>
<span class="academic-year-semester-period">${escapeHtml(period)}</span> <span class="academic-year-semester-period">${escapeHtml(period)}</span>
</div> </div>
<button class="btn-edit-classroom btn-edit-semester" data-id="${semester.id}" aria-label="Изменить ${escapeHtml(title)} семестр ${escapeHtml(period)}">Изменить</button> <button class="btn btn-sm btn-secondary btn-edit-semester" data-id="${semester.id}" aria-label="Изменить ${escapeHtml(title)} семестр ${escapeHtml(period)}">Изменить</button>
</div> </div>
`; `;
}).join('')}</div>`; }).join('')}</div>`;
@@ -400,9 +400,9 @@ export async function initAcademicCalendar() {
<td>${escapeHtml(calendar.studyFormName || '-')}</td> <td>${escapeHtml(calendar.studyFormName || '-')}</td>
<td>${escapeHtml(String(calendar.courseCount || '-'))}</td> <td>${escapeHtml(String(calendar.courseCount || '-'))}</td>
<td> <td>
<button class="btn-edit-classroom btn-edit-calendar" data-id="${calendar.id}">Изменить</button> <button class="btn btn-sm btn-secondary btn-edit-calendar" data-id="${calendar.id}">Изменить</button>
<button class="btn-edit-classroom btn-open-calendar-grid" data-id="${calendar.id}">Сетка</button> <button class="btn btn-sm btn-secondary btn-open-calendar-grid" data-id="${calendar.id}">Сетка</button>
<button class="btn-delete btn-delete-calendar" data-id="${calendar.id}">Удалить</button> <button class="btn btn-sm btn-danger btn-delete-calendar" data-id="${calendar.id}">Удалить</button>
</td> </td>
</tr> </tr>
`).join(''); `).join('');

View File

@@ -13,7 +13,6 @@ async function fetchEquipments() {
function renderEquipmentCheckboxes(equipments, containerId, selectTextId, selectedIds = []) { function renderEquipmentCheckboxes(equipments, containerId, selectTextId, selectedIds = []) {
const container = document.getElementById(containerId); const container = document.getElementById(containerId);
if (!container) return; if (!container) return;
const selected = new Set(selectedIds.map(id => String(id)));
if (!equipments || !equipments.length) { if (!equipments || !equipments.length) {
container.innerHTML = '<p class="text-muted"><small>Оборудование отсутствует</small></p>'; container.innerHTML = '<p class="text-muted"><small>Оборудование отсутствует</small></p>';
@@ -22,10 +21,9 @@ function renderEquipmentCheckboxes(equipments, containerId, selectTextId, select
} }
container.innerHTML = equipments.map(eq => ` container.innerHTML = equipments.map(eq => `
<label class="checkbox-item"> <label class="checkbox-container">
<input type="checkbox" value="${escapeHtml(eq.id)}" ${selected.has(String(eq.id)) ? 'checked' : ''}> <input type="checkbox" value="${eq.id}" ${selectedIds.includes(eq.id) ? 'checked' : ''}>
<span class="checkmark"></span> <span>${escapeHtml(eq.name)}</span>
<span class="checkbox-label">${escapeHtml(eq.name)}</span>
</label> </label>
`).join(''); `).join('');
@@ -89,16 +87,16 @@ export async function initClassrooms() {
<span class="badge ${c.isAvailable ? 'badge-available' : 'badge-unavailable'}"> <span class="badge ${c.isAvailable ? 'badge-available' : 'badge-unavailable'}">
${c.status === 'ARCHIVED' ? 'Архив' : (c.isAvailable ? 'Доступна' : 'Не доступна')} ${c.status === 'ARCHIVED' ? 'Архив' : (c.isAvailable ? 'Доступна' : 'Не доступна')}
</span> </span>
<button class="btn-icon-toggle" data-id="${c.id}" data-current-status="${c.isAvailable}" title="Сменить статус"> <button class="btn btn-icon-sm btn-secondary btn-icon-toggle" data-id="${c.id}" data-current-status="${c.isAvailable}" title="Сменить статус" aria-label="Сменить статус">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9"></path></svg> <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 12a9 9 0 01-9 9m9-9a9 9 0 00-9-9m9 9H3m9 9a9 9 0 01-9-9m9 9c1.657 0 3-4.03 3-9s-1.343-9-3-9m0 18c-1.657 0-3-4.03-3-9s1.343-9 3-9m-9 9a9 9 0 019-9"></path></svg>
</button> </button>
</div> </div>
</td> </td>
<td style="text-align: right;"> <td style="text-align: right;">
<button class="btn-edit-classroom" data-id="${c.id}">Изменить</button> <button class="btn btn-sm btn-secondary classroom-edit-action" data-id="${c.id}">Изменить</button>
${c.status === 'ARCHIVED' ${c.status === 'ARCHIVED'
? `<button class="btn-restore-classroom" data-id="${c.id}" style="margin-left: 0.5rem;">Восстановить</button>` ? `<button class="btn-restore-classroom" data-id="${c.id}" style="margin-left: 0.5rem;">Восстановить</button>`
: `<button class="btn-delete" data-id="${c.id}" style="margin-left: 0.5rem;">Архивировать</button>`} : `<button class="btn btn-sm btn-danger-subtle btn-archive-classroom" data-id="${c.id}" style="margin-left: 0.5rem;">Архивировать</button>`}
</td> </td>
</tr>`; </tr>`;
}).join(''); }).join('');
@@ -127,9 +125,9 @@ export async function initClassrooms() {
}); });
classroomsTbody.addEventListener('click', async (e) => { classroomsTbody.addEventListener('click', async (e) => {
const btnDelete = e.target.closest('.btn-delete'); const btnDelete = e.target.closest('.btn-archive-classroom');
const btnToggleStatus = e.target.closest('.btn-icon-toggle'); const btnToggleStatus = e.target.closest('.btn-icon-toggle');
const btnEdit = e.target.closest('.btn-edit-classroom'); const btnEdit = e.target.closest('.classroom-edit-action');
if (btnDelete) { if (btnDelete) {
if (!confirm('Вывести аудиторию из эксплуатации? Историческое расписание сохранится.')) return; if (!confirm('Вывести аудиторию из эксплуатации? Историческое расписание сохранится.')) return;
@@ -263,7 +261,7 @@ export async function initClassrooms() {
<tr> <tr>
<td>${eq.id}</td> <td>${eq.id}</td>
<td>${escapeHtml(eq.name)}</td> <td>${escapeHtml(eq.name)}</td>
<td><button class="btn-delete btn-delete-equipment" data-id="${eq.id}">Удалить</button></td> <td><button class="btn btn-sm btn-danger btn-delete-equipment" data-id="${eq.id}">Удалить</button></td>
</tr> </tr>
`).join(''); `).join('');
} }

View File

@@ -94,7 +94,7 @@ export async function initDepartmentWorkspace() {
<td>${escapeHtml(subject.name || '-')}</td> <td>${escapeHtml(subject.name || '-')}</td>
<td>${escapeHtml(subject.description || '-')}</td> <td>${escapeHtml(subject.description || '-')}</td>
<td> <td>
<button type="button" class="btn-edit-classroom department-comments-open" <button type="button" class="btn btn-sm btn-secondary department-comments-open"
data-subject-id="${escapeHtml(subject.id)}" data-subject-id="${escapeHtml(subject.id)}"
data-subject-name="${escapeHtml(subject.name || '')}"> data-subject-name="${escapeHtml(subject.name || '')}">
Открыть Открыть
@@ -112,15 +112,15 @@ export async function initDepartmentWorkspace() {
if (!importRowsContainer) return; if (!importRowsContainer) return;
importRowsContainer.innerHTML = (rows || [{ code: '', name: '' }]).map((row, index) => ` importRowsContainer.innerHTML = (rows || [{ code: '', name: '' }]).map((row, index) => `
<div class="subject-import-row" data-index="${index}"> <div class="subject-import-row" data-index="${index}">
<div class="form-group subject-import-code-field"> <div class="form-group">
<label>Код</label> <label>Код</label>
<input type="text" class="subject-import-code" value="${escapeHtml(row.code || '')}" placeholder="DB201"> <input type="text" class="subject-import-code" value="${escapeHtml(row.code || '')}" placeholder="DB201">
</div> </div>
<div class="form-group subject-import-name-field"> <div class="form-group">
<label>Название</label> <label>Название</label>
<input type="text" class="subject-import-name" value="${escapeHtml(row.name || '')}" placeholder="Базы данных"> <input type="text" class="subject-import-name" value="${escapeHtml(row.name || '')}" placeholder="Базы данных">
</div> </div>
<button type="button" class="btn-delete subject-import-remove" data-index="${index}">Удалить</button> <button type="button" class="btn btn-sm btn-danger subject-import-remove" data-index="${index}">Удалить</button>
</div> </div>
`).join(''); `).join('');
} }

View File

@@ -173,12 +173,12 @@ export async function initGroups() {
<td><span class="badge ${statusBadgeClass(group)}">${escapeHtml(statusLabel(group))}</span></td> <td><span class="badge ${statusBadgeClass(group)}">${escapeHtml(statusLabel(group))}</span></td>
<td class="group-actions-cell"> <td class="group-actions-cell">
<div class="group-actions"> <div class="group-actions">
<button class="btn-edit-classroom btn-edit-group group-action-btn" data-id="${group.id}">Изменить</button> <button class="btn btn-sm btn-secondary btn-edit-group group-action-btn" data-id="${group.id}">Изменить</button>
<button class="btn-primary btn-manage-subgroups group-action-btn" data-id="${group.id}">Подгруппы</button> <button class="btn btn-sm btn-secondary btn-manage-subgroups group-action-btn" data-id="${group.id}">Подгруппы</button>
<button class="btn-primary btn-manage-calendar group-action-btn" data-id="${group.id}">Календарь</button> <button class="btn btn-sm btn-secondary btn-manage-calendar group-action-btn" data-id="${group.id}">Календарь</button>
${group.status === 'ARCHIVED' ${group.status === 'ARCHIVED'
? `<button class="btn-restore-group group-action-btn" data-id="${group.id}">Восстановить</button>` ? `<button class="btn-restore-group group-action-btn" data-id="${group.id}">Восстановить</button>`
: `<button class="btn-delete group-action-btn" data-id="${group.id}">Архив</button>`} : `<button class="btn btn-sm btn-danger-subtle btn-archive-group group-action-btn" data-id="${group.id}">Архив</button>`}
</div> </div>
</td> </td>
</tr> </tr>
@@ -347,7 +347,7 @@ export async function initGroups() {
} }
async function handleGroupTableClick(event) { async function handleGroupTableClick(event) {
const btnDelete = event.target.closest('.btn-delete'); const btnDelete = event.target.closest('.btn-archive-group');
const btnEdit = event.target.closest('.btn-edit-group'); const btnEdit = event.target.closest('.btn-edit-group');
const btnRestore = event.target.closest('.btn-restore-group'); const btnRestore = event.target.closest('.btn-restore-group');
const btnSubgroups = event.target.closest('.btn-manage-subgroups'); const btnSubgroups = event.target.closest('.btn-manage-subgroups');
@@ -429,8 +429,8 @@ export async function initGroups() {
<td>${escapeHtml(sub.name)}</td> <td>${escapeHtml(sub.name)}</td>
<td>${escapeHtml(String(sub.studentCapacity ?? '-'))} чел.</td> <td>${escapeHtml(String(sub.studentCapacity ?? '-'))} чел.</td>
<td> <td>
<button class="btn-edit-classroom btn-edit-subgroup" data-id="${sub.id}">Изменить</button> <button class="btn btn-sm btn-secondary btn-edit-subgroup" data-id="${sub.id}">Изменить</button>
<button class="btn-delete btn-delete-subgroup" data-id="${sub.id}">Удалить</button> <button class="btn btn-sm btn-danger btn-delete-subgroup" data-id="${sub.id}">Удалить</button>
</td> </td>
</tr> </tr>
`).join(''); `).join('');
@@ -541,7 +541,7 @@ export async function initGroups() {
<td>${escapeHtml(assignment.academicYearTitle)}</td> <td>${escapeHtml(assignment.academicYearTitle)}</td>
<td>${escapeHtml(assignment.calendarTitle)}</td> <td>${escapeHtml(assignment.calendarTitle)}</td>
<td> <td>
<button class="btn-delete btn-delete-assignment" data-id="${assignment.id}">Удалить</button> <button class="btn btn-sm btn-danger btn-delete-assignment" data-id="${assignment.id}">Удалить</button>
</td> </td>
</tr> </tr>
`).join(''); `).join('');

View File

@@ -35,6 +35,7 @@ const CELL_PARITY_LAYOUT = [
{ parity: 'EVEN', label: 'Чётная' } { parity: 'EVEN', label: 'Чётная' }
]; ];
const MOBILE_QUERY = '(max-width: 760px)'; const MOBILE_QUERY = '(max-width: 760px)';
const DEFAULT_SEMESTER_WEEKS = 22;
export async function initScheduleView() { export async function initScheduleView() {
const role = localStorage.getItem('role'); const role = localStorage.getItem('role');
@@ -622,10 +623,42 @@ export async function initScheduleView() {
if (startWeek <= 0) startWeek = 1; if (startWeek <= 0) startWeek = 1;
if (endWeek < startWeek) endWeek = startWeek; if (endWeek < startWeek) endWeek = startWeek;
if (startWeek === endWeek) { return formatLessonWeeksText(startWeek, endWeek, parity, semesterWeeksForLesson(lesson));
return `${startWeek} нед.`; }
}
return `с ${startWeek} по ${endWeek} нед.`; function formatLessonWeeksText(startWeek, endWeek, parity, semesterWeeks) {
const semesterEndWeek = lastApplicableSemesterWeek(semesterWeeks, parity);
const semesterStartWeek = firstApplicableSemesterWeek(parity);
if (semesterEndWeek && startWeek <= semesterStartWeek && endWeek >= semesterEndWeek) return '';
if (semesterEndWeek && endWeek >= semesterEndWeek) return `(с ${startWeek} нед.)`;
return startWeek === endWeek
? `(${startWeek} нед.)`
: `(с ${startWeek} по ${endWeek} нед.)`;
}
function semesterWeeksForLesson(lesson) {
const semester = (state.dictionaries.semesters || []).find(item => dateInRange(lesson.date, item.startDate, item.endDate));
if (!semester?.startDate || !semester?.endDate) return DEFAULT_SEMESTER_WEEKS;
const start = parseIsoDate(semester.startDate);
const end = parseIsoDate(semester.endDate);
const days = Math.max(1, Math.round((end - start) / 86400000) + 1);
return Math.max(1, Math.ceil(days / 7));
}
function dateInRange(date, startDate, endDate) {
return Boolean(date && startDate && endDate && date >= startDate && date <= endDate);
}
function firstApplicableSemesterWeek(parity) {
return parity === 'EVEN' ? 2 : 1;
}
function lastApplicableSemesterWeek(maxWeeks, parity) {
const totalWeeks = Number(maxWeeks || 0);
if (!totalWeeks) return 0;
if (parity === 'ODD' && totalWeeks % 2 === 0) return totalWeeks - 1;
if (parity === 'EVEN' && totalWeeks % 2 !== 0) return totalWeeks - 1;
return totalWeeks;
} }
function renderLessonCard(lesson) { function renderLessonCard(lesson) {
@@ -694,13 +727,14 @@ export async function initScheduleView() {
} }
async function loadDictionaries(role, userDepartmentId) { async function loadDictionaries(role, userDepartmentId) {
const [groups, teachers, classrooms, departments, subjects, lessonTypes] = await Promise.all([ const [groups, teachers, classrooms, departments, subjects, lessonTypes, academicYears] = await Promise.all([
api.get('/api/groups'), api.get('/api/groups'),
api.get('/api/users/teachers'), api.get('/api/users/teachers'),
api.get('/api/classrooms'), api.get('/api/classrooms'),
api.get('/api/departments'), api.get('/api/departments'),
api.get('/api/subjects'), api.get('/api/subjects'),
api.get('/api/lesson-types') api.get('/api/lesson-types'),
api.get('/api/admin/calendar/years').catch(() => [])
]); ]);
const visibleGroups = role === ROLE_DEPARTMENT && userDepartmentId const visibleGroups = role === ROLE_DEPARTMENT && userDepartmentId
@@ -718,7 +752,8 @@ async function loadDictionaries(role, userDepartmentId) {
groups, groups,
teachers, teachers,
classrooms, classrooms,
departments departments,
semesters: (academicYears || []).flatMap(year => year.semesters || [])
}; };
} }

View File

@@ -56,7 +56,6 @@ const LESSON_TYPE_LIMITS = [
startWeekInputId: 'schedule-rule-practice-start-week' startWeekInputId: 'schedule-rule-practice-start-week'
} }
]; ];
const LESSON_TYPE_ORDER = new Map(LESSON_TYPE_LIMITS.map((config, index) => [config.key, index]));
export async function initSchedule() { export async function initSchedule() {
const ruleForm = document.getElementById('schedule-rule-form'); const ruleForm = document.getElementById('schedule-rule-form');
@@ -83,6 +82,8 @@ export async function initSchedule() {
const visualBackdrop = document.getElementById('schedule-visual-backdrop'); const visualBackdrop = document.getElementById('schedule-visual-backdrop');
const visualStage = document.getElementById('schedule-visual-stage'); const visualStage = document.getElementById('schedule-visual-stage');
const visualSummary = document.getElementById('schedule-visual-summary'); const visualSummary = document.getElementById('schedule-visual-summary');
const visualYearSelect = document.getElementById('schedule-visual-year');
const visualSemesterSelect = document.getElementById('schedule-visual-semester');
const visualGroupFilter = document.getElementById('schedule-visual-group-filter'); const visualGroupFilter = document.getElementById('schedule-visual-group-filter');
const visualUseFormButton = document.getElementById('schedule-visual-use-form'); const visualUseFormButton = document.getElementById('schedule-visual-use-form');
const visualSelectAllButton = document.getElementById('schedule-visual-select-all'); const visualSelectAllButton = document.getElementById('schedule-visual-select-all');
@@ -95,9 +96,14 @@ export async function initSchedule() {
let lessonTypes = []; let lessonTypes = [];
let subgroups = []; let subgroups = [];
let timeSlots = []; let timeSlots = [];
let academicYears = [];
let semesters = []; let semesters = [];
let selectedVisualYearId = '';
let selectedVisualSemesterId = '';
let selectedVisualGroupIds = new Set(); let selectedVisualGroupIds = new Set();
let visualGroupFilterTouched = false; let visualGroupFilterTouched = false;
let visualPeriodTouched = false;
let syncingVisualPeriod = false;
bindEvents(); bindEvents();
@@ -117,17 +123,30 @@ export async function initSchedule() {
visualToggleButton?.addEventListener('click', toggleVisualDrawer); visualToggleButton?.addEventListener('click', toggleVisualDrawer);
visualCloseButton?.addEventListener('click', closeVisualDrawer); visualCloseButton?.addEventListener('click', closeVisualDrawer);
visualBackdrop?.addEventListener('click', closeVisualDrawer); visualBackdrop?.addEventListener('click', closeVisualDrawer);
visualYearSelect?.addEventListener('change', handleVisualYearChange);
visualSemesterSelect?.addEventListener('change', handleVisualSemesterChange);
visualGroupFilter?.addEventListener('change', handleVisualGroupFilterChange); visualGroupFilter?.addEventListener('change', handleVisualGroupFilterChange);
visualStage?.addEventListener('click', handleVisualStageClick);
visualUseFormButton?.addEventListener('click', () => { visualUseFormButton?.addEventListener('click', () => {
const formSemesterId = ruleSemesterSelect.value;
if (formSemesterId) {
visualPeriodTouched = true;
setVisualPeriodBySemesterId(formSemesterId);
}
visualGroupFilterTouched = false; visualGroupFilterTouched = false;
selectedVisualGroupIds = new Set(); selectedVisualGroupIds = new Set();
renderVisualSchedule(); renderVisualSchedule();
}); });
visualSelectAllButton?.addEventListener('click', () => { visualSelectAllButton?.addEventListener('click', () => {
selectedVisualGroupIds = new Set(collectVisualGroups().map(group => String(group.id))); selectedVisualGroupIds = new Set(collectVisualGroups(visualRulesForSelectedPeriod()).map(group => String(group.id)));
visualGroupFilterTouched = true; visualGroupFilterTouched = true;
renderVisualSchedule(); renderVisualSchedule();
}); });
ruleSemesterSelect?.addEventListener('change', () => {
if (visualPeriodTouched) return;
selectDefaultVisualPeriod();
renderVisualSchedule();
});
document.addEventListener('keydown', (event) => { document.addEventListener('keydown', (event) => {
if (event.key === 'Escape') closeVisualDrawer(); if (event.key === 'Escape') closeVisualDrawer();
}); });
@@ -187,20 +206,23 @@ export async function initSchedule() {
api.get('/api/lesson-types'), api.get('/api/lesson-types'),
api.get('/api/subgroups') api.get('/api/subgroups')
]); ]);
lessonTypes = sortLessonTypes(lessonTypes);
populateSubjectSelects(); populateSubjectSelects();
} }
async function loadYears() { async function loadYears() {
const academicYears = await api.get('/api/admin/calendar/years'); academicYears = await api.get('/api/admin/calendar/years');
semesters = academicYears.flatMap(year => year.semesters || []); semesters = academicYears.flatMap(year => year.semesters || []);
populateSemesterSelects(); populateSemesterSelects();
selectDefaultVisualPeriod();
} }
async function loadRules() { async function loadRules() {
rulesTbody.innerHTML = '<tr><td colspan="7" class="loading-row">Загрузка...</td></tr>'; rulesTbody.innerHTML = '<tr><td colspan="7" class="loading-row">Загрузка...</td></tr>';
try { try {
rules = await api.get('/api/admin/schedule-rules'); rules = await api.get('/api/admin/schedule-rules');
if (!visualPeriodTouched) {
selectDefaultVisualPeriod();
}
renderRules(); renderRules();
renderVisualSchedule(); renderVisualSchedule();
} catch (error) { } catch (error) {
@@ -224,8 +246,10 @@ export async function initSchedule() {
<td>${renderRuleLoadSummary(rule)}</td> <td>${renderRuleLoadSummary(rule)}</td>
<td>${renderRuleSlotsSummary(rule.slots)}</td> <td>${renderRuleSlotsSummary(rule.slots)}</td>
<td> <td>
<button class="btn-edit-classroom btn-edit-rule" data-id="${rule.id}">Изменить</button> <div class="schedule-rule-table-actions">
<button class="btn-delete btn-delete-rule" data-id="${rule.id}">Удалить</button> <button type="button" class="btn btn-sm btn-secondary btn-edit-rule" data-id="${rule.id}">Изменить</button>
<button type="button" class="btn btn-sm btn-danger btn-delete-rule" data-id="${rule.id}">Удалить</button>
</div>
</td> </td>
</tr> </tr>
`).join(''); `).join('');
@@ -264,16 +288,32 @@ export async function initSchedule() {
function renderVisualSchedule() { function renderVisualSchedule() {
if (!visualStage || !visualSummary) return; if (!visualStage || !visualSummary) return;
if (!selectedVisualSemesterId) {
syncVisualGroupFilter([]);
visualSummary.textContent = 'Выберите учебный год и семестр';
setVisualState('Выберите период, чтобы увидеть правила конкретного семестра');
return;
}
if (!rules.length) { if (!rules.length) {
syncVisualGroupFilter([]);
visualSummary.textContent = 'Нет сохранённых правил'; visualSummary.textContent = 'Нет сохранённых правил';
setVisualState('Сохраните правила расписания, чтобы увидеть матрицу по группам'); setVisualState('Сохраните правила расписания, чтобы увидеть матрицу по группам');
return; return;
} }
const allGroups = collectVisualGroups(); const visualRules = visualRulesForSelectedPeriod();
const periodLabel = visualPeriodLabel();
if (!visualRules.length) {
syncVisualGroupFilter([]);
visualSummary.textContent = `${periodLabel} · правил нет`;
setVisualState('В выбранном семестре нет сохранённых правил расписания');
return;
}
const allGroups = collectVisualGroups(visualRules);
syncVisualGroupFilter(allGroups); syncVisualGroupFilter(allGroups);
const columns = allGroups.filter(group => selectedVisualGroupIds.has(String(group.id))); const columns = allGroups.filter(group => selectedVisualGroupIds.has(String(group.id)));
const slots = timeSlots.length ? timeSlots.map(visualSlotFromTimeSlot) : collectVisualSlotsFromRules(); const slots = timeSlots.length ? timeSlots.map(visualSlotFromTimeSlot) : collectVisualSlotsFromRules(visualRules);
if (!allGroups.length || !slots.length) { if (!allGroups.length || !slots.length) {
visualSummary.textContent = 'Недостаточно данных для построения'; visualSummary.textContent = 'Недостаточно данных для построения';
setVisualState('У правил должны быть группы и слоты занятий'); setVisualState('У правил должны быть группы и слоты занятий');
@@ -285,15 +325,15 @@ export async function initSchedule() {
return; return;
} }
const lessonsByCell = buildVisualLessonsByCell(selectedVisualGroupIds); const lessonsByCell = buildVisualLessonsByCell(selectedVisualGroupIds, visualRules);
const rows = buildVisualRows(columns, slots, lessonsByCell); const rows = buildVisualRows(columns, slots, lessonsByCell);
if (!rows.length) { if (!rows.length) {
visualSummary.textContent = `${columns.length} ${groupCountLabel(columns.length)} · пар не найдено`; visualSummary.textContent = `${periodLabel} · ${columns.length} ${groupCountLabel(columns.length)} · пар не найдено`;
setVisualState('Для выбранных групп нет активных пар в сохранённых правилах'); setVisualState('Для выбранных групп нет активных пар в правилах выбранного семестра');
return; return;
} }
visualSummary.textContent = `${rules.length} ${ruleCountLabel(rules.length)} · ${columns.length} ${groupCountLabel(columns.length)} · ${rows.length} ${rowCountLabel(rows.length)}`; visualSummary.textContent = `${periodLabel} · ${visualRules.length} ${ruleCountLabel(visualRules.length)} · ${columns.length} ${groupCountLabel(columns.length)} · ${rows.length} ${rowCountLabel(rows.length)}`;
visualStage.innerHTML = ` visualStage.innerHTML = `
<div class="schedule-visual-table-wrap"> <div class="schedule-visual-table-wrap">
<table class="schedule-visual-table"> <table class="schedule-visual-table">
@@ -350,10 +390,10 @@ export async function initSchedule() {
`; `;
} }
function buildVisualLessonsByCell(allowedGroupIds) { function buildVisualLessonsByCell(allowedGroupIds, visualRules) {
const grouped = new Map(); const grouped = new Map();
const activeWeeks = buildActiveWeeksByRuleSlot(); const activeWeeks = buildActiveWeeksByRuleSlot(visualRules);
rules.forEach(rule => { visualRules.forEach(rule => {
(rule.slots || []).forEach((slot, index) => { (rule.slots || []).forEach((slot, index) => {
const slotKey = visualRuleSlotKey(slot, index); const slotKey = visualRuleSlotKey(slot, index);
(rule.groupIds || []).forEach((groupId, groupIndex) => { (rule.groupIds || []).forEach((groupId, groupIndex) => {
@@ -362,6 +402,7 @@ export async function initSchedule() {
if (!weeks.length) return; if (!weeks.length) return;
const lesson = { const lesson = {
id: `${rule.id}:${slotKey}:${groupId}`, id: `${rule.id}:${slotKey}:${groupId}`,
ruleId: rule.id,
parity: slot.parity || 'BOTH', parity: slot.parity || 'BOTH',
subjectName: rule.subjectName || 'Без дисциплины', subjectName: rule.subjectName || 'Без дисциплины',
teacherName: slot.teacherName || '', teacherName: slot.teacherName || '',
@@ -369,7 +410,7 @@ export async function initSchedule() {
lessonTypeName: slot.lessonTypeName || '', lessonTypeName: slot.lessonTypeName || '',
lessonFormat: slot.lessonFormat || '', lessonFormat: slot.lessonFormat || '',
subgroupNames: slotSubgroupNames(slot), subgroupNames: slotSubgroupNames(slot),
weekText: compactWeekRanges(weeks), weekText: compactWeekRanges(weeks, maxWeeksForRule(rule), slot.parity),
groupName: (rule.groupNames || [])[groupIndex] || groupNameById(groupId) groupName: (rule.groupNames || [])[groupIndex] || groupNameById(groupId)
}; };
const key = visualCellKey(groupId, slot.dayOfWeek, visualSlotKey(slot)); const key = visualCellKey(groupId, slot.dayOfWeek, visualSlotKey(slot));
@@ -382,9 +423,9 @@ export async function initSchedule() {
return grouped; return grouped;
} }
function buildActiveWeeksByRuleSlot() { function buildActiveWeeksByRuleSlot(visualRules) {
const activeWeeks = new Map(); const activeWeeks = new Map();
rules.forEach(rule => { visualRules.forEach(rule => {
const maxWeeks = maxWeeksForRule(rule); const maxWeeks = maxWeeksForRule(rule);
(rule.groupIds || []).forEach(groupId => { (rule.groupIds || []).forEach(groupId => {
LESSON_TYPE_LIMITS.forEach(config => { LESSON_TYPE_LIMITS.forEach(config => {
@@ -456,13 +497,18 @@ export async function initSchedule() {
<strong>${escapeHtml(lesson.subjectName)}${lesson.weekText ? ` <span>${escapeHtml(lesson.weekText)}</span>` : ''}</strong> <strong>${escapeHtml(lesson.subjectName)}${lesson.weekText ? ` <span>${escapeHtml(lesson.weekText)}</span>` : ''}</strong>
${subgroupText ? `<small>${escapeHtml(subgroupText)}</small>` : ''} ${subgroupText ? `<small>${escapeHtml(subgroupText)}</small>` : ''}
${meta ? `<small>${escapeHtml(meta)}</small>` : ''} ${meta ? `<small>${escapeHtml(meta)}</small>` : ''}
<button type="button"
class="schedule-visual-edit-rule"
data-rule-id="${escapeHtml(String(lesson.ruleId))}"
title="Изменить правило"
aria-label="${escapeHtml(`Изменить правило: ${lesson.subjectName}`)}">&#8942;</button>
</article> </article>
`; `;
} }
function collectVisualGroups() { function collectVisualGroups(visualRules = rules) {
const byId = new Map(); const byId = new Map();
rules.forEach(rule => { visualRules.forEach(rule => {
(rule.groupIds || []).forEach((id, index) => { (rule.groupIds || []).forEach((id, index) => {
if (!byId.has(String(id))) { if (!byId.has(String(id))) {
byId.set(String(id), { byId.set(String(id), {
@@ -475,9 +521,9 @@ export async function initSchedule() {
return Array.from(byId.values()).sort((a, b) => naturalCompare(a.name, b.name)); return Array.from(byId.values()).sort((a, b) => naturalCompare(a.name, b.name));
} }
function collectVisualSlotsFromRules() { function collectVisualSlotsFromRules(visualRules = rules) {
const byKey = new Map(); const byKey = new Map();
rules.flatMap(rule => rule.slots || []).forEach(slot => { visualRules.flatMap(rule => rule.slots || []).forEach(slot => {
const key = visualSlotKey(slot); const key = visualSlotKey(slot);
if (!byKey.has(key)) { if (!byKey.has(key)) {
byKey.set(key, { byKey.set(key, {
@@ -513,6 +559,39 @@ export async function initSchedule() {
renderVisualSchedule(); renderVisualSchedule();
} }
function handleVisualYearChange() {
if (syncingVisualPeriod) return;
selectedVisualYearId = visualYearSelect?.value || '';
selectedVisualSemesterId = firstSemesterForYear(selectedVisualYearId)?.id
? String(firstSemesterForYear(selectedVisualYearId).id)
: '';
visualPeriodTouched = true;
visualGroupFilterTouched = false;
populateVisualSemesterSelect();
renderVisualSchedule();
}
function handleVisualSemesterChange() {
if (syncingVisualPeriod) return;
selectedVisualSemesterId = visualSemesterSelect?.value || '';
visualPeriodTouched = true;
visualGroupFilterTouched = false;
renderVisualSchedule();
}
function handleVisualStageClick(event) {
const editButton = event.target.closest('.schedule-visual-edit-rule');
if (!editButton) return;
const rule = rules.find(item => String(item.id) === String(editButton.dataset.ruleId));
if (!rule) {
showAlert('schedule-rule-alert', 'Правило не найдено. Обновите список правил.', 'error');
return;
}
fillRuleForm(rule);
closeVisualDrawer();
showAlert('schedule-rule-alert', 'Правило открыто для редактирования', 'success');
}
function syncVisualGroupFilter(allGroups) { function syncVisualGroupFilter(allGroups) {
ensureVisualGroupSelection(allGroups); ensureVisualGroupSelection(allGroups);
if (!visualGroupFilter) return; if (!visualGroupFilter) return;
@@ -549,6 +628,92 @@ export async function initSchedule() {
} }
} }
function selectDefaultVisualPeriod() {
const formSemester = semesterById(ruleSemesterSelect?.value);
const ruleSemester = firstSemesterWithRules();
const fallbackSemester = semesters[0];
const semester = formSemester || ruleSemester || fallbackSemester;
setVisualPeriodBySemesterId(semester?.id || '');
}
function setVisualPeriodBySemesterId(semesterId) {
const semester = semesterById(semesterId);
selectedVisualSemesterId = semester ? String(semester.id) : '';
selectedVisualYearId = semester ? String(semester.academicYearId) : '';
populateVisualYearSelect();
populateVisualSemesterSelect();
}
function populateVisualYearSelect() {
if (!visualYearSelect) return;
visualYearSelect.innerHTML = '<option value="">Выберите учебный год</option>' +
academicYears.map(year => `<option value="${year.id}">${escapeHtml(year.title)}</option>`).join('');
if (selectedVisualYearId && academicYears.some(year => String(year.id) === selectedVisualYearId)) {
visualYearSelect.value = selectedVisualYearId;
} else if (academicYears.length) {
selectedVisualYearId = String(academicYears[0].id);
visualYearSelect.value = selectedVisualYearId;
} else {
selectedVisualYearId = '';
visualYearSelect.value = '';
}
syncVisualPeriodSelects(visualYearSelect);
}
function populateVisualSemesterSelect() {
if (!visualSemesterSelect) return;
const yearSemesters = semestersForYear(selectedVisualYearId);
visualSemesterSelect.innerHTML = yearSemesters.length
? '<option value="">Выберите семестр</option>' + yearSemesters
.map(semester => `<option value="${semester.id}">${escapeHtml(shortSemesterLabel(semester))}</option>`)
.join('')
: '<option value="">Нет семестров</option>';
if (selectedVisualSemesterId && yearSemesters.some(semester => String(semester.id) === selectedVisualSemesterId)) {
visualSemesterSelect.value = selectedVisualSemesterId;
} else if (yearSemesters.length) {
selectedVisualSemesterId = String(yearSemesters[0].id);
visualSemesterSelect.value = selectedVisualSemesterId;
} else {
selectedVisualSemesterId = '';
visualSemesterSelect.value = '';
}
syncVisualPeriodSelects(visualSemesterSelect);
}
function syncVisualPeriodSelects(...selects) {
syncingVisualPeriod = true;
syncSelects(...selects);
syncingVisualPeriod = false;
}
function visualRulesForSelectedPeriod() {
if (!selectedVisualSemesterId) return [];
return rules.filter(rule => String(rule.semesterId) === selectedVisualSemesterId);
}
function firstSemesterWithRules() {
const ruleSemesterIds = new Set(rules.map(rule => String(rule.semesterId)));
return semesters.find(semester => ruleSemesterIds.has(String(semester.id)));
}
function semesterById(id) {
return semesters.find(semester => String(semester.id) === String(id));
}
function semestersForYear(yearId) {
return semesters.filter(semester => String(semester.academicYearId) === String(yearId));
}
function firstSemesterForYear(yearId) {
return semestersForYear(yearId)[0] || null;
}
function visualPeriodLabel() {
const semester = semesterById(selectedVisualSemesterId);
return semester ? shortSemesterLabel(semester) : 'Семестр не выбран';
}
function renderRuleSlotsSummary(slots) { function renderRuleSlotsSummary(slots) {
if (!slots || !slots.length) return '<span class="muted">Нет слотов</span>'; if (!slots || !slots.length) return '<span class="muted">Нет слотов</span>';
return slots.map(slot => { return slots.map(slot => {
@@ -689,10 +854,10 @@ export async function initSchedule() {
<div class="form-group"><label>Пара</label><select class="slot-time" required>${options(timeSlots.map(toTimeSlotOption), slot.timeSlotId)}</select></div> <div class="form-group"><label>Пара</label><select class="slot-time" required>${options(timeSlots.map(toTimeSlotOption), slot.timeSlotId)}</select></div>
<div class="form-group"><label>Преподаватель</label><select class="slot-teacher" required>${options(teachers.map(toTeacherOption), slot.teacherId)}</select></div> <div class="form-group"><label>Преподаватель</label><select class="slot-teacher" required>${options(teachers.map(toTeacherOption), slot.teacherId)}</select></div>
<div class="form-group"><label>Аудитория</label><select class="slot-classroom" required>${options(classrooms.map(toClassroomOption), slot.classroomId)}</select></div> <div class="form-group"><label>Аудитория</label><select class="slot-classroom" required>${options(classrooms.map(toClassroomOption), slot.classroomId)}</select></div>
<div class="form-group"><label>Тип</label><select class="slot-lesson-type" required>${options(lessonTypes.map(toLessonTypeOption), slot.lessonTypeId)}</select></div> <div class="form-group"><label>Тип</label><select class="slot-lesson-type" required>${options(orderedLessonTypes().map(toLessonTypeOption), slot.lessonTypeId)}</select></div>
${isLaboratorySlot(slot) ? renderSubgroupField(slot, index) : ''} ${isLaboratorySlot(slot) ? renderSubgroupField(slot, index) : ''}
<div class="form-group"><label>Формат</label><select class="slot-format" required>${options([{ value: 'Очно', label: 'Очно' }, { value: 'Онлайн', label: 'Онлайн' }], slot.lessonFormat || 'Очно')}</select></div> <div class="form-group"><label>Формат</label><select class="slot-format" required>${options([{ value: 'Очно', label: 'Очно' }, { value: 'Онлайн', label: 'Онлайн' }], slot.lessonFormat || 'Очно')}</select></div>
<button type="button" class="btn-delete schedule-slot-remove" data-index="${index}">Удалить</button> <button type="button" class="btn btn-sm btn-danger schedule-slot-remove" data-index="${index}">Удалить</button>
</div> </div>
`).join(''); `).join('');
} }
@@ -1024,13 +1189,21 @@ export async function initSchedule() {
return { value: lessonType.id, label: lessonType.name || lessonType.lessonType || `ID ${lessonType.id}` }; return { value: lessonType.id, label: lessonType.name || lessonType.lessonType || `ID ${lessonType.id}` };
} }
function sortLessonTypes(items) { function orderedLessonTypes() {
return [...items].sort((first, second) => { return [...lessonTypes].sort(compareLessonTypes);
const firstOrder = LESSON_TYPE_ORDER.get(lessonTypeKey(first)) ?? LESSON_TYPE_ORDER.size; }
const secondOrder = LESSON_TYPE_ORDER.get(lessonTypeKey(second)) ?? LESSON_TYPE_ORDER.size;
return firstOrder - secondOrder function compareLessonTypes(first, second) {
|| naturalCompare(first.name || first.lessonType, second.name || second.lessonType); return lessonTypeRank(first) - lessonTypeRank(second)
}); || naturalCompare(toLessonTypeOption(first).label, toLessonTypeOption(second).label);
}
function lessonTypeRank(lessonType) {
const key = lessonTypeKey(lessonType);
if (key === 'lecture') return 0;
if (key === 'laboratory') return 1;
if (key === 'practice') return 2;
return 3;
} }
function lessonTypeKeyById(lessonTypeId) { function lessonTypeKeyById(lessonTypeId) {
@@ -1059,6 +1232,11 @@ export async function initSchedule() {
return `${semester.academicYearTitle || '-'}, ${SEMESTER_LABELS[type] || type} (${semester.startDate} - ${semester.endDate})`; return `${semester.academicYearTitle || '-'}, ${SEMESTER_LABELS[type] || type} (${semester.startDate} - ${semester.endDate})`;
} }
function shortSemesterLabel(semester) {
const type = normalizeSemesterType(semester.semesterType);
return `${semester.academicYearTitle || '-'}, ${SEMESTER_LABELS[type] || type}`;
}
function formatSemester(rule) { function formatSemester(rule) {
const type = normalizeSemesterType(rule.semesterType); const type = normalizeSemesterType(rule.semesterType);
return `${rule.academicYearTitle || '-'}, ${SEMESTER_LABELS[type] || type || '-'}`; return `${rule.academicYearTitle || '-'}, ${SEMESTER_LABELS[type] || type || '-'}`;
@@ -1117,16 +1295,34 @@ export async function initSchedule() {
return new Date(year, month - 1, day); return new Date(year, month - 1, day);
} }
function compactWeekRanges(weeks) { function compactWeekRanges(weeks, maxWeeks, parity) {
const uniqueWeeks = [...new Set((weeks || []).map(Number).filter(Boolean))].sort((a, b) => a - b); const uniqueWeeks = [...new Set((weeks || []).map(Number).filter(Boolean))].sort((a, b) => a - b);
if (!uniqueWeeks.length) return ''; if (!uniqueWeeks.length) return '';
return `(${weekRangeText(uniqueWeeks[0], uniqueWeeks[uniqueWeeks.length - 1])})`; const start = uniqueWeeks[0];
const end = uniqueWeeks[uniqueWeeks.length - 1];
const semesterEndWeek = lastApplicableSemesterWeek(maxWeeks, parity);
const semesterStartWeek = firstApplicableSemesterWeek(parity);
if (semesterEndWeek && start <= semesterStartWeek && end >= semesterEndWeek) return '';
if (semesterEndWeek && end >= semesterEndWeek) return `(с ${start} нед.)`;
return weekRangeText(start, end);
} }
function weekRangeText(start, end) { function weekRangeText(start, end) {
return start === end return start === end
? `${start} нед.` ? `(${start} нед.)`
: `с ${start} по ${end} нед.`; : `(с ${start} по ${end} нед.)`;
}
function firstApplicableSemesterWeek(parity) {
return parity === 'EVEN' ? 2 : 1;
}
function lastApplicableSemesterWeek(maxWeeks, parity) {
const totalWeeks = Number(maxWeeks || 0);
if (!totalWeeks) return 0;
if (parity === 'ODD' && totalWeeks % 2 === 0) return totalWeeks - 1;
if (parity === 'EVEN' && totalWeeks % 2 !== 0) return totalWeeks - 1;
return totalWeeks;
} }
function compareSlotItems(first, second) { function compareSlotItems(first, second) {

View File

@@ -100,8 +100,8 @@ export async function initSubjects() {
<td>${escapeHtml(departmentLabel(s.departmentId))}</td> <td>${escapeHtml(departmentLabel(s.departmentId))}</td>
<td><div style="display: flex; flex-wrap: wrap; gap: 0.25rem;">${teachersHtml}</div></td> <td><div style="display: flex; flex-wrap: wrap; gap: 0.25rem;">${teachersHtml}</div></td>
<td style="text-align: right; white-space: nowrap;"> <td style="text-align: right; white-space: nowrap;">
<button class="btn-primary btn-manage-teachers" data-id="${s.id}" style="padding: 0.25rem 0.5rem; font-size: 0.85rem;">Преподаватели</button> <button class="btn btn-sm btn-secondary btn-manage-teachers" data-id="${s.id}">Преподаватели</button>
<button class="btn-delete btn-delete-subject" data-id="${s.id}">Удалить</button> <button class="btn btn-sm btn-danger btn-delete-subject" data-id="${s.id}">Удалить</button>
</td> </td>
</tr> </tr>
`; `;
@@ -175,7 +175,7 @@ export async function initSubjects() {
<tr> <tr>
<td><strong>${escapeHtml(ts.fullName || ts.username)}</strong> <span class="text-muted">(${escapeHtml(ts.username)})</span></td> <td><strong>${escapeHtml(ts.fullName || ts.username)}</strong> <span class="text-muted">(${escapeHtml(ts.username)})</span></td>
<td> <td>
<button class="btn-delete btn-delete-ts" data-user-id="${ts.userId}" data-subject-id="${ts.subjectId}">Удалить</button> <button class="btn btn-sm btn-danger btn-delete-ts" data-user-id="${ts.userId}" data-subject-id="${ts.subjectId}">Удалить</button>
</td> </td>
</tr> </tr>
`).join(''); `).join('');

View File

@@ -129,8 +129,8 @@ export async function initUniversityStructure() {
<td>${escapeHtml(d.departmentName || d.name)}</td> <td>${escapeHtml(d.departmentName || d.name)}</td>
<td>${escapeHtml(String(d.departmentCode || d.code))}</td> <td>${escapeHtml(String(d.departmentCode || d.code))}</td>
<td> <td>
<button class="btn-edit-classroom btn-edit-department" data-id="${d.id}">Изменить</button> <button class="btn btn-sm btn-secondary btn-edit-department" data-id="${d.id}">Изменить</button>
<button class="btn-delete btn-delete-department" data-id="${d.id}">Удалить</button> <button class="btn btn-sm btn-danger btn-delete-department" data-id="${d.id}">Удалить</button>
</td> </td>
</tr> </tr>
`).join(''); `).join('');
@@ -147,9 +147,9 @@ export async function initUniversityStructure() {
<td>${escapeHtml(s.specialityName || s.name)}</td> <td>${escapeHtml(s.specialityName || s.name)}</td>
<td>${escapeHtml(s.specialityCode || s.specialtyCode || s.specialty_code)}</td> <td>${escapeHtml(s.specialityCode || s.specialtyCode || s.specialty_code)}</td>
<td> <td>
<button class="btn-edit-classroom btn-edit-specialty" data-id="${s.id}">Изменить</button> <button class="btn btn-sm btn-secondary btn-edit-specialty" data-id="${s.id}">Изменить</button>
<button class="btn-primary btn-manage-profiles" data-id="${s.id}" style="padding: 0.25rem 0.5rem; font-size: 0.85rem;">Профили</button> <button class="btn btn-sm btn-secondary btn-manage-profiles" data-id="${s.id}">Профили</button>
<button class="btn-delete btn-delete-specialty" data-id="${s.id}">Удалить</button> <button class="btn btn-sm btn-danger btn-delete-specialty" data-id="${s.id}">Удалить</button>
</td> </td>
</tr> </tr>
`).join(''); `).join('');
@@ -338,8 +338,8 @@ export async function initUniversityStructure() {
<td>${escapeHtml(p.name)}</td> <td>${escapeHtml(p.name)}</td>
<td>${escapeHtml(p.description || '-')}</td> <td>${escapeHtml(p.description || '-')}</td>
<td> <td>
<button class="btn-edit-classroom btn-edit-profile" data-id="${p.id}">Изменить</button> <button class="btn btn-sm btn-secondary btn-edit-profile" data-id="${p.id}">Изменить</button>
<button class="btn-delete btn-delete-profile" data-id="${p.id}">Удалить</button> <button class="btn btn-sm btn-danger btn-delete-profile" data-id="${p.id}">Удалить</button>
</td> </td>
</tr> </tr>
`).join(''); `).join('');
@@ -453,8 +453,8 @@ export async function initUniversityStructure() {
<td>${escapeHtml(p.name)}</td> <td>${escapeHtml(p.name)}</td>
<td>${escapeHtml(p.description || '-')}</td> <td>${escapeHtml(p.description || '-')}</td>
<td> <td>
<button class="btn-edit-classroom btn-edit-tab-profile" data-id="${p.id}">Изменить</button> <button class="btn btn-sm btn-secondary btn-edit-tab-profile" data-id="${p.id}">Изменить</button>
<button class="btn-delete btn-delete-tab-profile" data-id="${p.id}">Удалить</button> <button class="btn btn-sm btn-danger btn-delete-tab-profile" data-id="${p.id}">Удалить</button>
</td> </td>
</tr> </tr>
`).join(''); `).join('');

View File

@@ -50,7 +50,7 @@ export async function initUsers() {
<td> <td>
${u.status === 'ARCHIVED' ${u.status === 'ARCHIVED'
? `<button class="btn-restore-user" data-id="${u.id}">Восстановить</button>` ? `<button class="btn-restore-user" data-id="${u.id}">Восстановить</button>`
: `<button class="btn-delete" data-id="${u.id}">Архивировать</button>`} : `<button class="btn btn-sm btn-danger-subtle btn-archive-user" data-id="${u.id}">Архивировать</button>`}
</td> </td>
</tr> </tr>
`).join(''); `).join('');
@@ -90,7 +90,7 @@ export async function initUsers() {
}); });
usersTbody.addEventListener('click', async (e) => { usersTbody.addEventListener('click', async (e) => {
const deleteBtn = e.target.closest('.btn-delete'); const deleteBtn = e.target.closest('.btn-archive-user');
if (!deleteBtn) return; if (!deleteBtn) return;
if (!confirm('Архивировать пользователя? История расписания и связей сохранится.')) return; if (!confirm('Архивировать пользователя? История расписания и связей сохранится.')) return;

View File

@@ -25,15 +25,7 @@
} }
.sidebar-close-btn { .sidebar-close-btn {
background: transparent;
border: none;
color: var(--text-secondary); color: var(--text-secondary);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
padding: 0.25rem;
border-radius: var(--radius-sm);
transition: all var(--transition); transition: all var(--transition);
} }
@@ -265,12 +257,7 @@
/* ===== Mobile Menu Toggle ===== */ /* ===== Mobile Menu Toggle ===== */
.menu-toggle { .menu-toggle {
display: none; display: none;
padding: 0.4rem;
background: none;
border: none;
color: var(--text-primary); color: var(--text-primary);
cursor: pointer;
border-radius: var(--radius-sm);
transition: background var(--transition); transition: background var(--transition);
} }

View File

@@ -46,9 +46,9 @@
--text-primary: #0f172a; --text-primary: #0f172a;
--text-secondary: #475569; --text-secondary: #475569;
--text-placeholder: #94a3b8; --text-placeholder: #94a3b8;
--accent: #6366f1; --accent: #4F46E5;
--accent-hover: #4f46e5; --accent-hover: #4338CA;
--accent-glow: rgba(99, 102, 241, 0.3); --accent-glow: rgba(79, 70, 229, 0.3);
--accent-secondary: #d946ef; --accent-secondary: #d946ef;
--error: #ef4444; --error: #ef4444;
--success: #10b981; --success: #10b981;
@@ -77,20 +77,19 @@ body {
/* ===== Переключатель темы ===== */ /* ===== Переключатель темы ===== */
.theme-toggle { .theme-toggle {
width: 42px; width: 40px;
height: 42px; height: 40px;
border: none; border-radius: var(--button-radius);
border-radius: 50%; background: var(--button-secondary-bg);
background: var(--bg-card); border: 1px solid var(--button-secondary-border);
border: 1px solid var(--bg-card-border); color: var(--button-secondary-text);
color: var(--text-primary); display: inline-flex;
cursor: pointer;
display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
backdrop-filter: blur(12px); backdrop-filter: blur(12px);
-webkit-backdrop-filter: blur(12px); -webkit-backdrop-filter: blur(12px);
transition: background 0.3s ease, transform 0.3s ease, box-shadow 0.3s ease; padding: 0;
transition: background var(--transition), border-color var(--transition), color var(--transition), box-shadow var(--transition), transform var(--transition);
z-index: 100; z-index: 100;
flex-shrink: 0; flex-shrink: 0;
} }
@@ -102,12 +101,18 @@ body {
} }
.theme-toggle:hover { .theme-toggle:hover {
transform: scale(1.1); background: var(--button-secondary-bg-hover);
box-shadow: 0 4px 16px var(--accent-glow); border-color: var(--button-secondary-border);
transform: translateY(-1px);
} }
.theme-toggle:active { .theme-toggle:active {
transform: scale(0.95); transform: translateY(0);
}
.theme-toggle:focus-visible {
outline: none;
box-shadow: 0 0 0 3px var(--button-focus-ring);
} }
.theme-toggle--fixed { .theme-toggle--fixed {
@@ -265,63 +270,6 @@ body {
cursor: pointer; cursor: pointer;
} }
.btn-primary,
.btn-secondary,
.btn-delete {
min-height: 40px;
border: 1px solid transparent;
border-radius: var(--radius-sm);
padding: 0 0.9rem;
color: var(--text-primary);
font: inherit;
font-weight: 700;
cursor: pointer;
transition: transform var(--transition), background var(--transition), border-color var(--transition);
white-space: nowrap;
}
.btn-primary {
background: var(--accent);
}
.btn-primary:hover {
background: var(--accent-hover);
transform: translateY(-1px);
}
.btn-secondary {
background: var(--bg-input);
border-color: var(--bg-card-border);
}
.btn-secondary:hover {
background: var(--bg-hover);
transform: translateY(-1px);
}
.btn-delete {
background: rgba(239, 68, 68, 0.13);
border-color: rgba(239, 68, 68, 0.25);
color: #fecaca;
}
[data-theme="light"] .btn-delete {
color: #991b1b;
}
.btn-delete:hover {
background: rgba(239, 68, 68, 0.2);
transform: translateY(-1px);
}
.btn-primary:disabled,
.btn-secondary:disabled,
.btn-delete:disabled {
cursor: not-allowed;
opacity: 0.45;
transform: none;
}
.form-alert { .form-alert {
grid-column: 1 / -1; grid-column: 1 / -1;
min-height: 0; min-height: 0;
@@ -614,7 +562,7 @@ body {
grid-template-columns: 1fr; grid-template-columns: 1fr;
} }
.settings-form .btn-primary { .settings-form .btn {
width: fit-content; width: fit-content;
} }
@@ -630,9 +578,16 @@ body {
flex-direction: column; flex-direction: column;
} }
.settings-header-row .btn-primary, .settings-header-row .btn,
.card-header-row .btn-secondary, .card-header-row .btn,
.settings-form .btn-primary { .settings-form .btn {
width: 100%; width: 100%;
} }
} }
.settings-modal-close.btn-icon-sm {
width: 32px !important;
height: 32px !important;
min-height: 32px !important;
padding: 0 !important;
border-radius: var(--button-radius) !important;
}

View File

@@ -32,7 +32,7 @@
</svg> </svg>
<span>Настройки</span> <span>Настройки</span>
</div> </div>
<button class="sidebar-close-btn" id="sidebar-close-btn" aria-label="Скрыть панель"> <button class="btn btn-icon-sm btn-ghost sidebar-close-btn" id="sidebar-close-btn" aria-label="Скрыть панель">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<polyline points="15 18 9 12 15 6" /> <polyline points="15 18 9 12 15 6" />
</svg> </svg>
@@ -94,7 +94,7 @@
<!-- Основная область --> <!-- Основная область -->
<main class="main"> <main class="main">
<header class="topbar"> <header class="topbar">
<button class="menu-toggle" id="menu-toggle" aria-label="Меню"> <button class="btn btn-icon-md btn-ghost menu-toggle" id="menu-toggle" aria-label="Меню">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" <svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
stroke-linecap="round" stroke-linejoin="round"> stroke-linecap="round" stroke-linejoin="round">
<line x1="3" y1="6" x2="21" y2="6" /> <line x1="3" y1="6" x2="21" y2="6" />

View File

@@ -68,7 +68,7 @@ export async function initDatabase() {
<td><code style="font-size: 0.82rem;">${escapeHtml(t.url)}</code></td> <td><code style="font-size: 0.82rem;">${escapeHtml(t.url)}</code></td>
<td>${escapeHtml(t.username || '—')}</td> <td>${escapeHtml(t.username || '—')}</td>
<td>${statusBadge}</td> <td>${statusBadge}</td>
<td><button class="btn-delete" data-domain="${escapeHtml(t.domain)}">Удалить</button></td> <td><button class="btn btn-sm btn-danger btn-delete-tenant" data-domain="${escapeHtml(t.domain)}">Удалить</button></td>
</tr>`; </tr>`;
}).join(''); }).join('');
} }
@@ -136,7 +136,7 @@ export async function initDatabase() {
// === Удаление тенанта === // === Удаление тенанта ===
tenantsTbody.addEventListener('click', async (e) => { tenantsTbody.addEventListener('click', async (e) => {
const btn = e.target.closest('.btn-delete'); const btn = e.target.closest('.btn-delete-tenant');
if (!btn) return; if (!btn) return;
const domain = btn.dataset.domain; const domain = btn.dataset.domain;

View File

@@ -35,7 +35,7 @@ export async function initEduForms() {
<tr> <tr>
<td>${ef.id}</td> <td>${ef.id}</td>
<td>${escapeHtml(ef.name)}</td> <td>${escapeHtml(ef.name)}</td>
<td><button class="btn-delete" data-id="${ef.id}">Удалить</button></td> <td><button class="btn btn-sm btn-danger btn-delete-edu-form" data-id="${ef.id}">Удалить</button></td>
</tr>`).join(''); </tr>`).join('');
} }
@@ -56,7 +56,7 @@ export async function initEduForms() {
}); });
efTbody.addEventListener('click', async (e) => { efTbody.addEventListener('click', async (e) => {
const btn = e.target.closest('.btn-delete'); const btn = e.target.closest('.btn-delete-edu-form');
if (!btn) return; if (!btn) return;
if (!confirm('Удалить форму обучения?')) return; if (!confirm('Удалить форму обучения?')) return;
try { try {

View File

@@ -143,8 +143,8 @@ export async function initTimeSlotsSettings() {
<td>${escapeHtml(trimTime(slot.endTime))}</td> <td>${escapeHtml(trimTime(slot.endTime))}</td>
<td>${escapeHtml(String(slot.durationMinutes ?? '-'))}</td> <td>${escapeHtml(String(slot.durationMinutes ?? '-'))}</td>
<td class="actions-cell"> <td class="actions-cell">
<button type="button" class="btn-secondary btn-edit-time-slot" data-id="${slot.id}">Изменить</button> <button type="button" class="btn btn-sm btn-secondary btn-edit-time-slot" data-id="${slot.id}">Изменить</button>
<button type="button" class="btn-delete btn-delete-time-slot" data-id="${slot.id}">Удалить</button> <button type="button" class="btn btn-sm btn-danger btn-delete-time-slot" data-id="${slot.id}">Удалить</button>
</td> </td>
</tr> </tr>
`).join(''); `).join('');

View File

@@ -61,10 +61,10 @@
<label for="tenant-password">Пароль</label> <label for="tenant-password">Пароль</label>
<input type="password" id="tenant-password" placeholder="••••••••" required> <input type="password" id="tenant-password" placeholder="••••••••" required>
</div> </div>
<button type="button" class="btn-primary" id="btn-test-connection" style="height: fit-content;"> <button type="button" class="btn btn-md btn-secondary" id="btn-test-connection" style="height: fit-content;">
Тест Тест
</button> </button>
<button type="submit" class="btn-primary" style="height: fit-content;"> <button type="submit" class="btn btn-md btn-primary" style="height: fit-content;">
Добавить Добавить
</button> </button>
</div> </div>

View File

@@ -7,7 +7,7 @@
<label for="new-ef-name">Название</label> <label for="new-ef-name">Название</label>
<input type="text" id="new-ef-name" placeholder="Бакалавриат" required> <input type="text" id="new-ef-name" placeholder="Бакалавриат" required>
</div> </div>
<button type="submit" class="btn-primary">Создать</button> <button type="submit" class="btn btn-md btn-primary">Создать</button>
</div> </div>
<div class="form-alert" id="create-ef-alert" role="alert"></div> <div class="form-alert" id="create-ef-alert" role="alert"></div>
</form> </form>

View File

@@ -4,7 +4,7 @@
<h2>Временные слоты</h2> <h2>Временные слоты</h2>
<p>Настройка базовой, субботней и ручных сеток времени.</p> <p>Настройка базовой, субботней и ручных сеток времени.</p>
</div> </div>
<button type="button" class="btn-primary" id="time-slots-refresh">Обновить</button> <button type="button" class="btn btn-md btn-secondary" id="time-slots-refresh">Обновить</button>
</div> </div>
<div class="settings-grid"> <div class="settings-grid">
@@ -12,8 +12,8 @@
<div class="card-header-row"> <div class="card-header-row">
<h3>Сетка времени</h3> <h3>Сетка времени</h3>
<div class="scope-header-actions"> <div class="scope-header-actions">
<button type="button" class="btn-primary" id="time-slot-scope-open-create">Новая ручная сетка</button> <button type="button" class="btn btn-md btn-primary" id="time-slot-scope-open-create">Новая ручная сетка</button>
<button type="button" class="btn-secondary" id="time-slot-scope-delete">Удалить сетку</button> <button type="button" class="btn btn-md btn-danger-subtle" id="time-slot-scope-delete">Удалить сетку</button>
</div> </div>
</div> </div>
@@ -47,7 +47,7 @@
<article class="settings-card"> <article class="settings-card">
<div class="card-header-row"> <div class="card-header-row">
<h3 id="time-slot-form-title">Новый слот</h3> <h3 id="time-slot-form-title">Новый слот</h3>
<button type="button" class="btn-secondary" id="time-slot-reset">Очистить</button> <button type="button" class="btn btn-md btn-ghost" id="time-slot-reset">Очистить</button>
</div> </div>
<form id="time-slot-form" class="settings-form" novalidate> <form id="time-slot-form" class="settings-form" novalidate>
@@ -73,7 +73,7 @@
<input type="number" id="time-slot-duration" min="1" step="1" placeholder="90"> <input type="number" id="time-slot-duration" min="1" step="1" placeholder="90">
</label> </label>
<button type="submit" class="btn-primary">Сохранить слот</button> <button type="submit" class="btn btn-md btn-primary">Сохранить слот</button>
<div class="form-alert" id="time-slot-alert" role="alert"></div> <div class="form-alert" id="time-slot-alert" role="alert"></div>
</form> </form>
</article> </article>
@@ -106,7 +106,12 @@
<h3 id="time-slot-scope-modal-title">Новая ручная сетка</h3> <h3 id="time-slot-scope-modal-title">Новая ручная сетка</h3>
<p class="settings-modal-subtitle">Создайте отдельную сетку времени, которую затем можно назначать датам в календарном графике.</p> <p class="settings-modal-subtitle">Создайте отдельную сетку времени, которую затем можно назначать датам в календарном графике.</p>
</div> </div>
<button type="button" class="settings-modal-close" id="time-slot-scope-modal-close" aria-label="Закрыть">×</button> <button type="button" class="btn btn-icon-sm btn-secondary settings-modal-close" id="time-slot-scope-modal-close" aria-label="Закрыть">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<line x1="18" y1="6" x2="6" y2="18"></line>
<line x1="6" y1="6" x2="18" y2="18"></line>
</svg>
</button>
</div> </div>
<form id="time-slot-scope-form" class="settings-modal-form" novalidate> <form id="time-slot-scope-form" class="settings-modal-form" novalidate>
@@ -116,8 +121,8 @@
</label> </label>
<div class="settings-modal-actions"> <div class="settings-modal-actions">
<button type="submit" class="btn-primary">Создать сетку</button> <button type="submit" class="btn btn-md btn-primary">Создать сетку</button>
<button type="button" class="btn-secondary" id="time-slot-scope-modal-cancel">Отмена</button> <button type="button" class="btn btn-md btn-ghost" id="time-slot-scope-modal-cancel">Отмена</button>
</div> </div>
<div class="form-alert" id="time-slot-scope-alert" role="alert"></div> <div class="form-alert" id="time-slot-scope-alert" role="alert"></div>

View File

@@ -15,8 +15,8 @@
<label for="academic-year-end">Окончание</label> <label for="academic-year-end">Окончание</label>
<input type="date" id="academic-year-end" required> <input type="date" id="academic-year-end" required>
</div> </div>
<button type="submit" class="btn-primary">Сохранить год</button> <button type="submit" class="btn btn-md btn-primary">Сохранить год</button>
<button type="button" class="btn-edit-classroom" id="academic-year-reset">Очистить</button> <button type="button" class="btn btn-md btn-ghost" id="academic-year-reset">Очистить</button>
</div> </div>
<div class="form-alert" id="academic-year-alert" role="alert"></div> <div class="form-alert" id="academic-year-alert" role="alert"></div>
</form> </form>
@@ -25,10 +25,10 @@
<div class="card"> <div class="card">
<div class="card-header-row"> <div class="card-header-row">
<h2>Учебные годы</h2> <h2>Учебные годы</h2>
<button class="btn-primary" id="academic-years-refresh">Обновить</button> <button class="btn btn-md btn-secondary" id="academic-years-refresh">Обновить</button>
</div> </div>
<div class="table-wrap"> <div class="table-wrap">
<table class="academic-years-table"> <table>
<thead> <thead>
<tr> <tr>
<th>Название</th> <th>Название</th>
@@ -72,8 +72,8 @@
<label for="semester-end">Окончание</label> <label for="semester-end">Окончание</label>
<input type="date" id="semester-end" required> <input type="date" id="semester-end" required>
</div> </div>
<button type="submit" class="btn-primary">Сохранить семестр</button> <button type="submit" class="btn btn-md btn-primary">Сохранить семестр</button>
<button type="button" class="btn-edit-classroom" id="semester-reset">Очистить</button> <button type="button" class="btn btn-md btn-ghost" id="semester-reset">Очистить</button>
</div> </div>
<div class="form-alert" id="semester-alert" role="alert"></div> <div class="form-alert" id="semester-alert" role="alert"></div>
</form> </form>
@@ -82,7 +82,7 @@
<div class="card create-card"> <div class="card create-card">
<div class="card-header-row"> <div class="card-header-row">
<h2 id="academic-calendar-form-title">Календарный учебный график</h2> <h2 id="academic-calendar-form-title">Календарный учебный график</h2>
<button type="button" class="btn-edit-classroom" id="academic-calendar-reset">Очистить</button> <button type="button" class="btn btn-md btn-ghost" id="academic-calendar-reset">Очистить</button>
</div> </div>
<form id="academic-calendar-form" novalidate> <form id="academic-calendar-form" novalidate>
<input type="hidden" id="academic-calendar-id"> <input type="hidden" id="academic-calendar-id">
@@ -119,7 +119,7 @@
<label for="academic-calendar-course-count">Курсов</label> <label for="academic-calendar-course-count">Курсов</label>
<input type="number" id="academic-calendar-course-count" min="1" max="8" value="4" required> <input type="number" id="academic-calendar-course-count" min="1" max="8" value="4" required>
</div> </div>
<button type="submit" class="btn-primary">Сохранить график</button> <button type="submit" class="btn btn-md btn-primary">Сохранить график</button>
</div> </div>
<div class="form-alert" id="academic-calendar-alert" role="alert"></div> <div class="form-alert" id="academic-calendar-alert" role="alert"></div>
</form> </form>
@@ -128,7 +128,7 @@
<div class="card"> <div class="card">
<div class="card-header-row"> <div class="card-header-row">
<h2>Календарные графики</h2> <h2>Календарные графики</h2>
<button class="btn-primary" id="academic-calendars-refresh">Обновить</button> <button class="btn btn-md btn-secondary" id="academic-calendars-refresh">Обновить</button>
</div> </div>
<div class="table-wrap"> <div class="table-wrap">
<table> <table>
@@ -161,8 +161,8 @@
<option value="">Загрузка...</option> <option value="">Загрузка...</option>
</select> </select>
</div> </div>
<button type="button" class="btn-primary" id="calendar-editor-load">Загрузить сетку</button> <button type="button" class="btn btn-md btn-secondary" id="calendar-editor-load">Загрузить сетку</button>
<button type="button" class="btn-primary" id="calendar-editor-save">Сохранить сетку</button> <button type="button" class="btn btn-md btn-primary" id="calendar-editor-save">Сохранить сетку</button>
</div> </div>
<div class="calendar-fill-panel"> <div class="calendar-fill-panel">
<div class="form-group"> <div class="form-group">
@@ -185,7 +185,7 @@
<option value="">Загрузка...</option> <option value="">Загрузка...</option>
</select> </select>
</div> </div>
<button type="button" class="btn-edit-classroom" id="calendar-fill-apply">Заполнить диапазон</button> <button type="button" class="btn btn-md btn-secondary" id="calendar-fill-apply">Заполнить диапазон</button>
</div> </div>
<div class="form-alert" id="calendar-editor-alert" role="alert"></div> <div class="form-alert" id="calendar-editor-alert" role="alert"></div>
<div id="calendar-totals" class="calendar-totals"></div> <div id="calendar-totals" class="calendar-totals"></div>
@@ -229,8 +229,8 @@
</select> </select>
</div> </div>
<div class="calendar-day-dialog-actions"> <div class="calendar-day-dialog-actions">
<button type="button" class="btn-primary" id="calendar-day-dialog-time-save">Применить</button> <button type="button" class="btn btn-md btn-primary" id="calendar-day-dialog-time-save">Применить</button>
<button type="button" class="btn-edit-classroom" id="calendar-day-dialog-time-clear">Убрать ручную сетку</button> <button type="button" class="btn btn-md btn-danger-subtle" id="calendar-day-dialog-time-clear">Убрать ручную сетку</button>
</div> </div>
<div class="form-alert" id="calendar-day-dialog-time-alert" role="alert"></div> <div class="form-alert" id="calendar-day-dialog-time-alert" role="alert"></div>
</div> </div>

View File

@@ -1,7 +1,7 @@
<div class="card"> <div class="card">
<div class="card-header-row" style="margin-bottom: 1.5rem;"> <div class="card-header-row" style="margin-bottom: 1.5rem;">
<h2>Загруженность</h2> <h2>Загруженность</h2>
<button type="button" class="btn-primary" id="workload-refresh">Обновить</button> <button type="button" class="btn btn-md btn-secondary" id="workload-refresh">Обновить</button>
</div> </div>
<div class="filter-row workload-filter-row" style="margin-bottom: 2rem; align-items: flex-end; gap: 1.5rem;"> <div class="filter-row workload-filter-row" style="margin-bottom: 2rem; align-items: flex-end; gap: 1.5rem;">

View File

@@ -2,7 +2,7 @@
<div class="card create-card"> <div class="card create-card">
<div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem; flex-wrap: wrap; gap: 0.5rem;"> <div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 1rem; flex-wrap: wrap; gap: 0.5rem;">
<h2 style="margin: 0;">Новая аудитория</h2> <h2 style="margin: 0;">Новая аудитория</h2>
<button type="button" class="btn-primary" id="btn-manage-equipments">Справочник оборудования</button> <button type="button" class="btn btn-md btn-secondary" id="btn-manage-equipments">Справочник оборудования</button>
</div> </div>
<form id="create-classroom-form"> <form id="create-classroom-form">
<div class="form-row"> <div class="form-row">
@@ -36,7 +36,7 @@
</div> </div>
</div> </div>
<div class="form-group" style="display: flex; align-items: flex-end;"> <div class="form-group" style="display: flex; align-items: flex-end;">
<button type="submit" class="btn-primary">Добавить</button> <button type="submit" class="btn btn-md btn-primary">Добавить</button>
</div> </div>
</div> </div>
<div class="form-alert" id="create-classroom-alert" role="alert"></div> <div class="form-alert" id="create-classroom-alert" role="alert"></div>
@@ -103,7 +103,7 @@
</div> </div>
</div> </div>
<div class="form-group" style="display: flex; align-items: flex-end;"> <div class="form-group" style="display: flex; align-items: flex-end;">
<button type="submit" class="btn-primary" style="width: 100%;">Сохранить</button> <button type="submit" class="btn btn-md btn-primary" style="width: 100%;">Сохранить</button>
</div> </div>
</div> </div>
<div class="form-alert" id="edit-classroom-alert" role="alert"></div> <div class="form-alert" id="edit-classroom-alert" role="alert"></div>
@@ -128,7 +128,7 @@
<label for="manage-equipment-name">Название оборудования</label> <label for="manage-equipment-name">Название оборудования</label>
<input type="text" id="manage-equipment-name" placeholder="Проектор" required> <input type="text" id="manage-equipment-name" placeholder="Проектор" required>
</div> </div>
<button type="submit" class="btn-primary" style="height: fit-content; margin-bottom: 0;">Добавить</button> <button type="submit" class="btn btn-md btn-primary" style="height: fit-content; margin-bottom: 0;">Добавить</button>
</div> </div>
<div class="form-alert" id="manage-equipment-alert" role="alert"></div> <div class="form-alert" id="manage-equipment-alert" role="alert"></div>
</form> </form>

View File

@@ -100,7 +100,7 @@
<div class="card dashboard-compact-card"> <div class="card dashboard-compact-card">
<h3 class="dashboard-card-title">Быстрый переход</h3> <h3 class="dashboard-card-title">Быстрый переход</h3>
<div class="dashboard-actions"> <div class="dashboard-actions">
<a href="#schedule" class="btn-primary dashboard-action-link"> <a href="#schedule" class="btn btn-md btn-primary dashboard-action-link">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<rect x="3" y="4" width="18" height="18" rx="2" ry="2"></rect> <rect x="3" y="4" width="18" height="18" rx="2" ry="2"></rect>
<line x1="16" y1="2" x2="16" y2="6"></line> <line x1="16" y1="2" x2="16" y2="6"></line>
@@ -109,7 +109,7 @@
</svg> </svg>
Конструктор расписания Конструктор расписания
</a> </a>
<a href="#schedule-view" class="btn-secondary dashboard-action-link"> <a href="#schedule-view" class="btn btn-md btn-secondary dashboard-action-link">
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> <svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<path d="M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7-10-7-10-7z" /> <path d="M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7-10-7-10-7z" />
<circle cx="12" cy="12" r="3" /> <circle cx="12" cy="12" r="3" />
@@ -135,7 +135,7 @@
</h2> </h2>
<div style="color: var(--text-secondary); font-size: 0.85rem; margin-top: 0.25rem;">Анализ накладок и коллизий расписания на текущую неделю</div> <div style="color: var(--text-secondary); font-size: 0.85rem; margin-top: 0.25rem;">Анализ накладок и коллизий расписания на текущую неделю</div>
</div> </div>
<button class="btn-primary" id="btn-recheck-conflicts" style="font-size: 0.85rem; padding: 0.35rem 0.75rem;">Перепроверить</button> <button class="btn btn-sm btn-secondary" id="btn-recheck-conflicts">Перепроверить</button>
</div> </div>
<div id="conflicts-container" style="display: flex; flex-direction: column; gap: 0.75rem; margin-top: 1rem;"> <div id="conflicts-container" style="display: flex; flex-direction: column; gap: 0.75rem; margin-top: 1rem;">

View File

@@ -1,7 +1,7 @@
<div class="card"> <div class="card">
<div class="card-header-row"> <div class="card-header-row">
<h2>Рабочая область кафедры</h2> <h2>Рабочая область кафедры</h2>
<button type="button" class="btn-primary" id="department-workspace-refresh">Обновить</button> <button type="button" class="btn btn-md btn-secondary" id="department-workspace-refresh">Обновить</button>
</div> </div>
<div class="schedule-view-filter-grid"> <div class="schedule-view-filter-grid">
@@ -25,7 +25,7 @@
</div> </div>
<div class="department-workspace-grid"> <div class="department-workspace-grid">
<div class="card create-card department-subject-import-card"> <div class="card create-card">
<div class="card-header-row"> <div class="card-header-row">
<h2>Загрузка дисциплин</h2> <h2>Загрузка дисциплин</h2>
</div> </div>
@@ -33,16 +33,16 @@
<div class="subject-import-slots-panel"> <div class="subject-import-slots-panel">
<div class="card-header-row"> <div class="card-header-row">
<label class="schedule-section-label" style="margin:0">Дисциплины для загрузки</label> <label class="schedule-section-label" style="margin:0">Дисциплины для загрузки</label>
<button type="button" class="btn-edit-classroom" id="subject-import-add">Добавить строку</button> <button type="button" class="btn btn-sm btn-secondary" id="subject-import-add">Добавить строку</button>
</div> </div>
<div id="subject-import-rows" class="subject-import-rows-list"></div> <div id="subject-import-rows" class="subject-import-rows-list"></div>
</div> </div>
<button type="submit" class="btn-primary">Загрузить</button> <button type="submit" class="btn btn-md btn-secondary">Загрузить</button>
<div class="form-alert" id="department-subject-import-alert" role="alert"></div> <div class="form-alert" id="department-subject-import-alert" role="alert"></div>
</form> </form>
</div> </div>
<div class="card department-teachers-card"> <div class="card">
<div class="card-header-row"> <div class="card-header-row">
<h2>Преподаватели и нагрузка</h2> <h2>Преподаватели и нагрузка</h2>
</div> </div>
@@ -75,7 +75,7 @@
<div class="card" id="department-comments-card" hidden> <div class="card" id="department-comments-card" hidden>
<div class="card-header-row"> <div class="card-header-row">
<h2 id="department-comments-title">Комментарии</h2> <h2 id="department-comments-title">Комментарии</h2>
<button type="button" class="btn-edit-classroom" id="department-comments-close">Скрыть</button> <button type="button" class="btn btn-sm btn-ghost" id="department-comments-close">Скрыть</button>
</div> </div>
<form id="department-comment-form" class="form-row"> <form id="department-comment-form" class="form-row">
@@ -84,7 +84,7 @@
<label for="department-comment-text">Новый комментарий</label> <label for="department-comment-text">Новый комментарий</label>
<input type="text" id="department-comment-text" placeholder="Добавьте примечание по дисциплине"> <input type="text" id="department-comment-text" placeholder="Добавьте примечание по дисциплине">
</div> </div>
<button type="submit" class="btn-primary">Сохранить</button> <button type="submit" class="btn btn-md btn-primary">Сохранить</button>
</form> </form>
<div class="form-alert" id="department-comment-alert" role="alert"></div> <div class="form-alert" id="department-comment-alert" role="alert"></div>
<div class="department-comment-list" id="department-comment-list">Выберите дисциплину</div> <div class="department-comment-list" id="department-comment-list">Выберите дисциплину</div>

View File

@@ -39,7 +39,7 @@
<option value="">Выберите специальность</option> <option value="">Выберите специальность</option>
</select> </select>
</div> </div>
<button type="submit" class="btn-primary">Создать</button> <button type="submit" class="btn btn-md btn-primary">Создать</button>
</div> </div>
<div class="form-alert" id="create-group-alert" role="alert"></div> <div class="form-alert" id="create-group-alert" role="alert"></div>
</form> </form>
@@ -127,7 +127,7 @@
</select> </select>
</div> </div>
<div class="form-group" style="display: flex; align-items: flex-end;"> <div class="form-group" style="display: flex; align-items: flex-end;">
<button type="submit" class="btn-primary" style="width: 100%;">Сохранить</button> <button type="submit" class="btn btn-md btn-primary" style="width: 100%;">Сохранить</button>
</div> </div>
</div> </div>
<div class="form-alert" id="edit-group-alert" role="alert"></div> <div class="form-alert" id="edit-group-alert" role="alert"></div>
@@ -162,8 +162,8 @@
<input type="number" id="manage-subgroup-capacity" min="1" placeholder="12" required> <input type="number" id="manage-subgroup-capacity" min="1" placeholder="12" required>
</div> </div>
<div style="display: flex; gap: 0.5rem; margin-bottom: 0;"> <div style="display: flex; gap: 0.5rem; margin-bottom: 0;">
<button type="submit" class="btn-primary" id="btn-save-subgroup">Создать</button> <button type="submit" class="btn btn-md btn-primary" id="btn-save-subgroup">Создать</button>
<button type="button" class="btn-secondary" id="btn-cancel-subgroup-edit" style="display: none;">Отмена</button> <button type="button" class="btn btn-md btn-ghost" id="btn-cancel-subgroup-edit" style="display: none;">Отмена</button>
</div> </div>
</div> </div>
<div class="form-alert" id="manage-subgroup-alert" role="alert"></div> <div class="form-alert" id="manage-subgroup-alert" role="alert"></div>
@@ -219,7 +219,7 @@
<option value="">Выберите учебный год</option> <option value="">Выберите учебный год</option>
</select> </select>
</div> </div>
<button type="submit" class="btn-primary" style="height: fit-content; margin-bottom: 0;">Назначить</button> <button type="submit" class="btn btn-md btn-primary" style="height: fit-content; margin-bottom: 0;">Назначить</button>
</div> </div>
<div class="form-alert" id="manage-calendar-alert" role="alert"></div> <div class="form-alert" id="manage-calendar-alert" role="alert"></div>
</form> </form>

View File

@@ -5,7 +5,7 @@
<h2>Расписания</h2> <h2>Расписания</h2>
<span class="muted" id="schedule-view-period-label">Период не выбран</span> <span class="muted" id="schedule-view-period-label">Период не выбран</span>
</div> </div>
<button type="button" class="btn-primary" id="schedule-view-load">Показать</button> <button type="button" class="btn btn-md btn-secondary" id="schedule-view-load">Показать</button>
</div> </div>
<div class="schedule-view-control-grid"> <div class="schedule-view-control-grid">
@@ -87,9 +87,17 @@
<span class="muted" id="schedule-view-count">0 занятий</span> <span class="muted" id="schedule-view-count">0 занятий</span>
</div> </div>
<div class="schedule-view-result-actions"> <div class="schedule-view-result-actions">
<button type="button" class="btn-secondary" id="schedule-view-result-prev" aria-label="Предыдущее расписание"></button> <button type="button" class="btn btn-icon-md btn-secondary" id="schedule-view-result-prev" aria-label="Предыдущее расписание">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<polyline points="15 18 9 12 15 6"></polyline>
</svg>
</button>
<span id="schedule-view-active-label">Нет выбранного расписания</span> <span id="schedule-view-active-label">Нет выбранного расписания</span>
<button type="button" class="btn-secondary" id="schedule-view-result-next" aria-label="Следующее расписание"></button> <button type="button" class="btn btn-icon-md btn-secondary" id="schedule-view-result-next" aria-label="Следующее расписание">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<polyline points="9 18 15 12 9 6"></polyline>
</svg>
</button>
</div> </div>
</div> </div>
<div class="schedule-view-result-tabs" id="schedule-view-result-tabs"></div> <div class="schedule-view-result-tabs" id="schedule-view-result-tabs"></div>

View File

@@ -1,7 +1,7 @@
<div class="card create-card"> <div class="card create-card">
<div class="card-header-row"> <div class="card-header-row">
<h2 id="schedule-rule-form-title">Новое правило расписания</h2> <h2 id="schedule-rule-form-title">Новое правило расписания</h2>
<button type="button" class="btn-edit-classroom" id="schedule-rule-reset">Очистить форму</button> <button type="button" class="btn btn-sm btn-ghost" id="schedule-rule-reset">Очистить форму</button>
</div> </div>
<form id="schedule-rule-form" novalidate> <form id="schedule-rule-form" novalidate>
<input type="hidden" id="schedule-rule-id"> <input type="hidden" id="schedule-rule-id">
@@ -79,13 +79,13 @@
<div class="schedule-slots-panel"> <div class="schedule-slots-panel">
<div class="card-header-row"> <div class="card-header-row">
<h2>Слоты правила</h2> <h2>Слоты правила</h2>
<button type="button" class="btn-edit-classroom" id="schedule-slot-add">Добавить слот</button> <button type="button" class="btn btn-sm btn-secondary" id="schedule-slot-add">Добавить слот</button>
</div> </div>
<div id="schedule-rule-slots" class="schedule-slots-list"></div> <div id="schedule-rule-slots" class="schedule-slots-list"></div>
</div> </div>
<div class="form-row schedule-rule-actions"> <div class="form-row schedule-rule-actions">
<button type="submit" class="btn-primary">Сохранить правило</button> <button type="submit" class="btn btn-md btn-primary">Сохранить правило</button>
</div> </div>
<div class="form-alert" id="schedule-rule-alert" role="alert"></div> <div class="form-alert" id="schedule-rule-alert" role="alert"></div>
</form> </form>
@@ -94,7 +94,7 @@
<div class="card"> <div class="card">
<div class="card-header-row"> <div class="card-header-row">
<h2>Правила динамического расписания</h2> <h2>Правила динамического расписания</h2>
<button class="btn-primary" id="schedule-refresh">Обновить</button> <button class="btn btn-md btn-secondary" id="schedule-refresh">Обновить</button>
</div> </div>
<div class="table-wrap"> <div class="table-wrap">
<table id="schedule-table"> <table id="schedule-table">
@@ -119,10 +119,15 @@
</div> </div>
<button type="button" <button type="button"
class="schedule-visual-handle" class="btn btn-icon-md btn-secondary schedule-visual-handle"
id="schedule-visual-toggle" id="schedule-visual-toggle"
aria-label="Показать визуальное расписание" aria-label="Показать визуальное расписание"
aria-expanded="false"></button> aria-expanded="false">
<svg class="schedule-visual-handle-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"
stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<polyline points="15 18 9 12 15 6"></polyline>
</svg>
</button>
<div class="schedule-visual-backdrop" id="schedule-visual-backdrop" hidden></div> <div class="schedule-visual-backdrop" id="schedule-visual-backdrop" hidden></div>
@@ -133,14 +138,28 @@
<h2>Визуальное расписание групп</h2> <h2>Визуальное расписание групп</h2>
<span class="muted" id="schedule-visual-summary">Правила ещё не загружены</span> <span class="muted" id="schedule-visual-summary">Правила ещё не загружены</span>
</div> </div>
<button type="button" class="btn-edit-classroom" id="schedule-visual-close">Свернуть</button> <button type="button" class="btn btn-sm btn-ghost" id="schedule-visual-close">Свернуть</button>
</div> </div>
<div class="schedule-visual-toolbar"> <div class="schedule-visual-toolbar">
<div class="schedule-visual-period-grid">
<div class="form-group">
<label for="schedule-visual-year">Учебный год</label>
<select id="schedule-visual-year">
<option value="">Загрузка...</option>
</select>
</div>
<div class="form-group">
<label for="schedule-visual-semester">Семестр</label>
<select id="schedule-visual-semester">
<option value="">Загрузка...</option>
</select>
</div>
</div>
<div class="schedule-visual-filter-head"> <div class="schedule-visual-filter-head">
<span>Группы</span> <span>Группы</span>
<div> <div>
<button type="button" class="btn-edit-classroom" id="schedule-visual-use-form">Из формы</button> <button type="button" class="btn btn-sm btn-ghost" id="schedule-visual-use-form">Из формы</button>
<button type="button" class="btn-edit-classroom" id="schedule-visual-select-all">Все</button> <button type="button" class="btn btn-sm btn-ghost" id="schedule-visual-select-all">Все</button>
</div> </div>
</div> </div>
<div class="schedule-visual-group-filter" id="schedule-visual-group-filter"> <div class="schedule-visual-group-filter" id="schedule-visual-group-filter">

View File

@@ -17,7 +17,7 @@
<option value="">Загрузка...</option> <option value="">Загрузка...</option>
</select> </select>
</div> </div>
<button type="submit" class="btn-primary">Добавить</button> <button type="submit" class="btn btn-md btn-primary">Добавить</button>
</div> </div>
<div class="form-alert" id="create-subject-alert" role="alert"></div> <div class="form-alert" id="create-subject-alert" role="alert"></div>
</form> </form>
@@ -69,7 +69,7 @@
<option value="">Загрузка...</option> <option value="">Загрузка...</option>
</select> </select>
</div> </div>
<button type="submit" class="btn-primary" style="height: fit-content; margin-bottom: 0;">Привязать</button> <button type="submit" class="btn btn-md btn-primary" style="height: fit-content; margin-bottom: 0;">Привязать</button>
</div> </div>
<div class="form-alert" id="manage-ts-alert" role="alert"></div> <div class="form-alert" id="manage-ts-alert" role="alert"></div>
</form> </form>

View File

@@ -20,7 +20,7 @@
<label for="dept-code">Код кафедры</label> <label for="dept-code">Код кафедры</label>
<input type="number" id="dept-code" placeholder="Например: 1" required> <input type="number" id="dept-code" placeholder="Например: 1" required>
</div> </div>
<button type="submit" class="btn-primary">Создать</button> <button type="submit" class="btn btn-md btn-primary">Создать</button>
</div> </div>
<div class="form-alert" id="create-dept-alert" role="alert"></div> <div class="form-alert" id="create-dept-alert" role="alert"></div>
</form> </form>
@@ -64,7 +64,7 @@
<label for="spec-code">Код специальности</label> <label for="spec-code">Код специальности</label>
<input type="text" id="spec-code" placeholder="Например: 09.03.04" required> <input type="text" id="spec-code" placeholder="Например: 09.03.04" required>
</div> </div>
<button type="submit" class="btn-primary">Создать</button> <button type="submit" class="btn btn-md btn-primary">Создать</button>
</div> </div>
<div class="form-alert" id="create-spec-alert" role="alert"></div> <div class="form-alert" id="create-spec-alert" role="alert"></div>
</form> </form>
@@ -115,8 +115,8 @@
<input type="text" id="tab-profile-description" placeholder="Необязательно"> <input type="text" id="tab-profile-description" placeholder="Необязательно">
</div> </div>
<div style="display: flex; gap: 0.5rem; align-items: flex-end;"> <div style="display: flex; gap: 0.5rem; align-items: flex-end;">
<button type="submit" class="btn-primary" id="btn-tab-save-profile">Создать</button> <button type="submit" class="btn btn-md btn-primary" id="btn-tab-save-profile">Создать</button>
<button type="button" class="btn-secondary" id="btn-tab-cancel-profile-edit" style="display: none;">Отмена</button> <button type="button" class="btn btn-md btn-ghost" id="btn-tab-cancel-profile-edit" style="display: none;">Отмена</button>
</div> </div>
</div> </div>
<div class="form-alert" id="tab-profile-alert" role="alert"></div> <div class="form-alert" id="tab-profile-alert" role="alert"></div>
@@ -165,7 +165,7 @@
<label for="edit-dept-code">Код кафедры</label> <label for="edit-dept-code">Код кафедры</label>
<input type="number" id="edit-dept-code" required> <input type="number" id="edit-dept-code" required>
</div> </div>
<button type="submit" class="btn-primary">Сохранить</button> <button type="submit" class="btn btn-md btn-primary">Сохранить</button>
</div> </div>
<div class="form-alert" id="edit-dept-alert" role="alert"></div> <div class="form-alert" id="edit-dept-alert" role="alert"></div>
</form> </form>
@@ -188,7 +188,7 @@
<label for="edit-spec-code">Код специальности</label> <label for="edit-spec-code">Код специальности</label>
<input type="text" id="edit-spec-code" required> <input type="text" id="edit-spec-code" required>
</div> </div>
<button type="submit" class="btn-primary">Сохранить</button> <button type="submit" class="btn btn-md btn-primary">Сохранить</button>
</div> </div>
<div class="form-alert" id="edit-spec-alert" role="alert"></div> <div class="form-alert" id="edit-spec-alert" role="alert"></div>
</form> </form>
@@ -222,8 +222,8 @@
<input type="text" id="manage-profile-description" placeholder="Необязательно"> <input type="text" id="manage-profile-description" placeholder="Необязательно">
</div> </div>
<div style="display: flex; gap: 0.5rem; align-items: flex-end;"> <div style="display: flex; gap: 0.5rem; align-items: flex-end;">
<button type="submit" class="btn-primary" id="btn-save-profile">Создать</button> <button type="submit" class="btn btn-md btn-primary" id="btn-save-profile">Создать</button>
<button type="button" class="btn-secondary" id="btn-cancel-profile-edit" style="display: none;">Отмена</button> <button type="button" class="btn btn-md btn-ghost" id="btn-cancel-profile-edit" style="display: none;">Отмена</button>
</div> </div>
</div> </div>
<div class="form-alert" id="manage-profile-alert" role="alert"></div> <div class="form-alert" id="manage-profile-alert" role="alert"></div>

View File

@@ -34,7 +34,7 @@
<label for="new-department">ID Кафедры</label> <label for="new-department">ID Кафедры</label>
<input type="number" id="new-department" placeholder="ID" required> <input type="number" id="new-department" placeholder="ID" required>
</div> </div>
<button type="submit" class="btn-primary">Создать</button> <button type="submit" class="btn btn-md btn-primary">Создать</button>
</div> </div>
<div class="form-alert" id="create-alert" role="alert"></div> <div class="form-alert" id="create-alert" role="alert"></div>
</form> </form>

View File

@@ -57,7 +57,7 @@
<path d="M7 11V7a5 5 0 0 1 10 0v4"/> <path d="M7 11V7a5 5 0 0 1 10 0v4"/>
</svg> </svg>
<input type="password" id="password" name="password" placeholder="Введите пароль" autocomplete="current-password" required> <input type="password" id="password" name="password" placeholder="Введите пароль" autocomplete="current-password" required>
<button type="button" class="toggle-password" id="toggle-password" aria-label="Показать пароль"> <button type="button" class="btn btn-icon-md btn-secondary toggle-password" id="toggle-password" aria-label="Показать пароль">
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> <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="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/> <path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"/>
<circle cx="12" cy="12" r="3"/> <circle cx="12" cy="12" r="3"/>
@@ -69,7 +69,7 @@
<div class="form-alert stagger-3" id="form-alert" role="alert"></div> <div class="form-alert stagger-3" id="form-alert" role="alert"></div>
<button type="submit" class="btn-submit stagger-4" id="btn-submit"> <button type="submit" class="btn btn-lg btn-primary btn-submit stagger-4" id="btn-submit">
<span class="btn-text">Войти</span> <span class="btn-text">Войти</span>
<span class="btn-loader" hidden> <span class="btn-loader" hidden>
<svg class="spinner" width="20" height="20" viewBox="0 0 24 24"> <svg class="spinner" width="20" height="20" viewBox="0 0 24 24">

View File

@@ -155,8 +155,6 @@
setTimeout(() => { window.location.href = redirect; }, 400); setTimeout(() => { window.location.href = redirect; }, 400);
} else { } else {
showAlert(data.message || 'Неверное имя пользователя или пароль', 'error'); showAlert(data.message || 'Неверное имя пользователя или пароль', 'error');
passwordInput.value = '';
passwordInput.focus();
} }
} catch (err) { } catch (err) {
showAlert('Ошибка соединения с сервером', 'error'); showAlert('Ошибка соединения с сервером', 'error');
@@ -165,3 +163,8 @@
} }
}); });
})(); })();
document.addEventListener('DOMContentLoaded', () => {
document
.querySelector('#login-form button[type="submit"]')
?.classList.add('btn', 'btn-lg', 'btn-primary');
});

View File

@@ -15,8 +15,8 @@
--panel-border: #dbe3ef; --panel-border: #dbe3ef;
--text: #162033; --text: #162033;
--muted: #66758f; --muted: #66758f;
--accent: #0f766e; --accent: #4F46E5;
--accent-strong: #0b5d57; --accent-strong: #4338CA;
--blue: #2563eb; --blue: #2563eb;
--warning: #b45309; --warning: #b45309;
--shadow: 0 12px 34px rgba(22, 32, 51, 0.08); --shadow: 0 12px 34px rgba(22, 32, 51, 0.08);
@@ -28,8 +28,8 @@
--panel-border: #263449; --panel-border: #263449;
--text: #edf3fb; --text: #edf3fb;
--muted: #9cadc5; --muted: #9cadc5;
--accent: #2dd4bf; --accent: #8B5CF6;
--accent-strong: #5eead4; --accent-strong: #7C3AED;
--blue: #60a5fa; --blue: #60a5fa;
--warning: #f59e0b; --warning: #f59e0b;
--shadow: 0 18px 44px rgba(0, 0, 0, 0.28); --shadow: 0 18px 44px rgba(0, 0, 0, 0.28);
@@ -94,6 +94,7 @@
padding: 0 14px; padding: 0 14px;
cursor: pointer; cursor: pointer;
text-decoration: none; text-decoration: none;
transition: background 0.2s ease, border-color 0.2s ease, color 0.2s ease, transform 0.2s ease;
} }
.btn:hover { .btn:hover {
@@ -112,6 +113,47 @@
color: #fff; color: #fff;
} }
.btn-secondary {
background: var(--panel);
border-color: var(--panel-border);
color: var(--text);
}
.btn-secondary:hover {
background: color-mix(in srgb, var(--accent) 7%, var(--panel));
}
.btn-danger-subtle {
background: #FEF2F2;
border-color: #FECACA;
color: #B91C1C;
}
[data-theme="dark"] .btn-danger-subtle {
background: rgba(239, 68, 68, 0.13);
border-color: rgba(239, 68, 68, 0.25);
color: #FECACA;
}
.btn-icon-md {
width: 40px;
height: 40px;
padding: 0;
border-radius: 8px;
}
.btn-icon-md svg {
width: 20px;
height: 20px;
}
.btn-md {
min-height: 40px;
padding: 0 16px;
font-size: 14px;
font-weight: 600;
}
.toolbar { .toolbar {
display: grid; display: grid;
grid-template-columns: minmax(220px, 1fr) auto auto; grid-template-columns: minmax(220px, 1fr) auto auto;
@@ -269,8 +311,12 @@
<span id="week-label">Загрузка периода...</span> <span id="week-label">Загрузка периода...</span>
</div> </div>
<div class="actions"> <div class="actions">
<button class="btn" id="theme-toggle-local" type="button" aria-label="Переключить тему"></button> <button class="btn btn-icon-md btn-secondary" id="theme-toggle-local" type="button" aria-label="Переключить тему">
<a class="btn" href="/" id="logout-link">Выйти</a> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M21 12.79A9 9 0 1 1 11.21 3a7 7 0 0 0 9.79 9.79z"></path>
</svg>
</button>
<a class="btn btn-danger-subtle" href="/" id="logout-link">Выйти</a>
</div> </div>
</header> </header>
@@ -279,9 +325,17 @@
<option value="">Загрузка групп...</option> <option value="">Загрузка групп...</option>
</select> </select>
<div class="week-nav"> <div class="week-nav">
<button class="btn" id="prev-week" type="button" aria-label="Предыдущая неделя"></button> <button class="btn btn-icon-md btn-secondary" id="prev-week" type="button" aria-label="Предыдущая неделя">
<button class="btn btn-primary" id="today-week" type="button">Сегодня</button> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<button class="btn" id="next-week" type="button" aria-label="Следующая неделя"></button> <polyline points="15 18 9 12 15 6"></polyline>
</svg>
</button>
<button class="btn btn-md btn-primary" id="today-week" type="button">Сегодня</button>
<button class="btn btn-icon-md btn-secondary" id="next-week" type="button" aria-label="Следующая неделя">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<polyline points="9 18 15 12 9 6"></polyline>
</svg>
</button>
</div> </div>
<input class="date-input" id="date-picker" type="date" aria-label="Дата недели"> <input class="date-input" id="date-picker" type="date" aria-label="Дата недели">
</section> </section>

View File

@@ -25,6 +25,24 @@
--accent-hover: #a78bfa; --accent-hover: #a78bfa;
--accent-glow: rgba(139, 92, 246, 0.4); --accent-glow: rgba(139, 92, 246, 0.4);
--accent-secondary: #ec4899; --accent-secondary: #ec4899;
--button-primary-bg: #8B5CF6;
--button-primary-bg-hover: #7C3AED;
--button-primary-text: #FFFFFF;
--button-secondary-bg: rgba(255, 255, 255, 0.04);
--button-secondary-bg-hover: rgba(255, 255, 255, 0.08);
--button-secondary-border: rgba(255, 255, 255, 0.10);
--button-secondary-text: #F8FAFC;
--button-ghost-bg-hover: rgba(255, 255, 255, 0.06);
--button-danger-bg: #DC2626;
--button-danger-bg-hover: #B91C1C;
--button-danger-subtle-bg: rgba(239, 68, 68, 0.13);
--button-danger-subtle-bg-hover: rgba(239, 68, 68, 0.20);
--button-danger-subtle-border: rgba(239, 68, 68, 0.25);
--button-danger-subtle-text: #FECACA;
--button-focus-ring: rgba(139, 92, 246, 0.45);
--button-disabled-bg: rgba(148, 163, 184, 0.16);
--button-disabled-text: #94A3B8;
--button-radius: 8px;
/* Status Colors */ /* Status Colors */
--error: #ef4444; --error: #ef4444;
@@ -48,10 +66,27 @@
--text-primary: #0f172a; --text-primary: #0f172a;
--text-secondary: #475569; --text-secondary: #475569;
--text-placeholder: #94a3b8; --text-placeholder: #94a3b8;
--accent: #6366f1; --accent: #4F46E5;
--accent-hover: #4f46e5; --accent-hover: #4338CA;
--accent-glow: rgba(99, 102, 241, 0.3); --accent-glow: rgba(79, 70, 229, 0.3);
--accent-secondary: #d946ef; --accent-secondary: #d946ef;
--button-primary-bg: #4F46E5;
--button-primary-bg-hover: #4338CA;
--button-primary-text: #FFFFFF;
--button-secondary-bg: #FFFFFF;
--button-secondary-bg-hover: #F1F5F9;
--button-secondary-border: #CBD5E1;
--button-secondary-text: #334155;
--button-ghost-bg-hover: #F8FAFC;
--button-danger-bg: #DC2626;
--button-danger-bg-hover: #B91C1C;
--button-danger-subtle-bg: #FEF2F2;
--button-danger-subtle-bg-hover: #FEE2E2;
--button-danger-subtle-border: #FECACA;
--button-danger-subtle-text: #B91C1C;
--button-focus-ring: rgba(79, 70, 229, 0.35);
--button-disabled-bg: #CBD5E1;
--button-disabled-text: #64748B;
--error: #ef4444; --error: #ef4444;
--success: #10b981; --success: #10b981;
--warning: #f59e0b; --warning: #f59e0b;
@@ -389,59 +424,19 @@ body {
/* ===== Submit Button ===== */ /* ===== Submit Button ===== */
.btn-submit { .btn-submit {
position: relative;
overflow: hidden;
width: 100%; width: 100%;
padding: 0.95rem;
border: none;
border-radius: var(--radius-sm);
background: linear-gradient(135deg, var(--accent), var(--accent-secondary));
color: #fff;
font-family: inherit;
font-size: 1rem;
font-weight: 600;
letter-spacing: 0.02em;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem; gap: 0.5rem;
transition:
transform var(--transition),
box-shadow var(--transition),
opacity var(--transition);
box-shadow: 0 4px 15px var(--accent-glow);
}
.btn-submit::before {
content: '';
position: absolute;
top: 0; left: 0; right: 0; bottom: 0;
background: linear-gradient(rgba(255,255,255,0.2), transparent);
border-radius: inherit;
opacity: 0;
transition: opacity var(--transition);
}
.btn-submit:hover {
transform: translateY(-2px);
box-shadow: 0 8px 25px var(--accent-glow);
}
.btn-submit:hover::before {
opacity: 1;
} }
.btn-submit:active { .btn-submit:active {
transform: translateY(1px); transform: translateY(0);
box-shadow: 0 2px 10px var(--accent-glow);
} }
.btn-submit:disabled { .btn-submit:disabled {
opacity: 0.6; background: var(--button-disabled-bg);
color: var(--button-disabled-text);
cursor: not-allowed; cursor: not-allowed;
transform: none; transform: none;
box-shadow: none;
} }
/* ===== Ripple Effect ===== */ /* ===== Ripple Effect ===== */
@@ -480,13 +475,12 @@ body {
/* ===== Theme Toggle Button ===== */ /* ===== Theme Toggle Button ===== */
.theme-toggle { .theme-toggle {
width: 42px; width: 40px;
height: 42px; height: 40px;
border: none; border-radius: var(--button-radius);
border-radius: 50%; background: var(--button-secondary-bg);
background: var(--bg-card); border: 1px solid var(--button-secondary-border);
border: 1px solid var(--bg-card-border); color: var(--button-secondary-text);
color: var(--text-primary);
cursor: pointer; cursor: pointer;
display: flex; display: flex;
align-items: center; align-items: center;
@@ -505,12 +499,13 @@ body {
} }
.theme-toggle:hover { .theme-toggle:hover {
transform: scale(1.1); background: var(--button-secondary-bg-hover);
box-shadow: 0 4px 16px var(--accent-glow); transform: translateY(-1px);
box-shadow: none;
} }
.theme-toggle:active { .theme-toggle:active {
transform: scale(0.95); transform: translateY(0);
} }
.theme-toggle--fixed { .theme-toggle--fixed {
@@ -541,3 +536,94 @@ body {
height: 18px; height: 18px;
} }
} }
.btn {
display: inline-flex;
align-items: center;
justify-content: center;
gap: 0.45rem;
min-width: 0;
border: 1px solid transparent;
border-radius: var(--button-radius);
background: transparent;
color: inherit;
font-family: inherit;
font-size: 0.875rem;
font-weight: 600;
line-height: 1;
text-align: center;
text-decoration: none;
white-space: nowrap;
cursor: pointer;
transition: background var(--transition), border-color var(--transition), color var(--transition), box-shadow var(--transition), transform var(--transition);
}
.btn:hover:not(:disabled) {
transform: translateY(-1px);
}
.btn:focus-visible {
outline: none;
box-shadow: 0 0 0 3px var(--button-focus-ring);
}
.btn-lg {
min-height: 48px;
padding: 0 20px;
font-size: 15px;
font-weight: 700;
}
.btn-icon-md {
width: 40px;
height: 40px;
padding: 0;
}
.btn-icon-md svg {
width: 20px;
height: 20px;
}
.btn-primary {
background: var(--button-primary-bg);
border-color: transparent;
color: var(--button-primary-text);
}
.btn-primary:hover:not(:disabled) {
background: var(--button-primary-bg-hover);
color: var(--button-primary-text);
}
.btn-secondary {
background: var(--button-secondary-bg);
border-color: var(--button-secondary-border);
color: var(--button-secondary-text);
}
.btn-secondary:hover:not(:disabled) {
background: var(--button-secondary-bg-hover);
color: var(--button-secondary-text);
}
#login-form .btn.btn-primary {
min-height: 48px !important;
padding: 0 20px !important;
border-radius: var(--button-radius) !important;
background: var(--button-primary-bg) !important;
color: var(--button-primary-text) !important;
font-size: 15px !important;
font-weight: 700 !important;
box-shadow: none !important;
}
#login-form .btn.btn-primary:hover {
background: var(--button-primary-bg-hover) !important;
}
#toggle-password.btn-icon-md {
width: 40px !important;
height: 40px !important;
min-height: 40px !important;
padding: 0 !important;
border-radius: var(--button-radius) !important;
background: transparent !important;
}

View File

@@ -15,8 +15,8 @@
--panel-border: #d8dee8; --panel-border: #d8dee8;
--text: #172033; --text: #172033;
--muted: #67758d; --muted: #67758d;
--accent: #1d4ed8; --accent: #4F46E5;
--accent-strong: #1e40af; --accent-strong: #4338CA;
--green: #047857; --green: #047857;
--orange: #b45309; --orange: #b45309;
--shadow: 0 12px 30px rgba(23, 32, 51, 0.08); --shadow: 0 12px 30px rgba(23, 32, 51, 0.08);
@@ -28,8 +28,8 @@
--panel-border: #2a374b; --panel-border: #2a374b;
--text: #eef4fb; --text: #eef4fb;
--muted: #a2b0c5; --muted: #a2b0c5;
--accent: #60a5fa; --accent: #8B5CF6;
--accent-strong: #93c5fd; --accent-strong: #7C3AED;
--green: #34d399; --green: #34d399;
--orange: #f59e0b; --orange: #f59e0b;
--shadow: 0 18px 42px rgba(0, 0, 0, 0.28); --shadow: 0 18px 42px rgba(0, 0, 0, 0.28);
@@ -107,6 +107,7 @@
padding: 0 14px; padding: 0 14px;
cursor: pointer; cursor: pointer;
text-decoration: none; text-decoration: none;
transition: background 0.2s ease, border-color 0.2s ease, color 0.2s ease, transform 0.2s ease;
} }
.btn:hover { .btn:hover {
@@ -125,6 +126,47 @@
color: #fff; color: #fff;
} }
.btn-secondary {
background: var(--panel);
border-color: var(--panel-border);
color: var(--text);
}
.btn-secondary:hover {
background: color-mix(in srgb, var(--accent) 7%, var(--panel));
}
.btn-danger-subtle {
background: #FEF2F2;
border-color: #FECACA;
color: #B91C1C;
}
[data-theme="dark"] .btn-danger-subtle {
background: rgba(239, 68, 68, 0.13);
border-color: rgba(239, 68, 68, 0.25);
color: #FECACA;
}
.btn-icon-md {
width: 40px;
height: 40px;
padding: 0;
border-radius: 8px;
}
.btn-icon-md svg {
width: 20px;
height: 20px;
}
.btn-md {
min-height: 40px;
padding: 0 16px;
font-size: 14px;
font-weight: 600;
}
.date-input { .date-input {
padding: 0 12px; padding: 0 12px;
} }
@@ -275,16 +317,28 @@
<span id="week-label">Загрузка периода...</span> <span id="week-label">Загрузка периода...</span>
</div> </div>
<div class="actions"> <div class="actions">
<button class="btn" id="theme-toggle-local" type="button" aria-label="Переключить тему"></button> <button class="btn btn-icon-md btn-secondary" id="theme-toggle-local" type="button" aria-label="Переключить тему">
<a class="btn" href="/" id="logout-link">Выйти</a> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M21 12.79A9 9 0 1 1 11.21 3a7 7 0 0 0 9.79 9.79z"></path>
</svg>
</button>
<a class="btn btn-danger-subtle" href="/" id="logout-link">Выйти</a>
</div> </div>
</header> </header>
<section class="toolbar"> <section class="toolbar">
<div class="week-nav"> <div class="week-nav">
<button class="btn" id="prev-week" type="button" aria-label="Предыдущая неделя"></button> <button class="btn btn-icon-md btn-secondary" id="prev-week" type="button" aria-label="Предыдущая неделя">
<button class="btn btn-primary" id="today-week" type="button">Сегодня</button> <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<button class="btn" id="next-week" type="button" aria-label="Следующая неделя"></button> <polyline points="15 18 9 12 15 6"></polyline>
</svg>
</button>
<button class="btn btn-md btn-primary" id="today-week" type="button">Сегодня</button>
<button class="btn btn-icon-md btn-secondary" id="next-week" type="button" aria-label="Следующая неделя">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<polyline points="9 18 15 12 9 6"></polyline>
</svg>
</button>
</div> </div>
<input class="date-input" id="date-picker" type="date" aria-label="Дата недели"> <input class="date-input" id="date-picker" type="date" aria-label="Дата недели">
</section> </section>

View File

@@ -23,7 +23,7 @@
const isDark = () => document.documentElement.getAttribute('data-theme') !== 'light'; const isDark = () => document.documentElement.getAttribute('data-theme') !== 'light';
const btn = document.createElement('button'); const btn = document.createElement('button');
btn.className = 'theme-toggle'; btn.className = 'btn btn-icon-md btn-secondary theme-toggle';
btn.setAttribute('aria-label', 'Переключить тему'); btn.setAttribute('aria-label', 'Переключить тему');
btn.innerHTML = isDark() ? moonSVG : sunSVG; btn.innerHTML = isDark() ? moonSVG : sunSVG;