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:
@@ -1107,6 +1107,18 @@ try {
|
||||
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); }
|
||||
|
||||
// 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.getAppSetting = getAppSetting;
|
||||
module.exports.saveAppSetting = saveAppSetting;
|
||||
|
||||
@@ -5096,7 +5096,8 @@ router.post('/backup/cleanup', requireAdminSession, express.urlencoded({ extende
|
||||
router.get('/inventory', requireAdminSession, requireSidebarMenuAccess('inventory'), (req, res) => {
|
||||
const items = inventorySvc.getAllItems(req.query.q);
|
||||
const categories = inventorySvc.getAllCategories();
|
||||
const logs = inventorySvc.getInventoryLogs(100);
|
||||
const logs = inventorySvc.getInventoryLogs({ limit: 100 });
|
||||
const stats = inventorySvc.getInventoryStats();
|
||||
|
||||
res.render('admin/inventory', {
|
||||
title: 'Manajemen Inventaris',
|
||||
@@ -5105,7 +5106,8 @@ router.get('/inventory', requireAdminSession, requireSidebarMenuAccess('inventor
|
||||
msg: flashMsg(req),
|
||||
items,
|
||||
categories,
|
||||
logs
|
||||
logs,
|
||||
stats
|
||||
});
|
||||
});
|
||||
|
||||
@@ -5159,6 +5161,54 @@ router.post('/inventory/stock/add', requireAdminSession, express.urlencoded({ ex
|
||||
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) => {
|
||||
const filters = {
|
||||
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) => {
|
||||
try {
|
||||
payrollSvc.approveSlip(req.params.id);
|
||||
|
||||
@@ -1,42 +1,111 @@
|
||||
/**
|
||||
* Service: Manajemen Inventaris / Gudang (Warehouse)
|
||||
* Sistem Pelacakan Stok, Pemasukan, Pengeluaran & Sisa Stok Terintegrasi
|
||||
*/
|
||||
const db = require('../config/database');
|
||||
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 ───────────────────────────────────────────────────────────
|
||||
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) {
|
||||
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) {
|
||||
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) {
|
||||
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);
|
||||
}
|
||||
|
||||
// ─── 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 ───────────────────────────────────────────────────────────────
|
||||
function getAllItems(search = '') {
|
||||
function getAllItems(search = '', categoryId = null) {
|
||||
seedDefaultCategories();
|
||||
let query = `
|
||||
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,
|
||||
(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 = '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
|
||||
LEFT JOIN inventory_categories c ON i.category_id = c.id
|
||||
WHERE 1=1
|
||||
`;
|
||||
const params = [];
|
||||
if (categoryId) {
|
||||
query += ' AND i.category_id = ?';
|
||||
params.push(categoryId);
|
||||
}
|
||||
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}%`;
|
||||
params.push(s, s, s);
|
||||
params.push(s, s, s, s);
|
||||
}
|
||||
query += ' ORDER BY i.name ASC';
|
||||
return db.prepare(query).all(...params);
|
||||
@@ -44,35 +113,495 @@ function getAllItems(search = '') {
|
||||
|
||||
function getItemById(id) {
|
||||
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
|
||||
LEFT JOIN inventory_categories c ON i.category_id = c.id
|
||||
WHERE i.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) {
|
||||
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(`
|
||||
INSERT INTO inventory_items (category_id, name, brand, model, unit, min_stock, description)
|
||||
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) {
|
||||
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(`
|
||||
UPDATE inventory_items
|
||||
SET category_id = ?, name = ?, brand = ?, model = ?, unit = ?, min_stock = ?, description = ?
|
||||
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) {
|
||||
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);
|
||||
}
|
||||
|
||||
// ─── 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) {
|
||||
return db.prepare(`
|
||||
SELECT s.*, cust.name as customer_name
|
||||
@@ -83,78 +612,15 @@ function getStockByItem(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 = '') {
|
||||
const stock = db.prepare('SELECT * FROM inventory_stock WHERE id = ?').get(stockId);
|
||||
if (!stock) throw new Error('Stock tidak ditemukan');
|
||||
if (stock.status === 'assigned') throw new Error('Stock sudah terpasang di pelanggan lain');
|
||||
|
||||
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);
|
||||
return issueStock({ item_id: stock.item_id, stock_id: stockId, customer_id: customerId, note }, actor);
|
||||
}
|
||||
|
||||
function getLowStockItems() {
|
||||
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
|
||||
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'
|
||||
@@ -164,8 +630,11 @@ function getLowStockItems() {
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
seedDefaultCategories,
|
||||
getAllCategories, createCategory, updateCategory, deleteCategory,
|
||||
getAllItems, getItemById, createItem, updateItem, deleteItem,
|
||||
getStockByItem, addStock, assignStockToCustomer, adjustStock,
|
||||
getInventoryLogs, getLowStockItems
|
||||
getAllItems, getItemById, getItemDetail, createItem, updateItem, deleteItem,
|
||||
getAvailableSerialNumbers, getItemAvailableStock, getItemAssignedStock, getItemBrokenStock,
|
||||
addStock, issueStock, adjustStock, returnStock,
|
||||
getInventoryLogs, getInventoryStats,
|
||||
getStockByItem, assignStockToCustomer, getLowStockItems
|
||||
};
|
||||
|
||||
@@ -435,6 +435,67 @@ function getAvailablePeriods() {
|
||||
`).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 = {
|
||||
EMPLOYEE_TYPES,
|
||||
getPayrollSetting, getAllPayrollSettings, upsertPayrollSetting, deletePayrollSetting,
|
||||
@@ -442,7 +503,7 @@ module.exports = {
|
||||
getTicketsResolvedCount, getCollectionAmount, getAttendanceData,
|
||||
generateSlip, generateAllSlips,
|
||||
getSlipById, getSlipsByPeriod, getSlipsByEmployee,
|
||||
updateSlipDeductions, approveSlip, markSlipPaid,
|
||||
updateSlipDeductions, updateSlip, approveSlip, markSlipPaid,
|
||||
bulkApprove, bulkMarkPaid,
|
||||
deleteSlip, deleteSlipsByPeriod,
|
||||
getPayrollSummary, getAvailablePeriods
|
||||
|
||||
@@ -93,6 +93,9 @@
|
||||
<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 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>
|
||||
</div>
|
||||
</div>
|
||||
@@ -250,6 +253,76 @@
|
||||
</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')">×</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')">×</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')">×</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')">×</button></div>
|
||||
<div class="mbody" id="d_body"></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
var I18N_INV = {
|
||||
addTitle: "<%= t('admin.inventory.modal_add', 'Tambah Barang Baru') %>",
|
||||
@@ -288,6 +361,48 @@
|
||||
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 filterItems(q) {
|
||||
|
||||
Reference in New Issue
Block a user