upload tài liệu

This commit is contained in:
2026-08-06 10:44:25 +07:00
parent 92bcb04682
commit 26094ee4d6
10 changed files with 620 additions and 8 deletions

File diff suppressed because one or more lines are too long

View File

@@ -65,6 +65,7 @@ class AccountManager {
this.users = [];
this.assets = [];
this.consumables = [];
this.documents = [];
this.roles = [];
this.accountPage = 1;
this.accountPageSize = 9;
@@ -98,6 +99,8 @@ class AccountManager {
this.consumableExportRecipientFilter = '';
this.consumableExportProjectFilter = '';
this.consumableExportDateFilter = '';
this.documentSearchTerm = '';
this.documentMaxFileSizeMb = 25;
this.assetBorrows = [];
this.assetBorrowSearchTerm = '';
this.assetBorrowTypeFilter = '';
@@ -259,6 +262,7 @@ class AccountManager {
await this.fetchAssetBorrows();
await this.fetchAssetDepartments();
await this.fetchAssetProjects();
await this.fetchDocuments();
if (this.canCurrentUserManageAssets()) {
await this.fetchUsers();
@@ -338,6 +342,9 @@ class AccountManager {
this.setupAddButtonListeners();
this.setupFilters();
this.setupAccountPagerListeners();
} else if (page === 'documents') {
mainContent.innerHTML = this.getDocumentsContent();
this.setupDocumentListeners();
} else if (page === 'users') {
// Check if user is admin
if (!this.isCurrentUserAdmin()) {
@@ -481,6 +488,25 @@ class AccountManager {
}
}
async fetchDocuments(search = '') {
try {
const query = String(search || '').trim();
const url = query
? `${this.apiBase}/documents?search=${encodeURIComponent(query)}`
: `${this.apiBase}/documents`;
const response = await fetch(url, { cache: 'no-store' });
const data = await response.json();
if (!response.ok || !data.success) {
throw new Error(data.message || 'Không thể tải danh sách tài liệu');
}
this.documents = Array.isArray(data.data) ? data.data : [];
this.documentMaxFileSizeMb = Number(data.maxFileSizeMb) || 25;
} catch (err) {
console.error('Fetch documents error:', err);
this.documents = [];
}
}
async fetchAccountSecret(accountId) {
const response = await fetch(`${this.apiBase}/accounts/${accountId}/secret`, { cache: 'no-store' });
const data = await response.json();
@@ -2737,6 +2763,310 @@ class AccountManager {
`;
}
formatFileSize(value) {
const bytes = Number(value) || 0;
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
}
getFilteredDocuments() {
const keyword = String(this.documentSearchTerm || '').trim().toLocaleLowerCase('vi');
if (!keyword) return this.documents;
return this.documents.filter(document => [
document?.Title,
document?.OriginalFileName,
document?.UploadedByName
].some(value => String(value || '').toLocaleLowerCase('vi').includes(keyword)));
}
getDocumentsContent() {
const documents = this.getFilteredDocuments();
const totalSize = this.documents.reduce((sum, document) => sum + (Number(document?.FileSize) || 0), 0);
return `
<div class="documents-page p-4 md:p-6 w-full h-full overflow-y-auto">
<div class="page-header flex items-end justify-between gap-4 mb-5">
<div>
<div class="flex items-center gap-2 text-red-600 mb-1">
<span class="material-symbols-outlined">picture_as_pdf</span>
<span class="text-xs font-extrabold uppercase tracking-widest">Kho tài liệu</span>
</div>
<h1 class="text-2xl font-extrabold text-slate-900 dark:text-slate-50">Tài liệu PDF</h1>
<p class="text-sm text-slate-500 mt-1">Lưu trữ, xem và tải xuống tài liệu dùng chung.</p>
</div>
<div class="document-header-actions flex flex-wrap items-center justify-end gap-3">
<div class="inline-flex h-10 items-center gap-2 whitespace-nowrap px-1 text-xs text-slate-500" aria-label="${this.documents.length} tài liệu, tổng dung lượng ${this.formatFileSize(totalSize)}">
<span class="material-symbols-outlined text-lg text-slate-400">folder</span>
<span><strong class="font-extrabold text-slate-700">${this.documents.length}</strong> tài liệu</span>
<span class="text-slate-300" aria-hidden="true">•</span>
<span class="font-semibold text-slate-600">${this.formatFileSize(totalSize)}</span>
</div>
<button id="openDocumentUploadDialog" type="button" class="inline-flex items-center justify-center gap-2 rounded-xl bg-red-600 px-4 py-2 text-sm font-bold text-white shadow-sm transition hover:bg-red-700 active:scale-95">
<span class="material-symbols-outlined text-base">upload_file</span>
<span>Thêm tài liệu</span>
</button>
</div>
</div>
<div class="rounded-xl border border-slate-200 bg-white shadow-sm overflow-hidden">
<div class="page-filters flex items-center gap-3 border-b border-slate-200 p-4">
<div class="relative flex-1">
<span class="material-symbols-outlined absolute left-3 top-1/2 -translate-y-1/2 text-slate-400">search</span>
<input id="documentSearch" type="search" value="${this.escapeHtml(this.documentSearchTerm)}" placeholder="Tìm theo tên tài liệu, tên file hoặc người tải lên..."
class="w-full rounded-lg border-slate-200 py-2 pl-10 pr-3 text-sm focus:border-primary focus:ring-primary" />
</div>
<span class="text-xs font-semibold text-slate-500 whitespace-nowrap">${documents.length} kết quả</span>
</div>
${documents.length ? `
<div class="grid grid-cols-1 xl:grid-cols-2 gap-4 p-4">
${documents.map(document => {
const documentId = Number(document?.DocumentId);
const canDelete = Boolean(document?.CanDelete);
return `
<article class="flex min-w-0 gap-4 rounded-xl border border-slate-200 p-4 transition hover:border-red-200 hover:shadow-sm">
<div class="flex h-12 w-12 shrink-0 items-center justify-center rounded-xl bg-red-100 text-red-600">
<span class="material-symbols-outlined text-3xl">picture_as_pdf</span>
</div>
<div class="min-w-0 flex-1">
<h2 class="truncate font-extrabold text-slate-800" title="${this.escapeHtml(document?.Title || '')}">${this.escapeHtml(document?.Title || 'Tài liệu PDF')}</h2>
<p class="mt-0.5 truncate text-xs text-slate-500" title="${this.escapeHtml(document?.OriginalFileName || '')}">${this.escapeHtml(document?.OriginalFileName || '-')}</p>
<div class="mt-2 flex flex-wrap gap-x-3 gap-y-1 text-[11px] text-slate-500">
<span class="inline-flex items-center gap-1"><span class="material-symbols-outlined text-sm">hard_drive</span>${this.formatFileSize(document?.FileSize)}</span>
<span class="inline-flex items-center gap-1"><span class="material-symbols-outlined text-sm">person</span>${this.escapeHtml(document?.UploadedByName || '-')}</span>
<span class="inline-flex items-center gap-1"><span class="material-symbols-outlined text-sm">schedule</span>${this.formatDateTime(document?.UploadedDate)}</span>
</div>
<div class="mt-3 flex flex-wrap items-center gap-2">
<a href="${this.apiBase}/documents/${documentId}/file" target="_blank" rel="noopener noreferrer" class="inline-flex items-center gap-1.5 rounded-lg bg-primary px-3 py-1.5 text-xs font-bold text-white hover:bg-primary-dim">
<span class="material-symbols-outlined text-base">visibility</span>Xem PDF
</a>
<a href="${this.apiBase}/documents/${documentId}/file?download=1" class="inline-flex items-center gap-1.5 rounded-lg border border-slate-200 px-3 py-1.5 text-xs font-bold text-slate-700 hover:bg-slate-50">
<span class="material-symbols-outlined text-base">download</span>Tải xuống
</a>
${canDelete ? `
<button type="button" class="delete-document ml-auto inline-flex items-center gap-1 rounded-lg px-2 py-1.5 text-xs font-bold text-red-600 hover:bg-red-50" data-document-id="${documentId}" data-document-title="${this.escapeHtml(document?.Title || document?.OriginalFileName || 'Tài liệu')}">
<span class="material-symbols-outlined text-base">delete</span>Xóa
</button>
` : ''}
</div>
</div>
</article>
`;
}).join('')}
</div>
` : `
<div class="flex flex-col items-center justify-center px-6 py-16 text-center">
<div class="flex h-16 w-16 items-center justify-center rounded-full bg-slate-100 text-slate-400">
<span class="material-symbols-outlined text-4xl">folder_open</span>
</div>
<h2 class="mt-4 font-extrabold text-slate-700">${this.documentSearchTerm ? 'Không tìm thấy tài liệu' : 'Chưa có tài liệu PDF'}</h2>
<p class="mt-1 max-w-md text-sm text-slate-500">${this.documentSearchTerm ? 'Hãy thử một từ khóa khác.' : 'Bấm “Thêm tài liệu” để lưu tài liệu đầu tiên.'}</p>
</div>
`}
</div>
</div>
<dialog id="documentUploadDialog" aria-labelledby="documentUploadDialogTitle" class="m-auto w-[min(92vw,34rem)] max-w-none overflow-hidden rounded-2xl border-0 bg-white p-0 text-slate-800 shadow-2xl backdrop:bg-slate-950/50 backdrop:backdrop-blur-sm">
<form id="documentUploadForm">
<div class="flex items-start justify-between gap-4 border-b border-slate-200 bg-red-50 px-5 py-4">
<div class="flex min-w-0 items-start gap-3">
<div class="flex h-10 w-10 shrink-0 items-center justify-center rounded-xl bg-red-100 text-red-600">
<span class="material-symbols-outlined">upload_file</span>
</div>
<div class="min-w-0">
<h2 id="documentUploadDialogTitle" class="text-lg font-extrabold text-slate-900">Thêm tài liệu PDF</h2>
<p class="mt-0.5 text-xs text-slate-500">Tải tài liệu lên kho dùng chung.</p>
</div>
</div>
<button type="button" data-close-document-dialog class="inline-flex h-9 w-9 shrink-0 items-center justify-center rounded-lg text-slate-500 transition hover:bg-white hover:text-slate-800 disabled:cursor-not-allowed disabled:opacity-50" aria-label="Đóng hộp thoại">
<span class="material-symbols-outlined">close</span>
</button>
</div>
<div class="space-y-4 px-5 py-5">
<div>
<label for="documentFile" class="mb-1.5 block text-sm font-bold text-slate-700">Chọn file PDF <span class="text-red-600">*</span></label>
<input id="documentFile" name="file" type="file" accept="application/pdf,.pdf" required
class="block h-11 w-full rounded-lg border border-slate-200 bg-white p-0 pr-3 text-sm file:mr-4 file:h-full file:border-0 file:bg-slate-100 file:px-4 file:font-bold file:text-slate-700 hover:file:bg-slate-200" />
<p class="mt-1.5 text-xs text-slate-500">Chỉ nhận định dạng PDF, tối đa ${this.documentMaxFileSizeMb} MB.</p>
</div>
<div>
<label for="documentTitle" class="mb-1.5 block text-sm font-bold text-slate-700">Tên hiển thị <span class="font-normal text-slate-400">(không bắt buộc)</span></label>
<input id="documentTitle" name="title" maxlength="255" placeholder="Mặc định lấy theo tên file"
class="h-11 w-full rounded-lg border-slate-200 bg-white text-sm focus:border-red-400 focus:ring-red-400" />
</div>
</div>
<div class="flex flex-col-reverse gap-2 border-t border-slate-200 bg-slate-50 px-5 py-4 sm:flex-row sm:justify-end">
<button type="button" data-close-document-dialog class="inline-flex h-10 items-center justify-center rounded-lg border border-slate-300 bg-white px-4 text-sm font-bold text-slate-700 transition hover:bg-slate-100 disabled:cursor-not-allowed disabled:opacity-50">Hủy</button>
<button id="documentUploadSubmit" type="submit" class="inline-flex h-10 items-center justify-center gap-2 rounded-lg bg-red-600 px-5 text-sm font-bold text-white shadow-sm transition hover:bg-red-700 disabled:cursor-not-allowed disabled:opacity-60">
<span class="material-symbols-outlined text-base">upload_file</span>
<span>Tải lên</span>
</button>
</div>
</form>
</dialog>
`;
}
setupDocumentListeners() {
const dialog = document.getElementById('documentUploadDialog');
const form = document.getElementById('documentUploadForm');
const openDialogButton = document.getElementById('openDocumentUploadDialog');
const closeDialog = () => {
if (!dialog || form?.dataset.uploading === 'true') return;
if (typeof dialog.close === 'function' && dialog.open) {
dialog.close();
} else {
dialog.removeAttribute('open');
}
};
if (openDialogButton && dialog && !openDialogButton.dataset.boundClick) {
openDialogButton.addEventListener('click', () => {
if (typeof dialog.showModal === 'function') {
if (!dialog.open) dialog.showModal();
} else {
dialog.setAttribute('open', '');
}
requestAnimationFrame(() => document.getElementById('documentFile')?.focus());
});
openDialogButton.dataset.boundClick = 'true';
}
document.querySelectorAll('[data-close-document-dialog]').forEach(button => {
if (button.dataset.boundClick) return;
button.addEventListener('click', closeDialog);
button.dataset.boundClick = 'true';
});
if (dialog && !dialog.dataset.boundDialog) {
dialog.addEventListener('cancel', event => {
if (form?.dataset.uploading === 'true') event.preventDefault();
});
dialog.addEventListener('close', () => form?.reset());
dialog.addEventListener('click', event => {
if (event.target !== dialog || form?.dataset.uploading === 'true') return;
const bounds = dialog.getBoundingClientRect();
const outside = event.clientX < bounds.left
|| event.clientX > bounds.right
|| event.clientY < bounds.top
|| event.clientY > bounds.bottom;
if (outside) closeDialog();
});
dialog.dataset.boundDialog = 'true';
}
if (form && !form.dataset.boundSubmit) {
form.addEventListener('submit', event => this.handleDocumentUpload(event));
form.dataset.boundSubmit = 'true';
}
const searchInput = document.getElementById('documentSearch');
if (searchInput && !searchInput.dataset.boundInput) {
searchInput.addEventListener('input', event => {
const cursor = event.target.selectionStart ?? event.target.value.length;
this.documentSearchTerm = event.target.value;
this.renderView('documents');
const nextInput = document.getElementById('documentSearch');
nextInput?.focus();
nextInput?.setSelectionRange(cursor, cursor);
});
searchInput.dataset.boundInput = 'true';
}
document.querySelectorAll('.delete-document').forEach(button => {
if (button.dataset.boundClick) return;
button.addEventListener('click', () => {
this.deleteDocument(button.dataset.documentId, button.dataset.documentTitle);
});
button.dataset.boundClick = 'true';
});
}
async handleDocumentUpload(event) {
event.preventDefault();
const form = event.currentTarget;
const fileInput = form.querySelector('#documentFile');
const titleInput = form.querySelector('#documentTitle');
const submitButton = form.querySelector('#documentUploadSubmit');
const file = fileInput?.files?.[0];
if (!file) {
this.notifyWarning('Vui lòng chọn file PDF');
return;
}
const extensionIsPdf = String(file.name || '').toLowerCase().endsWith('.pdf');
if (!extensionIsPdf || (file.type && file.type !== 'application/pdf')) {
this.notifyFailure('Chỉ chấp nhận file PDF');
return;
}
if (file.size > this.documentMaxFileSizeMb * 1024 * 1024) {
this.notifyFailure(`File vượt quá ${this.documentMaxFileSizeMb} MB`);
return;
}
const payload = new FormData();
payload.append('file', file);
payload.append('title', String(titleInput?.value || '').trim());
form.dataset.uploading = 'true';
form.querySelectorAll('[data-close-document-dialog]').forEach(button => {
button.disabled = true;
});
if (submitButton) {
submitButton.disabled = true;
submitButton.querySelector('span:last-child').textContent = 'Đang tải...';
}
try {
const response = await fetch(`${this.apiBase}/documents`, {
method: 'POST',
body: payload
});
const data = await response.json();
if (!response.ok || !data.success) {
throw new Error(data.message || 'Không thể tải tài liệu lên');
}
this.documentSearchTerm = '';
await this.fetchDocuments();
this.renderView('documents');
this.notifySuccess(data.message || 'Đã lưu tài liệu PDF');
} catch (err) {
console.error('Upload document error:', err);
this.notifyFailure(err.message || 'Không thể tải tài liệu lên');
delete form.dataset.uploading;
form.querySelectorAll('[data-close-document-dialog]').forEach(button => {
button.disabled = false;
});
if (submitButton) {
submitButton.disabled = false;
submitButton.querySelector('span:last-child').textContent = 'Tải lên';
}
}
}
async deleteDocument(documentId, title) {
const parsedId = Number.parseInt(documentId, 10);
if (!Number.isInteger(parsedId) || parsedId <= 0) return;
if (!window.confirm(`Xóa tài liệu “${String(title || 'Tài liệu')}”? Thao tác này không thể hoàn tác.`)) return;
try {
const response = await fetch(`${this.apiBase}/documents/${parsedId}`, { method: 'DELETE' });
const data = await response.json();
if (!response.ok || !data.success) {
throw new Error(data.message || 'Không thể xóa tài liệu');
}
await this.fetchDocuments();
this.renderView('documents');
this.notifySuccess(data.message || 'Đã xóa tài liệu');
} catch (err) {
console.error('Delete document error:', err);
this.notifyFailure(err.message || 'Không thể xóa tài liệu');
}
}
getApplicationsContent() {
const filteredApps = this.getFilteredApplications();
const pageInfo = this.getPaged(filteredApps, this.appPage, this.appPageSize);

View File

@@ -9,7 +9,7 @@
<link href="https://fonts.googleapis.com/css2?family=Manrope:wght@400;500;600;700;800&family=Inter:wght@300;400;500;600&display=swap" rel="stylesheet"/>
<!-- Material Symbols -->
<link href="https://fonts.googleapis.com/css2?family=Material+Symbols+Outlined:opsz,wght,FILL,GRAD@20..48,100..700,0..1,-50..200&display=swap" rel="stylesheet"/>
<link rel="stylesheet" href="../css/main.css" />
<link rel="stylesheet" href="../css/main.css?v=20260806-4" />
<!-- Notiflix Notify -->
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/notiflix@3.2.7/dist/notiflix-3.2.7.min.css" />
<script src="https://cdn.jsdelivr.net/npm/notiflix@3.2.7/dist/notiflix-aio-3.2.7.min.js"></script>
@@ -281,7 +281,7 @@
</a>
</div>
<!-- Primary Nav -->
<nav class="flex-1 px-3 space-y-4">
<nav class="flex-1 px-3 space-y-4 overflow-y-auto">
<div>
<div class="tree-label">Tài khoản</div>
<div class="tree-branch">
@@ -340,6 +340,16 @@
</div>
</div>
<div>
<div class="tree-label">Tài liệu</div>
<div class="tree-branch">
<a href="#documents" data-nav="documents" class="flex items-center gap-3 px-3 py-2 text-slate-600 dark:text-slate-400 hover:text-slate-900 hover:bg-slate-200/50 transition-all group cursor-pointer rounded-r-lg">
<span class="material-symbols-outlined">picture_as_pdf</span>
<span>Tài liệu PDF</span>
</a>
</div>
</div>
<div id="usersSection" class="pt-2 border-t border-outline-variant/10" style="display: none;">
<a id="usersNav" href="#users" data-nav="users" class="flex items-center gap-3 px-3 py-2 text-slate-600 dark:text-slate-400 hover:text-slate-900 hover:bg-slate-200/50 transition-all group cursor-pointer rounded-lg" style="display: none;">
<span class="material-symbols-outlined">people</span>
@@ -390,6 +400,6 @@
</div>
</main>
<script src="../js/app.js?v=20260717-1"></script>
<script src="../js/app.js?v=20260806-4"></script>
</body>
</html>