594 lines
20 KiB
HTML
594 lines
20 KiB
HTML
{% extends "base.html" %}
|
||
{% block body_class %}page-bg bg-cabinets{% endblock %}
|
||
{% block content %}
|
||
<style>
|
||
body.bg-cabinets {
|
||
background-color: #B3B3DA;
|
||
background-image:
|
||
linear-gradient(rgba(179, 179, 218, 0.75), rgba(179, 179, 218, 0.75)),
|
||
url('{{ url_for("static", filename="bg/bg_zdanie.png", v=1) }}');
|
||
background-size: auto 40vh;
|
||
}
|
||
.row-actions {
|
||
background: #f8f9fa;
|
||
}
|
||
.row-actions__inner {
|
||
max-height: 0;
|
||
opacity: 0;
|
||
transform: translateY(-6px);
|
||
transition: max-height 0.2s ease, opacity 0.2s ease, transform 0.2s ease;
|
||
overflow: hidden;
|
||
padding: 0 0.75rem;
|
||
}
|
||
.row-actions.show .row-actions__inner {
|
||
max-height: 60px;
|
||
opacity: 1;
|
||
transform: translateY(0);
|
||
}
|
||
.overlay-modal {
|
||
position: fixed;
|
||
inset: 0;
|
||
background: rgba(15, 23, 32, 0.55);
|
||
display: none;
|
||
align-items: center;
|
||
justify-content: center;
|
||
z-index: 1050;
|
||
padding: 1rem;
|
||
}
|
||
.overlay-modal.show {
|
||
display: flex;
|
||
}
|
||
.overlay-modal__dialog {
|
||
background: #fff;
|
||
border-radius: 0.75rem;
|
||
width: min(720px, 100%);
|
||
max-height: 85vh;
|
||
overflow: auto;
|
||
}
|
||
.overlay-modal__header {
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: space-between;
|
||
padding: 0.75rem 1rem;
|
||
border-bottom: 1px solid #e2e2e2;
|
||
}
|
||
.overlay-modal__title {
|
||
margin: 0;
|
||
font-size: 1.05rem;
|
||
}
|
||
.overlay-modal__footer {
|
||
display: flex;
|
||
justify-content: flex-end;
|
||
gap: 0.5rem;
|
||
padding: 0.75rem 1rem;
|
||
border-top: 1px solid #e2e2e2;
|
||
}
|
||
.floor-combo {
|
||
position: relative;
|
||
}
|
||
.floor-combo__trigger {
|
||
width: 100%;
|
||
text-align: left;
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
}
|
||
.floor-combo__trigger::after {
|
||
content: "▾";
|
||
color: #5a6570;
|
||
margin-left: 0.5rem;
|
||
}
|
||
.floor-combo.open .floor-combo__trigger::after {
|
||
content: "▴";
|
||
}
|
||
.floor-combo__menu {
|
||
position: absolute;
|
||
left: 0;
|
||
right: 0;
|
||
top: calc(100% + 0.25rem);
|
||
background: #fff;
|
||
border: 1px solid #d5dbe1;
|
||
border-radius: 0.5rem;
|
||
box-shadow: 0 8px 18px rgba(25, 32, 38, 0.14);
|
||
z-index: 40;
|
||
display: none;
|
||
max-height: 260px;
|
||
overflow: auto;
|
||
padding: 0.35rem;
|
||
}
|
||
.floor-combo.open .floor-combo__menu {
|
||
display: block;
|
||
}
|
||
.floor-combo__option {
|
||
width: 100%;
|
||
border: 0;
|
||
background: transparent;
|
||
text-align: left;
|
||
padding: 0.35rem 0.5rem;
|
||
border-radius: 0.35rem;
|
||
}
|
||
.floor-combo__option:hover {
|
||
background: #f2f5f8;
|
||
}
|
||
.floor-combo__option-row {
|
||
display: flex;
|
||
gap: 0.35rem;
|
||
align-items: center;
|
||
}
|
||
.floor-combo__option-row .floor-combo__option {
|
||
flex: 1;
|
||
}
|
||
.floor-combo__delete {
|
||
white-space: nowrap;
|
||
}
|
||
.floor-combo__empty {
|
||
color: #687483;
|
||
font-size: 0.9rem;
|
||
padding: 0.35rem 0.5rem;
|
||
}
|
||
.floor-combo__add {
|
||
border-top: 1px solid #e4e9ee;
|
||
margin-top: 0.35rem;
|
||
padding-top: 0.45rem;
|
||
}
|
||
.floor-combo__add-row {
|
||
display: flex;
|
||
gap: 0.35rem;
|
||
align-items: center;
|
||
}
|
||
</style>
|
||
|
||
<h3>Здания и кабинеты</h3>
|
||
|
||
{% if session.get('role') not in ('admin','storekeeper') %}
|
||
<div class="alert alert-secondary">Режим просмотра: изменения доступны только администратору.</div>
|
||
{% endif %}
|
||
|
||
{% if grouped %}
|
||
{% for building, rows in grouped.items() %}
|
||
<h5 class="mt-4 mb-2">Здание: {{ building }}</h5>
|
||
<table class="table table-striped">
|
||
<tr>
|
||
<th>Кабинет</th>
|
||
<th>Этаж</th>
|
||
<th>Здание</th>
|
||
</tr>
|
||
{% for row in rows %}
|
||
<tr class="js-cabinet-row"
|
||
data-id="{{ row.id }}"
|
||
data-building="{{ row.building }}"
|
||
data-floor="{{ row.floor }}"
|
||
data-cabinet="{{ row.cabinet }}">
|
||
<td>{{ row.cabinet }}</td>
|
||
<td>{{ row.floor or '—' }}</td>
|
||
<td>{{ row.building or '—' }}</td>
|
||
</tr>
|
||
{% endfor %}
|
||
</table>
|
||
{% if session.get('role') in ('admin','storekeeper') and building != 'Без здания' %}
|
||
<form method="post" action="/buildings/delete" class="mb-4 d-flex justify-content-end" onsubmit="return confirm('Удалить организацию/здание и все кабинеты в нем?')">
|
||
<input type="hidden" name="building" value="{{ building }}">
|
||
<button class="btn btn-sm btn-outline-secondary text-danger" type="submit">Удалить организацию/здание</button>
|
||
</form>
|
||
{% endif %}
|
||
{% endfor %}
|
||
{% else %}
|
||
<div class="alert alert-secondary">Кабинеты не найдены.</div>
|
||
{% endif %}
|
||
|
||
{% if session.get('role') in ('admin','storekeeper') %}
|
||
<div class="overlay-modal" id="addBuildingModal" aria-hidden="true">
|
||
<div class="overlay-modal__dialog">
|
||
<div class="overlay-modal__header">
|
||
<h4 class="overlay-modal__title">Добавить здание</h4>
|
||
<button type="button" class="btn btn-sm btn-outline-secondary" data-modal-close>Закрыть</button>
|
||
</div>
|
||
<form method="post" action="/buildings/add">
|
||
<div class="row g-2 px-3 py-3">
|
||
<div class="col-12">
|
||
<label class="form-label">Здание</label>
|
||
<input name="building" class="form-control" placeholder="Например: 1, А, Корпус 2" required>
|
||
</div>
|
||
</div>
|
||
<div class="overlay-modal__footer">
|
||
<button type="button" class="btn btn-outline-secondary" data-modal-close>Отмена</button>
|
||
<button class="btn btn-success">Добавить</button>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="overlay-modal" id="addCabinetModal" aria-hidden="true">
|
||
<div class="overlay-modal__dialog">
|
||
<div class="overlay-modal__header">
|
||
<h4 class="overlay-modal__title">Добавить кабинет</h4>
|
||
<button type="button" class="btn btn-sm btn-outline-secondary" data-modal-close>Закрыть</button>
|
||
</div>
|
||
<form method="post" action="/cabinets/add" id="cabinetAddForm">
|
||
<div class="row g-2 px-3 py-3">
|
||
<div class="col-md-6">
|
||
<label class="form-label">Здание</label>
|
||
<select name="building" class="form-select" id="cabinetBuilding" required>
|
||
<option value="">Выберите здание...</option>
|
||
{% for building in buildings %}
|
||
<option value="{{ building }}" {% if selected_building == building %}selected{% endif %}>{{ building }}</option>
|
||
{% endfor %}
|
||
</select>
|
||
</div>
|
||
<div class="col-md-6">
|
||
<label class="form-label">Кабинет</label>
|
||
<input name="cabinet" class="form-control" placeholder="Например: 101" required>
|
||
</div>
|
||
<div class="col-md-6">
|
||
<label class="form-label">Этаж</label>
|
||
<div class="floor-combo" id="cabinetFloorCombo">
|
||
<input type="hidden" name="floor" id="cabinetFloor">
|
||
<button type="button" class="btn btn-outline-secondary floor-combo__trigger" id="cabinetFloorTrigger">Выберите этаж</button>
|
||
<div class="floor-combo__menu" id="cabinetFloorMenu">
|
||
<div id="cabinetFloorOptions"></div>
|
||
<div class="floor-combo__add">
|
||
<div class="floor-combo__add-row">
|
||
<input type="text" class="form-control form-control-sm" id="cabinetFloorNew" placeholder="Новый этаж">
|
||
<button type="button" class="btn btn-sm btn-outline-primary" id="cabinetFloorAddBtn">Добавить этаж</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="overlay-modal__footer">
|
||
<button type="button" class="btn btn-outline-secondary" data-modal-close>Отмена</button>
|
||
<button class="btn btn-success">Добавить</button>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="overlay-modal" id="cabinetEditModal" aria-hidden="true">
|
||
<div class="overlay-modal__dialog">
|
||
<div class="overlay-modal__header">
|
||
<h4 class="overlay-modal__title">Редактирование кабинета</h4>
|
||
<button type="button" class="btn btn-sm btn-outline-secondary" data-modal-close>Закрыть</button>
|
||
</div>
|
||
<form method="post" action="/cabinets/edit" id="cabinetEditForm">
|
||
<input type="hidden" name="id" id="cabinetModalId">
|
||
<div class="row g-2 px-3 py-3">
|
||
<div class="col-md-6">
|
||
<label class="form-label">Здание</label>
|
||
<select name="building" id="cabinetModalBuilding" class="form-select" required>
|
||
<option value="">Выберите здание...</option>
|
||
{% for building in buildings %}
|
||
<option value="{{ building }}">{{ building }}</option>
|
||
{% endfor %}
|
||
</select>
|
||
</div>
|
||
<div class="col-md-6">
|
||
<label class="form-label">Кабинет</label>
|
||
<input name="cabinet" id="cabinetModalCabinet" class="form-control" placeholder="Кабинет" required>
|
||
</div>
|
||
<div class="col-md-6">
|
||
<label class="form-label">Этаж</label>
|
||
<div class="floor-combo" id="cabinetModalFloorCombo">
|
||
<input type="hidden" name="floor" id="cabinetModalFloor">
|
||
<button type="button" class="btn btn-outline-secondary floor-combo__trigger" id="cabinetModalFloorTrigger">Выберите этаж</button>
|
||
<div class="floor-combo__menu" id="cabinetModalFloorMenu">
|
||
<div id="cabinetModalFloorOptions"></div>
|
||
<div class="floor-combo__add">
|
||
<div class="floor-combo__add-row">
|
||
<input type="text" class="form-control form-control-sm" id="cabinetModalFloorNew" placeholder="Новый этаж">
|
||
<button type="button" class="btn btn-sm btn-outline-primary" id="cabinetModalFloorAddBtn">Добавить этаж</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="overlay-modal__footer">
|
||
<button type="button" class="btn btn-outline-secondary" data-modal-close>Отмена</button>
|
||
<button class="btn btn-primary">Сохранить</button>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
{% endif %}
|
||
|
||
<script>
|
||
(function() {
|
||
const floorMap = {{ floor_map | tojson }};
|
||
const openModalName = {{ open_modal | tojson }};
|
||
|
||
function getFloorOptions(building) {
|
||
return floorMap[(building || '').trim()] || [];
|
||
}
|
||
|
||
function upsertFloor(building, floorValue) {
|
||
const bld = (building || '').trim();
|
||
const floor = (floorValue || '').trim();
|
||
if (!bld || !floor) return;
|
||
const current = floorMap[bld] || [];
|
||
if (!current.includes(floor)) {
|
||
current.push(floor);
|
||
current.sort();
|
||
floorMap[bld] = current;
|
||
}
|
||
}
|
||
|
||
function createFloorCombo(config) {
|
||
const root = config.root;
|
||
const hidden = config.hidden;
|
||
const trigger = config.trigger;
|
||
const optionsEl = config.options;
|
||
const addInput = config.addInput;
|
||
const addBtn = config.addBtn;
|
||
const resolveBuilding = config.resolveBuilding;
|
||
const onAdd = config.onAdd;
|
||
const onDelete = config.onDelete;
|
||
|
||
if (!root || !hidden || !trigger || !optionsEl || !addInput || !addBtn) {
|
||
return { refresh: () => {}, setValue: () => {}, close: () => {} };
|
||
}
|
||
|
||
const close = () => root.classList.remove('open');
|
||
const open = () => root.classList.add('open');
|
||
|
||
const setTriggerText = () => {
|
||
const value = (hidden.value || '').trim();
|
||
trigger.textContent = value || 'Выберите этаж';
|
||
};
|
||
|
||
const setValue = (value) => {
|
||
hidden.value = (value || '').trim();
|
||
setTriggerText();
|
||
};
|
||
|
||
const renderOptions = () => {
|
||
const building = resolveBuilding();
|
||
const options = getFloorOptions(building);
|
||
optionsEl.innerHTML = '';
|
||
if (!options.length) {
|
||
const empty = document.createElement('div');
|
||
empty.className = 'floor-combo__empty';
|
||
empty.textContent = 'Этажей пока нет';
|
||
optionsEl.appendChild(empty);
|
||
return;
|
||
}
|
||
options.forEach((item) => {
|
||
const row = document.createElement('div');
|
||
row.className = 'floor-combo__option-row';
|
||
const btn = document.createElement('button');
|
||
btn.type = 'button';
|
||
btn.className = 'floor-combo__option';
|
||
btn.textContent = item;
|
||
btn.addEventListener('click', () => {
|
||
setValue(item);
|
||
close();
|
||
});
|
||
row.appendChild(btn);
|
||
|
||
if (typeof onDelete === 'function') {
|
||
const del = document.createElement('button');
|
||
del.type = 'button';
|
||
del.className = 'btn btn-sm btn-outline-danger floor-combo__delete';
|
||
del.textContent = 'Удалить';
|
||
del.addEventListener('click', (e) => {
|
||
e.stopPropagation();
|
||
const building = resolveBuilding();
|
||
if (!building) return;
|
||
if (!confirm(`Удалить этаж ${item} в здании ${building}?`)) return;
|
||
onDelete(building, item);
|
||
});
|
||
row.appendChild(del);
|
||
}
|
||
|
||
optionsEl.appendChild(row);
|
||
});
|
||
};
|
||
|
||
trigger.addEventListener('click', () => {
|
||
if (root.classList.contains('open')) {
|
||
close();
|
||
return;
|
||
}
|
||
renderOptions();
|
||
open();
|
||
});
|
||
|
||
addBtn.addEventListener('click', () => {
|
||
const building = resolveBuilding();
|
||
const newFloor = (addInput.value || '').trim();
|
||
if (!building || !newFloor) return;
|
||
upsertFloor(building, newFloor);
|
||
setValue(newFloor);
|
||
addInput.value = '';
|
||
renderOptions();
|
||
if (typeof onAdd === 'function') {
|
||
onAdd(building, newFloor);
|
||
}
|
||
});
|
||
|
||
document.addEventListener('click', (e) => {
|
||
if (!root.contains(e.target)) close();
|
||
});
|
||
|
||
setTriggerText();
|
||
return { refresh: renderOptions, setValue, close };
|
||
}
|
||
|
||
function bindOverlay(modalEl) {
|
||
if (!modalEl) return { open: () => {}, close: () => {} };
|
||
const close = () => {
|
||
modalEl.classList.remove('show');
|
||
modalEl.setAttribute('aria-hidden', 'true');
|
||
};
|
||
const open = () => {
|
||
modalEl.classList.add('show');
|
||
modalEl.setAttribute('aria-hidden', 'false');
|
||
};
|
||
modalEl.addEventListener('click', (e) => {
|
||
if (e.target === modalEl || e.target.hasAttribute('data-modal-close')) close();
|
||
});
|
||
return { open, close };
|
||
}
|
||
|
||
function submitFloorDelete(building, floor) {
|
||
const form = document.createElement('form');
|
||
form.method = 'post';
|
||
form.action = '/floors/delete';
|
||
|
||
const b = document.createElement('input');
|
||
b.type = 'hidden';
|
||
b.name = 'building';
|
||
b.value = building;
|
||
form.appendChild(b);
|
||
|
||
const f = document.createElement('input');
|
||
f.type = 'hidden';
|
||
f.name = 'floor';
|
||
f.value = floor;
|
||
form.appendChild(f);
|
||
|
||
document.body.appendChild(form);
|
||
form.submit();
|
||
}
|
||
|
||
function submitFloorAdd(building, floor) {
|
||
const form = document.createElement('form');
|
||
form.method = 'post';
|
||
form.action = '/floors/add';
|
||
|
||
const b = document.createElement('input');
|
||
b.type = 'hidden';
|
||
b.name = 'building';
|
||
b.value = building;
|
||
form.appendChild(b);
|
||
|
||
const f = document.createElement('input');
|
||
f.type = 'hidden';
|
||
f.name = 'floor';
|
||
f.value = floor;
|
||
form.appendChild(f);
|
||
|
||
document.body.appendChild(form);
|
||
form.submit();
|
||
}
|
||
|
||
const canEdit = {{ 'true' if session.get('role') in ('admin','storekeeper') else 'false' }};
|
||
if (!canEdit) return;
|
||
|
||
const addBuildingModal = document.getElementById('addBuildingModal');
|
||
const addCabinetModal = document.getElementById('addCabinetModal');
|
||
const editModal = document.getElementById('cabinetEditModal');
|
||
|
||
const addBuildingCtl = bindOverlay(addBuildingModal);
|
||
const addCabinetCtl = bindOverlay(addCabinetModal);
|
||
const editCtl = bindOverlay(editModal);
|
||
|
||
const addBuildingSelect = document.getElementById('cabinetBuilding');
|
||
const addFloorCombo = createFloorCombo({
|
||
root: document.getElementById('cabinetFloorCombo'),
|
||
hidden: document.getElementById('cabinetFloor'),
|
||
trigger: document.getElementById('cabinetFloorTrigger'),
|
||
options: document.getElementById('cabinetFloorOptions'),
|
||
addInput: document.getElementById('cabinetFloorNew'),
|
||
addBtn: document.getElementById('cabinetFloorAddBtn'),
|
||
resolveBuilding: () => addBuildingSelect ? addBuildingSelect.value : '',
|
||
onAdd: submitFloorAdd,
|
||
onDelete: submitFloorDelete,
|
||
});
|
||
|
||
if (addBuildingSelect) {
|
||
addBuildingSelect.addEventListener('change', () => {
|
||
addFloorCombo.setValue('');
|
||
addFloorCombo.refresh();
|
||
});
|
||
}
|
||
|
||
const fields = {
|
||
id: document.getElementById('cabinetModalId'),
|
||
building: document.getElementById('cabinetModalBuilding'),
|
||
floor: document.getElementById('cabinetModalFloor'),
|
||
cabinet: document.getElementById('cabinetModalCabinet'),
|
||
};
|
||
|
||
const modalFloorCombo = createFloorCombo({
|
||
root: document.getElementById('cabinetModalFloorCombo'),
|
||
hidden: document.getElementById('cabinetModalFloor'),
|
||
trigger: document.getElementById('cabinetModalFloorTrigger'),
|
||
options: document.getElementById('cabinetModalFloorOptions'),
|
||
addInput: document.getElementById('cabinetModalFloorNew'),
|
||
addBtn: document.getElementById('cabinetModalFloorAddBtn'),
|
||
resolveBuilding: () => fields.building ? fields.building.value : '',
|
||
onAdd: submitFloorAdd,
|
||
onDelete: submitFloorDelete,
|
||
});
|
||
|
||
if (fields.building) {
|
||
fields.building.addEventListener('change', () => {
|
||
modalFloorCombo.setValue('');
|
||
modalFloorCombo.refresh();
|
||
});
|
||
}
|
||
|
||
function fillModalFromRow(row) {
|
||
const rowBuilding = row.getAttribute('data-building') || '';
|
||
if (fields.id) fields.id.value = row.getAttribute('data-id') || '';
|
||
if (fields.building) {
|
||
const exists = Array.from(fields.building.options || []).some((o) => o.value === rowBuilding);
|
||
if (!exists && rowBuilding) {
|
||
const opt = document.createElement('option');
|
||
opt.value = rowBuilding;
|
||
opt.textContent = rowBuilding;
|
||
fields.building.appendChild(opt);
|
||
}
|
||
fields.building.value = rowBuilding;
|
||
}
|
||
modalFloorCombo.setValue(row.getAttribute('data-floor') || '');
|
||
if (fields.cabinet) fields.cabinet.value = row.getAttribute('data-cabinet') || '';
|
||
modalFloorCombo.refresh();
|
||
return true;
|
||
}
|
||
|
||
const rows = document.querySelectorAll('.js-cabinet-row');
|
||
function showActionRow(row) {
|
||
const existing = row.parentElement.querySelector('.row-actions');
|
||
if (existing) existing.remove();
|
||
const rowId = row.getAttribute('data-id') || '';
|
||
const actionRow = document.createElement('tr');
|
||
actionRow.className = 'row-actions';
|
||
actionRow.innerHTML = `<td colspan="3">
|
||
<div class="row-actions__inner">
|
||
<button type="button" class="btn btn-sm btn-outline-primary js-open-cabinet-modal">Редактировать</button>
|
||
<form method="post" action="/cabinets/delete" class="d-inline ms-2" onsubmit="return confirm('Удалить кабинет?')">
|
||
<input type="hidden" name="id" value="${rowId}">
|
||
<button class="btn btn-sm btn-outline-danger" type="submit">Удалить</button>
|
||
</form>
|
||
</div>
|
||
</td>`;
|
||
row.insertAdjacentElement('afterend', actionRow);
|
||
const btn = actionRow.querySelector('.js-open-cabinet-modal');
|
||
btn.addEventListener('click', () => {
|
||
if (fillModalFromRow(row)) editCtl.open();
|
||
});
|
||
requestAnimationFrame(() => actionRow.classList.add('show'));
|
||
}
|
||
|
||
rows.forEach((row) => {
|
||
row.addEventListener('click', (e) => {
|
||
if (e.target.closest('button, input, select, textarea, a')) return;
|
||
showActionRow(row);
|
||
});
|
||
row.addEventListener('dblclick', (e) => {
|
||
if (e.target.closest('button, input, select, textarea, a')) return;
|
||
if (fillModalFromRow(row)) editCtl.open();
|
||
});
|
||
});
|
||
|
||
if (openModalName === 'add-building') {
|
||
addBuildingCtl.open();
|
||
} else if (openModalName === 'add-cabinet') {
|
||
addCabinetCtl.open();
|
||
}
|
||
})();
|
||
</script>
|
||
{% endblock %}
|