xuất tài sản

This commit is contained in:
2026-05-06 16:04:56 +07:00
parent 8b2a9d7afe
commit d88aa39bd6
4 changed files with 639 additions and 52 deletions

View File

@@ -1725,6 +1725,27 @@ async function createTables() {
)
END`,
// Asset Export History Table
`IF NOT EXISTS (SELECT * FROM sys.tables WHERE name = 'AssetExportHistory')
BEGIN
CREATE TABLE AssetExportHistory (
ExportHistoryId INT PRIMARY KEY IDENTITY(1,1),
AssetId INT NOT NULL,
AssetCode NVARCHAR(100) NOT NULL,
AssetName NVARCHAR(255) NOT NULL,
ExportQuantity INT NOT NULL DEFAULT 1,
ProjectName NVARCHAR(150) NULL,
CustodianName NVARCHAR(100) NOT NULL,
ExportedByName NVARCHAR(100) NOT NULL,
ExportNote NVARCHAR(1000) NULL,
CreatedBy INT NULL,
ExportedDate 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
)
END`,
// AuditLog Table
`IF NOT EXISTS (SELECT * FROM sys.tables WHERE name = 'AuditLog')
BEGIN
@@ -1783,6 +1804,14 @@ async function createTables() {
console.error('AssetBorrowRequests index creation error:', err.message);
}
// Ensure AssetExportHistory indexes exist
try {
await pool.request().query(`IF NOT EXISTS (SELECT * FROM sys.indexes WHERE name = 'IX_AssetExportHistory_AssetId') CREATE INDEX IX_AssetExportHistory_AssetId ON AssetExportHistory(AssetId);`);
await pool.request().query(`IF NOT EXISTS (SELECT * FROM sys.indexes WHERE name = 'IX_AssetExportHistory_ExportedDate') CREATE INDEX IX_AssetExportHistory_ExportedDate ON AssetExportHistory(ExportedDate DESC);`);
} catch (err) {
console.error('AssetExportHistory index creation error:', err.message);
}
// Ensure new columns exist on Applications for migrations
try {
await pool.request().query(`IF EXISTS (
@@ -1820,6 +1849,34 @@ async function createTables() {
FOREIGN KEY (ProcessedBy) REFERENCES Users(UserId) ON DELETE SET NULL;
`);
await pool.request().query(`IF COL_LENGTH('dbo.AssetBorrowRequests','UpdatedDate') IS NULL ALTER TABLE AssetBorrowRequests ADD UpdatedDate DATETIME NOT NULL CONSTRAINT DF_AssetBorrowRequests_UpdatedDate DEFAULT(GETDATE());`);
await pool.request().query(`IF COL_LENGTH('dbo.AssetExportHistory','AssetCode') IS NULL ALTER TABLE AssetExportHistory ADD AssetCode NVARCHAR(100) NULL;`);
await pool.request().query(`IF COL_LENGTH('dbo.AssetExportHistory','AssetName') IS NULL ALTER TABLE AssetExportHistory ADD AssetName NVARCHAR(255) NULL;`);
await pool.request().query(`IF COL_LENGTH('dbo.AssetExportHistory','ExportQuantity') IS NULL ALTER TABLE AssetExportHistory ADD ExportQuantity INT NOT NULL CONSTRAINT DF_AssetExportHistory_ExportQuantity DEFAULT(1);`);
await pool.request().query(`IF COL_LENGTH('dbo.AssetExportHistory','ProjectName') IS NULL ALTER TABLE AssetExportHistory ADD ProjectName NVARCHAR(150) NULL;`);
await pool.request().query(`IF COL_LENGTH('dbo.AssetExportHistory','CustodianName') IS NULL ALTER TABLE AssetExportHistory ADD CustodianName NVARCHAR(100) NULL;`);
await pool.request().query(`IF COL_LENGTH('dbo.AssetExportHistory','ExportedByName') IS NULL ALTER TABLE AssetExportHistory ADD ExportedByName NVARCHAR(100) NULL;`);
await pool.request().query(`IF COL_LENGTH('dbo.AssetExportHistory','ExportNote') IS NULL ALTER TABLE AssetExportHistory ADD ExportNote NVARCHAR(1000) NULL;`);
await pool.request().query(`IF COL_LENGTH('dbo.AssetExportHistory','CreatedBy') IS NULL ALTER TABLE AssetExportHistory ADD CreatedBy INT NULL;`);
await pool.request().query(`IF COL_LENGTH('dbo.AssetExportHistory','ExportedDate') IS NULL ALTER TABLE AssetExportHistory ADD ExportedDate DATETIME NOT NULL CONSTRAINT DF_AssetExportHistory_ExportedDate DEFAULT(GETDATE());`);
await pool.request().query(`IF COL_LENGTH('dbo.AssetExportHistory','CreatedDate') IS NULL ALTER TABLE AssetExportHistory ADD CreatedDate DATETIME NOT NULL CONSTRAINT DF_AssetExportHistory_CreatedDate DEFAULT(GETDATE());`);
await pool.request().query(`IF COL_LENGTH('dbo.AssetExportHistory','UpdatedDate') IS NULL ALTER TABLE AssetExportHistory ADD UpdatedDate DATETIME NOT NULL CONSTRAINT DF_AssetExportHistory_UpdatedDate DEFAULT(GETDATE());`);
await pool.request().query(`
IF NOT EXISTS (
SELECT 1
FROM sys.foreign_key_columns fkc
INNER JOIN sys.columns c
ON c.object_id = fkc.parent_object_id
AND c.column_id = fkc.parent_column_id
WHERE fkc.parent_object_id = OBJECT_ID('dbo.AssetExportHistory')
AND c.name = 'CreatedBy'
)
AND COL_LENGTH('dbo.AssetExportHistory', 'CreatedBy') IS NOT NULL
BEGIN
ALTER TABLE AssetExportHistory
ADD CONSTRAINT FK_AssetExportHistory_CreatedBy
FOREIGN KEY (CreatedBy) REFERENCES Users(UserId) ON DELETE SET NULL;
END
`);
await pool.request().query(`UPDATE AssetBorrowRequests SET RequestType = ISNULL(NULLIF(LTRIM(RTRIM(RequestType)), ''), 'borrow');`);
await pool.request().query(`UPDATE AssetBorrowRequests SET RequestStatus = ISNULL(NULLIF(LTRIM(RTRIM(RequestStatus)), ''), 'approved');`);
await pool.request().query(`UPDATE AssetInventory SET EndingBalance = ISNULL(EndingBalance, ISNULL(Quantity, 0));`);
@@ -3711,10 +3768,19 @@ app.post('/api/asset-borrows/:id/process', requireAssetOrAdmin, async (req, res)
await new sql.Request(transaction)
.input('assetId', sql.Int, targetRequest.AssetId)
.input('borrower', sql.NVarChar, mergedBorrowerSummary)
.input('exportInPeriod', sql.Int, currentBorrowed + requestQuantity)
.input('endingBalance', sql.Int, Math.max(
parseNonNegativeInteger(targetRequest.Quantity, 0)
+ parseNonNegativeInteger(targetRequest.ImportInPeriod, 0)
- (currentBorrowed + requestQuantity),
0
))
.input('exportedBy', sql.NVarChar, processorName || null)
.query(`
UPDATE AssetInventory
SET Borrower = @borrower,
ExportInPeriod = @exportInPeriod,
EndingBalance = @endingBalance,
ExportedBy = @exportedBy,
UpdatedDate = GETDATE()
WHERE AssetId = @assetId
@@ -3735,13 +3801,22 @@ app.post('/api/asset-borrows/:id/process', requireAssetOrAdmin, async (req, res)
}
const borrowerSummary = decreased.summary || null;
const remainingBorrowed = decreased.entries.reduce((sum, entry) => (
sum + parseNonNegativeInteger(entry?.quantity, 0)
), 0);
const quantity = parseNonNegativeInteger(targetRequest.Quantity, 0);
const importInPeriod = parseNonNegativeInteger(targetRequest.ImportInPeriod, 0);
await new sql.Request(transaction)
.input('assetId', sql.Int, targetRequest.AssetId)
.input('borrower', sql.NVarChar, borrowerSummary)
.input('exportInPeriod', sql.Int, remainingBorrowed)
.input('endingBalance', sql.Int, Math.max(quantity + importInPeriod - remainingBorrowed, 0))
.input('exportedBy', sql.NVarChar, processorName || null)
.query(`
UPDATE AssetInventory
SET Borrower = @borrower,
ExportInPeriod = @exportInPeriod,
EndingBalance = @endingBalance,
ExportedBy = CASE WHEN @borrower IS NULL THEN NULL ELSE @exportedBy END,
UpdatedDate = GETDATE()
WHERE AssetId = @assetId
@@ -3957,6 +4032,215 @@ app.get('/api/assets/:id', async (req, res) => {
}
});
app.get('/api/asset-export-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)
ExportHistoryId,
AssetId,
AssetCode,
AssetName,
ExportQuantity,
ProjectName,
CustodianName,
ExportedByName,
ExportNote,
CreatedBy,
ExportedDate,
CreatedDate,
UpdatedDate
FROM AssetExportHistory
ORDER BY ExportedDate DESC, ExportHistoryId 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/export', requireAssetOrAdmin, async (req, res) => {
let transaction;
try {
const assetId = Number(req.params.id);
const exportQuantity = parseNonNegativeInteger(req.body?.quantity, 0);
const borrowerName = String(req.body?.borrowerName || req.body?.custodianName || '').trim();
const projectName = String(req.body?.projectName || '').trim() || null;
const exportNote = String(req.body?.note || '').trim() || null;
const createdBy = getUserIdFromRequest(req);
const exportedByName = await getUserDisplayNameById(createdBy) || String(req.headers['x-user-role'] || '').trim() || 'Unknown';
const exportedDate = new Date();
if (!Number.isInteger(assetId) || assetId <= 0) {
return res.status(400).json({ success: false, message: 'Asset id is invalid' });
}
if (exportQuantity <= 0) {
return res.status(400).json({ success: false, message: 'So luong xuat phai lon hon 0' });
}
if (!borrowerName) {
return res.status(400).json({ success: false, message: 'Nguoi muon la bat buoc' });
}
if (!projectName) {
return res.status(400).json({ success: false, message: 'Du an xuat la bat buoc' });
}
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,
Custodian,
Borrower
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 quantity = parseNonNegativeInteger(asset.Quantity, 0);
const importInPeriod = parseNonNegativeInteger(asset.ImportInPeriod, 0);
const existingBorrowerEntries = parseBorrowerEntries(asset.Borrower);
const previousBorrowerExport = existingBorrowerEntries.reduce((sum, entry) => (
sum + parseNonNegativeInteger(entry?.quantity, 0)
), 0);
const storedExportInPeriod = parseOptionalNonNegativeInteger(asset.ExportInPeriod);
const baseExportInPeriod = storedExportInPeriod !== null ? storedExportInPeriod : previousBorrowerExport;
const storedEndingBalance = parseOptionalNonNegativeInteger(asset.EndingBalance);
const baseEndingBalance = storedEndingBalance !== null
? storedEndingBalance
: Math.max(quantity + importInPeriod - baseExportInPeriod, 0);
if (baseEndingBalance <= 0) {
await transaction.rollback();
return res.status(400).json({ success: false, message: 'Tai san da het ton cuoi ky, khong the xuat them' });
}
if (exportQuantity > baseEndingBalance) {
await transaction.rollback();
return res.status(400).json({
success: false,
message: `So luong xuat (${exportQuantity}) vuot qua ton cuoi ky (${baseEndingBalance})`
});
}
const borrowerSummary = mergeBorrowerEntries(asset.Borrower, borrowerName, exportQuantity);
const nextBorrowerEntries = parseBorrowerEntries(borrowerSummary);
const nextBorrowerExport = nextBorrowerEntries.reduce((sum, entry) => (
sum + parseNonNegativeInteger(entry?.quantity, 0)
), 0);
const exportDelta = nextBorrowerExport - previousBorrowerExport;
const nextExportInPeriod = Math.max(baseExportInPeriod + exportDelta, 0);
const nextEndingBalance = Math.max(baseEndingBalance - exportDelta, 0);
await new sql.Request(transaction)
.input('assetId', sql.Int, assetId)
.input('project', sql.NVarChar, projectName)
.input('borrower', sql.NVarChar, borrowerSummary)
.input('exportInPeriod', sql.Int, nextExportInPeriod)
.input('endingBalance', sql.Int, nextEndingBalance)
.input('exportedBy', sql.NVarChar, exportedByName)
.query(`
UPDATE AssetInventory
SET Project = @project,
Borrower = @borrower,
ExportInPeriod = @exportInPeriod,
EndingBalance = @endingBalance,
ExportedBy = @exportedBy,
UpdatedDate = GETDATE()
WHERE AssetId = @assetId
`);
const historyCustodianName = String(asset.Custodian || '').trim() || '-';
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('exportQuantity', sql.Int, exportQuantity)
.input('projectName', sql.NVarChar, projectName)
.input('custodianName', sql.NVarChar, historyCustodianName)
.input('exportedByName', sql.NVarChar, exportedByName)
.input('exportNote', sql.NVarChar, exportNote)
.input('createdBy', sql.Int, createdBy)
.input('exportedDate', sql.DateTime, exportedDate)
.query(`
INSERT INTO AssetExportHistory (
AssetId,
AssetCode,
AssetName,
ExportQuantity,
ProjectName,
CustodianName,
ExportedByName,
ExportNote,
CreatedBy,
ExportedDate
)
OUTPUT
INSERTED.ExportHistoryId,
INSERTED.AssetId,
INSERTED.AssetCode,
INSERTED.AssetName,
INSERTED.ExportQuantity,
INSERTED.ProjectName,
INSERTED.CustodianName,
INSERTED.ExportedByName,
INSERTED.ExportNote,
INSERTED.CreatedBy,
INSERTED.ExportedDate,
INSERTED.CreatedDate,
INSERTED.UpdatedDate
VALUES (
@assetId,
@assetCode,
@assetName,
@exportQuantity,
@projectName,
@custodianName,
@exportedByName,
@exportNote,
@createdBy,
@exportedDate
)
`);
await transaction.commit();
res.json({
success: true,
message: 'Xuat tai san thanh cong',
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', requireAssetOrAdmin, async (req, res) => {
try {
const payload = normalizeAssetPayload(req.body);

View File

@@ -260,7 +260,105 @@ UPDATE AssetBorrowRequests
SET RequestStatus = ISNULL(NULLIF(LTRIM(RTRIM(RequestStatus)), ''), 'approved');
-- ===========================================
-- 8. CREATE AUDIT LOG TABLE
-- 8. CREATE ASSET EXPORT HISTORY TABLE
-- ===========================================
IF NOT EXISTS (SELECT * FROM sys.tables WHERE name = 'AssetExportHistory')
BEGIN
CREATE TABLE AssetExportHistory (
ExportHistoryId INT PRIMARY KEY IDENTITY(1,1),
AssetId INT NOT NULL,
AssetCode NVARCHAR(100) NOT NULL,
AssetName NVARCHAR(255) NOT NULL,
ExportQuantity INT NOT NULL DEFAULT 1,
ProjectName NVARCHAR(150) NULL,
CustodianName NVARCHAR(100) NOT NULL,
ExportedByName NVARCHAR(100) NOT NULL,
ExportNote NVARCHAR(1000) NULL,
CreatedBy INT NULL,
ExportedDate 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
);
PRINT 'Table AssetExportHistory created successfully.';
END
IF COL_LENGTH('dbo.AssetExportHistory', 'AssetCode') IS NULL
BEGIN
ALTER TABLE AssetExportHistory ADD AssetCode NVARCHAR(100) NULL;
END
IF COL_LENGTH('dbo.AssetExportHistory', 'AssetName') IS NULL
BEGIN
ALTER TABLE AssetExportHistory ADD AssetName NVARCHAR(255) NULL;
END
IF COL_LENGTH('dbo.AssetExportHistory', 'ExportQuantity') IS NULL
BEGIN
ALTER TABLE AssetExportHistory ADD ExportQuantity INT NOT NULL CONSTRAINT DF_AssetExportHistory_ExportQuantity DEFAULT(1);
END
IF COL_LENGTH('dbo.AssetExportHistory', 'ProjectName') IS NULL
BEGIN
ALTER TABLE AssetExportHistory ADD ProjectName NVARCHAR(150) NULL;
END
IF COL_LENGTH('dbo.AssetExportHistory', 'CustodianName') IS NULL
BEGIN
ALTER TABLE AssetExportHistory ADD CustodianName NVARCHAR(100) NULL;
END
IF COL_LENGTH('dbo.AssetExportHistory', 'ExportedByName') IS NULL
BEGIN
ALTER TABLE AssetExportHistory ADD ExportedByName NVARCHAR(100) NULL;
END
IF COL_LENGTH('dbo.AssetExportHistory', 'ExportNote') IS NULL
BEGIN
ALTER TABLE AssetExportHistory ADD ExportNote NVARCHAR(1000) NULL;
END
IF COL_LENGTH('dbo.AssetExportHistory', 'CreatedBy') IS NULL
BEGIN
ALTER TABLE AssetExportHistory ADD CreatedBy INT NULL;
END
IF COL_LENGTH('dbo.AssetExportHistory', 'ExportedDate') IS NULL
BEGIN
ALTER TABLE AssetExportHistory ADD ExportedDate DATETIME NOT NULL CONSTRAINT DF_AssetExportHistory_ExportedDate DEFAULT(GETDATE());
END
IF COL_LENGTH('dbo.AssetExportHistory', 'CreatedDate') IS NULL
BEGIN
ALTER TABLE AssetExportHistory ADD CreatedDate DATETIME NOT NULL CONSTRAINT DF_AssetExportHistory_CreatedDate DEFAULT(GETDATE());
END
IF COL_LENGTH('dbo.AssetExportHistory', 'UpdatedDate') IS NULL
BEGIN
ALTER TABLE AssetExportHistory ADD UpdatedDate DATETIME NOT NULL CONSTRAINT DF_AssetExportHistory_UpdatedDate DEFAULT(GETDATE());
END
IF NOT EXISTS (SELECT 1 FROM sys.foreign_keys WHERE name = 'FK_AssetExportHistory_CreatedBy')
AND COL_LENGTH('dbo.AssetExportHistory', 'CreatedBy') IS NOT NULL
BEGIN
IF NOT EXISTS (
SELECT 1
FROM sys.foreign_key_columns fkc
INNER JOIN sys.columns c
ON c.object_id = fkc.parent_object_id
AND c.column_id = fkc.parent_column_id
WHERE fkc.parent_object_id = OBJECT_ID('dbo.AssetExportHistory')
AND c.name = 'CreatedBy'
)
BEGIN
ALTER TABLE AssetExportHistory
ADD CONSTRAINT FK_AssetExportHistory_CreatedBy
FOREIGN KEY (CreatedBy) REFERENCES Users(UserId) ON DELETE SET NULL;
END
END
-- ===========================================
-- 9. CREATE AUDIT LOG TABLE
-- ===========================================
IF NOT EXISTS (SELECT * FROM sys.tables WHERE name = 'AuditLog')
BEGIN
@@ -279,7 +377,7 @@ BEGIN
END
-- ===========================================
-- 9. CREATE INDEXES
-- 10. CREATE INDEXES
-- ===========================================
IF NOT EXISTS (SELECT * FROM sys.indexes WHERE name = 'IX_Users_Username')
BEGIN
@@ -341,10 +439,20 @@ BEGIN
CREATE INDEX IX_AssetBorrowRequests_RequestType ON AssetBorrowRequests(RequestType);
END
IF NOT EXISTS (SELECT * FROM sys.indexes WHERE name = 'IX_AssetExportHistory_AssetId')
BEGIN
CREATE INDEX IX_AssetExportHistory_AssetId ON AssetExportHistory(AssetId);
END
IF NOT EXISTS (SELECT * FROM sys.indexes WHERE name = 'IX_AssetExportHistory_ExportedDate')
BEGIN
CREATE INDEX IX_AssetExportHistory_ExportedDate ON AssetExportHistory(ExportedDate DESC);
END
PRINT 'Indexes created successfully.';
-- ===========================================
-- 10. INSERT INITIAL DATA
-- 11. INSERT INITIAL DATA
-- ===========================================
-- Check if admin user exists
@@ -368,7 +476,7 @@ BEGIN
END
-- ===========================================
-- 11. DISPLAY DATABASE INFORMATION
-- 12. DISPLAY DATABASE INFORMATION
-- ===========================================
PRINT '';
PRINT '========================================';

View File

@@ -48,6 +48,7 @@ class AccountManager {
this.assetDepartmentSearchTerm = '';
this.assetProjects = [];
this.assetProjectSearchTerm = '';
this.assetExportHistories = [];
this.selectedAssetIds = new Set();
this.mobileBreakpoint = 900;
this.boundResizeHandler = null;
@@ -467,6 +468,44 @@ class AccountManager {
});
}
refreshBorrowAssetProjectOptions(selectedValue = '') {
const select = document.getElementById('borrowAssetProjectInput');
if (!select) {
return;
}
const normalizedSelected = String(selectedValue || select.value || '').trim();
const projectNames = this.getUniqueAssetProjectNames();
select.innerHTML = '';
const emptyOption = document.createElement('option');
emptyOption.value = '';
emptyOption.textContent = '-- Chọn dự án --';
select.appendChild(emptyOption);
let hasSelected = false;
projectNames.forEach(name => {
const option = document.createElement('option');
option.value = name;
option.textContent = name;
if (normalizedSelected && name === normalizedSelected) {
option.selected = true;
hasSelected = true;
}
select.appendChild(option);
});
if (normalizedSelected && !hasSelected) {
const legacyOption = document.createElement('option');
legacyOption.value = normalizedSelected;
legacyOption.textContent = normalizedSelected;
legacyOption.selected = true;
select.appendChild(legacyOption);
} else if (!normalizedSelected) {
select.value = '';
}
}
getAssetBorrowProductDisplayName(asset) {
if (!asset) {
return '-- Chọn tài sản --';
@@ -907,6 +946,7 @@ class AccountManager {
if (data.success) {
this.assetProjects = Array.isArray(data.data) ? data.data : [];
this.refreshAssetProjectOptions(document.getElementById('assetProjectInput')?.value || '');
this.refreshBorrowAssetProjectOptions(document.getElementById('borrowAssetProjectInput')?.value || '');
} else {
console.error('Load asset projects failed:', data.message);
}
@@ -915,6 +955,82 @@ class AccountManager {
}
}
async fetchAssetExportHistories(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-export-history?limit=${safeLimit}`, {
headers: this.getAuthHeaders(false)
});
const data = await res.json();
if (data.success) {
this.assetExportHistories = Array.isArray(data.data) ? data.data : [];
} else {
console.error('Load asset export history failed:', data.message);
}
} catch (err) {
console.error('Fetch asset export history error:', err);
}
}
buildAssetExportHistoryRowsHtml(rows = []) {
if (!Array.isArray(rows) || rows.length === 0) {
return `
<tr>
<td colspan="7" class="px-4 py-8 text-sm text-center text-slate-500">Chưa có dữ liệu lịch sử xuất.</td>
</tr>
`;
}
return rows.map(item => {
const assetLabel = [String(item?.AssetCode || '').trim(), String(item?.AssetName || '').trim()]
.filter(Boolean)
.join(' - ') || '-';
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?.ExportedDate || item?.CreatedDate)}</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">${Number(item?.ExportQuantity) || 0}</td>
<td class="px-4 py-3 text-sm text-slate-600">${this.escapeHtml(item?.ProjectName || '-')}</td>
<td class="px-4 py-3 text-sm text-slate-600">${this.escapeHtml(item?.CustodianName || '-')}</td>
<td class="px-4 py-3 text-sm text-slate-600">${this.escapeHtml(item?.ExportedByName || '-')}</td>
<td class="px-4 py-3 text-sm text-slate-600 whitespace-pre-line">${this.escapeHtml(item?.ExportNote || '-')}</td>
</tr>
`;
}).join('');
}
renderAssetExportHistoryModal() {
const tbody = document.getElementById('assetExportHistoryTableBody');
if (!tbody) {
return;
}
tbody.innerHTML = this.buildAssetExportHistoryRowsHtml(this.assetExportHistories);
}
async openAssetExportHistoryModal() {
if (!this.ensureAssetManagePermission('xem lich su xuat tai san')) {
return;
}
const modal = document.getElementById('assetExportHistoryModal');
const tbody = document.getElementById('assetExportHistoryTableBody');
if (!modal || !tbody) {
this.notifyFailure('Không tìm thấy biểu mẫu lịch sử xuất tài sản.');
return;
}
tbody.innerHTML = `
<tr>
<td colspan="7" class="px-4 py-8 text-sm text-center text-slate-500">Đang tải lịch sử xuất...</td>
</tr>
`;
modal.classList.add('open');
await this.fetchAssetExportHistories();
this.renderAssetExportHistoryModal();
}
async fetchRoles() {
try {
const res = await fetch(`${this.apiBase}/roles`);
@@ -952,6 +1068,7 @@ class AccountManager {
this.setupFilters();
this.refreshAssetDepartmentOptions(document.getElementById('assetDepartmentInput')?.value || '');
this.refreshAssetProjectOptions(document.getElementById('assetProjectInput')?.value || '');
this.refreshBorrowAssetProjectOptions(document.getElementById('borrowAssetProjectInput')?.value || '');
} catch (error) {
console.error('Lỗi load modals:', error);
}
@@ -1835,7 +1952,7 @@ class AccountManager {
return entries
.map(entry => this.formatBorrowerDisplay(entry.name, entry.quantity))
.filter(Boolean)
.map(item => `<div class="leading-5">${this.escapeHtml(item)}</div>`)
.map(item => `<div class="leading-5 whitespace-nowrap">${this.escapeHtml(item)}</div>`)
.join('');
}
@@ -1866,15 +1983,10 @@ class AccountManager {
const borrowerExportInPeriod = borrowerEntries.reduce((sum, entry) => (
sum + this.parseNonNegativeInteger(entry?.quantity, 0)
), 0);
const storedExportInPeriod = this.parseOptionalNonNegativeInteger(asset?.ExportInPeriod ?? asset?.exportInPeriod);
const exportInPeriod = storedExportInPeriod !== null
? storedExportInPeriod
: borrowerExportInPeriod;
const computedEndingBalance = Math.max(quantity + importInPeriod - exportInPeriod, 0);
const storedEndingBalance = this.parseOptionalNonNegativeInteger(asset?.EndingBalance ?? asset?.endingBalance);
const endingBalance = storedEndingBalance !== null
? storedEndingBalance
: computedEndingBalance;
// Borrower entries are the source of truth for exported quantity.
// This keeps UI consistent even when legacy rows have stale stored balances.
const exportInPeriod = borrowerExportInPeriod;
const endingBalance = Math.max(quantity + importInPeriod - exportInPeriod, 0);
return {
quantity,
@@ -3375,13 +3487,17 @@ class AccountManager {
<span class="material-symbols-outlined text-base">download</span>
Xuất Excel
</button>
<button id="openAssetExportHistoryBtn" class="border border-amber-300 text-amber-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-amber-50' : 'opacity-50 cursor-not-allowed'}" ${canManageAssets ? '' : 'disabled'}>
<span class="material-symbols-outlined text-base">history</span>
Lịch sử xuất
</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'}>
<span class="material-symbols-outlined text-base">handshake</span>
Mượn tài sản
Xuất tài sản
</button>
<input id="assetImportInput" type="file" accept=".xlsx,.xls" class="hidden" />
</div>
@@ -3433,7 +3549,7 @@ class AccountManager {
<th class="px-4 py-2.5 text-[10px] font-bold uppercase tracking-wider text-slate-500" style="width: 132px; min-width: 132px; white-space: nowrap;">Trạng thái</th>
<th class="px-4 py-2.5 text-[10px] font-bold uppercase tracking-wider text-slate-500">Vị trí</th>
<th class="px-4 py-2.5 text-[10px] font-bold uppercase tracking-wider text-slate-500">Ngày mua</th>
<th class="px-4 py-2.5 text-[10px] font-bold uppercase tracking-wider text-slate-500">Người mượn</th>
<th class="px-4 py-2.5 text-[10px] font-bold uppercase tracking-wider text-slate-500" style="width: 240px; min-width: 240px;">Người mượn</th>
<th class="px-4 py-2.5 text-[10px] font-bold uppercase tracking-wider text-slate-500">Ghi chú</th>
<th class="px-4 py-2.5 text-[10px] font-bold uppercase tracking-wider text-slate-500">Ngày tạo</th>
<th class="px-4 py-2.5 text-[10px] font-bold uppercase tracking-wider text-slate-500">Người xuất</th>
@@ -3469,7 +3585,7 @@ class AccountManager {
</td>
<td class="px-4 py-3 text-sm text-slate-600">${asset.Location || '-'}</td>
<td class="px-4 py-3 text-sm text-slate-600 whitespace-nowrap">${this.formatDateOnly(asset.PurchaseDate)}</td>
<td class="px-4 py-3 text-sm text-slate-600 whitespace-normal">${this.formatBorrowerTableHtml(asset.Borrower)}</td>
<td class="px-4 py-3 text-sm text-slate-600 whitespace-normal" style="width: 240px; min-width: 240px;">${this.formatBorrowerTableHtml(asset.Borrower)}</td>
<td class="px-4 py-3 text-sm text-slate-600 max-w-xs truncate" title="${asset.Notes || ''}">${asset.Notes || '-'}</td>
<td class="px-4 py-3 text-sm text-slate-600 whitespace-nowrap">${this.formatDateOnly(asset.CreatedDate)}</td>
<td class="px-4 py-3 text-sm text-slate-600">${asset.ExportedBy || '-'}</td>
@@ -3553,7 +3669,7 @@ class AccountManager {
</td>
<td class="px-4 py-3 text-sm text-slate-600">${asset.Location || '-'}</td>
<td class="px-4 py-3 text-sm text-slate-600 whitespace-nowrap">${this.formatDateOnly(asset.PurchaseDate)}</td>
<td class="px-4 py-3 text-sm text-slate-600 whitespace-normal">${this.formatBorrowerTableHtml(asset.Borrower)}</td>
<td class="px-4 py-3 text-sm text-slate-600 whitespace-normal" style="width: 240px; min-width: 240px;">${this.formatBorrowerTableHtml(asset.Borrower)}</td>
<td class="px-4 py-3 text-sm text-slate-600 max-w-xs truncate" title="${asset.Notes || ''}">${asset.Notes || '-'}</td>
<td class="px-4 py-3 text-sm text-slate-600 whitespace-nowrap">${this.formatDateOnly(asset.CreatedDate)}</td>
<td class="px-4 py-3 text-sm text-slate-600">${asset.ExportedBy || '-'}</td>
@@ -3996,7 +4112,7 @@ class AccountManager {
};
}
buildAssetPayloadFromAsset(asset, borrowerEntriesOverride = null) {
buildAssetPayloadFromAsset(asset, borrowerEntriesOverride = null, fieldOverrides = {}) {
if (!asset) {
return null;
}
@@ -4045,9 +4161,9 @@ class AccountManager {
endingBalance,
unit: String(asset?.Unit || '').trim(),
department: String(asset?.Department || '').trim(),
project: String(asset?.Project || '').trim(),
project: String(fieldOverrides?.project ?? asset?.Project ?? '').trim(),
location: String(asset?.Location || '').trim(),
custodian: String(asset?.Custodian || '').trim(),
custodian: String(fieldOverrides?.custodian ?? asset?.Custodian ?? '').trim(),
borrower,
purchaseDate: this.toDateInputValue(asset?.PurchaseDate) || null,
purchasePrice: normalizedPrice,
@@ -4059,14 +4175,14 @@ class AccountManager {
const selectedIds = [...this.selectedAssetIds];
if (!selectedIds.length) {
if (showWarning) {
this.notifyWarning('Vui lòng chọn 1 tài sản để mượn.');
this.notifyWarning('Vui lòng chọn 1 tài sản để xuất.');
}
return null;
}
if (selectedIds.length > 1) {
if (showWarning) {
this.notifyWarning('Chỉ chọn đúng 1 tài sản cho mỗi lần mượn.');
this.notifyWarning('Chỉ chọn đúng 1 tài sản cho mỗi lần xuất.');
}
return null;
}
@@ -4080,7 +4196,7 @@ class AccountManager {
}
async openBorrowAssetModal() {
if (!this.ensureAssetManagePermission('muon tai san')) {
if (!this.ensureAssetManagePermission('xuat tai san')) {
return;
}
@@ -4092,10 +4208,13 @@ class AccountManager {
if (!this.users.length) {
await this.fetchUsers();
}
if (!this.assetProjects.length) {
await this.fetchAssetProjects();
}
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ể mượn thêm.');
this.notifyWarning('Tài sản đã hết tồn cuối kỳ, không thể xuất thêm.');
return;
}
@@ -4104,12 +4223,14 @@ class AccountManager {
const assetNameInput = document.getElementById('borrowAssetNameInput');
const endingInput = document.getElementById('borrowCurrentEndingInput');
const quantityInput = document.getElementById('borrowQuantityInput');
const projectInput = document.getElementById('borrowAssetProjectInput');
const noteInput = document.getElementById('borrowAssetNoteInput');
const borrowByInput = document.getElementById('borrowByInput');
const borrowRoleInput = document.getElementById('borrowRoleInput');
const modal = document.getElementById('borrowAssetModal');
if (!modal || !assetNameInput || !endingInput || !quantityInput) {
this.notifyFailure('Không tìm thấy biểu mẫu mượn tài sản.');
if (!modal || !assetNameInput || !endingInput || !quantityInput || !projectInput || !noteInput) {
this.notifyFailure('Không tìm thấy biểu mẫu xuất tài sản.');
return;
}
@@ -4124,6 +4245,8 @@ class AccountManager {
quantityInput.max = String(metrics.endingBalance);
this.refreshBorrowAssetUserOptions('');
this.refreshBorrowAssetProjectOptions(String(asset?.Project || '').trim());
noteInput.value = '';
if (borrowByInput) {
borrowByInput.value = this.getCurrentUserDisplayName();
@@ -4139,23 +4262,25 @@ class AccountManager {
async handleBorrowAssetSubmit(e) {
e.preventDefault();
if (!this.ensureAssetManagePermission('muon tai san')) {
if (!this.ensureAssetManagePermission('xuat tai san')) {
return;
}
const assetIdInput = document.getElementById('borrowAssetIdInput');
const borrowerInput = document.getElementById('borrowAssetUserInput');
const projectInput = document.getElementById('borrowAssetProjectInput');
const quantityInput = document.getElementById('borrowQuantityInput');
const noteInput = document.getElementById('borrowAssetNoteInput');
const selectedAssetId = Number(assetIdInput?.value || this.pendingBorrowAssetId);
if (!Number.isFinite(selectedAssetId) || selectedAssetId <= 0) {
this.notifyFailure('Không xác định được tài sản cần mượn.');
this.notifyFailure('Không xác định được tài sản cần xuất.');
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 mượn.');
this.notifyFailure('Không tìm thấy tài sản cần xuất.');
return;
}
@@ -4165,50 +4290,61 @@ class AccountManager {
return;
}
const borrowQuantity = this.parseNonNegativeInteger(quantityInput?.value ?? 0, 0);
if (borrowQuantity <= 0) {
this.notifyWarning('Số lượng mượn phải lớn hơn 0.');
const projectName = String(projectInput?.value || '').trim();
if (!projectName) {
this.notifyWarning('Vui lòng chọn dự án cần xuất.');
return;
}
const borrowQuantity = this.parseNonNegativeInteger(quantityInput?.value ?? 0, 0);
if (borrowQuantity <= 0) {
this.notifyWarning('Số lượng xuất phải lớn hơn 0.');
return;
}
const exportNote = String(noteInput?.value || '').trim();
const currentMetrics = this.buildAssetQuantityMetrics(asset);
if (currentMetrics.endingBalance <= 0) {
this.notifyWarning('Tài sản đã hết tồn cuối kỳ, không thể mượn thêm.');
this.notifyWarning('Tài sản đã hết tồn cuối kỳ, không thể xuất thêm.');
return;
}
if (borrowQuantity > currentMetrics.endingBalance) {
this.notifyWarning(`Số lượng mượn (${borrowQuantity}) vượt quá tồn cuối kỳ (${currentMetrics.endingBalance}).`);
return;
}
const updatedEntries = this.mergeBorrowerEntries(currentMetrics.borrowerEntries, borrowerName, borrowQuantity);
const payload = this.buildAssetPayloadFromAsset(asset, updatedEntries);
if (!payload) {
this.notifyFailure('Không tạo được dữ liệu mượn tài sản.');
this.notifyWarning(`Số lượng xuất (${borrowQuantity}) vượt quá tồn cuối kỳ (${currentMetrics.endingBalance}).`);
return;
}
try {
const response = await fetch(`${this.apiBase}/assets/${selectedAssetId}`, {
method: 'PUT',
const response = await fetch(`${this.apiBase}/assets/${selectedAssetId}/export`, {
method: 'POST',
headers: this.getAuthHeaders(true),
body: JSON.stringify(payload)
body: JSON.stringify({
quantity: borrowQuantity,
borrowerName,
custodianName: borrowerName,
projectName,
note: exportNote
})
});
const data = await response.json();
if (!response.ok || !data.success) {
this.notifyFailure(data.message || 'Mượn tài sản thất bại');
this.notifyFailure(data.message || 'Xuất tài sản thất bại');
return;
}
this.pendingBorrowAssetId = undefined;
this.notifySuccess('Mượn tài sản thành công');
this.notifySuccess('Xuất tài sản thành công');
this.closeModals();
await this.refreshAssetsUI();
const exportHistoryModal = document.getElementById('assetExportHistoryModal');
if (exportHistoryModal?.classList.contains('open')) {
await this.fetchAssetExportHistories();
this.renderAssetExportHistoryModal();
}
} catch (err) {
console.error(err);
this.notifyFailure('Mượn tài sản thất bại');
this.notifyFailure('Xuất tài sản thất bại');
}
}
@@ -5622,6 +5758,7 @@ class AccountManager {
const importAssetBtn = document.getElementById('importAssetBtn');
const assetImportInput = document.getElementById('assetImportInput');
const exportAssetBtn = document.getElementById('exportAssetBtn');
const openAssetExportHistoryBtn = document.getElementById('openAssetExportHistoryBtn');
if (importAssetBtn && assetImportInput && !importAssetBtn.dataset.boundClick) {
importAssetBtn.addEventListener('click', () => {
@@ -5642,6 +5779,11 @@ class AccountManager {
exportAssetBtn.addEventListener('click', () => this.exportAssetsToExcel());
exportAssetBtn.dataset.boundClick = 'true';
}
if (openAssetExportHistoryBtn && !openAssetExportHistoryBtn.dataset.boundClick) {
openAssetExportHistoryBtn.addEventListener('click', () => this.openAssetExportHistoryModal());
openAssetExportHistoryBtn.dataset.boundClick = 'true';
}
}
setupFilters() {
@@ -6835,6 +6977,13 @@ function closeBorrowAssetModal() {
}
}
function closeAssetExportHistoryModal() {
const modal = document.getElementById('assetExportHistoryModal');
if (modal) {
modal.classList.remove('open');
}
}
function closeAssetBorrowRequestModal() {
const modal = document.getElementById('assetBorrowRequestModal');
const dropdown = document.getElementById('assetBorrowProductDropdown');

View File

@@ -304,11 +304,11 @@
</div>
</div>
<!-- Borrow Asset Modal -->
<!-- Export Asset Modal -->
<div class="modal-backdrop fixed inset-0 z-[100] flex items-center justify-center bg-black/40 backdrop-blur-sm" id="borrowAssetModal">
<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">Mượn tài sản</h3>
<h3 class="text-base font-extrabold text-slate-900">Xuất tài sản</h3>
<button class="p-1.5 rounded-lg hover:bg-slate-200 text-slate-400 transition-colors" onclick="closeBorrowAssetModal()">
<span class="material-symbols-outlined">close</span>
</button>
@@ -325,7 +325,7 @@
<input type="number" id="borrowCurrentEndingInput" 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">Số lượng mượn</label>
<label class="text-[10px] font-bold uppercase text-slate-500 tracking-widest block mb-1">Số lượng xuất</label>
<input type="number" id="borrowQuantityInput" min="1" class="w-full border border-slate-200 rounded-lg text-sm py-2.5 px-3" value="1" required>
</div>
<div>
@@ -334,6 +334,12 @@
<option value="">-- Chọn người mượn --</option>
</select>
</div>
<div>
<label class="text-[10px] font-bold uppercase text-slate-500 tracking-widest block mb-1">Xuất cho dự án</label>
<select id="borrowAssetProjectInput" class="w-full border border-slate-200 rounded-lg text-sm py-2.5 px-3" required>
<option value="">-- Chọn dự án --</option>
</select>
</div>
<div>
<label class="text-[10px] font-bold uppercase text-slate-500 tracking-widest block mb-1">Người xuất</label>
<input type="text" id="borrowByInput" class="w-full border border-slate-200 rounded-lg text-sm py-2.5 px-3 bg-slate-50" readonly>
@@ -342,15 +348,55 @@
<label class="text-[10px] font-bold uppercase text-slate-500 tracking-widest block mb-1">Role người thao tác</label>
<input type="text" id="borrowRoleInput" 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ú xuất</label>
<textarea id="borrowAssetNoteInput" class="w-full border border-slate-200 rounded-lg text-sm py-2.5 px-3 h-20 resize-none" placeholder="Nhập ghi chú (nếu có)"></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="closeBorrowAssetModal()">Hủy</button>
<button type="submit" class="flex-1 px-4 py-2 bg-primary hover:bg-primary-dim text-on-primary rounded-lg text-xs font-bold">Xác nhận mượn</button>
<button type="submit" class="flex-1 px-4 py-2 bg-primary hover:bg-primary-dim text-on-primary rounded-lg text-xs font-bold">Xác nhận xuất</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);">
<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">Lịch sử xuất tài sản</h3>
<button class="p-1.5 rounded-lg hover:bg-slate-200 text-slate-400 transition-colors" onclick="closeAssetExportHistoryModal()">
<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: 1100px;">
<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">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">Dự án</th>
<th class="px-4 py-2.5 text-[10px] font-bold uppercase tracking-wider text-slate-500">Người phụ trách</th>
<th class="px-4 py-2.5 text-[10px] font-bold uppercase tracking-wider text-slate-500">Người xuất</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="assetExportHistoryTableBody" class="divide-y divide-slate-100">
<tr>
<td colspan="7" class="px-4 py-8 text-sm text-center text-slate-500">Chưa có dữ liệu lịch sử xuất.</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">