Spaces:
Running
Running
<html lang="uk"> | |
<head> | |
<meta charset="UTF-8"> | |
<meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
<title>Інвентаризація</title> | |
<script src="https://cdn.tailwindcss.com"></script> | |
<script src="https://cdnjs.cloudflare.com/ajax/libs/xlsx/0.18.5/xlsx.full.min.js"></script> | |
<style> | |
:root { | |
--color-match: #10b981; | |
--color-missing: #ef4444; | |
--color-excess: #f59e0b; | |
--color-unchecked: #9ca3af; | |
} | |
@media (prefers-color-scheme: dark) { | |
:root { | |
--color-bg: #1f2937; | |
--color-text: #f3f4f6; | |
--color-card: #374151; | |
} | |
} | |
@media (prefers-color-scheme: light) { | |
:root { | |
--color-bg: #f3f4f6; | |
--color-text: #111827; | |
--color-card: #ffffff; | |
} | |
} | |
body { | |
background-color: var(--color-bg); | |
color: var(--color-text); | |
min-height: 100vh; | |
padding-bottom: env(safe-area-inset-bottom); | |
} | |
.tab-content { | |
display: none; | |
} | |
.tab-content.active { | |
display: block; | |
} | |
.status-match { | |
background-color: var(--color-match); | |
} | |
.status-missing { | |
background-color: var(--color-missing); | |
} | |
.status-excess { | |
background-color: var(--color-excess); | |
} | |
.status-unchecked { | |
background-color: var(--color-unchecked); | |
} | |
.toast { | |
animation: fadeInOut 3s ease-in-out forwards; | |
} | |
@keyframes fadeInOut { | |
0% { opacity: 0; transform: translateY(20px); } | |
10% { opacity: 1; transform: translateY(0); } | |
90% { opacity: 1; transform: translateY(0); } | |
100% { opacity: 0; transform: translateY(-20px); } | |
} | |
.progress-bar { | |
height: 1rem; | |
border-radius: 0.5rem; | |
background-color: #e5e7eb; | |
overflow: hidden; | |
} | |
.progress-fill { | |
height: 100%; | |
background: linear-gradient(90deg, var(--color-match), var(--color-excess)); | |
transition: width 0.3s ease; | |
} | |
input[type="number"]::-webkit-inner-spin-button, | |
input[type="number"]::-webkit-outer-spin-button { | |
-webkit-appearance: none; | |
margin: 0; | |
} | |
input[type="number"] { | |
-moz-appearance: textfield; | |
} | |
</style> | |
</head> | |
<body class="font-sans"> | |
<div class="container mx-auto px-4 py-6 max-w-4xl"> | |
<!-- Header --> | |
<header class="mb-8"> | |
<h1 class="text-3xl font-bold text-center">Інвентаризація v1.0</h1> | |
</header> | |
<!-- Tabs Navigation --> | |
<div class="flex border-b border-gray-300 mb-6"> | |
<button class="tab-btn py-2 px-4 font-medium border-b-2 border-transparent hover:border-gray-400 transition" data-tab="scan">Сканування</button> | |
<button class="tab-btn py-2 px-4 font-medium border-b-2 border-transparent hover:border-gray-400 transition" data-tab="list">Список товарів</button> | |
<button class="tab-btn py-2 px-4 font-medium border-b-2 border-transparent hover:border-gray-400 transition" data-tab="results">Результати</button> | |
</div> | |
<!-- Toast Notifications --> | |
<div id="toast-container" class="fixed bottom-4 right-4 z-50 space-y-2"></div> | |
<!-- Scan Tab Content --> | |
<div id="scan" class="tab-content active"> | |
<div class="space-y-6"> | |
<!-- Input Fields --> | |
<div class="space-y-4"> | |
<div> | |
<label for="sku-input" class="block text-sm font-medium mb-1">Артикул товару</label> | |
<div class="flex"> | |
<input type="text" id="sku-input" class="flex-1 px-4 py-2 border border-gray-300 rounded-l-lg focus:outline-none focus:ring-2 focus:ring-blue-500" placeholder="Введіть або відскануйте артикул" inputmode="numeric"> | |
<button id="sku-submit" class="px-4 py-2 bg-blue-500 text-white rounded-r-lg hover:bg-blue-600 transition">➜</button> | |
</div> | |
</div> | |
<div> | |
<label for="quantity-input" class="block text-sm font-medium mb-1">Зміна кількості</label> | |
<div class="flex"> | |
<input type="number" id="quantity-input" disabled class="flex-1 px-4 py-2 border border-gray-300 rounded-l-lg focus:outline-none focus:ring-2 focus:ring-blue-500" placeholder="Число(+) або 0+Число(-)"> | |
<button id="quantity-submit" disabled class="px-4 py-2 bg-blue-500 text-white rounded-r-lg hover:bg-blue-600 transition">➜</button> | |
</div> | |
<p class="text-xs text-gray-500 mt-1">Введіть число для додавання або 0+число для віднімання (напр. 5 або 05)</p> | |
</div> | |
</div> | |
<!-- Item Card --> | |
<div id="item-card" class="bg-white/10 p-4 rounded-lg shadow hidden"> | |
<div class="flex justify-between items-start mb-2"> | |
<h3 class="text-lg font-semibold" id="item-name">Назва товару</h3> | |
<span id="item-status" class="px-2 py-1 rounded-full text-xs font-medium text-white">Не перевірено</span> | |
</div> | |
<div class="grid grid-cols-2 gap-4"> | |
<div> | |
<p class="text-sm text-gray-400">Артикул</p> | |
<p id="item-sku" class="font-mono">123456789</p> | |
</div> | |
<div> | |
<p class="text-sm text-gray-400">Очікувано</p> | |
<p id="item-expected">0</p> | |
</div> | |
<div> | |
<p class="text-sm text-gray-400">Фактично</p> | |
<p id="item-actual">0</p> | |
</div> | |
<div> | |
<p class="text-sm text-gray-400">Різниця</p> | |
<p id="item-difference">0</p> | |
</div> | |
</div> | |
</div> | |
<!-- Action Buttons --> | |
<div class="flex flex-wrap gap-2 mt-6"> | |
<button id="open-file-btn" class="px-4 py-2 bg-green-500 text-white rounded-lg hover:bg-green-600 transition flex items-center gap-2"> | |
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"> | |
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12" /> | |
</svg> | |
Відкрити файл | |
</button> | |
<button id="save-results-btn" disabled class="px-4 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600 transition flex items-center gap-2"> | |
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"> | |
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 7H5a2 2 0 00-2 2v9a2 2 0 002 2h14a2 2 0 002-2V9a2 2 0 00-2-2h-3m-1 4l-3 3m0 0l-3-3m3 3V4" /> | |
</svg> | |
Зберегти інвентаризацію | |
</button> | |
<button id="clear-data-btn" class="px-4 py-2 bg-red-500 text-white rounded-lg hover:bg-red-600 transition flex items-center gap-2"> | |
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"> | |
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16" /> | |
</svg> | |
Очистити дані | |
</button> | |
</div> | |
<input type="file" id="file-input" accept=".xlsx,.xls" class="hidden"> | |
</div> | |
</div> | |
<!-- List Tab Content --> | |
<div id="list" class="tab-content"> | |
<div class="space-y-6"> | |
<!-- File Info --> | |
<div id="file-info" class="bg-white/10 p-4 rounded-lg shadow hidden"> | |
<h3 class="font-medium mb-2">Інформація про файл</h3> | |
<div class="grid grid-cols-2 gap-4"> | |
<div> | |
<p class="text-sm text-gray-400">Назва файлу</p> | |
<p id="file-name">-</p> | |
</div> | |
<div> | |
<p class="text-sm text-gray-400">Дата завантаження</p> | |
<p id="file-date">-</p> | |
</div> | |
</div> | |
</div> | |
<!-- Progress Stats --> | |
<div class="bg-white/10 p-4 rounded-lg shadow"> | |
<h3 class="font-medium mb-2">Прогрес інвентаризації</h3> | |
<div class="progress-bar mb-2"> | |
<div id="progress-fill" class="progress-fill" style="width: 0%"></div> | |
</div> | |
<div class="grid grid-cols-2 md:grid-cols-5 gap-2 text-sm"> | |
<div class="bg-gray-100/10 p-2 rounded text-center"> | |
<p class="text-gray-400">Всього</p> | |
<p id="total-count" class="font-bold">0</p> | |
</div> | |
<div class="bg-gray-100/10 p-2 rounded text-center"> | |
<p class="text-gray-400">Перевірено</p> | |
<p id="checked-count" class="font-bold">0</p> | |
</div> | |
<div class="bg-green-500/20 p-2 rounded text-center"> | |
<p class="text-gray-400">Відповідність</p> | |
<p id="match-count" class="font-bold">0</p> | |
</div> | |
<div class="bg-red-500/20 p-2 rounded text-center"> | |
<p class="text-gray-400">Нестача</p> | |
<p id="missing-count" class="font-bold">0</p> | |
</div> | |
<div class="bg-yellow-500/20 p-2 rounded text-center"> | |
<p class="text-gray-400">Надлишок</p> | |
<p id="excess-count" class="font-bold">0</p> | |
</div> | |
</div> | |
</div> | |
<!-- Filters --> | |
<div class="grid grid-cols-1 md:grid-cols-2 gap-4"> | |
<div> | |
<label for="status-filter" class="block text-sm font-medium mb-1">Фільтр за статусом</label> | |
<select id="status-filter" class="w-full px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500"> | |
<option value="all">Всі</option> | |
<option value="match">Відповідність</option> | |
<option value="missing">Нестача</option> | |
<option value="excess">Надлишок</option> | |
<option value="unchecked">Не перевірено</option> | |
</select> | |
</div> | |
<div> | |
<label for="search-input" class="block text-sm font-medium mb-1">Пошук за назвою</label> | |
<div class="flex"> | |
<input type="text" id="search-input" class="flex-1 px-4 py-2 border border-gray-300 rounded-lg focus:outline-none focus:ring-2 focus:ring-blue-500" placeholder="Введіть назву товару"> | |
<button id="clear-search" class="ml-2 px-3 py-2 bg-gray-200 text-gray-700 rounded-lg hover:bg-gray-300 transition"> | |
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" fill="none" viewBox="0 0 24 24" stroke="currentColor"> | |
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" /> | |
</svg> | |
</button> | |
</div> | |
</div> | |
</div> | |
<!-- Items Table --> | |
<div class="overflow-x-auto"> | |
<table id="items-table" class="w-full border-collapse"> | |
<thead> | |
<tr class="border-b border-gray-300"> | |
<th class="px-4 py-2 text-left">Статус</th> | |
<th class="px-4 py-2 text-left">Артикул</th> | |
<th class="px-4 py-2 text-left">Назва</th> | |
<th class="px-4 py-2 text-right">Очікувано</th> | |
<th class="px-4 py-2 text-right">Фактично</th> | |
</tr> | |
</thead> | |
<tbody id="items-table-body"> | |
<!-- Items will be inserted here dynamically --> | |
</tbody> | |
</table> | |
</div> | |
<div id="no-items-message" class="text-center py-8 text-gray-500"> | |
<p>Немає товарів для відображення. Завантажте файл з даними.</p> | |
</div> | |
</div> | |
</div> | |
<!-- Results Tab Content --> | |
<div id="results" class="tab-content"> | |
<div class="space-y-6"> | |
<div class="bg-white/10 p-4 rounded-lg shadow"> | |
<h2 class="text-xl font-bold mb-2">Результати інвентаризації</h2> | |
<p id="results-summary" class="text-gray-400">Знайдено 0 розбіжностей</p> | |
</div> | |
<!-- Results Table --> | |
<div class="overflow-x-auto"> | |
<table id="results-table" class="w-full border-collapse"> | |
<thead> | |
<tr class="border-b border-gray-300"> | |
<th class="px-4 py-2 text-left">Статус</th> | |
<th class="px-4 py-2 text-left">Артикул</th> | |
<th class="px-4 py-2 text-left">Назва</th> | |
<th class="px-4 py-2 text-right">Очікувано</th> | |
<th class="px-4 py-2 text-right">Фактично</th> | |
<th class="px-4 py-2 text-right">Різниця</th> | |
</tr> | |
</thead> | |
<tbody id="results-table-body"> | |
<!-- Results will be inserted here dynamically --> | |
</tbody> | |
</table> | |
</div> | |
<div id="no-results-message" class="text-center py-8 text-gray-500"> | |
<p>Немає розбіжностей для відображення.</p> | |
</div> | |
</div> | |
</div> | |
</div> | |
<script> | |
// Global variables | |
let items = []; | |
let currentItem = null; | |
let fileMetadata = { | |
fileName: null, | |
fileDate: null, | |
totalItems: 0 | |
}; | |
// DOM elements | |
const skuInput = document.getElementById('sku-input'); | |
const skuSubmit = document.getElementById('sku-submit'); | |
const quantityInput = document.getElementById('quantity-input'); | |
const quantitySubmit = document.getElementById('quantity-submit'); | |
const itemCard = document.getElementById('item-card'); | |
const itemName = document.getElementById('item-name'); | |
const itemSku = document.getElementById('item-sku'); | |
const itemExpected = document.getElementById('item-expected'); | |
const itemActual = document.getElementById('item-actual'); | |
const itemDifference = document.getElementById('item-difference'); | |
const itemStatus = document.getElementById('item-status'); | |
const openFileBtn = document.getElementById('open-file-btn'); | |
const fileInput = document.getElementById('file-input'); | |
const saveResultsBtn = document.getElementById('save-results-btn'); | |
const clearDataBtn = document.getElementById('clear-data-btn'); | |
const fileInfo = document.getElementById('file-info'); | |
const fileName = document.getElementById('file-name'); | |
const fileDate = document.getElementById('file-date'); | |
const progressFill = document.getElementById('progress-fill'); | |
const totalCount = document.getElementById('total-count'); | |
const checkedCount = document.getElementById('checked-count'); | |
const matchCount = document.getElementById('match-count'); | |
const missingCount = document.getElementById('missing-count'); | |
const excessCount = document.getElementById('excess-count'); | |
const statusFilter = document.getElementById('status-filter'); | |
const searchInput = document.getElementById('search-input'); | |
const clearSearch = document.getElementById('clear-search'); | |
const itemsTableBody = document.getElementById('items-table-body'); | |
const noItemsMessage = document.getElementById('no-items-message'); | |
const resultsTableBody = document.getElementById('results-table-body'); | |
const noResultsMessage = document.getElementById('no-results-message'); | |
const resultsSummary = document.getElementById('results-summary'); | |
const tabContents = document.querySelectorAll('.tab-content'); | |
const tabButtons = document.querySelectorAll('.tab-btn'); | |
const toastContainer = document.getElementById('toast-container'); | |
// Initialize IndexedDB | |
let db; | |
const DB_NAME = 'inventoryDB'; | |
const DB_VERSION = 1; | |
const ITEMS_STORE = 'items'; | |
const METADATA_STORE = 'metadata'; | |
function openDB() { | |
return new Promise((resolve, reject) => { | |
const request = indexedDB.open(DB_NAME, DB_VERSION); | |
request.onupgradeneeded = (event) => { | |
const db = event.target.result; | |
if (!db.objectStoreNames.contains(ITEMS_STORE)) { | |
db.createObjectStore(ITEMS_STORE, { keyPath: 'sku' }); | |
} | |
if (!db.objectStoreNames.contains(METADATA_STORE)) { | |
db.createObjectStore(METADATA_STORE, { keyPath: 'id' }); | |
} | |
}; | |
request.onsuccess = (event) => { | |
db = event.target.result; | |
resolve(db); | |
}; | |
request.onerror = (event) => { | |
console.error('Error opening DB', event.target.error); | |
reject(event.target.error); | |
}; | |
}); | |
} | |
function saveItemToDB(item) { | |
return new Promise((resolve, reject) => { | |
const transaction = db.transaction([ITEMS_STORE], 'readwrite'); | |
const store = transaction.objectStore(ITEMS_STORE); | |
const request = store.put(item); | |
request.onsuccess = () => resolve(); | |
request.onerror = (event) => reject(event.target.error); | |
}); | |
} | |
function saveAllItemsToDB(items) { | |
return new Promise((resolve, reject) => { | |
const transaction = db.transaction([ITEMS_STORE], 'readwrite'); | |
const store = transaction.objectStore(ITEMS_STORE); | |
// Clear existing items | |
const clearRequest = store.clear(); | |
clearRequest.onsuccess = () => { | |
// Add all new items | |
const requests = items.map(item => { | |
return new Promise((res, rej) => { | |
const addRequest = store.add(item); | |
addRequest.onsuccess = () => res(); | |
addRequest.onerror = (event) => rej(event.target.error); | |
}); | |
}); | |
Promise.all(requests) | |
.then(() => resolve()) | |
.catch(error => reject(error)); | |
}; | |
clearRequest.onerror = (event) => reject(event.target.error); | |
}); | |
} | |
function getAllItemsFromDB() { | |
return new Promise((resolve, reject) => { | |
const transaction = db.transaction([ITEMS_STORE], 'readonly'); | |
const store = transaction.objectStore(ITEMS_STORE); | |
const request = store.getAll(); | |
request.onsuccess = (event) => resolve(event.target.result || []); | |
request.onerror = (event) => reject(event.target.error); | |
}); | |
} | |
function clearItemsDB() { | |
return new Promise((resolve, reject) => { | |
const transaction = db.transaction([ITEMS_STORE], 'readwrite'); | |
const store = transaction.objectStore(ITEMS_STORE); | |
const request = store.clear(); | |
request.onsuccess = () => resolve(); | |
request.onerror = (event) => reject(event.target.error); | |
}); | |
} | |
function saveMetadataToDB(metadata) { | |
return new Promise((resolve, reject) => { | |
const transaction = db.transaction([METADATA_STORE], 'readwrite'); | |
const store = transaction.objectStore(METADATA_STORE); | |
const request = store.put({ id: 'current', ...metadata }); | |
request.onsuccess = () => resolve(); | |
request.onerror = (event) => reject(event.target.error); | |
}); | |
} | |
function getMetadataFromDB() { | |
return new Promise((resolve, reject) => { | |
const transaction = db.transaction([METADATA_STORE], 'readonly'); | |
const store = transaction.objectStore(METADATA_STORE); | |
const request = store.get('current'); | |
request.onsuccess = (event) => resolve(event.target.result || null); | |
request.onerror = (event) => reject(event.target.error); | |
}); | |
} | |
function clearMetadataDB() { | |
return new Promise((resolve, reject) => { | |
const transaction = db.transaction([METADATA_STORE], 'readwrite'); | |
const store = transaction.objectStore(METADATA_STORE); | |
const request = store.clear(); | |
request.onsuccess = () => resolve(); | |
request.onerror = (event) => reject(event.target.error); | |
}); | |
} | |
// Initialize the app | |
async function initializeApp() { | |
try { | |
await openDB(); | |
// Load saved data | |
const savedItems = await getAllItemsFromDB(); | |
const savedMetadata = await getMetadataFromDB(); | |
if (savedItems.length > 0) { | |
items = savedItems; | |
fileMetadata = savedMetadata || fileMetadata; | |
updateUI(); | |
showToast('Відновлено попередню сесію', 'success'); | |
} | |
// Set up event listeners | |
setupEventListeners(); | |
// Focus on SKU input | |
skuInput.focus(); | |
} catch (error) { | |
console.error('Initialization error:', error); | |
showToast('Помилка ініціалізації додатку', 'error'); | |
} | |
} | |
// Set up event listeners | |
function setupEventListeners() { | |
// Tab switching | |
tabButtons.forEach(button => { | |
button.addEventListener('click', () => { | |
const tabId = button.getAttribute('data-tab'); | |
switchTab(tabId); | |
}); | |
}); | |
// SKU input handling | |
skuInput.addEventListener('keydown', (e) => { | |
if (e.key === 'Enter') { | |
handleSkuInput(); | |
} | |
}); | |
skuSubmit.addEventListener('click', handleSkuInput); | |
// Quantity input handling | |
quantityInput.addEventListener('keydown', (e) => { | |
if (e.key === 'Enter') { | |
handleQuantityInput(); | |
} | |
}); | |
quantitySubmit.addEventListener('click', handleQuantityInput); | |
// File handling | |
openFileBtn.addEventListener('click', () => fileInput.click()); | |
fileInput.addEventListener('change', handleFileUpload); | |
// Save results | |
saveResultsBtn.addEventListener('click', saveResultsToFile); | |
// Clear data | |
clearDataBtn.addEventListener('click', confirmClearData); | |
// Filter and search | |
statusFilter.addEventListener('change', updateUI); | |
searchInput.addEventListener('input', updateUI); | |
clearSearch.addEventListener('click', () => { | |
searchInput.value = ''; | |
updateUI(); | |
}); | |
// Click outside to hide keyboard (for mobile) | |
document.addEventListener('click', (e) => { | |
if (!['sku-input', 'quantity-input', 'search-input'].includes(e.target.id)) { | |
document.activeElement.blur(); | |
} | |
}); | |
} | |
// Switch between tabs | |
function switchTab(tabId) { | |
// Update active tab button | |
tabButtons.forEach(button => { | |
if (button.getAttribute('data-tab') === tabId) { | |
button.classList.add('border-blue-500', 'text-blue-500'); | |
button.classList.remove('border-transparent'); | |
} else { | |
button.classList.remove('border-blue-500', 'text-blue-500'); | |
button.classList.add('border-transparent'); | |
} | |
}); | |
// Update active tab content | |
tabContents.forEach(content => { | |
if (content.id === tabId) { | |
content.classList.add('active'); | |
} else { | |
content.classList.remove('active'); | |
} | |
}); | |
// Focus on appropriate input | |
if (tabId === 'scan') { | |
skuInput.focus(); | |
} else if (tabId === 'list') { | |
searchInput.focus(); | |
} | |
// Update UI for the new tab | |
updateUI(); | |
} | |
// Handle SKU input | |
function handleSkuInput() { | |
const sku = skuInput.value.trim(); | |
if (!sku) { | |
showToast('Введіть артикул товару', 'warning'); | |
vibrate(); | |
return; | |
} | |
// Find item by SKU | |
currentItem = items.find(item => item.sku === sku); | |
if (currentItem) { | |
// Item found - display it | |
displayItem(currentItem); | |
quantityInput.disabled = false; | |
quantityInput.focus(); | |
showToast(`Знайдено товар: ${currentItem.name}`, 'success'); | |
} else { | |
// Item not found - ask to create new | |
const createNew = confirm(`Товар з артикулом ${sku} не знайдено. Створити новий запис?`); | |
if (createNew) { | |
const name = prompt('Введіть назву товару:'); | |
if (name) { | |
currentItem = { | |
sku, | |
name, | |
expectedQuantity: 0, | |
actualQuantity: null, | |
status: 'unchecked' | |
}; | |
items.push(currentItem); | |
saveItemToDB(currentItem) | |
.then(() => { | |
displayItem(currentItem); | |
quantityInput.disabled = false; | |
quantityInput.focus(); | |
updateUI(); | |
showToast('Новий товар додано', 'success'); | |
}) | |
.catch(error => { | |
console.error('Error saving new item:', error); | |
showToast('Помилка збереження нового товару', 'error'); | |
}); | |
} | |
} else { | |
skuInput.focus(); | |
} | |
} | |
skuInput.value = ''; | |
} | |
// Display item in the card | |
function displayItem(item) { | |
itemName.textContent = item.name; | |
itemSku.textContent = item.sku; | |
itemExpected.textContent = item.expectedQuantity; | |
itemActual.textContent = item.actualQuantity !== null ? item.actualQuantity : '-'; | |
const difference = item.actualQuantity !== null ? | |
item.actualQuantity - item.expectedQuantity : | |
0; | |
itemDifference.textContent = difference !== 0 ? difference : '-'; | |
// Update status display | |
itemStatus.textContent = getStatusText(item.status); | |
itemStatus.className = 'px-2 py-1 rounded-full text-xs font-medium text-white'; | |
itemStatus.classList.add(`status-${item.status}`); | |
// Show the card if hidden | |
itemCard.classList.remove('hidden'); | |
} | |
// Handle quantity input | |
function handleQuantityInput() { | |
if (!currentItem) { | |
showToast('Спочатку знайдіть товар', 'warning'); | |
vibrate(); | |
return; | |
} | |
const quantityStr = quantityInput.value.trim(); | |
if (!quantityStr) { | |
showToast('Введіть кількість', 'warning'); | |
vibrate(); | |
return; | |
} | |
let newQuantity; | |
// Check if it's a subtraction (starts with 0) | |
if (quantityStr.startsWith('0') && quantityStr.length > 1) { | |
const subtractValue = parseInt(quantityStr.substring(1), 10); | |
if (isNaN(subtractValue)) { | |
showToast('Невірний формат кількості', 'error'); | |
vibrate(); | |
return; | |
} | |
newQuantity = (currentItem.actualQuantity || 0) - subtractValue; | |
} else { | |
const addValue = parseInt(quantityStr, 10); | |
if (isNaN(addValue)) { | |
showToast('Невірний формат кількості', 'error'); | |
vibrate(); | |
return; | |
} | |
newQuantity = (currentItem.actualQuantity || 0) + addValue; | |
} | |
// Update item | |
currentItem.actualQuantity = newQuantity; | |
currentItem.status = calculateStatus(currentItem); | |
// Save to DB and update UI | |
saveItemToDB(currentItem) | |
.then(() => { | |
displayItem(currentItem); | |
quantityInput.value = ''; | |
skuInput.focus(); | |
updateUI(); | |
showToast('Кількість оновлено', 'success'); | |
}) | |
.catch(error => { | |
console.error('Error updating item:', error); | |
showToast('Помилка оновлення кількості', 'error'); | |
}); | |
} | |
// Calculate item status | |
function calculateStatus(item) { | |
if (item.actualQuantity === null) { | |
return 'unchecked'; | |
} | |
if (item.actualQuantity === item.expectedQuantity) { | |
return 'match'; | |
} else if (item.actualQuantity < item.expectedQuantity) { | |
return 'missing'; | |
} else { | |
return 'excess'; | |
} | |
} | |
// Get status text | |
function getStatusText(status) { | |
const statusMap = { | |
'match': 'Відповідність', | |
'missing': 'Нестача', | |
'excess': 'Надлишок', | |
'unchecked': 'Не перевірено' | |
}; | |
return statusMap[status] || status; | |
} | |
// Handle file upload | |
function handleFileUpload(event) { | |
const file = event.target.files[0]; | |
if (!file) { | |
return; | |
} | |
const reader = new FileReader(); | |
reader.onload = function(e) { | |
try { | |
const data = new Uint8Array(e.target.result); | |
const workbook = XLSX.read(data, { type: 'array' }); | |
// Get first sheet | |
const firstSheet = workbook.Sheets[workbook.SheetNames[0]]; | |
// Convert to JSON | |
const jsonData = XLSX.utils.sheet_to_json(firstSheet); | |
if (jsonData.length === 0) { | |
showToast('Файл не містить даних', 'error'); | |
return; | |
} | |
// Check if it's a results file (has status column) | |
const isResultsFile = jsonData[0].hasOwnProperty('Статус') || | |
jsonData[0].hasOwnProperty('Status'); | |
// Process data | |
const newItems = []; | |
jsonData.forEach(row => { | |
let sku, name, expected, actual; | |
if (isResultsFile) { | |
// Results file format | |
sku = row['Артикул'] || row['SKU'] || row['Арт.'] || ''; | |
name = row['Назва'] || row['Name'] || row['Товар'] || ''; | |
expected = parseInt(row['Очікувано'] || row['Expected'] || row['Кількість'] || 0, 10); | |
actual = parseInt(row['Фактично'] || row['Actual'] || row['Факт'] || 0, 10); | |
} else { | |
// New file format (only expected quantities) | |
sku = row['Артикул'] || row['SKU'] || row['Арт.'] || ''; | |
name = row['Назва'] || row['Name'] || row['Товар'] || ''; | |
expected = parseInt(row['Кількість'] || row['Quantity'] || row['Очікувано'] || 0, 10); | |
actual = null; | |
} | |
if (sku) { | |
newItems.push({ | |
sku: sku.toString(), | |
name: name.toString(), | |
expectedQuantity: expected, | |
actualQuantity: actual, | |
status: actual !== null ? calculateStatus({ expectedQuantity: expected, actualQuantity: actual }) : 'unchecked' | |
}); | |
} | |
}); | |
if (newItems.length === 0) { | |
showToast('Не вдалося знайти товари у файлі', 'error'); | |
return; | |
} | |
// Update items and metadata | |
items = newItems; | |
fileMetadata = { | |
fileName: file.name, | |
fileDate: new Date().toLocaleString(), | |
totalItems: newItems.length | |
}; | |
// Save to DB | |
Promise.all([ | |
saveAllItemsToDB(items), | |
saveMetadataToDB(fileMetadata) | |
]) | |
.then(() => { | |
updateUI(); | |
showToast(`Завантажено ${newItems.length} товарів`, 'success'); | |
switchTab('list'); | |
}) | |
.catch(error => { | |
console.error('Error saving file data:', error); | |
showToast('Помилка збереження даних з файлу', 'error'); | |
}); | |
// Reset file input | |
event.target.value = ''; | |
} catch (error) { | |
console.error('File processing error:', error); | |
showToast('Помилка обробки файлу', 'error'); | |
} | |
}; | |
reader.onerror = function() { | |
showToast('Помилка читання файлу', 'error'); | |
}; | |
reader.readAsArrayBuffer(file); | |
} | |
// Save results to file | |
function saveResultsToFile() { | |
if (items.length === 0) { | |
showToast('Немає даних для збереження', 'warning'); | |
return; | |
} | |
// Prepare data for export | |
const exportData = items.map(item => ({ | |
'Артикул': item.sku, | |
'Назва': item.name, | |
'Очікувано': item.expectedQuantity, | |
'Фактично': item.actualQuantity !== null ? item.actualQuantity : '', | |
'Різниця': item.actualQuantity !== null ? item.actualQuantity - item.expectedQuantity : '', | |
'Статус': getStatusText(item.status) | |
})); | |
// Create workbook | |
const wb = XLSX.utils.book_new(); | |
const ws = XLSX.utils.json_to_sheet(exportData); | |
XLSX.utils.book_append_sheet(wb, ws, 'Інвентаризація'); | |
// Add metadata sheet | |
const metadata = [ | |
{ 'Параметр': 'Значення' }, | |
{ 'Параметр': 'Назва оригінального файлу', 'Значення': fileMetadata.fileName || 'Невідомо' }, | |
{ 'Параметр': 'Дата завантаження', 'Значення': fileMetadata.fileDate || 'Невідомо' }, | |
{ 'Параметр': 'Дата експорту', 'Значення': new Date().toLocaleString() }, | |
{ 'Параметр': 'Всього товарів', 'Значення': items.length }, | |
{ 'Параметр': 'Перевірено', 'Значення': items.filter(i => i.status !== 'unchecked').length }, | |
{ 'Параметр': 'Відповідність', 'Значення': items.filter(i => i.status === 'match').length }, | |
{ 'Параметр': 'Нестача', 'Значення': items.filter(i => i.status === 'missing').length }, | |
{ 'Параметр': 'Надлишок', 'Значення': items.filter(i => i.status === 'excess').length } | |
]; | |
const wsMetadata = XLSX.utils.json_to_sheet(metadata); | |
XLSX.utils.book_append_sheet(wb, wsMetadata, 'Метадані'); | |
// Generate file name | |
const fileName = `Інвентаризація_${formatDateForFileName(new Date())}.xlsx`; | |
// Export | |
XLSX.writeFile(wb, fileName); | |
showToast('Результати збережено у файл', 'success'); | |
} | |
// Format date for file name | |
function formatDateForFileName(date) { | |
const pad = num => num.toString().padStart(2, '0'); | |
return `${date.getFullYear()}${pad(date.getMonth() + 1)}${pad(date.getDate())}_${pad(date.getHours())}${pad(date.getMinutes())}`; | |
} | |
// Confirm clear data | |
function confirmClearData() { | |
if (items.length === 0) { | |
showToast('Немає даних для очищення', 'info'); | |
return; | |
} | |
const confirmClear = confirm('Ви впевнені, що хочете очистити всі дані інвентаризації? Цю дію не можна скасувати.'); | |
if (confirmClear) { | |
Promise.all([ | |
clearItemsDB(), | |
clearMetadataDB() | |
]) | |
.then(() => { | |
items = []; | |
fileMetadata = { | |
fileName: null, | |
fileDate: null, | |
totalItems: 0 | |
}; | |
currentItem = null; | |
updateUI(); | |
showToast('Всі дані очищено', 'success'); | |
}) | |
.catch(error => { | |
console.error('Error clearing data:', error); | |
showToast('Помилка очищення даних', 'error'); | |
}); | |
} | |
} | |
// Update UI based on current state | |
function updateUI() { | |
// Update file info | |
if (fileMetadata.fileName) { | |
fileInfo.classList.remove('hidden'); | |
fileName.textContent = fileMetadata.fileName; | |
fileDate.textContent = fileMetadata.fileDate; | |
} else { | |
fileInfo.classList.add('hidden'); | |
} | |
// Filter items based on status and search | |
const statusFilterValue = statusFilter.value; | |
const searchQuery = searchInput.value.toLowerCase(); | |
const filteredItems = items.filter(item => { | |
// Status filter | |
const statusMatch = statusFilterValue === 'all' || | |
(statusFilterValue === 'match' && item.status === 'match') || | |
(statusFilterValue === 'missing' && item.status === 'missing') || | |
(statusFilterValue === 'excess' && item.status === 'excess') || | |
(statusFilterValue === 'unchecked' && item.status === 'unchecked'); | |
// Search filter | |
const searchMatch = searchQuery === '' || | |
item.name.toLowerCase().includes(searchQuery) || | |
item.sku.toLowerCase().includes(searchQuery); | |
return statusMatch && searchMatch; | |
}); | |
// Update items table | |
renderItemsTable(filteredItems); | |
// Update results table | |
const resultsItems = items.filter(item => item.status === 'missing' || item.status === 'excess'); | |
renderResultsTable(resultsItems); | |
// Update progress and stats | |
updateProgressAndStats(); | |
// Update control states | |
updateControlStates(); | |
} | |
// Render items table | |
function renderItemsTable(itemsToRender) { | |
itemsTableBody.innerHTML = ''; | |
if (itemsToRender.length === 0) { | |
noItemsMessage.classList.remove('hidden'); | |
return; | |
} | |
noItemsMessage.classList.add('hidden'); | |
itemsToRender.forEach(item => { | |
const row = document.createElement('tr'); | |
row.className = 'border-b border-gray-200 hover:bg-gray-100/10 transition'; | |
row.addEventListener('click', () => { | |
switchTab('scan'); | |
currentItem = item; | |
displayItem(item); | |
quantityInput.disabled = false; | |
quantityInput.focus(); | |
}); | |
// Status cell | |
const statusCell = document.createElement('td'); | |
statusCell.className = 'px-4 py-2'; | |
const statusBadge = document.createElement('span'); | |
statusBadge.className = `px-2 py-1 rounded-full text-xs font-medium text-white status-${item.status}`; | |
statusBadge.textContent = getStatusText(item.status); | |
statusCell.appendChild(statusBadge); | |
row.appendChild(statusCell); | |
// SKU cell | |
const skuCell = document.createElement('td'); | |
skuCell.className = 'px-4 py-2 font-mono'; | |
skuCell.textContent = item.sku; | |
row.appendChild(skuCell); | |
// Name cell | |
const nameCell = document.createElement('td'); | |
nameCell.className = 'px-4 py-2'; | |
nameCell.textContent = item.name; | |
row.appendChild(nameCell); | |
// Expected cell | |
const expectedCell = document.createElement('td'); | |
expectedCell.className = 'px-4 py-2 text-right'; | |
expectedCell.textContent = item.expectedQuantity; | |
row.appendChild(expectedCell); | |
// Actual cell | |
const actualCell = document.createElement('td'); | |
actualCell.className = 'px-4 py-2 text-right'; | |
actualCell.textContent = item.actualQuantity !== null ? item.actualQuantity : '-'; | |
row.appendChild(actualCell); | |
itemsTableBody.appendChild(row); | |
}); | |
} | |
// Render results table | |
function renderResultsTable(itemsToRender) { | |
resultsTableBody.innerHTML = ''; | |
if (itemsToRender.length === 0) { | |
noResultsMessage.classList.remove('hidden'); | |
resultsSummary.textContent = 'Знайдено 0 розбіжностей'; | |
return; | |
} | |
noResultsMessage.classList.add('hidden'); | |
resultsSummary.textContent = `Знайдено ${itemsToRender.length} розбіжностей`; | |
itemsToRender.forEach(item => { | |
const row = document.createElement('tr'); | |
row.className = 'border-b border-gray-200 hover:bg-gray-100/10 transition'; | |
row.addEventListener('click', () => { | |
switchTab('scan'); | |
currentItem = item; | |
displayItem(item); | |
quantityInput.disabled = false; | |
quantityInput.focus(); | |
}); | |
// Status cell | |
const statusCell = document.createElement('td'); | |
statusCell.className = 'px-4 py-2'; | |
const statusBadge = document.createElement('span'); | |
statusBadge.className = `px-2 py-1 rounded-full text-xs font-medium text-white status-${item.status}`; | |
statusBadge.textContent = getStatusText(item.status); | |
statusCell.appendChild(statusBadge); | |
row.appendChild(statusCell); | |
// SKU cell | |
const skuCell = document.createElement('td'); | |
skuCell.className = 'px-4 py-2 font-mono'; | |
skuCell.textContent = item.sku; | |
row.appendChild(skuCell); | |
// Name cell | |
const nameCell = document.createElement('td'); | |
nameCell.className = 'px-4 py-2'; | |
nameCell.textContent = item.name; | |
row.appendChild(nameCell); | |
// Expected cell | |
const expectedCell = document.createElement('td'); | |
expectedCell.className = 'px-4 py-2 text-right'; | |
expectedCell.textContent = item.expectedQuantity; | |
row.appendChild(expectedCell); | |
// Actual cell | |
const actualCell = document.createElement('td'); | |
actualCell.className = 'px-4 py-2 text-right'; | |
actualCell.textContent = item.actualQuantity !== null ? item.actualQuantity : '-'; | |
row.appendChild(actualCell); | |
// Difference cell | |
const differenceCell = document.createElement('td'); | |
differenceCell.className = 'px-4 py-2 text-right font-medium'; | |
const difference = item.actualQuantity - item.expectedQuantity; | |
differenceCell.textContent = difference; | |
if (difference > 0) { | |
differenceCell.classList.add('text-green-500'); | |
} else if (difference < 0) { | |
differenceCell.classList.add('text-red-500'); | |
} | |
row.appendChild(differenceCell); | |
resultsTableBody.appendChild(row); | |
}); | |
} | |
// Update progress and statistics | |
function updateProgressAndStats() { | |
const total = items.length; | |
const checked = items.filter(item => item.status !== 'unchecked').length; | |
const match = items.filter(item => item.status === 'match').length; | |
const missing = items.filter(item => item.status === 'missing').length; | |
const excess = items.filter(item => item.status === 'excess').length; | |
// Update counts | |
totalCount.textContent = total; | |
checkedCount.textContent = checked; | |
matchCount.textContent = match; | |
missingCount.textContent = missing; | |
excessCount.textContent = excess; | |
// Update progress bar | |
const progressPercent = total > 0 ? (checked / total) * 100 : 0; | |
progressFill.style.width = `${progressPercent}%`; | |
} | |
// Update control states (enabled/disabled) | |
function updateControlStates() { | |
// Save button | |
saveResultsBtn.disabled = items.length === 0; | |
// Clear button | |
clearDataBtn.disabled = items.length === 0; | |
// Quantity input (enabled only if an item is selected) | |
quantityInput.disabled = currentItem === null; | |
quantitySubmit.disabled = currentItem === null; | |
} | |
// Show toast notification | |
function showToast(message, type) { | |
const toast = document.createElement('div'); | |
toast.className = `toast px-4 py-2 rounded-lg shadow-lg text-white ${ | |
type === 'success' ? 'bg-green-500' : | |
type === 'error' ? 'bg-red-500' : | |
type === 'warning' ? 'bg-yellow-500' : 'bg-blue-500' | |
}`; | |
toast.textContent = message; | |
toastContainer.appendChild(toast); | |
// Remove after animation | |
setTimeout(() => { | |
toast.remove(); | |
}, 3000); | |
} | |
// Vibrate device (if supported) | |
function vibrate() { | |
if ('vibrate' in navigator) { | |
navigator.vibrate(50); | |
} | |
} | |
// Initialize the app when DOM is loaded | |
document.addEventListener('DOMContentLoaded', initializeApp); | |
</script> | |
<p style="border-radius: 8px; text-align: center; font-size: 12px; color: #fff; margin-top: 16px;position: fixed; left: 8px; bottom: 8px; z-index: 10; background: rgba(0, 0, 0, 0.8); padding: 4px 8px;">Made with <img src="https://enzostvs-deepsite.hf.space/logo.svg" alt="DeepSite Logo" style="width: 16px; height: 16px; vertical-align: middle;display:inline-block;margin-right:3px;filter:brightness(0) invert(1);"><a href="https://enzostvs-deepsite.hf.space" style="color: #fff;text-decoration: underline;" target="_blank" >DeepSite</a> - 🧬 <a href="https://enzostvs-deepsite.hf.space?remix=rostyslavpwork/inventarization" style="color: #fff;text-decoration: underline;" target="_blank" >Remix</a></p></body> | |
</html> |