inventarization / index.html
rostyslavpwork's picture
Add 3 files
8ac6f9c verified
<!DOCTYPE html>
<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>