Corp/backend/templates/auth/register.html
Игорь Надеин 6b7ca64ce4 feat(ui+calendar+auth+depts): большой апдейт календаря, форм авторизации и списка отделов
Календарь (rightbar) :

• Цвет события по умолчанию (#0d6efd) + удобная палитра (input[type=color] + быстрые свотчи).

• Детали события в модале (центрирование, перенос модала в <body> при необходимости), кнопки Редактировать/Удалить.

• Редактирование: заполнение формы из деталки, PATCH по API, возврат к календарю, безопасные обработчики (навешиваются один раз).

• Еженедельная лента теперь собирает события со всех доступных календарей (компания + отделы пользователя), дедупликация по id.

• В ленте недели: учитывается цвет события, показ источника (Компания / Отдел: N), location и краткое описание (≤20 символов).

• Устранены дубли отделов в селекторе календарей; нормализованы id; аккуратные ошибки/403; безопасная работа без токена.

Список отделов:

• Поиск по названию и руководителю (data-haystack); счётчик сотрудников обновляется только по видимым строкам.

• Кнопка «Создать отдел» в стиле списка (вся строка — кликабельна), видна только при наличии прав/статуса.

• Чёткое центрирование текста и иконки без кастомных стилей (Bootstrap-утилиты), стрелка фиксирована справа.

• Прокрутка только у списка (#deptList): шапка, поиск и кнопка остаются на месте; высота как в employees_list.html.

Auth / Регистрация / Логин / Верификация email / Сброс пароля:

• Шаблоны приведены к единой теме: уважаем переключатель тем из base.html, устранён «мигание» темы.

• RegistrationForm: валидация контактов (хотя бы один из Telegram/WhatsApp/WeChat), пароли совпадают.

• Поля телефона (регистрация/логин): inputmode='numeric', pattern для E.164, подсказки; на бэке по-прежнему нормализуется.

• Регистрация: превью аватара с заглушкой, radio для пола; убраны поля навыков/должности из формы (они не нужны здесь).

• API регистрации: принимаем avatar через Base64ImageField и сохраняем user.avatar при создании; остальная логика без изменений.

• Сообщение об ошибке «нужно хотя бы одно контактное поле» возвращается в ключе detail (совместимо с тестом).

• Логин/верификация/сброс: единые заголовки, floating labels, иконки, соответствие теме.

Техническое:

• Аккуратные утилиты JS: извлечение токена, формирование URL с диапазонами дат, нормализация событий, один раз навешиваемые обработчики.

• Безопасные fallbacks для отсутствующих узлов/данных; кросс-браузерные мелочи (overflow-scrolling, aria-атрибуты).

Примечания:

• Миграций БД нет. Изменены шаблоны, фронтовый JS и вью/сериализатор регистрации.

• Если пути файлов в проекте отличаются, коммит всё равно включает все изменённые файлы (git add -A).
2025-09-10 17:03:45 +03:00

282 lines
11 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

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

{% extends 'base.html' %}
{% load static django_bootstrap5 %}
{% block title %}Регистрация сотрудника{% endblock %}
{# ВАЖНО: применяем тему ещё в <head>, чтобы не было «мигания» темы #}
{% block extra_css %}
<script>
(function(){
const root = document.documentElement;
const media = window.matchMedia('(prefers-color-scheme: dark)');
const getSaved = () => localStorage.getItem('theme') || 'auto';
const effective = (m) => m === 'auto' ? (media.matches ? 'dark' : 'light') : m;
const apply = () => root.setAttribute('data-bs-theme', effective(getSaved()));
apply();
media.addEventListener('change', () => { if (getSaved()==='auto') apply(); });
})();
</script>
{% endblock %}
{% block content %}
<div class="row justify-content-center">
<div class="col-lg-8">
<div class="card shadow">
{# Заголовок без жёсткой покраски — следуем теме #}
<div class="card-header bg-primary text-white">
<h3 class="mb-0">Регистрация нового сотрудника</h3>
</div>
<div class="card-body">
<form method="post" enctype="multipart/form-data" novalidate>
{% csrf_token %}
<!-- Контакты -->
<div class="row g-3 mb-4">
<div class="col-md-6">
{% bootstrap_field form.email show_label=False placeholder="E-mail" %}
<small class="text-muted">Пример: ivanov@company.com</small>
</div>
<div class="col-md-6">
{% bootstrap_field form.phone_number show_label=False placeholder="Телефон" %}
</div>
</div>
<!-- Мессенджеры -->
<div class="row g-3 mb-4">
<div class="col-md-4 position-relative">
{% bootstrap_field form.whatsapp show_label=False placeholder="WhatsApp" %}
<span class="position-absolute top-50 end-0 translate-middle-y me-3">
<i class="bi bi-whatsapp text-success"></i>
</span>
</div>
<div class="col-md-4 position-relative">
{% bootstrap_field form.telegram show_label=False placeholder="Telegram" %}
<span class="position-absolute top-50 end-0 translate-middle-y me-3">
<i class="bi bi-telegram"></i>
</span>
</div>
<div class="col-md-4 position-relative">
{% bootstrap_field form.wechat show_label=False placeholder="WeChat" %}
<span class="position-absolute top-50 end-0 translate-middle-y me-3">
<i class="bi bi-wechat" style="color:#07C160;"></i>
</span>
</div>
{% if form.non_field_errors %}
<div class="alert alert-danger alert-dismissible fade show mb-0 mt-3" style="border-radius:8px;">
{% for error in form.non_field_errors %}
<i class="bi bi-exclamation-circle me-2"></i>{{ error }}
{% endfor %}
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>
{% endif %}
</div>
<!-- ФИО -->
<div class="row g-3 mb-4">
<div class="col-md-4">{% bootstrap_field form.last_name show_label=False placeholder="Фамилия" %}</div>
<div class="col-md-4">{% bootstrap_field form.first_name show_label=False placeholder="Имя" %}</div>
<div class="col-md-4">{% bootstrap_field form.patronymic show_label=False placeholder="Отчество" %}</div>
</div>
<!-- Дата рождения + Пол -->
<div class="row g-3 mb-4">
<div class="col-md-4">
{% bootstrap_field form.birth_date show_label=False placeholder="Дата рождения" %}
</div>
<div class="col-md-8">
<label class="form-label d-block mb-2">Пол</label>
<div class="d-flex flex-wrap gap-3">
<div class="form-check">
<input class="form-check-input" type="radio" name="gender" id="gender-m" value="1">
<label class="form-check-label" for="gender-m">Мужской</label>
</div>
<div class="form-check">
<input class="form-check-input" type="radio" name="gender" id="gender-f" value="2">
<label class="form-check-label" for="gender-f">Женский</label>
</div>
<div class="form-check">
<input class="form-check-input" type="radio" name="gender" id="gender-u" value="0" checked>
<label class="form-check-label" for="gender-u">Не указан</label>
</div>
</div>
</div>
</div>
<!-- Пароли -->
<div class="row g-3 mb-4">
<div class="col-md-6 position-relative">
{% bootstrap_field form.password1 show_label=False placeholder="Пароль" %}
<span class="position-absolute top-50 end-0 translate-middle-y me-3" style="cursor:pointer">
<i class="bi bi-eye-slash password-toggle" data-target="#{{ form.password1.auto_id }}"></i>
</span>
</div>
<div class="col-md-6 position-relative">
{% bootstrap_field form.password2 show_label=False placeholder="Повторите пароль" %}
<span class="position-absolute top-50 end-0 translate-middle-y me-3" style="cursor:pointer">
<i class="bi bi-eye-slash password-toggle" data-target="#{{ form.password2.auto_id }}"></i>
</span>
</div>
</div>
<!-- Аватар с превью -->
<div class="row g-3 align-items-center mb-4">
<div class="col-auto">
<div class="rounded-circle border bg-body-secondary d-flex align-items-center justify-content-center"
style="width:96px;height:96px;overflow:hidden;">
<img id="avatarPreview" src=""
alt="Превью аватара"
class="d-none"
style="width:100%;height:100%;object-fit:cover;">
<i id="avatarPlaceholder" class="bi bi-person-circle fs-1 text-secondary"></i>
</div>
</div>
<div class="col">
{% bootstrap_field form.avatar %}
<small class="text-muted">Рекомендуемый размер: 200×200 px</small>
</div>
</div>
<div class="d-grid gap-2">
<button type="submit" class="btn btn-primary btn-lg">
<i class="bi bi-person-plus me-2"></i>Зарегистрироваться
</button>
<a href="{% url 'auth_front:login' %}" class="btn btn-link">Уже есть аккаунт? Войти</a>
</div>
</form>
</div>
</div>
</div>
</div>
{% endblock %}
{% block extra_js %}
<script>
document.addEventListener('DOMContentLoaded', function () {
// Переключение видимости пароля
document.querySelectorAll('.password-toggle').forEach(icon => {
icon.addEventListener('click', function(){
const input = document.querySelector(this.getAttribute('data-target'));
if (!input) return;
input.type = input.type === 'password' ? 'text' : 'password';
this.classList.toggle('bi-eye');
this.classList.toggle('bi-eye-slash');
});
});
// === Превью аватара ===
const fileInput = document.querySelector('input[type="file"][name="{{ form.avatar.html_name }}"]');
const preview = document.getElementById('avatarPreview');
const placeholder = document.getElementById('avatarPlaceholder');
const formEl = fileInput ? fileInput.closest('form') : null;
let objectURL = null;
function showPreview(url){
if (!preview) return;
// Скрыть плейсхолдер, показать картинку
if (placeholder) placeholder.classList.add('d-none');
preview.src = url;
preview.classList.remove('d-none');
}
function hidePreview(){
if (!preview) return;
// Убрать картинку, показать плейсхолдер
preview.removeAttribute('src');
preview.classList.add('d-none');
if (placeholder) placeholder.classList.remove('d-none');
}
function revokeObjectURLSafe(){
try { if (objectURL) URL.revokeObjectURL(objectURL); } catch(_) {}
objectURL = null;
}
if (fileInput && preview){
fileInput.addEventListener('change', function(){
revokeObjectURLSafe();
const file = this.files && this.files[0];
if (file){
objectURL = URL.createObjectURL(file);
showPreview(objectURL);
} else {
hidePreview();
}
});
}
// Сброс формы — вернуть плейсхолдер
if (formEl){
formEl.addEventListener('reset', function(){
revokeObjectURLSafe();
// Небольшая задержка, чтобы браузер успел очистить value у input[type=file]
setTimeout(hidePreview, 0);
});
}
// Освобождение URL при уходе со страницы
window.addEventListener('beforeunload', revokeObjectURLSafe);
});
</script>
<script>
document.addEventListener('DOMContentLoaded', function () {
// Навешиваемся на все инпуты, помеченные data-phone-only
const phoneInputs = document.querySelectorAll('input[data-phone-only]');
const PHONE_RE = /^\+?\d{10,15}$/;
function sanitizePhone(v){
v = String(v || '');
let out = '';
for (let i=0;i<v.length;i++){
const ch = v[i];
if (ch >= '0' && ch <= '9') { out += ch; continue; }
if (ch === '+' && out.length === 0) { out = '+'; }
}
return out;
}
function validate(el){
const v = el.value;
const ok = v === '' || PHONE_RE.test(v);
el.setCustomValidity(ok ? '' : 'Введите номер телефона в формате +79991234567 (10–15 цифр).');
}
phoneInputs.forEach((el)=>{
// Ввод
el.addEventListener('input', function(){
const prev = this.value;
const cur = sanitizePhone(prev);
if (prev !== cur){
const pos = this.selectionStart || cur.length;
this.value = cur;
try { this.setSelectionRange(pos - (prev.length - cur.length), pos - (prev.length - cur.length)); } catch(_){}
}
validate(this);
});
// Вставка (paste)
el.addEventListener('paste', (e)=>{
e.preventDefault();
const text = (e.clipboardData || window.clipboardData).getData('text');
const cur = sanitizePhone(text);
document.execCommand('insertText', false, cur);
});
// Потеря фокуса — финальная проверка
el.addEventListener('blur', function(){
this.value = sanitizePhone(this.value);
validate(this);
});
// Инициализация
el.value = sanitizePhone(el.value);
validate(el);
});
});
</script>
{% endblock %}