Corp/backend/templates/employees/_employee_edit.html.backup
Игорь Надеин 4d04aafdb9 refactor: Replace inline CSS with components (Phase 3)
- Replaced feed-* inline styles in base.html with feed-cards.css component (~60 lines)
- Replaced ios-search inline styles in 3 templates with ios-search.css component
  * employees/employees_list.html
  * search/results.html
  * employees/department_list.html
- Eliminated ~150 lines of duplicate CSS across templates
- All 17 tests passing
2025-10-28 08:54:38 +03:00

1392 lines
61 KiB
Plaintext
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.

{# templates/employees/_employee_edit.html #}
<style>
/* модал всегда выше бэкдропа/локальных стэков */
.modal { z-index: 1080; }
.modal-backdrop { z-index: 1075; }
/* не даём «просвечивать» через бэкдроп */
.modal .modal-content { background-color: var(--bs-body-bg); }
</style>
<form id="apiEditForm" class="row g-3" data-endpoint="{{ endpoint }}"
action="{% if is_me %}{% url 'employees:employee_set_position_me' %}{% else %}{% url 'employees:employee_set_position' emp.id %}{% endif %}">
{% csrf_token %}
{# === ЛИЧНЫЕ ДАННЫЕ === #}
<div class="col-12">
<div class="ios-acc mb-3">
<div class="d-flex align-items-center gap-2 px-3 py-3">
<i class="bi-person-badge"></i>
<div class="ios-section-title mb-0">Личные данные</div>
</div>
<div class="ios-acc-body">
<div class="row g-3 px-2">
<div class="col-sm-6">
<label class="form-label" for="f_first_name">Имя</label>
<input id="f_first_name" class="form-control" name="first_name"
value="{{ emp.first_name|default:'' }}" autocomplete="given-name"
data-init="{{ emp.first_name|default:'' }}">
</div>
<div class="col-sm-6">
<label class="form-label" for="f_last_name">Фамилия</label>
<input id="f_last_name" class="form-control" name="last_name"
value="{{ emp.last_name|default:'' }}" autocomplete="family-name"
data-init="{{ emp.last_name|default:'' }}">
</div>
<div class="col-sm-6">
<label class="form-label" for="f_patronymic">Отчество</label>
<input id="f_patronymic" class="form-control" name="patronymic"
value="{{ emp.patronymic|default:'' }}"
data-init="{{ emp.patronymic|default:'' }}">
</div>
<div class="col-sm-3">
<label class="form-label" for="f_gender">Пол</label>
<select id="f_gender" class="form-select" name="gender"
data-init="{{ emp.gender|default_if_none:'' }}">
<option value="" {% if emp.gender is None %}selected{% endif %}>— Не указано —</option>
<option value="1" {% if emp.gender == 1 %}selected{% endif %}>Мужской</option>
<option value="2" {% if emp.gender == 2 %}selected{% endif %}>Женский</option>
</select>
</div>
<div class="col-sm-3">
<label class="form-label" for="f_birth">Дата рождения</label>
<input id="f_birth" class="form-control" type="date" name="birth_date"
value="{{ emp.birth_date|default:'' }}"
data-init="{{ emp.birth_date|default:'' }}">
</div>
</div>
</div>
</div>
</div>
{# === КОНТАКТЫ === #}
<div class="col-12">
<div class="ios-acc mb-3">
<div class="d-flex align-items-center gap-2 px-3 py-3">
<i class="bi-person-lines-fill"></i>
<div class="ios-section-title mb-0">Контакты</div>
</div>
<div class="ios-acc-body">
<div class="row g-3 px-2">
<div class="col-sm-6">
<label class="form-label" for="f_phone">Телефон</label>
<input id="f_phone" class="form-control" name="phone_number"
value="{{ emp.phone_number|default:'' }}" inputmode="tel"
data-init="{{ emp.phone_number|default:'' }}" data-contact="1">
<div class="invalid-feedback"></div>
</div>
<div class="col-sm-6">
<label class="form-label" for="f_email">Email</label>
<input id="f_email" class="form-control" type="email" name="email"
value="{{ emp.email|default:'' }}" autocomplete="email"
data-init="{{ emp.email|default:'' }}" data-contact="1">
<div class="invalid-feedback"></div>
</div>
<div class="col-sm-4">
<label class="form-label" for="f_telegram">Telegram</label>
<input id="f_telegram" class="form-control" name="telegram"
value="{{ emp.telegram|default:'' }}" inputmode="text"
data-init="{{ emp.telegram|default:'' }}" data-contact="1">
<div class="invalid-feedback"></div>
</div>
<div class="col-sm-4">
<label class="form-label" for="f_whatsapp">WhatsApp</label>
<input id="f_whatsapp" class="form-control" name="whatsapp"
value="{{ emp.whatsapp|default:'' }}" inputmode="tel"
data-init="{{ emp.whatsapp|default:'' }}" data-contact="1">
<div class="invalid-feedback"></div>
</div>
<div class="col-sm-4">
<label class="form-label" for="f_wechat">WeChat</label>
<input id="f_wechat" class="form-control" name="wechat"
value="{{ emp.wechat|default:'' }}" inputmode="text"
data-init="{{ emp.wechat|default:'' }}" data-contact="1">
<div class="invalid-feedback"></div>
</div>
</div>
</div>
</div>
</div>
{# === ДОЛЖНОСТЬ === #}
<div class="col-12">
{% if can_edit_position or request.user.is_staff or request.user.is_superuser %}
<div class="ios-acc mb-2" id="positionBlock"
data-update-url-tpl="{% url 'employees:position_update' 0 %}"
data-detail-url-tpl="{% url 'employees:position_detail' 0 %}"
data-create-url="{% url 'employees:position_create' %}"
data-assign-url="{% if is_me %}{% url 'employees:employee_set_position_me' %}{% else %}{% url 'employees:employee_set_position' emp.id %}{% endif %}"
data-can-assign="{% if can_assign_position_groups or request.user.is_staff or request.user.is_superuser %}1{% else %}0{% endif %}">
<div class="d-flex align-items-center gap-2 px-3 py-3">
<i class="bi-briefcase"></i>
<div class="ios-section-title mb-0">Должность</div>
</div>
<div class="ios-acc-body">
<div class="row g-3 px-2">
{# ТЕКУЩАЯ ДОЛЖНОСТЬ #}
<div class="col-md-6">
<label class="form-label" for="f_position">Текущая должность</label>
{% if all_positions %}
<select id="f_position" class="form-select" name="position_id"
data-init="{{ emp.position.id|default:'' }}">
<option value="">— Не указано —</option>
{% for p in all_positions %}
{% if p.id and p.name %}
<option value="{{ p.id }}"
{% if emp.position and emp.position.id == p.id %}selected{% endif %}>
{{ p.name }}
</option>
{% endif %}
{% endfor %}
</select>
{% else %}
<input id="f_position" class="form-control" name="position_id"
value="{{ emp.position.id|default:'' }}" placeholder="ID должности"
data-init="{{ emp.position.id|default:'' }}">
<div class="form-text">Передайте в контекст <code>all_positions</code> для выпадающего списка.</div>
{% endif %}
</div>
{# СОЗДАТЬ НОВУЮ #}
{% if can_create_position or request.user.is_staff or request.user.is_superuser %}
<div class="col-md-6">
<label class="form-label d-block">Новая должность</label>
<button type="button" class="btn btn-outline-secondary btn-sm"
data-bs-toggle="collapse" data-bs-target="#newPositionPane" aria-expanded="false" aria-controls="newPositionPane">
Создать должность
</button>
</div>
<div class="col-12">
<div id="newPositionPane" class="collapse mt-2">
<div class="row g-2">
<div class="col-sm-6">
<label class="form-label" for="pos_name">Название</label>
<input id="pos_name" class="form-control" placeholder="Напр. Руководитель отдела">
</div>
<div class="col-sm-6">
<label class="form-label" for="pos_desc">Описание</label>
<input id="pos_desc" class="form-control" placeholder="Опционально">
</div>
{% if can_assign_position_groups or request.user.is_staff or request.user.is_superuser %}
<div class="col-12">
{% if all_groups %}
<div class="col-12">
<div id="pos_groups_chips" class="chips mb-2"></div>
<input type="hidden" id="pos_groups_hidden" value="">
</div>
{# Модал выбора групп для новой должности #}
<div class="modal fade" id="groupsPickerModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered modal-dialog-scrollable modal-fullscreen-sm-down">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Группы прав</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Закрыть"></button>
</div>
<div class="modal-body">
<input type="search" class="form-control mb-3" id="groupsSearch" placeholder="Поиск по группам">
<div id="groupsList" class="list-group">
{% for g in all_groups %}
{% if g.id and g.name %}
<label class="list-group-item d-flex align-items-center gap-2">
<input class="form-check-input me-2 group-check" type="checkbox" value="{{ g.id }}">
<span class="flex-grow-1">{{ g.name }}</span>
<span class="text-secondary small">#{{ g.id }}</span>
</label>
{% endif %}
{% endfor %}
</div>
</div>
<div class="modal-footer">
<button type="button" id="groupsClear" class="btn btn-ghost">Сбросить</button>
<button type="button" id="groupsDone" class="btn btn-primary" data-bs-dismiss="modal">Готово</button>
</div>
</div>
</div>
</div>
{% else %}
<div class="col-12">
<label class="form-label" for="pos_groups_csv">Группы прав (id через запятую)</label>
<input id="pos_groups_csv" class="form-control" placeholder="Напр. 1,2,3">
</div>
{% endif %}
</div>
{% endif %}
<div class="col-12 d-flex flex-wrap gap-2 mt-1">
<button type="button" class="btn btn-outline-secondary btn-sm"
data-bs-toggle="modal" data-bs-target="#groupsPickerModal">
Выбрать группы полномочий
</button>
<button type="button" class="btn btn-primary btn-sm" id="posCreateBtn">
<span class="lbl">Создать и назначить</span>
<span class="spinner-border spinner-border-sm d-none" role="status" aria-hidden="true"></span>
</button>
<button type="button" class="btn btn-ghost btn-sm"
data-bs-toggle="collapse" data-bs-target="#newPositionPane">Отмена</button>
</div>
<div class="col-12">
<div class="small mt-1 d-none" id="posCreateMsg"></div>
</div>
</div>
</div>
</div>
{% endif %}
{# РЕДАКТИРОВАТЬ ВЫБРАННУЮ #}
<div class="col-12">
<div class="mt-2">
<button type="button" class="btn btn-outline-secondary btn-sm"
data-bs-toggle="collapse" data-bs-target="#editPositionPane"
id="toggleEditPositionBtn" aria-expanded="false" aria-controls="editPositionPane">
Редактировать должность
</button>
</div>
<div id="editPositionPane" class="collapse mt-2">
<div class="row g-2">
<div class="col-sm-6">
<label class="form-label" for="pos_edit_name">Название должности</label>
<input id="pos_edit_name" class="form-control" placeholder="Напр. Руководитель отдела">
</div>
<div class="col-sm-6">
<label class="form-label" for="pos_edit_desc">Описание</label>
<input id="pos_edit_desc" class="form-control" placeholder="Опционально">
</div>
{% if can_assign_position_groups or request.user.is_staff or request.user.is_superuser %}
<div class="col-12">
<label class="form-label d-block">Группы полномочий</label>
<div id="pos_edit_groups_chips" class="chips mb-2"></div>
<input type="hidden" id="pos_edit_groups_hidden" value="">
<div class="d-flex flex-wrap gap-2">
<button type="button" class="btn btn-outline-secondary btn-sm"
data-bs-toggle="modal" data-bs-target="#posEditGroupsPickerModal">
Выбрать группы
</button>
<button type="button" class="btn btn-ghost btn-sm" id="posEditClearGroups">Очистить</button>
</div>
</div>
{# Модал выбора групп для редактирования должности #}
<div class="modal fade" id="posEditGroupsPickerModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered modal-dialog-scrollable modal-fullscreen-sm-down">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Группы прав (должность)</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Закрыть"></button>
</div>
<div class="modal-body">
<input type="search" class="form-control mb-3" id="posEditGroupsSearch" placeholder="Поиск по группам">
<div id="posEditGroupsList" class="list-group">
{% for g in all_groups %}
{% if g.id and g.name %}
<label class="list-group-item d-flex align-items-center gap-2">
<input class="form-check-input me-2 pos-edit-group-check" type="checkbox" value="{{ g.id }}">
<span class="flex-grow-1">{{ g.name }}</span>
<span class="text-secondary small">#{{ g.id }}</span>
</label>
{% endif %}
{% endfor %}
</div>
</div>
<div class="modal-footer">
<button type="button" id="posEditGroupsClear" class="btn btn-ghost">Сбросить</button>
<button type="button" id="posEditGroupsDone" class="btn btn-primary" data-bs-dismiss="modal">Готово</button>
</div>
</div>
</div>
</div>
{% endif %}
<div class="col-12 d-flex flex-wrap gap-2 mt-1">
<button type="button" class="btn btn-outline-secondary btn-sm" id="posEditLoadBtn">
<span class="lbl">Загрузить из выбранной</span>
<span class="spinner-border spinner-border-sm d-none" role="status" aria-hidden="true"></span>
</button>
<button type="button" class="btn btn-primary btn-sm" id="posEditSaveBtn">
<span class="lbl">Сохранить изменения</span>
<span class="spinner-border spinner-border-sm d-none" role="status" aria-hidden="true"></span>
</button>
<button type="button" class="btn btn-ghost btn-sm"
data-bs-toggle="collapse" data-bs-target="#editPositionPane">Отмена</button>
</div>
<div class="col-12">
<div class="small mt-1 d-none" id="posEditMsg"></div>
</div>
</div>
</div>
</div>
</div>{# /.row #}
</div>{# /.ios-acc-body #}
</div>
{% else %}
<div class="ios-acc mb-2">
<div class="d-flex align-items-center gap-2 px-3 py-3">
<i class="bi-briefcase"></i>
<div class="ios-section-title mb-0">Должность</div>
</div>
<div class="ios-acc-body">
<input class="form-control" value="{% firstof emp.position.name emp.position_label '—' %}" disabled>
</div>
</div>
{% endif %}
</div>
{# === ПЕРСОНАЛЬНЫЕ ГРУППЫ ПОЛНОМОЧИЙ === #}
<div class="col-12">
{% if request.user.is_staff or request.user.is_superuser %}
<div class="ios-acc mb-3" id="empGroupsBlock"
data-bulk-url="{% url 'employees:employee_groups_bulk' emp.id %}"
data-create-url="{% url 'employees:group_create' %}"
data-delete-url="{% url 'employees:group_delete' %}">
<div class="d-flex align-items-center gap-2 px-3 py-3">
<i class="bi-people"></i>
<div class="ios-section-title mb-0">Дополнительные полномочия</div>
</div>
<div class="ios-acc-body">
<div class="row g-3 px-2">
<div class="col-12">
<div class="small text-secondary mb-2">
</div>
</div>
{# <<< НОВОЕ: вывод уже назначенных групп сотруднику в виде «пилюль» >>> #}
<div class="col-12">
<label class="form-label mb-1">Назначено сотруднику</label>
<div id="empGroupsAssigned" class="d-flex flex-wrap gap-2">
{% for g in emp_personal_groups %}
{% if g.id and g.name %}
<span class="badge rounded-pill bg-primary-subtle text-primary-emphasis border"
data-gid="{{ g.id }}">{{ g.name }}</span>
{% endif %}
{% empty %}
<span class="text-secondary small">Нет персональных групп</span>
{% endfor %}
</div>
</div>
<div class="col-12">
<div class="d-flex flex-wrap gap-2">
<button type="button"
class="btn btn-outline-secondary btn-sm"
data-bs-toggle="modal"
data-bs-target="#empGroupsPickerModal">
Выбрать группы
</button>
<button type="button" class="btn btn-primary btn-sm" id="empGroupsAssignBtn">
<span class="lbl">Назначить выбранные</span>
<span class="spinner-border spinner-border-sm d-none" role="status" aria-hidden="true"></span>
</button>
<button type="button" class="btn btn-outline-danger btn-sm" id="empGroupsRemoveBtn">
<span class="lbl">Снять выбранные</span>
<span class="spinner-border spinner-border-sm d-none" role="status" aria-hidden="true"></span>
</button>
</div>
<input type="hidden" id="emp_groups_hidden" value="">
<div id="empGroupsMsg" class="small mt-2 d-none"></div>
</div>
<hr class="my-2">
<div class="col-12 col-md-6">
<label class="form-label">Создать новую группу</label>
<div class="input-group">
<input id="empGroupsCreateName" class="form-control" placeholder="CN (имя группы)">
<button type="button" class="btn btn-outline-secondary" id="empGroupsCreateBtn">
<span class="lbl">Создать</span>
<span class="spinner-border spinner-border-sm d-none" role="status" aria-hidden="true"></span>
</button>
</div>
<div id="empGroupsCreateMsg" class="small mt-2 d-none"></div>
</div>
<div class="col-12 col-md-6">
<label class="form-label">Удалить группу</label>
<div class="input-group">
<select id="empGroupsDeleteSelect" class="form-select">
<option value="">— Выберите группу —</option>
{% for g in all_groups %}
{% if g.id and g.name %}
<option value="{{ g.id }}">{{ g.name }}</option>
{% endif %}
{% endfor %}
</select>
<button type="button" class="btn btn-outline-danger" id="empGroupsDeleteBtn">
<span class="lbl">Удалить</span>
<span class="spinner-border spinner-border-sm d-none" role="status" aria-hidden="true"></span>
</button>
</div>
<div class="form-text">Удаляется и LDAP-группа, и объект в БД.</div>
<div id="empGroupsDeleteMsg" class="small mt-2 d-none"></div>
</div>
</div>
</div>
</div>
{# Модал выбора групп для персонального назначения #}
<div class="modal fade" id="empGroupsPickerModal" tabindex="-1" aria-hidden="true">
<div class="modal-dialog modal-dialog-centered modal-dialog-scrollable modal-fullscreen-sm-down">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">Выбор групп</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Закрыть"></button>
</div>
<div class="modal-body">
<input type="search" class="form-control mb-3" id="empGroupsSearch" placeholder="Поиск по группам">
<div id="empGroupsList" class="list-group">
{% for g in all_groups %}
{% if g.id and g.name %}
<label class="list-group-item d-flex align-items-center gap-2">
<input class="form-check-input me-2 emp-group-check" type="checkbox" value="{{ g.id }}" data-cn="{{ g.name }}">
<span class="flex-grow-1">{{ g.name }}</span>
<span class="text-secondary small">#{{ g.id }}</span>
</label>
{% endif %}
{% endfor %}
</div>
</div>
<div class="modal-footer">
<button type="button" id="empGroupsClear" class="btn btn-ghost">Сбросить</button>
<button type="button" id="empGroupsDone" class="btn btn-primary" data-bs-dismiss="modal">Готово</button>
</div>
</div>
</div>
</div>
{% endif %}
</div>
{# === КНОПКИ === #}
<div class="col-12 d-flex gap-2 mt-2">
<button type="submit" class="btn btn-primary">
<span class="btn-label"><i class="bi-check2 me-1"></i>Сохранить</span>
<span class="btn-spinner d-none spinner-border spinner-border-sm" role="status" aria-hidden="true"></span>
</button>
<button type="button" class="btn btn-ghost" data-bs-toggle="collapse" data-bs-target="#editFormWrap">Отмена</button>
</div>
</form>
<script>
(function(){
const form = document.getElementById('apiEditForm');
if (!form) return;
const endpoint = form.dataset.endpoint || '';
if (!endpoint) { console.error('Нет endpoint для формы редактирования'); return; }
const CONTACT_FIELDS = ['email','phone_number','telegram','whatsapp','wechat'];
const btn = form.querySelector('[type="submit"]');
const btnLabel = btn?.querySelector('.btn-label');
const btnSpin = btn?.querySelector('.btn-spinner');
function setSubmitting(on){
if (!btn) return;
if (on){ btn.setAttribute('disabled',''); btnSpin?.classList.remove('d-none'); btnLabel?.classList.add('visually-hidden'); }
else { btn.removeAttribute('disabled'); btnSpin?.classList.add('d-none'); btnLabel?.classList.remove('visually-hidden'); }
}
function clearErrors(){
form.querySelectorAll('.is-invalid').forEach(el => el.classList.remove('is-invalid'));
form.querySelectorAll('.invalid-feedback').forEach(el => el.textContent = '');
}
function fieldError(name, message){
const el = form.querySelector(`[name="${CSS.escape(name)}"]`);
if (!el) return;
el.classList.add('is-invalid');
const container = el.closest('[class^="col-"]') || el.parentElement;
let fb = container?.querySelector('.invalid-feedback');
if (!fb) { fb = document.createElement('div'); fb.className = 'invalid-feedback'; el.after(fb); }
fb.textContent = message || 'Некорректное значение';
}
function ensureOneContact(){
const hasAny = CONTACT_FIELDS.some(n => (form.elements[n]?.value || '').trim().length > 0);
if (hasAny) return true;
CONTACT_FIELDS.forEach(n => fieldError(n, 'Укажите хотя бы один контакт'));
form.elements[CONTACT_FIELDS[0]]?.focus();
return false;
}
// Прочитать файл в Base64
function readFileAsDataURL(file){
return new Promise((resolve, reject) => {
const fr = new FileReader();
fr.onload = () => resolve(fr.result);
fr.onerror = reject;
fr.readAsDataURL(file);
});
}
// собрать skills_ids (массив) и сравнить с init
function collectSkillsPayload(){
const sel = form.querySelector('select[name="skills_ids"]');
if (!sel) return null;
const selected = Array.from(sel.selectedOptions).map(o => o.value).filter(Boolean);
const initCsv = sel.dataset.init || '';
const initArr = initCsv ? initCsv.split(',').map(s => s.trim()).filter(Boolean) : [];
// сравнение (без учёта порядка)
const norm = a => a.slice().sort().join(',');
if (norm(selected) === norm(initArr)) return null;
return selected;
}
// собрать общие поля (кроме skills_ids и avatar)
function buildScalarPayload(){
const payload = {};
const fields = form.querySelectorAll(
'input[name]:not([type="file"]):not([name="clear_avatar"]):not(:disabled), select[name]:not([name="skills_ids"]):not(:disabled)'
);
fields.forEach(el => {
const name = el.name;
const raw = (el.value ?? '').toString().trim();
const init = (el.dataset.init ?? el.defaultValue ?? '').toString();
// Ничего не менялось
if (raw === init) return;
// Пустое значение: для некоторых полей нужно слать null
const shouldNull = (raw === '' && ['gender','birth_date','position_id'].includes(name));
if (shouldNull) {
// меняем непустое init -> null (очистка)
if (init !== '') payload[name] = null;
return;
}
// Типизация gender в число
if (name === 'gender' && raw !== '') {
payload[name] = Number(raw);
return;
}
// Остальные — как есть (пустые строки допустимы для текстовых полей)
payload[name] = raw;
});
return payload;
}
form.addEventListener('submit', async (e) => {
e.preventDefault();
clearErrors();
if (!ensureOneContact()) return;
setSubmitting(true);
try{
const payload = buildScalarPayload();
// skills_ids (массив)
const skills = collectSkillsPayload();
if (skills) payload.skills_ids = skills;
// avatar (Base64) / clear
const avatarFile = document.getElementById('f_avatar')?.files?.[0] || null;
const clearAvatar = form.querySelector('input[name="clear_avatar"]')?.checked;
if (avatarFile){
try{
const dataUrl = await readFileAsDataURL(avatarFile);
payload.avatar = dataUrl; // Base64ImageField ожидает data URL
}catch(_){}
} else if (clearAvatar){
payload.avatar = ""; // очистка
}
// если нечего отправлять — просто обновим UI
if (Object.keys(payload).length === 0){
setSubmitting(false);
if (window.bootstrap) {
try { new bootstrap.Collapse(document.getElementById('editFormWrap'), {toggle:false}).hide(); } catch(_){}
}
location.reload();
return;
}
const csrftoken = form.querySelector('input[name=csrfmiddlewaretoken]')?.value || '';
const resp = await fetch(endpoint, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
'X-Requested-With': 'XMLHttpRequest',
'X-CSRFToken': csrftoken
},
body: JSON.stringify(payload),
credentials: 'same-origin'
});
if (!resp.ok){
let body = null;
try { body = await resp.json(); } catch {}
if (body && typeof body === 'object'){
let shown = false;
for (const [k, v] of Object.entries(body)){
if (k === 'detail') continue;
const msg = Array.isArray(v) ? v.join(' ') : String(v);
fieldError(k, msg); shown = true;
}
if (body.detail) alert(body.detail);
else if (!shown) alert('Не удалось сохранить изменения (ошибка валидации).');
} else {
const t = await resp.text();
alert('Не удалось сохранить изменения.\n' + (t || `HTTP ${resp.status}`));
}
setSubmitting(false);
return;
}
// успех
try { if (window.bootstrap) new bootstrap.Collapse(document.getElementById('editFormWrap'), {toggle:false}).hide(); } catch(_){}
location.reload();
}catch(err){
console.error(err);
alert('Не удалось сохранить изменения. Попробуйте ещё раз.');
setSubmitting(false);
}
});
})();
</script>
<script>
(function(){
const form = document.getElementById('apiEditForm');
const block = document.getElementById('positionBlock');
if (!form || !block) return;
const select = document.getElementById('f_position');
const csrftoken = form.querySelector('input[name=csrfmiddlewaretoken]')?.value || '';
const createUrl = block.dataset.createUrl || '{% url "employees:position_create" %}';
const assignUrl = block.dataset.assignUrl || '';
const canAssign = block.dataset.canAssign === '1';
function busy(btn, on){
const lbl = btn.querySelector('.lbl');
const sp = btn.querySelector('.spinner-border');
if (on){ btn.setAttribute('disabled',''); sp?.classList.remove('d-none'); lbl?.classList.add('visually-hidden'); }
else { btn.removeAttribute('disabled'); sp?.classList.add('d-none'); lbl?.classList.remove('visually-hidden'); }
}
function msg(el, text, ok){
if (!el) return;
el.textContent = text || '';
el.classList.toggle('d-none', !text);
el.classList.toggle('text-success', !!ok);
el.classList.toggle('text-danger', !ok);
}
document.getElementById('posCreateBtn')?.addEventListener('click', async () => {
const btn = document.getElementById('posCreateBtn');
const m = document.getElementById('posCreateMsg');
msg(m, '');
// ВАЛИДАЦИЯ URL'ов
if (!createUrl || !assignUrl){
msg(m, 'Не настроены URL создания/назначения.', false);
return;
}
// ДАННЫЕ ФОРМЫ
const name = (document.getElementById('pos_name')?.value || '').trim();
const description = (document.getElementById('pos_desc')?.value || '').trim();
if (!name){ msg(m, 'Укажите название должности.', false); return; }
// ГРУППЫ ПРАВ
let groupIds = [];
if (canAssign){
const msel = document.getElementById('pos_groups'); // если есть select[multiple]
const hid = document.getElementById('pos_groups_hidden'); // из модального выбора
const csv = document.getElementById('pos_groups_csv'); // fallback
if (msel) groupIds = Array.from(msel.selectedOptions).map(o => o.value).filter(Boolean);
else if (hid) groupIds = (hid.value || '').split(',').map(s => s.trim()).filter(Boolean);
else if (csv) groupIds = (csv.value || '').split(',').map(s => s.trim()).filter(Boolean);
groupIds = groupIds.map(id => Number(id)).filter(n => !Number.isNaN(n));
}
// ОТПРАВКА
busy(btn, true);
try{
// 1) Создаём должность
const createPayload = { name, description };
if (canAssign && groupIds.length) createPayload.groups = groupIds;
const cResp = await fetch(createUrl, {
method:'POST',
credentials:'same-origin',
headers:{
'Content-Type':'application/json',
'X-Requested-With':'XMLHttpRequest',
'X-CSRFToken': csrftoken
},
body: JSON.stringify(createPayload)
});
const cData = await (async()=>{ try{return await cResp.json()}catch{return null} })();
if (!cResp.ok){
msg(m, (cData?.detail || JSON.stringify(cData) || `HTTP ${cResp.status}`), false);
return;
}
const newId = cData?.id;
if (!newId){
msg(m, 'API не вернул id новой должности.', false);
return;
}
// 2) Назначаем сотруднику
const eResp = await fetch(assignUrl, {
method:'POST',
credentials:'same-origin',
headers:{
'Content-Type':'application/json',
'X-Requested-With':'XMLHttpRequest',
'X-CSRFToken': csrftoken
},
body: JSON.stringify({ position_id: newId })
});
const eData = await (async()=>{ try{return await eResp.json()}catch{return null} })();
if (!eResp.ok){
msg(m, 'Создано, но не назначено: ' + (eData?.detail || JSON.stringify(eData) || `HTTP ${eResp.status}`), false);
return;
}
// 3) Обновляем селект и закрываем панель
if (select){
let opt = Array.from(select.options).find(o => o.value == String(newId));
if (!opt){
opt = document.createElement('option');
opt.value = String(newId);
opt.textContent = cData?.name || ('Должность #'+newId);
select.appendChild(opt);
}
select.value = String(newId);
select.dataset.init = String(newId);
}
msg(m, 'Должность создана и назначена.', true);
try{ if (window.bootstrap) new bootstrap.Collapse(document.getElementById('newPositionPane'), {toggle:false}).hide(); }catch(_){}
}catch(ex){
console.error(ex);
msg(m, 'Неожиданная ошибка при создании/назначении.', false);
}finally{
busy(btn, false);
}
});
})();
</script>
<script>
(function(){
const chipsBox = document.getElementById('pos_groups_chips');
const hiddenInp = document.getElementById('pos_groups_hidden');
const listBox = document.getElementById('groupsList');
const searchInp = document.getElementById('groupsSearch');
const clearBtn = document.getElementById('groupsClear');
const doneBtn = document.getElementById('groupsDone');
if (!listBox || !hiddenInp) return;
// Текущее множество выбранных id
const sel = new Set((hiddenInp.value || '').split(',').map(s => s.trim()).filter(Boolean));
// Рендер чипсов
function renderChips(){
if (!chipsBox) return;
chipsBox.innerHTML = '';
if (sel.size === 0){ return; }
// соберём карту id->название из списка (чтобы показывать имена)
const nameById = {};
listBox.querySelectorAll('.group-check').forEach(ch => {
nameById[ch.value] = ch.closest('.list-group-item').querySelector('span.flex-grow-1')?.textContent || ('#'+ch.value);
});
for (const id of sel){
const chip = document.createElement('span');
chip.className = 'chip';
chip.dataset.id = id;
chip.innerHTML = `${nameById[id] || ('#'+id)} <button type="button" class="btn btn-sm p-0 ms-1" aria-label="Удалить" style="line-height:1;">×</button>`;
chipsBox.appendChild(chip);
}
}
// Синхронизация чекбоксов ↔ выбранные
function syncChecksFromSel(){
listBox.querySelectorAll('.group-check').forEach(ch => {
ch.checked = sel.has(ch.value);
});
}
function syncSelFromChecks(){
sel.clear();
listBox.querySelectorAll('.group-check:checked').forEach(ch => sel.add(ch.value));
}
function saveHidden(){
hiddenInp.value = Array.from(sel).join(',');
}
// Интерактив: клик по чекбоксу
listBox.addEventListener('change', (e) => {
const ch = e.target.closest('.group-check');
if (!ch) return;
if (ch.checked) sel.add(ch.value); else sel.delete(ch.value);
});
// Фильтр
searchInp?.addEventListener('input', () => {
const q = (searchInp.value || '').toLowerCase().trim();
listBox.querySelectorAll('.list-group-item').forEach(row => {
const name = row.querySelector('.flex-grow-1')?.textContent.toLowerCase() || '';
const id = row.querySelector('.group-check')?.value || '';
row.classList.toggle('d-none', !(name.includes(q) || id.includes(q)));
});
});
// Сброс
clearBtn?.addEventListener('click', () => {
sel.clear();
syncChecksFromSel();
renderChips();
saveHidden();
});
// Готово
doneBtn?.addEventListener('click', () => {
// на случай если что-то поменяли напрямую в чекбоксах
syncSelFromChecks();
renderChips();
saveHidden();
});
// Удаление из чипса
chipsBox?.addEventListener('click', (e) => {
const btn = e.target.closest('button');
const chip = btn?.closest('.chip');
if (!chip) return;
sel.delete(chip.dataset.id);
renderChips();
syncChecksFromSel();
saveHidden();
});
// Инициализация при первом открытии модала (если уже есть значения)
renderChips();
syncChecksFromSel();
// === ДОПОЛНЕНИЕ К ВАШЕЙ КНОПКЕ "Создать и назначить" ===
// в вашем коде уже есть место, где собираются groupIds:
// if (msel) ... else if (csv) ...
// добавим чтение из скрытого поля:
const posCreateBtn = document.getElementById('posCreateBtn');
if (posCreateBtn){
posCreateBtn.addEventListener('click', () => {
// прокинем выбранные id в data-атрибут кнопки, чтобы ваш обработчик прочитал
if (hiddenInp) {
posCreateBtn.dataset.groupsSelected = hiddenInp.value; // "1,2,3"
}
}, {capture:true});
}
// А внутри вашего обработчика posCreateBtn используйте ещё один источник:
// const msel = document.getElementById('pos_groups');
// const csv = document.getElementById('pos_groups_csv');
// const hid = document.getElementById('pos_groups_hidden');
// if (msel) groupIds = Array.from(msel.selectedOptions).map(o => o.value).filter(Boolean);
// else if (hid) groupIds = (hid.value || '').split(',').map(s => s.trim()).filter(Boolean);
// else if (csv) groupIds = (csv.value || '').split(',').map(s => s.trim()).filter(Boolean);
//
// и перед отправкой:
// groupIds = groupIds.map(id => Number(id)).filter(n => !Number.isNaN(n));
})();
</script>
<script>
(function(){
const m = document.getElementById('groupsPickerModal');
if (m && m.parentNode !== document.body) {
document.body.appendChild(m);
}
})();
</script>
<script>
(function(){
'use strict';
const wrap = document.getElementById('empGroupsBlock');
if (!wrap) return;
const bulkUrl = wrap.dataset.bulkUrl || ''; // /employees/<id>/groups/bulk/
const createUrl = wrap.dataset.createUrl || ''; // /groups/create/
const deleteUrl = wrap.dataset.deleteUrl || ''; // /groups/delete/
const csrftoken = document.querySelector('#apiEditForm input[name=csrfmiddlewaretoken]')?.value || '';
const hidden = document.getElementById('emp_groups_hidden');
const listBox = document.getElementById('empGroupsList');
const search = document.getElementById('empGroupsSearch');
const clearBtn = document.getElementById('empGroupsClear');
const doneBtn = document.getElementById('empGroupsDone');
const msgBox = document.getElementById('empGroupsMsg');
const assignBtn = document.getElementById('empGroupsAssignBtn');
const removeBtn = document.getElementById('empGroupsRemoveBtn');
const createName = document.getElementById('empGroupsCreateName');
const createBtn = document.getElementById('empGroupsCreateBtn');
const createMsg = document.getElementById('empGroupsCreateMsg');
const delSel = document.getElementById('empGroupsDeleteSelect');
const delBtn = document.getElementById('empGroupsDeleteBtn');
const delMsg = document.getElementById('empGroupsDeleteMsg');
const assignedBox = document.getElementById('empGroupsAssigned');
// --- переносим модал в body и предзаполняем чекбоксы текущими группами ---
(function(){
const m = document.getElementById('empGroupsPickerModal');
if (m && m.parentNode !== document.body) document.body.appendChild(m);
// При открытии модала отметим чекбоксы, соответствующие уже назначенным группам
m?.addEventListener('show.bs.modal', () => {
const assigned = new Set(
Array.from(assignedBox?.querySelectorAll('[data-gid]') || [])
.map(x => x.dataset.gid)
.filter(Boolean)
);
const ids = [];
listBox?.querySelectorAll('.emp-group-check').forEach(ch => {
const on = assigned.has(ch.value);
ch.checked = on;
if (on) ids.push(ch.value);
});
if (hidden) hidden.value = ids.join(',');
});
})();
// --- helpers ---
function busy(btn, on){
if (!btn) return;
const lbl = btn.querySelector('.lbl');
const sp = btn.querySelector('.spinner-border');
if (on){ btn.setAttribute('disabled',''); sp?.classList.remove('d-none'); lbl?.classList.add('visually-hidden'); }
else { btn.removeAttribute('disabled'); sp?.classList.add('d-none'); lbl?.classList.remove('visually-hidden'); }
}
function showMsg(el, text, ok){
if (!el) return;
el.textContent = text || '';
el.classList.toggle('d-none', !text);
el.classList.toggle('text-success', !!ok);
el.classList.toggle('text-danger', !ok);
}
function selectedIds(){
if (!hidden) return [];
const csv = (hidden.value || '').trim();
return csv ? csv.split(',').map(s => s.trim()).filter(Boolean) : [];
}
function setSelected(ids){
if (hidden) hidden.value = (ids || []).join(',');
}
function ensureEmptyStub(){
if (!assignedBox) return;
if (!assignedBox.children.length){
const span = document.createElement('span');
span.className = 'text-secondary small';
span.textContent = 'Нет персональных групп';
assignedBox.appendChild(span);
}
}
function clearEmptyStub(){
const stub = assignedBox?.querySelector('.text-secondary.small');
if (stub) stub.remove();
}
function namesDict(){
const m = {};
listBox?.querySelectorAll('.emp-group-check').forEach(ch => {
const nm = ch.closest('.list-group-item')?.querySelector('.flex-grow-1')?.textContent || ('#'+ch.value);
m[ch.value] = nm;
});
return m;
}
function addAssignedPills(ids){
if (!assignedBox || !ids?.length) return;
clearEmptyStub();
const map = namesDict();
ids.forEach(id => {
const key = String(id);
if (assignedBox.querySelector(`[data-gid="${key}"]`)) return;
const pill = document.createElement('span');
pill.className = 'badge rounded-pill bg-primary-subtle text-primary-emphasis border';
pill.dataset.gid = key;
pill.textContent = map[key] || ('#' + key);
assignedBox.appendChild(pill);
});
ensureEmptyStub();
}
function removeAssignedPills(ids){
if (!assignedBox || !ids?.length) return;
ids.forEach(id => assignedBox.querySelector(`[data-gid="${String(id)}"]`)?.remove());
ensureEmptyStub();
}
// --- список/поиск/сохранение выбранных в модалке ---
listBox?.addEventListener('change', (e) => {
const ch = e.target.closest('.emp-group-check');
if (!ch) return;
const ids = new Set(selectedIds());
if (ch.checked) ids.add(ch.value); else ids.delete(ch.value);
setSelected(Array.from(ids));
});
search?.addEventListener('input', () => {
if (!listBox) return;
const q = (search.value || '').toLowerCase().trim();
listBox.querySelectorAll('.list-group-item').forEach(row => {
const name = row.querySelector('.flex-grow-1')?.textContent.toLowerCase() || '';
const id = row.querySelector('.emp-group-check')?.value || '';
row.classList.toggle('d-none', !(name.includes(q) || id.includes(q)));
});
});
clearBtn?.addEventListener('click', () => {
setSelected([]);
listBox?.querySelectorAll('.emp-group-check').forEach(ch => ch.checked = false);
});
doneBtn?.addEventListener('click', () => {
// выбор уже сохранён в hidden по change
});
// --- сетевой helper ---
async function postJSON(url, body){
const resp = await fetch(url, {
method: 'POST',
headers: {
'Content-Type':'application/json',
'Accept':'application/json',
'X-Requested-With':'XMLHttpRequest',
'X-CSRFToken': csrftoken
},
credentials: 'same-origin',
body: JSON.stringify(body || {})
});
let data = null;
try { data = await resp.json(); } catch {}
if (!resp.ok){
const detail = (data && (data.detail || data.error)) || resp.statusText || ('HTTP ' + resp.status);
throw new Error(detail);
}
return data || {};
}
// --- bulk add/remove с обновлением «пилюль» ---
async function bulkMembership(action){
const ids = selectedIds().map(id => Number(id)).filter(n => !Number.isNaN(n));
if (!ids.length){ showMsg(msgBox, 'Не выбраны группы.', false); return; }
if (!bulkUrl){ showMsg(msgBox, 'Не настроен endpoint bulkUrl.', false); return; }
const btn = (action === 'add') ? assignBtn : removeBtn;
busy(btn, true); showMsg(msgBox, '', true);
try{
const data = await postJSON(bulkUrl, { action, group_ids: ids });
// считаем реально успешные id (без требования ok_ids от API)
const failedMap = data.failed || {};
const successIds = ids.filter(id => !(String(id) in failedMap));
if (action === 'add') addAssignedPills(successIds);
else removeAssignedPills(successIds);
// синхронизируем чекбоксы и hidden сразу, чтоб при повторном открытии модалки всё было актуально
const selSet = new Set(selectedIds()); // строковые значения
successIds.forEach(id => {
const val = String(id);
const ch = listBox?.querySelector(`.emp-group-check[value="${val}"]`);
if (action === 'add'){
ch && (ch.checked = true);
selSet.add(val);
} else {
ch && (ch.checked = false);
selSet.delete(val);
}
});
setSelected(Array.from(selSet));
const failed = Object.keys(failedMap).length;
const ok = successIds.length;
showMsg(msgBox, failed ? `Успех: ${ok}, ошибок: ${failed}.` : `Готово: ${ok} групп обработано.`, !failed);
}catch(e){
showMsg(msgBox, 'Ошибка: ' + String(e.message || e), false);
}finally{
busy(btn, false);
}
}
assignBtn?.addEventListener('click', () => bulkMembership('add'));
removeBtn?.addEventListener('click', () => bulkMembership('remove'));
// --- Создание группы (через /groups/create/) ---
createBtn?.addEventListener('click', async () => {
const name = (createName.value || '').trim();
if (!name){ showMsg(createMsg, 'Укажите имя группы (CN).', false); return; }
if (!createUrl){ showMsg(createMsg, 'Не настроен endpoint createUrl.', false); return; }
busy(createBtn, true); showMsg(createMsg, '', true);
try{
const data = await postJSON(createUrl, { name });
// добавить в список в модалке
const row = document.createElement('label');
row.className = 'list-group-item d-flex align-items-center gap-2';
row.innerHTML = `
<input class="form-check-input me-2 emp-group-check" type="checkbox" value="${data.id}" data-cn="${data.name}">
<span class="flex-grow-1">${data.name}</span>
<span class="text-secondary small">#${data.id}</span>`;
listBox?.appendChild(row);
// добавить в select удаления
const opt = document.createElement('option');
opt.value = String(data.id); opt.textContent = data.name;
delSel?.appendChild(opt);
showMsg(createMsg, 'Группа создана.', true);
createName.value = '';
}catch(e){
showMsg(createMsg, 'Не удалось создать: ' + String(e.message || e), false);
}finally{
busy(createBtn, false);
}
});
// --- Удаление группы (через /groups/delete/) ---
delBtn?.addEventListener('click', async () => {
const gid = Number(delSel?.value || 0);
if (!gid){ showMsg(delMsg, 'Выберите группу.', false); return; }
if (!deleteUrl){ showMsg(delMsg, 'Не настроен endpoint deleteUrl.', false); return; }
if (!confirm('Удалить группу? Действие необратимо.')) return;
busy(delBtn, true); showMsg(delMsg, '', true);
try{
await postJSON(deleteUrl, { group_id: gid });
// убрать из UI
listBox?.querySelector(`.emp-group-check[value="${gid}"]`)?.closest('.list-group-item')?.remove();
delSel?.querySelector(`option[value="${gid}"]`)?.remove();
// убрать из выбранных
const ids = new Set(selectedIds());
ids.delete(String(gid));
setSelected(Array.from(ids));
// убрать «пилюлю», если была назначена
removeAssignedPills([gid]);
showMsg(delMsg, 'Группа удалена.', true);
}catch(e){
showMsg(delMsg, 'Не удалось удалить: ' + String(e.message || e), false);
}finally{
busy(delBtn, false);
}
});
// при загрузке гарантируем плейсхолдер «Нет персональных групп», если нужно
ensureEmptyStub();
})();
</script>
<script>
(function(){
const form = document.getElementById('apiEditForm');
const block = document.getElementById('positionBlock');
const select = document.getElementById('f_position');
if (!form || !block || !select) return;
const csrftoken = form.querySelector('input[name=csrfmiddlewaretoken]')?.value || '';
const updateTpl = block.dataset.updateUrlTpl || '';
const detailTpl = block.dataset.detailUrlTpl || ''; // ← фронтовая detail-вьюха
const canAssign = block.dataset.canAssign === '1';
const nameInp = document.getElementById('pos_edit_name');
const descInp = document.getElementById('pos_edit_desc');
const chipsBox = document.getElementById('pos_edit_groups_chips');
const hidden = document.getElementById('pos_edit_groups_hidden');
const msgBox = document.getElementById('posEditMsg');
const loadBtn = document.getElementById('posEditLoadBtn');
const saveBtn = document.getElementById('posEditSaveBtn');
const modalEl = document.getElementById('posEditGroupsPickerModal');
const listBox = document.getElementById('posEditGroupsList');
const search = document.getElementById('posEditGroupsSearch');
const clearBtn = document.getElementById('posEditGroupsClear');
const doneBtn = document.getElementById('posEditGroupsDone');
const clearGroupsBtn = document.getElementById('posEditClearGroups');
if (modalEl && modalEl.parentNode !== document.body) document.body.appendChild(modalEl);
function busy(btn, on){
if (!btn) return;
const lbl = btn.querySelector('.lbl');
const sp = btn.querySelector('.spinner-border');
if (on){ btn.setAttribute('disabled',''); sp?.classList.remove('d-none'); lbl?.classList.add('visually-hidden'); }
else { btn.removeAttribute('disabled'); sp?.classList.add('d-none'); lbl?.classList.remove('visually-hidden'); }
}
function showMsg(text, ok){
msgBox.textContent = text || '';
msgBox.classList.toggle('d-none', !text);
msgBox.classList.toggle('text-success', !!ok);
msgBox.classList.toggle('text-danger', !ok);
}
function currentIds(){ const v=(hidden?.value||'').trim(); return v? v.split(',').map(s=>s.trim()).filter(Boolean):[]; }
function setIds(ids){ if (hidden) hidden.value=(ids||[]).join(','); renderChips(); syncChecksFromHidden(); }
function renderChips(){
if (!chipsBox) return;
chipsBox.innerHTML = '';
const ids = currentIds(); if (!ids.length) return;
const nameById = {};
listBox?.querySelectorAll('.pos-edit-group-check').forEach(ch=>{
const nm = ch.closest('.list-group-item')?.querySelector('.flex-grow-1')?.textContent || ('#'+ch.value);
nameById[ch.value] = nm;
});
ids.forEach(id=>{
const chip = document.createElement('span');
chip.className = 'chip';
chip.dataset.id = id;
chip.innerHTML = `${nameById[id] || ('#'+id)} <button type="button" class="btn btn-sm p-0 ms-1" aria-label="Удалить" style="line-height:1;">×</button>`;
chipsBox.appendChild(chip);
});
}
chipsBox?.addEventListener('click', (e)=>{
const btn = e.target.closest('button'); const chip = btn?.closest('.chip'); if (!chip) return;
const ids = new Set(currentIds()); ids.delete(chip.dataset.id); setIds(Array.from(ids));
});
function syncChecksFromHidden(){
const ids = new Set(currentIds());
listBox?.querySelectorAll('.pos-edit-group-check').forEach(ch=> ch.checked = ids.has(ch.value));
}
function syncHiddenFromChecks(){
const ids=[]; listBox?.querySelectorAll('.pos-edit-group-check:checked').forEach(ch=>ids.push(ch.value));
setIds(ids);
}
search?.addEventListener('input', ()=>{
const q=(search.value||'').toLowerCase().trim();
listBox?.querySelectorAll('.list-group-item').forEach(row=>{
const name=row.querySelector('.flex-grow-1')?.textContent.toLowerCase()||'';
const id=row.querySelector('.pos-edit-group-check')?.value||'';
row.classList.toggle('d-none', !(name.includes(q)||id.includes(q)));
});
});
clearBtn?.addEventListener('click', ()=>{ listBox?.querySelectorAll('.pos-edit-group-check').forEach(ch=>ch.checked=false); setIds([]); });
doneBtn?.addEventListener('click', syncHiddenFromChecks);
clearGroupsBtn?.addEventListener('click', ()=> setIds([]));
function buildUrlFromTpl(tpl, id){
if (!tpl) return '';
if (tpl.includes('/0/')) return tpl.replace('/0/', `/${id}/`);
if (tpl.endsWith('0')) return tpl.slice(0,-1) + id;
return tpl.replace('0', String(id));
}
function buildUpdateUrl(posId){ return buildUrlFromTpl(updateTpl, posId); }
function buildDetailUrl(posId){ return buildUrlFromTpl(detailTpl, posId); } // ← используем фронт-вью
async function loadPositionDetail(posId){
if (!posId){ showMsg('Выберите должность в выпадающем списке.', false); return; }
const url = buildDetailUrl(posId);
if (!url){ showMsg('Не настроен URL detail-вьюхи должности.', false); return; }
showMsg('', true); busy(loadBtn, true);
try{
const resp = await fetch(url, {
credentials:'same-origin',
headers:{ 'Accept':'application/json', 'X-Requested-With':'XMLHttpRequest' }
});
const data = await (async()=>{ try{return await resp.json()}catch{return null} })();
if (!resp.ok){ showMsg(data?.detail || `Не удалось загрузить должность (HTTP ${resp.status})`, false); return; }
nameInp && (nameInp.value = data?.name || '');
descInp && (descInp.value = data?.description || '');
if (canAssign){
const groupsRaw = Array.isArray(data?.groups) ? data.groups
: Array.isArray(data?.groups_ids) ? data.groups_ids : [];
const ids = groupsRaw.map(x => (typeof x==='number' ? String(x) : String(x?.id || x))).filter(Boolean);
setIds(ids);
}
showMsg('Данные должности загружены.', true);
}catch(_){
showMsg('Ошибка загрузки должности.', false);
}finally{
busy(loadBtn, false);
}
}
async function savePositionChanges(){
const posId = Number(select.value || 0);
if (!posId){ showMsg('Сначала выберите должность, которую хотите редактировать.', false); return; }
const updateUrl = buildUpdateUrl(posId);
if (!updateUrl){ showMsg('Не настроен URL обновления должности.', false); return; }
const payload = {};
const nm = (nameInp?.value || '').trim();
const ds = (descInp?.value || '').trim();
if (nm) payload.name = nm;
payload.description = ds;
if (canAssign){
const groups = currentIds().map(x=>Number(x)).filter(n=>!Number.isNaN(n));
payload.groups = groups;
}
showMsg('', true); busy(saveBtn, true);
try{
const resp = await fetch(updateUrl, {
method: 'POST', // фронт-прокси примет POST и внутри пошлёт PATCH
credentials: 'same-origin',
headers: {
'Content-Type': 'application/json',
'Accept': 'application/json',
'X-Requested-With':'XMLHttpRequest',
'X-CSRFToken': csrftoken
},
body: JSON.stringify(payload)
});
const data = await (async()=>{ try{return await resp.json()}catch{return null} })();
if (!resp.ok){ showMsg(data?.detail || `Не удалось сохранить (HTTP ${resp.status})`, false); return; }
if (payload.name){
const opt = Array.from(select.options).find(o => o.value == String(posId));
if (opt) opt.textContent = payload.name;
}
try{ if (window.bootstrap) new bootstrap.Collapse(document.getElementById('editPositionPane'), {toggle:false}).hide(); }catch(_){}
showMsg('Изменения сохранены.', true);
}catch(_){
showMsg('Ошибка сохранения изменений.', false);
}finally{
busy(saveBtn, false);
}
}
document.getElementById('toggleEditPositionBtn')?.addEventListener('click', ()=>{
const posId = Number(select.value || 0);
if (posId) setTimeout(()=> loadPositionDetail(posId), 150);
else showMsg('Выберите должность в выпадающем списке.', false);
});
document.getElementById('posEditLoadBtn')?.addEventListener('click', ()=> loadPositionDetail(Number(select.value || 0)));
document.getElementById('posEditSaveBtn')?.addEventListener('click', savePositionChanges);
})();
</script>