447 lines
15 KiB
HTML
447 lines
15 KiB
HTML
|
||
{% extends "base.html" %}
|
||
{% block body_class %}page-bg bg-cartridges{% endblock %}
|
||
{% block content %}
|
||
<style>
|
||
.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);
|
||
}
|
||
.issues-table .row-actions__inner {
|
||
max-height: 0;
|
||
}
|
||
#issueCancelCard {
|
||
background: transparent;
|
||
border: none;
|
||
}
|
||
#issueCancelCard .card-body {
|
||
background: transparent;
|
||
padding-left: 0;
|
||
padding-right: 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(700px, 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;
|
||
}
|
||
</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') == 'admin' %}
|
||
<form method="post" action="/orders/low_stock">
|
||
<button class="btn btn-warning" type="submit">Добавить в заказы (≤ 1)</button>
|
||
</form>
|
||
<button class="btn btn-outline-secondary" type="button" id="toggleIssueCancel">Отменить выдачу</button>
|
||
<button class="btn btn-outline-primary" type="button" id="toggleOrderForm">Заказать картридж</button>
|
||
{% endif %}
|
||
<a class="btn btn-success" href="/report/cartridges.xlsx">Экспорт в Excel</a>
|
||
</div>
|
||
</div>
|
||
|
||
{% if session.get('role') != 'admin' %}
|
||
<div class="alert alert-secondary">Режим просмотра: изменения доступны только администратору.</div>
|
||
{% endif %}
|
||
|
||
{% if session.get('role') == 'admin' %}
|
||
<div class="card mb-3">
|
||
<div class="card-body">
|
||
<h5 class="card-title mb-2">Выдача по штрихкоду (сканер)</h5>
|
||
<form method="post" action="/issue" class="row g-2 align-items-center" id="issueByBarcodeForm">
|
||
<div class="col-md-3">
|
||
<input name="barcode" class="form-control" placeholder="Сканируй штрихкод" id="issue_barcode" autocomplete="off" autofocus>
|
||
<div class="form-text" id="issue_hint"></div>
|
||
</div>
|
||
<div class="col-md-3">
|
||
<input class="form-control" id="issue_model" placeholder="Модель" readonly>
|
||
</div>
|
||
<div class="col-md-2">
|
||
<input name="quantity" class="form-control" placeholder="Кол-во" value="1">
|
||
</div>
|
||
<div class="col-md-2">
|
||
<select name="cabinet" class="form-select">
|
||
<option value="">Выберите кабинет...</option>
|
||
{% for c in cabinets %}
|
||
<option value="{{ c }}">{{ c }}</option>
|
||
{% endfor %}
|
||
</select>
|
||
</div>
|
||
<div class="col-md-2 d-grid">
|
||
<button class="btn btn-danger">Выдать</button>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="card mb-3 d-none" id="orderFormCard">
|
||
<div class="card-body">
|
||
<h5 class="card-title mb-2">Заказ картриджа</h5>
|
||
<form method="post" action="/order_by_model" class="row g-2 align-items-center">
|
||
<div class="col-md-5">
|
||
<input name="model" class="form-control" placeholder="Модель картриджа">
|
||
</div>
|
||
<div class="col-md-3">
|
||
<input name="quantity" class="form-control" placeholder="Кол-во" value="1">
|
||
</div>
|
||
<div class="col-md-2 d-grid">
|
||
<button class="btn btn-success">Заказать</button>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="card mb-3 d-none" id="issueCancelCard">
|
||
<div class="card-body">
|
||
<h5 class="card-title mb-2">Отмена выдачи</h5>
|
||
<div class="text-muted mb-2">Выдачи за последние 7 дней (нажмите на строку, чтобы отменить)</div>
|
||
<div class="table-responsive">
|
||
<table class="excel-table issues-table">
|
||
<thead>
|
||
<tr>
|
||
<th>Дата</th><th>Штрихкод</th><th>Модель</th><th>Кол-во</th><th>Кабинет</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody id="issuesTableBody">
|
||
<tr><td colspan="5">Загрузка...</td></tr>
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<form method="post" action="/add" class="row g-2 mb-3">
|
||
<input name="barcode" class="form-control col" placeholder="Штрихкод">
|
||
<input name="model" class="form-control col" placeholder="Модель">
|
||
<input name="quantity" class="form-control col" placeholder="Кол-во">
|
||
<button class="btn btn-success col">Добавить</button>
|
||
</form>
|
||
|
||
<script>
|
||
// Автоподсказка: по введённому/отсканированному штрихкоду показываем модель и остаток.
|
||
(function() {
|
||
const barcodeEl = document.getElementById('issue_barcode');
|
||
const modelEl = document.getElementById('issue_model');
|
||
const hintEl = document.getElementById('issue_hint');
|
||
|
||
if (!barcodeEl || !modelEl || !hintEl) return;
|
||
|
||
let last = '';
|
||
async function lookup() {
|
||
const barcode = (barcodeEl.value || '').trim();
|
||
if (!barcode || barcode === last) return;
|
||
last = barcode;
|
||
|
||
modelEl.value = '';
|
||
hintEl.textContent = 'Ищу...';
|
||
|
||
try {
|
||
const res = await fetch(`/api/cartridge?barcode=${encodeURIComponent(barcode)}`);
|
||
if (!res.ok) {
|
||
hintEl.textContent = 'Ошибка запроса';
|
||
return;
|
||
}
|
||
const data = await res.json();
|
||
if (!data.found) {
|
||
hintEl.textContent = 'Не найдено (проверь штрихкод)';
|
||
return;
|
||
}
|
||
modelEl.value = data.model || '';
|
||
hintEl.textContent = `Остаток: ${data.quantity}`;
|
||
} catch (e) {
|
||
hintEl.textContent = 'Ошибка сети';
|
||
}
|
||
}
|
||
|
||
// Сканер обычно вводит строку очень быстро и жмёт Enter.
|
||
barcodeEl.addEventListener('input', () => {
|
||
// небольшой debounce
|
||
window.clearTimeout(window.__barcodeTimer);
|
||
window.__barcodeTimer = window.setTimeout(lookup, 120);
|
||
});
|
||
barcodeEl.addEventListener('keydown', (e) => {
|
||
if (e.key === 'Enter') {
|
||
// не мешаем сабмиту, но успеваем обновить подсказку
|
||
lookup();
|
||
}
|
||
});
|
||
})();
|
||
</script>
|
||
|
||
<script>
|
||
(function() {
|
||
const toggleBtn = document.getElementById('toggleOrderForm');
|
||
const card = document.getElementById('orderFormCard');
|
||
if (!toggleBtn || !card) return;
|
||
toggleBtn.addEventListener('click', () => {
|
||
card.classList.toggle('d-none');
|
||
});
|
||
})();
|
||
</script>
|
||
|
||
<script>
|
||
(function() {
|
||
const toggleBtn = document.getElementById('toggleIssueCancel');
|
||
const card = document.getElementById('issueCancelCard');
|
||
const tbody = document.getElementById('issuesTableBody');
|
||
if (!toggleBtn || !card || !tbody) return;
|
||
|
||
let loaded = false;
|
||
|
||
function clearActionRow() {
|
||
const existing = tbody.querySelector('.row-actions');
|
||
if (existing) existing.remove();
|
||
}
|
||
|
||
function renderRows(items) {
|
||
tbody.innerHTML = '';
|
||
if (!items.length) {
|
||
tbody.innerHTML = '<tr><td colspan="5">Нет выдач за последнюю неделю</td></tr>';
|
||
return;
|
||
}
|
||
items.forEach((item) => {
|
||
const tr = document.createElement('tr');
|
||
tr.className = 'js-issue-row';
|
||
tr.dataset.movementId = item.id;
|
||
tr.dataset.barcode = item.barcode || '';
|
||
tr.dataset.model = item.model || '';
|
||
tr.dataset.qty = item.quantity || 0;
|
||
tr.dataset.cabinet = item.cabinet || '';
|
||
tr.innerHTML = `<td>${item.date || ''}</td>
|
||
<td>${item.barcode || ''}</td>
|
||
<td>${item.model || ''}</td>
|
||
<td>${item.quantity || ''}</td>
|
||
<td>${item.cabinet || ''}</td>`;
|
||
tbody.appendChild(tr);
|
||
});
|
||
|
||
const rows = tbody.querySelectorAll('.js-issue-row');
|
||
rows.forEach((row) => {
|
||
row.addEventListener('click', (e) => {
|
||
if (e.target.closest('button, input, select, textarea, a')) return;
|
||
clearActionRow();
|
||
const movementId = row.dataset.movementId || '';
|
||
if (!movementId) return;
|
||
const actionRow = document.createElement('tr');
|
||
actionRow.className = 'row-actions show';
|
||
actionRow.innerHTML = `<td colspan="5">
|
||
<div class="row-actions__inner" style="max-height:60px;opacity:1;transform:translateY(0)">
|
||
<form method="post" action="/cartridges/issue/cancel" class="d-inline">
|
||
<input type="hidden" name="movement_id" value="${movementId}">
|
||
<button class="btn btn-sm btn-outline-danger" type="submit">Отменить выдачу</button>
|
||
</form>
|
||
</div>
|
||
</td>`;
|
||
row.insertAdjacentElement('afterend', actionRow);
|
||
});
|
||
});
|
||
}
|
||
|
||
async function loadIssues() {
|
||
tbody.innerHTML = '<tr><td colspan="5">Загрузка...</td></tr>';
|
||
try {
|
||
const res = await fetch('/api/cartridge/issues/recent');
|
||
if (!res.ok) throw new Error('bad status');
|
||
const data = await res.json();
|
||
renderRows((data && data.items) || []);
|
||
} catch (e) {
|
||
tbody.innerHTML = '<tr><td colspan="5">Ошибка загрузки</td></tr>';
|
||
}
|
||
}
|
||
|
||
toggleBtn.addEventListener('click', () => {
|
||
card.classList.toggle('d-none');
|
||
if (!card.classList.contains('d-none') && !loaded) {
|
||
loaded = true;
|
||
loadIssues();
|
||
}
|
||
});
|
||
|
||
tbody.addEventListener('submit', async (e) => {
|
||
const form = e.target.closest('form');
|
||
if (!form || !form.action.includes('/cartridges/issue/cancel')) return;
|
||
e.preventDefault();
|
||
const fd = new FormData(form);
|
||
try {
|
||
const res = await fetch(form.action, { method: 'POST', body: fd });
|
||
if (!res.ok) throw new Error('bad status');
|
||
await loadIssues();
|
||
} catch (err) {
|
||
alert('Не удалось отменить выдачу');
|
||
}
|
||
});
|
||
})();
|
||
</script>
|
||
{% endif %}
|
||
|
||
{% if session.get('role') == 'admin' %}
|
||
<script>
|
||
window.addEventListener('DOMContentLoaded', () => {
|
||
const rows = document.querySelectorAll('.js-cartridge-row');
|
||
if (!rows.length) return;
|
||
const modal = document.getElementById('cartridgeEditModal');
|
||
const form = document.getElementById('cartridgeEditForm');
|
||
if (!modal || !form) return;
|
||
const modalFields = {
|
||
barcode: document.getElementById('cartModalBarcode'),
|
||
model: document.getElementById('cartModalModel'),
|
||
quantity: document.getElementById('cartModalQuantity'),
|
||
};
|
||
|
||
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 quantity = row.getAttribute('data-quantity') || '';
|
||
if (!barcode) return false;
|
||
modalFields.barcode.value = barcode;
|
||
modalFields.model.value = model;
|
||
modalFields.quantity.value = quantity;
|
||
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="3">
|
||
<div class="row-actions__inner">
|
||
<button type="button" class="btn btn-sm btn-outline-primary js-open-cart-modal">Редактировать</button>
|
||
<form method="post" action="/cartridges/delete" class="d-inline ms-2">
|
||
<input type="hidden" name="barcode" value="${barcode}">
|
||
<button class="btn btn-sm btn-outline-danger" type="submit">Удалить</button>
|
||
</form>
|
||
</div>
|
||
</td>`;
|
||
row.insertAdjacentElement('afterend', actionRow);
|
||
const btn = actionRow.querySelector('.js-open-cart-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();
|
||
});
|
||
});
|
||
});
|
||
</script>
|
||
{% endif %}
|
||
|
||
<table class="table table-striped">
|
||
<tr>
|
||
<th>Штрихкод</th><th>Модель</th><th>Остаток</th>
|
||
</tr>
|
||
{% for b,m,q,minq in items %}
|
||
<tr class="js-cartridge-row {{ 'table-danger' if q < minq else '' }}"
|
||
data-barcode="{{ b }}"
|
||
data-model="{{ m or '' }}"
|
||
data-quantity="{{ q }}">
|
||
<td>{{ b }}</td><td>{{ m }}</td><td>{{ q }}</td>
|
||
</tr>
|
||
{% endfor %}
|
||
</table>
|
||
|
||
{% if session.get('role') == 'admin' %}
|
||
<div class="edit-modal" id="cartridgeEditModal" 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="/cartridges/edit" id="cartridgeEditForm">
|
||
<div class="row g-2">
|
||
<div class="col-md-4">
|
||
<input name="barcode" id="cartModalBarcode" class="form-control" placeholder="Штрихкод" readonly>
|
||
</div>
|
||
<div class="col-md-4">
|
||
<input name="model" id="cartModalModel" class="form-control" placeholder="Модель">
|
||
</div>
|
||
<div class="col-md-4">
|
||
<input name="quantity" id="cartModalQuantity" 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 %}
|
||
{% endblock %}
|