feat(inventaris): stok keluar/retur/opname + detail unit, dan edit slip payroll (port upstream)

- inventoryService: port penuh dari upstream (issueStock FIFO/SN, returnStock, adjustStock opname, serial tersedia, statistik, detail barang) + kompatibilitas fork
- schema: kolom price/supplier (stock) dan remaining_stock/recipient/reference_no (log) via migrasi aman
- admin: route stok keluar/retur/opname + API detail & serial; modal + tombol aksi di halaman inventaris; viewStock yang tadinya undefined
- payroll: updateSlip() + route /payroll/slip/:id/update
This commit is contained in:
oxpoe
2026-11-05 01:26:18 +07:00
parent d24cabf818
commit 95fa69e968
5 changed files with 800 additions and 83 deletions

View File

@@ -1107,6 +1107,18 @@ try {
for (const [n, def] of _add) { if (!_have.has(n)) db.exec('ALTER TABLE packages ADD COLUMN ' + n + ' ' + def); } for (const [n, def] of _add) { if (!_have.has(n)) db.exec('ALTER TABLE packages ADD COLUMN ' + n + ' ' + def); }
} catch (e) { console.error('Failed to migrate packages ip columns:', e); } } catch (e) { console.error('Failed to migrate packages ip columns:', e); }
// Safe migration: kolom tambahan inventaris (harga/supplier + log penerima & sisa stok)
try {
const stockCols = db.prepare("PRAGMA table_info(inventory_stock)").all();
if (!stockCols.find(c => c.name === 'price')) db.exec("ALTER TABLE inventory_stock ADD COLUMN price INTEGER DEFAULT 0");
if (!stockCols.find(c => c.name === 'supplier')) db.exec("ALTER TABLE inventory_stock ADD COLUMN supplier TEXT DEFAULT ''");
const logCols = db.prepare("PRAGMA table_info(inventory_logs)").all();
if (!logCols.find(c => c.name === 'remaining_stock')) db.exec("ALTER TABLE inventory_logs ADD COLUMN remaining_stock INTEGER DEFAULT 0");
if (!logCols.find(c => c.name === 'recipient')) db.exec("ALTER TABLE inventory_logs ADD COLUMN recipient TEXT DEFAULT ''");
if (!logCols.find(c => c.name === 'reference_no')) db.exec("ALTER TABLE inventory_logs ADD COLUMN reference_no TEXT DEFAULT ''");
} catch (e) { console.error('Failed to migrate inventory columns:', e); }
module.exports = db; module.exports = db;
module.exports.getAppSetting = getAppSetting; module.exports.getAppSetting = getAppSetting;
module.exports.saveAppSetting = saveAppSetting; module.exports.saveAppSetting = saveAppSetting;

View File

@@ -5096,7 +5096,8 @@ router.post('/backup/cleanup', requireAdminSession, express.urlencoded({ extende
router.get('/inventory', requireAdminSession, requireSidebarMenuAccess('inventory'), (req, res) => { router.get('/inventory', requireAdminSession, requireSidebarMenuAccess('inventory'), (req, res) => {
const items = inventorySvc.getAllItems(req.query.q); const items = inventorySvc.getAllItems(req.query.q);
const categories = inventorySvc.getAllCategories(); const categories = inventorySvc.getAllCategories();
const logs = inventorySvc.getInventoryLogs(100); const logs = inventorySvc.getInventoryLogs({ limit: 100 });
const stats = inventorySvc.getInventoryStats();
res.render('admin/inventory', { res.render('admin/inventory', {
title: 'Manajemen Inventaris', title: 'Manajemen Inventaris',
@@ -5105,7 +5106,8 @@ router.get('/inventory', requireAdminSession, requireSidebarMenuAccess('inventor
msg: flashMsg(req), msg: flashMsg(req),
items, items,
categories, categories,
logs logs,
stats
}); });
}); });
@@ -5159,6 +5161,54 @@ router.post('/inventory/stock/add', requireAdminSession, express.urlencoded({ ex
res.redirect('/admin/inventory'); res.redirect('/admin/inventory');
}); });
router.post('/inventory/stock/out', requireAdminSession, express.urlencoded({ extended: true }), (req, res) => {
try {
const result = inventorySvc.issueStock(req.body, req.session.adminUser || 'Admin');
req.session._msg = { type: 'success', text: `Stok keluar dicatat. Sisa tersedia: ${result.remainingStock}.` };
} catch (e) {
req.session._msg = { type: 'error', text: 'Gagal: ' + e.message };
}
res.redirect('/admin/inventory');
});
router.post('/inventory/stock/return', requireAdminSession, express.urlencoded({ extended: true }), (req, res) => {
try {
inventorySvc.returnStock(req.body, req.session.adminUser || 'Admin');
req.session._msg = { type: 'success', text: 'Retur barang berhasil dicatat.' };
} catch (e) {
req.session._msg = { type: 'error', text: 'Gagal: ' + e.message };
}
res.redirect('/admin/inventory');
});
router.post('/inventory/stock/adjust', requireAdminSession, express.urlencoded({ extended: true }), (req, res) => {
try {
const result = inventorySvc.adjustStock(req.body, req.session.adminUser || 'Admin');
req.session._msg = { type: 'success', text: result.message || `Opname disimpan. Selisih: ${result.diff}, stok fisik: ${result.remainingStock}.` };
} catch (e) {
req.session._msg = { type: 'error', text: 'Gagal: ' + e.message };
}
res.redirect('/admin/inventory');
});
router.get('/api/inventory/item/:id', requireAdminSession, (req, res) => {
try {
const detail = inventorySvc.getItemDetail(req.params.id);
if (!detail) return res.status(404).json({ success: false, message: 'Barang tidak ditemukan' });
res.json({ success: true, ...detail });
} catch (e) {
res.status(500).json({ success: false, message: e.message });
}
});
router.get('/api/inventory/serials/:itemId', requireAdminSession, (req, res) => {
try {
res.json({ success: true, serials: inventorySvc.getAvailableSerialNumbers(req.params.itemId) });
} catch (e) {
res.status(500).json({ success: false, message: e.message });
}
});
router.get('/audit-logs', requireAdminSession, requireSidebarMenuAccess('audit_logs'), restrictToAdmin, (req, res) => { router.get('/audit-logs', requireAdminSession, requireSidebarMenuAccess('audit_logs'), restrictToAdmin, (req, res) => {
const filters = { const filters = {
action: req.query.action || null, action: req.query.action || null,
@@ -7790,6 +7840,16 @@ router.post('/payroll/slip/:id/deduction', requireAdmin, express.json(), (req, r
} }
}); });
router.post('/payroll/slip/:id/update', requireAdmin, express.urlencoded({ extended: true }), (req, res) => {
try {
payrollSvc.updateSlip(req.params.id, req.body);
req.session._msg = { type: 'success', text: 'Rincian slip gaji berhasil diperbarui.' };
} catch (e) {
req.session._msg = { type: 'error', text: 'Gagal memperbarui slip: ' + e.message };
}
res.redirect('back');
});
router.post('/payroll/slip/:id/approve', requireAdmin, (req, res) => { router.post('/payroll/slip/:id/approve', requireAdmin, (req, res) => {
try { try {
payrollSvc.approveSlip(req.params.id); payrollSvc.approveSlip(req.params.id);

View File

@@ -1,42 +1,111 @@
/** /**
* Service: Manajemen Inventaris / Gudang (Warehouse) * Service: Manajemen Inventaris / Gudang (Warehouse)
* Sistem Pelacakan Stok, Pemasukan, Pengeluaran & Sisa Stok Terintegrasi
*/ */
const db = require('../config/database'); const db = require('../config/database');
const { logger } = require('../config/logger'); const { logger } = require('../config/logger');
// ─── DEFAULT CATEGORIES SEEDER ───────────────────────────────────────────
function seedDefaultCategories() {
try {
const count = db.prepare('SELECT count(*) as c FROM inventory_categories').get().c;
if (count === 0) {
const defaults = [
['Router & ONT', 'Modem ONT GPON/EPON, Router WiFi, Access Point, Wireless Bridge'],
['Kabel & Drop Core', 'Kabel Fiber Optic Drop Core 1-2 Core, Kabel LAN UTP Cat5e/Cat6'],
['Aksesoris Fiber Optic', 'Patchcord, Pigtail, Adapter SC, ODP, OTB, Roset FO, Joint Closure, Protection Sleeve'],
['Aksesoris LAN & Jaringan', 'Konektor RJ45, Switch Hub, Barrel LAN, Faceplate, Modular Jack'],
['Aksesoris Tiang & Outdoor', 'Klem Buaya, Span Wartel, Hook S, Stainless Band, Bracket Tiang'],
['Power & Adaptor', 'Adaptor 12V 1A/2A, POE Injector, UPS Mini DC, Kabel Power']
];
const insert = db.prepare('INSERT OR IGNORE INTO inventory_categories (name, description) VALUES (?, ?)');
defaults.forEach(([name, desc]) => insert.run(name, desc));
}
} catch (e) {
logger.error('Failed to seed default inventory categories: ' + e.message);
}
}
// ─── CATEGORIES ─────────────────────────────────────────────────────────── // ─── CATEGORIES ───────────────────────────────────────────────────────────
function getAllCategories() { function getAllCategories() {
return db.prepare('SELECT * FROM inventory_categories ORDER BY name ASC').all(); seedDefaultCategories();
return db.prepare(`
SELECT c.*,
(SELECT count(*) FROM inventory_items i WHERE i.category_id = c.id) as item_count
FROM inventory_categories c
ORDER BY c.name ASC
`).all();
} }
function createCategory(data) { function createCategory(data) {
const { name, description } = data; const { name, description } = data;
return db.prepare('INSERT INTO inventory_categories (name, description) VALUES (?, ?)').run(name, description); if (!name || !name.trim()) throw new Error('Nama kategori wajib diisi');
return db.prepare('INSERT INTO inventory_categories (name, description) VALUES (?, ?)').run(name.trim(), description || '');
} }
function updateCategory(id, data) { function updateCategory(id, data) {
const { name, description } = data; const { name, description } = data;
return db.prepare('UPDATE inventory_categories SET name = ?, description = ? WHERE id = ?').run(name, description, id); if (!name || !name.trim()) throw new Error('Nama kategori wajib diisi');
return db.prepare('UPDATE inventory_categories SET name = ?, description = ? WHERE id = ?').run(name.trim(), description || '', id);
} }
function deleteCategory(id) { function deleteCategory(id) {
const itemsCount = db.prepare('SELECT count(*) as c FROM inventory_items WHERE category_id = ?').get(id)?.c || 0;
if (itemsCount > 0) {
throw new Error(`Tidak dapat menghapus kategori karena masih memiliki ${itemsCount} jenis barang terkait.`);
}
return db.prepare('DELETE FROM inventory_categories WHERE id = ?').run(id); return db.prepare('DELETE FROM inventory_categories WHERE id = ?').run(id);
} }
// ─── HELPER STOCK COUNT ───────────────────────────────────────────────────
function getItemAvailableStock(itemId) {
const res = db.prepare(`
SELECT COALESCE(SUM(quantity), 0) as total
FROM inventory_stock
WHERE item_id = ? AND status = 'available'
`).get(itemId);
return Number(res?.total || 0);
}
function getItemAssignedStock(itemId) {
const res = db.prepare(`
SELECT COALESCE(SUM(quantity), 0) as total
FROM inventory_stock
WHERE item_id = ? AND status = 'assigned'
`).get(itemId);
return Number(res?.total || 0);
}
function getItemBrokenStock(itemId) {
const res = db.prepare(`
SELECT COALESCE(SUM(quantity), 0) as total
FROM inventory_stock
WHERE item_id = ? AND status = 'broken'
`).get(itemId);
return Number(res?.total || 0);
}
// ─── ITEMS ─────────────────────────────────────────────────────────────── // ─── ITEMS ───────────────────────────────────────────────────────────────
function getAllItems(search = '') { function getAllItems(search = '', categoryId = null) {
seedDefaultCategories();
let query = ` let query = `
SELECT i.*, c.name as category_name, SELECT i.*, c.name as category_name,
(SELECT SUM(quantity) FROM inventory_stock s WHERE s.item_id = i.id AND s.status = 'available') as stock_available, COALESCE((SELECT SUM(quantity) FROM inventory_stock s WHERE s.item_id = i.id AND s.status = 'available'), 0) as stock_available,
(SELECT SUM(quantity) FROM inventory_stock s WHERE s.item_id = i.id AND s.status = 'assigned') as stock_assigned COALESCE((SELECT SUM(quantity) FROM inventory_stock s WHERE s.item_id = i.id AND s.status = 'assigned'), 0) as stock_assigned,
COALESCE((SELECT SUM(quantity) FROM inventory_stock s WHERE s.item_id = i.id AND s.status = 'broken'), 0) as stock_broken
FROM inventory_items i FROM inventory_items i
LEFT JOIN inventory_categories c ON i.category_id = c.id LEFT JOIN inventory_categories c ON i.category_id = c.id
WHERE 1=1
`; `;
const params = []; const params = [];
if (categoryId) {
query += ' AND i.category_id = ?';
params.push(categoryId);
}
if (search) { if (search) {
query += ' WHERE i.name LIKE ? OR i.brand LIKE ? OR i.model LIKE ?'; query += ' AND (i.name LIKE ? OR i.brand LIKE ? OR i.model LIKE ? OR c.name LIKE ?)';
const s = `%${search}%`; const s = `%${search}%`;
params.push(s, s, s); params.push(s, s, s, s);
} }
query += ' ORDER BY i.name ASC'; query += ' ORDER BY i.name ASC';
return db.prepare(query).all(...params); return db.prepare(query).all(...params);
@@ -44,35 +113,495 @@ function getAllItems(search = '') {
function getItemById(id) { function getItemById(id) {
return db.prepare(` return db.prepare(`
SELECT i.*, c.name as category_name SELECT i.*, c.name as category_name,
COALESCE((SELECT SUM(quantity) FROM inventory_stock s WHERE s.item_id = i.id AND s.status = 'available'), 0) as stock_available,
COALESCE((SELECT SUM(quantity) FROM inventory_stock s WHERE s.item_id = i.id AND s.status = 'assigned'), 0) as stock_assigned,
COALESCE((SELECT SUM(quantity) FROM inventory_stock s WHERE s.item_id = i.id AND s.status = 'broken'), 0) as stock_broken
FROM inventory_items i FROM inventory_items i
LEFT JOIN inventory_categories c ON i.category_id = c.id LEFT JOIN inventory_categories c ON i.category_id = c.id
WHERE i.id = ? WHERE i.id = ?
`).get(id); `).get(id);
} }
function getItemDetail(id) {
const item = getItemById(id);
if (!item) return null;
const stockList = db.prepare(`
SELECT s.*, cust.name as customer_name, cust.pppoe_username as pppoe_user
FROM inventory_stock s
LEFT JOIN customers cust ON s.assigned_to_customer_id = cust.id
WHERE s.item_id = ?
ORDER BY CASE WHEN s.status = 'available' THEN 1 WHEN s.status = 'assigned' THEN 2 ELSE 3 END, s.created_at DESC
`).all(id);
const logs = db.prepare(`
SELECT l.*, s.serial_number
FROM inventory_logs l
LEFT JOIN inventory_stock s ON l.stock_id = s.id
WHERE l.item_id = ?
ORDER BY l.created_at DESC, l.id DESC
LIMIT 100
`).all(id);
return { item, stockList, logs };
}
function createItem(data) { function createItem(data) {
const { category_id, name, brand, model, unit, min_stock, description } = data; const { category_id, name, brand, model, unit, min_stock, description } = data;
if (!name || !name.trim()) throw new Error('Nama barang wajib diisi');
return db.prepare(` return db.prepare(`
INSERT INTO inventory_items (category_id, name, brand, model, unit, min_stock, description) INSERT INTO inventory_items (category_id, name, brand, model, unit, min_stock, description)
VALUES (?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?)
`).run(category_id, name, brand, model, unit, min_stock, description); `).run(
category_id ? Number(category_id) : null,
name.trim(),
brand ? brand.trim() : '',
model ? model.trim() : '',
unit ? unit.trim() : 'pcs',
Number(min_stock) || 5,
description ? description.trim() : ''
);
} }
function updateItem(id, data) { function updateItem(id, data) {
const { category_id, name, brand, model, unit, min_stock, description } = data; const { category_id, name, brand, model, unit, min_stock, description } = data;
if (!name || !name.trim()) throw new Error('Nama barang wajib diisi');
return db.prepare(` return db.prepare(`
UPDATE inventory_items UPDATE inventory_items
SET category_id = ?, name = ?, brand = ?, model = ?, unit = ?, min_stock = ?, description = ? SET category_id = ?, name = ?, brand = ?, model = ?, unit = ?, min_stock = ?, description = ?
WHERE id = ? WHERE id = ?
`).run(category_id, name, brand, model, unit, min_stock, description, id); `).run(
category_id ? Number(category_id) : null,
name.trim(),
brand ? brand.trim() : '',
model ? model.trim() : '',
unit ? unit.trim() : 'pcs',
Number(min_stock) || 5,
description ? description.trim() : '',
id
);
} }
function deleteItem(id) { function deleteItem(id) {
const stockCount = db.prepare('SELECT count(*) as c FROM inventory_stock WHERE item_id = ?').get(id)?.c || 0;
if (stockCount > 0) {
throw new Error(`Tidak dapat menghapus barang karena masih ada ${stockCount} data stok terkait. Silakan sesuaikan atau kosongkan stok terlebih dahulu.`);
}
return db.prepare('DELETE FROM inventory_items WHERE id = ?').run(id); return db.prepare('DELETE FROM inventory_items WHERE id = ?').run(id);
} }
// ─── STOCK ─────────────────────────────────────────────────────────────── // ─── SERIAL NUMBERS ───────────────────────────────────────────────────────
function getAvailableSerialNumbers(itemId) {
return db.prepare(`
SELECT id, serial_number, condition, location, note
FROM inventory_stock
WHERE item_id = ? AND status = 'available' AND serial_number IS NOT NULL AND TRIM(serial_number) != ''
ORDER BY serial_number ASC
`).all(itemId);
}
// ─── STOCK TRANSACTIONS ───────────────────────────────────────────────────
/**
* 1. INPUT STOK MASUK (IN)
* Menambah stok barang dan mencatat saldo sisa stok akhir secara presisi
*/
function addStock(data, actor = 'Admin') {
const { item_id, serial_number, quantity, condition, location, price, supplier, reference_no, note } = data;
if (!item_id) throw new Error('Pilih barang terlebih dahulu');
const item = getItemById(item_id);
if (!item) throw new Error('Data barang tidak ditemukan');
const cleanSn = serial_number ? serial_number.trim() : '';
const qty = cleanSn ? 1 : Math.max(1, parseInt(quantity, 10) || 1);
const run = db.transaction(() => {
let stockId = null;
if (cleanSn) {
// Cek duplikasi SN
const existing = db.prepare('SELECT id, status FROM inventory_stock WHERE serial_number = ?').get(cleanSn);
if (existing) {
throw new Error(`Serial Number "${cleanSn}" sudah pernah terdaftar di sistem (Status: ${existing.status})!`);
}
const res = db.prepare(`
INSERT INTO inventory_stock (item_id, serial_number, quantity, condition, location, status, price, supplier, note)
VALUES (?, ?, 1, ?, ?, 'available', ?, ?, ?)
`).run(item_id, cleanSn, condition || 'new', location || 'Gudang Utama', Number(price) || 0, supplier || '', note || '');
stockId = res.lastInsertRowid;
} else {
// Non-serial item
const res = db.prepare(`
INSERT INTO inventory_stock (item_id, serial_number, quantity, condition, location, status, price, supplier, note)
VALUES (?, NULL, ?, ?, ?, 'available', ?, ?, ?)
`).run(item_id, qty, condition || 'new', location || 'Gudang Utama', Number(price) || 0, supplier || '', note || '');
stockId = res.lastInsertRowid;
}
// Hitung sisa stok tersedia saat ini
const remainingStock = getItemAvailableStock(item_id);
// Catat log stok masuk lengkap dengan sisa stok
const logNote = note || (cleanSn ? `Stok Masuk SN: ${cleanSn}` : `Stok Masuk (+${qty} ${item.unit})`);
db.prepare(`
INSERT INTO inventory_logs (item_id, stock_id, type, quantity, remaining_stock, actor, recipient, reference_no, note)
VALUES (?, ?, 'in', ?, ?, ?, ?, ?, ?)
`).run(
item_id,
stockId,
qty,
remainingStock,
actor || 'Admin',
supplier || 'Gudang Utama',
reference_no || '',
logNote
);
return { stockId, remainingStock };
});
return run();
}
/**
* 2. PENGELUARAN BARANG (OUT / ISSUE)
* Mengeluarkan barang untuk instalasi baru, perbaikan, pemakaian teknisi, atau rusak
* Mengurangi stok secara otomatis dan mencatat sisa stok akhir
*/
function issueStock(data, actor = 'Admin') {
const { item_id, stock_id, serial_number, quantity, type, customer_id, recipient, reference_no, note } = data;
if (!item_id) throw new Error('Pilih barang terlebih dahulu');
const item = getItemById(item_id);
if (!item) throw new Error('Data barang tidak ditemukan');
const availableStock = getItemAvailableStock(item_id);
if (availableStock <= 0) {
throw new Error(`Stok ${item.name} saat ini kosong (0 ${item.unit})!`);
}
const issueType = type || (customer_id ? 'assigned' : 'out'); // 'out', 'assigned', 'broken', 'loss'
const run = db.transaction(() => {
let stockIdTarget = stock_id ? Number(stock_id) : null;
let qtyToDeduct = 1;
// A. JIKA DITARGETKAN KE SERIAL NUMBER SPESIFIK ATAU STOCK_ID SPESIFIK
if (serial_number && !stockIdTarget) {
const row = db.prepare('SELECT id, status FROM inventory_stock WHERE item_id = ? AND serial_number = ?').get(item_id, serial_number.trim());
if (!row) throw new Error(`Serial Number "${serial_number}" tidak ditemukan untuk barang ini`);
if (row.status !== 'available') throw new Error(`Barang SN "${serial_number}" statusnya "${row.status}", tidak dapat dikeluarkan!`);
stockIdTarget = row.id;
}
if (stockIdTarget) {
const stock = db.prepare('SELECT * FROM inventory_stock WHERE id = ?').get(stockIdTarget);
if (!stock) throw new Error('Data unit stok tidak ditemukan');
if (stock.status !== 'available') throw new Error(`Unit stok ini statusnya "${stock.status}", tidak tersedia`);
const targetStatus = (customer_id || issueType === 'assigned') ? 'assigned' : (issueType === 'broken' ? 'broken' : 'out');
const targetCustomer = customer_id ? Number(customer_id) : null;
db.prepare(`
UPDATE inventory_stock
SET status = ?, assigned_to_customer_id = ?, updated_at = (NOW_LOCAL())
WHERE id = ?
`).run(targetStatus, targetCustomer, stockIdTarget);
qtyToDeduct = stock.quantity || 1;
} else {
// B. BARANG NON-SERIAL / PENGURANGAN KUANTITAS (FIFO)
qtyToDeduct = Math.max(1, parseInt(quantity, 10) || 1);
if (qtyToDeduct > availableStock) {
throw new Error(`Stok tidak mencukupi! Tersedia di gudang: ${availableStock} ${item.unit}, diminta keluar: ${qtyToDeduct} ${item.unit}`);
}
// Ambil batch stok yang available terurut dari yang paling lama masuk (FIFO)
const batches = db.prepare(`
SELECT id, quantity
FROM inventory_stock
WHERE item_id = ? AND status = 'available'
ORDER BY created_at ASC, id ASC
`).all(item_id);
let needed = qtyToDeduct;
for (const b of batches) {
if (needed <= 0) break;
if (b.quantity <= needed) {
db.prepare("UPDATE inventory_stock SET quantity = 0, status = 'out', updated_at = (NOW_LOCAL()) WHERE id = ?").run(b.id);
needed -= b.quantity;
} else {
db.prepare("UPDATE inventory_stock SET quantity = quantity - ?, updated_at = (NOW_LOCAL()) WHERE id = ?").run(needed, b.id);
needed = 0;
break;
}
}
}
// Hitung sisa stok tersedia setelah pengeluaran
const remainingStock = getItemAvailableStock(item_id);
// Keterangan penerima (teknisi / pelanggan)
let finalRecipient = recipient ? recipient.trim() : '';
if (customer_id && !finalRecipient) {
const cust = db.prepare('SELECT name, pppoe_username as pppoe_user FROM customers WHERE id = ?').get(customer_id);
if (cust) finalRecipient = `Pelanggan: ${cust.name} (${cust.pppoe_user || '-'})`;
}
if (!finalRecipient) finalRecipient = 'Teknisi / Operasional';
const logNote = note || `Pengeluaran Barang (-${qtyToDeduct} ${item.unit})`;
// Catat log pengeluaran dengan sisa stok
db.prepare(`
INSERT INTO inventory_logs (item_id, stock_id, type, quantity, remaining_stock, actor, recipient, reference_no, note)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(
item_id,
stockIdTarget,
issueType,
-qtyToDeduct,
remainingStock,
actor || 'Admin',
finalRecipient,
reference_no || '',
logNote
);
return { qtyDeducted: qtyToDeduct, remainingStock };
});
return run();
}
/**
* 3. PENYESUAIAN STOK (STOCK OPNAME / ADJUSTMENT)
* Menyesuaikan data sistem dengan fisik gudang dan mencatat selisih & saldo akhir
*/
function adjustStock(data, actor = 'Admin') {
const { item_id, actual_quantity, note } = data;
if (!item_id) throw new Error('Pilih barang terlebih dahulu');
const item = getItemById(item_id);
if (!item) throw new Error('Data barang tidak ditemukan');
const actualQty = Math.max(0, parseInt(actual_quantity, 10));
if (isNaN(actualQty)) throw new Error('Jumlah stok fisik harus berupa angka');
const currentAvailable = getItemAvailableStock(item_id);
const diff = actualQty - currentAvailable;
if (diff === 0) {
return { diff: 0, remainingStock: actualQty, message: 'Jumlah stok sudah sesuai, tidak ada perubahan.' };
}
const run = db.transaction(() => {
if (diff > 0) {
// Stok fisik lebih banyak dari sistem -> Tambah selisih ke stok
db.prepare(`
INSERT INTO inventory_stock (item_id, serial_number, quantity, condition, location, status, note)
VALUES (?, NULL, ?, 'new', 'Gudang Utama', 'available', ?)
`).run(item_id, diff, note || 'Penyesuaian Fisik (Opname)');
} else {
// Stok fisik lebih sedikit dari sistem -> Kurangi selisih dari stok yang ada
let neededToReduce = Math.abs(diff);
const batches = db.prepare(`
SELECT id, quantity FROM inventory_stock
WHERE item_id = ? AND status = 'available'
ORDER BY created_at ASC, id ASC
`).all(item_id);
for (const b of batches) {
if (neededToReduce <= 0) break;
if (b.quantity <= neededToReduce) {
db.prepare("UPDATE inventory_stock SET quantity = 0, status = 'out', updated_at = (NOW_LOCAL()) WHERE id = ?").run(b.id);
neededToReduce -= b.quantity;
} else {
db.prepare("UPDATE inventory_stock SET quantity = quantity - ?, updated_at = (NOW_LOCAL()) WHERE id = ?").run(neededToReduce, b.id);
neededToReduce = 0;
break;
}
}
}
const logNote = note || `Stock Opname (Penyesuaian Fisik: ${diff > 0 ? '+' : ''}${diff} ${item.unit})`;
db.prepare(`
INSERT INTO inventory_logs (item_id, stock_id, type, quantity, remaining_stock, actor, recipient, reference_no, note)
VALUES (?, NULL, 'adjust', ?, ?, ?, 'Stock Opname', 'OPNAME', ?)
`).run(
item_id,
diff,
actualQty,
actor || 'Admin',
logNote
);
return { diff, remainingStock: actualQty };
});
return run();
}
/**
* 4. PENGEMBALIAN BARANG (RETURN / COPOTAN)
* Barang bekas / copotan dari pelanggan atau sisa pemakaian lapangan dikembalikan ke gudang
*/
function returnStock(data, actor = 'Admin') {
const { item_id, serial_number, quantity, condition, customer_id, returned_by, note } = data;
if (!item_id) throw new Error('Pilih barang terlebih dahulu');
const item = getItemById(item_id);
if (!item) throw new Error('Data barang tidak ditemukan');
const cleanSn = serial_number ? serial_number.trim() : '';
const qty = cleanSn ? 1 : Math.max(1, parseInt(quantity, 10) || 1);
const cond = condition || 'used';
const run = db.transaction(() => {
let stockId = null;
if (cleanSn) {
const existing = db.prepare('SELECT id, status FROM inventory_stock WHERE serial_number = ?').get(cleanSn);
if (existing) {
// Kembalikan unit yang sudah ada ke gudang
db.prepare(`
UPDATE inventory_stock
SET status = 'available', condition = ?, assigned_to_customer_id = NULL, location = 'Gudang Utama', updated_at = (NOW_LOCAL()), note = ?
WHERE id = ?
`).run(cond, note || 'Pengembalian / Copotan', existing.id);
stockId = existing.id;
} else {
const res = db.prepare(`
INSERT INTO inventory_stock (item_id, serial_number, quantity, condition, location, status, note)
VALUES (?, ?, 1, ?, 'Gudang Utama', 'available', ?)
`).run(item_id, cleanSn, cond, note || 'Pengembalian / Copotan');
stockId = res.lastInsertRowid;
}
} else {
const res = db.prepare(`
INSERT INTO inventory_stock (item_id, serial_number, quantity, condition, location, status, note)
VALUES (?, NULL, ?, ?, 'Gudang Utama', 'available', ?)
`).run(item_id, qty, cond, note || 'Pengembalian / Copotan');
stockId = res.lastInsertRowid;
}
const remainingStock = getItemAvailableStock(item_id);
let returner = returned_by ? returned_by.trim() : '';
if (customer_id && !returner) {
const cust = db.prepare('SELECT name FROM customers WHERE id = ?').get(customer_id);
if (cust) returner = `Copotan dari: ${cust.name}`;
}
if (!returner) returner = 'Teknisi Lapangan';
const logNote = note || (cleanSn ? `Pengembalian SN: ${cleanSn} (${cond})` : `Pengembalian (+${qty} ${item.unit})`);
db.prepare(`
INSERT INTO inventory_logs (item_id, stock_id, type, quantity, remaining_stock, actor, recipient, reference_no, note)
VALUES (?, ?, 'return', ?, ?, ?, ?, 'RETURN', ?)
`).run(
item_id,
stockId,
qty,
remainingStock,
actor || 'Admin',
returner,
logNote
);
return { stockId, remainingStock };
});
return run();
}
/**
* 5. RIWAYAT PERGERAKAN STOK (INVENTORY LOGS)
*/
function getInventoryLogs(options = {}) {
if (typeof options === 'number') options = { limit: options };
const limit = options.limit ? parseInt(options.limit, 10) : 100;
const itemId = options.itemId ? Number(options.itemId) : null;
const type = options.type ? options.type.trim() : null;
const search = options.search ? options.search.trim() : null;
let query = `
SELECT l.*,
i.name as item_name,
i.unit as item_unit,
i.brand as item_brand,
i.model as item_model,
c.name as category_name,
s.serial_number
FROM inventory_logs l
LEFT JOIN inventory_items i ON l.item_id = i.id
LEFT JOIN inventory_categories c ON i.category_id = c.id
LEFT JOIN inventory_stock s ON l.stock_id = s.id
WHERE 1=1
`;
const params = [];
if (itemId) {
query += ' AND l.item_id = ?';
params.push(itemId);
}
if (type) {
query += ' AND l.type = ?';
params.push(type);
}
if (search) {
query += ' AND (i.name LIKE ? OR s.serial_number LIKE ? OR l.recipient LIKE ? OR l.note LIKE ? OR l.reference_no LIKE ?)';
const s = `%${search}%`;
params.push(s, s, s, s, s);
}
query += ' ORDER BY l.created_at DESC, l.id DESC LIMIT ?';
params.push(limit);
return db.prepare(query).all(...params);
}
/**
* 6. STATISTIK GUDANG
*/
function getInventoryStats() {
seedDefaultCategories();
const totalItems = db.prepare('SELECT count(*) as c FROM inventory_items').get()?.c || 0;
const totalCategories = db.prepare('SELECT count(*) as c FROM inventory_categories').get()?.c || 0;
const stockSummary = db.prepare(`
SELECT
COALESCE(SUM(CASE WHEN status = 'available' THEN quantity ELSE 0 END), 0) as available,
COALESCE(SUM(CASE WHEN status = 'assigned' THEN quantity ELSE 0 END), 0) as assigned,
COALESCE(SUM(CASE WHEN status = 'broken' THEN quantity ELSE 0 END), 0) as broken
FROM inventory_stock
`).get();
const lowStockCount = db.prepare(`
SELECT count(*) as c FROM (
SELECT i.id
FROM inventory_items i
LEFT JOIN inventory_stock s ON s.item_id = i.id AND s.status = 'available'
GROUP BY i.id
HAVING COALESCE(SUM(s.quantity), 0) <= i.min_stock
)
`).get()?.c || 0;
return {
total_items: totalItems,
total_categories: totalCategories,
total_stock_available: Number(stockSummary?.available || 0),
total_stock_assigned: Number(stockSummary?.assigned || 0),
total_stock_broken: Number(stockSummary?.broken || 0),
low_stock_count: lowStockCount
};
}
// ─── KOMPATIBILITAS FORK ─────────────────────────────────────────────────
function getStockByItem(itemId) { function getStockByItem(itemId) {
return db.prepare(` return db.prepare(`
SELECT s.*, cust.name as customer_name SELECT s.*, cust.name as customer_name
@@ -83,78 +612,15 @@ function getStockByItem(itemId) {
`).all(itemId); `).all(itemId);
} }
function addStock(data, actor = 'Admin') {
const { item_id, serial_number, quantity, condition, location, note } = data;
const run = db.transaction(() => {
const result = db.prepare(`
INSERT INTO inventory_stock (item_id, serial_number, quantity, condition, location, note)
VALUES (?, ?, ?, ?, ?, ?)
`).run(item_id, serial_number || null, quantity, condition || 'new', location || 'Gudang Utama', note || '');
db.prepare(`
INSERT INTO inventory_logs (item_id, stock_id, type, quantity, actor, note)
VALUES (?, ?, 'in', ?, ?, ?)
`).run(item_id, result.lastInsertRowid, quantity, actor, note || 'Stock Masuk');
return result;
});
return run();
}
function assignStockToCustomer(stockId, customerId, actor = 'Admin', note = '') { function assignStockToCustomer(stockId, customerId, actor = 'Admin', note = '') {
const stock = db.prepare('SELECT * FROM inventory_stock WHERE id = ?').get(stockId); const stock = db.prepare('SELECT * FROM inventory_stock WHERE id = ?').get(stockId);
if (!stock) throw new Error('Stock tidak ditemukan'); if (!stock) throw new Error('Stock tidak ditemukan');
if (stock.status === 'assigned') throw new Error('Stock sudah terpasang di pelanggan lain'); return issueStock({ item_id: stock.item_id, stock_id: stockId, customer_id: customerId, note }, actor);
const run = db.transaction(() => {
db.prepare(`
UPDATE inventory_stock
SET status = 'assigned', assigned_to_customer_id = ?, updated_at = (NOW_LOCAL())
WHERE id = ?
`).run(customerId, stockId);
db.prepare(`
INSERT INTO inventory_logs (item_id, stock_id, type, quantity, actor, note)
VALUES (?, ?, 'out', ?, ?, ?)
`).run(stock.item_id, stockId, stock.quantity, actor, note || `Terpasang ke pelanggan ID: ${customerId}`);
});
return run();
}
function adjustStock(stockId, newQuantity, note, actor = 'Admin') {
const stock = db.prepare('SELECT * FROM inventory_stock WHERE id = ?').get(stockId);
if (!stock) throw new Error('Stock tidak ditemukan');
const run = db.transaction(() => {
const diff = newQuantity - stock.quantity;
db.prepare('UPDATE inventory_stock SET quantity = ?, updated_at = (NOW_LOCAL()) WHERE id = ?').run(newQuantity, stockId);
db.prepare(`
INSERT INTO inventory_logs (item_id, stock_id, type, quantity, actor, note)
VALUES (?, ?, 'adjust', ?, ?, ?)
`).run(stock.item_id, stockId, diff, actor, note || 'Penyesuaian Stock');
});
return run();
}
function getInventoryLogs(limit = 100) {
return db.prepare(`
SELECT l.*, i.name as item_name, s.serial_number
FROM inventory_logs l
LEFT JOIN inventory_items i ON l.item_id = i.id
LEFT JOIN inventory_stock s ON l.stock_id = s.id
ORDER BY l.created_at DESC
LIMIT ?
`).all(limit);
} }
function getLowStockItems() { function getLowStockItems() {
return db.prepare(` return db.prepare(`
SELECT i.*, c.name as category_name, SUM(s.quantity) as current_stock SELECT i.*, c.name as category_name, COALESCE(SUM(s.quantity), 0) as current_stock
FROM inventory_items i FROM inventory_items i
LEFT JOIN inventory_categories c ON i.category_id = c.id LEFT JOIN inventory_categories c ON i.category_id = c.id
LEFT JOIN inventory_stock s ON s.item_id = i.id AND s.status = 'available' LEFT JOIN inventory_stock s ON s.item_id = i.id AND s.status = 'available'
@@ -164,8 +630,11 @@ function getLowStockItems() {
} }
module.exports = { module.exports = {
seedDefaultCategories,
getAllCategories, createCategory, updateCategory, deleteCategory, getAllCategories, createCategory, updateCategory, deleteCategory,
getAllItems, getItemById, createItem, updateItem, deleteItem, getAllItems, getItemById, getItemDetail, createItem, updateItem, deleteItem,
getStockByItem, addStock, assignStockToCustomer, adjustStock, getAvailableSerialNumbers, getItemAvailableStock, getItemAssignedStock, getItemBrokenStock,
getInventoryLogs, getLowStockItems addStock, issueStock, adjustStock, returnStock,
getInventoryLogs, getInventoryStats,
getStockByItem, assignStockToCustomer, getLowStockItems
}; };

View File

@@ -435,6 +435,67 @@ function getAvailablePeriods() {
`).all(); `).all();
} }
function updateSlip(id, data) {
const slip = getSlipById(id);
if (!slip) throw new Error('Slip tidak ditemukan');
if (slip.status !== 'draft') throw new Error('Hanya slip berstatus draft yang dapat diedit');
const pickInt = (key, fallback) => (data[key] !== undefined && data[key] !== '') ? parseInt(data[key], 10) || 0 : fallback;
const pickFloat = (key, fallback) => (data[key] !== undefined && data[key] !== '') ? parseFloat(data[key]) || 0 : fallback;
const baseSalary = pickInt('base_salary', slip.base_salary);
const transportAllowance = pickInt('transport_allowance', slip.transport_allowance);
const mealAllowance = pickInt('meal_allowance', slip.meal_allowance);
const phoneAllowance = pickInt('phone_allowance', slip.phone_allowance);
const otherAllowance = pickInt('other_allowance', slip.other_allowance);
const otherAllowanceNote = data.other_allowance_note !== undefined ? String(data.other_allowance_note) : slip.other_allowance_note;
const workingDays = pickInt('working_days', slip.working_days);
const absentDays = pickInt('absent_days', slip.absent_days);
const lateDays = pickInt('late_days', slip.late_days);
const overtimeHours = pickFloat('overtime_hours', slip.overtime_hours);
const overtimeBonus = pickInt('overtime_bonus', slip.overtime_bonus);
const ticketBonus = pickInt('ticket_bonus', slip.ticket_bonus);
const collectionCommission = pickInt('collection_commission', slip.collection_commission);
const absenceDeduction = pickInt('absence_deduction', slip.absence_deduction);
const lateDeduction = pickInt('late_deduction', slip.late_deduction);
const otherDeduction = pickInt('other_deduction', slip.other_deduction);
const otherDeductionNote = data.other_deduction_note !== undefined ? String(data.other_deduction_note) : slip.other_deduction_note;
const notes = data.notes !== undefined ? String(data.notes) : slip.notes;
const grossSalary = baseSalary + transportAllowance + mealAllowance + phoneAllowance + otherAllowance + ticketBonus + collectionCommission + overtimeBonus;
const totalDeductions = absenceDeduction + lateDeduction + otherDeduction;
const netSalary = Math.max(0, grossSalary - totalDeductions);
db.prepare(`
UPDATE payroll_slips SET
base_salary = ?, transport_allowance = ?, meal_allowance = ?,
phone_allowance = ?, other_allowance = ?, other_allowance_note = ?,
working_days = ?, absent_days = ?, late_days = ?,
overtime_hours = ?, overtime_bonus = ?,
ticket_bonus = ?, collection_commission = ?,
absence_deduction = ?, late_deduction = ?,
other_deduction = ?, other_deduction_note = ?,
gross_salary = ?, total_deductions = ?, net_salary = ?,
notes = ?
WHERE id = ?
`).run(
baseSalary, transportAllowance, mealAllowance,
phoneAllowance, otherAllowance, otherAllowanceNote || '',
workingDays, absentDays, lateDays,
overtimeHours, overtimeBonus,
ticketBonus, collectionCommission,
absenceDeduction, lateDeduction,
otherDeduction, otherDeductionNote || '',
grossSalary, totalDeductions, netSalary,
notes || '', id
);
return getSlipById(id);
}
module.exports = { module.exports = {
EMPLOYEE_TYPES, EMPLOYEE_TYPES,
getPayrollSetting, getAllPayrollSettings, upsertPayrollSetting, deletePayrollSetting, getPayrollSetting, getAllPayrollSettings, upsertPayrollSetting, deletePayrollSetting,
@@ -442,7 +503,7 @@ module.exports = {
getTicketsResolvedCount, getCollectionAmount, getAttendanceData, getTicketsResolvedCount, getCollectionAmount, getAttendanceData,
generateSlip, generateAllSlips, generateSlip, generateAllSlips,
getSlipById, getSlipsByPeriod, getSlipsByEmployee, getSlipById, getSlipsByPeriod, getSlipsByEmployee,
updateSlipDeductions, approveSlip, markSlipPaid, updateSlipDeductions, updateSlip, approveSlip, markSlipPaid,
bulkApprove, bulkMarkPaid, bulkApprove, bulkMarkPaid,
deleteSlip, deleteSlipsByPeriod, deleteSlip, deleteSlipsByPeriod,
getPayrollSummary, getAvailablePeriods getPayrollSummary, getAvailablePeriods

View File

@@ -93,6 +93,9 @@
<div class="act-menu"> <div class="act-menu">
<button type="button" class="act-item" <%- `onclick="viewStock(${item.id}, '${item.name}')"` %>><i class="bi bi-eye"></i> <%= t('admin.inventory.detail_stock', 'Detail Stok') %></button> <button type="button" class="act-item" <%- `onclick="viewStock(${item.id}, '${item.name}')"` %>><i class="bi bi-eye"></i> <%= t('admin.inventory.detail_stock', 'Detail Stok') %></button>
<button type="button" class="act-item success" <%- `onclick="openAddStock(${item.id}, '${item.name}')"` %>><i class="bi bi-plus-circle"></i> <%= t('admin.inventory.add_stock', 'Tambah Stok') %></button> <button type="button" class="act-item success" <%- `onclick="openAddStock(${item.id}, '${item.name}')"` %>><i class="bi bi-plus-circle"></i> <%= t('admin.inventory.add_stock', 'Tambah Stok') %></button>
<button type="button" class="act-item" <%- `onclick="openIssueStock(${item.id}, '${item.name}')"` %>><i class="bi bi-box-arrow-up"></i> Stok Keluar</button>
<button type="button" class="act-item" <%- `onclick="openReturnStock(${item.id}, '${item.name}')"` %>><i class="bi bi-arrow-return-left"></i> Retur</button>
<button type="button" class="act-item" <%- `onclick="openAdjustStock(${item.id}, '${item.name}')"` %>><i class="bi bi-clipboard-check"></i> Opname</button>
<button type="button" class="act-item" <%- `onclick='openEditItem(${JSON.stringify(item)})'` %>><i class="bi bi-pencil"></i> Edit Barang</button> <button type="button" class="act-item" <%- `onclick='openEditItem(${JSON.stringify(item)})'` %>><i class="bi bi-pencil"></i> Edit Barang</button>
</div> </div>
</div> </div>
@@ -250,6 +253,76 @@
</div> </div>
</div> </div>
<!-- MODAL: STOK KELUAR -->
<div class="mo" id="modal-issue">
<div class="mb">
<div class="mh"><div class="mt">Stok Keluar</div><button class="mc" onclick="closeModal('modal-issue')">&times;</button></div>
<form action="/admin/inventory/stock/out" method="POST">
<input type="hidden" name="item_id" id="o_item_id">
<div class="mbody">
<div class="fg"><label class="fl">Nama Barang</label><input class="fc" id="o_item_name" readonly></div>
<div class="fg"><label class="fl">Serial Number (kosongkan untuk barang tanpa SN)</label><input class="fc" name="serial_number" list="o_serials" placeholder="Pilih / scan SN"><datalist id="o_serials"></datalist></div>
<div class="form-row">
<div class="fg"><label class="fl">Jumlah</label><input class="fc" type="number" name="quantity" value="1" min="1"></div>
<div class="fg"><label class="fl">Tipe</label>
<select class="fs" name="type"><option value="out">Keluar / Operasional</option><option value="assigned">Terpasang ke Pelanggan</option><option value="broken">Rusak</option><option value="loss">Hilang</option></select>
</div>
</div>
<div class="fg"><label class="fl">Penerima / Teknisi</label><input class="fc" name="recipient" placeholder="Nama teknisi / pelanggan"></div>
<div class="fg"><label class="fl">Catatan</label><textarea class="fc" name="note"></textarea></div>
</div>
<div class="mf"><button type="button" class="btn btn-g" onclick="closeModal('modal-issue')">Batal</button><button class="btn btn-p">Simpan</button></div>
</form>
</div>
</div>
<!-- MODAL: RETUR -->
<div class="mo" id="modal-return">
<div class="mb">
<div class="mh"><div class="mt">Retur Barang</div><button class="mc" onclick="closeModal('modal-return')">&times;</button></div>
<form action="/admin/inventory/stock/return" method="POST">
<input type="hidden" name="item_id" id="r_item_id">
<div class="mbody">
<div class="fg"><label class="fl">Nama Barang</label><input class="fc" id="r_item_name" readonly></div>
<div class="fg"><label class="fl">Serial Number (untuk unit ber-SN)</label><input class="fc" name="serial_number" placeholder="Kosongkan untuk barang tanpa SN"></div>
<div class="form-row">
<div class="fg"><label class="fl">Jumlah</label><input class="fc" type="number" name="quantity" value="1" min="1"></div>
<div class="fg"><label class="fl">Kondisi</label>
<select class="fs" name="condition"><option value="used">Bekas / Copotan</option><option value="new">Baru</option><option value="broken">Rusak</option></select>
</div>
</div>
<div class="fg"><label class="fl">Catatan</label><textarea class="fc" name="note"></textarea></div>
</div>
<div class="mf"><button type="button" class="btn btn-g" onclick="closeModal('modal-return')">Batal</button><button class="btn btn-p">Simpan</button></div>
</form>
</div>
</div>
<!-- MODAL: OPNAME -->
<div class="mo" id="modal-adjust">
<div class="mb">
<div class="mh"><div class="mt">Stock Opname</div><button class="mc" onclick="closeModal('modal-adjust')">&times;</button></div>
<form action="/admin/inventory/stock/adjust" method="POST">
<input type="hidden" name="item_id" id="a_item_id">
<div class="mbody">
<div class="fg"><label class="fl">Nama Barang</label><input class="fc" id="a_item_name" readonly></div>
<div class="fg"><label class="fl">Jumlah Fisik di Gudang</label><input class="fc" type="number" name="actual_quantity" min="0" required></div>
<div class="fg"><label class="fl">Catatan</label><textarea class="fc" name="note"></textarea></div>
</div>
<div class="mf"><button type="button" class="btn btn-g" onclick="closeModal('modal-adjust')">Batal</button><button class="btn btn-p">Simpan</button></div>
</form>
</div>
</div>
<!-- MODAL: DETAIL STOK -->
<div class="mo" id="modal-detail">
<div class="mb">
<div class="mh"><div class="mt" id="d_title">Detail Stok</div><button class="mc" onclick="closeModal('modal-detail')">&times;</button></div>
<div class="mbody" id="d_body"></div>
</div>
</div>
<script> <script>
var I18N_INV = { var I18N_INV = {
addTitle: "<%= t('admin.inventory.modal_add', 'Tambah Barang Baru') %>", addTitle: "<%= t('admin.inventory.modal_add', 'Tambah Barang Baru') %>",
@@ -288,6 +361,48 @@
document.getElementById('modal-stock').classList.add('show'); document.getElementById('modal-stock').classList.add('show');
} }
function openIssueStock(itemId, itemName) {
document.getElementById('o_item_id').value = itemId;
document.getElementById('o_item_name').value = itemName;
var dl = document.getElementById('o_serials');
dl.innerHTML = '';
fetch('/admin/api/inventory/serials/' + itemId).then(r => r.json()).then(d => {
(d.serials || []).forEach(function (s) {
var opt = document.createElement('option');
opt.value = s.serial_number;
dl.appendChild(opt);
});
}).catch(function () {});
document.getElementById('modal-issue').classList.add('show');
}
function openReturnStock(itemId, itemName) {
document.getElementById('r_item_id').value = itemId;
document.getElementById('r_item_name').value = itemName;
document.getElementById('modal-return').classList.add('show');
}
function openAdjustStock(itemId, itemName) {
document.getElementById('a_item_id').value = itemId;
document.getElementById('a_item_name').value = itemName;
document.getElementById('modal-adjust').classList.add('show');
}
function viewStock(itemId, itemName) {
document.getElementById('d_title').innerText = 'Detail Stok — ' + itemName;
var body = document.getElementById('d_body');
body.innerHTML = 'Memuat…';
fetch('/admin/api/inventory/item/' + itemId).then(r => r.json()).then(function (d) {
if (!d.success) { body.innerText = d.message || 'Gagal memuat'; return; }
var rows = (d.stockList || []).map(function (s) {
return '<tr><td>' + (s.serial_number || '-') + '</td><td>' + s.status + '</td><td class="num">' + s.quantity + '</td><td>' + (s.customer_name || '-') + '</td></tr>';
}).join('');
body.innerHTML = '<table class="dtbl"><thead><tr><th>SN</th><th>Status</th><th class="num">Qty</th><th>Pelanggan</th></tr></thead><tbody>' + (rows || '<tr><td colspan="4" class="text-muted">Belum ada unit stok</td></tr>') + '</tbody></table>';
}).catch(function () { body.innerText = 'Gagal memuat'; });
document.getElementById('modal-detail').classList.add('show');
}
function closeModal(id) { document.getElementById(id).classList.remove('show'); } function closeModal(id) { document.getElementById(id).classList.remove('show'); }
function filterItems(q) { function filterItems(q) {