481 lines
18 KiB
HTML
481 lines
18 KiB
HTML
{% extends "base.html" %}
|
|
{% block body_class %}page-bg bg-cartridges{% endblock %}
|
|
{% block content %}
|
|
<style>
|
|
body.bg-cartridges {
|
|
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_opc.png", v=1) }}');
|
|
background-size: auto 40vh;
|
|
}
|
|
.table td,
|
|
.table th {
|
|
white-space: normal;
|
|
word-break: break-word;
|
|
}
|
|
.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);
|
|
}
|
|
.edit-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;
|
|
}
|
|
.edit-modal.show {
|
|
display: flex;
|
|
}
|
|
.edit-modal__dialog {
|
|
background: #fff;
|
|
border-radius: 0.75rem;
|
|
width: min(800px, 100%);
|
|
max-height: 85vh;
|
|
overflow: auto;
|
|
padding: 1.25rem 1.5rem;
|
|
box-shadow: 0 24px 48px rgba(15, 23, 32, 0.25);
|
|
}
|
|
.edit-modal__header {
|
|
display: flex;
|
|
align-items: center;
|
|
justify-content: space-between;
|
|
margin-bottom: 1rem;
|
|
}
|
|
.edit-modal__title {
|
|
font-size: 1.1rem;
|
|
font-weight: 600;
|
|
margin: 0;
|
|
}
|
|
.edit-modal__footer {
|
|
display: flex;
|
|
justify-content: flex-end;
|
|
gap: 0.5rem;
|
|
margin-top: 1rem;
|
|
}
|
|
.consumable-add-dialog {
|
|
width: min(980px, 100%);
|
|
max-height: none;
|
|
overflow: visible;
|
|
}
|
|
.consumable-autocomplete {
|
|
position: relative;
|
|
}
|
|
.consumable-hints {
|
|
display: none;
|
|
position: absolute;
|
|
top: calc(100% + 0.25rem);
|
|
left: 0;
|
|
right: 0;
|
|
z-index: 1070;
|
|
max-height: none;
|
|
overflow: visible;
|
|
border-radius: 0.375rem;
|
|
box-shadow: 0 10px 24px rgba(15, 23, 32, 0.18);
|
|
}
|
|
.consumable-device-list {
|
|
margin: 0;
|
|
padding-left: 0;
|
|
list-style: none;
|
|
}
|
|
</style>
|
|
<div class="d-flex align-items-center justify-content-between mb-2">
|
|
<h3 class="mb-0">Расходные материалы и ЗИП</h3>
|
|
<div class="d-flex gap-2">
|
|
{% if session.get('role') in ('admin','storekeeper') %}
|
|
<button class="btn btn-outline-primary" type="button" id="openAddConsumableModal">Добавить</button>
|
|
{% endif %}
|
|
<button class="btn btn-outline-primary" type="button" id="toggleConsSearch" aria-label="Поиск">🔍</button>
|
|
<a class="btn btn-success" href="/report/consumables.xlsx">Экспорт в Excel</a>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="card mb-3 d-none" id="consSearchCard">
|
|
<div class="card-body">
|
|
<form class="row g-2 align-items-center" id="consSearchForm">
|
|
<div class="col-md-6">
|
|
<input id="consSearchInput" class="form-control" placeholder="Модель расходника">
|
|
</div>
|
|
<div class="col-md-2 d-grid">
|
|
<button class="btn btn-primary" type="submit">Найти</button>
|
|
</div>
|
|
<div class="col-md-2 d-grid">
|
|
<button class="btn btn-outline-secondary" type="button" id="consSearchClear">Очистить</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
|
|
{% if session.get('role') not in ('admin','storekeeper') %}
|
|
<div class="alert alert-secondary">Режим просмотра: изменения доступны только администратору.</div>
|
|
{% endif %}
|
|
|
|
<table class="table table-striped">
|
|
<tr>
|
|
<th>Штрихкод</th>
|
|
<th>Модель</th>
|
|
<th>Тип</th>
|
|
<th>Кол-во</th>
|
|
<th>Устройство</th>
|
|
</tr>
|
|
{% for barcode, model, ctype, qty, devices in rows %}
|
|
<tr class="js-consumable-row"
|
|
data-barcode="{{ barcode }}"
|
|
data-model="{{ model or '' }}"
|
|
data-type="{{ ctype or '' }}"
|
|
data-quantity="{{ qty }}">
|
|
<td>{{ barcode }}</td>
|
|
<td>{{ model }}</td>
|
|
<td>{{ ctype }}</td>
|
|
<td>{{ qty }}</td>
|
|
<td>
|
|
{% if devices %}
|
|
<ul class="consumable-device-list">
|
|
{% for device in devices %}<li>{{ device }}</li>{% endfor %}
|
|
</ul>
|
|
{% else %}—{% endif %}
|
|
</td>
|
|
</tr>
|
|
{% endfor %}
|
|
</table>
|
|
|
|
{% if session.get('role') in ('admin','storekeeper') %}
|
|
<div class="edit-modal" id="addConsumableModal" aria-hidden="true">
|
|
<div class="edit-modal__dialog consumable-add-dialog">
|
|
<div class="edit-modal__header">
|
|
<h4 class="edit-modal__title">Добавить расходный материал</h4>
|
|
<button type="button" class="btn btn-sm btn-outline-secondary" data-add-modal-close>Закрыть</button>
|
|
</div>
|
|
<form method="post" action="/consumables/add" id="addConsumableForm">
|
|
<div class="row g-2">
|
|
<div class="col-md-3">
|
|
<label class="form-label" for="addConsumableBarcode">Штрихкод</label>
|
|
<input name="barcode" id="addConsumableBarcode" class="form-control" autocomplete="off" required>
|
|
</div>
|
|
<div class="col-md-3">
|
|
<label class="form-label" for="addConsumableModel">Модель</label>
|
|
<input name="model" id="addConsumableModel" class="form-control" autocomplete="off" required>
|
|
</div>
|
|
<div class="col-md-4 consumable-autocomplete">
|
|
<label class="form-label" for="addConsumableType">Тип</label>
|
|
<input name="type" id="addConsumableType" class="form-control" autocomplete="off" placeholder="Выберите или введите свой тип" required>
|
|
<div class="consumable-hints list-group" id="addConsumableTypeHints"></div>
|
|
</div>
|
|
<div class="col-md-2">
|
|
<label class="form-label" for="addConsumableQuantity">Кол-во</label>
|
|
<input name="quantity" id="addConsumableQuantity" class="form-control" type="number" min="1" step="1" value="1" required>
|
|
</div>
|
|
<div class="col-12 consumable-autocomplete mt-3">
|
|
<label class="form-label" for="addConsumableDevice">Устройство</label>
|
|
<input type="hidden" name="device_model" id="addConsumableDeviceModel">
|
|
<input type="hidden" name="device_type" id="addConsumableDeviceType">
|
|
<input name="device_query" id="addConsumableDevice" class="form-control" autocomplete="off" placeholder="Начните вводить модель или укажите своё устройство" required>
|
|
<div class="consumable-hints list-group" id="addConsumableDeviceHints"></div>
|
|
<div class="form-text">Выберите устройство из списка или напишите своё.</div>
|
|
</div>
|
|
</div>
|
|
<div class="edit-modal__footer">
|
|
<button type="button" class="btn btn-outline-secondary" data-add-modal-close>Отмена</button>
|
|
<button class="btn btn-success" type="submit">Добавить</button>
|
|
</div>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
|
|
<div class="edit-modal" id="consumableEditModal" aria-hidden="true">
|
|
<div class="edit-modal__dialog">
|
|
<div class="edit-modal__header">
|
|
<h4 class="edit-modal__title">Редактирование расходника</h4>
|
|
<button type="button" class="btn btn-sm btn-outline-secondary" data-modal-close>Закрыть</button>
|
|
</div>
|
|
<form method="post" action="/consumables/edit" id="consumableEditForm">
|
|
<div class="row g-2">
|
|
<div class="col-md-3">
|
|
<input name="barcode" id="consModalBarcode" class="form-control" placeholder="Штрихкод" readonly>
|
|
</div>
|
|
<div class="col-md-3">
|
|
<input name="model" id="consModalModel" class="form-control" placeholder="Модель">
|
|
</div>
|
|
<div class="col-md-4">
|
|
<input name="type" id="consModalType" class="form-control" autocomplete="off" placeholder="Тип">
|
|
</div>
|
|
<div class="col-md-2">
|
|
<input name="quantity" id="consModalQuantity" class="form-control" placeholder="Количество">
|
|
</div>
|
|
</div>
|
|
<div class="edit-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 %}
|
|
|
|
{% if session.get('role') in ('admin','storekeeper') %}
|
|
<script>
|
|
window.addEventListener('DOMContentLoaded', () => {
|
|
const deviceOptions = {{ device_options | tojson }};
|
|
const typeOptions = {{ consumable_types | tojson }};
|
|
const addModal = document.getElementById('addConsumableModal');
|
|
const addForm = document.getElementById('addConsumableForm');
|
|
const openAddButton = document.getElementById('openAddConsumableModal');
|
|
const barcodeInput = document.getElementById('addConsumableBarcode');
|
|
const typeInput = document.getElementById('addConsumableType');
|
|
const typeHints = document.getElementById('addConsumableTypeHints');
|
|
const deviceInput = document.getElementById('addConsumableDevice');
|
|
const deviceModelInput = document.getElementById('addConsumableDeviceModel');
|
|
const deviceTypeInput = document.getElementById('addConsumableDeviceType');
|
|
const deviceHints = document.getElementById('addConsumableDeviceHints');
|
|
|
|
const normalize = (value) => String(value || '').trim().toLocaleLowerCase('ru-RU');
|
|
|
|
function hideTypeHints() {
|
|
typeHints.style.display = 'none';
|
|
typeHints.replaceChildren();
|
|
}
|
|
|
|
function renderTypeHints() {
|
|
const query = normalize(typeInput.value);
|
|
const matches = typeOptions.filter((option) => !query || normalize(option).includes(query));
|
|
typeHints.replaceChildren();
|
|
if (!matches.length) {
|
|
const message = document.createElement('div');
|
|
message.className = 'list-group-item text-muted';
|
|
message.textContent = 'Совпадений нет — можно сохранить введённый тип.';
|
|
typeHints.appendChild(message);
|
|
} else {
|
|
matches.forEach((option) => {
|
|
const button = document.createElement('button');
|
|
button.type = 'button';
|
|
button.className = 'list-group-item list-group-item-action';
|
|
button.textContent = option;
|
|
button.addEventListener('click', () => {
|
|
typeInput.value = option;
|
|
hideTypeHints();
|
|
typeInput.focus();
|
|
});
|
|
typeHints.appendChild(button);
|
|
});
|
|
}
|
|
typeHints.style.display = 'block';
|
|
}
|
|
|
|
function hideDeviceHints() {
|
|
deviceHints.style.display = 'none';
|
|
deviceHints.replaceChildren();
|
|
}
|
|
|
|
function selectDevice(option) {
|
|
deviceInput.value = option.label;
|
|
deviceModelInput.value = option.model;
|
|
deviceTypeInput.value = option.type;
|
|
hideDeviceHints();
|
|
}
|
|
|
|
function renderDeviceHints() {
|
|
const query = normalize(deviceInput.value);
|
|
const matches = deviceOptions.filter((option) => {
|
|
return !query || normalize(option.label).includes(query) || normalize(option.model).includes(query);
|
|
});
|
|
deviceHints.replaceChildren();
|
|
if (!matches.length) {
|
|
const message = document.createElement('div');
|
|
message.className = 'list-group-item text-muted';
|
|
message.textContent = 'Совпадений нет — введённое значение будет сохранено как своё устройство.';
|
|
deviceHints.appendChild(message);
|
|
} else {
|
|
matches.forEach((option) => {
|
|
const button = document.createElement('button');
|
|
button.type = 'button';
|
|
button.className = 'list-group-item list-group-item-action';
|
|
button.textContent = option.label;
|
|
button.addEventListener('click', () => selectDevice(option));
|
|
deviceHints.appendChild(button);
|
|
});
|
|
}
|
|
deviceHints.style.display = 'block';
|
|
}
|
|
|
|
function closeAddModal() {
|
|
addModal.classList.remove('show');
|
|
addModal.setAttribute('aria-hidden', 'true');
|
|
hideTypeHints();
|
|
hideDeviceHints();
|
|
}
|
|
|
|
function openAddModal() {
|
|
addForm.reset();
|
|
document.getElementById('addConsumableQuantity').value = '1';
|
|
deviceModelInput.value = '';
|
|
deviceTypeInput.value = '';
|
|
addModal.classList.add('show');
|
|
addModal.setAttribute('aria-hidden', 'false');
|
|
window.setTimeout(() => barcodeInput.focus(), 0);
|
|
}
|
|
|
|
openAddButton.addEventListener('click', openAddModal);
|
|
addModal.addEventListener('click', (event) => {
|
|
if (event.target === addModal || event.target.hasAttribute('data-add-modal-close')) {
|
|
closeAddModal();
|
|
}
|
|
});
|
|
typeInput.addEventListener('input', renderTypeHints);
|
|
typeInput.addEventListener('focus', renderTypeHints);
|
|
deviceInput.addEventListener('input', () => {
|
|
deviceModelInput.value = '';
|
|
deviceTypeInput.value = '';
|
|
renderDeviceHints();
|
|
});
|
|
deviceInput.addEventListener('focus', renderDeviceHints);
|
|
addForm.addEventListener('submit', () => {
|
|
if (deviceModelInput.value) return;
|
|
const typedValue = normalize(deviceInput.value);
|
|
const exact = deviceOptions.find((option) => {
|
|
return normalize(option.label) === typedValue || normalize(option.model) === typedValue;
|
|
});
|
|
if (exact) selectDevice(exact);
|
|
});
|
|
document.addEventListener('click', (event) => {
|
|
if (!typeInput.closest('.consumable-autocomplete').contains(event.target)) hideTypeHints();
|
|
if (!deviceInput.closest('.consumable-autocomplete').contains(event.target)) hideDeviceHints();
|
|
});
|
|
|
|
const rows = document.querySelectorAll('.js-consumable-row');
|
|
const modal = document.getElementById('consumableEditModal');
|
|
const form = document.getElementById('consumableEditForm');
|
|
if (!modal || !form) return;
|
|
const modalFields = {
|
|
barcode: document.getElementById('consModalBarcode'),
|
|
model: document.getElementById('consModalModel'),
|
|
quantity: document.getElementById('consModalQuantity'),
|
|
type: document.getElementById('consModalType'),
|
|
};
|
|
|
|
function closeModal() {
|
|
modal.classList.remove('show');
|
|
modal.setAttribute('aria-hidden', 'true');
|
|
}
|
|
|
|
function openModal() {
|
|
modal.classList.add('show');
|
|
modal.setAttribute('aria-hidden', 'false');
|
|
}
|
|
|
|
function fillModalFromRow(row) {
|
|
const barcode = row.getAttribute('data-barcode') || '';
|
|
const model = row.getAttribute('data-model') || '';
|
|
const ctype = row.getAttribute('data-type') || '';
|
|
const quantity = row.getAttribute('data-quantity') || '';
|
|
if (!barcode) return false;
|
|
modalFields.barcode.value = barcode;
|
|
modalFields.model.value = model;
|
|
modalFields.quantity.value = quantity;
|
|
modalFields.type.value = ctype;
|
|
return true;
|
|
}
|
|
|
|
modal.addEventListener('click', (e) => {
|
|
if (e.target === modal || e.target.hasAttribute('data-modal-close')) {
|
|
closeModal();
|
|
}
|
|
});
|
|
function showActionRow(row) {
|
|
const existing = row.parentElement.querySelector('.row-actions');
|
|
if (existing) existing.remove();
|
|
const barcode = row.getAttribute('data-barcode') || '';
|
|
const actionRow = document.createElement('tr');
|
|
actionRow.className = 'row-actions';
|
|
actionRow.innerHTML = `<td colspan="5">
|
|
<div class="row-actions__inner">
|
|
<button type="button" class="btn btn-sm btn-outline-primary js-open-cons-modal">Редактировать</button>
|
|
<form method="post" action="/consumables/delete" class="d-inline ms-2" onsubmit="return confirm('Удалить расходный материал?')">
|
|
<input type="hidden" name="barcode" class="js-delete-consumable-barcode">
|
|
<button class="btn btn-sm btn-outline-danger" type="submit">Удалить</button>
|
|
</form>
|
|
</div>
|
|
</td>`;
|
|
actionRow.querySelector('.js-delete-consumable-barcode').value = barcode;
|
|
row.insertAdjacentElement('afterend', actionRow);
|
|
const btn = actionRow.querySelector('.js-open-cons-modal');
|
|
btn.addEventListener('click', () => {
|
|
if (fillModalFromRow(row)) openModal();
|
|
});
|
|
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)) openModal();
|
|
});
|
|
});
|
|
|
|
document.addEventListener('keydown', (event) => {
|
|
if (event.key !== 'Escape') return;
|
|
if (addModal.classList.contains('show')) closeAddModal();
|
|
if (modal.classList.contains('show')) closeModal();
|
|
});
|
|
});
|
|
</script>
|
|
<script>
|
|
window.addEventListener('DOMContentLoaded', () => {
|
|
const toggleBtn = document.getElementById('toggleConsSearch');
|
|
const card = document.getElementById('consSearchCard');
|
|
const form = document.getElementById('consSearchForm');
|
|
const input = document.getElementById('consSearchInput');
|
|
const clearBtn = document.getElementById('consSearchClear');
|
|
if (!toggleBtn || !card || !form || !input) return;
|
|
|
|
const rows = () => document.querySelectorAll('.js-consumable-row');
|
|
|
|
const applyFilter = () => {
|
|
const q = (input.value || '').trim().toLowerCase();
|
|
rows().forEach((row) => {
|
|
const model = (row.getAttribute('data-model') || '').toLowerCase();
|
|
const match = !q || model.includes(q);
|
|
row.classList.toggle('d-none', !match);
|
|
});
|
|
};
|
|
|
|
toggleBtn.addEventListener('click', () => {
|
|
card.classList.toggle('d-none');
|
|
if (!card.classList.contains('d-none')) input.focus();
|
|
});
|
|
|
|
form.addEventListener('submit', (e) => {
|
|
e.preventDefault();
|
|
applyFilter();
|
|
});
|
|
|
|
if (clearBtn) {
|
|
clearBtn.addEventListener('click', () => {
|
|
input.value = '';
|
|
applyFilter();
|
|
});
|
|
}
|
|
});
|
|
</script>
|
|
{% endif %}
|
|
{% endblock %}
|