<html lang="vi">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Quản Lý Hàng Hóa - CameGPS</title>
<style>
:root{--accent:#0d6efd;--danger:#dc3545;--muted:#666}
body{font-family:Inter, Arial, sans-serif;margin:0;background:#f3f6fb;color:#111}
.app{max-width:1100px;margin:20px auto;padding:20px}
.card{background:#fff;border-radius:12px;padding:16px;box-shadow:0 6px 18px rgba(20,30,60,0.06);margin-bottom:16px}
h1{margin:0 0 8px;font-size:20px}
.grid{display:grid;gap:12px}
.grid.cols-3{grid-template-columns:1fr 120px 120px}
input,select,button{padding:8px;border-radius:8px;border:1px solid #e1e7ef}
button{cursor:pointer}
table{width:100%;border-collapse:collapse;margin-top:12px}
th,td{padding:8px;border-bottom:1px solid #eef3fb;text-align:left}
.muted{color:var(--muted)}
.row{display:flex;gap:8px;align-items:center}
.actions button{padding:6px 8px}
.small{font-size:13px}
.badge{display:inline-block;padding:4px 8px;border-radius:999px;background:#f1f6ff;color:var(--accent);font-weight:600}
</style>
</head>
<body>
<div class="app">
<div class="card">
<h1>📦 Quản Lý Hàng Hóa (Mobile + Desktop)</h1>
<div class="muted small">Tính năng: Thêm / Sửa / Xóa hàng · Nhập / Bán (ghi giao dịch) · Tồn kho tự tính · Cảnh báo tồn kho thấp · Nhập / xuất CSV · Đồng bộ Google Drive</div>
</div>
<div class="card">
<h2>Thêm / Cập nhật hàng</h2>
<div class="grid cols-3">
<input id="name" placeholder="Tên hàng hóa" />
<input id="sku" placeholder="Mã (SKU) - tùy chọn" />
<input id="price" type="number" placeholder="Giá (VNĐ)" />
</div>
<div class="grid cols-3" style="margin-top:8px">
<input id="qty" type="number" placeholder="Số lượng" />
<input id="minStock" type="number" placeholder="Mức cảnh báo tồn kho" />
<select id="category"><option value="">Danh mục</option><option>Phụ kiện</option><option>Camera</option><option>Phụ tùng</option></select>
</div>
<div style="margin-top:8px" class="row">
<button onclick="saveItem()" style="background:var(--accent);color:#fff">Lưu hàng</button>
<button onclick="clearForm()">Xóa form</button>
<div class="muted small">Đang chỉnh sửa: <span id="editing">-</span></div>
</div>
</div>
<div class="card">
<div style="display:flex;justify-content:space-between;align-items:center">
<h2>Danh sách hàng</h2>
<div class="row">
<input id="search" placeholder="Tìm theo tên hoặc SKU" oninput="render()" />
<select id="filterCat" onchange="render()"><option value="">Tất cả</option><option>Phụ kiện</option><option>Camera</option><option>Phụ tùng</option></select>
<button onclick="exportCSV()">Xuất CSV</button>
<input type="file" id="csvfile" accept=".csv" onchange="importCSV(event)" style="display:none" />
<button onclick="document.getElementById('csvfile').click()">Nhập CSV</button>
</div>
</div>
<table>
<thead>
<tr><th>Tên</th><th>SKU</th><th>SL</th><th>Giá</th><th>Đơn vị</th><th>Min</th><th>Ghi chú</th><th>Hành động</th></tr>
</thead>
<tbody id="list"></tbody>
</table>
</div>
<div class="card">
<h2>Nhập - Bán hàng (Ghi giao dịch)</h2>
<div class="grid" style="grid-template-columns:1fr 120px 120px 120px;align-items:end">
<select id="txSku"></select>
<input id="txQty" type="number" placeholder="Số lượng" />
<select id="txType"><option value="in">Nhập hàng</option><option value="out">Bán hàng</option></select>
<button onclick="recordTransaction()">Ghi giao dịch</button>
</div>
<div style="margin-top:12px">
<h3 class="small">Lịch sử giao dịch</h3>
<table>
<thead><tr><th>Ngày</th><th>SKU</th><th>Tên</th><th>Loại</th><th>Số lượng</th><th>Ghi chú</th></tr></thead>
<tbody id="txList"></tbody>
</table>
</div>
</div>
<div class="card">
<h2>Đồng bộ Google Drive (tùy chọn)</h2>
<div class="muted small">Hướng dẫn ngắn: bạn cần tạo OAuth Client ID trên Google Cloud Console, bật Drive API và dán CLIENT_ID vào chỗ bên dưới. Ứng dụng sẽ lưu một file JSON (inventory.json) vào Drive để sao lưu / tải lại dữ liệu.</div>
<div style="margin-top:8px" class="row">
<input id="googleClientId" placeholder="GOOGLE_CLIENT_ID (dán vào đây)" style="width:360px" />
<button onclick="initGapi()">Kết nối Google</button>
<button onclick="saveToDrive()">Lưu lên Drive</button>
<button onclick="loadFromDrive()">Tải từ Drive</button>
</div>
<div id="gstatus" class="muted small" style="margin-top:8px">Chưa kết nối</div>
</div>
</div>
<script>
// --- Dữ liệu ---
let items = JSON.parse(localStorage.getItem('inventory_items') || '[]');
let transactions = JSON.parse(localStorage.getItem('inventory_tx') || '[]');
let editingIndex = -1;
// --- Utility ---
function saveLocal(){
localStorage.setItem('inventory_items', JSON.stringify(items));
localStorage.setItem('inventory_tx', JSON.stringify(transactions));
render();
populateTxSelect();
}
function formatDate(d){return new Date(d).toLocaleString();}
// --- Item CRUD ---
function saveItem(){
const name = document.getElementById('name').value.trim();
const sku = document.getElementById('sku').value.trim() || 'SKU-'+Math.random().toString(36).slice(2,8).toUpperCase();
const price = Number(document.getElementById('price').value) || 0;
const qty = Number(document.getElementById('qty').value) || 0;
const minStock = Number(document.getElementById('minStock').value) || 0;
const category = document.getElementById('category').value;
if(!name) return alert('Vui lòng nhập tên hàng');
const item = {name,sku,price,qty,minStock,category,note:''};
if(editingIndex >=0){
// update existing by index
items[editingIndex] = {...items[editingIndex], ...item};
editingIndex = -1;
document.getElementById('editing').innerText = '-';
} else {
// prevent duplicate SKU
if(items.find(i=>i.sku===sku)) return alert('SKU đã tồn tại, hãy đổi mã hoặc sửa hàng hiện có');
items.push(item);
}
clearForm();
saveLocal();
}
function editItem(index){
const it = items[index];
editingIndex = index;
document.getElementById('name').value = it.name;
document.getElementById('sku').value = it.sku;
document.getElementById('price').value = it.price;
document.getElementById('qty').value = it.qty;
document.getElementById('minStock').value = it.minStock;
document.getElementById('category').value = it.category || '';
document.getElementById('editing').innerText = `${it.name} (idx ${index})`;
window.scrollTo({top:0,behavior:'smooth'});
}
function deleteItem(index){
if(!confirm('Xác nhận xóa hàng này?')) return;
items.splice(index,1);
saveLocal();
}
function clearForm(){
document.getElementById('name').value='';
document.getElementById('sku').value='';
document.getElementById('price').value='';
document.getElementById('qty').value='';
document.getElementById('minStock').value='';
document.getElementById('category').value='';
}
// --- Render ---
function render(){
const list = document.getElementById('list');
const search = document.getElementById('search').value.toLowerCase();
const filterCat = document.getElementById('filterCat').value;
list.innerHTML='';
items.forEach((it,idx)=>{
if(search && !(it.name.toLowerCase().includes(search) || (it.sku||'').toLowerCase().includes(search))) return;
if(filterCat && it.category!==filterCat) return;
const low = it.minStock>0 && it.qty<=it.minStock;
list.innerHTML += `<tr>
<td>${it.name}</td>
<td>${it.sku}</td>
<td>${it.qty} ${low?'Tồn thấp':''}</td>
<td>${Number(it.price).toLocaleString()}₫</td>
<td>${it.unit||'Cái'}</td>
<td>${it.minStock||'-'}</td>
<td>${it.note||'-'}</td>
<td class="actions">
<button onclick="editItem(${idx})">Sửa</button>
<button onclick="deleteItem(${idx})" style="background:var(--danger);color:#fff">Xóa</button>
<button onclick="quickIn(${idx})">Nhập</button>
<button onclick="quickOut(${idx})">Bán</button>
</td>
</tr>`;
});
}
// --- Quick in/out ---
function quickIn(index){
const q = Number(prompt('Số lượng nhập:', '1'))||0;
if(q<=0) return;
transactions.push({date:Date.now(),sku:items[index].sku,type:'in',qty:q,note:'Nhập nhanh'});
items[index].qty += q;
saveLocal();
}
function quickOut(index){
const q = Number(prompt('Số lượng bán:', '1'))||0;
if(q<=0) return;
if(items[index].qty - q < 0 && !confirm('Số lượng sẽ âm, tiếp tục?')) return;
transactions.push({date:Date.now(),sku:items[index].sku,type:'out',qty:q,note:'Bán nhanh'});
items[index].qty -= q;
saveLocal();
}
// --- Transaction handling ---
function populateTxSelect(){
const sel = document.getElementById('txSku');
sel.innerHTML='';
items.forEach(it=>{
const opt = document.createElement('option'); opt.value = it.sku; opt.text = `${it.sku} — ${it.name}`; sel.appendChild(opt);
});
}
function recordTransaction(){
const sku = document.getElementById('txSku').value;
const qty = Number(document.getElementById('txQty').value)||0;
const type = document.getElementById('txType').value;
if(!sku || qty<=0) return alert('Chọn SKU và số lượng > 0');
const item = items.find(i=>i.sku===sku);
if(!item) return alert('SKU không tìm thấy');
if(type==='out' && item.qty - qty < 0 && !confirm('Số lượng sẽ âm, tiếp tục?')) return;
transactions.push({date:Date.now(),sku,type,qty,note:''});
item.qty += (type==='in'? qty : -qty);
document.getElementById('txQty').value='';
saveLocal();
}
function renderTx(){
const txList = document.getElementById('txList'); txList.innerHTML='';
const recent = transactions.slice().reverse();
recent.forEach(tx=>{
const it = items.find(i=>i.sku===tx.sku) || {name:'-'};
txList.innerHTML += `<tr><td>${formatDate(tx.date)}</td><td>${tx.sku}</td><td>${it.name}</td><td>${tx.type}</td><td>${tx.qty}</td><td>${tx.note||'-'}</td></tr>`;
});
}
// --- CSV export/import ---
function exportCSV(){
let csv = 'name,sku,price,qty,minStock,category,noten';
items.forEach(it=>{csv += `"${it.name}","${it.sku}",${it.price},${it.qty},${it.minStock},"${it.category || ''}","${(it.note||'').replace(/"/g,'""')}"n`});
const blob = new Blob([csv],{type:'text/csv;charset=utf-8;'});
const url = URL.createObjectURL(blob);
const a = document.createElement('a'); a.href = url; a.download = 'inventory.csv'; a.click(); URL.revokeObjectURL(url);
}
function importCSV(e){
const f = e.target.files[0]; if(!f) return;
const reader = new FileReader();
reader.onload = ()=>{
const text = reader.result;
const lines = text.split(/r?n/).filter(Boolean);
const data = lines.slice(1).map(l=>{
const cols = l.split(/,(?=(?:[^"]*"[^"]*")*[^"]*$)/); // crude CSV parse
return {name:cols[0].replace(/^"|"$/g,''), sku:cols[1].replace(/^"|"$/g,''), price:Number(cols[2])||0, qty:Number(cols[3])||0, minStock:Number(cols[4])||0, category:cols[5].replace(/^"|"$/g,''), note:cols[6]?cols[6].replace(/^"|"$/g,''):''};
});
// merge or add
data.forEach(d=>{
const idx = items.findIndex(i=>i.sku===d.sku);
if(idx>=0) items[idx] = {...items[idx],...d}; else items.push(d);
});
saveLocal();
alert('Nhập CSV xong');
};
reader.readAsText(f,'utf-8');
e.target.value='';
}
// --- Google Drive sync (client-side) ---
// NOTE: You must create OAuth Client ID in Google Cloud Console, enable Drive API and paste CLIENT_ID below.
// This implementation uses simple file create & list to save a JSON file named 'inventory_backup.json'.
let gapiInited = false; let googleAuth = null; let CLIENT_ID = '';
function initGapi(){
CLIENT_ID = document.getElementById('googleClientId').value.trim();
if(!CLIENT_ID) return alert('Dán GOOGLE_CLIENT_ID vào ô input trước khi kết nối');
loadGapiClient(()=>{
gapiInited = true; document.getElementById('gstatus').innerText = 'GAPI đã sẵn sàng. Hãy đăng nhập.';
signIn();
});
}
function loadGapiClient(cb){
if(window.gapi) return cb();
const s = document.createElement('script');
s.src = 'https://apis.google.com/js/api.js'; s.onload = ()=>{
gapi.load('client:auth2', async ()=>{
await gapi.client.init({apiKey: null, clientId: CLIENT_ID, scope:'https://www.googleapis.com/auth/drive.file'});
googleAuth = gapi.auth2.getAuthInstance();
cb();
});
};
document.head.appendChild(s);
}
async function signIn(){
if(!googleAuth) return alert('Chưa khởi tạo GAPI');
try{
await googleAuth.signIn();
document.getElementById('gstatus').innerText = 'Đã đăng nhập: ' + googleAuth.currentUser.get().getBasicProfile().getEmail();
}catch(e){console.error(e); alert('Không thể đăng nhập Google: '+e.message)}
}
async function saveToDrive(){
if(!gapiInited) return alert('Chưa kết nối Google');
const content = JSON.stringify({items,transactions});
const metadata = {name:'inventory_backup.json', mimeType:'application/json'};
const boundary = '-------314159265358979323846';
const delimiter = 'rn--' + boundary + 'rn';
const closeDelimiter = 'rn--' + boundary + '--';
const multipartRequestBody =
delimiter +
'Content-Type: application/json; charset=UTF-8rnrn' +
JSON.stringify(metadata) +
delimiter +
'Content-Type: application/jsonrnrn' +
content +
closeDelimiter;
try{
const response = await gapi.client.request({
path: '/upload/drive/v3/files',
method: 'POST',
params: {uploadType: 'multipart'},
headers: {'Content-Type': 'multipart/related; boundary="' + boundary + '"'},
body: multipartRequestBody
});
alert('Lưu lên Drive thành công');
}catch(e){console.error(e); alert('Lưu thất bại: '+ (e.message||e.result||e))}
}
async function loadFromDrive(){
if(!gapiInited) return alert('Chưa kết nối Google');
try{
const res = await gapi.client.drive.files.list({q:"name='inventory_backup.json' and trashed=false", fields:'files(id,name,modifiedTime)'});
if(!res.result.files || res.result.files.length===0) return alert('Không tìm thấy file inventory_backup.json trên Drive');
const file = res.result.files[0];
const contentRes = await gapi.client.request({path:`/drive/v3/files/${file.id}?alt=media`});
const data = typeof contentRes.result === 'string' ? JSON.parse(contentRes.result) : contentRes.result;
items = data.items || []; transactions = data.transactions || [];
saveLocal();
alert('Tải dữ liệu từ Drive xong (file: '+file.name+')');
}catch(e){console.error(e); alert('Tải thất bại: '+(e.message||e.result||e))}
}
// --- Init ---
function boot(){
saveLocal();
renderTx();
populateTxSelect();
render();
setInterval(()=>{render();renderTx()}, 1000*10);
}
// run
boot();
</script>
</body>
</html>
Ngày: 16-11-2025