diff --git a/UI_DESIGN.md b/UI_DESIGN.md
new file mode 100644
index 0000000..8267787
--- /dev/null
+++ b/UI_DESIGN.md
@@ -0,0 +1,765 @@
+# Portable UI Style Guide - AccManager Inspired
+
+Tài liệu này dùng để mang phong cách UI hiện tại của AccManager sang một dự án khác. Nội dung tập trung vào ngôn ngữ thiết kế, token, layout, component pattern và checklist triển khai, không phụ thuộc chặt vào nghiệp vụ tài khoản/tài sản của AccManager.
+
+## 1. Design DNA
+
+Phong cách UI gốc là một **admin console nội bộ**: gọn, sáng, nhiều dữ liệu, dễ quét bảng, thao tác nhanh và ít trang trí thừa.
+
+Tinh thần chính:
+
+- Ưu tiên hiệu quả vận hành hơn cảm giác marketing.
+- Nền sáng, bề mặt trắng/xám nhạt, màu nhấn xanh primary.
+- Layout chắc, dày thông tin nhưng vẫn thoáng.
+- Component nhỏ gọn: table, filter, modal, form, badge, icon action.
+- Button và icon rõ chức năng, phản hồi hover/active nhẹ.
+- Typography dùng heading đậm, label nhỏ uppercase, nội dung bảng vừa đủ đọc.
+- Phù hợp cho dashboard, CRM, ERP mini, tool nội bộ, quản lý dữ liệu, asset/account/user management.
+
+Không nên biến phong cách này thành landing page, hero lớn, nhiều gradient trang trí, card marketing hoặc layout quá thoáng kiểu portfolio.
+
+## 2. Stack Khuyến Nghị
+
+Để tái tạo đúng style, dự án mới nên dùng:
+
+- Tailwind CSS.
+- Font heading: `Manrope`.
+- Font body: `Inter`.
+- Icon: Google Material Symbols Outlined.
+- Toast/notification: Notiflix hoặc thư viện tương đương.
+- Table data với sticky header, horizontal scroll trên mobile.
+
+Google Fonts:
+
+```html
+
+
+```
+
+Material Symbols base CSS:
+
+```css
+.material-symbols-outlined {
+ font-family: 'Material Symbols Outlined';
+ font-variation-settings: 'FILL' 0, 'wght' 400, 'GRAD' 0, 'opsz' 20;
+ font-size: 1.25rem;
+ line-height: 1;
+ letter-spacing: normal;
+ text-transform: none;
+ display: inline-flex;
+ white-space: nowrap;
+ direction: ltr;
+}
+```
+
+## 3. Tailwind Design Tokens
+
+Có thể copy phần `theme.extend` này sang `tailwind.config.js` của dự án mới.
+
+```js
+theme: {
+ extend: {
+ colors: {
+ "primary": "#3755c3",
+ "primary-dim": "#2848b7",
+ "primary-container": "#dde1ff",
+ "on-primary": "#f8f7ff",
+ "background": "#f7f9fb",
+ "surface": "#f7f9fb",
+ "surface-container-lowest": "#ffffff",
+ "surface-container-low": "#f0f4f7",
+ "surface-container": "#e8eff3",
+ "surface-container-high": "#e1e9ee",
+ "surface-container-highest": "#d9e4ea",
+ "on-surface": "#2a3439",
+ "on-surface-variant": "#566166",
+ "outline": "#717c82",
+ "outline-variant": "#a9b4b9",
+ "error": "#9f403d",
+ "error-container": "#fe8983"
+ },
+ fontFamily: {
+ headline: ["Manrope", "sans-serif"],
+ body: ["Inter", "sans-serif"],
+ label: ["Inter", "sans-serif"]
+ },
+ borderRadius: {
+ DEFAULT: "0.125rem",
+ lg: "0.25rem",
+ xl: "0.5rem",
+ full: "0.75rem"
+ }
+ }
+}
+```
+
+Base body:
+
+```html
+
+```
+
+Font rule:
+
+```css
+body { font-family: 'Inter', sans-serif; }
+h1, h2, h3, .brand-logo { font-family: 'Manrope', sans-serif; }
+```
+
+## 4. Color Usage
+
+Màu nền:
+
+- App background: `bg-background`.
+- Main card/table: `bg-white` hoặc `bg-surface-container-lowest`.
+- Filter bar/input subtle: `bg-surface-container-low`.
+- Header/table head: `bg-slate-50`.
+
+Màu chữ:
+
+- Text chính: `text-on-surface`, `text-slate-900`.
+- Text phụ: `text-on-surface-variant`, `text-slate-500`, `text-slate-600`.
+- Link/action chính: `text-primary`.
+
+Màu hành động:
+
+- Primary: `bg-primary hover:bg-primary-dim text-on-primary`.
+- Secondary: border slate, text slate.
+- Danger: `bg-red-600 hover:bg-red-700 text-white` hoặc `text-error`.
+- Success: emerald/green.
+- Pending/warning: amber/yellow.
+- Info: blue.
+
+Tránh:
+
+- Dùng quá nhiều gradient.
+- Dùng một palette đơn sắc toàn xanh.
+- Nền tối toàn trang nếu chưa thiết kế dark mode đầy đủ.
+- Màu quá rực trên bảng dữ liệu.
+
+## 5. Typography Scale
+
+Page title:
+
+```html
+
+```
+
+Subtitle:
+
+```html
+Short page description.
+```
+
+Table header / form label:
+
+```html
+
+```
+
+Responsive:
+
+- Desktop: 4 cột.
+- Tablet/mobile: 2 cột.
+- Mobile nhỏ: 1 cột.
+
+## 9. Table Pattern
+
+Đây là component quan trọng nhất của style này.
+
+```html
+
+
+
+
+
+ Name
+ Actions
+
+
+
+
+ Example
+ ...
+
+
+
+
+
+
+
+```
+
+Quy ước table:
+
+- Header sticky.
+- Row hover nhẹ.
+- Cell padding đều.
+- Text trong bảng ưu tiên `text-sm`.
+- Header dùng `text-[10px] uppercase`.
+- Nếu nhiều cột, đặt `min-width` và cho horizontal scroll.
+- Nếu có action column quan trọng, có thể sticky bên phải.
+
+Sticky action column:
+
+```html
+
+```
+
+## 10. Filter/Search Pattern
+
+Filter bar:
+
+```html
+
+
+ Status
+
+ All
+
+
+
+
+ Search
+
+
+
+```
+
+Nguyên tắc:
+
+- Filter ngắn, đặt sát bảng.
+- Search chiếm phần còn lại.
+- Label filter nhỏ và uppercase.
+- Trên mobile, filter chuyển thành column/full width.
+
+## 11. Button Pattern
+
+Primary:
+
+```html
+
+ add
+ Add New
+
+```
+
+Secondary:
+
+```html
+
+ download
+ Export
+
+```
+
+Danger:
+
+```html
+
+ Delete
+
+```
+
+Icon action:
+
+```html
+
+ edit
+
+```
+
+Disabled:
+
+```html
+Disabled
+```
+
+## 12. Form Pattern
+
+Form field:
+
+```html
+
+ Name
+
+
+```
+
+Readonly:
+
+```html
+
+```
+
+Textarea:
+
+```html
+
+```
+
+Field with icon:
+
+```html
+
+
+ person
+
+
+
+```
+
+## 13. Badge/Status Pattern
+
+Online/offline:
+
+```html
+
+```
+
+Status badges:
+
+```html
+Trong kho
+Đang sử dụng
+Đang chờ
+Từ chối
+```
+
+Role chip:
+
+```html
+Admin
+```
+
+## 14. Modal Pattern
+
+Backdrop + content:
+
+```html
+
+```
+
+Modal CSS:
+
+```css
+.modal-backdrop {
+ opacity: 0;
+ transition: opacity 0.2s ease-in-out;
+ pointer-events: none;
+}
+
+.modal-backdrop.open {
+ opacity: 1;
+ pointer-events: auto;
+}
+
+.modal-content {
+ transform: scale(0.95);
+ transition: transform 0.2s ease-in-out;
+}
+
+.modal-backdrop.open .modal-content {
+ transform: scale(1);
+}
+```
+
+Danger confirm modal:
+
+```html
+
+ warning
+
+
+```
+
+## 15. Login/Auth Screen Pattern
+
+Auth screen dùng centered card:
+
+```html
+
+
+
+
+
+
+
Product Name
+
Admin Console
+
+
+
+
+
+
+
+```
+
+Auth tabs:
+
+```html
+
+ Đăng nhập
+ Đăng ký
+
+```
+
+Alert states:
+
+```html
+Error message
+Success message
+Warning message
+```
+
+## 16. Responsive Rules
+
+Recommended CSS:
+
+```css
+#mobileMenuBtn,
+#sidebarBackdrop {
+ display: none;
+}
+
+@media (max-width: 900px) {
+ body.app-shell {
+ width: 100%;
+ min-height: 100dvh;
+ height: 100dvh;
+ overflow: hidden;
+ position: relative;
+ }
+
+ #mobileMenuBtn {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ }
+
+ #sidebarBackdrop {
+ display: block;
+ position: fixed;
+ inset: 0;
+ z-index: 70;
+ background: rgba(15, 23, 42, 0.45);
+ opacity: 0;
+ pointer-events: none;
+ transition: opacity 0.2s ease-in-out;
+ }
+
+ #appSidebar {
+ position: fixed;
+ inset: 0 auto 0 0;
+ height: 100dvh;
+ width: min(82vw, 16rem);
+ z-index: 80;
+ transform: translateX(-100%);
+ transition: transform 0.2s ease-in-out;
+ box-shadow: 0 20px 45px rgba(15, 23, 42, 0.35);
+ }
+
+ body.mobile-nav-open #sidebarBackdrop {
+ opacity: 1;
+ pointer-events: auto;
+ }
+
+ body.mobile-nav-open #appSidebar {
+ transform: translateX(0);
+ }
+
+ .page-header,
+ .page-filters,
+ .users-controls {
+ flex-direction: column;
+ align-items: stretch;
+ gap: 0.75rem;
+ }
+
+ .table-wrap {
+ overflow-x: auto;
+ }
+
+ .table-wrap table {
+ min-width: 700px;
+ }
+
+ .page-pager {
+ gap: 0.5rem;
+ flex-direction: column;
+ align-items: flex-start;
+ }
+
+ .modal-backdrop {
+ align-items: flex-end;
+ }
+
+ .modal-backdrop .modal-content {
+ width: calc(100% - 1rem);
+ max-height: min(88dvh, 700px);
+ margin: 0.5rem;
+ overflow-y: auto;
+ border-radius: 0.9rem;
+ }
+}
+
+@media (max-width: 560px) {
+ .dashboard-stats {
+ grid-template-columns: 1fr;
+ }
+}
+```
+
+Mobile behavior:
+
+- Sidebar thành off-canvas.
+- Topbar gọn lại.
+- Profile metadata nên ẩn.
+- Header/filter chuyển column.
+- Table luôn scroll ngang nếu nhiều cột.
+- Modal gần bottom và giới hạn chiều cao.
+
+## 17. UX Rules
+
+Giữ những nguyên tắc này khi áp dụng sang dự án khác:
+
+1. Mỗi màn dữ liệu cần có search hoặc filter nếu danh sách có thể dài.
+2. Action chính luôn rõ ràng, nằm ở page header.
+3. Action nguy hiểm luôn cần confirm.
+4. Action theo quyền nên disabled trực quan, không chỉ báo lỗi sau khi bấm.
+5. Table phải có empty state.
+6. Table dài cần pagination.
+7. Form dài nên chia grid 2 cột trên desktop, 1 cột mobile.
+8. Modal dài phải scroll trong content, không làm tràn viewport.
+9. Badge trạng thái phải thống nhất màu theo ý nghĩa.
+10. Icon-only button phải có `title` hoặc accessible name.
+11. Không dùng nhiều card trang trí trong dashboard vận hành.
+12. Text không được overlap hoặc tràn button/card.
+
+## 18. Accessibility Checklist
+
+Khi port sang dự án mới, nên bổ sung:
+
+- `aria-label` cho icon-only buttons.
+- `role="dialog"` và `aria-modal="true"` cho modal.
+- Focus trap trong modal.
+- Đóng modal bằng phím `Escape`.
+- `aria-expanded` cho mobile menu.
+- `aria-selected` cho tabs.
+- Label đầy đủ cho form input.
+- Không chỉ dùng màu để truyền đạt trạng thái; nên có text status.
+- Contrast đủ tốt cho text nhỏ.
+
+## 19. Implementation Checklist
+
+Khi bắt đầu dự án mới:
+
+1. Cài Tailwind và plugin forms nếu cần.
+2. Copy design tokens vào `tailwind.config.js`.
+3. Thêm Google Fonts và Material Symbols.
+4. Tạo app shell: sidebar, topbar, content area.
+5. Tạo component base cho button, table, filter, modal, badge.
+6. Tạo responsive sidebar theo breakpoint `900px`.
+7. Áp dụng page layout pattern cho từng màn hình.
+8. Kiểm tra table trên mobile với horizontal scroll.
+9. Chuẩn hóa status color map.
+10. Thêm empty/loading/error states.
+
+## 20. Mapping Sang Dự Án Khác
+
+Nếu dự án mới không phải quản lý tài khoản/tài sản, vẫn có thể dùng style này bằng cách đổi tên module:
+
+| AccManager concept | Dự án mới có thể thay bằng |
+| --- | --- |
+| Applications | Products, Services, Modules, Integrations |
+| Accounts | Records, Credentials, Customers, Contracts |
+| Assets | Inventory, Devices, Documents, Items |
+| Users | Members, Staff, Operators |
+| Departments | Teams, Groups, Categories |
+| Projects | Campaigns, Workspaces, Sites |
+| Borrow/Return | Requests, Tickets, Approvals, Workflows |
+
+Điều quan trọng là giữ cùng cấu trúc:
+
+- Sidebar nhóm chức năng.
+- Page header + actions.
+- Filter/search.
+- Table/list.
+- Modal CRUD.
+- Badge trạng thái.
+- Toast feedback.
+
+## 21. Những Gì Nên Tránh Khi Port
+
+- Copy nguyên text nghiệp vụ AccManager nếu dự án mới khác domain.
+- Copy table quá nhiều cột khi domain không cần.
+- Lạm dụng modal cho mọi thứ nếu workflow mới cần full page editor.
+- Dùng gradient nền cho app shell chính.
+- Tăng border radius quá lớn làm mất chất admin console.
+- Dùng font khác quá mềm hoặc decorative.
+- Thêm quá nhiều shadow, glassmorphism, decorative blobs.
+- Trộn nhiều style icon khác nhau.
+
diff --git a/backend/server.js b/backend/server.js
index dbd06a1..ae7dc5f 100644
--- a/backend/server.js
+++ b/backend/server.js
@@ -1606,6 +1606,419 @@ function parseAssetImportRowsFromWorkbook(workbook) {
};
}
+const CONSUMABLE_IMPORT_ALIASES = {
+ stt: ['STT', 'So thu tu'],
+ requestMonth: ['Thang de xuat', 'Thang', 'Ky de xuat', 'Ky'],
+ consumableCode: ['Ma vat tu', 'Ma VT', 'Ma linh kien', 'Code', 'SKU', 'Part Number', 'PN'],
+ consumableName: ['Ten linh kien/sp', 'Ten linh kien sp', 'Ten linh kien', 'Ten vat tu', 'Ten sp', 'Ten san pham', 'Name', 'Dien giai', 'Mo ta'],
+ model: ['Model', 'Dong may', 'Quy cach'],
+ unit: ['DVT', 'Don vi', 'Unit'],
+ openingBalance: ['Ton dau ky', 'Ton dau ki', 'Ton dau', 'Opening Balance', 'Quantity', 'So luong', 'SL'],
+ importInPeriod: ['Nhap trong ky', 'Nhap trong ki', 'Nhap ky', 'Nhap'],
+ exportInPeriod: ['Xuat trong ky', 'Xuat trong ki', 'Xuat ky', 'Xuat'],
+ endingBalance: ['Ton cuoi ky', 'Ton cuoi ki', 'Ton cuoi', 'Ending Balance'],
+ exportReason: ['Li do xuat', 'Ly do xuat', 'Lí do xuất', 'Ghi chu', 'Ghi chú', 'Notes']
+};
+
+function normalizeConsumablePayload(payload = {}) {
+ const consumableName = String(payload.consumableName || payload.assetName || '').trim();
+ const model = String(payload.model || '').trim();
+ const consumableCode = String(payload.consumableCode || payload.assetCode || '').trim();
+ const openingBalance = parseNonNegativeIntegerOrFallback(payload.openingBalance ?? payload.quantity, 0);
+ const importInPeriod = parseNonNegativeIntegerOrFallback(payload.importInPeriod, 0);
+ const exportInPeriod = parseNonNegativeIntegerOrFallback(payload.exportInPeriod, 0);
+ const providedEndingBalance = parseOptionalNonNegativeInteger(payload.endingBalance);
+ const endingBalance = providedEndingBalance !== null
+ ? providedEndingBalance
+ : Math.max(openingBalance + importInPeriod - exportInPeriod, 0);
+
+ return {
+ requestMonth: String(payload.requestMonth || '').trim() || null,
+ consumableCode,
+ consumableName: consumableName || model || consumableCode || null,
+ model: model || null,
+ unit: String(payload.unit || '').trim() || null,
+ openingBalance,
+ importInPeriod,
+ exportInPeriod,
+ endingBalance,
+ exportReason: String(payload.exportReason || payload.notes || '').trim() || null
+ };
+}
+
+function isHeaderLikeConsumableImportRow(row = {}) {
+ const headerTokens = new Set([
+ 'stt',
+ 'thangdexuat',
+ 'mavattu',
+ 'mavt',
+ 'tenlinhkiensp',
+ 'tenlinhkien',
+ 'tenvattu',
+ 'model',
+ 'dvt',
+ 'donvi',
+ 'tondauky',
+ 'tondauki',
+ 'nhaptrongky',
+ 'nhaptrongki',
+ 'xuattrongky',
+ 'xuattrongki',
+ 'toncuoiky',
+ 'toncuoiki',
+ 'lidoxuat',
+ 'lydoxuat'
+ ]);
+
+ const fields = [
+ row.requestMonth,
+ row.consumableCode,
+ row.consumableName,
+ row.model,
+ row.unit,
+ row.openingBalance,
+ row.importInPeriod,
+ row.exportInPeriod,
+ row.endingBalance,
+ row.exportReason
+ ];
+
+ const headerLikeCount = fields.reduce((count, value) => {
+ const token = normalizeImportToken(value);
+ return count + (token && headerTokens.has(token) ? 1 : 0);
+ }, 0);
+
+ return headerLikeCount >= 2;
+}
+
+function isMeaningfulImportedConsumableRow(row = {}) {
+ return [
+ row.requestMonth,
+ row.consumableCode,
+ row.consumableName,
+ row.model,
+ row.unit,
+ row.exportReason,
+ row.openingBalance,
+ row.importInPeriod,
+ row.exportInPeriod,
+ row.endingBalance
+ ].some(value => String(value ?? '').trim() !== '');
+}
+
+function inferConsumableFieldFromHeaderToken(headerToken) {
+ const token = String(headerToken || '');
+ if (!token) {
+ return null;
+ }
+
+ if (token.includes('thang') || token.includes('ky')) return 'requestMonth';
+ if (token.includes('model') || token.includes('quycach')) return 'model';
+ if (token.includes('tondau')) return 'openingBalance';
+ if (token.includes('nhaptrongky') || token.includes('nhaptrongki')) return 'importInPeriod';
+ if (token.includes('xuattrongky') || token.includes('xuattrongki')) return 'exportInPeriod';
+ if (token.includes('toncuoi')) return 'endingBalance';
+ if (token.includes('donvi') || token.includes('dvt') || token === 'unit') return 'unit';
+ if (token.includes('lydoxuat') || token.includes('lidoxuat') || token.includes('ghichu') || token === 'notes') return 'exportReason';
+
+ const hasTen = token.includes('ten');
+ const hasMa = token.includes('ma');
+ const hasConsumableLike = token.includes('linhkien') || token.includes('vattu') || token.includes('sanpham') || token.includes('sp');
+
+ if (hasTen && hasConsumableLike) return 'consumableName';
+ if (hasMa && hasConsumableLike) return 'consumableCode';
+
+ return null;
+}
+
+function resolveConsumableImportFieldByHeader(headerCell) {
+ const token = normalizeImportToken(headerCell);
+ if (!token) {
+ return null;
+ }
+
+ let bestField = null;
+ let bestScore = 0;
+
+ for (const [field, aliases] of Object.entries(CONSUMABLE_IMPORT_ALIASES)) {
+ for (const alias of aliases) {
+ const aliasToken = normalizeImportToken(alias);
+ if (!aliasToken) {
+ continue;
+ }
+
+ let score = 0;
+ if (token === aliasToken) {
+ score = 5;
+ } else if (token.includes(aliasToken) || aliasToken.includes(token)) {
+ score = 3;
+ }
+
+ if (score > bestScore) {
+ bestScore = score;
+ bestField = field;
+ }
+ }
+ }
+
+ return bestField || inferConsumableFieldFromHeaderToken(token);
+}
+
+function buildConsumableImportFieldMapFromHeaderRow(headerRow) {
+ const row = Array.isArray(headerRow) ? headerRow : [];
+ const fieldMap = {};
+
+ for (let index = 0; index < row.length; index += 1) {
+ const field = resolveConsumableImportFieldByHeader(row[index]);
+ if (field && fieldMap[field] === undefined) {
+ fieldMap[field] = index;
+ }
+ }
+
+ return fieldMap;
+}
+
+function scoreConsumableImportFieldMap(fieldMap = {}) {
+ let score = Object.keys(fieldMap).length;
+ if (fieldMap.consumableName !== undefined) score += 6;
+ if (fieldMap.model !== undefined) score += 3;
+ if (fieldMap.consumableCode !== undefined) score += 3;
+ if (fieldMap.requestMonth !== undefined) score += 2;
+ if (fieldMap.openingBalance !== undefined) score += 2;
+ if (fieldMap.importInPeriod !== undefined) score += 2;
+ if (fieldMap.exportInPeriod !== undefined) score += 2;
+ if (fieldMap.endingBalance !== undefined) score += 2;
+ if (fieldMap.exportReason !== undefined) score += 1;
+ return score;
+}
+
+function generateImportConsumableCodeFromRow(mapped, rowNumber = 0) {
+ const fromModel = sanitizeAssetCodeToken(mapped.model);
+ const fromName = sanitizeAssetCodeToken(mapped.consumableName);
+ const base = (fromModel || fromName || 'VTTH').slice(0, 42);
+ const sttNumber = parseAssetImportSttNumber(mapped?.sourceStt);
+ const suffixSeed = sttNumber || rowNumber || 0;
+ const suffix = String(suffixSeed).padStart(4, '0');
+ return `VTTH-${base}-${suffix}`;
+}
+
+function generateManualConsumableCode(payload = {}) {
+ const fromModel = sanitizeAssetCodeToken(payload.model);
+ const fromName = sanitizeAssetCodeToken(payload.consumableName);
+ const base = (fromModel || fromName || 'VTTH').slice(0, 32);
+ const timestamp = formatAppTimestampForCode(new Date(), true);
+ const randomSuffix = String(Math.floor(Math.random() * 100)).padStart(2, '0');
+ return `VTTH-${base}-${timestamp}${randomSuffix}`;
+}
+
+async function generateUniqueManualConsumableCode(payload = {}, maxAttempts = 8) {
+ for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
+ const candidate = generateManualConsumableCode(payload);
+ const existed = await pool.request()
+ .input('consumableCode', sql.NVarChar, candidate)
+ .query(`
+ SELECT TOP 1 ConsumableId
+ FROM ConsumableInventory
+ WHERE ConsumableCode = @consumableCode
+ `);
+
+ if (existed.recordset.length === 0) {
+ return candidate;
+ }
+ }
+
+ throw new Error('Cannot generate unique consumable code');
+}
+
+function finalizeImportedConsumablePayload(mapped, rowNumber = 0) {
+ const result = { ...mapped };
+ if (!result.consumableName) {
+ result.consumableName = String(result.model || result.consumableCode || '').trim();
+ }
+
+ if (!result.consumableCode && result.consumableName) {
+ result.consumableCode = generateImportConsumableCodeFromRow(result, rowNumber);
+ }
+
+ return result;
+}
+
+function parseConsumableImportRowsByHeaderMap(matrixRows) {
+ const rows = Array.isArray(matrixRows) ? matrixRows : [];
+ const maxScanRows = Math.min(rows.length, 120);
+ let bestHeaderRowIndex = -1;
+ let bestFieldMap = {};
+ let bestScore = 0;
+
+ for (let rowIndex = 0; rowIndex < maxScanRows; rowIndex += 1) {
+ const headerRow = Array.isArray(rows[rowIndex]) ? rows[rowIndex] : [];
+ if (!headerRow.some(cell => String(cell ?? '').trim() !== '')) {
+ continue;
+ }
+
+ const candidateMap = buildConsumableImportFieldMapFromHeaderRow(headerRow);
+ const score = scoreConsumableImportFieldMap(candidateMap);
+ if (score > bestScore) {
+ bestScore = score;
+ bestHeaderRowIndex = rowIndex;
+ bestFieldMap = candidateMap;
+ }
+ }
+
+ if (bestHeaderRowIndex < 0 || bestScore < 6) {
+ return [];
+ }
+
+ const pick = (row, index) => {
+ if (!Array.isArray(row) || index === undefined || index < 0) {
+ return '';
+ }
+ return row[index] ?? '';
+ };
+
+ return rows
+ .slice(bestHeaderRowIndex + 1)
+ .filter(row => Array.isArray(row) && row.some(cell => String(cell ?? '').trim() !== ''))
+ .map((row, rowOffset) => {
+ const sttValue = parseAssetImportSttNumber(pick(row, bestFieldMap.stt));
+ if (bestFieldMap.stt !== undefined && sttValue === null) {
+ return null;
+ }
+
+ const mapped = {
+ sourceStt: sttValue,
+ requestMonth: String(pick(row, bestFieldMap.requestMonth)).trim(),
+ consumableCode: String(pick(row, bestFieldMap.consumableCode)).trim(),
+ consumableName: String(pick(row, bestFieldMap.consumableName)).trim(),
+ model: String(pick(row, bestFieldMap.model)).trim(),
+ unit: String(pick(row, bestFieldMap.unit)).trim(),
+ openingBalance: parseAssetImportNumericValue(pick(row, bestFieldMap.openingBalance), 0),
+ importInPeriod: parseAssetImportNumericValue(pick(row, bestFieldMap.importInPeriod), 0),
+ exportInPeriod: parseAssetImportNumericValue(pick(row, bestFieldMap.exportInPeriod), 0),
+ endingBalance: parseAssetImportNumericValue(pick(row, bestFieldMap.endingBalance), 0),
+ exportReason: String(pick(row, bestFieldMap.exportReason)).trim()
+ };
+
+ const hasCoreValue = [mapped.consumableCode, mapped.consumableName, mapped.model, mapped.exportReason]
+ .some(value => String(value || '').trim() !== '');
+ if (!hasCoreValue) {
+ return null;
+ }
+
+ return finalizeImportedConsumablePayload(mapped, bestHeaderRowIndex + rowOffset + 2);
+ })
+ .filter(Boolean)
+ .filter(row => !isHeaderLikeConsumableImportRow(row))
+ .filter(row => isMeaningfulImportedConsumableRow(row));
+}
+
+function parseConsumableImportRowsLoose(matrixRows) {
+ const rows = Array.isArray(matrixRows) ? matrixRows : [];
+ const sttCol = detectLikelySttColumn(rows);
+ if (sttCol < 0) {
+ return [];
+ }
+
+ return rows
+ .filter(row => Array.isArray(row) && parseAssetImportSttNumber(row[sttCol]) !== null)
+ .map((row, rowOffset) => {
+ const mapped = {
+ sourceStt: parseAssetImportSttNumber(row[sttCol]),
+ requestMonth: String(row[sttCol + 1] ?? '').trim(),
+ consumableCode: String(row[sttCol + 2] ?? '').trim(),
+ consumableName: String(row[sttCol + 3] ?? '').trim(),
+ model: String(row[sttCol + 4] ?? '').trim(),
+ unit: String(row[sttCol + 5] ?? '').trim(),
+ openingBalance: parseAssetImportNumericValue(row[sttCol + 6] ?? '', 0),
+ importInPeriod: parseAssetImportNumericValue(row[sttCol + 7] ?? '', 0),
+ exportInPeriod: parseAssetImportNumericValue(row[sttCol + 8] ?? '', 0),
+ endingBalance: parseAssetImportNumericValue(row[sttCol + 9] ?? '', 0),
+ exportReason: String(row[sttCol + 10] ?? '').trim()
+ };
+
+ return finalizeImportedConsumablePayload(mapped, rowOffset + 2);
+ })
+ .filter(row => !isHeaderLikeConsumableImportRow(row))
+ .filter(row => isMeaningfulImportedConsumableRow(row));
+}
+
+function parseConsumableImportRows(matrixRows) {
+ const headerRows = parseConsumableImportRowsByHeaderMap(matrixRows);
+ if (headerRows.length > 0) {
+ return headerRows;
+ }
+
+ return parseConsumableImportRowsLoose(matrixRows);
+}
+
+function scoreConsumableImportSheet(sheetName = '', matrixRows = []) {
+ const sheetToken = normalizeImportToken(sheetName);
+ const titleToken = normalizeImportToken(
+ (Array.isArray(matrixRows) ? matrixRows.slice(0, 8) : [])
+ .flat()
+ .join(' ')
+ );
+ const token = `${sheetToken} ${titleToken}`;
+ let score = 0;
+
+ if (token.includes('vattutieuhao') || token.includes('vtth')) score += 220;
+ if (token.includes('baocaoxuatnhaptonkho')) score += 100;
+ if (token.includes('xuatnhapton')) score += 80;
+ if (sheetToken.includes('2026') || sheetToken.includes('2025')) score += 20;
+ if (token.includes('kho')) score += 20;
+
+ return score;
+}
+
+function parseConsumableImportRowsFromWorkbook(workbook) {
+ const sheetNames = Array.isArray(workbook?.SheetNames) ? workbook.SheetNames : [];
+ let bestRows = [];
+ let bestSheetName = '';
+ let bestNonEmptyRows = 0;
+ let bestSheetPriority = 0;
+ const diagnostics = [];
+
+ for (const sheetName of sheetNames) {
+ const sheet = workbook.Sheets?.[sheetName];
+ if (!sheet) {
+ continue;
+ }
+
+ const matrixRows = XLSX.utils.sheet_to_json(sheet, {
+ header: 1,
+ defval: '',
+ raw: false
+ });
+
+ const parsedRows = parseConsumableImportRows(matrixRows);
+ const nonEmptyRows = countNonEmptyMatrixRows(matrixRows);
+ const sheetPriority = scoreConsumableImportSheet(sheetName, matrixRows);
+ diagnostics.push({
+ sheetName,
+ parsedRows: parsedRows.length,
+ nonEmptyRows,
+ sheetPriority
+ });
+
+ if (
+ (sheetPriority > bestSheetPriority && parsedRows.length > 0)
+ || (sheetPriority === bestSheetPriority && parsedRows.length > bestRows.length)
+ || (sheetPriority === bestSheetPriority && parsedRows.length === bestRows.length && nonEmptyRows > bestNonEmptyRows)
+ ) {
+ bestRows = parsedRows;
+ bestSheetName = sheetName;
+ bestNonEmptyRows = nonEmptyRows;
+ bestSheetPriority = sheetPriority;
+ }
+ }
+
+ return {
+ rows: bestRows,
+ sheetName: bestSheetName,
+ diagnostics
+ };
+}
+
// Middleware
app.use(cors());
app.use(express.json());
@@ -1745,6 +2158,8 @@ async function ensureAppTimeDefaultConstraints() {
(N'Accounts', N'UpdatedDate', N'DF_Accounts_UpdatedDate', N'(DATEADD(HOUR, 7, SYSUTCDATETIME()))'),
(N'AssetInventory', N'CreatedDate', N'DF_AssetInventory_CreatedDate', N'(DATEADD(HOUR, 7, SYSUTCDATETIME()))'),
(N'AssetInventory', N'UpdatedDate', N'DF_AssetInventory_UpdatedDate', N'(DATEADD(HOUR, 7, SYSUTCDATETIME()))'),
+ (N'ConsumableInventory', N'CreatedDate', N'DF_ConsumableInventory_CreatedDate', N'(DATEADD(HOUR, 7, SYSUTCDATETIME()))'),
+ (N'ConsumableInventory', N'UpdatedDate', N'DF_ConsumableInventory_UpdatedDate', N'(DATEADD(HOUR, 7, SYSUTCDATETIME()))'),
(N'AssetDepartments', N'CreatedDate', N'DF_AssetDepartments_CreatedDate', N'(DATEADD(HOUR, 7, SYSUTCDATETIME()))'),
(N'AssetDepartments', N'UpdatedDate', N'DF_AssetDepartments_UpdatedDate', N'(DATEADD(HOUR, 7, SYSUTCDATETIME()))'),
(N'AssetProjects', N'CreatedDate', N'DF_AssetProjects_CreatedDate', N'(DATEADD(HOUR, 7, SYSUTCDATETIME()))'),
@@ -1756,6 +2171,9 @@ async function ensureAppTimeDefaultConstraints() {
(N'AssetExportHistory', N'ExportedDate', N'DF_AssetExportHistory_ExportedDate', N'(DATEADD(HOUR, 7, SYSUTCDATETIME()))'),
(N'AssetExportHistory', N'CreatedDate', N'DF_AssetExportHistory_CreatedDate', N'(DATEADD(HOUR, 7, SYSUTCDATETIME()))'),
(N'AssetExportHistory', N'UpdatedDate', N'DF_AssetExportHistory_UpdatedDate', N'(DATEADD(HOUR, 7, SYSUTCDATETIME()))'),
+ (N'ConsumableExportHistory', N'ExportedDate', N'DF_ConsumableExportHistory_ExportedDate', N'(DATEADD(HOUR, 7, SYSUTCDATETIME()))'),
+ (N'ConsumableExportHistory', N'CreatedDate', N'DF_ConsumableExportHistory_CreatedDate', N'(DATEADD(HOUR, 7, SYSUTCDATETIME()))'),
+ (N'ConsumableExportHistory', N'UpdatedDate', N'DF_ConsumableExportHistory_UpdatedDate', N'(DATEADD(HOUR, 7, SYSUTCDATETIME()))'),
(N'AssetDamageDisposalHistory', N'ActionDate', N'DF_AssetDamageDisposalHistory_ActionDate', N'(DATEADD(HOUR, 7, SYSUTCDATETIME()))'),
(N'AssetDamageDisposalHistory', N'CreatedDate', N'DF_AssetDamageDisposalHistory_CreatedDate', N'(DATEADD(HOUR, 7, SYSUTCDATETIME()))'),
(N'AssetDamageDisposalHistory', N'UpdatedDate', N'DF_AssetDamageDisposalHistory_UpdatedDate', N'(DATEADD(HOUR, 7, SYSUTCDATETIME()))'),
@@ -1905,6 +2323,55 @@ async function createTables() {
)
END`,
+ // Consumable Inventory Table
+ `IF NOT EXISTS (SELECT * FROM sys.tables WHERE name = 'ConsumableInventory')
+ BEGIN
+ CREATE TABLE ConsumableInventory (
+ ConsumableId INT PRIMARY KEY IDENTITY(1,1),
+ RequestMonth NVARCHAR(50) NULL,
+ ConsumableCode NVARCHAR(100) NOT NULL UNIQUE,
+ ConsumableName NVARCHAR(255) NOT NULL,
+ Model NVARCHAR(255) NULL,
+ Unit NVARCHAR(50) NULL,
+ OpeningBalance INT NOT NULL DEFAULT 0,
+ ImportInPeriod INT NOT NULL DEFAULT 0,
+ ExportInPeriod INT NOT NULL DEFAULT 0,
+ EndingBalance INT NOT NULL DEFAULT 0,
+ ExportReason NVARCHAR(1000) NULL,
+ CreatedBy INT NULL,
+ CreatedDate DATETIME DEFAULT (DATEADD(HOUR, 7, SYSUTCDATETIME())),
+ UpdatedDate DATETIME DEFAULT (DATEADD(HOUR, 7, SYSUTCDATETIME())),
+ FOREIGN KEY (CreatedBy) REFERENCES Users(UserId) ON DELETE SET NULL
+ )
+ END`,
+
+ // Consumable Export History Table
+ `IF NOT EXISTS (SELECT * FROM sys.tables WHERE name = 'ConsumableExportHistory')
+ BEGIN
+ CREATE TABLE ConsumableExportHistory (
+ ExportHistoryId INT PRIMARY KEY IDENTITY(1,1),
+ ConsumableId INT NOT NULL,
+ ConsumableCode NVARCHAR(100) NOT NULL,
+ ConsumableName NVARCHAR(255) NOT NULL,
+ Unit NVARCHAR(50) NULL,
+ ExportQuantity INT NOT NULL DEFAULT 1,
+ RecipientName NVARCHAR(100) NOT NULL,
+ ProjectName NVARCHAR(150) NULL,
+ ExportedByName NVARCHAR(100) NOT NULL,
+ ExportNote NVARCHAR(1000) NULL,
+ PreviousExportInPeriod INT NOT NULL DEFAULT 0,
+ NextExportInPeriod INT NOT NULL DEFAULT 0,
+ PreviousEndingBalance INT NOT NULL DEFAULT 0,
+ NextEndingBalance INT NOT NULL DEFAULT 0,
+ CreatedBy INT NULL,
+ ExportedDate 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 (ConsumableId) REFERENCES ConsumableInventory(ConsumableId) ON DELETE CASCADE,
+ FOREIGN KEY (CreatedBy) REFERENCES Users(UserId) ON DELETE SET NULL
+ )
+ END`,
+
// Asset Departments Table
`IF NOT EXISTS (SELECT * FROM sys.tables WHERE name = 'AssetDepartments')
BEGIN
@@ -2058,6 +2525,15 @@ async function createTables() {
console.error('AssetInventory index creation error:', err.message);
}
+ // Ensure ConsumableInventory indexes exist for lookup/filter performance
+ try {
+ await pool.request().query(`IF NOT EXISTS (SELECT * FROM sys.indexes WHERE name = 'IX_ConsumableInventory_ConsumableCode') CREATE INDEX IX_ConsumableInventory_ConsumableCode ON ConsumableInventory(ConsumableCode);`);
+ await pool.request().query(`IF NOT EXISTS (SELECT * FROM sys.indexes WHERE name = 'IX_ConsumableInventory_RequestMonth') CREATE INDEX IX_ConsumableInventory_RequestMonth ON ConsumableInventory(RequestMonth);`);
+ await pool.request().query(`IF NOT EXISTS (SELECT * FROM sys.indexes WHERE name = 'IX_ConsumableInventory_EndingBalance') CREATE INDEX IX_ConsumableInventory_EndingBalance ON ConsumableInventory(EndingBalance);`);
+ } catch (err) {
+ console.error('ConsumableInventory index creation error:', err.message);
+ }
+
// Ensure AssetDepartments indexes exist
try {
await pool.request().query(`IF NOT EXISTS (SELECT * FROM sys.indexes WHERE name = 'UX_AssetDepartments_DepartmentName') CREATE UNIQUE INDEX UX_AssetDepartments_DepartmentName ON AssetDepartments(DepartmentName);`);
@@ -2093,6 +2569,14 @@ async function createTables() {
console.error('AssetExportHistory index creation error:', err.message);
}
+ // Ensure ConsumableExportHistory indexes exist
+ try {
+ await pool.request().query(`IF NOT EXISTS (SELECT * FROM sys.indexes WHERE name = 'IX_ConsumableExportHistory_ConsumableId') CREATE INDEX IX_ConsumableExportHistory_ConsumableId ON ConsumableExportHistory(ConsumableId);`);
+ await pool.request().query(`IF NOT EXISTS (SELECT * FROM sys.indexes WHERE name = 'IX_ConsumableExportHistory_ExportedDate') CREATE INDEX IX_ConsumableExportHistory_ExportedDate ON ConsumableExportHistory(ExportedDate DESC);`);
+ } catch (err) {
+ console.error('ConsumableExportHistory index creation error:', err.message);
+ }
+
// Ensure AssetDamageDisposalHistory indexes exist
try {
await pool.request().query(`IF NOT EXISTS (SELECT * FROM sys.indexes WHERE name = 'IX_AssetDamageDisposalHistory_AssetId') CREATE INDEX IX_AssetDamageDisposalHistory_AssetId ON AssetDamageDisposalHistory(AssetId);`);
@@ -2121,6 +2605,91 @@ async function createTables() {
await pool.request().query(`IF COL_LENGTH('dbo.AssetInventory','Project') IS NULL ALTER TABLE AssetInventory ADD Project NVARCHAR(150) NULL;`);
await pool.request().query(`IF COL_LENGTH('dbo.AssetInventory','Borrower') IS NULL ALTER TABLE AssetInventory ADD Borrower NVARCHAR(255) NULL;`);
await pool.request().query(`IF COL_LENGTH('dbo.AssetInventory','ExportedBy') IS NULL ALTER TABLE AssetInventory ADD ExportedBy NVARCHAR(100) NULL;`);
+ await pool.request().query(`IF COL_LENGTH('dbo.ConsumableInventory','RequestMonth') IS NULL ALTER TABLE ConsumableInventory ADD RequestMonth NVARCHAR(50) NULL;`);
+ await pool.request().query(`IF COL_LENGTH('dbo.ConsumableInventory','Model') IS NULL ALTER TABLE ConsumableInventory ADD Model NVARCHAR(255) NULL;`);
+ await pool.request().query(`IF COL_LENGTH('dbo.ConsumableInventory','Unit') IS NULL ALTER TABLE ConsumableInventory ADD Unit NVARCHAR(50) NULL;`);
+ await pool.request().query(`IF COL_LENGTH('dbo.ConsumableInventory','OpeningBalance') IS NULL ALTER TABLE ConsumableInventory ADD OpeningBalance INT NOT NULL CONSTRAINT DF_ConsumableInventory_OpeningBalance DEFAULT(0);`);
+ await pool.request().query(`IF COL_LENGTH('dbo.ConsumableInventory','ImportInPeriod') IS NULL ALTER TABLE ConsumableInventory ADD ImportInPeriod INT NOT NULL CONSTRAINT DF_ConsumableInventory_ImportInPeriod DEFAULT(0);`);
+ await pool.request().query(`IF COL_LENGTH('dbo.ConsumableInventory','ExportInPeriod') IS NULL ALTER TABLE ConsumableInventory ADD ExportInPeriod INT NOT NULL CONSTRAINT DF_ConsumableInventory_ExportInPeriod DEFAULT(0);`);
+ await pool.request().query(`IF COL_LENGTH('dbo.ConsumableInventory','EndingBalance') IS NULL ALTER TABLE ConsumableInventory ADD EndingBalance INT NOT NULL CONSTRAINT DF_ConsumableInventory_EndingBalance DEFAULT(0);`);
+ await pool.request().query(`IF COL_LENGTH('dbo.ConsumableInventory','ExportReason') IS NULL ALTER TABLE ConsumableInventory ADD ExportReason NVARCHAR(1000) NULL;`);
+ await pool.request().query(`
+ IF NOT EXISTS (SELECT * FROM sys.tables WHERE name = 'ConsumableExportHistory')
+ BEGIN
+ CREATE TABLE ConsumableExportHistory (
+ ExportHistoryId INT PRIMARY KEY IDENTITY(1,1),
+ ConsumableId INT NOT NULL,
+ ConsumableCode NVARCHAR(100) NOT NULL,
+ ConsumableName NVARCHAR(255) NOT NULL,
+ Unit NVARCHAR(50) NULL,
+ ExportQuantity INT NOT NULL CONSTRAINT DF_ConsumableExportHistory_ExportQuantity DEFAULT(1),
+ RecipientName NVARCHAR(100) NOT NULL,
+ ProjectName NVARCHAR(150) NULL,
+ ExportedByName NVARCHAR(100) NOT NULL,
+ ExportNote NVARCHAR(1000) NULL,
+ PreviousExportInPeriod INT NOT NULL CONSTRAINT DF_ConsumableExportHistory_PreviousExportInPeriod DEFAULT(0),
+ NextExportInPeriod INT NOT NULL CONSTRAINT DF_ConsumableExportHistory_NextExportInPeriod DEFAULT(0),
+ PreviousEndingBalance INT NOT NULL CONSTRAINT DF_ConsumableExportHistory_PreviousEndingBalance DEFAULT(0),
+ NextEndingBalance INT NOT NULL CONSTRAINT DF_ConsumableExportHistory_NextEndingBalance DEFAULT(0),
+ CreatedBy INT NULL,
+ ExportedDate DATETIME NOT NULL CONSTRAINT DF_ConsumableExportHistory_ExportedDate DEFAULT(DATEADD(HOUR, 7, SYSUTCDATETIME())),
+ CreatedDate DATETIME NOT NULL CONSTRAINT DF_ConsumableExportHistory_CreatedDate DEFAULT(DATEADD(HOUR, 7, SYSUTCDATETIME())),
+ UpdatedDate DATETIME NOT NULL CONSTRAINT DF_ConsumableExportHistory_UpdatedDate DEFAULT(DATEADD(HOUR, 7, SYSUTCDATETIME())),
+ FOREIGN KEY (ConsumableId) REFERENCES ConsumableInventory(ConsumableId) ON DELETE CASCADE,
+ FOREIGN KEY (CreatedBy) REFERENCES Users(UserId) ON DELETE SET NULL
+ );
+ END
+ `);
+ await pool.request().query(`IF COL_LENGTH('dbo.ConsumableExportHistory','ConsumableCode') IS NULL ALTER TABLE ConsumableExportHistory ADD ConsumableCode NVARCHAR(100) NULL;`);
+ await pool.request().query(`IF COL_LENGTH('dbo.ConsumableExportHistory','ConsumableName') IS NULL ALTER TABLE ConsumableExportHistory ADD ConsumableName NVARCHAR(255) NULL;`);
+ await pool.request().query(`IF COL_LENGTH('dbo.ConsumableExportHistory','Unit') IS NULL ALTER TABLE ConsumableExportHistory ADD Unit NVARCHAR(50) NULL;`);
+ await pool.request().query(`IF COL_LENGTH('dbo.ConsumableExportHistory','ExportQuantity') IS NULL ALTER TABLE ConsumableExportHistory ADD ExportQuantity INT NOT NULL CONSTRAINT DF_ConsumableExportHistory_ExportQuantity DEFAULT(1);`);
+ await pool.request().query(`IF COL_LENGTH('dbo.ConsumableExportHistory','RecipientName') IS NULL ALTER TABLE ConsumableExportHistory ADD RecipientName NVARCHAR(100) NULL;`);
+ await pool.request().query(`IF COL_LENGTH('dbo.ConsumableExportHistory','ProjectName') IS NULL ALTER TABLE ConsumableExportHistory ADD ProjectName NVARCHAR(150) NULL;`);
+ await pool.request().query(`IF COL_LENGTH('dbo.ConsumableExportHistory','ExportedByName') IS NULL ALTER TABLE ConsumableExportHistory ADD ExportedByName NVARCHAR(100) NULL;`);
+ await pool.request().query(`IF COL_LENGTH('dbo.ConsumableExportHistory','ExportNote') IS NULL ALTER TABLE ConsumableExportHistory ADD ExportNote NVARCHAR(1000) NULL;`);
+ await pool.request().query(`IF COL_LENGTH('dbo.ConsumableExportHistory','PreviousExportInPeriod') IS NULL ALTER TABLE ConsumableExportHistory ADD PreviousExportInPeriod INT NOT NULL CONSTRAINT DF_ConsumableExportHistory_PreviousExportInPeriod DEFAULT(0);`);
+ await pool.request().query(`IF COL_LENGTH('dbo.ConsumableExportHistory','NextExportInPeriod') IS NULL ALTER TABLE ConsumableExportHistory ADD NextExportInPeriod INT NOT NULL CONSTRAINT DF_ConsumableExportHistory_NextExportInPeriod DEFAULT(0);`);
+ await pool.request().query(`IF COL_LENGTH('dbo.ConsumableExportHistory','PreviousEndingBalance') IS NULL ALTER TABLE ConsumableExportHistory ADD PreviousEndingBalance INT NOT NULL CONSTRAINT DF_ConsumableExportHistory_PreviousEndingBalance DEFAULT(0);`);
+ await pool.request().query(`IF COL_LENGTH('dbo.ConsumableExportHistory','NextEndingBalance') IS NULL ALTER TABLE ConsumableExportHistory ADD NextEndingBalance INT NOT NULL CONSTRAINT DF_ConsumableExportHistory_NextEndingBalance DEFAULT(0);`);
+ await pool.request().query(`IF COL_LENGTH('dbo.ConsumableExportHistory','CreatedBy') IS NULL ALTER TABLE ConsumableExportHistory ADD CreatedBy INT NULL;`);
+ await pool.request().query(`IF COL_LENGTH('dbo.ConsumableExportHistory','ExportedDate') IS NULL ALTER TABLE ConsumableExportHistory ADD ExportedDate DATETIME NOT NULL CONSTRAINT DF_ConsumableExportHistory_ExportedDate DEFAULT(DATEADD(HOUR, 7, SYSUTCDATETIME()));`);
+ await pool.request().query(`IF COL_LENGTH('dbo.ConsumableExportHistory','CreatedDate') IS NULL ALTER TABLE ConsumableExportHistory ADD CreatedDate DATETIME NOT NULL CONSTRAINT DF_ConsumableExportHistory_CreatedDate DEFAULT(DATEADD(HOUR, 7, SYSUTCDATETIME()));`);
+ await pool.request().query(`IF COL_LENGTH('dbo.ConsumableExportHistory','UpdatedDate') IS NULL ALTER TABLE ConsumableExportHistory ADD UpdatedDate DATETIME NOT NULL CONSTRAINT DF_ConsumableExportHistory_UpdatedDate DEFAULT(DATEADD(HOUR, 7, SYSUTCDATETIME()));`);
+ 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.ConsumableExportHistory')
+ AND c.name = 'ConsumableId'
+ )
+ AND COL_LENGTH('dbo.ConsumableExportHistory', 'ConsumableId') IS NOT NULL
+ BEGIN
+ ALTER TABLE ConsumableExportHistory
+ ADD CONSTRAINT FK_ConsumableExportHistory_ConsumableId
+ FOREIGN KEY (ConsumableId) REFERENCES ConsumableInventory(ConsumableId) ON DELETE CASCADE;
+ END
+ `);
+ 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.ConsumableExportHistory')
+ AND c.name = 'CreatedBy'
+ )
+ AND COL_LENGTH('dbo.ConsumableExportHistory', 'CreatedBy') IS NOT NULL
+ BEGIN
+ ALTER TABLE ConsumableExportHistory
+ ADD CONSTRAINT FK_ConsumableExportHistory_CreatedBy
+ FOREIGN KEY (CreatedBy) REFERENCES Users(UserId) ON DELETE SET NULL;
+ END
+ `);
await pool.request().query(`IF COL_LENGTH('dbo.AssetBorrowRequests','Unit') IS NULL ALTER TABLE AssetBorrowRequests ADD Unit NVARCHAR(50) NULL;`);
await pool.request().query(`IF COL_LENGTH('dbo.AssetBorrowRequests','BorrowDate') IS NULL ALTER TABLE AssetBorrowRequests ADD BorrowDate DATE NOT NULL CONSTRAINT DF_AssetBorrowRequests_BorrowDate DEFAULT(CAST(DATEADD(HOUR, 7, SYSUTCDATETIME()) AS DATE));`);
await pool.request().query(`IF COL_LENGTH('dbo.AssetBorrowRequests','RequestType') IS NULL ALTER TABLE AssetBorrowRequests ADD RequestType NVARCHAR(20) NOT NULL CONSTRAINT DF_AssetBorrowRequests_RequestType DEFAULT('borrow');`);
@@ -5007,6 +5576,576 @@ app.delete('/api/asset-borrows/:id', async (req, res) => {
}
});
+app.get('/api/consumables', async (req, res) => {
+ try {
+ const result = await pool.request().query(`
+ SELECT
+ ConsumableId,
+ RequestMonth,
+ ConsumableCode,
+ ConsumableName,
+ Model,
+ Unit,
+ OpeningBalance,
+ ImportInPeriod,
+ ExportInPeriod,
+ EndingBalance,
+ ExportReason,
+ ISNULL(exportSummary.ExportedQuantity, 0) AS ExportedQuantity,
+ exportSummary.ExportedSummary,
+ exportSummary.RecipientSummary,
+ exportSummary.ProjectSummary,
+ CreatedBy,
+ CreatedDate,
+ UpdatedDate
+ FROM ConsumableInventory ci
+ OUTER APPLY (
+ SELECT
+ SUM(ISNULL(historyTotals.ExportQuantity, 0)) AS ExportedQuantity,
+ STUFF((
+ SELECT N', ' + grouped.DestinationName + N' - ' + CONVERT(NVARCHAR(20), grouped.TotalQuantity)
+ FROM (
+ SELECT
+ COALESCE(
+ NULLIF(LTRIM(RTRIM(ProjectName)), ''),
+ NULLIF(LTRIM(RTRIM(RecipientName)), ''),
+ N'Không rõ'
+ ) AS DestinationName,
+ SUM(ISNULL(ExportQuantity, 0)) AS TotalQuantity
+ FROM ConsumableExportHistory
+ WHERE ConsumableId = ci.ConsumableId
+ GROUP BY COALESCE(
+ NULLIF(LTRIM(RTRIM(ProjectName)), ''),
+ NULLIF(LTRIM(RTRIM(RecipientName)), ''),
+ N'Không rõ'
+ )
+ ) grouped
+ ORDER BY grouped.DestinationName
+ FOR XML PATH(''), TYPE
+ ).value('.', 'NVARCHAR(MAX)'), 1, 2, '') AS ExportedSummary
+ ,
+ STUFF((
+ SELECT N', ' + grouped.RecipientName + N' - ' + CONVERT(NVARCHAR(20), grouped.TotalQuantity)
+ FROM (
+ SELECT
+ NULLIF(LTRIM(RTRIM(RecipientName)), '') AS RecipientName,
+ SUM(ISNULL(ExportQuantity, 0)) AS TotalQuantity
+ FROM ConsumableExportHistory
+ WHERE ConsumableId = ci.ConsumableId
+ AND NULLIF(LTRIM(RTRIM(RecipientName)), '') IS NOT NULL
+ GROUP BY NULLIF(LTRIM(RTRIM(RecipientName)), '')
+ ) grouped
+ ORDER BY grouped.RecipientName
+ FOR XML PATH(''), TYPE
+ ).value('.', 'NVARCHAR(MAX)'), 1, 2, '') AS RecipientSummary,
+ STUFF((
+ SELECT N', ' + grouped.ProjectName + N' - ' + CONVERT(NVARCHAR(20), grouped.TotalQuantity)
+ FROM (
+ SELECT
+ NULLIF(LTRIM(RTRIM(ProjectName)), '') AS ProjectName,
+ SUM(ISNULL(ExportQuantity, 0)) AS TotalQuantity
+ FROM ConsumableExportHistory
+ WHERE ConsumableId = ci.ConsumableId
+ AND NULLIF(LTRIM(RTRIM(ProjectName)), '') IS NOT NULL
+ GROUP BY NULLIF(LTRIM(RTRIM(ProjectName)), '')
+ ) grouped
+ ORDER BY grouped.ProjectName
+ FOR XML PATH(''), TYPE
+ ).value('.', 'NVARCHAR(MAX)'), 1, 2, '') AS ProjectSummary
+ FROM ConsumableExportHistory historyTotals
+ WHERE historyTotals.ConsumableId = ci.ConsumableId
+ ) exportSummary
+ ORDER BY UpdatedDate DESC, ConsumableName ASC
+ `);
+
+ res.json({ success: true, data: result.recordset });
+ } catch (err) {
+ res.status(500).json({ success: false, message: err.message });
+ }
+});
+
+app.post('/api/consumables', requireAssetOrAdmin, async (req, res) => {
+ try {
+ const payload = normalizeConsumablePayload(req.body);
+ const createdBy = getUserIdFromRequest(req);
+
+ if (!payload.consumableName) {
+ return res.status(400).json({ success: false, message: 'Consumable name is required' });
+ }
+
+ if (!payload.consumableCode) {
+ payload.consumableCode = await generateUniqueManualConsumableCode(payload);
+ }
+
+ const result = await pool.request()
+ .input('requestMonth', sql.NVarChar, payload.requestMonth)
+ .input('consumableCode', sql.NVarChar, payload.consumableCode)
+ .input('consumableName', sql.NVarChar, payload.consumableName)
+ .input('model', sql.NVarChar, payload.model)
+ .input('unit', sql.NVarChar, payload.unit)
+ .input('openingBalance', sql.Int, payload.openingBalance)
+ .input('importInPeriod', sql.Int, payload.importInPeriod)
+ .input('exportInPeriod', sql.Int, payload.exportInPeriod)
+ .input('endingBalance', sql.Int, payload.endingBalance)
+ .input('exportReason', sql.NVarChar, payload.exportReason)
+ .input('createdBy', sql.Int, createdBy)
+ .query(`
+ INSERT INTO ConsumableInventory (
+ RequestMonth, ConsumableCode, ConsumableName, Model, Unit,
+ OpeningBalance, ImportInPeriod, ExportInPeriod, EndingBalance,
+ ExportReason, CreatedBy
+ ) VALUES (
+ @requestMonth, @consumableCode, @consumableName, @model, @unit,
+ @openingBalance, @importInPeriod, @exportInPeriod, @endingBalance,
+ @exportReason, @createdBy
+ );
+ SELECT SCOPE_IDENTITY() AS ConsumableId;
+ `);
+
+ res.json({
+ success: true,
+ message: 'Consumable created',
+ consumableId: result.recordset[0].ConsumableId
+ });
+ } catch (err) {
+ if (String(err.message || '').includes('UNIQUE')) {
+ return res.status(409).json({ success: false, message: 'Consumable code already exists' });
+ }
+
+ res.status(500).json({ success: false, message: err.message });
+ }
+});
+
+app.put('/api/consumables/:id', requireAssetOrAdmin, async (req, res) => {
+ try {
+ const consumableId = Number(req.params.id);
+ const payload = normalizeConsumablePayload(req.body);
+
+ if (!Number.isInteger(consumableId) || consumableId <= 0) {
+ return res.status(400).json({ success: false, message: 'Consumable id is invalid' });
+ }
+
+ if (!payload.consumableName) {
+ return res.status(400).json({ success: false, message: 'Consumable name is required' });
+ }
+
+ if (!payload.consumableCode) {
+ payload.consumableCode = generateManualConsumableCode(payload);
+ }
+
+ const result = await pool.request()
+ .input('consumableId', sql.Int, consumableId)
+ .input('requestMonth', sql.NVarChar, payload.requestMonth)
+ .input('consumableCode', sql.NVarChar, payload.consumableCode)
+ .input('consumableName', sql.NVarChar, payload.consumableName)
+ .input('model', sql.NVarChar, payload.model)
+ .input('unit', sql.NVarChar, payload.unit)
+ .input('openingBalance', sql.Int, payload.openingBalance)
+ .input('importInPeriod', sql.Int, payload.importInPeriod)
+ .input('exportInPeriod', sql.Int, payload.exportInPeriod)
+ .input('endingBalance', sql.Int, payload.endingBalance)
+ .input('exportReason', sql.NVarChar, payload.exportReason)
+ .query(`
+ UPDATE ConsumableInventory
+ SET RequestMonth = @requestMonth,
+ ConsumableCode = @consumableCode,
+ ConsumableName = @consumableName,
+ Model = @model,
+ Unit = @unit,
+ OpeningBalance = @openingBalance,
+ ImportInPeriod = @importInPeriod,
+ ExportInPeriod = @exportInPeriod,
+ EndingBalance = @endingBalance,
+ ExportReason = @exportReason,
+ UpdatedDate = DATEADD(HOUR, 7, SYSUTCDATETIME())
+ OUTPUT INSERTED.ConsumableId
+ WHERE ConsumableId = @consumableId
+ `);
+
+ if (!result.recordset?.length) {
+ return res.status(404).json({ success: false, message: 'Consumable not found' });
+ }
+
+ res.json({ success: true, message: 'Consumable updated' });
+ } catch (err) {
+ if (String(err.message || '').includes('UNIQUE')) {
+ return res.status(409).json({ success: false, message: 'Consumable code already exists' });
+ }
+
+ res.status(500).json({ success: false, message: err.message });
+ }
+});
+
+app.delete('/api/consumables/:id', requireAssetOrAdmin, async (req, res) => {
+ try {
+ const consumableId = Number(req.params.id);
+ if (!Number.isInteger(consumableId) || consumableId <= 0) {
+ return res.status(400).json({ success: false, message: 'Consumable id is invalid' });
+ }
+
+ const result = await pool.request()
+ .input('consumableId', sql.Int, consumableId)
+ .query('DELETE FROM ConsumableInventory OUTPUT DELETED.ConsumableId WHERE ConsumableId = @consumableId');
+
+ if (!result.recordset?.length) {
+ return res.status(404).json({ success: false, message: 'Consumable not found' });
+ }
+
+ res.json({ success: true, message: 'Consumable deleted' });
+ } catch (err) {
+ res.status(500).json({ success: false, message: err.message });
+ }
+});
+
+app.get('/api/consumable-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,
+ ConsumableId,
+ ConsumableCode,
+ ConsumableName,
+ Unit,
+ ExportQuantity,
+ RecipientName,
+ ProjectName,
+ ExportedByName,
+ ExportNote,
+ PreviousExportInPeriod,
+ NextExportInPeriod,
+ PreviousEndingBalance,
+ NextEndingBalance,
+ CreatedBy,
+ ExportedDate,
+ CreatedDate,
+ UpdatedDate
+ FROM ConsumableExportHistory
+ 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/consumables/:id/export', requireAssetOrAdmin, async (req, res) => {
+ let transaction;
+
+ try {
+ const consumableId = Number(req.params.id);
+ const exportQuantity = parseNonNegativeInteger(req.body?.quantity, 0);
+ const rawTargetType = String(req.body?.targetType || req.body?.exportTargetType || '').trim().toLowerCase();
+ const targetType = ['project', 'du_an', 'du-an'].includes(rawTargetType) ? 'project' : 'user';
+ const recipientName = String(req.body?.recipientName || req.body?.userName || req.body?.exportedTo || '').trim();
+ const projectName = String(req.body?.projectName || req.body?.project || '').trim();
+ 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(consumableId) || consumableId <= 0) {
+ return res.status(400).json({ success: false, message: 'Consumable id is invalid' });
+ }
+
+ if (exportQuantity <= 0) {
+ return res.status(400).json({ success: false, message: 'So luong xuat phai lon hon 0' });
+ }
+
+ if (targetType === 'user' && !recipientName) {
+ return res.status(400).json({ success: false, message: 'Nguoi nhan la bat buoc' });
+ }
+
+ if (targetType === 'project' && !projectName) {
+ return res.status(400).json({ success: false, message: 'Du an nhan la bat buoc' });
+ }
+
+ transaction = new sql.Transaction(pool);
+ await transaction.begin();
+
+ const consumableResult = await new sql.Request(transaction)
+ .input('consumableId', sql.Int, consumableId)
+ .query(`
+ SELECT TOP 1
+ ConsumableId,
+ ConsumableCode,
+ ConsumableName,
+ Unit,
+ OpeningBalance,
+ ImportInPeriod,
+ ExportInPeriod,
+ EndingBalance
+ FROM ConsumableInventory WITH (UPDLOCK, ROWLOCK)
+ WHERE ConsumableId = @consumableId
+ `);
+
+ const consumable = consumableResult.recordset?.[0];
+ if (!consumable) {
+ await transaction.rollback();
+ return res.status(404).json({ success: false, message: 'Consumable not found' });
+ }
+
+ const openingBalance = parseNonNegativeInteger(consumable.OpeningBalance, 0);
+ const importInPeriod = parseNonNegativeInteger(consumable.ImportInPeriod, 0);
+ const previousExportInPeriod = parseNonNegativeInteger(consumable.ExportInPeriod, 0);
+ const storedEndingBalance = parseOptionalNonNegativeInteger(consumable.EndingBalance);
+ const previousEndingBalance = storedEndingBalance !== null
+ ? storedEndingBalance
+ : Math.max(openingBalance + importInPeriod - previousExportInPeriod, 0);
+
+ if (previousEndingBalance <= 0) {
+ await transaction.rollback();
+ return res.status(400).json({ success: false, message: 'Vat tu da het ton cuoi ky, khong the xuat them' });
+ }
+
+ if (exportQuantity > previousEndingBalance) {
+ await transaction.rollback();
+ return res.status(400).json({
+ success: false,
+ message: `So luong xuat (${exportQuantity}) vuot qua ton cuoi ky (${previousEndingBalance})`
+ });
+ }
+
+ const nextExportInPeriod = previousExportInPeriod + exportQuantity;
+ const nextEndingBalance = Math.max(previousEndingBalance - exportQuantity, 0);
+
+ await new sql.Request(transaction)
+ .input('consumableId', sql.Int, consumableId)
+ .input('exportInPeriod', sql.Int, nextExportInPeriod)
+ .input('endingBalance', sql.Int, nextEndingBalance)
+ .query(`
+ UPDATE ConsumableInventory
+ SET ExportInPeriod = @exportInPeriod,
+ EndingBalance = @endingBalance,
+ UpdatedDate = DATEADD(HOUR, 7, SYSUTCDATETIME())
+ WHERE ConsumableId = @consumableId
+ `);
+
+ const historyResult = await new sql.Request(transaction)
+ .input('consumableId', sql.Int, consumableId)
+ .input('consumableCode', sql.NVarChar, String(consumable.ConsumableCode || '').trim())
+ .input('consumableName', sql.NVarChar, String(consumable.ConsumableName || '').trim())
+ .input('unit', sql.NVarChar, String(consumable.Unit || '').trim() || null)
+ .input('exportQuantity', sql.Int, exportQuantity)
+ .input('recipientName', sql.NVarChar, recipientName || null)
+ .input('projectName', sql.NVarChar, targetType === 'project' ? projectName : null)
+ .input('exportedByName', sql.NVarChar, exportedByName)
+ .input('exportNote', sql.NVarChar, exportNote)
+ .input('previousExportInPeriod', sql.Int, previousExportInPeriod)
+ .input('nextExportInPeriod', sql.Int, nextExportInPeriod)
+ .input('previousEndingBalance', sql.Int, previousEndingBalance)
+ .input('nextEndingBalance', sql.Int, nextEndingBalance)
+ .input('createdBy', sql.Int, createdBy)
+ .input('exportedDate', sql.DateTime, exportedDate)
+ .query(`
+ INSERT INTO ConsumableExportHistory (
+ ConsumableId,
+ ConsumableCode,
+ ConsumableName,
+ Unit,
+ ExportQuantity,
+ RecipientName,
+ ProjectName,
+ ExportedByName,
+ ExportNote,
+ PreviousExportInPeriod,
+ NextExportInPeriod,
+ PreviousEndingBalance,
+ NextEndingBalance,
+ CreatedBy,
+ ExportedDate
+ )
+ OUTPUT
+ INSERTED.ExportHistoryId,
+ INSERTED.ConsumableId,
+ INSERTED.ConsumableCode,
+ INSERTED.ConsumableName,
+ INSERTED.Unit,
+ INSERTED.ExportQuantity,
+ INSERTED.RecipientName,
+ INSERTED.ProjectName,
+ INSERTED.ExportedByName,
+ INSERTED.ExportNote,
+ INSERTED.PreviousExportInPeriod,
+ INSERTED.NextExportInPeriod,
+ INSERTED.PreviousEndingBalance,
+ INSERTED.NextEndingBalance,
+ INSERTED.CreatedBy,
+ INSERTED.ExportedDate,
+ INSERTED.CreatedDate,
+ INSERTED.UpdatedDate
+ VALUES (
+ @consumableId,
+ @consumableCode,
+ @consumableName,
+ @unit,
+ @exportQuantity,
+ @recipientName,
+ @projectName,
+ @exportedByName,
+ @exportNote,
+ @previousExportInPeriod,
+ @nextExportInPeriod,
+ @previousEndingBalance,
+ @nextEndingBalance,
+ @createdBy,
+ @exportedDate
+ )
+ `);
+
+ await transaction.commit();
+
+ res.json({
+ success: true,
+ message: 'Xuat vat tu tieu hao 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/consumables/import', requireAssetOrAdmin, upload.single('file'), async (req, res) => {
+ let incomingRows = [];
+ let source = 'rows';
+ let parseDiagnostics = [];
+
+ try {
+ if (req.file?.buffer) {
+ const workbook = XLSX.read(req.file.buffer, { type: 'buffer' });
+ if (!workbook.SheetNames?.length) {
+ return res.status(400).json({ success: false, message: 'Excel file does not contain a worksheet' });
+ }
+
+ const parsed = parseConsumableImportRowsFromWorkbook(workbook);
+ incomingRows = parsed.rows;
+ parseDiagnostics = parsed.diagnostics;
+ source = parsed.sheetName ? `file:${parsed.sheetName}` : 'file';
+ } else {
+ incomingRows = Array.isArray(req.body?.rows) ? req.body.rows : [];
+ }
+ } catch (err) {
+ return res.status(400).json({ success: false, message: `Cannot parse import file: ${err.message}` });
+ }
+
+ if (!incomingRows.length) {
+ return res.status(400).json({
+ success: false,
+ message: req.file
+ ? 'Khong tim thay dong vat tu tieu hao hop le trong file Excel.'
+ : 'Import data is empty',
+ diagnostics: req.file ? parseDiagnostics : undefined
+ });
+ }
+
+ const createdBy = getUserIdFromRequest(req);
+ const normalizedRows = incomingRows
+ .map((row, rowIndex) => {
+ const normalized = normalizeConsumablePayload(row);
+ if (!normalized.consumableCode && normalized.consumableName) {
+ normalized.consumableCode = generateImportConsumableCodeFromRow(row, rowIndex + 1);
+ }
+ return normalized;
+ })
+ .filter(row => !isHeaderLikeConsumableImportRow(row))
+ .filter(row => isMeaningfulImportedConsumableRow(row))
+ .filter(row => row.consumableCode && row.consumableName);
+
+ if (!normalizedRows.length) {
+ return res.status(400).json({ success: false, message: 'No valid consumable rows found in import data.' });
+ }
+
+ const transaction = new sql.Transaction(pool);
+ let inserted = 0;
+ let updated = 0;
+
+ try {
+ await transaction.begin();
+
+ for (const row of normalizedRows) {
+ const mergeResult = await new sql.Request(transaction)
+ .input('requestMonth', sql.NVarChar, row.requestMonth)
+ .input('consumableCode', sql.NVarChar, row.consumableCode)
+ .input('consumableName', sql.NVarChar, row.consumableName)
+ .input('model', sql.NVarChar, row.model)
+ .input('unit', sql.NVarChar, row.unit)
+ .input('openingBalance', sql.Int, row.openingBalance)
+ .input('importInPeriod', sql.Int, row.importInPeriod)
+ .input('exportInPeriod', sql.Int, row.exportInPeriod)
+ .input('endingBalance', sql.Int, row.endingBalance)
+ .input('exportReason', sql.NVarChar, row.exportReason)
+ .input('createdBy', sql.Int, createdBy)
+ .query(`
+ MERGE ConsumableInventory AS target
+ USING (SELECT @consumableCode AS ConsumableCode) AS source
+ ON target.ConsumableCode = source.ConsumableCode
+ WHEN MATCHED THEN
+ UPDATE SET
+ RequestMonth = @requestMonth,
+ ConsumableName = @consumableName,
+ Model = @model,
+ Unit = @unit,
+ OpeningBalance = @openingBalance,
+ ImportInPeriod = @importInPeriod,
+ ExportInPeriod = @exportInPeriod,
+ EndingBalance = @endingBalance,
+ ExportReason = @exportReason,
+ UpdatedDate = DATEADD(HOUR, 7, SYSUTCDATETIME())
+ WHEN NOT MATCHED THEN
+ INSERT (
+ RequestMonth, ConsumableCode, ConsumableName, Model, Unit,
+ OpeningBalance, ImportInPeriod, ExportInPeriod, EndingBalance,
+ ExportReason, CreatedBy
+ )
+ VALUES (
+ @requestMonth, @consumableCode, @consumableName, @model, @unit,
+ @openingBalance, @importInPeriod, @exportInPeriod, @endingBalance,
+ @exportReason, @createdBy
+ )
+ OUTPUT $action AS MergeAction;
+ `);
+
+ const mergeAction = String(mergeResult.recordset?.[0]?.MergeAction || '').toUpperCase();
+ if (mergeAction === 'INSERT') inserted += 1;
+ if (mergeAction === 'UPDATE') updated += 1;
+ }
+
+ await transaction.commit();
+
+ res.json({
+ success: true,
+ message: `Import completed. Inserted: ${inserted}, Updated: ${updated}`,
+ data: {
+ source,
+ totalReceived: incomingRows.length,
+ processed: normalizedRows.length,
+ inserted,
+ updated
+ }
+ });
+ } catch (err) {
+ try {
+ await transaction.rollback();
+ } catch (rollbackErr) {
+ // Ignore rollback errors if transaction is already completed.
+ }
+ res.status(500).json({ success: false, message: err.message });
+ }
+});
+
app.get('/api/assets', async (req, res) => {
try {
const result = await pool.request().query(`
@@ -6074,6 +7213,7 @@ app.get('/api/database/info', async (req, res) => {
const apps = await pool.request().query('SELECT COUNT(*) as Count FROM Applications');
const accounts = await pool.request().query('SELECT COUNT(*) as Count FROM Accounts');
const assets = await pool.request().query('SELECT COUNT(*) as Count FROM AssetInventory');
+ const consumables = await pool.request().query('SELECT COUNT(*) as Count FROM ConsumableInventory');
res.json({
success: true,
@@ -6084,7 +7224,8 @@ app.get('/api/database/info', async (req, res) => {
users: users.recordset[0].Count,
applications: apps.recordset[0].Count,
accounts: accounts.recordset[0].Count,
- assets: assets.recordset[0].Count
+ assets: assets.recordset[0].Count,
+ consumables: consumables.recordset[0].Count
}
});
} catch (err) {
diff --git a/database/setup.sql b/database/setup.sql
index 48c0062..43c584e 100644
--- a/database/setup.sql
+++ b/database/setup.sql
@@ -168,7 +168,214 @@ SET NewQuantity = CASE
END;
-- ===========================================
--- 5. CREATE ASSET DEPARTMENTS TABLE
+-- 5. CREATE CONSUMABLE INVENTORY TABLE
+-- ===========================================
+IF NOT EXISTS (SELECT * FROM sys.tables WHERE name = 'ConsumableInventory')
+BEGIN
+ CREATE TABLE ConsumableInventory (
+ ConsumableId INT PRIMARY KEY IDENTITY(1,1),
+ RequestMonth NVARCHAR(50) NULL,
+ ConsumableCode NVARCHAR(100) NOT NULL UNIQUE,
+ ConsumableName NVARCHAR(255) NOT NULL,
+ Model NVARCHAR(255) NULL,
+ Unit NVARCHAR(50) NULL,
+ OpeningBalance INT NOT NULL DEFAULT 0,
+ ImportInPeriod INT NOT NULL DEFAULT 0,
+ ExportInPeriod INT NOT NULL DEFAULT 0,
+ EndingBalance INT NOT NULL DEFAULT 0,
+ ExportReason NVARCHAR(1000) NULL,
+ CreatedBy INT NULL,
+ CreatedDate DATETIME DEFAULT (DATEADD(HOUR, 7, SYSUTCDATETIME())),
+ UpdatedDate DATETIME DEFAULT (DATEADD(HOUR, 7, SYSUTCDATETIME())),
+ FOREIGN KEY (CreatedBy) REFERENCES Users(UserId) ON DELETE SET NULL
+ );
+ PRINT 'Table ConsumableInventory created successfully.';
+END
+
+IF COL_LENGTH('dbo.ConsumableInventory', 'RequestMonth') IS NULL
+BEGIN
+ ALTER TABLE ConsumableInventory ADD RequestMonth NVARCHAR(50) NULL;
+END
+
+IF COL_LENGTH('dbo.ConsumableInventory', 'Model') IS NULL
+BEGIN
+ ALTER TABLE ConsumableInventory ADD Model NVARCHAR(255) NULL;
+END
+
+IF COL_LENGTH('dbo.ConsumableInventory', 'Unit') IS NULL
+BEGIN
+ ALTER TABLE ConsumableInventory ADD Unit NVARCHAR(50) NULL;
+END
+
+IF COL_LENGTH('dbo.ConsumableInventory', 'OpeningBalance') IS NULL
+BEGIN
+ ALTER TABLE ConsumableInventory ADD OpeningBalance INT NOT NULL CONSTRAINT DF_ConsumableInventory_OpeningBalance DEFAULT(0);
+END
+
+IF COL_LENGTH('dbo.ConsumableInventory', 'ImportInPeriod') IS NULL
+BEGIN
+ ALTER TABLE ConsumableInventory ADD ImportInPeriod INT NOT NULL CONSTRAINT DF_ConsumableInventory_ImportInPeriod DEFAULT(0);
+END
+
+IF COL_LENGTH('dbo.ConsumableInventory', 'ExportInPeriod') IS NULL
+BEGIN
+ ALTER TABLE ConsumableInventory ADD ExportInPeriod INT NOT NULL CONSTRAINT DF_ConsumableInventory_ExportInPeriod DEFAULT(0);
+END
+
+IF COL_LENGTH('dbo.ConsumableInventory', 'EndingBalance') IS NULL
+BEGIN
+ ALTER TABLE ConsumableInventory ADD EndingBalance INT NOT NULL CONSTRAINT DF_ConsumableInventory_EndingBalance DEFAULT(0);
+END
+
+IF COL_LENGTH('dbo.ConsumableInventory', 'ExportReason') IS NULL
+BEGIN
+ ALTER TABLE ConsumableInventory ADD ExportReason NVARCHAR(1000) NULL;
+END
+
+-- ===========================================
+-- 6. CREATE CONSUMABLE EXPORT HISTORY TABLE
+-- ===========================================
+IF NOT EXISTS (SELECT * FROM sys.tables WHERE name = 'ConsumableExportHistory')
+BEGIN
+ CREATE TABLE ConsumableExportHistory (
+ ExportHistoryId INT PRIMARY KEY IDENTITY(1,1),
+ ConsumableId INT NOT NULL,
+ ConsumableCode NVARCHAR(100) NOT NULL,
+ ConsumableName NVARCHAR(255) NOT NULL,
+ Unit NVARCHAR(50) NULL,
+ ExportQuantity INT NOT NULL DEFAULT 1,
+ RecipientName NVARCHAR(100) NOT NULL,
+ ProjectName NVARCHAR(150) NULL,
+ ExportedByName NVARCHAR(100) NOT NULL,
+ ExportNote NVARCHAR(1000) NULL,
+ PreviousExportInPeriod INT NOT NULL DEFAULT 0,
+ NextExportInPeriod INT NOT NULL DEFAULT 0,
+ PreviousEndingBalance INT NOT NULL DEFAULT 0,
+ NextEndingBalance INT NOT NULL DEFAULT 0,
+ CreatedBy INT NULL,
+ ExportedDate 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 (ConsumableId) REFERENCES ConsumableInventory(ConsumableId) ON DELETE CASCADE,
+ FOREIGN KEY (CreatedBy) REFERENCES Users(UserId) ON DELETE SET NULL
+ );
+ PRINT 'Table ConsumableExportHistory created successfully.';
+END
+
+IF COL_LENGTH('dbo.ConsumableExportHistory', 'ConsumableCode') IS NULL
+BEGIN
+ ALTER TABLE ConsumableExportHistory ADD ConsumableCode NVARCHAR(100) NULL;
+END
+
+IF COL_LENGTH('dbo.ConsumableExportHistory', 'ConsumableName') IS NULL
+BEGIN
+ ALTER TABLE ConsumableExportHistory ADD ConsumableName NVARCHAR(255) NULL;
+END
+
+IF COL_LENGTH('dbo.ConsumableExportHistory', 'Unit') IS NULL
+BEGIN
+ ALTER TABLE ConsumableExportHistory ADD Unit NVARCHAR(50) NULL;
+END
+
+IF COL_LENGTH('dbo.ConsumableExportHistory', 'ExportQuantity') IS NULL
+BEGIN
+ ALTER TABLE ConsumableExportHistory ADD ExportQuantity INT NOT NULL CONSTRAINT DF_ConsumableExportHistory_ExportQuantity DEFAULT(1);
+END
+
+IF COL_LENGTH('dbo.ConsumableExportHistory', 'RecipientName') IS NULL
+BEGIN
+ ALTER TABLE ConsumableExportHistory ADD RecipientName NVARCHAR(100) NULL;
+END
+
+IF COL_LENGTH('dbo.ConsumableExportHistory', 'ProjectName') IS NULL
+BEGIN
+ ALTER TABLE ConsumableExportHistory ADD ProjectName NVARCHAR(150) NULL;
+END
+
+IF COL_LENGTH('dbo.ConsumableExportHistory', 'ExportedByName') IS NULL
+BEGIN
+ ALTER TABLE ConsumableExportHistory ADD ExportedByName NVARCHAR(100) NULL;
+END
+
+IF COL_LENGTH('dbo.ConsumableExportHistory', 'ExportNote') IS NULL
+BEGIN
+ ALTER TABLE ConsumableExportHistory ADD ExportNote NVARCHAR(1000) NULL;
+END
+
+IF COL_LENGTH('dbo.ConsumableExportHistory', 'PreviousExportInPeriod') IS NULL
+BEGIN
+ ALTER TABLE ConsumableExportHistory ADD PreviousExportInPeriod INT NOT NULL CONSTRAINT DF_ConsumableExportHistory_PreviousExportInPeriod DEFAULT(0);
+END
+
+IF COL_LENGTH('dbo.ConsumableExportHistory', 'NextExportInPeriod') IS NULL
+BEGIN
+ ALTER TABLE ConsumableExportHistory ADD NextExportInPeriod INT NOT NULL CONSTRAINT DF_ConsumableExportHistory_NextExportInPeriod DEFAULT(0);
+END
+
+IF COL_LENGTH('dbo.ConsumableExportHistory', 'PreviousEndingBalance') IS NULL
+BEGIN
+ ALTER TABLE ConsumableExportHistory ADD PreviousEndingBalance INT NOT NULL CONSTRAINT DF_ConsumableExportHistory_PreviousEndingBalance DEFAULT(0);
+END
+
+IF COL_LENGTH('dbo.ConsumableExportHistory', 'NextEndingBalance') IS NULL
+BEGIN
+ ALTER TABLE ConsumableExportHistory ADD NextEndingBalance INT NOT NULL CONSTRAINT DF_ConsumableExportHistory_NextEndingBalance DEFAULT(0);
+END
+
+IF COL_LENGTH('dbo.ConsumableExportHistory', 'CreatedBy') IS NULL
+BEGIN
+ ALTER TABLE ConsumableExportHistory ADD CreatedBy INT NULL;
+END
+
+IF COL_LENGTH('dbo.ConsumableExportHistory', 'ExportedDate') IS NULL
+BEGIN
+ ALTER TABLE ConsumableExportHistory ADD ExportedDate DATETIME NOT NULL CONSTRAINT DF_ConsumableExportHistory_ExportedDate DEFAULT(DATEADD(HOUR, 7, SYSUTCDATETIME()));
+END
+
+IF COL_LENGTH('dbo.ConsumableExportHistory', 'CreatedDate') IS NULL
+BEGIN
+ ALTER TABLE ConsumableExportHistory ADD CreatedDate DATETIME NOT NULL CONSTRAINT DF_ConsumableExportHistory_CreatedDate DEFAULT(DATEADD(HOUR, 7, SYSUTCDATETIME()));
+END
+
+IF COL_LENGTH('dbo.ConsumableExportHistory', 'UpdatedDate') IS NULL
+BEGIN
+ ALTER TABLE ConsumableExportHistory ADD UpdatedDate DATETIME NOT NULL CONSTRAINT DF_ConsumableExportHistory_UpdatedDate DEFAULT(DATEADD(HOUR, 7, SYSUTCDATETIME()));
+END
+
+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.ConsumableExportHistory')
+ AND c.name = 'ConsumableId'
+)
+AND COL_LENGTH('dbo.ConsumableExportHistory', 'ConsumableId') IS NOT NULL
+BEGIN
+ ALTER TABLE ConsumableExportHistory
+ ADD CONSTRAINT FK_ConsumableExportHistory_ConsumableId
+ FOREIGN KEY (ConsumableId) REFERENCES ConsumableInventory(ConsumableId) ON DELETE CASCADE;
+END
+
+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.ConsumableExportHistory')
+ AND c.name = 'CreatedBy'
+)
+AND COL_LENGTH('dbo.ConsumableExportHistory', 'CreatedBy') IS NOT NULL
+BEGIN
+ ALTER TABLE ConsumableExportHistory
+ ADD CONSTRAINT FK_ConsumableExportHistory_CreatedBy
+ FOREIGN KEY (CreatedBy) REFERENCES Users(UserId) ON DELETE SET NULL;
+END
+
+-- ===========================================
+-- 7. CREATE ASSET DEPARTMENTS TABLE
-- ===========================================
IF NOT EXISTS (SELECT * FROM sys.tables WHERE name = 'AssetDepartments')
BEGIN
@@ -197,7 +404,7 @@ WHERE NOT EXISTS (
);
-- ===========================================
--- 6. CREATE ASSET PROJECTS TABLE
+-- 8. CREATE ASSET PROJECTS TABLE
-- ===========================================
IF NOT EXISTS (SELECT * FROM sys.tables WHERE name = 'AssetProjects')
BEGIN
@@ -211,7 +418,7 @@ BEGIN
END
-- ===========================================
--- 7. CREATE ASSET BORROW REQUESTS TABLE
+-- 9. CREATE ASSET BORROW REQUESTS TABLE
-- ===========================================
IF NOT EXISTS (SELECT * FROM sys.tables WHERE name = 'AssetBorrowRequests')
BEGIN
@@ -386,7 +593,7 @@ BEGIN
END
-- ===========================================
--- 8. CREATE ASSET EXPORT HISTORY TABLE
+-- 10. CREATE ASSET EXPORT HISTORY TABLE
-- ===========================================
IF NOT EXISTS (SELECT * FROM sys.tables WHERE name = 'AssetExportHistory')
BEGIN
@@ -484,7 +691,7 @@ BEGIN
END
-- ===========================================
--- 9. CREATE ASSET DAMAGE/DISPOSAL HISTORY TABLE
+-- 11. CREATE ASSET DAMAGE/DISPOSAL HISTORY TABLE
-- ===========================================
IF NOT EXISTS (SELECT * FROM sys.tables WHERE name = 'AssetDamageDisposalHistory')
BEGIN
@@ -522,7 +729,7 @@ BEGIN
END
-- ===========================================
--- 10. CREATE AUDIT LOG TABLE
+-- 12. CREATE AUDIT LOG TABLE
-- ===========================================
IF NOT EXISTS (SELECT * FROM sys.tables WHERE name = 'AuditLog')
BEGIN
@@ -541,7 +748,7 @@ BEGIN
END
-- ===========================================
--- 11. CREATE INDEXES
+-- 13. CREATE INDEXES
-- ===========================================
IF NOT EXISTS (SELECT * FROM sys.indexes WHERE name = 'IX_Users_Username')
BEGIN
@@ -573,6 +780,31 @@ BEGIN
CREATE INDEX IX_AssetInventory_Department ON AssetInventory(Department);
END
+IF NOT EXISTS (SELECT * FROM sys.indexes WHERE name = 'IX_ConsumableInventory_ConsumableCode')
+BEGIN
+ CREATE INDEX IX_ConsumableInventory_ConsumableCode ON ConsumableInventory(ConsumableCode);
+END
+
+IF NOT EXISTS (SELECT * FROM sys.indexes WHERE name = 'IX_ConsumableInventory_RequestMonth')
+BEGIN
+ CREATE INDEX IX_ConsumableInventory_RequestMonth ON ConsumableInventory(RequestMonth);
+END
+
+IF NOT EXISTS (SELECT * FROM sys.indexes WHERE name = 'IX_ConsumableInventory_EndingBalance')
+BEGIN
+ CREATE INDEX IX_ConsumableInventory_EndingBalance ON ConsumableInventory(EndingBalance);
+END
+
+IF NOT EXISTS (SELECT * FROM sys.indexes WHERE name = 'IX_ConsumableExportHistory_ConsumableId')
+BEGIN
+ CREATE INDEX IX_ConsumableExportHistory_ConsumableId ON ConsumableExportHistory(ConsumableId);
+END
+
+IF NOT EXISTS (SELECT * FROM sys.indexes WHERE name = 'IX_ConsumableExportHistory_ExportedDate')
+BEGIN
+ CREATE INDEX IX_ConsumableExportHistory_ExportedDate ON ConsumableExportHistory(ExportedDate DESC);
+END
+
IF NOT EXISTS (SELECT * FROM sys.indexes WHERE name = 'UX_AssetDepartments_DepartmentName')
BEGIN
CREATE UNIQUE INDEX UX_AssetDepartments_DepartmentName ON AssetDepartments(DepartmentName);
diff --git a/public/js/app.js b/public/js/app.js
index 696a12a..443e303 100644
--- a/public/js/app.js
+++ b/public/js/app.js
@@ -43,6 +43,7 @@ class AccountManager {
this.applications = [];
this.users = [];
this.assets = [];
+ this.consumables = [];
this.roles = [];
this.accountPage = 1;
this.accountPageSize = 9;
@@ -52,6 +53,10 @@ class AccountManager {
this.userPageSize = 9;
this.assetPage = 1;
this.assetPageSize = 10;
+ this.consumablePage = 1;
+ this.consumablePageSize = 10;
+ this.consumableExportPage = 1;
+ this.consumableExportPageSize = 10;
this.assetBorrowPage = 1;
this.assetBorrowPageSize = 10;
this.myBorrowedAssetPage = 1;
@@ -65,6 +70,13 @@ class AccountManager {
this.userRoleFilter = '';
this.assetSearchTerm = '';
this.assetStatusFilter = '';
+ this.consumableSearchTerm = '';
+ this.consumableMonthFilter = '';
+ this.consumableStatusFilter = '';
+ this.consumableExportSearchTerm = '';
+ this.consumableExportRecipientFilter = '';
+ this.consumableExportProjectFilter = '';
+ this.consumableExportDateFilter = '';
this.assetBorrows = [];
this.assetBorrowSearchTerm = '';
this.assetBorrowTypeFilter = '';
@@ -82,6 +94,7 @@ class AccountManager {
this.assetProjectSearchTerm = '';
this.assetExportHistories = [];
this.assetDamageHistories = [];
+ this.consumableExportHistories = [];
this.selectedAssetIds = new Set();
this.mobileBreakpoint = 900;
this.boundResizeHandler = null;
@@ -101,6 +114,9 @@ class AccountManager {
this.pendingAssetRequestDeleteConfirmResolver = undefined;
this.pendingBulkAssetDeleteConfirmResolver = undefined;
this.pendingAssetDamageId = undefined;
+ this.editingConsumableId = undefined;
+ this.pendingDeleteConsumableId = undefined;
+ this.pendingConsumableExportId = undefined;
}
configureNotifications() {
@@ -213,6 +229,7 @@ class AccountManager {
await this.fetchApplications();
await this.fetchAccounts();
await this.fetchAssets();
+ await this.fetchConsumables();
await this.fetchAssetBorrows();
await this.fetchAssetDepartments();
await this.fetchAssetProjects();
@@ -264,6 +281,16 @@ class AccountManager {
this.setupAddButtonListeners();
this.setupFilters();
this.setupAssetPagerListeners();
+ } else if (page === 'consumables') {
+ mainContent.innerHTML = this.getConsumablesContent();
+ this.setupConsumableRowListeners();
+ this.setupAddButtonListeners();
+ this.setupFilters();
+ this.setupConsumablePagerListeners();
+ } else if (page === 'consumable-exports') {
+ mainContent.innerHTML = this.getConsumableExportsContent();
+ this.setupConsumableExportHistoryListeners();
+ this.refreshConsumableExportsPage();
} else if (page === 'asset-borrows') {
mainContent.innerHTML = this.getAssetBorrowsContent();
this.setupAssetBorrowListeners();
@@ -423,6 +450,7 @@ class AccountManager {
this.users = data.data;
this.refreshAssetCustodianOptions(document.getElementById('assetCustodianInput')?.value || '');
this.refreshBorrowAssetUserOptions(document.getElementById('borrowAssetUserInput')?.value || '');
+ this.refreshConsumableExportUserOptions(document.getElementById('consumableExportUserInput')?.value || '');
} else {
console.error('Load users failed:', data.message);
}
@@ -507,6 +535,13 @@ class AccountManager {
});
}
+ refreshConsumableExportUserOptions(selectedValue = '') {
+ this.populateUserSelectOptions('consumableExportUserInput', {
+ selectedValue,
+ emptyLabel: '-- Chọn người nhận --'
+ });
+ }
+
refreshBorrowAssetProjectOptions(selectedValue = '') {
const select = document.getElementById('borrowAssetProjectInput');
if (!select) {
@@ -956,6 +991,87 @@ class AccountManager {
}
}
+ refreshConsumableExportProjectOptions(selectedValue = '') {
+ const select = document.getElementById('consumableExportProjectInput');
+ 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 = '';
+ }
+ }
+
+ setupConsumableExportTargetTypeListeners() {
+ const targetTypeInput = document.getElementById('consumableExportTargetTypeInput');
+ const recipientInput = document.getElementById('consumableExportUserInput');
+ const recipientLabel = document.getElementById('consumableExportRecipientLabel');
+ const projectInput = document.getElementById('consumableExportProjectInput');
+ const projectField = document.getElementById('consumableExportProjectField');
+ if (!targetTypeInput || !recipientInput || !projectInput || !projectField) {
+ return;
+ }
+
+ const syncTargetType = () => {
+ const isProject = String(targetTypeInput.value || '').trim() === 'project';
+ projectField.classList.toggle('hidden', !isProject);
+ if (isProject) {
+ projectInput.required = true;
+ projectInput.setAttribute('required', 'required');
+ recipientInput.required = false;
+ recipientInput.removeAttribute('required');
+ recipientInput.setAttribute('aria-required', 'false');
+ if (recipientLabel) {
+ recipientLabel.textContent = 'Người nhận (nếu có)';
+ }
+ } else {
+ projectInput.required = false;
+ projectInput.removeAttribute('required');
+ recipientInput.required = true;
+ recipientInput.setAttribute('required', 'required');
+ recipientInput.setAttribute('aria-required', 'true');
+ if (recipientLabel) {
+ recipientLabel.textContent = 'Người nhận';
+ }
+ projectInput.value = '';
+ }
+ };
+
+ if (targetTypeInput.dataset.boundChange !== 'true') {
+ targetTypeInput.addEventListener('change', syncTargetType);
+ targetTypeInput.dataset.boundChange = 'true';
+ }
+
+ syncTargetType();
+ }
+
async fetchAssets() {
try {
const res = await fetch(`${this.apiBase}/assets`);
@@ -978,6 +1094,20 @@ class AccountManager {
}
}
+ async fetchConsumables() {
+ try {
+ const res = await fetch(`${this.apiBase}/consumables`);
+ const data = await res.json();
+ if (data.success) {
+ this.consumables = Array.isArray(data.data) ? data.data : [];
+ } else {
+ console.error('Load consumables failed:', data.message);
+ }
+ } catch (err) {
+ console.error('Fetch consumables error:', err);
+ }
+ }
+
async fetchAssetBorrows() {
try {
const res = await fetch(`${this.apiBase}/asset-borrows`, {
@@ -1018,6 +1148,7 @@ class AccountManager {
this.assetProjects = Array.isArray(data.data) ? data.data : [];
this.refreshAssetProjectOptions(document.getElementById('assetProjectInput')?.value || '');
this.refreshBorrowAssetProjectOptions(document.getElementById('borrowAssetProjectInput')?.value || '');
+ this.refreshConsumableExportProjectOptions(document.getElementById('consumableExportProjectInput')?.value || '');
} else {
console.error('Load asset projects failed:', data.message);
}
@@ -1102,6 +1233,86 @@ class AccountManager {
this.renderAssetExportHistoryModal();
}
+ async fetchConsumableExportHistories(limit = 300) {
+ try {
+ const safeLimit = Number.isFinite(Number(limit)) ? Math.max(1, Math.min(Number(limit), 2000)) : 300;
+ const res = await fetch(`${this.apiBase}/consumable-export-history?limit=${safeLimit}`, {
+ headers: this.getAuthHeaders(false)
+ });
+ const data = await res.json();
+ if (data.success) {
+ this.consumableExportHistories = Array.isArray(data.data) ? data.data : [];
+ } else {
+ console.error('Load consumable export history failed:', data.message);
+ }
+ } catch (err) {
+ console.error('Fetch consumable export history error:', err);
+ }
+ }
+
+ buildConsumableExportHistoryRowsHtml(rows = []) {
+ if (!Array.isArray(rows) || rows.length === 0) {
+ return `
+
+ Chưa có dữ liệu lịch sử xuất vật tư.
+
+ `;
+ }
+
+ return rows.map(item => {
+ const consumableLabel = [String(item?.ConsumableCode || '').trim(), String(item?.ConsumableName || '').trim()]
+ .filter(Boolean)
+ .join(' - ') || '-';
+ const quantityLabel = `${Number(item?.ExportQuantity) || 0}${item?.Unit ? ` ${this.escapeHtml(item.Unit)}` : ''}`;
+ const balanceLabel = `${Number(item?.PreviousEndingBalance) || 0} -> ${Number(item?.NextEndingBalance) || 0}`;
+
+ return `
+
+ ${this.formatDateTime(item?.ExportedDate || item?.CreatedDate)}
+ ${this.escapeHtml(consumableLabel)}
+ ${quantityLabel}
+ ${this.escapeHtml(item?.RecipientName || '-')}
+ ${this.escapeHtml(item?.ProjectName || '-')}
+ ${this.escapeHtml(item?.ExportedByName || '-')}
+ ${this.escapeHtml(balanceLabel)}
+ ${this.escapeHtml(item?.ExportNote || '-')}
+
+ `;
+ }).join('');
+ }
+
+ renderConsumableExportHistoryModal() {
+ const tbody = document.getElementById('consumableExportHistoryTableBody');
+ if (!tbody) {
+ return;
+ }
+
+ tbody.innerHTML = this.buildConsumableExportHistoryRowsHtml(this.consumableExportHistories);
+ }
+
+ async openConsumableExportHistoryModal() {
+ if (!this.ensureAssetManagePermission('xem lich su xuat vat tu tieu hao')) {
+ return;
+ }
+
+ const modal = document.getElementById('consumableExportHistoryModal');
+ const tbody = document.getElementById('consumableExportHistoryTableBody');
+ if (!modal || !tbody) {
+ this.notifyFailure('Không tìm thấy biểu mẫu lịch sử xuất vật tư.');
+ return;
+ }
+
+ tbody.innerHTML = `
+
+ Đang tải lịch sử xuất...
+
+ `;
+ modal.classList.add('open');
+
+ await this.fetchConsumableExportHistories();
+ this.renderConsumableExportHistoryModal();
+ }
+
normalizeAssetDamageType(value) {
const normalized = String(value || '').trim().toLowerCase();
return normalized === 'disposed' || normalized === 'thanh_ly' || normalized === 'thanh ly'
@@ -1248,6 +1459,8 @@ class AccountManager {
this.refreshAssetDepartmentOptions(document.getElementById('assetDepartmentInput')?.value || '');
this.refreshAssetProjectOptions(document.getElementById('assetProjectInput')?.value || '');
this.refreshBorrowAssetProjectOptions(document.getElementById('borrowAssetProjectInput')?.value || '');
+ this.refreshConsumableExportProjectOptions(document.getElementById('consumableExportProjectInput')?.value || '');
+ this.refreshConsumableExportUserOptions(document.getElementById('consumableExportUserInput')?.value || '');
} catch (error) {
console.error('Lỗi load modals:', error);
}
@@ -1257,6 +1470,8 @@ class AccountManager {
const accountSearch = document.getElementById('accountSearch');
const appSearch = document.getElementById('appSearch');
const assetSearch = document.getElementById('assetSearch');
+ const consumableSearch = document.getElementById('consumableSearch');
+ const consumableExportSearch = document.getElementById('consumableExportSearch');
const assetBorrowSearch = document.getElementById('assetBorrowSearch');
const myBorrowedAssetSearch = document.getElementById('myBorrowedAssetSearch');
const assetDepartmentSearch = document.getElementById('assetDepartmentSearch');
@@ -1280,6 +1495,18 @@ class AccountManager {
assetSearch.setSelectionRange(pos, pos);
}
+ if (consumableSearch && consumableSearch.dataset.focused === 'true') {
+ const pos = consumableSearch.selectionStart || consumableSearch.value.length;
+ consumableSearch.focus();
+ consumableSearch.setSelectionRange(pos, pos);
+ }
+
+ if (consumableExportSearch && consumableExportSearch.dataset.focused === 'true') {
+ const pos = consumableExportSearch.selectionStart || consumableExportSearch.value.length;
+ consumableExportSearch.focus();
+ consumableExportSearch.setSelectionRange(pos, pos);
+ }
+
if (assetBorrowSearch && assetBorrowSearch.dataset.focused === 'true') {
const pos = assetBorrowSearch.selectionStart || assetBorrowSearch.value.length;
assetBorrowSearch.focus();
@@ -1374,6 +1601,35 @@ class AccountManager {
this.setupAssetFormValidationListeners();
}
+ const consumableForm = document.getElementById('consumableForm');
+ if (consumableForm) {
+ if (!consumableForm.dataset.boundSubmit) {
+ consumableForm.addEventListener('submit', (e) => this.handleConsumableSubmit(e));
+ consumableForm.dataset.boundSubmit = 'true';
+ }
+ this.setupConsumableStockListeners();
+ }
+
+ const consumableExportForm = document.getElementById('consumableExportForm');
+ if (consumableExportForm) {
+ if (!consumableExportForm.dataset.boundSubmit) {
+ consumableExportForm.addEventListener('submit', (e) => this.handleConsumableExportSubmit(e));
+ consumableExportForm.dataset.boundSubmit = 'true';
+ }
+ this.refreshConsumableExportUserOptions(document.getElementById('consumableExportUserInput')?.value || '');
+ this.refreshConsumableExportProjectOptions(document.getElementById('consumableExportProjectInput')?.value || '');
+ this.setupConsumableExportTargetTypeListeners();
+ }
+
+ document.querySelectorAll('.confirm-delete-consumable').forEach(btn => {
+ if (btn.dataset.boundClick === 'true') {
+ return;
+ }
+
+ btn.addEventListener('click', () => this.confirmDeleteConsumable());
+ btn.dataset.boundClick = 'true';
+ });
+
const borrowAssetForm = document.getElementById('borrowAssetForm');
if (borrowAssetForm) {
if (!borrowAssetForm.dataset.boundSubmit) {
@@ -1550,6 +1806,147 @@ class AccountManager {
});
}
+ getConsumableMonthOptions() {
+ const months = new Set();
+ (Array.isArray(this.consumables) ? this.consumables : []).forEach(item => {
+ const value = String(item?.RequestMonth || '').trim();
+ if (value) {
+ months.add(value);
+ }
+ });
+
+ return [...months].sort((a, b) => a.localeCompare(b, 'vi'));
+ }
+
+ getFilteredConsumables() {
+ const monthFilter = String(this.consumableMonthFilter || '').trim().toLowerCase();
+ const statusFilter = String(this.consumableStatusFilter || '').trim().toLowerCase();
+ const search = String(this.consumableSearchTerm || '').trim().toLowerCase();
+ const rows = Array.isArray(this.consumables) ? this.consumables : [];
+
+ return rows.filter(item => {
+ const matchesMonth = !monthFilter || String(item?.RequestMonth || '').trim().toLowerCase() === monthFilter;
+ if (!matchesMonth) {
+ return false;
+ }
+
+ const statusMeta = this.getConsumableStockStatusMeta(item);
+ if (statusFilter && statusMeta.key !== statusFilter) {
+ return false;
+ }
+
+ if (!search) {
+ return true;
+ }
+
+ const haystack = [
+ item.ConsumableCode,
+ item.ConsumableName,
+ item.Model,
+ item.Unit,
+ item.RequestMonth,
+ item.OpeningBalance,
+ item.ImportInPeriod,
+ item.ExportInPeriod,
+ item.ExportedSummary,
+ item.RecipientSummary,
+ item.ProjectSummary,
+ item.EndingBalance,
+ item.ExportReason
+ ].map(value => String(value || '').toLowerCase());
+
+ return haystack.some(value => value.includes(search));
+ });
+ }
+
+ getConsumableExportRecipientOptions() {
+ const recipients = new Set();
+ (Array.isArray(this.consumableExportHistories) ? this.consumableExportHistories : []).forEach(item => {
+ const value = String(item?.RecipientName || '').trim();
+ if (value) {
+ recipients.add(value);
+ }
+ });
+
+ return [...recipients].sort((a, b) => a.localeCompare(b, 'vi', { sensitivity: 'base' }));
+ }
+
+ getConsumableExportProjectOptions() {
+ const projects = new Set();
+ (Array.isArray(this.consumableExportHistories) ? this.consumableExportHistories : []).forEach(item => {
+ const value = String(item?.ProjectName || '').trim();
+ if (value) {
+ projects.add(value);
+ }
+ });
+
+ return [...projects].sort((a, b) => a.localeCompare(b, 'vi', { sensitivity: 'base' }));
+ }
+
+ getFilteredConsumableExportHistories() {
+ const search = String(this.consumableExportSearchTerm || '').trim().toLowerCase();
+ const recipientFilter = String(this.consumableExportRecipientFilter || '').trim().toLowerCase();
+ const projectFilter = String(this.consumableExportProjectFilter || '').trim().toLowerCase();
+ const dateFilter = String(this.consumableExportDateFilter || '').trim();
+ const rows = Array.isArray(this.consumableExportHistories) ? this.consumableExportHistories : [];
+
+ return rows.filter(item => {
+ const recipientName = String(item?.RecipientName || '').trim();
+ if (recipientFilter && recipientName.toLowerCase() !== recipientFilter) {
+ return false;
+ }
+
+ const projectName = String(item?.ProjectName || '').trim();
+ if (projectFilter && projectName.toLowerCase() !== projectFilter) {
+ return false;
+ }
+
+ const exportedDateInput = this.toDateInputValue(item?.ExportedDate || item?.CreatedDate);
+ if (dateFilter && exportedDateInput !== dateFilter) {
+ return false;
+ }
+
+ if (!search) {
+ return true;
+ }
+
+ const haystack = [
+ item.ExportHistoryId,
+ item.ConsumableCode,
+ item.ConsumableName,
+ item.Unit,
+ item.ExportQuantity,
+ item.RecipientName,
+ item.ProjectName,
+ item.ExportedByName,
+ item.ExportNote,
+ item.PreviousEndingBalance,
+ item.NextEndingBalance,
+ item.ExportedDate,
+ this.formatDateTime(item?.ExportedDate || item?.CreatedDate)
+ ].map(value => String(value || '').toLowerCase());
+
+ return haystack.some(value => value.includes(search));
+ });
+ }
+
+ getConsumableStockStatusMeta(consumable = {}) {
+ const endingBalance = this.parseNonNegativeInteger(consumable?.EndingBalance, 0);
+ if (endingBalance <= 0) {
+ return {
+ key: 'out_of_stock',
+ label: 'Hết tồn',
+ className: 'bg-red-50 text-red-700 border border-red-100'
+ };
+ }
+
+ return {
+ key: 'in_stock',
+ label: 'Còn tồn',
+ className: 'bg-emerald-50 text-emerald-700 border border-emerald-100'
+ };
+ }
+
normalizeNameForMatching(value) {
const normalized = String(value || '').trim().toLowerCase();
if (!normalized) {
@@ -4419,6 +4816,495 @@ class AccountManager {
this.notifyFailure(failureMessage);
}
}
+ buildConsumableTableRows(pageInfo) {
+ const canManageAssets = this.canCurrentUserManageAssets();
+ return pageInfo.data.map((item, index) => {
+ const statusMeta = this.getConsumableStockStatusMeta(item);
+ const rowNumber = pageInfo.start + index;
+ const reason = String(item.ExportReason || '').trim();
+ const exportedSummary = String(item.ExportedSummary || '').trim();
+ const recipientSummary = String(item.RecipientSummary || '').trim();
+ const projectSummary = String(item.ProjectSummary || '').trim();
+
+ return `
+
+ ${rowNumber}
+ ${this.escapeHtml(item.RequestMonth || '-')}
+ ${this.escapeHtml(item.ConsumableCode || '-')}
+ ${this.escapeHtml(item.ConsumableName || '-')}
+ ${this.escapeHtml(item.Model || '-')}
+ ${this.escapeHtml(item.Unit || '-')}
+ ${item.OpeningBalance ?? 0}
+ ${item.ImportInPeriod ?? 0}
+ ${item.ExportInPeriod ?? 0}
+ ${this.escapeHtml(exportedSummary || '-')}
+ ${this.escapeHtml(recipientSummary || '-')}
+ ${this.escapeHtml(projectSummary || '-')}
+ ${item.EndingBalance ?? 0}
+
+ ${statusMeta.label}
+
+ ${this.escapeHtml(reason || '-')}
+ ${this.formatDateOnly(item.UpdatedDate || item.CreatedDate)}
+
+
+
+ outbox
+
+
+ edit
+
+
+ delete
+
+
+
+
+ `;
+ }).join('');
+ }
+
+ getConsumablesContent() {
+ const canManageAssets = this.canCurrentUserManageAssets();
+ const filteredConsumables = this.getFilteredConsumables();
+ const pageInfo = this.getPaged(filteredConsumables, this.consumablePage, this.consumablePageSize);
+ const monthOptions = this.getConsumableMonthOptions();
+ this.consumablePage = pageInfo.current;
+
+ return `
+
+
+
+
+
+ Tháng
+
+ Tất cả
+ ${monthOptions.map(month => `${this.escapeHtml(month)} `).join('')}
+
+
+
+ Trạng thái
+
+ Tất cả
+ Còn tồn
+ Hết tồn
+
+
+
+ Tìm kiếm
+
+
+
+
+
+ ${pageInfo.data.length > 0 ? `
+
+
+
+
+ STT
+ Tháng đề xuất
+ Mã vật tư
+ Tên linh kiện/sp
+ Model
+ ĐVT
+ Tồn đầu
+ Nhập
+ Xuất
+ Đã xuất
+ Người đang nhận
+ Dự án nhận
+ Tồn cuối
+ Trạng thái
+ Lý do xuất
+ Cập nhật
+ Thao tác
+
+
+
+ ${this.buildConsumableTableRows(pageInfo)}
+
+
+
+
+ ` : `
+
+
+
Chưa có dữ liệu vật tư tiêu hao.
+
+ add_box
+ Thêm vật tư đầu tiên
+
+
+
+ `}
+
+
+ `;
+ }
+
+ renderConsumablesTableBody() {
+ const tbody = document.querySelector('.consumables-table-body');
+ if (!tbody) {
+ if (this.currentPage === 'consumables') {
+ this.renderView('consumables');
+ }
+ return;
+ }
+
+ const pageInfo = this.getPaged(this.getFilteredConsumables(), this.consumablePage, this.consumablePageSize);
+ this.consumablePage = pageInfo.current;
+ tbody.innerHTML = this.buildConsumableTableRows(pageInfo);
+
+ const pager = document.getElementById('consumablesPager');
+ if (pager) {
+ pager.innerHTML = `
+
Hiển thị ${pageInfo.start}-${pageInfo.end} / ${pageInfo.total}
+
+ Trước
+ Trang ${pageInfo.current} / ${pageInfo.totalPages}
+ Sau
+
+ `;
+ }
+
+ this.setupConsumableRowListeners();
+ this.setupConsumablePagerListeners();
+ }
+
+ setupConsumablePagerListeners() {
+ document.querySelectorAll('.consumable-page-btn').forEach(btn => {
+ if (btn.dataset.boundClick === 'true') {
+ return;
+ }
+
+ btn.addEventListener('click', () => {
+ this.consumablePage = Number(btn.dataset.page) || 1;
+ this.renderConsumablesTableBody();
+ });
+ btn.dataset.boundClick = 'true';
+ });
+ }
+
+ getConsumableExportsContent() {
+ const canManageAssets = this.canCurrentUserManageAssets();
+ const filteredRows = this.getFilteredConsumableExportHistories();
+ const pageInfo = this.getPaged(filteredRows, this.consumableExportPage, this.consumableExportPageSize);
+ const recipientOptions = this.getConsumableExportRecipientOptions();
+ const projectOptions = this.getConsumableExportProjectOptions();
+ this.consumableExportPage = pageInfo.current;
+
+ return `
+
+
+
+
+
+ Người nhận
+
+ Tất cả
+ ${recipientOptions.map(name => `${this.escapeHtml(name)} `).join('')}
+
+
+
+ Dự án
+
+ Tất cả
+ ${projectOptions.map(name => `${this.escapeHtml(name)} `).join('')}
+
+
+
+ Ngày
+
+
+
+ Tìm kiếm
+
+
+
+
+
+
+
+
+
+ STT
+ Ngày giờ
+ Mã vật tư
+ Tên vật tư
+ Số lượng
+ ĐVT
+ Người nhận
+ Dự án nhận
+ Người xuất
+ Tồn trước
+ Tồn sau
+ Ghi chú
+
+
+
+ ${canManageAssets
+ ? this.buildConsumableExportHistoryPageRowsHtml(pageInfo)
+ : `Bạn chỉ có quyền xem danh sách vật tư. `}
+
+
+
+
+
+
+ `;
+ }
+
+ buildConsumableExportHistoryEmptyRowHtml() {
+ return `
+
+ Chưa có dữ liệu lịch sử xuất vật tư.
+
+ `;
+ }
+
+ buildConsumableExportHistoryPageRowHtml(item, rowNumber) {
+ return `
+
+ ${rowNumber}
+ ${this.formatDateTime(item?.ExportedDate || item?.CreatedDate)}
+ ${this.escapeHtml(item?.ConsumableCode || '-')}
+ ${this.escapeHtml(item?.ConsumableName || '-')}
+ ${Number(item?.ExportQuantity) || 0}
+ ${this.escapeHtml(item?.Unit || '-')}
+ ${this.escapeHtml(item?.RecipientName || '-')}
+ ${this.escapeHtml(item?.ProjectName || '-')}
+ ${this.escapeHtml(item?.ExportedByName || '-')}
+ ${Number(item?.PreviousEndingBalance) || 0}
+ ${Number(item?.NextEndingBalance) || 0}
+ ${this.escapeHtml(item?.ExportNote || '-')}
+
+ `;
+ }
+
+ buildConsumableExportHistoryPageRowsHtml(pageInfo) {
+ if (!pageInfo?.data?.length) {
+ return this.buildConsumableExportHistoryEmptyRowHtml();
+ }
+
+ return pageInfo.data
+ .map((item, index) => this.buildConsumableExportHistoryPageRowHtml(item, pageInfo.start + index))
+ .join('');
+ }
+
+ renderConsumableExportHistoryPageBody() {
+ const tbody = document.getElementById('consumableExportHistoryPageTableBody');
+ if (!tbody) {
+ return;
+ }
+
+ const pageInfo = this.getPaged(
+ this.getFilteredConsumableExportHistories(),
+ this.consumableExportPage,
+ this.consumableExportPageSize
+ );
+ this.consumableExportPage = pageInfo.current;
+
+ tbody.innerHTML = this.buildConsumableExportHistoryPageRowsHtml(pageInfo);
+ this.renderConsumableExportHistoryPager(pageInfo);
+ this.setupConsumableExportHistoryPagerListeners();
+ }
+
+ renderConsumableExportHistoryPager(pageInfo) {
+ const pager = document.getElementById('consumableExportsPager');
+ if (!pager) {
+ return;
+ }
+
+ pager.innerHTML = `
+
Hiển thị ${pageInfo.start}-${pageInfo.end} / ${pageInfo.total}
+
+ Trước
+ Trang ${pageInfo.current} / ${pageInfo.totalPages}
+ Tiếp
+
+ `;
+ }
+
+ async refreshConsumableExportsPage() {
+ if (!this.canCurrentUserManageAssets()) {
+ return;
+ }
+
+ const tbody = document.getElementById('consumableExportHistoryPageTableBody');
+ if (tbody) {
+ tbody.innerHTML = `
+
+ Đang tải lịch sử xuất...
+
+ `;
+ }
+
+ await this.fetchConsumableExportHistories(2000);
+
+ if (this.currentPage === 'consumable-exports') {
+ const recipientFilter = document.getElementById('consumableExportRecipientFilter');
+ if (recipientFilter) {
+ const recipientOptions = this.getConsumableExportRecipientOptions();
+ const currentValue = this.consumableExportRecipientFilter;
+ recipientFilter.innerHTML = `
+
Tất cả
+ ${recipientOptions.map(name => `
${this.escapeHtml(name)} `).join('')}
+ `;
+ }
+
+ const projectFilter = document.getElementById('consumableExportProjectFilter');
+ if (projectFilter) {
+ const projectOptions = this.getConsumableExportProjectOptions();
+ const currentValue = this.consumableExportProjectFilter;
+ projectFilter.innerHTML = `
+
Tất cả
+ ${projectOptions.map(name => `
${this.escapeHtml(name)} `).join('')}
+ `;
+ }
+ this.renderConsumableExportHistoryPageBody();
+ }
+ }
+
+ setupConsumableExportHistoryPagerListeners() {
+ document.querySelectorAll('.consumable-export-page-btn').forEach(btn => {
+ if (btn.dataset.boundClick === 'true') {
+ return;
+ }
+
+ btn.addEventListener('click', () => {
+ const targetPage = Number(btn.dataset.page);
+ if (!targetPage || targetPage < 1) {
+ return;
+ }
+ this.consumableExportPage = targetPage;
+ this.renderConsumableExportHistoryPageBody();
+ });
+ btn.dataset.boundClick = 'true';
+ });
+ }
+
+ setupConsumableExportHistoryListeners() {
+ const recipientFilter = document.getElementById('consumableExportRecipientFilter');
+ if (recipientFilter && recipientFilter.dataset.boundChange !== 'true') {
+ recipientFilter.addEventListener('change', event => {
+ this.consumableExportRecipientFilter = String(event.target.value || '').trim();
+ this.consumableExportPage = 1;
+ this.renderConsumableExportHistoryPageBody();
+ });
+ recipientFilter.dataset.boundChange = 'true';
+ }
+
+ const projectFilter = document.getElementById('consumableExportProjectFilter');
+ if (projectFilter && projectFilter.dataset.boundChange !== 'true') {
+ projectFilter.addEventListener('change', event => {
+ this.consumableExportProjectFilter = String(event.target.value || '').trim();
+ this.consumableExportPage = 1;
+ this.renderConsumableExportHistoryPageBody();
+ });
+ projectFilter.dataset.boundChange = 'true';
+ }
+
+ const dateFilter = document.getElementById('consumableExportDateFilter');
+ if (dateFilter && dateFilter.dataset.boundChange !== 'true') {
+ dateFilter.addEventListener('change', event => {
+ this.consumableExportDateFilter = String(event.target.value || '').trim();
+ this.consumableExportPage = 1;
+ this.renderConsumableExportHistoryPageBody();
+ });
+ dateFilter.dataset.boundChange = 'true';
+ }
+
+ const searchInput = document.getElementById('consumableExportSearch');
+ if (searchInput && searchInput.dataset.boundInput !== 'true') {
+ searchInput.addEventListener('input', event => {
+ this.consumableExportSearchTerm = String(event.target.value || '').trim();
+ this.consumableExportPage = 1;
+ this.renderConsumableExportHistoryPageBody();
+ });
+ searchInput.addEventListener('focus', () => {
+ searchInput.dataset.focused = 'true';
+ });
+ searchInput.addEventListener('blur', () => {
+ searchInput.dataset.focused = 'false';
+ });
+ searchInput.dataset.boundInput = 'true';
+ }
+
+ const refreshBtn = document.getElementById('refreshConsumableExportHistoryPageBtn');
+ if (refreshBtn && refreshBtn.dataset.boundClick !== 'true') {
+ refreshBtn.addEventListener('click', () => this.refreshConsumableExportsPage());
+ refreshBtn.dataset.boundClick = 'true';
+ }
+
+ const exportBtn = document.getElementById('exportConsumableHistoryBtn');
+ if (exportBtn && exportBtn.dataset.boundClick !== 'true') {
+ exportBtn.addEventListener('click', () => this.exportConsumableHistoryToExcel());
+ exportBtn.dataset.boundClick = 'true';
+ }
+
+ this.setupConsumableExportHistoryPagerListeners();
+ }
+
getAssetsContent() {
this.syncSelectedAssetIds();
const canManageAssets = this.canCurrentUserManageAssets();
@@ -5606,6 +6492,514 @@ class AccountManager {
}
}
+ setupConsumableStockListeners() {
+ const openingInput = document.getElementById('consumableOpeningBalanceInput');
+ const importInput = document.getElementById('consumableImportInPeriodInput');
+ const exportInput = document.getElementById('consumableExportInPeriodInput');
+ const endingInput = document.getElementById('consumableEndingBalanceInput');
+
+ if (!openingInput || !importInput || !exportInput || !endingInput) {
+ return;
+ }
+
+ const updateEnding = () => {
+ const openingBalance = this.parseNonNegativeInteger(openingInput.value, 0);
+ const importInPeriod = this.parseNonNegativeInteger(importInput.value, 0);
+ const exportInPeriod = this.parseNonNegativeInteger(exportInput.value, 0);
+ endingInput.value = String(Math.max(openingBalance + importInPeriod - exportInPeriod, 0));
+ };
+
+ [openingInput, importInput, exportInput].forEach(input => {
+ if (input.dataset.boundConsumableStock === 'true') {
+ return;
+ }
+ input.addEventListener('input', updateEnding);
+ input.dataset.boundConsumableStock = 'true';
+ });
+
+ updateEnding();
+ }
+
+ collectConsumableFormPayload() {
+ const openingBalance = this.parseNonNegativeInteger(document.getElementById('consumableOpeningBalanceInput')?.value, 0);
+ const importInPeriod = this.parseNonNegativeInteger(document.getElementById('consumableImportInPeriodInput')?.value, 0);
+ const exportInPeriod = this.parseNonNegativeInteger(document.getElementById('consumableExportInPeriodInput')?.value, 0);
+
+ return {
+ requestMonth: String(document.getElementById('consumableRequestMonthInput')?.value || '').trim(),
+ consumableCode: String(document.getElementById('consumableCodeInput')?.value || '').trim(),
+ consumableName: String(document.getElementById('consumableNameInput')?.value || '').trim(),
+ model: String(document.getElementById('consumableModelInput')?.value || '').trim(),
+ unit: String(document.getElementById('consumableUnitInput')?.value || '').trim(),
+ openingBalance,
+ importInPeriod,
+ exportInPeriod,
+ endingBalance: Math.max(openingBalance + importInPeriod - exportInPeriod, 0),
+ exportReason: String(document.getElementById('consumableExportReasonInput')?.value || '').trim()
+ };
+ }
+
+ populateConsumableForm(consumable = null) {
+ const isEdit = Boolean(consumable);
+ const title = document.getElementById('consumableModalTitle');
+ if (title) {
+ title.textContent = isEdit ? 'Sửa vật tư tiêu hao' : 'Thêm vật tư tiêu hao';
+ }
+
+ const setValue = (id, value) => {
+ const input = document.getElementById(id);
+ if (input) {
+ input.value = value ?? '';
+ }
+ };
+
+ const requestMonth = String(consumable?.RequestMonth || '').trim();
+ const requestMonthInput = document.getElementById('consumableRequestMonthInput');
+ if (
+ requestMonthInput?.tagName === 'SELECT'
+ && requestMonth
+ && !Array.from(requestMonthInput.options).some(option => option.value === requestMonth)
+ ) {
+ const legacyOption = document.createElement('option');
+ legacyOption.value = requestMonth;
+ legacyOption.textContent = requestMonth;
+ requestMonthInput.appendChild(legacyOption);
+ }
+
+ setValue('consumableRequestMonthInput', requestMonth);
+ setValue('consumableCodeInput', consumable?.ConsumableCode || '');
+ setValue('consumableNameInput', consumable?.ConsumableName || '');
+ setValue('consumableModelInput', consumable?.Model || '');
+ setValue('consumableUnitInput', consumable?.Unit || '');
+ setValue('consumableOpeningBalanceInput', consumable?.OpeningBalance ?? 0);
+ setValue('consumableImportInPeriodInput', consumable?.ImportInPeriod ?? 0);
+ setValue('consumableExportInPeriodInput', consumable?.ExportInPeriod ?? 0);
+ setValue('consumableEndingBalanceInput', consumable?.EndingBalance ?? 0);
+ setValue('consumableExportReasonInput', consumable?.ExportReason || '');
+
+ this.setupConsumableStockListeners();
+ }
+
+ openConsumableModal() {
+ if (!this.ensureAssetManagePermission('quan ly vat tu tieu hao')) {
+ return;
+ }
+
+ if (this.editingConsumableId === undefined) {
+ this.populateConsumableForm(null);
+ }
+
+ document.getElementById('consumableModal')?.classList.add('open');
+ }
+
+ openConsumableExportModal(consumable) {
+ if (!this.ensureAssetManagePermission('xuat vat tu tieu hao')) {
+ return;
+ }
+
+ if (!consumable) {
+ this.notifyWarning('Vui lòng chọn vật tư cần xuất.');
+ return;
+ }
+
+ const endingBalance = this.parseNonNegativeInteger(consumable?.EndingBalance, 0);
+ if (endingBalance <= 0) {
+ this.notifyWarning('Vật tư đã hết tồn cuối kỳ, không thể xuất thêm.');
+ return;
+ }
+
+ this.pendingConsumableExportId = Number(consumable.ConsumableId);
+
+ const idInput = document.getElementById('consumableExportConsumableIdInput');
+ const nameInput = document.getElementById('consumableExportConsumableNameInput');
+ const endingInput = document.getElementById('consumableExportCurrentEndingInput');
+ const quantityInput = document.getElementById('consumableExportQuantityInput');
+ const noteInput = document.getElementById('consumableExportNoteInput');
+ const actorInput = document.getElementById('consumableExportActorInput');
+ const roleInput = document.getElementById('consumableExportRoleInput');
+ const targetTypeInput = document.getElementById('consumableExportTargetTypeInput');
+ const projectInput = document.getElementById('consumableExportProjectInput');
+ const modal = document.getElementById('consumableExportModal');
+
+ if (!modal || !nameInput || !endingInput || !quantityInput || !noteInput) {
+ this.notifyFailure('Không tìm thấy biểu mẫu xuất vật tư.');
+ return;
+ }
+
+ if (idInput) {
+ idInput.value = String(consumable.ConsumableId || '');
+ }
+
+ nameInput.value = `${consumable.ConsumableCode || ''} - ${consumable.ConsumableName || ''}`.trim();
+ endingInput.value = String(endingBalance);
+ quantityInput.value = '1';
+ quantityInput.min = '1';
+ quantityInput.max = String(endingBalance);
+ noteInput.value = '';
+
+ if (targetTypeInput) {
+ targetTypeInput.value = 'user';
+ }
+ if (projectInput) {
+ projectInput.value = '';
+ }
+ this.refreshConsumableExportUserOptions('');
+ this.refreshConsumableExportProjectOptions('');
+ this.setupConsumableExportTargetTypeListeners();
+
+ if (actorInput) {
+ actorInput.value = this.getCurrentUserDisplayName();
+ }
+
+ if (roleInput) {
+ roleInput.value = String(this.getCurrentUserRoleRaw() || '').trim() || '-';
+ }
+
+ modal.classList.add('open');
+ }
+
+ async handleConsumableExportSubmit(e) {
+ e.preventDefault();
+
+ if (!this.ensureAssetManagePermission('xuat vat tu tieu hao')) {
+ return;
+ }
+
+ const consumableIdInput = document.getElementById('consumableExportConsumableIdInput');
+ const targetTypeInput = document.getElementById('consumableExportTargetTypeInput');
+ const recipientInput = document.getElementById('consumableExportUserInput');
+ const projectInput = document.getElementById('consumableExportProjectInput');
+ const quantityInput = document.getElementById('consumableExportQuantityInput');
+ const noteInput = document.getElementById('consumableExportNoteInput');
+ const selectedConsumableId = Number(consumableIdInput?.value || this.pendingConsumableExportId);
+ const targetType = String(targetTypeInput?.value || 'user').trim() === 'project' ? 'project' : 'user';
+
+ if (!Number.isFinite(selectedConsumableId) || selectedConsumableId <= 0) {
+ this.notifyFailure('Không xác định được vật tư cần xuất.');
+ return;
+ }
+
+ const consumable = this.consumables.find(item => Number(item?.ConsumableId) === selectedConsumableId);
+ if (!consumable) {
+ this.notifyFailure('Không tìm thấy vật tư cần xuất.');
+ return;
+ }
+
+ const recipientName = String(recipientInput?.value || '').trim();
+ const projectName = String(projectInput?.value || '').trim();
+ if (targetType === 'user' && !recipientName) {
+ this.notifyWarning('Vui lòng chọn người nhận.');
+ return;
+ }
+
+ if (targetType === 'project' && !projectName) {
+ this.notifyWarning('Vui lòng chọn dự án nhận.');
+ return;
+ }
+
+ const exportQuantity = this.parseNonNegativeInteger(quantityInput?.value ?? 0, 0);
+ if (exportQuantity <= 0) {
+ this.notifyWarning('Số lượng xuất phải lớn hơn 0.');
+ return;
+ }
+
+ const endingBalance = this.parseNonNegativeInteger(consumable?.EndingBalance, 0);
+ if (endingBalance <= 0) {
+ this.notifyWarning('Vật tư đã hết tồn cuối kỳ, không thể xuất thêm.');
+ return;
+ }
+
+ if (exportQuantity > endingBalance) {
+ this.notifyWarning(`Số lượng xuất (${exportQuantity}) vượt quá tồn cuối kỳ (${endingBalance}).`);
+ return;
+ }
+
+ try {
+ const response = await fetch(`${this.apiBase}/consumables/${selectedConsumableId}/export`, {
+ method: 'POST',
+ headers: this.getAuthHeaders(true),
+ body: JSON.stringify({
+ quantity: exportQuantity,
+ targetType,
+ recipientName,
+ projectName,
+ note: String(noteInput?.value || '').trim()
+ })
+ });
+ const data = await response.json();
+
+ if (!response.ok || !data.success) {
+ this.notifyFailure(data.message || 'Xuất vật tư tiêu hao thất bại');
+ return;
+ }
+
+ this.pendingConsumableExportId = undefined;
+ this.notifySuccess('Xuất vật tư tiêu hao thành công');
+ this.closeModals();
+ await this.refreshConsumablesUI();
+
+ const exportHistoryModal = document.getElementById('consumableExportHistoryModal');
+ if (exportHistoryModal?.classList.contains('open')) {
+ await this.fetchConsumableExportHistories();
+ this.renderConsumableExportHistoryModal();
+ }
+ } catch (err) {
+ console.error(err);
+ this.notifyFailure('Xuất vật tư tiêu hao thất bại');
+ }
+ }
+
+ async handleConsumableSubmit(e) {
+ e.preventDefault();
+
+ if (!this.ensureAssetManagePermission('them hoac sua vat tu tieu hao')) {
+ return;
+ }
+
+ const isEdit = this.editingConsumableId !== undefined;
+ const payload = this.collectConsumableFormPayload();
+
+ if (!payload.consumableName) {
+ this.notifyWarning('Vui lòng nhập tên vật tư.');
+ document.getElementById('consumableNameInput')?.focus();
+ return;
+ }
+
+ const url = isEdit ? `${this.apiBase}/consumables/${this.editingConsumableId}` : `${this.apiBase}/consumables`;
+ const method = isEdit ? 'PUT' : 'POST';
+
+ try {
+ const response = await fetch(url, {
+ method,
+ headers: this.getAuthHeaders(true),
+ body: JSON.stringify(payload)
+ });
+ const data = await response.json();
+
+ if (!response.ok || !data.success) {
+ this.notifyFailure(data.message || 'Lưu vật tư tiêu hao thất bại');
+ return;
+ }
+
+ this.editingConsumableId = undefined;
+ this.notifySuccess(isEdit ? 'Cập nhật vật tư thành công' : 'Thêm vật tư thành công');
+ this.closeModals();
+ await this.refreshConsumablesUI();
+ } catch (err) {
+ console.error(err);
+ this.notifyFailure('Lưu vật tư tiêu hao thất bại');
+ }
+ }
+
+ async refreshConsumablesUI() {
+ await this.fetchConsumables();
+ if (this.currentPage === 'consumables') {
+ this.renderView('consumables');
+ }
+ }
+
+ setupConsumableRowListeners() {
+ const canManageAssets = this.canCurrentUserManageAssets();
+
+ document.querySelectorAll('.edit-consumable').forEach(btn => {
+ if (btn.dataset.boundClick === 'true') {
+ return;
+ }
+
+ btn.addEventListener('click', () => {
+ if (!this.ensureAssetManagePermission('sua vat tu tieu hao')) {
+ return;
+ }
+
+ const consumableId = Number(btn.dataset.consumableId);
+ const consumable = this.consumables.find(item => Number(item.ConsumableId) === consumableId);
+ this.editingConsumableId = consumable?.ConsumableId;
+ this.populateConsumableForm(consumable);
+ this.closeModals();
+ this.openConsumableModal();
+ });
+ btn.dataset.boundClick = 'true';
+ });
+
+ document.querySelectorAll('.export-consumable').forEach(btn => {
+ if (btn.dataset.boundClick === 'true') {
+ return;
+ }
+
+ btn.addEventListener('click', () => {
+ if (!canManageAssets || !this.ensureAssetManagePermission('xuat vat tu tieu hao')) {
+ return;
+ }
+
+ const consumableId = Number(btn.dataset.consumableId);
+ const consumable = this.consumables.find(item => Number(item.ConsumableId) === consumableId);
+ this.openConsumableExportModal(consumable);
+ });
+ btn.dataset.boundClick = 'true';
+ });
+
+ document.querySelectorAll('.delete-consumable').forEach(btn => {
+ if (btn.dataset.boundClick === 'true') {
+ return;
+ }
+
+ btn.addEventListener('click', () => {
+ if (!canManageAssets || !this.ensureAssetManagePermission('xoa vat tu tieu hao')) {
+ return;
+ }
+
+ const consumableId = Number(btn.dataset.consumableId);
+ const consumable = this.consumables.find(item => Number(item.ConsumableId) === consumableId);
+ this.pendingDeleteConsumableId = consumableId;
+ const label = consumable?.ConsumableName || consumable?.ConsumableCode || '-';
+ document.getElementById('deleteConsumableName').textContent = label;
+ document.getElementById('deleteConsumableModal')?.classList.add('open');
+ });
+ btn.dataset.boundClick = 'true';
+ });
+ }
+
+ async confirmDeleteConsumable() {
+ if (!this.ensureAssetManagePermission('xoa vat tu tieu hao')) {
+ return;
+ }
+
+ const consumableId = Number(this.pendingDeleteConsumableId);
+ if (!Number.isFinite(consumableId) || consumableId <= 0) {
+ this.notifyFailure('Không xác định được vật tư cần xóa');
+ return;
+ }
+
+ try {
+ const response = await fetch(`${this.apiBase}/consumables/${consumableId}`, {
+ method: 'DELETE',
+ headers: this.getAuthHeaders(false)
+ });
+ const data = await response.json();
+
+ if (!response.ok || !data.success) {
+ this.notifyFailure(data.message || 'Xóa vật tư thất bại');
+ return;
+ }
+
+ this.pendingDeleteConsumableId = undefined;
+ this.notifySuccess('Xóa vật tư thành công');
+ this.closeModals();
+ await this.refreshConsumablesUI();
+ } catch (err) {
+ console.error(err);
+ this.notifyFailure('Xóa vật tư thất bại');
+ }
+ }
+
+ async processConsumableImportFile(event) {
+ if (!this.ensureAssetManagePermission('nhap du lieu vat tu tieu hao')) {
+ event.target.value = '';
+ return;
+ }
+
+ const file = event.target.files?.[0];
+ event.target.value = '';
+ if (!file) {
+ return;
+ }
+
+ try {
+ const formData = new FormData();
+ formData.append('file', file);
+ const response = await fetch(`${this.apiBase}/consumables/import`, {
+ method: 'POST',
+ headers: this.getAuthHeaders(false),
+ body: formData
+ });
+ const data = await response.json();
+
+ if (!response.ok || !data.success) {
+ this.notifyFailure(data.message || 'Import vật tư tiêu hao thất bại');
+ return;
+ }
+
+ const inserted = data.data?.inserted ?? 0;
+ const updated = data.data?.updated ?? 0;
+ this.notifySuccess(`Import VTTH xong: thêm ${inserted}, cập nhật ${updated}`);
+ await this.refreshConsumablesUI();
+ } catch (err) {
+ console.error(err);
+ this.notifyFailure('Import vật tư tiêu hao thất bại');
+ }
+ }
+
+ exportConsumablesToExcel() {
+ if (!window.XLSX) {
+ this.notifyFailure('Không tìm thấy thư viện xuất Excel');
+ return;
+ }
+
+ try {
+ const exportRows = this.getFilteredConsumables().map(item => ({
+ 'STT': '',
+ 'Tháng đề xuất': item.RequestMonth || '',
+ 'Mã vật tư': item.ConsumableCode || '',
+ 'Tên linh kiện/sp': item.ConsumableName || '',
+ 'Model': item.Model || '',
+ 'ĐVT': item.Unit || '',
+ 'Tồn đầu kỳ': item.OpeningBalance ?? 0,
+ 'Nhập trong kì': item.ImportInPeriod ?? 0,
+ 'Xuất trong kì': item.ExportInPeriod ?? 0,
+ 'Đã xuất': item.ExportedSummary || '',
+ 'Người đang nhận': item.RecipientSummary || '',
+ 'Dự án nhận': item.ProjectSummary || '',
+ 'Tồn cuối kì': item.EndingBalance ?? 0,
+ 'Lí do xuất': item.ExportReason || ''
+ })).map((row, index) => ({
+ ...row,
+ 'STT': index + 1
+ }));
+
+ const worksheet = window.XLSX.utils.json_to_sheet(exportRows);
+ const workbook = window.XLSX.utils.book_new();
+ window.XLSX.utils.book_append_sheet(workbook, worksheet, 'VTTH');
+ const timestamp = this.formatTimestampForCode(new Date()).slice(0, 8);
+ window.XLSX.writeFile(workbook, `vat-tu-tieu-hao-${timestamp}.xlsx`);
+ } catch (err) {
+ console.error(err);
+ this.notifyFailure('Xuất vật tư tiêu hao thất bại');
+ }
+ }
+
+ exportConsumableHistoryToExcel() {
+ if (!window.XLSX) {
+ this.notifyFailure('Không tìm thấy thư viện xuất Excel');
+ return;
+ }
+
+ try {
+ const rows = this.getFilteredConsumableExportHistories().map((item, index) => ({
+ 'STT': index + 1,
+ 'Ngày giờ': this.formatDateTime(item?.ExportedDate || item?.CreatedDate),
+ 'Mã vật tư': item?.ConsumableCode || '',
+ 'Tên vật tư': item?.ConsumableName || '',
+ 'Số lượng': Number(item?.ExportQuantity) || 0,
+ 'ĐVT': item?.Unit || '',
+ 'Người nhận': item?.RecipientName || '',
+ 'Dự án nhận': item?.ProjectName || '',
+ 'Người xuất': item?.ExportedByName || '',
+ 'Tồn trước': Number(item?.PreviousEndingBalance) || 0,
+ 'Tồn sau': Number(item?.NextEndingBalance) || 0,
+ 'Ghi chú': item?.ExportNote || ''
+ }));
+
+ const worksheet = window.XLSX.utils.json_to_sheet(rows);
+ const workbook = window.XLSX.utils.book_new();
+ window.XLSX.utils.book_append_sheet(workbook, worksheet, 'LichSuXuatVTTH');
+ const timestamp = this.formatTimestampForCode(new Date()).slice(0, 8);
+ window.XLSX.writeFile(workbook, `lich-su-xuat-vtth-${timestamp}.xlsx`);
+ } catch (err) {
+ console.error(err);
+ this.notifyFailure('Xuất lịch sử vật tư tiêu hao thất bại');
+ }
+ }
+
async refreshAssetsUI() {
await this.fetchAssets();
await this.fetchAssetDepartments();
@@ -6917,6 +8311,18 @@ class AccountManager {
});
});
+ document.querySelectorAll('#addConsumableBtn').forEach(btn => {
+ if (btn.dataset.boundClick === 'true') {
+ return;
+ }
+
+ btn.addEventListener('click', () => {
+ this.editingConsumableId = undefined;
+ this.openConsumableModal();
+ });
+ btn.dataset.boundClick = 'true';
+ });
+
const addAssetDepartmentBtn = document.getElementById('addAssetDepartmentBtn');
if (addAssetDepartmentBtn && !addAssetDepartmentBtn.dataset.boundClick) {
addAssetDepartmentBtn.addEventListener('click', () => this.handleCreateAssetDepartment());
@@ -6962,6 +8368,11 @@ class AccountManager {
const importAssetBtn = document.getElementById('importAssetBtn');
const assetImportInput = document.getElementById('assetImportInput');
const exportAssetBtn = document.getElementById('exportAssetBtn');
+ const importConsumableBtn = document.getElementById('importConsumableBtn');
+ const consumableImportInput = document.getElementById('consumableImportInput');
+ const exportConsumableBtn = document.getElementById('exportConsumableBtn');
+ const openConsumableExportHistoryBtn = document.getElementById('openConsumableExportHistoryBtn');
+ const refreshConsumableExportHistoryPageBtn = document.getElementById('refreshConsumableExportHistoryPageBtn');
const openAssetExportHistoryBtn = document.getElementById('openAssetExportHistoryBtn');
const openAssetDamageHistoryBtn = document.getElementById('openAssetDamageHistoryBtn');
@@ -6985,6 +8396,36 @@ class AccountManager {
exportAssetBtn.dataset.boundClick = 'true';
}
+ if (importConsumableBtn && consumableImportInput && !importConsumableBtn.dataset.boundClick) {
+ importConsumableBtn.addEventListener('click', () => {
+ if (!this.ensureAssetManagePermission('nhap du lieu vat tu tieu hao')) {
+ return;
+ }
+ consumableImportInput.click();
+ });
+ importConsumableBtn.dataset.boundClick = 'true';
+ }
+
+ if (consumableImportInput && !consumableImportInput.dataset.boundChange) {
+ consumableImportInput.addEventListener('change', (event) => this.processConsumableImportFile(event));
+ consumableImportInput.dataset.boundChange = 'true';
+ }
+
+ if (exportConsumableBtn && !exportConsumableBtn.dataset.boundClick) {
+ exportConsumableBtn.addEventListener('click', () => this.exportConsumablesToExcel());
+ exportConsumableBtn.dataset.boundClick = 'true';
+ }
+
+ if (openConsumableExportHistoryBtn && !openConsumableExportHistoryBtn.dataset.boundClick) {
+ openConsumableExportHistoryBtn.addEventListener('click', () => this.openConsumableExportHistoryModal());
+ openConsumableExportHistoryBtn.dataset.boundClick = 'true';
+ }
+
+ if (refreshConsumableExportHistoryPageBtn && !refreshConsumableExportHistoryPageBtn.dataset.boundClick) {
+ refreshConsumableExportHistoryPageBtn.addEventListener('click', () => this.refreshConsumableExportsPage());
+ refreshConsumableExportHistoryPageBtn.dataset.boundClick = 'true';
+ }
+
if (openAssetExportHistoryBtn && !openAssetExportHistoryBtn.dataset.boundClick) {
openAssetExportHistoryBtn.addEventListener('click', () => this.openAssetExportHistoryModal());
openAssetExportHistoryBtn.dataset.boundClick = 'true';
@@ -7071,6 +8512,44 @@ class AccountManager {
assetSearch.dataset.focused = 'false';
});
}
+
+ const consumableMonthFilter = document.getElementById('consumableMonthFilter');
+ if (consumableMonthFilter) {
+ consumableMonthFilter.value = this.consumableMonthFilter || '';
+ consumableMonthFilter.addEventListener('change', (e) => {
+ this.consumableMonthFilter = String(e.target.value || '');
+ this.consumablePage = 1;
+ this.renderConsumablesTableBody();
+ });
+ }
+
+ const consumableStatusFilter = document.getElementById('consumableStatusFilter');
+ if (consumableStatusFilter) {
+ consumableStatusFilter.value = this.consumableStatusFilter || '';
+ consumableStatusFilter.addEventListener('change', (e) => {
+ this.consumableStatusFilter = String(e.target.value || '');
+ this.consumablePage = 1;
+ this.renderConsumablesTableBody();
+ });
+ }
+
+ const consumableSearch = document.getElementById('consumableSearch');
+ if (consumableSearch) {
+ consumableSearch.value = this.consumableSearchTerm;
+ const handleConsumableSearch = event => {
+ this.consumableSearchTerm = event.target.value.toLowerCase();
+ this.consumablePage = 1;
+ this.renderConsumablesTableBody();
+ };
+
+ consumableSearch.addEventListener('input', handleConsumableSearch);
+ consumableSearch.addEventListener('focus', () => {
+ consumableSearch.dataset.focused = 'true';
+ });
+ consumableSearch.addEventListener('blur', () => {
+ consumableSearch.dataset.focused = 'false';
+ });
+ }
}
async handleAccountSubmit(e) {
@@ -8178,6 +9657,28 @@ function closeAssetModal() {
document.getElementById('assetModal').classList.remove('open');
}
+function closeConsumableModal() {
+ document.getElementById('consumableModal').classList.remove('open');
+}
+
+function closeDeleteConsumableModal() {
+ document.getElementById('deleteConsumableModal').classList.remove('open');
+}
+
+function closeConsumableExportModal() {
+ const modal = document.getElementById('consumableExportModal');
+ if (modal) {
+ modal.classList.remove('open');
+ }
+}
+
+function closeConsumableExportHistoryModal() {
+ const modal = document.getElementById('consumableExportHistoryModal');
+ if (modal) {
+ modal.classList.remove('open');
+ }
+}
+
function closeViewAssetModal() {
document.getElementById('viewAssetModal').classList.remove('open');
}
diff --git a/public/modals.html b/public/modals.html
index 4ae5eb8..30468a1 100644
--- a/public/modals.html
+++ b/public/modals.html
@@ -300,6 +300,198 @@
+
+