Compare commits

..

4 Commits

Author SHA1 Message Date
1ff9826056 time 2026-05-15 14:26:30 +07:00
41e523ff35 case hỏng 2026-05-15 10:51:39 +07:00
12c6380ceb fix mươn tài sản đã xuất 2026-05-15 10:17:40 +07:00
cb4d5b9520 done mượn trả 2026-05-15 10:10:25 +07:00
8 changed files with 1209 additions and 141 deletions

View File

@@ -3,6 +3,8 @@ FROM node:20-bookworm-slim
WORKDIR /app WORKDIR /app
ENV NODE_ENV=production ENV NODE_ENV=production
ENV TZ=Asia/Ho_Chi_Minh
ENV APP_TIME_ZONE=Asia/Ho_Chi_Minh
COPY package*.json ./ COPY package*.json ./
RUN npm ci --omit=dev && npm cache clean --force RUN npm ci --omit=dev && npm cache clean --force

File diff suppressed because it is too large Load Diff

View File

@@ -32,7 +32,7 @@ BEGIN
FullName NVARCHAR(100), FullName NVARCHAR(100),
Role NVARCHAR(50) NOT NULL, Role NVARCHAR(50) NOT NULL,
Status NVARCHAR(20) DEFAULT 'Active', Status NVARCHAR(20) DEFAULT 'Active',
CreatedDate DATETIME DEFAULT GETDATE(), CreatedDate DATETIME DEFAULT (DATEADD(HOUR, 7, SYSUTCDATETIME())),
LastLogin DATETIME, LastLogin DATETIME,
IsActive BIT DEFAULT 1 IsActive BIT DEFAULT 1
); );
@@ -51,8 +51,8 @@ BEGIN
Status NVARCHAR(20) DEFAULT 'online', Status NVARCHAR(20) DEFAULT 'online',
Icon NVARCHAR(50), Icon NVARCHAR(50),
Description NVARCHAR(500), Description NVARCHAR(500),
CreatedDate DATETIME DEFAULT GETDATE(), CreatedDate DATETIME DEFAULT (DATEADD(HOUR, 7, SYSUTCDATETIME())),
UpdatedDate DATETIME DEFAULT GETDATE() UpdatedDate DATETIME DEFAULT (DATEADD(HOUR, 7, SYSUTCDATETIME()))
); );
PRINT 'Table Applications created successfully.'; PRINT 'Table Applications created successfully.';
END END
@@ -72,8 +72,8 @@ BEGIN
AccessLevel NVARCHAR(50), AccessLevel NVARCHAR(50),
Status NVARCHAR(20) DEFAULT 'Active', Status NVARCHAR(20) DEFAULT 'Active',
Notes NVARCHAR(MAX), Notes NVARCHAR(MAX),
CreatedDate DATETIME DEFAULT GETDATE(), CreatedDate DATETIME DEFAULT (DATEADD(HOUR, 7, SYSUTCDATETIME())),
UpdatedDate DATETIME DEFAULT GETDATE(), UpdatedDate DATETIME DEFAULT (DATEADD(HOUR, 7, SYSUTCDATETIME())),
FOREIGN KEY (UserId) REFERENCES Users(UserId) ON DELETE CASCADE, FOREIGN KEY (UserId) REFERENCES Users(UserId) ON DELETE CASCADE,
FOREIGN KEY (AppId) REFERENCES Applications(AppId) ON DELETE CASCADE FOREIGN KEY (AppId) REFERENCES Applications(AppId) ON DELETE CASCADE
); );
@@ -109,8 +109,8 @@ BEGIN
Status NVARCHAR(30) NOT NULL DEFAULT 'in_use', Status NVARCHAR(30) NOT NULL DEFAULT 'in_use',
Notes NVARCHAR(MAX), Notes NVARCHAR(MAX),
CreatedBy INT NULL, CreatedBy INT NULL,
CreatedDate DATETIME DEFAULT GETDATE(), CreatedDate DATETIME DEFAULT (DATEADD(HOUR, 7, SYSUTCDATETIME())),
UpdatedDate DATETIME DEFAULT GETDATE(), UpdatedDate DATETIME DEFAULT (DATEADD(HOUR, 7, SYSUTCDATETIME())),
FOREIGN KEY (CreatedBy) REFERENCES Users(UserId) ON DELETE SET NULL FOREIGN KEY (CreatedBy) REFERENCES Users(UserId) ON DELETE SET NULL
); );
PRINT 'Table AssetInventory created successfully.'; PRINT 'Table AssetInventory created successfully.';
@@ -175,8 +175,8 @@ BEGIN
CREATE TABLE AssetDepartments ( CREATE TABLE AssetDepartments (
DepartmentId INT PRIMARY KEY IDENTITY(1,1), DepartmentId INT PRIMARY KEY IDENTITY(1,1),
DepartmentName NVARCHAR(100) NOT NULL, DepartmentName NVARCHAR(100) NOT NULL,
CreatedDate DATETIME DEFAULT GETDATE(), CreatedDate DATETIME DEFAULT (DATEADD(HOUR, 7, SYSUTCDATETIME())),
UpdatedDate DATETIME DEFAULT GETDATE() UpdatedDate DATETIME DEFAULT (DATEADD(HOUR, 7, SYSUTCDATETIME()))
); );
PRINT 'Table AssetDepartments created successfully.'; PRINT 'Table AssetDepartments created successfully.';
END END
@@ -204,8 +204,8 @@ BEGIN
CREATE TABLE AssetProjects ( CREATE TABLE AssetProjects (
ProjectId INT PRIMARY KEY IDENTITY(1,1), ProjectId INT PRIMARY KEY IDENTITY(1,1),
ProjectName NVARCHAR(150) NOT NULL, ProjectName NVARCHAR(150) NOT NULL,
CreatedDate DATETIME DEFAULT GETDATE(), CreatedDate DATETIME DEFAULT (DATEADD(HOUR, 7, SYSUTCDATETIME())),
UpdatedDate DATETIME DEFAULT GETDATE() UpdatedDate DATETIME DEFAULT (DATEADD(HOUR, 7, SYSUTCDATETIME()))
); );
PRINT 'Table AssetProjects created successfully.'; PRINT 'Table AssetProjects created successfully.';
END END
@@ -224,15 +224,15 @@ BEGIN
BorrowQuantity INT NOT NULL DEFAULT 1, BorrowQuantity INT NOT NULL DEFAULT 1,
ReturnedQuantity INT NOT NULL DEFAULT 0, ReturnedQuantity INT NOT NULL DEFAULT 0,
Unit NVARCHAR(50), Unit NVARCHAR(50),
BorrowDate DATE NOT NULL DEFAULT CAST(GETDATE() AS DATE), BorrowDate DATE NOT NULL DEFAULT (CAST(DATEADD(HOUR, 7, SYSUTCDATETIME()) AS DATE)),
RequestNote NVARCHAR(500) NULL, RequestNote NVARCHAR(500) NULL,
RejectReason NVARCHAR(1000) NULL, RejectReason NVARCHAR(1000) NULL,
CreatedBy INT NULL, CreatedBy INT NULL,
ProcessedBy INT NULL, ProcessedBy INT NULL,
ProcessedByName NVARCHAR(100) NULL, ProcessedByName NVARCHAR(100) NULL,
ProcessedDate DATETIME NULL, ProcessedDate DATETIME NULL,
CreatedDate DATETIME DEFAULT GETDATE(), CreatedDate DATETIME DEFAULT (DATEADD(HOUR, 7, SYSUTCDATETIME())),
UpdatedDate DATETIME DEFAULT GETDATE(), UpdatedDate DATETIME DEFAULT (DATEADD(HOUR, 7, SYSUTCDATETIME())),
FOREIGN KEY (AssetId) REFERENCES AssetInventory(AssetId) ON DELETE CASCADE, FOREIGN KEY (AssetId) REFERENCES AssetInventory(AssetId) ON DELETE CASCADE,
FOREIGN KEY (CreatedBy) REFERENCES Users(UserId) ON DELETE SET NULL, FOREIGN KEY (CreatedBy) REFERENCES Users(UserId) ON DELETE SET NULL,
FOREIGN KEY (ProcessedBy) REFERENCES Users(UserId) ON DELETE SET NULL FOREIGN KEY (ProcessedBy) REFERENCES Users(UserId) ON DELETE SET NULL
@@ -247,12 +247,12 @@ END
IF COL_LENGTH('dbo.AssetBorrowRequests', 'BorrowDate') IS NULL IF COL_LENGTH('dbo.AssetBorrowRequests', 'BorrowDate') IS NULL
BEGIN BEGIN
ALTER TABLE AssetBorrowRequests ADD BorrowDate DATE NOT NULL CONSTRAINT DF_AssetBorrowRequests_BorrowDate DEFAULT(CAST(GETDATE() AS DATE)); ALTER TABLE AssetBorrowRequests ADD BorrowDate DATE NOT NULL CONSTRAINT DF_AssetBorrowRequests_BorrowDate DEFAULT(CAST(DATEADD(HOUR, 7, SYSUTCDATETIME()) AS DATE));
END END
IF COL_LENGTH('dbo.AssetBorrowRequests', 'UpdatedDate') IS NULL IF COL_LENGTH('dbo.AssetBorrowRequests', 'UpdatedDate') IS NULL
BEGIN BEGIN
ALTER TABLE AssetBorrowRequests ADD UpdatedDate DATETIME NOT NULL CONSTRAINT DF_AssetBorrowRequests_UpdatedDate DEFAULT(GETDATE()); ALTER TABLE AssetBorrowRequests ADD UpdatedDate DATETIME NOT NULL CONSTRAINT DF_AssetBorrowRequests_UpdatedDate DEFAULT(DATEADD(HOUR, 7, SYSUTCDATETIME()));
END END
IF COL_LENGTH('dbo.AssetBorrowRequests', 'RequestType') IS NULL IF COL_LENGTH('dbo.AssetBorrowRequests', 'RequestType') IS NULL
@@ -319,7 +319,7 @@ BEGIN
BorrowId INT NOT NULL, BorrowId INT NOT NULL,
ReturnId INT NOT NULL, ReturnId INT NOT NULL,
Quantity INT NOT NULL CONSTRAINT DF_AssetBorrowRequestLinks_Quantity DEFAULT(1), Quantity INT NOT NULL CONSTRAINT DF_AssetBorrowRequestLinks_Quantity DEFAULT(1),
CreatedDate DATETIME NOT NULL CONSTRAINT DF_AssetBorrowRequestLinks_CreatedDate DEFAULT(GETDATE()), CreatedDate DATETIME NOT NULL CONSTRAINT DF_AssetBorrowRequestLinks_CreatedDate DEFAULT(DATEADD(HOUR, 7, SYSUTCDATETIME())),
FOREIGN KEY (BorrowId) REFERENCES AssetBorrowRequests(BorrowId) ON DELETE NO ACTION, FOREIGN KEY (BorrowId) REFERENCES AssetBorrowRequests(BorrowId) ON DELETE NO ACTION,
FOREIGN KEY (ReturnId) REFERENCES AssetBorrowRequests(BorrowId) ON DELETE NO ACTION FOREIGN KEY (ReturnId) REFERENCES AssetBorrowRequests(BorrowId) ON DELETE NO ACTION
); );
@@ -373,7 +373,7 @@ BEGIN
THEN 'returned' THEN 'returned'
ELSE borrowRows.RequestStatus ELSE borrowRows.RequestStatus
END, END,
UpdatedDate = GETDATE() UpdatedDate = DATEADD(HOUR, 7, SYSUTCDATETIME())
FROM AssetBorrowRequests borrowRows FROM AssetBorrowRequests borrowRows
INNER JOIN ( INNER JOIN (
SELECT links.BorrowId, SUM(ISNULL(links.Quantity, 0)) AS ReturnedQuantity SELECT links.BorrowId, SUM(ISNULL(links.Quantity, 0)) AS ReturnedQuantity
@@ -401,9 +401,9 @@ BEGIN
ExportedByName NVARCHAR(100) NOT NULL, ExportedByName NVARCHAR(100) NOT NULL,
ExportNote NVARCHAR(1000) NULL, ExportNote NVARCHAR(1000) NULL,
CreatedBy INT NULL, CreatedBy INT NULL,
ExportedDate DATETIME NOT NULL DEFAULT GETDATE(), ExportedDate DATETIME NOT NULL DEFAULT (DATEADD(HOUR, 7, SYSUTCDATETIME())),
CreatedDate DATETIME NOT NULL DEFAULT GETDATE(), CreatedDate DATETIME NOT NULL DEFAULT (DATEADD(HOUR, 7, SYSUTCDATETIME())),
UpdatedDate DATETIME NOT NULL DEFAULT GETDATE(), UpdatedDate DATETIME NOT NULL DEFAULT (DATEADD(HOUR, 7, SYSUTCDATETIME())),
FOREIGN KEY (AssetId) REFERENCES AssetInventory(AssetId) ON DELETE CASCADE FOREIGN KEY (AssetId) REFERENCES AssetInventory(AssetId) ON DELETE CASCADE
); );
PRINT 'Table AssetExportHistory created successfully.'; PRINT 'Table AssetExportHistory created successfully.';
@@ -451,17 +451,17 @@ END
IF COL_LENGTH('dbo.AssetExportHistory', 'ExportedDate') IS NULL IF COL_LENGTH('dbo.AssetExportHistory', 'ExportedDate') IS NULL
BEGIN BEGIN
ALTER TABLE AssetExportHistory ADD ExportedDate DATETIME NOT NULL CONSTRAINT DF_AssetExportHistory_ExportedDate DEFAULT(GETDATE()); ALTER TABLE AssetExportHistory ADD ExportedDate DATETIME NOT NULL CONSTRAINT DF_AssetExportHistory_ExportedDate DEFAULT(DATEADD(HOUR, 7, SYSUTCDATETIME()));
END END
IF COL_LENGTH('dbo.AssetExportHistory', 'CreatedDate') IS NULL IF COL_LENGTH('dbo.AssetExportHistory', 'CreatedDate') IS NULL
BEGIN BEGIN
ALTER TABLE AssetExportHistory ADD CreatedDate DATETIME NOT NULL CONSTRAINT DF_AssetExportHistory_CreatedDate DEFAULT(GETDATE()); ALTER TABLE AssetExportHistory ADD CreatedDate DATETIME NOT NULL CONSTRAINT DF_AssetExportHistory_CreatedDate DEFAULT(DATEADD(HOUR, 7, SYSUTCDATETIME()));
END END
IF COL_LENGTH('dbo.AssetExportHistory', 'UpdatedDate') IS NULL IF COL_LENGTH('dbo.AssetExportHistory', 'UpdatedDate') IS NULL
BEGIN BEGIN
ALTER TABLE AssetExportHistory ADD UpdatedDate DATETIME NOT NULL CONSTRAINT DF_AssetExportHistory_UpdatedDate DEFAULT(GETDATE()); ALTER TABLE AssetExportHistory ADD UpdatedDate DATETIME NOT NULL CONSTRAINT DF_AssetExportHistory_UpdatedDate DEFAULT(DATEADD(HOUR, 7, SYSUTCDATETIME()));
END END
IF NOT EXISTS (SELECT 1 FROM sys.foreign_keys WHERE name = 'FK_AssetExportHistory_CreatedBy') IF NOT EXISTS (SELECT 1 FROM sys.foreign_keys WHERE name = 'FK_AssetExportHistory_CreatedBy')
@@ -484,7 +484,45 @@ BEGIN
END END
-- =========================================== -- ===========================================
-- 9. CREATE AUDIT LOG TABLE -- 9. CREATE ASSET DAMAGE/DISPOSAL HISTORY TABLE
-- ===========================================
IF NOT EXISTS (SELECT * FROM sys.tables WHERE name = 'AssetDamageDisposalHistory')
BEGIN
CREATE TABLE AssetDamageDisposalHistory (
DamageHistoryId INT PRIMARY KEY IDENTITY(1,1),
AssetId INT NOT NULL,
AssetCode NVARCHAR(100) NOT NULL,
AssetName NVARCHAR(255) NOT NULL,
ActionType NVARCHAR(20) NOT NULL,
ActionLabel NVARCHAR(50) NOT NULL,
ActionQuantity INT NOT NULL DEFAULT 1,
Unit NVARCHAR(50) NULL,
PreviousQuantity INT NOT NULL DEFAULT 0,
NextQuantity INT NOT NULL DEFAULT 0,
PreviousImportInPeriod INT NOT NULL DEFAULT 0,
NextImportInPeriod INT NOT NULL DEFAULT 0,
PreviousExportInPeriod INT NOT NULL DEFAULT 0,
NextExportInPeriod INT NOT NULL DEFAULT 0,
PreviousEndingBalance INT NOT NULL DEFAULT 0,
NextEndingBalance INT NOT NULL DEFAULT 0,
PreviousNewQuantity INT NOT NULL DEFAULT 0,
NextNewQuantity INT NOT NULL DEFAULT 0,
PreviousUsedQuantity INT NOT NULL DEFAULT 0,
NextUsedQuantity INT NOT NULL DEFAULT 0,
ActionNote NVARCHAR(1000) NULL,
CreatedBy INT NULL,
CreatedByName NVARCHAR(100) NULL,
ActionDate DATETIME NOT NULL DEFAULT (DATEADD(HOUR, 7, SYSUTCDATETIME())),
CreatedDate DATETIME NOT NULL DEFAULT (DATEADD(HOUR, 7, SYSUTCDATETIME())),
UpdatedDate DATETIME NOT NULL DEFAULT (DATEADD(HOUR, 7, SYSUTCDATETIME())),
FOREIGN KEY (AssetId) REFERENCES AssetInventory(AssetId) ON DELETE CASCADE,
FOREIGN KEY (CreatedBy) REFERENCES Users(UserId) ON DELETE SET NULL
);
PRINT 'Table AssetDamageDisposalHistory created successfully.';
END
-- ===========================================
-- 10. CREATE AUDIT LOG TABLE
-- =========================================== -- ===========================================
IF NOT EXISTS (SELECT * FROM sys.tables WHERE name = 'AuditLog') IF NOT EXISTS (SELECT * FROM sys.tables WHERE name = 'AuditLog')
BEGIN BEGIN
@@ -496,14 +534,14 @@ BEGIN
RecordId INT, RecordId INT,
OldValue NVARCHAR(MAX), OldValue NVARCHAR(MAX),
NewValue NVARCHAR(MAX), NewValue NVARCHAR(MAX),
Timestamp DATETIME DEFAULT GETDATE(), Timestamp DATETIME DEFAULT (DATEADD(HOUR, 7, SYSUTCDATETIME())),
FOREIGN KEY (UserId) REFERENCES Users(UserId) FOREIGN KEY (UserId) REFERENCES Users(UserId)
); );
PRINT 'Table AuditLog created successfully.'; PRINT 'Table AuditLog created successfully.';
END END
-- =========================================== -- ===========================================
-- 10. CREATE INDEXES -- 11. 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
@@ -590,10 +628,25 @@ BEGIN
CREATE INDEX IX_AssetExportHistory_ExportedDate ON AssetExportHistory(ExportedDate DESC); CREATE INDEX IX_AssetExportHistory_ExportedDate ON AssetExportHistory(ExportedDate DESC);
END END
IF NOT EXISTS (SELECT * FROM sys.indexes WHERE name = 'IX_AssetDamageDisposalHistory_AssetId')
BEGIN
CREATE INDEX IX_AssetDamageDisposalHistory_AssetId ON AssetDamageDisposalHistory(AssetId);
END
IF NOT EXISTS (SELECT * FROM sys.indexes WHERE name = 'IX_AssetDamageDisposalHistory_ActionDate')
BEGIN
CREATE INDEX IX_AssetDamageDisposalHistory_ActionDate ON AssetDamageDisposalHistory(ActionDate DESC);
END
IF NOT EXISTS (SELECT * FROM sys.indexes WHERE name = 'IX_AssetDamageDisposalHistory_ActionType')
BEGIN
CREATE INDEX IX_AssetDamageDisposalHistory_ActionType ON AssetDamageDisposalHistory(ActionType);
END
PRINT 'Indexes created successfully.'; PRINT 'Indexes created successfully.';
-- =========================================== -- ===========================================
-- 11. INSERT INITIAL DATA -- 12. INSERT INITIAL DATA
-- =========================================== -- ===========================================
-- Check if admin user exists -- Check if admin user exists

View File

@@ -8,6 +8,8 @@ services:
environment: environment:
NODE_ENV: ${NODE_ENV:-production} NODE_ENV: ${NODE_ENV:-production}
PORT: 3000 PORT: 3000
TZ: ${TZ:-Asia/Ho_Chi_Minh}
APP_TIME_ZONE: ${APP_TIME_ZONE:-Asia/Ho_Chi_Minh}
DB_SERVER: ${DB_SERVER:-172.20.235.176} DB_SERVER: ${DB_SERVER:-172.20.235.176}
DB_USER: ${DB_USER:-sa} DB_USER: ${DB_USER:-sa}
DB_PASSWORD: ${DB_PASSWORD:-changeme} DB_PASSWORD: ${DB_PASSWORD:-changeme}

View File

@@ -10,6 +10,8 @@ services:
environment: environment:
NODE_ENV: production NODE_ENV: production
PORT: 3000 PORT: 3000
TZ: ${TZ:-Asia/Ho_Chi_Minh}
APP_TIME_ZONE: ${APP_TIME_ZONE:-Asia/Ho_Chi_Minh}
DB_SERVER: ${DB_SERVER:-172.20.235.176} DB_SERVER: ${DB_SERVER:-172.20.235.176}
DB_USER: ${DB_USER:-sa} DB_USER: ${DB_USER:-sa}
DB_PASSWORD: ${DB_PASSWORD:-changeme} DB_PASSWORD: ${DB_PASSWORD:-changeme}

View File

@@ -1,6 +1,34 @@
// VaultSentinel - Account Management Application // VaultSentinel - Account Management Application
// Main JavaScript functionality // Main JavaScript functionality
const APP_TIME_ZONE = 'Asia/Ho_Chi_Minh';
const APP_DATE_FORMATTER = new Intl.DateTimeFormat('vi-VN', {
timeZone: APP_TIME_ZONE,
year: 'numeric',
month: '2-digit',
day: '2-digit'
});
const APP_DATE_TIME_FORMATTER = new Intl.DateTimeFormat('vi-VN', {
timeZone: APP_TIME_ZONE,
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hourCycle: 'h23'
});
const APP_TIME_PARTS_FORMATTER = new Intl.DateTimeFormat('en-CA', {
timeZone: APP_TIME_ZONE,
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit',
hourCycle: 'h23'
});
class AccountManager { class AccountManager {
constructor() { constructor() {
// Check if user is logged in // Check if user is logged in
@@ -53,6 +81,7 @@ class AccountManager {
this.assetProjects = []; this.assetProjects = [];
this.assetProjectSearchTerm = ''; this.assetProjectSearchTerm = '';
this.assetExportHistories = []; this.assetExportHistories = [];
this.assetDamageHistories = [];
this.selectedAssetIds = new Set(); this.selectedAssetIds = new Set();
this.mobileBreakpoint = 900; this.mobileBreakpoint = 900;
this.boundResizeHandler = null; this.boundResizeHandler = null;
@@ -71,6 +100,7 @@ class AccountManager {
this.assetBorrowAutoRefreshTimer = undefined; this.assetBorrowAutoRefreshTimer = undefined;
this.pendingAssetRequestDeleteConfirmResolver = undefined; this.pendingAssetRequestDeleteConfirmResolver = undefined;
this.pendingBulkAssetDeleteConfirmResolver = undefined; this.pendingBulkAssetDeleteConfirmResolver = undefined;
this.pendingAssetDamageId = undefined;
} }
configureNotifications() { configureNotifications() {
@@ -525,15 +555,40 @@ class AccountManager {
return `${code} - ${name}`.replace(/^\s*-\s*|\s*-\s*$/g, '').trim() || name || '-- Chọn tài sản --'; 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) { getAssetBorrowProductById(assetIdValue) {
const assetId = Number(assetIdValue); const assetId = Number(assetIdValue);
if (!Number.isFinite(assetId) || assetId <= 0) { if (!Number.isFinite(assetId) || assetId <= 0) {
return null; 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) || this.assets.find(item => Number(item?.AssetId) === assetId)
|| null; || null;
if (this.isBorrowAssetRequestMode() && !this.isAssetAvailableForBorrow(asset)) {
return null;
}
return asset;
} }
updateAssetBorrowProductDisplay(assetIdValue) { updateAssetBorrowProductDisplay(assetIdValue) {
@@ -654,9 +709,12 @@ class AccountManager {
const encodedKeyword = encodeURIComponent(this.assetBorrowProductQuery); const encodedKeyword = encodeURIComponent(this.assetBorrowProductQuery);
const offset = this.assetBorrowProductOffset; const offset = this.assetBorrowProductOffset;
const limit = this.assetBorrowProductLimit; const limit = this.assetBorrowProductLimit;
const borrowableOnly = this.isBorrowAssetRequestMode();
const appendRows = (rows = [], hasMore = false) => { 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) { if (source.length) {
const merged = new Map( const merged = new Map(
this.assetBorrowProductItems.map(item => [String(item.AssetId), item]) this.assetBorrowProductItems.map(item => [String(item.AssetId), item])
@@ -665,8 +723,8 @@ class AccountManager {
merged.set(String(item.AssetId), item); merged.set(String(item.AssetId), item);
}); });
this.assetBorrowProductItems = Array.from(merged.values()); this.assetBorrowProductItems = Array.from(merged.values());
this.assetBorrowProductOffset += source.length;
} }
this.assetBorrowProductOffset += rowsArray.length;
this.assetBorrowProductHasMore = Boolean(hasMore); this.assetBorrowProductHasMore = Boolean(hasMore);
this.assetBorrowProductLoading = false; this.assetBorrowProductLoading = false;
@@ -686,6 +744,10 @@ class AccountManager {
const source = Array.isArray(this.assets) ? this.assets : []; const source = Array.isArray(this.assets) ? this.assets : [];
const normalized = this.assetBorrowProductQuery.toLowerCase(); const normalized = this.assetBorrowProductQuery.toLowerCase();
const filtered = source.filter(asset => { const filtered = source.filter(asset => {
if (borrowableOnly && !this.isAssetAvailableForBorrow(asset)) {
return false;
}
if (!normalized) { if (!normalized) {
return true; return true;
} }
@@ -705,7 +767,7 @@ class AccountManager {
}; };
try { 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) headers: this.getAuthHeaders(false)
}); });
const data = await response.json(); const data = await response.json();
@@ -1040,6 +1102,114 @@ class AccountManager {
this.renderAssetExportHistoryModal(); this.renderAssetExportHistoryModal();
} }
normalizeAssetDamageType(value) {
const normalized = String(value || '').trim().toLowerCase();
return normalized === 'disposed' || normalized === 'thanh_ly' || normalized === 'thanh ly'
? 'disposed'
: 'damaged';
}
getAssetDamageTypeMeta(value) {
const type = this.normalizeAssetDamageType(value);
if (type === 'disposed') {
return {
value: 'disposed',
label: 'Thanh lý',
className: 'bg-slate-100 text-slate-700 border border-slate-200'
};
}
return {
value: 'damaged',
label: 'Hỏng',
className: 'bg-red-100 text-red-700 border border-red-200'
};
}
async fetchAssetDamageHistories(limit = 300) {
try {
const safeLimit = Number.isFinite(Number(limit)) ? Math.max(1, Math.min(Number(limit), 2000)) : 300;
const res = await fetch(`${this.apiBase}/asset-damage-disposal-history?limit=${safeLimit}`, {
headers: this.getAuthHeaders(false)
});
const data = await res.json();
if (data.success) {
this.assetDamageHistories = Array.isArray(data.data) ? data.data : [];
} else {
console.error('Load asset damage/disposal history failed:', data.message);
}
} catch (err) {
console.error('Fetch asset damage/disposal history error:', err);
}
}
buildAssetDamageHistoryRowsHtml(rows = []) {
if (!Array.isArray(rows) || rows.length === 0) {
return `
<tr>
<td colspan="10" class="px-4 py-8 text-sm text-center text-slate-500">Chưa có dữ liệu tài sản hỏng/thanh lý.</td>
</tr>
`;
}
return rows.map(item => {
const typeMeta = this.getAssetDamageTypeMeta(item?.ActionType);
const assetLabel = [String(item?.AssetCode || '').trim(), String(item?.AssetName || '').trim()]
.filter(Boolean)
.join(' - ') || '-';
const unit = String(item?.Unit || '').trim();
const quantityLabel = `${Number(item?.ActionQuantity) || 0}${unit ? ` ${unit}` : ''}`;
return `
<tr class="hover:bg-slate-50/80 transition-colors">
<td class="px-4 py-3 text-sm text-slate-600 whitespace-nowrap">${this.formatDateTime(item?.ActionDate || item?.CreatedDate)}</td>
<td class="px-4 py-3 text-sm">
<span class="inline-flex items-center px-2 py-1 rounded text-xs font-semibold ${typeMeta.className}">${typeMeta.label}</span>
</td>
<td class="px-4 py-3 text-sm text-slate-700">${this.escapeHtml(assetLabel)}</td>
<td class="px-4 py-3 text-sm text-slate-700 font-semibold">${this.escapeHtml(quantityLabel)}</td>
<td class="px-4 py-3 text-sm text-slate-600">${Number(item?.PreviousQuantity) || 0} -> ${Number(item?.NextQuantity) || 0}</td>
<td class="px-4 py-3 text-sm text-slate-600">${Number(item?.PreviousEndingBalance) || 0} -> ${Number(item?.NextEndingBalance) || 0}</td>
<td class="px-4 py-3 text-sm text-slate-600">${Number(item?.PreviousNewQuantity) || 0} -> ${Number(item?.NextNewQuantity) || 0}</td>
<td class="px-4 py-3 text-sm text-slate-600">${Number(item?.PreviousUsedQuantity) || 0} -> ${Number(item?.NextUsedQuantity) || 0}</td>
<td class="px-4 py-3 text-sm text-slate-600">${this.escapeHtml(item?.CreatedByName || '-')}</td>
<td class="px-4 py-3 text-sm text-slate-600 whitespace-pre-line">${this.escapeHtml(item?.ActionNote || '-')}</td>
</tr>
`;
}).join('');
}
renderAssetDamageHistoryModal() {
const tbody = document.getElementById('assetDamageHistoryTableBody');
if (!tbody) {
return;
}
tbody.innerHTML = this.buildAssetDamageHistoryRowsHtml(this.assetDamageHistories);
}
async openAssetDamageHistoryModal() {
if (!this.ensureAssetManagePermission('xem danh sach tai san hong/thanh ly')) {
return;
}
const modal = document.getElementById('assetDamageHistoryModal');
const tbody = document.getElementById('assetDamageHistoryTableBody');
if (!modal || !tbody) {
this.notifyFailure('Không tìm thấy bảng tài sản hỏng/thanh lý.');
return;
}
tbody.innerHTML = `
<tr>
<td colspan="10" class="px-4 py-8 text-sm text-center text-slate-500">Đang tải dữ liệu tài sản hỏng/thanh lý...</td>
</tr>
`;
modal.classList.add('open');
await this.fetchAssetDamageHistories();
this.renderAssetDamageHistoryModal();
}
async fetchRoles() { async fetchRoles() {
try { try {
const res = await fetch(`${this.apiBase}/roles`); const res = await fetch(`${this.apiBase}/roles`);
@@ -1212,6 +1382,14 @@ class AccountManager {
} }
} }
const assetDamageForm = document.getElementById('assetDamageForm');
if (assetDamageForm) {
if (!assetDamageForm.dataset.boundSubmit) {
assetDamageForm.addEventListener('submit', (e) => this.handleAssetDamageSubmit(e));
assetDamageForm.dataset.boundSubmit = 'true';
}
}
const assetBorrowRequestForm = document.getElementById('assetBorrowRequestForm'); const assetBorrowRequestForm = document.getElementById('assetBorrowRequestForm');
if (assetBorrowRequestForm) { if (assetBorrowRequestForm) {
if (!assetBorrowRequestForm.dataset.boundSubmit) { if (!assetBorrowRequestForm.dataset.boundSubmit) {
@@ -1645,7 +1823,7 @@ class AccountManager {
<div class="bg-surface-container-lowest p-4 rounded-xl border border-outline-variant/15 flex flex-col"> <div class="bg-surface-container-lowest p-4 rounded-xl border border-outline-variant/15 flex flex-col">
<span class="text-[10px] font-bold text-on-surface-variant uppercase tracking-wider mb-2">Last Updated</span> <span class="text-[10px] font-bold text-on-surface-variant uppercase tracking-wider mb-2">Last Updated</span>
<div class="flex items-baseline justify-between"> <div class="flex items-baseline justify-between">
<span class="text-sm font-black text-on-surface">${new Date().toLocaleDateString()}</span> <span class="text-sm font-black text-on-surface">${APP_DATE_FORMATTER.format(new Date())}</span>
</div> </div>
</div> </div>
<div class="bg-primary-container/10 p-4 rounded-xl border border-primary/20 flex flex-col"> <div class="bg-primary-container/10 p-4 rounded-xl border border-primary/20 flex flex-col">
@@ -1829,7 +2007,7 @@ class AccountManager {
<input id="appSearch" class="flex-1 bg-white border border-slate-200 rounded-md text-[11px] py-1 px-2 focus:ring-1 focus:ring-primary shadow-sm" placeholder="Search by name, type, description, url"> <input id="appSearch" class="flex-1 bg-white border border-slate-200 rounded-md text-[11px] py-1 px-2 focus:ring-1 focus:ring-primary shadow-sm" placeholder="Search by name, type, description, url">
</div> </div>
<div class="table-wrap overflow-y-auto overflow-x-auto flex-1"> <div class="table-wrap overflow-y-auto overflow-x-auto flex-1">
<table class="w-full text-left border-collapse"> <table class="w-full text-left border-collapse" style="min-width: 1500px;">
<thead class="sticky top-0 bg-surface-container-lowest z-10"> <thead class="sticky top-0 bg-surface-container-lowest z-10">
<tr class="bg-surface-container-low/30 border-b border-outline-variant/10"> <tr class="bg-surface-container-low/30 border-b border-outline-variant/10">
<th class="px-6 py-2.5 text-[10px] font-bold text-on-surface-variant uppercase tracking-widest">Name</th> <th class="px-6 py-2.5 text-[10px] font-bold text-on-surface-variant uppercase tracking-widest">Name</th>
@@ -1905,21 +2083,49 @@ class AccountManager {
return { label: 'Đang sử dụng', className: 'bg-blue-100 text-blue-700' }; return { label: 'Đang sử dụng', className: 'bg-blue-100 text-blue-700' };
} }
getAppTimeParts(value = new Date()) {
const date = value instanceof Date ? value : new Date(value);
if (Number.isNaN(date.getTime())) return null;
return APP_TIME_PARTS_FORMATTER.formatToParts(date).reduce((parts, part) => {
if (part.type !== 'literal') {
parts[part.type] = part.value;
}
return parts;
}, {});
}
formatTimestampForCode(value = new Date(), includeMilliseconds = false) {
const date = value instanceof Date ? value : new Date(value);
const parts = this.getAppTimeParts(date);
if (!parts) return '';
const timestamp = [
parts.year,
parts.month,
parts.day,
parts.hour,
parts.minute,
parts.second
].join('');
return includeMilliseconds
? `${timestamp}${String(date.getMilliseconds()).padStart(3, '0')}`
: timestamp;
}
formatDateOnly(value) { formatDateOnly(value) {
if (!value) return '-'; if (!value) return '-';
const date = new Date(value); const date = new Date(value);
if (Number.isNaN(date.getTime())) return String(value); if (Number.isNaN(date.getTime())) return String(value);
return date.toLocaleDateString(); return APP_DATE_FORMATTER.format(date);
} }
toDateInputValue(value) { toDateInputValue(value) {
if (!value) return ''; if (!value) return '';
const date = new Date(value); const parts = this.getAppTimeParts(value);
if (Number.isNaN(date.getTime())) return ''; if (!parts) return '';
const year = date.getFullYear(); return `${parts.year}-${parts.month}-${parts.day}`;
const month = String(date.getMonth() + 1).padStart(2, '0');
const day = String(date.getDate()).padStart(2, '0');
return `${year}-${month}-${day}`;
} }
formatBorrowerDisplay(name, quantity = 1) { formatBorrowerDisplay(name, quantity = 1) {
@@ -3056,8 +3262,8 @@ class AccountManager {
<td class="px-4 py-3 text-sm text-slate-600 whitespace-nowrap"> <td class="px-4 py-3 text-sm text-slate-600 whitespace-nowrap">
<span class="inline-flex items-center px-2 py-1 rounded-md text-xs font-bold whitespace-nowrap ${typeMeta.className}">${typeMeta.label}</span> <span class="inline-flex items-center px-2 py-1 rounded-md text-xs font-bold whitespace-nowrap ${typeMeta.className}">${typeMeta.label}</span>
</td> </td>
<td class="px-4 py-3 text-sm text-slate-600"> <td class="px-4 py-3 text-sm text-slate-600 whitespace-nowrap">
<span class="inline-flex items-center px-2 py-1 rounded-md text-xs font-bold ${statusMeta.className}">${statusMeta.label}</span> <span class="inline-flex items-center px-2 py-1 rounded-md text-xs font-bold whitespace-nowrap ${statusMeta.className}">${statusMeta.label}</span>
${returnProgressHtml} ${returnProgressHtml}
</td> </td>
<td class="px-4 py-3 text-sm text-slate-600">${this.escapeHtml(item.Unit || '-')}</td> <td class="px-4 py-3 text-sm text-slate-600">${this.escapeHtml(item.Unit || '-')}</td>
@@ -3184,7 +3390,7 @@ class AccountManager {
<th class="px-4 py-2.5 text-[10px] font-bold uppercase tracking-wider text-slate-500">Đơn vị</th> <th class="px-4 py-2.5 text-[10px] font-bold uppercase tracking-wider text-slate-500">Đơn vị</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">Dự án</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">Trạng thái</th> <th class="px-4 py-2.5 text-[10px] font-bold uppercase tracking-wider text-slate-500 whitespace-nowrap">Trạng thái</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>
</tr> </tr>
</thead> </thead>
@@ -3372,7 +3578,7 @@ class AccountManager {
<th class="px-4 py-2.5 text-[10px] font-bold uppercase tracking-wider text-slate-500">Tên đầy đủ</th> <th class="px-4 py-2.5 text-[10px] font-bold uppercase tracking-wider text-slate-500">Tên đầy đủ</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">Tài sản</th>
<th class="px-4 py-2.5 text-[10px] font-bold uppercase tracking-wider text-slate-500 whitespace-nowrap">Danh mục</th> <th class="px-4 py-2.5 text-[10px] font-bold uppercase tracking-wider text-slate-500 whitespace-nowrap">Danh mục</th>
<th class="px-4 py-2.5 text-[10px] font-bold uppercase tracking-wider text-slate-500">Trạng thái</th> <th class="px-4 py-2.5 text-[10px] font-bold uppercase tracking-wider text-slate-500 whitespace-nowrap">Trạng thái</th>
<th class="px-4 py-2.5 text-[10px] font-bold uppercase tracking-wider text-slate-500">Đơn vị</th> <th class="px-4 py-2.5 text-[10px] font-bold uppercase tracking-wider text-slate-500">Đơn vị</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">Số lượng</th>
<th class="px-4 py-2.5 text-[10px] font-bold uppercase tracking-wider text-slate-500">Ngày</th> <th class="px-4 py-2.5 text-[10px] font-bold uppercase tracking-wider text-slate-500">Ngày</th>
@@ -3773,7 +3979,7 @@ class AccountManager {
await this.searchAssetBorrowProducts('', '', { reset: true }); await this.searchAssetBorrowProducts('', '', { reset: true });
if (!this.assetBorrowProductItems.length) { 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; return;
} }
@@ -3805,6 +4011,19 @@ class AccountManager {
return; 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 borrowDate = String(dateInput?.value || '').trim() || this.toDateInputValue(new Date());
const unit = String(unitInput?.value || '').trim(); const unit = String(unitInput?.value || '').trim();
const borrowerName = String(requesterInput?.value || this.getCurrentUserDisplayName() || '').trim(); const borrowerName = String(requesterInput?.value || this.getCurrentUserDisplayName() || '').trim();
@@ -4233,14 +4452,22 @@ class AccountManager {
<span class="material-symbols-outlined text-base">history</span> <span class="material-symbols-outlined text-base">history</span>
Lịch sử xuất Lịch sử xuất
</button> </button>
<button id="openAssetDamageHistoryBtn" class="border border-red-200 text-red-700 px-3 py-2 rounded-lg text-xs font-bold flex items-center gap-1.5 transition-all active:scale-95 ${canManageAssets ? 'hover:bg-red-50' : 'opacity-50 cursor-not-allowed'}" ${canManageAssets ? '' : 'disabled'}>
<span class="material-symbols-outlined text-base">inventory_2</span>
DS hỏng/TL
</button>
<button id="addAssetBtn" class="bg-primary text-on-primary px-4 py-2 rounded-lg text-xs font-bold flex items-center gap-1.5 transition-all active:scale-95 ${canManageAssets ? 'hover:bg-primary-dim' : 'opacity-50 cursor-not-allowed'}" ${canManageAssets ? '' : 'disabled'}> <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 && selectedCount === 1) ? 'hover:bg-primary/5' : 'opacity-50 cursor-not-allowed'}" ${(canManageAssets && selectedCount === 1) ? '' : 'disabled'}>
<span class="material-symbols-outlined text-base">handshake</span> <span class="material-symbols-outlined text-base">handshake</span>
Xuất tài sản Xuất tài sản
</button> </button>
<button id="damageAssetBtn" class="border border-red-300 text-red-700 px-4 py-2 rounded-lg text-xs font-bold flex items-center gap-1.5 transition-all active:scale-95 ${(canManageAssets && selectedCount === 1) ? 'hover:bg-red-50' : 'opacity-50 cursor-not-allowed'}" ${(canManageAssets && selectedCount === 1) ? '' : 'disabled'}>
<span class="material-symbols-outlined text-base">broken_image</span>
Hỏng/Thanh lý
</button>
<input id="assetImportInput" type="file" accept=".xlsx,.xls" class="hidden" /> <input id="assetImportInput" type="file" accept=".xlsx,.xls" class="hidden" />
</div> </div>
</div> </div>
@@ -4564,6 +4791,16 @@ class AccountManager {
borrowAssetBtn.disabled = disabled; borrowAssetBtn.disabled = disabled;
borrowAssetBtn.classList.toggle('opacity-50', disabled); borrowAssetBtn.classList.toggle('opacity-50', disabled);
borrowAssetBtn.classList.toggle('cursor-not-allowed', disabled); borrowAssetBtn.classList.toggle('cursor-not-allowed', disabled);
borrowAssetBtn.classList.toggle('hover:bg-primary/5', !disabled);
}
const damageAssetBtn = document.getElementById('damageAssetBtn');
if (damageAssetBtn) {
const disabled = !canManageAssets || selectedCount !== 1;
damageAssetBtn.disabled = disabled;
damageAssetBtn.classList.toggle('opacity-50', disabled);
damageAssetBtn.classList.toggle('cursor-not-allowed', disabled);
damageAssetBtn.classList.toggle('hover:bg-red-50', !disabled);
} }
} }
@@ -4838,16 +5075,7 @@ class AccountManager {
.slice(0, 32); .slice(0, 32);
const base = toToken(payload.model) || toToken(payload.serialNumber) || toToken(payload.assetName) || 'ASSET'; const base = toToken(payload.model) || toToken(payload.serialNumber) || toToken(payload.assetName) || 'ASSET';
const now = new Date(); const timestamp = this.formatTimestampForCode(new Date(), true);
const timestamp = [
String(now.getFullYear()),
String(now.getMonth() + 1).padStart(2, '0'),
String(now.getDate()).padStart(2, '0'),
String(now.getHours()).padStart(2, '0'),
String(now.getMinutes()).padStart(2, '0'),
String(now.getSeconds()).padStart(2, '0'),
String(now.getMilliseconds()).padStart(3, '0')
].join('');
const randomSuffix = String(Math.floor(Math.random() * 100)).padStart(2, '0'); const randomSuffix = String(Math.floor(Math.random() * 100)).padStart(2, '0');
return `AST-${base}-${timestamp}${randomSuffix}`; return `AST-${base}-${timestamp}${randomSuffix}`;
} }
@@ -5006,6 +5234,161 @@ class AccountManager {
return asset; return asset;
} }
getSingleSelectedAssetForDamage(showWarning = true) {
const selectedIds = [...this.selectedAssetIds];
if (!selectedIds.length) {
if (showWarning) {
this.notifyWarning('Vui lòng chọn 1 tài sản để ghi nhận hỏng/thanh lý.');
}
return null;
}
if (selectedIds.length > 1) {
if (showWarning) {
this.notifyWarning('Chỉ chọn đúng 1 tài sản cho mỗi lần ghi nhận hỏng/thanh lý.');
}
return null;
}
const assetId = Number(selectedIds[0]);
const asset = this.assets.find(item => Number(item?.AssetId) === assetId) || null;
if (!asset && showWarning) {
this.notifyFailure('Không tìm thấy tài sản đã chọn.');
}
return asset;
}
openAssetDamageModal() {
if (!this.ensureAssetManagePermission('ghi nhan tai san hong/thanh ly')) {
return;
}
const asset = this.getSingleSelectedAssetForDamage(true);
if (!asset) {
return;
}
const metrics = this.buildAssetQuantityMetrics(asset);
if (metrics.endingBalance <= 0) {
this.notifyWarning('Tài sản đã hết tồn cuối kỳ, không thể ghi nhận hỏng/thanh lý thêm.');
return;
}
this.pendingAssetDamageId = Number(asset.AssetId);
const modal = document.getElementById('assetDamageModal');
const assetIdInput = document.getElementById('assetDamageAssetIdInput');
const assetNameInput = document.getElementById('assetDamageAssetNameInput');
const typeInput = document.getElementById('assetDamageTypeInput');
const quantityInput = document.getElementById('assetDamageQuantityInput');
const currentQuantityInput = document.getElementById('assetDamageCurrentQuantityInput');
const currentEndingInput = document.getElementById('assetDamageCurrentEndingInput');
const currentNewInput = document.getElementById('assetDamageCurrentNewInput');
const currentUsedInput = document.getElementById('assetDamageCurrentUsedInput');
const actorInput = document.getElementById('assetDamageActorInput');
const noteInput = document.getElementById('assetDamageNoteInput');
if (!modal || !assetNameInput || !quantityInput || !currentQuantityInput || !currentEndingInput) {
this.notifyFailure('Không tìm thấy biểu mẫu hỏng/thanh lý tài sản.');
return;
}
const stockSplit = this.normalizeAssetStockSplit(
metrics.endingBalance,
asset?.NewQuantity ?? metrics.endingBalance,
asset?.UsedQuantity ?? 0
);
if (assetIdInput) {
assetIdInput.value = String(asset.AssetId || '');
}
assetNameInput.value = `${asset.AssetCode || ''} - ${asset.AssetName || ''}`.trim();
currentQuantityInput.value = String(metrics.quantity);
currentEndingInput.value = String(metrics.endingBalance);
if (currentNewInput) currentNewInput.value = String(stockSplit.newQuantity);
if (currentUsedInput) currentUsedInput.value = String(stockSplit.usedQuantity);
if (typeInput) typeInput.value = 'damaged';
quantityInput.value = '1';
quantityInput.min = '1';
quantityInput.max = String(metrics.endingBalance);
if (actorInput) actorInput.value = this.getCurrentUserDisplayName();
if (noteInput) noteInput.value = '';
modal.classList.add('open');
}
async handleAssetDamageSubmit(e) {
e.preventDefault();
if (!this.ensureAssetManagePermission('ghi nhan tai san hong/thanh ly')) {
return;
}
const assetIdInput = document.getElementById('assetDamageAssetIdInput');
const typeInput = document.getElementById('assetDamageTypeInput');
const quantityInput = document.getElementById('assetDamageQuantityInput');
const noteInput = document.getElementById('assetDamageNoteInput');
const selectedAssetId = Number(assetIdInput?.value || this.pendingAssetDamageId);
if (!Number.isFinite(selectedAssetId) || selectedAssetId <= 0) {
this.notifyFailure('Không xác định được tài sản cần ghi nhận.');
return;
}
const asset = this.assets.find(item => Number(item?.AssetId) === selectedAssetId);
if (!asset) {
this.notifyFailure('Không tìm thấy tài sản cần ghi nhận.');
return;
}
const actionType = this.normalizeAssetDamageType(typeInput?.value || 'damaged');
const actionMeta = this.getAssetDamageTypeMeta(actionType);
const actionQuantity = this.parseNonNegativeInteger(quantityInput?.value ?? 0, 0);
const currentMetrics = this.buildAssetQuantityMetrics(asset);
if (actionQuantity <= 0) {
this.notifyWarning('Số lượng phải lớn hơn 0.');
return;
}
if (actionQuantity > currentMetrics.endingBalance) {
this.notifyWarning(`Số lượng ${actionMeta.label.toLowerCase()} (${actionQuantity}) vượt quá tồn cuối kỳ (${currentMetrics.endingBalance}).`);
return;
}
try {
const response = await fetch(`${this.apiBase}/assets/${selectedAssetId}/damage-disposal`, {
method: 'POST',
headers: this.getAuthHeaders(true),
body: JSON.stringify({
actionType,
quantity: actionQuantity,
note: String(noteInput?.value || '').trim()
})
});
const data = await response.json();
if (!response.ok || !data.success) {
this.notifyFailure(data.message || 'Ghi nhận hỏng/thanh lý thất bại');
return;
}
this.pendingAssetDamageId = undefined;
document.getElementById('assetDamageModal')?.classList.remove('open');
this.notifySuccess(data.message || `Đã ghi nhận tài sản ${actionMeta.label.toLowerCase()}`);
await this.refreshAssetsUI();
const historyModal = document.getElementById('assetDamageHistoryModal');
if (historyModal?.classList.contains('open')) {
await this.fetchAssetDamageHistories();
this.renderAssetDamageHistoryModal();
}
} catch (err) {
console.error(err);
this.notifyFailure('Ghi nhận hỏng/thanh lý thất bại');
}
}
async openBorrowAssetModal() { async openBorrowAssetModal() {
if (!this.ensureAssetManagePermission('xuat tai san')) { if (!this.ensureAssetManagePermission('xuat tai san')) {
return; return;
@@ -6145,8 +6528,7 @@ class AccountManager {
const workbook = window.XLSX.utils.book_new(); const workbook = window.XLSX.utils.book_new();
window.XLSX.utils.book_append_sheet(workbook, worksheet, 'TaiSan'); window.XLSX.utils.book_append_sheet(workbook, worksheet, 'TaiSan');
const now = new Date(); const timestamp = this.formatTimestampForCode(new Date()).slice(0, 8);
const timestamp = `${now.getFullYear()}${String(now.getMonth() + 1).padStart(2, '0')}${String(now.getDate()).padStart(2, '0')}`;
window.XLSX.writeFile(workbook, `danh-sach-tai-san-${timestamp}.xlsx`); window.XLSX.writeFile(workbook, `danh-sach-tai-san-${timestamp}.xlsx`);
this.notifySuccess('Xuất Excel thành công'); this.notifySuccess('Xuất Excel thành công');
} }
@@ -6571,10 +6953,17 @@ class AccountManager {
borrowAssetBtn.dataset.boundClick = 'true'; borrowAssetBtn.dataset.boundClick = 'true';
} }
const damageAssetBtn = document.getElementById('damageAssetBtn');
if (damageAssetBtn && !damageAssetBtn.dataset.boundClick) {
damageAssetBtn.addEventListener('click', () => this.openAssetDamageModal());
damageAssetBtn.dataset.boundClick = 'true';
}
const importAssetBtn = document.getElementById('importAssetBtn'); const 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'); const openAssetExportHistoryBtn = document.getElementById('openAssetExportHistoryBtn');
const openAssetDamageHistoryBtn = document.getElementById('openAssetDamageHistoryBtn');
if (importAssetBtn && assetImportInput && !importAssetBtn.dataset.boundClick) { if (importAssetBtn && assetImportInput && !importAssetBtn.dataset.boundClick) {
importAssetBtn.addEventListener('click', () => { importAssetBtn.addEventListener('click', () => {
@@ -6600,6 +6989,11 @@ class AccountManager {
openAssetExportHistoryBtn.addEventListener('click', () => this.openAssetExportHistoryModal()); openAssetExportHistoryBtn.addEventListener('click', () => this.openAssetExportHistoryModal());
openAssetExportHistoryBtn.dataset.boundClick = 'true'; openAssetExportHistoryBtn.dataset.boundClick = 'true';
} }
if (openAssetDamageHistoryBtn && !openAssetDamageHistoryBtn.dataset.boundClick) {
openAssetDamageHistoryBtn.addEventListener('click', () => this.openAssetDamageHistoryModal());
openAssetDamageHistoryBtn.dataset.boundClick = 'true';
}
} }
setupFilters() { setupFilters() {
@@ -7070,7 +7464,7 @@ class AccountManager {
if (Number.isNaN(date.getTime())) { if (Number.isNaN(date.getTime())) {
return String(value); return String(value);
} }
return date.toLocaleString(); return APP_DATE_TIME_FORMATTER.format(date);
} }
// ========== Users Management ========== // ========== Users Management ==========
@@ -7811,6 +8205,13 @@ function closeBorrowAssetModal() {
} }
} }
function closeAssetDamageModal() {
const modal = document.getElementById('assetDamageModal');
if (modal) {
modal.classList.remove('open');
}
}
function closeAssetExportHistoryModal() { function closeAssetExportHistoryModal() {
const modal = document.getElementById('assetExportHistoryModal'); const modal = document.getElementById('assetExportHistoryModal');
if (modal) { if (modal) {
@@ -7818,6 +8219,13 @@ function closeAssetExportHistoryModal() {
} }
} }
function closeAssetDamageHistoryModal() {
const modal = document.getElementById('assetDamageHistoryModal');
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

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

View File

@@ -309,6 +309,6 @@
</div> </div>
</main> </main>
<script src="../js/app.js?v=20260515-4"></script> <script src="../js/app.js?v=20260515-5"></script>
</body> </body>
</html> </html>