Календарь (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).
282 lines
11 KiB
HTML
282 lines
11 KiB
HTML
|
||
{% 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 %}
|
||
|