280 lines
8.9 KiB
JavaScript
280 lines
8.9 KiB
JavaScript
/**
|
||
* employeeFormHandler.js
|
||
* Обработчик формы редактирования сотрудника через API
|
||
*
|
||
* @module employeeFormHandler
|
||
* @version 1.0.0
|
||
*/
|
||
|
||
/**
|
||
* Инициализация обработчика формы редактирования сотрудника
|
||
* @param {Object} options - Параметры инициализации
|
||
* @param {string} options.formId - ID формы (по умолчанию 'apiEditForm')
|
||
* @param {string[]} options.contactFields - Список полей контактов для валидации
|
||
* @returns {Object} API формы { refresh, submit, clearErrors }
|
||
*/
|
||
export function initEmployeeForm(options = {}) {
|
||
const config = {
|
||
formId: options.formId || 'apiEditForm',
|
||
contactFields: options.contactFields || ['email', 'phone_number', 'telegram', 'whatsapp', 'wechat']
|
||
};
|
||
|
||
const form = document.getElementById(config.formId);
|
||
if (!form) {
|
||
return null; // Тихий выход, если форма не найдена
|
||
}
|
||
|
||
const endpoint = form.dataset.endpoint || '';
|
||
if (!endpoint) {
|
||
console.warn('Employee form: endpoint not found');
|
||
return null;
|
||
}
|
||
|
||
const CONTACT_FIELDS = config.contactFields;
|
||
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 (массив) и сравнить с начальным значением
|
||
*/
|
||
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;
|
||
}
|
||
|
||
/**
|
||
* Обработчик отправки формы
|
||
*/
|
||
async function handleSubmit(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 (файл через FormData)
|
||
const avatarInput = document.getElementById('avatarInput');
|
||
const avatarFile = avatarInput?.files?.[0] || null;
|
||
|
||
let body;
|
||
let headers = {
|
||
'X-CSRFToken': form.querySelector('[name="csrfmiddlewaretoken"]')?.value || ''
|
||
};
|
||
|
||
if (avatarFile) {
|
||
// Если есть файл - используем FormData
|
||
const formData = new FormData();
|
||
formData.append('avatar', avatarFile);
|
||
|
||
// Добавляем остальные поля
|
||
Object.entries(payload).forEach(([key, value]) => {
|
||
if (Array.isArray(value)) {
|
||
value.forEach(v => formData.append(key, v));
|
||
} else {
|
||
formData.append(key, value);
|
||
}
|
||
});
|
||
|
||
if (skills) {
|
||
skills.forEach(id => formData.append('skills_ids', id));
|
||
}
|
||
|
||
body = formData;
|
||
// Не устанавливаем Content-Type для FormData - браузер сам добавит boundary
|
||
} else {
|
||
// Если файла нет - используем JSON
|
||
headers['Content-Type'] = 'application/json';
|
||
body = JSON.stringify(payload);
|
||
}
|
||
|
||
// Если нечего отправлять — просто обновим UI
|
||
if (Object.keys(payload).length === 0 && !avatarFile) {
|
||
setSubmitting(false);
|
||
if (window.bootstrap) {
|
||
try {
|
||
new bootstrap.Collapse(document.getElementById('editFormWrap'), { toggle: false }).hide();
|
||
} catch (_) {}
|
||
}
|
||
location.reload();
|
||
return;
|
||
}
|
||
|
||
const resp = await fetch(endpoint, {
|
||
method: 'PATCH',
|
||
headers: headers,
|
||
body: body,
|
||
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);
|
||
}
|
||
}
|
||
|
||
form.addEventListener('submit', handleSubmit);
|
||
|
||
// API возвращаем для внешнего использования
|
||
return {
|
||
clearErrors,
|
||
setSubmitting,
|
||
fieldError,
|
||
refresh: () => location.reload()
|
||
};
|
||
}
|