Files
Inv_web/templates/consumables.html
2026-07-25 15:32:53 +03:00

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="Поиск">&#128269;</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 %}