case hỏng

This commit is contained in:
2026-05-15 10:51:39 +07:00
parent 12c6380ceb
commit 41e523ff35
4 changed files with 815 additions and 4 deletions

View File

@@ -692,6 +692,19 @@ function normalizeImportToken(value) {
.replace(/[^a-z0-9]/g, '');
}
function normalizeAssetDamageType(value) {
const normalized = normalizeImportToken(value);
if (['disposed', 'dispose', 'disposal', 'thanhly', 'liquidated', 'liquidation'].includes(normalized)) {
return 'disposed';
}
return 'damaged';
}
function getAssetDamageTypeLabel(value) {
return normalizeAssetDamageType(value) === 'disposed' ? 'Thanh lý' : 'Hỏng';
}
function isHeaderLikeAssetImportRow(row = {}) {
const headerTokens = new Set([
'stt',
@@ -1841,6 +1854,41 @@ async function createTables() {
FOREIGN KEY (AssetId) REFERENCES AssetInventory(AssetId) ON DELETE CASCADE
)
END`,
// Asset Damage/Disposal History Table
`IF NOT EXISTS (SELECT * FROM sys.tables WHERE name = 'AssetDamageDisposalHistory')
BEGIN
CREATE TABLE AssetDamageDisposalHistory (
DamageHistoryId INT PRIMARY KEY IDENTITY(1,1),
AssetId INT NOT NULL,
AssetCode NVARCHAR(100) NOT NULL,
AssetName NVARCHAR(255) NOT NULL,
ActionType NVARCHAR(20) NOT NULL,
ActionLabel NVARCHAR(50) NOT NULL,
ActionQuantity INT NOT NULL DEFAULT 1,
Unit NVARCHAR(50) NULL,
PreviousQuantity INT NOT NULL DEFAULT 0,
NextQuantity INT NOT NULL DEFAULT 0,
PreviousImportInPeriod INT NOT NULL DEFAULT 0,
NextImportInPeriod INT NOT NULL DEFAULT 0,
PreviousExportInPeriod INT NOT NULL DEFAULT 0,
NextExportInPeriod INT NOT NULL DEFAULT 0,
PreviousEndingBalance INT NOT NULL DEFAULT 0,
NextEndingBalance INT NOT NULL DEFAULT 0,
PreviousNewQuantity INT NOT NULL DEFAULT 0,
NextNewQuantity INT NOT NULL DEFAULT 0,
PreviousUsedQuantity INT NOT NULL DEFAULT 0,
NextUsedQuantity INT NOT NULL DEFAULT 0,
ActionNote NVARCHAR(1000) NULL,
CreatedBy INT NULL,
CreatedByName NVARCHAR(100) NULL,
ActionDate DATETIME NOT NULL DEFAULT GETDATE(),
CreatedDate DATETIME NOT NULL DEFAULT GETDATE(),
UpdatedDate DATETIME NOT NULL DEFAULT GETDATE(),
FOREIGN KEY (AssetId) REFERENCES AssetInventory(AssetId) ON DELETE CASCADE,
FOREIGN KEY (CreatedBy) REFERENCES Users(UserId) ON DELETE SET NULL
)
END`,
// AuditLog Table
`IF NOT EXISTS (SELECT * FROM sys.tables WHERE name = 'AuditLog')
@@ -1911,6 +1959,15 @@ async function createTables() {
console.error('AssetExportHistory index creation error:', err.message);
}
// Ensure AssetDamageDisposalHistory indexes exist
try {
await pool.request().query(`IF NOT EXISTS (SELECT * FROM sys.indexes WHERE name = 'IX_AssetDamageDisposalHistory_AssetId') CREATE INDEX IX_AssetDamageDisposalHistory_AssetId ON AssetDamageDisposalHistory(AssetId);`);
await pool.request().query(`IF NOT EXISTS (SELECT * FROM sys.indexes WHERE name = 'IX_AssetDamageDisposalHistory_ActionDate') CREATE INDEX IX_AssetDamageDisposalHistory_ActionDate ON AssetDamageDisposalHistory(ActionDate DESC);`);
await pool.request().query(`IF NOT EXISTS (SELECT * FROM sys.indexes WHERE name = 'IX_AssetDamageDisposalHistory_ActionType') CREATE INDEX IX_AssetDamageDisposalHistory_ActionType ON AssetDamageDisposalHistory(ActionType);`);
} catch (err) {
console.error('AssetDamageDisposalHistory index creation error:', err.message);
}
// Ensure new columns exist on Applications for migrations
try {
await pool.request().query(`IF EXISTS (
@@ -4979,6 +5036,291 @@ app.get('/api/asset-export-history', requireAssetOrAdmin, async (req, res) => {
}
});
app.get('/api/asset-damage-disposal-history', requireAssetOrAdmin, async (req, res) => {
try {
const limit = Math.min(parsePositiveInteger(req.query.limit, 300), 2000);
const result = await pool.request()
.input('limit', sql.Int, limit)
.query(`
SELECT TOP (@limit)
DamageHistoryId,
AssetId,
AssetCode,
AssetName,
ActionType,
ActionLabel,
ActionQuantity,
Unit,
PreviousQuantity,
NextQuantity,
PreviousImportInPeriod,
NextImportInPeriod,
PreviousExportInPeriod,
NextExportInPeriod,
PreviousEndingBalance,
NextEndingBalance,
PreviousNewQuantity,
NextNewQuantity,
PreviousUsedQuantity,
NextUsedQuantity,
ActionNote,
CreatedBy,
CreatedByName,
ActionDate,
CreatedDate,
UpdatedDate
FROM AssetDamageDisposalHistory
ORDER BY ActionDate DESC, DamageHistoryId DESC
`);
res.json({
success: true,
data: Array.isArray(result.recordset) ? result.recordset : []
});
} catch (err) {
res.status(500).json({ success: false, message: err.message });
}
});
app.post('/api/assets/:id/damage-disposal', requireAssetOrAdmin, async (req, res) => {
let transaction;
try {
const assetId = Number(req.params.id);
const actionType = normalizeAssetDamageType(req.body?.actionType || req.body?.reason);
const actionLabel = getAssetDamageTypeLabel(actionType);
const actionQuantity = parseNonNegativeInteger(req.body?.quantity, 0);
const actionNote = String(req.body?.note || '').trim() || null;
const createdBy = getUserIdFromRequest(req);
const createdByName = await getUserDisplayNameById(createdBy);
const actionDate = new Date();
if (!Number.isInteger(assetId) || assetId <= 0) {
return res.status(400).json({ success: false, message: 'Asset id is invalid' });
}
if (actionQuantity <= 0) {
return res.status(400).json({ success: false, message: 'Số lượng phải lớn hơn 0' });
}
transaction = new sql.Transaction(pool);
await transaction.begin();
const assetResult = await new sql.Request(transaction)
.input('assetId', sql.Int, assetId)
.query(`
SELECT TOP 1
AssetId,
AssetCode,
AssetName,
Quantity,
ImportInPeriod,
ExportInPeriod,
EndingBalance,
NewQuantity,
UsedQuantity,
Unit
FROM AssetInventory WITH (UPDLOCK, ROWLOCK)
WHERE AssetId = @assetId
`);
const asset = assetResult.recordset?.[0];
if (!asset) {
await transaction.rollback();
return res.status(404).json({ success: false, message: 'Asset not found' });
}
const previousQuantity = parseNonNegativeInteger(asset.Quantity, 0);
const previousImportInPeriod = parseNonNegativeInteger(asset.ImportInPeriod, 0);
const previousExportInPeriod = parseNonNegativeInteger(asset.ExportInPeriod, 0);
const storedEndingBalance = parseOptionalNonNegativeInteger(asset.EndingBalance);
const previousEndingBalance = storedEndingBalance !== null
? storedEndingBalance
: Math.max(previousQuantity + previousImportInPeriod - previousExportInPeriod, 0);
const previousStockBuckets = normalizeAssetStockBuckets(
previousEndingBalance,
parseOptionalNonNegativeInteger(asset.NewQuantity) ?? previousEndingBalance,
parseOptionalNonNegativeInteger(asset.UsedQuantity) ?? 0
);
if (previousEndingBalance <= 0) {
await transaction.rollback();
return res.status(400).json({ success: false, message: 'Tài sản đã hết tồn cuối kỳ' });
}
if (actionQuantity > previousEndingBalance) {
await transaction.rollback();
return res.status(400).json({
success: false,
message: `Số lượng ${actionLabel.toLowerCase()} (${actionQuantity}) vượt quá tồn cuối kỳ (${previousEndingBalance})`
});
}
let remainingSourceReduction = actionQuantity;
const reduceFromQuantity = Math.min(previousQuantity, remainingSourceReduction);
const nextQuantity = Math.max(previousQuantity - reduceFromQuantity, 0);
remainingSourceReduction -= reduceFromQuantity;
const reduceFromImport = Math.min(previousImportInPeriod, remainingSourceReduction);
const nextImportInPeriod = Math.max(previousImportInPeriod - reduceFromImport, 0);
const nextExportInPeriod = previousExportInPeriod;
const nextEndingBalance = Math.max(nextQuantity + nextImportInPeriod - nextExportInPeriod, 0);
let nextUsedQuantity = previousStockBuckets.usedQuantity;
let nextNewQuantity = previousStockBuckets.newQuantity;
let remainingStockReduction = actionQuantity;
const reduceFromUsed = Math.min(nextUsedQuantity, remainingStockReduction);
nextUsedQuantity -= reduceFromUsed;
remainingStockReduction -= reduceFromUsed;
const reduceFromNew = Math.min(nextNewQuantity, remainingStockReduction);
nextNewQuantity -= reduceFromNew;
const nextStockBuckets = normalizeAssetStockBuckets(nextEndingBalance, nextNewQuantity, nextUsedQuantity);
const nextStatus = resolveAssetStatusFromStock(nextEndingBalance, nextExportInPeriod);
await new sql.Request(transaction)
.input('assetId', sql.Int, assetId)
.input('quantity', sql.Int, nextQuantity)
.input('importInPeriod', sql.Int, nextImportInPeriod)
.input('endingBalance', sql.Int, nextEndingBalance)
.input('newQuantity', sql.Int, nextStockBuckets.newQuantity)
.input('usedQuantity', sql.Int, nextStockBuckets.usedQuantity)
.input('status', sql.NVarChar, nextStatus)
.query(`
UPDATE AssetInventory
SET Quantity = @quantity,
ImportInPeriod = @importInPeriod,
EndingBalance = @endingBalance,
NewQuantity = @newQuantity,
UsedQuantity = @usedQuantity,
Status = @status,
UpdatedDate = GETDATE()
WHERE AssetId = @assetId
`);
const historyResult = await new sql.Request(transaction)
.input('assetId', sql.Int, assetId)
.input('assetCode', sql.NVarChar, String(asset.AssetCode || '').trim())
.input('assetName', sql.NVarChar, String(asset.AssetName || '').trim())
.input('actionType', sql.NVarChar, actionType)
.input('actionLabel', sql.NVarChar, actionLabel)
.input('actionQuantity', sql.Int, actionQuantity)
.input('unit', sql.NVarChar, String(asset.Unit || '').trim() || null)
.input('previousQuantity', sql.Int, previousQuantity)
.input('nextQuantity', sql.Int, nextQuantity)
.input('previousImportInPeriod', sql.Int, previousImportInPeriod)
.input('nextImportInPeriod', sql.Int, nextImportInPeriod)
.input('previousExportInPeriod', sql.Int, previousExportInPeriod)
.input('nextExportInPeriod', sql.Int, nextExportInPeriod)
.input('previousEndingBalance', sql.Int, previousEndingBalance)
.input('nextEndingBalance', sql.Int, nextEndingBalance)
.input('previousNewQuantity', sql.Int, previousStockBuckets.newQuantity)
.input('nextNewQuantity', sql.Int, nextStockBuckets.newQuantity)
.input('previousUsedQuantity', sql.Int, previousStockBuckets.usedQuantity)
.input('nextUsedQuantity', sql.Int, nextStockBuckets.usedQuantity)
.input('actionNote', sql.NVarChar, actionNote)
.input('createdBy', sql.Int, createdBy)
.input('createdByName', sql.NVarChar, createdByName)
.input('actionDate', sql.DateTime, actionDate)
.query(`
INSERT INTO AssetDamageDisposalHistory (
AssetId,
AssetCode,
AssetName,
ActionType,
ActionLabel,
ActionQuantity,
Unit,
PreviousQuantity,
NextQuantity,
PreviousImportInPeriod,
NextImportInPeriod,
PreviousExportInPeriod,
NextExportInPeriod,
PreviousEndingBalance,
NextEndingBalance,
PreviousNewQuantity,
NextNewQuantity,
PreviousUsedQuantity,
NextUsedQuantity,
ActionNote,
CreatedBy,
CreatedByName,
ActionDate
)
OUTPUT
INSERTED.DamageHistoryId,
INSERTED.AssetId,
INSERTED.AssetCode,
INSERTED.AssetName,
INSERTED.ActionType,
INSERTED.ActionLabel,
INSERTED.ActionQuantity,
INSERTED.Unit,
INSERTED.PreviousQuantity,
INSERTED.NextQuantity,
INSERTED.PreviousImportInPeriod,
INSERTED.NextImportInPeriod,
INSERTED.PreviousExportInPeriod,
INSERTED.NextExportInPeriod,
INSERTED.PreviousEndingBalance,
INSERTED.NextEndingBalance,
INSERTED.PreviousNewQuantity,
INSERTED.NextNewQuantity,
INSERTED.PreviousUsedQuantity,
INSERTED.NextUsedQuantity,
INSERTED.ActionNote,
INSERTED.CreatedBy,
INSERTED.CreatedByName,
INSERTED.ActionDate,
INSERTED.CreatedDate,
INSERTED.UpdatedDate
VALUES (
@assetId,
@assetCode,
@assetName,
@actionType,
@actionLabel,
@actionQuantity,
@unit,
@previousQuantity,
@nextQuantity,
@previousImportInPeriod,
@nextImportInPeriod,
@previousExportInPeriod,
@nextExportInPeriod,
@previousEndingBalance,
@nextEndingBalance,
@previousNewQuantity,
@nextNewQuantity,
@previousUsedQuantity,
@nextUsedQuantity,
@actionNote,
@createdBy,
@createdByName,
@actionDate
)
`);
await transaction.commit();
res.json({
success: true,
message: `Đã ghi nhận tài sản ${actionLabel.toLowerCase()}`,
data: historyResult.recordset?.[0] || null
});
} catch (err) {
if (transaction) {
try {
await transaction.rollback();
} catch (_rollbackErr) {
// Ignore rollback error, respond original error below.
}
}
res.status(500).json({ success: false, message: err.message });
}
});
app.post('/api/assets/:id/export', requireAssetOrAdmin, async (req, res) => {
let transaction;
try {

View File

@@ -484,7 +484,45 @@ BEGIN
END
-- ===========================================
-- 9. CREATE AUDIT LOG TABLE
-- 9. CREATE ASSET DAMAGE/DISPOSAL HISTORY TABLE
-- ===========================================
IF NOT EXISTS (SELECT * FROM sys.tables WHERE name = 'AssetDamageDisposalHistory')
BEGIN
CREATE TABLE AssetDamageDisposalHistory (
DamageHistoryId INT PRIMARY KEY IDENTITY(1,1),
AssetId INT NOT NULL,
AssetCode NVARCHAR(100) NOT NULL,
AssetName NVARCHAR(255) NOT NULL,
ActionType NVARCHAR(20) NOT NULL,
ActionLabel NVARCHAR(50) NOT NULL,
ActionQuantity INT NOT NULL DEFAULT 1,
Unit NVARCHAR(50) NULL,
PreviousQuantity INT NOT NULL DEFAULT 0,
NextQuantity INT NOT NULL DEFAULT 0,
PreviousImportInPeriod INT NOT NULL DEFAULT 0,
NextImportInPeriod INT NOT NULL DEFAULT 0,
PreviousExportInPeriod INT NOT NULL DEFAULT 0,
NextExportInPeriod INT NOT NULL DEFAULT 0,
PreviousEndingBalance INT NOT NULL DEFAULT 0,
NextEndingBalance INT NOT NULL DEFAULT 0,
PreviousNewQuantity INT NOT NULL DEFAULT 0,
NextNewQuantity INT NOT NULL DEFAULT 0,
PreviousUsedQuantity INT NOT NULL DEFAULT 0,
NextUsedQuantity INT NOT NULL DEFAULT 0,
ActionNote NVARCHAR(1000) NULL,
CreatedBy INT NULL,
CreatedByName NVARCHAR(100) NULL,
ActionDate DATETIME NOT NULL DEFAULT GETDATE(),
CreatedDate DATETIME NOT NULL DEFAULT GETDATE(),
UpdatedDate DATETIME NOT NULL DEFAULT GETDATE(),
FOREIGN KEY (AssetId) REFERENCES AssetInventory(AssetId) ON DELETE CASCADE,
FOREIGN KEY (CreatedBy) REFERENCES Users(UserId) ON DELETE SET NULL
);
PRINT 'Table AssetDamageDisposalHistory created successfully.';
END
-- ===========================================
-- 10. CREATE AUDIT LOG TABLE
-- ===========================================
IF NOT EXISTS (SELECT * FROM sys.tables WHERE name = 'AuditLog')
BEGIN
@@ -503,7 +541,7 @@ BEGIN
END
-- ===========================================
-- 10. CREATE INDEXES
-- 11. CREATE INDEXES
-- ===========================================
IF NOT EXISTS (SELECT * FROM sys.indexes WHERE name = 'IX_Users_Username')
BEGIN
@@ -590,10 +628,25 @@ BEGIN
CREATE INDEX IX_AssetExportHistory_ExportedDate ON AssetExportHistory(ExportedDate DESC);
END
IF NOT EXISTS (SELECT * FROM sys.indexes WHERE name = 'IX_AssetDamageDisposalHistory_AssetId')
BEGIN
CREATE INDEX IX_AssetDamageDisposalHistory_AssetId ON AssetDamageDisposalHistory(AssetId);
END
IF NOT EXISTS (SELECT * FROM sys.indexes WHERE name = 'IX_AssetDamageDisposalHistory_ActionDate')
BEGIN
CREATE INDEX IX_AssetDamageDisposalHistory_ActionDate ON AssetDamageDisposalHistory(ActionDate DESC);
END
IF NOT EXISTS (SELECT * FROM sys.indexes WHERE name = 'IX_AssetDamageDisposalHistory_ActionType')
BEGIN
CREATE INDEX IX_AssetDamageDisposalHistory_ActionType ON AssetDamageDisposalHistory(ActionType);
END
PRINT 'Indexes created successfully.';
-- ===========================================
-- 11. INSERT INITIAL DATA
-- 12. INSERT INITIAL DATA
-- ===========================================
-- Check if admin user exists

View File

@@ -53,6 +53,7 @@ class AccountManager {
this.assetProjects = [];
this.assetProjectSearchTerm = '';
this.assetExportHistories = [];
this.assetDamageHistories = [];
this.selectedAssetIds = new Set();
this.mobileBreakpoint = 900;
this.boundResizeHandler = null;
@@ -71,6 +72,7 @@ class AccountManager {
this.assetBorrowAutoRefreshTimer = undefined;
this.pendingAssetRequestDeleteConfirmResolver = undefined;
this.pendingBulkAssetDeleteConfirmResolver = undefined;
this.pendingAssetDamageId = undefined;
}
configureNotifications() {
@@ -1072,6 +1074,114 @@ class AccountManager {
this.renderAssetExportHistoryModal();
}
normalizeAssetDamageType(value) {
const normalized = String(value || '').trim().toLowerCase();
return normalized === 'disposed' || normalized === 'thanh_ly' || normalized === 'thanh ly'
? 'disposed'
: 'damaged';
}
getAssetDamageTypeMeta(value) {
const type = this.normalizeAssetDamageType(value);
if (type === 'disposed') {
return {
value: 'disposed',
label: 'Thanh lý',
className: 'bg-slate-100 text-slate-700 border border-slate-200'
};
}
return {
value: 'damaged',
label: 'Hỏng',
className: 'bg-red-100 text-red-700 border border-red-200'
};
}
async fetchAssetDamageHistories(limit = 300) {
try {
const safeLimit = Number.isFinite(Number(limit)) ? Math.max(1, Math.min(Number(limit), 2000)) : 300;
const res = await fetch(`${this.apiBase}/asset-damage-disposal-history?limit=${safeLimit}`, {
headers: this.getAuthHeaders(false)
});
const data = await res.json();
if (data.success) {
this.assetDamageHistories = Array.isArray(data.data) ? data.data : [];
} else {
console.error('Load asset damage/disposal history failed:', data.message);
}
} catch (err) {
console.error('Fetch asset damage/disposal history error:', err);
}
}
buildAssetDamageHistoryRowsHtml(rows = []) {
if (!Array.isArray(rows) || rows.length === 0) {
return `
<tr>
<td colspan="10" class="px-4 py-8 text-sm text-center text-slate-500">Chưa có dữ liệu tài sản hỏng/thanh lý.</td>
</tr>
`;
}
return rows.map(item => {
const typeMeta = this.getAssetDamageTypeMeta(item?.ActionType);
const assetLabel = [String(item?.AssetCode || '').trim(), String(item?.AssetName || '').trim()]
.filter(Boolean)
.join(' - ') || '-';
const unit = String(item?.Unit || '').trim();
const quantityLabel = `${Number(item?.ActionQuantity) || 0}${unit ? ` ${unit}` : ''}`;
return `
<tr class="hover:bg-slate-50/80 transition-colors">
<td class="px-4 py-3 text-sm text-slate-600 whitespace-nowrap">${this.formatDateTime(item?.ActionDate || item?.CreatedDate)}</td>
<td class="px-4 py-3 text-sm">
<span class="inline-flex items-center px-2 py-1 rounded text-xs font-semibold ${typeMeta.className}">${typeMeta.label}</span>
</td>
<td class="px-4 py-3 text-sm text-slate-700">${this.escapeHtml(assetLabel)}</td>
<td class="px-4 py-3 text-sm text-slate-700 font-semibold">${this.escapeHtml(quantityLabel)}</td>
<td class="px-4 py-3 text-sm text-slate-600">${Number(item?.PreviousQuantity) || 0} -> ${Number(item?.NextQuantity) || 0}</td>
<td class="px-4 py-3 text-sm text-slate-600">${Number(item?.PreviousEndingBalance) || 0} -> ${Number(item?.NextEndingBalance) || 0}</td>
<td class="px-4 py-3 text-sm text-slate-600">${Number(item?.PreviousNewQuantity) || 0} -> ${Number(item?.NextNewQuantity) || 0}</td>
<td class="px-4 py-3 text-sm text-slate-600">${Number(item?.PreviousUsedQuantity) || 0} -> ${Number(item?.NextUsedQuantity) || 0}</td>
<td class="px-4 py-3 text-sm text-slate-600">${this.escapeHtml(item?.CreatedByName || '-')}</td>
<td class="px-4 py-3 text-sm text-slate-600 whitespace-pre-line">${this.escapeHtml(item?.ActionNote || '-')}</td>
</tr>
`;
}).join('');
}
renderAssetDamageHistoryModal() {
const tbody = document.getElementById('assetDamageHistoryTableBody');
if (!tbody) {
return;
}
tbody.innerHTML = this.buildAssetDamageHistoryRowsHtml(this.assetDamageHistories);
}
async openAssetDamageHistoryModal() {
if (!this.ensureAssetManagePermission('xem danh sach tai san hong/thanh ly')) {
return;
}
const modal = document.getElementById('assetDamageHistoryModal');
const tbody = document.getElementById('assetDamageHistoryTableBody');
if (!modal || !tbody) {
this.notifyFailure('Không tìm thấy bảng tài sản hỏng/thanh lý.');
return;
}
tbody.innerHTML = `
<tr>
<td colspan="10" class="px-4 py-8 text-sm text-center text-slate-500">Đang tải dữ liệu tài sản hỏng/thanh lý...</td>
</tr>
`;
modal.classList.add('open');
await this.fetchAssetDamageHistories();
this.renderAssetDamageHistoryModal();
}
async fetchRoles() {
try {
const res = await fetch(`${this.apiBase}/roles`);
@@ -1244,6 +1354,14 @@ class AccountManager {
}
}
const assetDamageForm = document.getElementById('assetDamageForm');
if (assetDamageForm) {
if (!assetDamageForm.dataset.boundSubmit) {
assetDamageForm.addEventListener('submit', (e) => this.handleAssetDamageSubmit(e));
assetDamageForm.dataset.boundSubmit = 'true';
}
}
const assetBorrowRequestForm = document.getElementById('assetBorrowRequestForm');
if (assetBorrowRequestForm) {
if (!assetBorrowRequestForm.dataset.boundSubmit) {
@@ -4278,14 +4396,22 @@ class AccountManager {
<span class="material-symbols-outlined text-base">history</span>
Lịch sử xuất
</button>
<button id="openAssetDamageHistoryBtn" class="border border-red-200 text-red-700 px-3 py-2 rounded-lg text-xs font-bold flex items-center gap-1.5 transition-all active:scale-95 ${canManageAssets ? 'hover:bg-red-50' : 'opacity-50 cursor-not-allowed'}" ${canManageAssets ? '' : 'disabled'}>
<span class="material-symbols-outlined text-base">inventory_2</span>
DS hỏng/TL
</button>
<button id="addAssetBtn" class="bg-primary text-on-primary px-4 py-2 rounded-lg text-xs font-bold flex items-center gap-1.5 transition-all active:scale-95 ${canManageAssets ? 'hover:bg-primary-dim' : 'opacity-50 cursor-not-allowed'}" ${canManageAssets ? '' : 'disabled'}>
<span class="material-symbols-outlined text-base">add_box</span>
Thêm tài sản
</button>
<button id="borrowAssetBtn" class="border border-primary text-primary px-4 py-2 rounded-lg text-xs font-bold flex items-center gap-1.5 transition-all active:scale-95 ${canManageAssets ? 'hover:bg-primary/5' : 'opacity-50 cursor-not-allowed'}" ${canManageAssets ? '' : 'disabled'}>
<button id="borrowAssetBtn" class="border border-primary text-primary px-4 py-2 rounded-lg text-xs font-bold flex items-center gap-1.5 transition-all active:scale-95 ${(canManageAssets && selectedCount === 1) ? 'hover:bg-primary/5' : 'opacity-50 cursor-not-allowed'}" ${(canManageAssets && selectedCount === 1) ? '' : 'disabled'}>
<span class="material-symbols-outlined text-base">handshake</span>
Xuất tài sản
</button>
<button id="damageAssetBtn" class="border border-red-300 text-red-700 px-4 py-2 rounded-lg text-xs font-bold flex items-center gap-1.5 transition-all active:scale-95 ${(canManageAssets && selectedCount === 1) ? 'hover:bg-red-50' : 'opacity-50 cursor-not-allowed'}" ${(canManageAssets && selectedCount === 1) ? '' : 'disabled'}>
<span class="material-symbols-outlined text-base">broken_image</span>
Hỏng/Thanh lý
</button>
<input id="assetImportInput" type="file" accept=".xlsx,.xls" class="hidden" />
</div>
</div>
@@ -4609,6 +4735,16 @@ class AccountManager {
borrowAssetBtn.disabled = disabled;
borrowAssetBtn.classList.toggle('opacity-50', disabled);
borrowAssetBtn.classList.toggle('cursor-not-allowed', disabled);
borrowAssetBtn.classList.toggle('hover:bg-primary/5', !disabled);
}
const damageAssetBtn = document.getElementById('damageAssetBtn');
if (damageAssetBtn) {
const disabled = !canManageAssets || selectedCount !== 1;
damageAssetBtn.disabled = disabled;
damageAssetBtn.classList.toggle('opacity-50', disabled);
damageAssetBtn.classList.toggle('cursor-not-allowed', disabled);
damageAssetBtn.classList.toggle('hover:bg-red-50', !disabled);
}
}
@@ -5051,6 +5187,161 @@ class AccountManager {
return asset;
}
getSingleSelectedAssetForDamage(showWarning = true) {
const selectedIds = [...this.selectedAssetIds];
if (!selectedIds.length) {
if (showWarning) {
this.notifyWarning('Vui lòng chọn 1 tài sản để ghi nhận hỏng/thanh lý.');
}
return null;
}
if (selectedIds.length > 1) {
if (showWarning) {
this.notifyWarning('Chỉ chọn đúng 1 tài sản cho mỗi lần ghi nhận hỏng/thanh lý.');
}
return null;
}
const assetId = Number(selectedIds[0]);
const asset = this.assets.find(item => Number(item?.AssetId) === assetId) || null;
if (!asset && showWarning) {
this.notifyFailure('Không tìm thấy tài sản đã chọn.');
}
return asset;
}
openAssetDamageModal() {
if (!this.ensureAssetManagePermission('ghi nhan tai san hong/thanh ly')) {
return;
}
const asset = this.getSingleSelectedAssetForDamage(true);
if (!asset) {
return;
}
const metrics = this.buildAssetQuantityMetrics(asset);
if (metrics.endingBalance <= 0) {
this.notifyWarning('Tài sản đã hết tồn cuối kỳ, không thể ghi nhận hỏng/thanh lý thêm.');
return;
}
this.pendingAssetDamageId = Number(asset.AssetId);
const modal = document.getElementById('assetDamageModal');
const assetIdInput = document.getElementById('assetDamageAssetIdInput');
const assetNameInput = document.getElementById('assetDamageAssetNameInput');
const typeInput = document.getElementById('assetDamageTypeInput');
const quantityInput = document.getElementById('assetDamageQuantityInput');
const currentQuantityInput = document.getElementById('assetDamageCurrentQuantityInput');
const currentEndingInput = document.getElementById('assetDamageCurrentEndingInput');
const currentNewInput = document.getElementById('assetDamageCurrentNewInput');
const currentUsedInput = document.getElementById('assetDamageCurrentUsedInput');
const actorInput = document.getElementById('assetDamageActorInput');
const noteInput = document.getElementById('assetDamageNoteInput');
if (!modal || !assetNameInput || !quantityInput || !currentQuantityInput || !currentEndingInput) {
this.notifyFailure('Không tìm thấy biểu mẫu hỏng/thanh lý tài sản.');
return;
}
const stockSplit = this.normalizeAssetStockSplit(
metrics.endingBalance,
asset?.NewQuantity ?? metrics.endingBalance,
asset?.UsedQuantity ?? 0
);
if (assetIdInput) {
assetIdInput.value = String(asset.AssetId || '');
}
assetNameInput.value = `${asset.AssetCode || ''} - ${asset.AssetName || ''}`.trim();
currentQuantityInput.value = String(metrics.quantity);
currentEndingInput.value = String(metrics.endingBalance);
if (currentNewInput) currentNewInput.value = String(stockSplit.newQuantity);
if (currentUsedInput) currentUsedInput.value = String(stockSplit.usedQuantity);
if (typeInput) typeInput.value = 'damaged';
quantityInput.value = '1';
quantityInput.min = '1';
quantityInput.max = String(metrics.endingBalance);
if (actorInput) actorInput.value = this.getCurrentUserDisplayName();
if (noteInput) noteInput.value = '';
modal.classList.add('open');
}
async handleAssetDamageSubmit(e) {
e.preventDefault();
if (!this.ensureAssetManagePermission('ghi nhan tai san hong/thanh ly')) {
return;
}
const assetIdInput = document.getElementById('assetDamageAssetIdInput');
const typeInput = document.getElementById('assetDamageTypeInput');
const quantityInput = document.getElementById('assetDamageQuantityInput');
const noteInput = document.getElementById('assetDamageNoteInput');
const selectedAssetId = Number(assetIdInput?.value || this.pendingAssetDamageId);
if (!Number.isFinite(selectedAssetId) || selectedAssetId <= 0) {
this.notifyFailure('Không xác định được tài sản cần ghi nhận.');
return;
}
const asset = this.assets.find(item => Number(item?.AssetId) === selectedAssetId);
if (!asset) {
this.notifyFailure('Không tìm thấy tài sản cần ghi nhận.');
return;
}
const actionType = this.normalizeAssetDamageType(typeInput?.value || 'damaged');
const actionMeta = this.getAssetDamageTypeMeta(actionType);
const actionQuantity = this.parseNonNegativeInteger(quantityInput?.value ?? 0, 0);
const currentMetrics = this.buildAssetQuantityMetrics(asset);
if (actionQuantity <= 0) {
this.notifyWarning('Số lượng phải lớn hơn 0.');
return;
}
if (actionQuantity > currentMetrics.endingBalance) {
this.notifyWarning(`Số lượng ${actionMeta.label.toLowerCase()} (${actionQuantity}) vượt quá tồn cuối kỳ (${currentMetrics.endingBalance}).`);
return;
}
try {
const response = await fetch(`${this.apiBase}/assets/${selectedAssetId}/damage-disposal`, {
method: 'POST',
headers: this.getAuthHeaders(true),
body: JSON.stringify({
actionType,
quantity: actionQuantity,
note: String(noteInput?.value || '').trim()
})
});
const data = await response.json();
if (!response.ok || !data.success) {
this.notifyFailure(data.message || 'Ghi nhận hỏng/thanh lý thất bại');
return;
}
this.pendingAssetDamageId = undefined;
document.getElementById('assetDamageModal')?.classList.remove('open');
this.notifySuccess(data.message || `Đã ghi nhận tài sản ${actionMeta.label.toLowerCase()}`);
await this.refreshAssetsUI();
const historyModal = document.getElementById('assetDamageHistoryModal');
if (historyModal?.classList.contains('open')) {
await this.fetchAssetDamageHistories();
this.renderAssetDamageHistoryModal();
}
} catch (err) {
console.error(err);
this.notifyFailure('Ghi nhận hỏng/thanh lý thất bại');
}
}
async openBorrowAssetModal() {
if (!this.ensureAssetManagePermission('xuat tai san')) {
return;
@@ -6616,10 +6907,17 @@ class AccountManager {
borrowAssetBtn.dataset.boundClick = 'true';
}
const damageAssetBtn = document.getElementById('damageAssetBtn');
if (damageAssetBtn && !damageAssetBtn.dataset.boundClick) {
damageAssetBtn.addEventListener('click', () => this.openAssetDamageModal());
damageAssetBtn.dataset.boundClick = 'true';
}
const importAssetBtn = document.getElementById('importAssetBtn');
const assetImportInput = document.getElementById('assetImportInput');
const exportAssetBtn = document.getElementById('exportAssetBtn');
const openAssetExportHistoryBtn = document.getElementById('openAssetExportHistoryBtn');
const openAssetDamageHistoryBtn = document.getElementById('openAssetDamageHistoryBtn');
if (importAssetBtn && assetImportInput && !importAssetBtn.dataset.boundClick) {
importAssetBtn.addEventListener('click', () => {
@@ -6645,6 +6943,11 @@ class AccountManager {
openAssetExportHistoryBtn.addEventListener('click', () => this.openAssetExportHistoryModal());
openAssetExportHistoryBtn.dataset.boundClick = 'true';
}
if (openAssetDamageHistoryBtn && !openAssetDamageHistoryBtn.dataset.boundClick) {
openAssetDamageHistoryBtn.addEventListener('click', () => this.openAssetDamageHistoryModal());
openAssetDamageHistoryBtn.dataset.boundClick = 'true';
}
}
setupFilters() {
@@ -7856,6 +8159,13 @@ function closeBorrowAssetModal() {
}
}
function closeAssetDamageModal() {
const modal = document.getElementById('assetDamageModal');
if (modal) {
modal.classList.remove('open');
}
}
function closeAssetExportHistoryModal() {
const modal = document.getElementById('assetExportHistoryModal');
if (modal) {
@@ -7863,6 +8173,13 @@ function closeAssetExportHistoryModal() {
}
}
function closeAssetDamageHistoryModal() {
const modal = document.getElementById('assetDamageHistoryModal');
if (modal) {
modal.classList.remove('open');
}
}
function closeAssetBorrowRequestModal() {
const modal = document.getElementById('assetBorrowRequestModal');
const dropdown = document.getElementById('assetBorrowProductDropdown');

View File

@@ -357,6 +357,66 @@
</div>
</div>
<!-- Asset Damage/Disposal Modal -->
<div class="modal-backdrop fixed inset-0 z-[100] flex items-center justify-center bg-black/40 backdrop-blur-sm" id="assetDamageModal">
<div class="modal-content w-full max-w-lg bg-white rounded-xl shadow-2xl border border-slate-200 overflow-hidden m-4">
<div class="px-6 py-4 border-b border-slate-100 flex items-center justify-between bg-slate-50">
<h3 class="text-base font-extrabold text-slate-900">Hỏng / Thanh lý tài sản</h3>
<button class="p-1.5 rounded-lg hover:bg-slate-200 text-slate-400 transition-colors" onclick="closeAssetDamageModal()">
<span class="material-symbols-outlined">close</span>
</button>
</div>
<form id="assetDamageForm" class="p-6 space-y-4">
<input type="hidden" id="assetDamageAssetIdInput">
<div>
<label class="text-[10px] font-bold uppercase text-slate-500 tracking-widest block mb-1">Tài sản</label>
<input type="text" id="assetDamageAssetNameInput" class="w-full border border-slate-200 rounded-lg text-sm py-2.5 px-3 bg-slate-50" readonly>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label class="text-[10px] font-bold uppercase text-slate-500 tracking-widest block mb-1">Lý do</label>
<select id="assetDamageTypeInput" class="w-full border border-slate-200 rounded-lg text-sm py-2.5 px-3" required>
<option value="damaged">Hỏng</option>
<option value="disposed">Thanh lý</option>
</select>
</div>
<div>
<label class="text-[10px] font-bold uppercase text-slate-500 tracking-widest block mb-1">Số lượng</label>
<input type="number" id="assetDamageQuantityInput" min="1" class="w-full border border-slate-200 rounded-lg text-sm py-2.5 px-3" value="1" required>
</div>
<div>
<label class="text-[10px] font-bold uppercase text-slate-500 tracking-widest block mb-1">Số lượng hiện tại</label>
<input type="number" id="assetDamageCurrentQuantityInput" class="w-full border border-slate-200 rounded-lg text-sm py-2.5 px-3 bg-slate-50" readonly>
</div>
<div>
<label class="text-[10px] font-bold uppercase text-slate-500 tracking-widest block mb-1">Tồn cuối kỳ hiện tại</label>
<input type="number" id="assetDamageCurrentEndingInput" class="w-full border border-slate-200 rounded-lg text-sm py-2.5 px-3 bg-slate-50" readonly>
</div>
<div>
<label class="text-[10px] font-bold uppercase text-slate-500 tracking-widest block mb-1">SL hàng mới</label>
<input type="number" id="assetDamageCurrentNewInput" class="w-full border border-slate-200 rounded-lg text-sm py-2.5 px-3 bg-slate-50" readonly>
</div>
<div>
<label class="text-[10px] font-bold uppercase text-slate-500 tracking-widest block mb-1">SL đã qua sử dụng</label>
<input type="number" id="assetDamageCurrentUsedInput" class="w-full border border-slate-200 rounded-lg text-sm py-2.5 px-3 bg-slate-50" readonly>
</div>
<div class="md:col-span-2">
<label class="text-[10px] font-bold uppercase text-slate-500 tracking-widest block mb-1">Người thao tác</label>
<input type="text" id="assetDamageActorInput" class="w-full border border-slate-200 rounded-lg text-sm py-2.5 px-3 bg-slate-50" readonly>
</div>
<div class="md:col-span-2">
<label class="text-[10px] font-bold uppercase text-slate-500 tracking-widest block mb-1">Ghi chú</label>
<textarea id="assetDamageNoteInput" class="w-full border border-slate-200 rounded-lg text-sm py-2.5 px-3 h-20 resize-none" placeholder="Nhập tình trạng, biên bản hoặc lý do chi tiết"></textarea>
</div>
</div>
<div class="flex gap-3 pt-2">
<button type="button" class="flex-1 px-4 py-2 text-xs font-bold text-slate-600 border border-slate-200 rounded-lg" onclick="closeAssetDamageModal()">Hủy</button>
<button type="submit" class="flex-1 px-4 py-2 bg-red-600 hover:bg-red-700 text-white rounded-lg text-xs font-bold">Xác nhận</button>
</div>
</form>
</div>
</div>
<!-- Asset Export History Modal -->
<div class="modal-backdrop fixed inset-0 z-[110] flex items-center justify-center bg-black/40 backdrop-blur-sm" id="assetExportHistoryModal" style="z-index: 125;">
<div class="modal-content w-full max-w-6xl bg-white rounded-xl shadow-2xl border border-slate-200 overflow-hidden m-4 flex flex-col" style="max-height: calc(100vh - 2rem);">
@@ -393,6 +453,45 @@
</div>
</div>
<!-- Asset Damage/Disposal History Modal -->
<div class="modal-backdrop fixed inset-0 z-[110] flex items-center justify-center bg-black/40 backdrop-blur-sm" id="assetDamageHistoryModal" style="z-index: 126;">
<div class="modal-content w-full max-w-6xl bg-white rounded-xl shadow-2xl border border-slate-200 overflow-hidden m-4 flex flex-col" style="max-height: calc(100vh - 2rem);">
<div class="px-6 py-4 border-b border-slate-100 flex items-center justify-between bg-slate-50">
<h3 class="text-base font-extrabold text-slate-900">Tài sản hỏng / thanh lý</h3>
<button class="p-1.5 rounded-lg hover:bg-slate-200 text-slate-400 transition-colors" onclick="closeAssetDamageHistoryModal()">
<span class="material-symbols-outlined">close</span>
</button>
</div>
<div class="p-6 pt-4 overflow-auto">
<div class="rounded-xl border border-slate-200 overflow-hidden">
<div class="overflow-x-auto">
<table class="w-full text-left border-collapse" style="min-width: 1280px;">
<thead class="bg-slate-50 border-b border-slate-200">
<tr>
<th class="px-4 py-2.5 text-[10px] font-bold uppercase tracking-wider text-slate-500">Ngày giờ</th>
<th class="px-4 py-2.5 text-[10px] font-bold uppercase tracking-wider text-slate-500">Lý do</th>
<th class="px-4 py-2.5 text-[10px] font-bold uppercase tracking-wider text-slate-500">Tài sản</th>
<th class="px-4 py-2.5 text-[10px] font-bold uppercase tracking-wider text-slate-500">Số lượng</th>
<th class="px-4 py-2.5 text-[10px] font-bold uppercase tracking-wider text-slate-500">Tồn đầu kỳ</th>
<th class="px-4 py-2.5 text-[10px] font-bold uppercase tracking-wider text-slate-500">Tồn cuối kỳ</th>
<th class="px-4 py-2.5 text-[10px] font-bold uppercase tracking-wider text-slate-500">SL hàng mới</th>
<th class="px-4 py-2.5 text-[10px] font-bold uppercase tracking-wider text-slate-500">SL đã sử dụng</th>
<th class="px-4 py-2.5 text-[10px] font-bold uppercase tracking-wider text-slate-500">Người thao tác</th>
<th class="px-4 py-2.5 text-[10px] font-bold uppercase tracking-wider text-slate-500">Ghi chú</th>
</tr>
</thead>
<tbody id="assetDamageHistoryTableBody" class="divide-y divide-slate-100">
<tr>
<td colspan="10" class="px-4 py-8 text-sm text-center text-slate-500">Chưa có dữ liệu tài sản hỏng/thanh lý.</td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
</div>
</div>
<!-- Asset Borrow Request Modal -->
<div class="modal-backdrop fixed inset-0 z-[100] flex items-center justify-center bg-black/40 backdrop-blur-sm" id="assetBorrowRequestModal">
<div class="modal-content w-full max-w-lg bg-white rounded-xl shadow-2xl border border-slate-200 overflow-visible m-4">