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

@@ -1724,6 +1724,27 @@ async function createTables() {
FOREIGN KEY (ProcessedBy) REFERENCES Users(UserId) ON DELETE SET NULL FOREIGN KEY (ProcessedBy) REFERENCES Users(UserId) ON DELETE SET NULL
) )
END`, 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 // AuditLog Table
`IF NOT EXISTS (SELECT * FROM sys.tables WHERE name = 'AuditLog') `IF NOT EXISTS (SELECT * FROM sys.tables WHERE name = 'AuditLog')
@@ -1783,6 +1804,14 @@ async function createTables() {
console.error('AssetBorrowRequests index creation error:', err.message); 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 // Ensure new columns exist on Applications for migrations
try { try {
await pool.request().query(`IF EXISTS ( await pool.request().query(`IF EXISTS (
@@ -1820,6 +1849,34 @@ async function createTables() {
FOREIGN KEY (ProcessedBy) REFERENCES Users(UserId) ON DELETE SET NULL; 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.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 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 AssetBorrowRequests SET RequestStatus = ISNULL(NULLIF(LTRIM(RTRIM(RequestStatus)), ''), 'approved');`);
await pool.request().query(`UPDATE AssetInventory SET EndingBalance = ISNULL(EndingBalance, ISNULL(Quantity, 0));`); 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) await new sql.Request(transaction)
.input('assetId', sql.Int, targetRequest.AssetId) .input('assetId', sql.Int, targetRequest.AssetId)
.input('borrower', sql.NVarChar, mergedBorrowerSummary) .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) .input('exportedBy', sql.NVarChar, processorName || null)
.query(` .query(`
UPDATE AssetInventory UPDATE AssetInventory
SET Borrower = @borrower, SET Borrower = @borrower,
ExportInPeriod = @exportInPeriod,
EndingBalance = @endingBalance,
ExportedBy = @exportedBy, ExportedBy = @exportedBy,
UpdatedDate = GETDATE() UpdatedDate = GETDATE()
WHERE AssetId = @assetId WHERE AssetId = @assetId
@@ -3735,13 +3801,22 @@ app.post('/api/asset-borrows/:id/process', requireAssetOrAdmin, async (req, res)
} }
const borrowerSummary = decreased.summary || null; 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) await new sql.Request(transaction)
.input('assetId', sql.Int, targetRequest.AssetId) .input('assetId', sql.Int, targetRequest.AssetId)
.input('borrower', sql.NVarChar, borrowerSummary) .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) .input('exportedBy', sql.NVarChar, processorName || null)
.query(` .query(`
UPDATE AssetInventory UPDATE AssetInventory
SET Borrower = @borrower, SET Borrower = @borrower,
ExportInPeriod = @exportInPeriod,
EndingBalance = @endingBalance,
ExportedBy = CASE WHEN @borrower IS NULL THEN NULL ELSE @exportedBy END, ExportedBy = CASE WHEN @borrower IS NULL THEN NULL ELSE @exportedBy END,
UpdatedDate = GETDATE() UpdatedDate = GETDATE()
WHERE AssetId = @assetId 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) => { app.post('/api/assets', requireAssetOrAdmin, async (req, res) => {
try { try {
const payload = normalizeAssetPayload(req.body); const payload = normalizeAssetPayload(req.body);

View File

@@ -260,7 +260,105 @@ UPDATE AssetBorrowRequests
SET RequestStatus = ISNULL(NULLIF(LTRIM(RTRIM(RequestStatus)), ''), 'approved'); 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') IF NOT EXISTS (SELECT * FROM sys.tables WHERE name = 'AuditLog')
BEGIN BEGIN
@@ -279,7 +377,7 @@ BEGIN
END END
-- =========================================== -- ===========================================
-- 9. CREATE INDEXES -- 10. CREATE INDEXES
-- =========================================== -- ===========================================
IF NOT EXISTS (SELECT * FROM sys.indexes WHERE name = 'IX_Users_Username') IF NOT EXISTS (SELECT * FROM sys.indexes WHERE name = 'IX_Users_Username')
BEGIN BEGIN
@@ -341,10 +439,20 @@ BEGIN
CREATE INDEX IX_AssetBorrowRequests_RequestType ON AssetBorrowRequests(RequestType); CREATE INDEX IX_AssetBorrowRequests_RequestType ON AssetBorrowRequests(RequestType);
END 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.'; PRINT 'Indexes created successfully.';
-- =========================================== -- ===========================================
-- 10. INSERT INITIAL DATA -- 11. INSERT INITIAL DATA
-- =========================================== -- ===========================================
-- Check if admin user exists -- Check if admin user exists
@@ -368,7 +476,7 @@ BEGIN
END END
-- =========================================== -- ===========================================
-- 11. DISPLAY DATABASE INFORMATION -- 12. DISPLAY DATABASE INFORMATION
-- =========================================== -- ===========================================
PRINT ''; PRINT '';
PRINT '========================================'; PRINT '========================================';

View File

@@ -48,6 +48,7 @@ class AccountManager {
this.assetDepartmentSearchTerm = ''; this.assetDepartmentSearchTerm = '';
this.assetProjects = []; this.assetProjects = [];
this.assetProjectSearchTerm = ''; this.assetProjectSearchTerm = '';
this.assetExportHistories = [];
this.selectedAssetIds = new Set(); this.selectedAssetIds = new Set();
this.mobileBreakpoint = 900; this.mobileBreakpoint = 900;
this.boundResizeHandler = null; 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) { getAssetBorrowProductDisplayName(asset) {
if (!asset) { if (!asset) {
return '-- Chọn tài sản --'; return '-- Chọn tài sản --';
@@ -907,6 +946,7 @@ class AccountManager {
if (data.success) { if (data.success) {
this.assetProjects = Array.isArray(data.data) ? data.data : []; this.assetProjects = Array.isArray(data.data) ? data.data : [];
this.refreshAssetProjectOptions(document.getElementById('assetProjectInput')?.value || ''); this.refreshAssetProjectOptions(document.getElementById('assetProjectInput')?.value || '');
this.refreshBorrowAssetProjectOptions(document.getElementById('borrowAssetProjectInput')?.value || '');
} else { } else {
console.error('Load asset projects failed:', data.message); 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() { async fetchRoles() {
try { try {
const res = await fetch(`${this.apiBase}/roles`); const res = await fetch(`${this.apiBase}/roles`);
@@ -952,6 +1068,7 @@ class AccountManager {
this.setupFilters(); this.setupFilters();
this.refreshAssetDepartmentOptions(document.getElementById('assetDepartmentInput')?.value || ''); this.refreshAssetDepartmentOptions(document.getElementById('assetDepartmentInput')?.value || '');
this.refreshAssetProjectOptions(document.getElementById('assetProjectInput')?.value || ''); this.refreshAssetProjectOptions(document.getElementById('assetProjectInput')?.value || '');
this.refreshBorrowAssetProjectOptions(document.getElementById('borrowAssetProjectInput')?.value || '');
} catch (error) { } catch (error) {
console.error('Lỗi load modals:', error); console.error('Lỗi load modals:', error);
} }
@@ -1835,7 +1952,7 @@ class AccountManager {
return entries return entries
.map(entry => this.formatBorrowerDisplay(entry.name, entry.quantity)) .map(entry => this.formatBorrowerDisplay(entry.name, entry.quantity))
.filter(Boolean) .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(''); .join('');
} }
@@ -1866,15 +1983,10 @@ class AccountManager {
const borrowerExportInPeriod = borrowerEntries.reduce((sum, entry) => ( const borrowerExportInPeriod = borrowerEntries.reduce((sum, entry) => (
sum + this.parseNonNegativeInteger(entry?.quantity, 0) sum + this.parseNonNegativeInteger(entry?.quantity, 0)
), 0); ), 0);
const storedExportInPeriod = this.parseOptionalNonNegativeInteger(asset?.ExportInPeriod ?? asset?.exportInPeriod); // Borrower entries are the source of truth for exported quantity.
const exportInPeriod = storedExportInPeriod !== null // This keeps UI consistent even when legacy rows have stale stored balances.
? storedExportInPeriod const exportInPeriod = borrowerExportInPeriod;
: borrowerExportInPeriod; const endingBalance = Math.max(quantity + importInPeriod - exportInPeriod, 0);
const computedEndingBalance = Math.max(quantity + importInPeriod - exportInPeriod, 0);
const storedEndingBalance = this.parseOptionalNonNegativeInteger(asset?.EndingBalance ?? asset?.endingBalance);
const endingBalance = storedEndingBalance !== null
? storedEndingBalance
: computedEndingBalance;
return { return {
quantity, quantity,
@@ -3375,13 +3487,17 @@ class AccountManager {
<span class="material-symbols-outlined text-base">download</span> <span class="material-symbols-outlined text-base">download</span>
Xuất Excel Xuất Excel
</button> </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'}> <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> <span class="material-symbols-outlined text-base">add_box</span>
Thêm tài sản Thêm tài sản
</button> </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 ? 'hover:bg-primary/5' : 'opacity-50 cursor-not-allowed'}" ${canManageAssets ? '' : 'disabled'}>
<span class="material-symbols-outlined text-base">handshake</span> <span class="material-symbols-outlined text-base">handshake</span>
Mượn tài sản Xuất tài sản
</button> </button>
<input id="assetImportInput" type="file" accept=".xlsx,.xls" class="hidden" /> <input id="assetImportInput" type="file" accept=".xlsx,.xls" class="hidden" />
</div> </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" 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">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à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">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à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> <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>
<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">${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-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 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 whitespace-nowrap">${this.formatDateOnly(asset.CreatedDate)}</td>
<td class="px-4 py-3 text-sm text-slate-600">${asset.ExportedBy || '-'}</td> <td class="px-4 py-3 text-sm text-slate-600">${asset.ExportedBy || '-'}</td>
@@ -3553,7 +3669,7 @@ class AccountManager {
</td> </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">${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-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 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 whitespace-nowrap">${this.formatDateOnly(asset.CreatedDate)}</td>
<td class="px-4 py-3 text-sm text-slate-600">${asset.ExportedBy || '-'}</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) { if (!asset) {
return null; return null;
} }
@@ -4045,9 +4161,9 @@ class AccountManager {
endingBalance, endingBalance,
unit: String(asset?.Unit || '').trim(), unit: String(asset?.Unit || '').trim(),
department: String(asset?.Department || '').trim(), department: String(asset?.Department || '').trim(),
project: String(asset?.Project || '').trim(), project: String(fieldOverrides?.project ?? asset?.Project ?? '').trim(),
location: String(asset?.Location || '').trim(), location: String(asset?.Location || '').trim(),
custodian: String(asset?.Custodian || '').trim(), custodian: String(fieldOverrides?.custodian ?? asset?.Custodian ?? '').trim(),
borrower, borrower,
purchaseDate: this.toDateInputValue(asset?.PurchaseDate) || null, purchaseDate: this.toDateInputValue(asset?.PurchaseDate) || null,
purchasePrice: normalizedPrice, purchasePrice: normalizedPrice,
@@ -4059,14 +4175,14 @@ class AccountManager {
const selectedIds = [...this.selectedAssetIds]; const selectedIds = [...this.selectedAssetIds];
if (!selectedIds.length) { if (!selectedIds.length) {
if (showWarning) { 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; return null;
} }
if (selectedIds.length > 1) { if (selectedIds.length > 1) {
if (showWarning) { 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; return null;
} }
@@ -4080,7 +4196,7 @@ class AccountManager {
} }
async openBorrowAssetModal() { async openBorrowAssetModal() {
if (!this.ensureAssetManagePermission('muon tai san')) { if (!this.ensureAssetManagePermission('xuat tai san')) {
return; return;
} }
@@ -4092,10 +4208,13 @@ class AccountManager {
if (!this.users.length) { if (!this.users.length) {
await this.fetchUsers(); await this.fetchUsers();
} }
if (!this.assetProjects.length) {
await this.fetchAssetProjects();
}
const metrics = this.buildAssetQuantityMetrics(asset); const metrics = this.buildAssetQuantityMetrics(asset);
if (metrics.endingBalance <= 0) { 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; return;
} }
@@ -4104,12 +4223,14 @@ class AccountManager {
const assetNameInput = document.getElementById('borrowAssetNameInput'); const assetNameInput = document.getElementById('borrowAssetNameInput');
const endingInput = document.getElementById('borrowCurrentEndingInput'); const endingInput = document.getElementById('borrowCurrentEndingInput');
const quantityInput = document.getElementById('borrowQuantityInput'); const quantityInput = document.getElementById('borrowQuantityInput');
const projectInput = document.getElementById('borrowAssetProjectInput');
const noteInput = document.getElementById('borrowAssetNoteInput');
const borrowByInput = document.getElementById('borrowByInput'); const borrowByInput = document.getElementById('borrowByInput');
const borrowRoleInput = document.getElementById('borrowRoleInput'); const borrowRoleInput = document.getElementById('borrowRoleInput');
const modal = document.getElementById('borrowAssetModal'); const modal = document.getElementById('borrowAssetModal');
if (!modal || !assetNameInput || !endingInput || !quantityInput) { if (!modal || !assetNameInput || !endingInput || !quantityInput || !projectInput || !noteInput) {
this.notifyFailure('Không tìm thấy biểu mẫu mượn tài sản.'); this.notifyFailure('Không tìm thấy biểu mẫu xuất tài sản.');
return; return;
} }
@@ -4124,6 +4245,8 @@ class AccountManager {
quantityInput.max = String(metrics.endingBalance); quantityInput.max = String(metrics.endingBalance);
this.refreshBorrowAssetUserOptions(''); this.refreshBorrowAssetUserOptions('');
this.refreshBorrowAssetProjectOptions(String(asset?.Project || '').trim());
noteInput.value = '';
if (borrowByInput) { if (borrowByInput) {
borrowByInput.value = this.getCurrentUserDisplayName(); borrowByInput.value = this.getCurrentUserDisplayName();
@@ -4139,23 +4262,25 @@ class AccountManager {
async handleBorrowAssetSubmit(e) { async handleBorrowAssetSubmit(e) {
e.preventDefault(); e.preventDefault();
if (!this.ensureAssetManagePermission('muon tai san')) { if (!this.ensureAssetManagePermission('xuat tai san')) {
return; return;
} }
const assetIdInput = document.getElementById('borrowAssetIdInput'); const assetIdInput = document.getElementById('borrowAssetIdInput');
const borrowerInput = document.getElementById('borrowAssetUserInput'); const borrowerInput = document.getElementById('borrowAssetUserInput');
const projectInput = document.getElementById('borrowAssetProjectInput');
const quantityInput = document.getElementById('borrowQuantityInput'); const quantityInput = document.getElementById('borrowQuantityInput');
const noteInput = document.getElementById('borrowAssetNoteInput');
const selectedAssetId = Number(assetIdInput?.value || this.pendingBorrowAssetId); const selectedAssetId = Number(assetIdInput?.value || this.pendingBorrowAssetId);
if (!Number.isFinite(selectedAssetId) || selectedAssetId <= 0) { 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; return;
} }
const asset = this.assets.find(item => Number(item?.AssetId) === selectedAssetId); const asset = this.assets.find(item => Number(item?.AssetId) === selectedAssetId);
if (!asset) { 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; return;
} }
@@ -4165,50 +4290,61 @@ class AccountManager {
return; return;
} }
const borrowQuantity = this.parseNonNegativeInteger(quantityInput?.value ?? 0, 0); const projectName = String(projectInput?.value || '').trim();
if (borrowQuantity <= 0) { if (!projectName) {
this.notifyWarning('Số lượng mượn phải lớn hơn 0.'); this.notifyWarning('Vui lòng chọn dự án cần xuất.');
return; 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); const currentMetrics = this.buildAssetQuantityMetrics(asset);
if (currentMetrics.endingBalance <= 0) { 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; return;
} }
if (borrowQuantity > currentMetrics.endingBalance) { if (borrowQuantity > currentMetrics.endingBalance) {
this.notifyWarning(`Số lượng mượn (${borrowQuantity}) vượt quá tồn cuối kỳ (${currentMetrics.endingBalance}).`); this.notifyWarning(`Số lượng xuất (${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.');
return; return;
} }
try { try {
const response = await fetch(`${this.apiBase}/assets/${selectedAssetId}`, { const response = await fetch(`${this.apiBase}/assets/${selectedAssetId}/export`, {
method: 'PUT', method: 'POST',
headers: this.getAuthHeaders(true), headers: this.getAuthHeaders(true),
body: JSON.stringify(payload) body: JSON.stringify({
quantity: borrowQuantity,
borrowerName,
custodianName: borrowerName,
projectName,
note: exportNote
})
}); });
const data = await response.json(); const data = await response.json();
if (!response.ok || !data.success) { 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; return;
} }
this.pendingBorrowAssetId = undefined; 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(); this.closeModals();
await this.refreshAssetsUI(); await this.refreshAssetsUI();
const exportHistoryModal = document.getElementById('assetExportHistoryModal');
if (exportHistoryModal?.classList.contains('open')) {
await this.fetchAssetExportHistories();
this.renderAssetExportHistoryModal();
}
} catch (err) { } catch (err) {
console.error(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 importAssetBtn = document.getElementById('importAssetBtn');
const assetImportInput = document.getElementById('assetImportInput'); const assetImportInput = document.getElementById('assetImportInput');
const exportAssetBtn = document.getElementById('exportAssetBtn'); const exportAssetBtn = document.getElementById('exportAssetBtn');
const openAssetExportHistoryBtn = document.getElementById('openAssetExportHistoryBtn');
if (importAssetBtn && assetImportInput && !importAssetBtn.dataset.boundClick) { if (importAssetBtn && assetImportInput && !importAssetBtn.dataset.boundClick) {
importAssetBtn.addEventListener('click', () => { importAssetBtn.addEventListener('click', () => {
@@ -5642,6 +5779,11 @@ class AccountManager {
exportAssetBtn.addEventListener('click', () => this.exportAssetsToExcel()); exportAssetBtn.addEventListener('click', () => this.exportAssetsToExcel());
exportAssetBtn.dataset.boundClick = 'true'; exportAssetBtn.dataset.boundClick = 'true';
} }
if (openAssetExportHistoryBtn && !openAssetExportHistoryBtn.dataset.boundClick) {
openAssetExportHistoryBtn.addEventListener('click', () => this.openAssetExportHistoryModal());
openAssetExportHistoryBtn.dataset.boundClick = 'true';
}
} }
setupFilters() { setupFilters() {
@@ -6835,6 +6977,13 @@ function closeBorrowAssetModal() {
} }
} }
function closeAssetExportHistoryModal() {
const modal = document.getElementById('assetExportHistoryModal');
if (modal) {
modal.classList.remove('open');
}
}
function closeAssetBorrowRequestModal() { function closeAssetBorrowRequestModal() {
const modal = document.getElementById('assetBorrowRequestModal'); const modal = document.getElementById('assetBorrowRequestModal');
const dropdown = document.getElementById('assetBorrowProductDropdown'); const dropdown = document.getElementById('assetBorrowProductDropdown');

View File

@@ -304,11 +304,11 @@
</div> </div>
</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-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="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"> <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()"> <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> <span class="material-symbols-outlined">close</span>
</button> </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> <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>
<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> <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>
<div> <div>
@@ -334,6 +334,12 @@
<option value="">-- Chọn người mượn --</option> <option value="">-- Chọn người mượn --</option>
</select> </select>
</div> </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> <div>
<label class="text-[10px] font-bold uppercase text-slate-500 tracking-widest block mb-1">Người xuất</label> <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> <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> <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> <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>
<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>
<div class="flex gap-3 pt-2"> <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="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> </div>
</form> </form>
</div> </div>
</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 --> <!-- 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-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"> <div class="modal-content w-full max-w-lg bg-white rounded-xl shadow-2xl border border-slate-200 overflow-visible m-4">