xuất tài sản
This commit is contained in:
@@ -1724,6 +1724,27 @@ async function createTables() {
|
||||
FOREIGN KEY (ProcessedBy) REFERENCES Users(UserId) ON DELETE SET NULL
|
||||
)
|
||||
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')
|
||||
@@ -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);
|
||||
|
||||
@@ -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 '========================================';
|
||||
|
||||
237
public/js/app.js
237
public/js/app.js
@@ -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');
|
||||
|
||||
@@ -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">
|
||||
|
||||
Reference in New Issue
Block a user