diff --git a/backend/server.js b/backend/server.js index 500e641..1dcb301 100644 --- a/backend/server.js +++ b/backend/server.js @@ -4000,6 +4000,8 @@ app.post('/api/asset-borrows', async (req, res) => { AssetName, Quantity, ImportInPeriod, + ExportInPeriod, + EndingBalance, Borrower, Unit FROM AssetInventory @@ -4015,10 +4017,12 @@ app.post('/api/asset-borrows', async (req, res) => { const currentBorrowed = currentBorrowedEntries.reduce((sum, entry) => ( sum + parseNonNegativeInteger(entry?.quantity, 0) ), 0); - const endingBalance = Math.max( + const derivedEndingBalance = Math.max( parseNonNegativeInteger(asset.Quantity, 0) + parseNonNegativeInteger(asset.ImportInPeriod, 0) - currentBorrowed, 0 ); + const storedEndingBalance = parseOptionalNonNegativeInteger(asset.EndingBalance); + const endingBalance = storedEndingBalance !== null ? storedEndingBalance : derivedEndingBalance; const unit = String(req.body?.unit || '').trim() || String(asset.Unit || '').trim() || null; @@ -4836,12 +4840,14 @@ app.get('/api/assets/search', async (req, res) => { const keywordLike = `%${rawKeyword}%`; const limit = Math.min(parsePositiveInteger(req.query.limit, 80), 200); const offset = parseNonNegativeInteger(req.query.offset, 0); + const borrowableOnly = ['1', 'true', 'yes'].includes(String(req.query.borrowableOnly || '').trim().toLowerCase()); const result = await pool.request() .input('limit', sql.Int, limit) .input('offset', sql.Int, offset) .input('keyword', sql.NVarChar, rawKeyword) .input('keywordLike', sql.NVarChar, keywordLike) + .input('borrowableOnly', sql.Bit, borrowableOnly ? 1 : 0) .query(` ;WITH FilteredAssets AS ( SELECT @@ -4849,14 +4855,23 @@ app.get('/api/assets/search', async (req, res) => { AssetCode, AssetName, Unit, + EndingBalance, + CASE + WHEN ISNULL(EndingBalance, 0) <= 0 THEN 'exported' + WHEN ISNULL(ExportInPeriod, 0) > 0 THEN 'in_use' + ELSE 'in_stock' + END AS Status, UpdatedDate, CASE WHEN @keyword <> '' AND AssetCode LIKE @keywordLike THEN 0 ELSE 1 END AS CodeRank, CASE WHEN @keyword <> '' AND AssetName LIKE @keywordLike THEN 0 ELSE 1 END AS NameRank FROM AssetInventory - WHERE @keyword = '' - OR AssetCode LIKE @keywordLike - OR AssetName LIKE @keywordLike - OR Model LIKE @keywordLike + WHERE (@borrowableOnly = 0 OR ISNULL(EndingBalance, 0) > 0) + AND ( + @keyword = '' + OR AssetCode LIKE @keywordLike + OR AssetName LIKE @keywordLike + OR Model LIKE @keywordLike + ) ), OrderedAssets AS ( SELECT @@ -4864,6 +4879,8 @@ app.get('/api/assets/search', async (req, res) => { AssetCode, AssetName, Unit, + EndingBalance, + Status, ROW_NUMBER() OVER ( ORDER BY CodeRank ASC, @@ -4873,17 +4890,20 @@ app.get('/api/assets/search', async (req, res) => { ) AS RowNum FROM FilteredAssets ) - SELECT AssetId, AssetCode, AssetName, Unit + SELECT AssetId, AssetCode, AssetName, Unit, EndingBalance, Status FROM OrderedAssets WHERE RowNum > @offset AND RowNum <= (@offset + @limit) ORDER BY RowNum; SELECT COUNT(*) AS TotalCount FROM AssetInventory - WHERE @keyword = '' - OR AssetCode LIKE @keywordLike - OR AssetName LIKE @keywordLike - OR Model LIKE @keywordLike; + WHERE (@borrowableOnly = 0 OR ISNULL(EndingBalance, 0) > 0) + AND ( + @keyword = '' + OR AssetCode LIKE @keywordLike + OR AssetName LIKE @keywordLike + OR Model LIKE @keywordLike + ); `); const rows = Array.isArray(result.recordsets?.[0]) ? result.recordsets[0] : []; diff --git a/public/js/app.js b/public/js/app.js index 55af71b..7662b1b 100644 --- a/public/js/app.js +++ b/public/js/app.js @@ -525,15 +525,40 @@ class AccountManager { return `${code} - ${name}`.replace(/^\s*-\s*|\s*-\s*$/g, '').trim() || name || '-- Chọn tài sản --'; } + isBorrowAssetRequestMode() { + const typeInput = document.getElementById('assetBorrowRequestTypeInput'); + return this.normalizeAssetRequestType(typeInput?.value || this.assetBorrowRequestType) === 'borrow'; + } + + isAssetAvailableForBorrow(asset) { + if (!asset) { + return false; + } + + const endingBalance = this.parseOptionalNonNegativeInteger(asset?.EndingBalance ?? asset?.endingBalance); + if (endingBalance !== null) { + return endingBalance > 0; + } + + const status = String(asset?.Status || asset?.status || '').trim().toLowerCase(); + return status !== 'exported'; + } + getAssetBorrowProductById(assetIdValue) { const assetId = Number(assetIdValue); if (!Number.isFinite(assetId) || assetId <= 0) { return null; } - return this.assetBorrowProductItems.find(item => Number(item?.AssetId) === assetId) + const asset = this.assetBorrowProductItems.find(item => Number(item?.AssetId) === assetId) || this.assets.find(item => Number(item?.AssetId) === assetId) || null; + + if (this.isBorrowAssetRequestMode() && !this.isAssetAvailableForBorrow(asset)) { + return null; + } + + return asset; } updateAssetBorrowProductDisplay(assetIdValue) { @@ -654,9 +679,12 @@ class AccountManager { const encodedKeyword = encodeURIComponent(this.assetBorrowProductQuery); const offset = this.assetBorrowProductOffset; const limit = this.assetBorrowProductLimit; + const borrowableOnly = this.isBorrowAssetRequestMode(); const appendRows = (rows = [], hasMore = false) => { - const source = Array.isArray(rows) ? rows : []; + const rowsArray = Array.isArray(rows) ? rows : []; + const source = rowsArray + .filter(asset => !borrowableOnly || this.isAssetAvailableForBorrow(asset)); if (source.length) { const merged = new Map( this.assetBorrowProductItems.map(item => [String(item.AssetId), item]) @@ -665,8 +693,8 @@ class AccountManager { merged.set(String(item.AssetId), item); }); this.assetBorrowProductItems = Array.from(merged.values()); - this.assetBorrowProductOffset += source.length; } + this.assetBorrowProductOffset += rowsArray.length; this.assetBorrowProductHasMore = Boolean(hasMore); this.assetBorrowProductLoading = false; @@ -686,6 +714,10 @@ class AccountManager { const source = Array.isArray(this.assets) ? this.assets : []; const normalized = this.assetBorrowProductQuery.toLowerCase(); const filtered = source.filter(asset => { + if (borrowableOnly && !this.isAssetAvailableForBorrow(asset)) { + return false; + } + if (!normalized) { return true; } @@ -705,7 +737,7 @@ class AccountManager { }; try { - const response = await fetch(`${this.apiBase}/assets/search?q=${encodedKeyword}&limit=${limit}&offset=${offset}`, { + const response = await fetch(`${this.apiBase}/assets/search?q=${encodedKeyword}&limit=${limit}&offset=${offset}&borrowableOnly=${borrowableOnly ? '1' : '0'}`, { headers: this.getAuthHeaders(false) }); const data = await response.json(); @@ -3773,7 +3805,7 @@ class AccountManager { await this.searchAssetBorrowProducts('', '', { reset: true }); if (!this.assetBorrowProductItems.length) { - this.notifyWarning('Hiện chưa có tài sản để tạo đơn.'); + this.notifyWarning(isReturnRequest ? 'Hiện chưa có tài sản để tạo đơn trả.' : 'Hiện chưa có tài sản còn tồn cuối kỳ để tạo đơn mượn.'); return; } @@ -3805,6 +3837,19 @@ class AccountManager { return; } + const selectedAsset = this.assetBorrowProductItems.find(item => Number(item?.AssetId) === assetId) + || this.assets.find(item => Number(item?.AssetId) === assetId) + || null; + if (requestType === 'borrow' && selectedAsset && !this.isAssetAvailableForBorrow(selectedAsset)) { + this.notifyWarning('Tài sản đã xuất hoặc hết tồn cuối kỳ, không thể tạo đơn mượn.'); + await this.searchAssetBorrowProducts( + document.getElementById('assetBorrowProductSearchInput')?.value || '', + '', + { reset: true } + ); + return; + } + const borrowDate = String(dateInput?.value || '').trim() || this.toDateInputValue(new Date()); const unit = String(unitInput?.value || '').trim(); const borrowerName = String(requesterInput?.value || this.getCurrentUserDisplayName() || '').trim();