first commit

This commit is contained in:
Joseph Le 2026-02-10 19:57:02 +11:00
commit 81a1448ed3
7 changed files with 4238 additions and 0 deletions

1
.gitignore vendored Normal file
View File

@ -0,0 +1 @@
node_modules

0
README.md Normal file
View File

3482
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

16
package.json Normal file
View File

@ -0,0 +1,16 @@
{
"name": "scanramproducts",
"version": "1.0.0",
"description": "",
"main": "index.js",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"keywords": [],
"author": "",
"license": "ISC",
"dependencies": {
"express": "^5.2.1",
"sqlite3": "^5.1.7"
}
}

BIN
products_old.db Normal file

Binary file not shown.

604
public/index.html Normal file
View File

@ -0,0 +1,604 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Product Management</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
background: linear-gradient(135deg, #2c3e50 0%, #34495e 100%);
min-height: 100vh;
padding: 20px;
}
.container {
max-width: 1400px;
margin: 0 auto;
background: white;
border-radius: 16px;
box-shadow: 0 20px 60px rgba(0,0,0,0.3);
overflow: hidden;
}
.header {
background: linear-gradient(135deg, #2c3e50 0%, #34495e 100%);
color: white;
padding: 30px;
text-align: center;
}
.header h1 {
font-size: 28px;
font-weight: 600;
margin-bottom: 10px;
}
.controls {
padding: 25px 30px;
background: #f8f9fa;
display: flex;
gap: 15px;
flex-wrap: wrap;
align-items: center;
border-bottom: 1px solid #e9ecef;
}
.search-box {
flex: 1;
min-width: 250px;
position: relative;
}
.search-box input {
width: 100%;
padding: 12px 16px;
border: 2px solid #e9ecef;
border-radius: 8px;
font-size: 14px;
transition: all 0.3s;
}
.search-box input:focus {
outline: none;
border-color: #2c3e50;
box-shadow: 0 0 0 3px rgba(44, 62, 80, 0.1);
}
.btn {
padding: 12px 24px;
border: none;
border-radius: 8px;
font-size: 14px;
font-weight: 500;
cursor: pointer;
transition: all 0.3s;
display: flex;
align-items: center;
gap: 8px;
}
.btn-primary {
background: linear-gradient(135deg, #2c3e50 0%, #34495e 100%);
color: white;
}
.btn-primary:hover {
transform: translateY(-2px);
box-shadow: 0 4px 12px rgba(44, 62, 80, 0.4);
}
.table-container {
overflow-x: auto;
padding: 0 30px 30px;
}
table {
width: 100%;
border-collapse: collapse;
margin-top: 20px;
}
thead {
background: #f8f9fa;
}
th {
padding: 16px;
text-align: left;
font-weight: 600;
color: #495057;
font-size: 13px;
text-transform: uppercase;
letter-spacing: 0.5px;
cursor: pointer;
user-select: none;
position: relative;
}
th:hover {
background: #e9ecef;
}
th.sorted::after {
content: ' ▼';
font-size: 10px;
}
th.sorted.asc::after {
content: ' ▲';
}
td {
padding: 16px;
border-bottom: 1px solid #f1f3f5;
color: #495057;
font-size: 14px;
}
tbody tr {
transition: background 0.2s;
}
tbody tr:hover {
background: #f8f9fa;
}
.delete-btn {
background: #dc3545;
color: white;
border: none;
padding: 6px 12px;
border-radius: 6px;
cursor: pointer;
font-size: 12px;
transition: all 0.3s;
}
.delete-btn:hover {
background: #c82333;
transform: scale(1.05);
}
.pagination {
display: flex;
justify-content: center;
align-items: center;
gap: 10px;
padding: 30px;
flex-wrap: wrap;
}
.page-btn {
padding: 8px 16px;
border: 2px solid #e9ecef;
background: white;
border-radius: 6px;
cursor: pointer;
font-size: 14px;
transition: all 0.3s;
}
.page-btn:hover:not(:disabled) {
border-color: #2c3e50;
color: #2c3e50;
}
.page-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.page-btn.active {
background: linear-gradient(135deg, #2c3e50 0%, #34495e 100%);
color: white;
border-color: transparent;
}
.page-info {
color: #6c757d;
font-size: 14px;
}
.loading {
text-align: center;
padding: 40px;
color: #6c757d;
}
.empty {
text-align: center;
padding: 60px;
color: #adb5bd;
}
.empty h3 {
margin-bottom: 10px;
color: #6c757d;
}
@media (max-width: 768px) {
.controls {
padding: 20px;
}
.search-box {
min-width: 100%;
}
.table-container {
padding: 0 20px 20px;
}
table {
font-size: 12px;
}
th, td {
padding: 10px 8px;
}
.header h1 {
font-size: 22px;
}
.pagination {
padding: 20px;
}
}
@media (max-width: 480px) {
body {
padding: 10px;
}
.container {
border-radius: 12px;
}
.header {
padding: 20px;
}
th, td {
padding: 8px 6px;
font-size: 11px;
}
.btn {
padding: 10px 16px;
font-size: 13px;
}
}
.modal {
display: none;
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: rgba(0,0,0,0.5);
z-index: 1000;
justify-content: center;
align-items: center;
}
.modal.active {
display: flex;
}
.modal-content {
background: white;
padding: 30px;
border-radius: 12px;
width: 90%;
max-width: 500px;
max-height: 80vh;
overflow-y: auto;
}
.modal h2 {
margin-bottom: 20px;
color: #495057;
}
.form-group {
margin-bottom: 15px;
}
.form-group label {
display: block;
margin-bottom: 5px;
color: #495057;
font-size: 14px;
font-weight: 500;
}
.form-group input {
width: 100%;
padding: 10px;
border: 2px solid #e9ecef;
border-radius: 6px;
font-size: 14px;
}
.modal-buttons {
display: flex;
gap: 10px;
margin-top: 20px;
}
.btn-secondary {
background: #6c757d;
color: white;
flex: 1;
}
.btn-secondary:hover {
background: #5a6268;
}
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>Product Management</h1>
<p>Manage your products efficiently</p>
</div>
<div class="controls">
<div class="search-box">
<input type="text" id="searchInput" placeholder="Search by ID, MPN, SN, or creator...">
</div>
<button class="btn btn-primary" onclick="openCreateModal()">
Add Product
</button>
</div>
<div class="table-container">
<div id="loading" class="loading" style="display: none;">
Loading data...
</div>
<div id="empty" class="empty" style="display: none;">
<h3>No products found</h3>
<p>Add your first product to get started!</p>
</div>
<table id="productTable" style="display: none;">
<thead>
<tr>
<th onclick="sortTable('id')">ID</th>
<th onclick="sortTable('mpn')">MPN</th>
<th onclick="sortTable('sn')">SN</th>
<th onclick="sortTable('createdAt')">Created At</th>
<th onclick="sortTable('createdBy')">Created By</th>
<th>Actions</th>
</tr>
</thead>
<tbody id="productBody"></tbody>
</table>
</div>
<div class="pagination" id="pagination"></div>
</div>
<!-- Create Product Modal -->
<div id="createModal" class="modal">
<div class="modal-content">
<h2>Add New Product</h2>
<div class="form-group">
<label>MPN *</label>
<input type="text" id="mpnInput" placeholder="Enter MPN code">
</div>
<div class="form-group">
<label>Serial Number (SN) *</label>
<input type="text" id="snInput" placeholder="Enter serial number">
</div>
<div class="form-group">
<label>Created By *</label>
<input type="text" id="createdByInput" placeholder="Enter creator name">
</div>
<div class="modal-buttons">
<button class="btn btn-secondary" onclick="closeCreateModal()">Cancel</button>
<button class="btn btn-primary" onclick="createProduct()">Create</button>
</div>
</div>
</div>
<script>
let currentPage = 1;
let currentSort = 'id';
let currentOrder = 'DESC';
let searchTerm = '';
let URL = "https://logs1.danielvu.com/mobile-api/"
async function loadProducts() {
document.getElementById('loading').style.display = 'block';
document.getElementById('productTable').style.display = 'none';
document.getElementById('empty').style.display = 'none';
try {
const response = await fetch(`${URL}product/get?page=${currentPage}&sortBy=${currentSort}&sortOrder=${currentOrder}&search=${encodeURIComponent(searchTerm)}`);
const data = await response.json();
document.getElementById('loading').style.display = 'none';
if (data.products.length === 0) {
document.getElementById('empty').style.display = 'block';
} else {
document.getElementById('productTable').style.display = 'table';
renderProducts(data.products);
renderPagination(data);
}
updateSortIndicators();
} catch (error) {
console.error('Error loading products:', error);
document.getElementById('loading').style.display = 'none';
}
}
function renderProducts(products) {
const tbody = document.getElementById('productBody');
tbody.innerHTML = products.map(product => {
// Convert UTC to client's local timezone
const date = new Date(product.createdAt+"Z");
const formattedDate = date.toLocaleString('en-US', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hour12: false
});
return `
<tr>
<td>${product.id}</td>
<td>${product.mpn}</td>
<td>${product.sn}</td>
<td>${formattedDate}</td>
<td>${product.createdBy}</td>
<td>
<button class="delete-btn" onclick="deleteProduct(${product.id})">Delete</button>
</td>
</tr>
`;
}).join('');
}
function renderPagination(data) {
const pagination = document.getElementById('pagination');
let html = '';
html += `<button class="page-btn" ${currentPage === 1 ? 'disabled' : ''} onclick="changePage(${currentPage - 1})">Previous</button>`;
const startPage = Math.max(1, currentPage - 2);
const endPage = Math.min(data.totalPages, currentPage + 2);
for (let i = startPage; i <= endPage; i++) {
html += `<button class="page-btn ${i === currentPage ? 'active' : ''}" onclick="changePage(${i})">${i}</button>`;
}
html += `<button class="page-btn" ${currentPage === data.totalPages ? 'disabled' : ''} onclick="changePage(${currentPage + 1})">Next</button>`;
html += `<span class="page-info">Total: ${data.total} products</span>`;
pagination.innerHTML = html;
}
function changePage(page) {
currentPage = page;
loadProducts();
}
function sortTable(column) {
if (currentSort === column) {
currentOrder = currentOrder === 'DESC' ? 'ASC' : 'DESC';
} else {
currentSort = column;
currentOrder = 'DESC';
}
currentPage = 1;
loadProducts();
}
function updateSortIndicators() {
document.querySelectorAll('th').forEach(th => {
th.classList.remove('sorted', 'asc');
});
const columns = ['id', 'mpn', 'sn', 'createdAt', 'createdBy'];
const index = columns.indexOf(currentSort);
if (index !== -1) {
const th = document.querySelectorAll('th')[index];
th.classList.add('sorted');
if (currentOrder === 'ASC') {
th.classList.add('asc');
}
}
}
let searchTimeout;
document.getElementById('searchInput').addEventListener('input', (e) => {
clearTimeout(searchTimeout);
searchTimeout = setTimeout(() => {
searchTerm = e.target.value;
currentPage = 1;
loadProducts();
}, 300);
});
async function deleteProduct(id) {
if (!confirm('Are you sure you want to delete this product?')) return;
try {
const response = await fetch(`${URL}product/delete/${id}`, {
method: 'DELETE'
});
if (response.ok) {
loadProducts();
} else {
alert('Error deleting product');
}
} catch (error) {
console.error('Error deleting product:', error);
alert('Error deleting product');
}
}
function openCreateModal() {
document.getElementById('createModal').classList.add('active');
}
function closeCreateModal() {
document.getElementById('createModal').classList.remove('active');
document.getElementById('mpnInput').value = '';
document.getElementById('snInput').value = '';
document.getElementById('createdByInput').value = '';
}
async function createProduct() {
const mpn = document.getElementById('mpnInput').value.trim();
const sn = document.getElementById('snInput').value.trim();
const createdBy = document.getElementById('createdByInput').value.trim();
if (!mpn || !sn || !createdBy) {
alert('Please fill in all fields');
return;
}
try {
const response = await fetch(URL+'product/create', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify([{ mpn, sn, createdBy }])
});
if (response.ok) {
closeCreateModal();
loadProducts();
} else {
alert('Error creating product');
}
} catch (error) {
console.error('Error creating product:', error);
alert('Error creating product');
}
}
// Load products on page load
loadProducts();
</script>
</body>
</html>

135
server.js Normal file
View File

@ -0,0 +1,135 @@
const express = require('express');
const sqlite3 = require('sqlite3').verbose();
const path = require('path');
const app = express();
// Middleware
app.use(express.json());
app.use(express.static('public'));
// Initialize SQLite database
const db = new sqlite3.Database('./products.db', (err) => {
if (err) console.error(err.message);
console.log('Connected to products database.');
});
// Create products table
db.run(`CREATE TABLE IF NOT EXISTS products (
id INTEGER PRIMARY KEY AUTOINCREMENT,
mpn TEXT NOT NULL,
sn TEXT NOT NULL,
createdAt DATETIME DEFAULT CURRENT_TIMESTAMP,
createdBy TEXT NOT NULL
)`);
// API Routes
// Create products (batch insert)
app.post('/api/product/create', (req, res) => {
const products = req.body;
if (!Array.isArray(products) || products.length === 0) {
return res.status(400).json({ error: 'Expected an array of products' });
}
const stmt = db.prepare('INSERT INTO products (mpn, sn, createdBy) VALUES (?, ?, ?)');
let inserted = 0;
let errors = [];
products.forEach((product, index) => {
if (!product.mpn || !product.sn || !product.createdBy) {
errors.push(`Product at index ${index} is missing required fields`);
return;
}
stmt.run(product.mpn, product.sn, product.createdBy, (err) => {
if (err) errors.push(`Error inserting product at index ${index}: ${err.message}`);
else inserted++;
});
});
stmt.finalize(() => {
res.json({
success: true,
inserted,
errors: errors.length > 0 ? errors : undefined
});
});
});
// Get products with pagination, search, and sort
app.get('/api/product/get', (req, res) => {
const page = parseInt(req.query.page) || 1;
const limit = parseInt(req.query.limit) || 50;
const search = req.query.search || '';
const sortBy = req.query.sortBy || 'id';
const sortOrder = req.query.sortOrder || 'DESC';
const offset = (page - 1) * limit;
// Validate sortBy to prevent SQL injection
const validColumns = ['id', 'mpn', 'sn', 'createdAt', 'createdBy'];
const column = validColumns.includes(sortBy) ? sortBy : 'id';
const order = sortOrder.toUpperCase() === 'ASC' ? 'ASC' : 'DESC';
let query = 'SELECT * FROM products';
let countQuery = 'SELECT COUNT(*) as total FROM products';
let params = [];
if (search) {
const searchCondition = ` WHERE mpn LIKE ? OR sn LIKE ? OR createdBy LIKE ? OR id LIKE ?`;
query += searchCondition;
countQuery += searchCondition;
const searchParam = `%${search}%`;
params = [searchParam, searchParam, searchParam, searchParam];
}
query += ` ORDER BY ${column} ${order} LIMIT ? OFFSET ?`;
params.push(limit, offset);
db.get(countQuery, search ? params.slice(0, 4) : [], (err, countRow) => {
if (err) {
return res.status(500).json({ error: err.message });
}
db.all(query, params, (err, rows) => {
if (err) {
return res.status(500).json({ error: err.message });
}
res.json({
products: rows,
total: countRow.total,
page,
limit,
totalPages: Math.ceil(countRow.total / limit)
});
});
});
});
// Delete product
app.delete('/api/product/delete/:id', (req, res) => {
const id = req.params.id;
db.run('DELETE FROM products WHERE id = ?', id, function(err) {
if (err) {
return res.status(500).json({ error: err.message });
}
if (this.changes === 0) {
return res.status(404).json({ error: 'Product not found' });
}
res.json({ success: true, deleted: this.changes });
});
});
// Serve HTML UI
app.get('/', (req, res) => {
res.sendFile(path.join(__dirname, 'public', 'index.html'));
});
const PORT = process.env.PORT || 4444;
app.listen(PORT, () => {
console.log(`Server is running on http://localhost:${PORT}`);
});