557 lines
18 KiB
HTML
557 lines
18 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);
|
||
}
|
||
.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;
|
||
}
|
||
.cartridges-page .btn,
|
||
.cartridges-page .btn:hover,
|
||
.cartridges-page .btn:focus {
|
||
background: #d9d9d9;
|
||
border: 1px solid #000;
|
||
color: #000;
|
||
padding: 2px 14px;
|
||
font-family: 'Chicago Regular', sans-serif;
|
||
font-size: 0.85rem;
|
||
line-height: 1.2;
|
||
border-radius: 6px;
|
||
box-shadow: inset 1px 1px 0 #f7f7f7, inset -1px -1px 0 #8a8a8a;
|
||
text-decoration: none;
|
||
min-height: 32px;
|
||
display: inline-flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
}
|
||
.cartridges-page .btn:active {
|
||
box-shadow: inset -1px -1px 0 #f7f7f7, inset 1px 1px 0 #8a8a8a;
|
||
}
|
||
.cartridges-page .excel-table {
|
||
width: 100%;
|
||
border-collapse: collapse;
|
||
background: #f8f8f8;
|
||
font-family: 'Chicago Regular', sans-serif;
|
||
font-size: 0.88rem;
|
||
}
|
||
.cartridges-page .excel-table th,
|
||
.cartridges-page .excel-table td {
|
||
border: 1px solid #7a7a7a;
|
||
padding: 6px 8px;
|
||
background: #ffffff;
|
||
}
|
||
.cartridges-page .excel-table th {
|
||
background: linear-gradient(#d9d9d9, #c0c0c0);
|
||
color: #111;
|
||
font-weight: 600;
|
||
text-align: left;
|
||
box-shadow: inset 1px 1px 0 #f7f7f7, inset -1px -1px 0 #9a9a9a;
|
||
}
|
||
.cartridges-page .excel-table tr.table-danger td {
|
||
background: #f5c2c7;
|
||
}
|
||
.cartridges-page .form-control,
|
||
.cartridges-page .form-select {
|
||
border: 1px solid #7a7a7a;
|
||
border-radius: 6px;
|
||
background: #ffffff;
|
||
font-family: 'Chicago Regular', sans-serif;
|
||
font-size: 0.88rem;
|
||
padding: 4px 6px;
|
||
box-shadow: inset 1px 1px 0 #f2f2f2, inset -1px -1px 0 #b3b3b3;
|
||
}
|
||
.cartridges-page .add-form {
|
||
column-gap: 10px;
|
||
}
|
||
.cartridges-page .add-form .qty-col {
|
||
flex: 0 0 120px;
|
||
max-width: 120px;
|
||
}
|
||
#issueByBarcodeForm .form-control,
|
||
#issueByBarcodeForm .form-select,
|
||
#issueByBarcodeForm .btn {
|
||
height: 34px;
|
||
line-height: 1.2;
|
||
margin-top: 0;
|
||
}
|
||
#issueByBarcodeForm .form-text {
|
||
margin-top: 2px;
|
||
}
|
||
.issues-table .row-actions__inner {
|
||
max-height: 0;
|
||
}
|
||
#issueCancelCard {
|
||
background: transparent;
|
||
border: none;
|
||
}
|
||
#issueCancelCard .card-body {
|
||
background: transparent;
|
||
padding-left: 0;
|
||
padding-right: 0;
|
||
}
|
||
</style>
|
||
<div class="cartridges-page">
|
||
<div class="d-flex align-items-center justify-content-between mb-2">
|
||
<h3 class="mb-0" style="font-family: 'Chicago Regular', sans-serif;">Картриджи</h3>
|
||
<div class="d-flex gap-2">
|
||
{% if session.get('role') in ('admin','storekeeper') %}
|
||
<button class="btn btn-outline-primary" type="button" id="toggleSearchForm" aria-label="Поиск">🔍</button>
|
||
<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>
|
||
|
||
<div class="card mb-3 d-none" id="searchFormCard">
|
||
<div class="card-body">
|
||
<form class="row g-2 align-items-center" id="searchForm">
|
||
<div class="col-md-6">
|
||
<input id="searchModelInput" 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="searchClearBtn">Очистить</button>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
</div>
|
||
|
||
{% if session.get('role') not in ('admin','storekeeper') %}
|
||
<div class="alert alert-secondary">Режим просмотра: изменения доступны только администратору.</div>
|
||
{% endif %}
|
||
|
||
<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 add-form">
|
||
<div class="col">
|
||
<input name="barcode" class="form-control" placeholder="Штрихкод">
|
||
</div>
|
||
<div class="col">
|
||
<input name="model" class="form-control" placeholder="Модель">
|
||
</div>
|
||
<div class="qty-col">
|
||
<input name="quantity" class="form-control" placeholder="Кол-во">
|
||
</div>
|
||
<div class="col">
|
||
<button class="btn btn-success w-100">Добавить</button>
|
||
</div>
|
||
</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>
|
||
|
||
<script>
|
||
window.addEventListener('DOMContentLoaded', () => {
|
||
const toggleBtn = document.getElementById('toggleSearchForm');
|
||
const card = document.getElementById('searchFormCard');
|
||
const form = document.getElementById('searchForm');
|
||
const input = document.getElementById('searchModelInput');
|
||
const clearBtn = document.getElementById('searchClearBtn');
|
||
if (!toggleBtn || !card || !form || !input) return;
|
||
|
||
const rows = () => document.querySelectorAll('.js-cartridge-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>
|
||
|
||
{% if session.get('role') in ('admin','storekeeper') %}
|
||
<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="excel-table">
|
||
<tr>
|
||
<th>Штрихкод</th><th>Модель</th><th>Остаток</th>
|
||
</tr>
|
||
{% for b,m,q,minq in items %}
|
||
{% set model_key = (m or '')|trim|lower %}
|
||
<tr class="js-cartridge-row {{ 'table-danger' if (q < minq) or (model_key in orphan_model_keys) 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') in ('admin','storekeeper') %}
|
||
<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 %}
|
||
</div>
|
||
{% endblock %}
|