Excel_managment/public/app.js

693 lines
22 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// Excel Management System Client Logic
document.addEventListener('DOMContentLoaded', () => {
// State
let currentFileId = null;
let currentFileName = '';
let currentSheetNames = [];
let currentActiveSheet = '';
let currentSheetRows = [];
let currentTotalCols = 0;
let renderedRowCount = 0;
let isDirty = false;
const CHUNK_SIZE = 200;
// DOM Elements - Navigation & Header
const brandHome = document.getElementById('brand-home');
const viewerNavInfo = document.getElementById('viewer-nav-info');
const currentFilenameText = document.getElementById('current-filename-text');
const btnBackDashboard = document.getElementById('btn-back-dashboard');
const btnDownloadFile = document.getElementById('btn-download-file');
const notificationBar = document.getElementById('notification-bar');
const notificationMessage = document.getElementById('notification-message');
const notificationClose = document.getElementById('notification-close');
// DOM Elements - Views
const dashboardView = document.getElementById('dashboard-view');
const spreadsheetView = document.getElementById('spreadsheet-view');
// DOM Elements - Dashboard
const dropzone = document.getElementById('dropzone');
const fileInput = document.getElementById('file-input');
const uploadProgress = document.getElementById('upload-progress');
const filesGrid = document.getElementById('files-grid');
const filesEmpty = document.getElementById('files-empty');
const filesLoading = document.getElementById('files-loading');
const fileCountBadge = document.getElementById('file-count-badge');
// DOM Elements - Spreadsheet Viewer
const btnViewerBack = document.getElementById('btn-viewer-back');
const btnViewerDownload = document.getElementById('btn-viewer-download');
const btnSaveSheet = document.getElementById('btn-save-sheet');
const btnAddRow = document.getElementById('btn-add-row');
const unsavedBadge = document.getElementById('unsaved-badge');
const sheetFileTitle = document.getElementById('sheet-file-title');
const sheetStats = document.getElementById('sheet-stats');
const sheetTabsList = document.getElementById('sheet-tabs-list');
const gridViewport = document.getElementById('grid-viewport');
const gridLoading = document.getElementById('grid-loading');
const sheetTable = document.getElementById('spreadsheet-table');
const sheetThead = document.getElementById('sheet-thead');
const sheetTbody = document.getElementById('sheet-tbody');
const sheetEmptyMsg = document.getElementById('sheet-empty-msg');
const renderedRowsInfo = document.getElementById('rendered-rows-info');
// ==========================================
// Helper Functions
// ==========================================
function showNotification(message, isSuccess = false) {
notificationBar.className = 'notification-bar' + (isSuccess ? ' success' : '');
notificationMessage.textContent = message;
notificationBar.style.display = 'block';
if (isSuccess) {
setTimeout(() => {
hideNotification();
}, 5000);
}
}
function hideNotification() {
notificationBar.style.display = 'none';
}
notificationClose.addEventListener('click', hideNotification);
function formatBytes(bytes) {
if (!bytes || bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i];
}
function formatDate(isoString) {
if (!isoString) return 'Just now';
const date = new Date(isoString);
return date.toLocaleDateString(undefined, {
month: 'short',
day: 'numeric',
year: 'numeric',
hour: '2-digit',
minute: '2-digit'
});
}
// Convert zero-indexed column number to Excel column letter (0 -> 'A', 25 -> 'Z', 26 -> 'AA')
function getExcelColLetter(index) {
let letter = '';
let temp = index;
while (temp >= 0) {
letter = String.fromCharCode((temp % 26) + 65) + letter;
temp = Math.floor(temp / 26) - 1;
}
return letter;
}
// ==========================================
// Navigation & View Switching
// ==========================================
function navigateToDashboard() {
if (isDirty) {
if (!confirm('You have unsaved changes in the spreadsheet. Do you want to leave without saving?')) {
return;
}
}
isDirty = false;
unsavedBadge.style.display = 'none';
currentFileId = null;
currentFileName = '';
currentSheetRows = [];
renderedRowCount = 0;
// Reset views
spreadsheetView.style.display = 'none';
spreadsheetView.classList.remove('active');
dashboardView.style.display = 'flex';
dashboardView.classList.add('active');
// Reset top nav
viewerNavInfo.style.display = 'none';
btnBackDashboard.style.display = 'none';
btnDownloadFile.style.display = 'none';
window.location.hash = '';
loadFilesList();
}
function navigateToSpreadsheet(fileId, fileName) {
currentFileId = fileId;
currentFileName = fileName;
// Update views
dashboardView.style.display = 'none';
dashboardView.classList.remove('active');
spreadsheetView.style.display = 'flex';
spreadsheetView.classList.add('active');
// Update top nav
currentFilenameText.textContent = fileName;
sheetFileTitle.textContent = fileName;
viewerNavInfo.style.display = 'inline-flex';
btnBackDashboard.style.display = 'inline-flex';
btnDownloadFile.style.display = 'inline-flex';
window.location.hash = `#file=${encodeURIComponent(fileId)}`;
loadFileDetailsAndSheet(fileId);
}
brandHome.addEventListener('click', navigateToDashboard);
btnBackDashboard.addEventListener('click', navigateToDashboard);
btnViewerBack.addEventListener('click', navigateToDashboard);
// Download Action
function triggerDownload() {
if (!currentFileId) return;
window.location.href = `/api/files/${encodeURIComponent(currentFileId)}/download`;
}
btnDownloadFile.addEventListener('click', triggerDownload);
btnViewerDownload.addEventListener('click', triggerDownload);
// ==========================================
// Dashboard & File Listing
// ==========================================
async function loadFilesList() {
filesLoading.style.display = 'flex';
filesEmpty.style.display = 'none';
filesGrid.innerHTML = '';
try {
const res = await fetch('/api/files');
const data = await res.json();
if (!res.ok) {
throw new Error(data.error || 'Failed to fetch files');
}
const files = data.files || [];
fileCountBadge.textContent = `${files.length} file${files.length === 1 ? '' : 's'}`;
if (files.length === 0) {
filesEmpty.style.display = 'block';
} else {
renderFileCards(files);
}
} catch (err) {
showNotification('Error loading files: ' + err.message);
} finally {
filesLoading.style.display = 'none';
}
}
function renderFileCards(files) {
filesGrid.innerHTML = '';
files.forEach(file => {
const card = document.createElement('div');
card.className = 'file-card';
const sheetCount = file.sheetNames ? file.sheetNames.length : 1;
card.innerHTML = `
<div class="file-card-main">
<div class="file-badge-icon">
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z"></path>
<polyline points="14 2 14 8 20 8"></polyline>
<line x1="8" y1="13" x2="16" y2="13"></line>
<line x1="8" y1="17" x2="16" y2="17"></line>
</svg>
</div>
<div class="file-details">
<div class="file-card-title" title="${file.originalName}">${file.originalName}</div>
<div class="file-meta-row">
<span class="file-meta-item">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21 16V8a2 2 0 0 0-1-1.73l-7-4a2 2 0 0 0-2 0l-7 4A2 2 0 0 0 3 8v8a2 2 0 0 0 1 1.73l7 4a2 2 0 0 0 2 0l7-4A2 2 0 0 0 21 16z"></path>
</svg>
${formatBytes(file.size)}
</span>
<span>•</span>
<span class="file-meta-item">
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<circle cx="12" cy="12" r="10"></circle>
<polyline points="12 6 12 12 16 14"></polyline>
</svg>
${formatDate(file.uploadedAt)}
</span>
<span>•</span>
<span class="file-meta-item">${sheetCount} sheet${sheetCount > 1 ? 's' : ''}</span>
</div>
</div>
</div>
<div class="file-card-actions">
<button class="btn btn-sm btn-primary btn-open-sheet">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"></path>
<circle cx="12" cy="12" r="3"></circle>
</svg>
Open Spreadsheet
</button>
<button class="btn btn-sm btn-secondary btn-card-download" title="Download original file">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path>
<polyline points="7 10 12 15 17 10"></polyline>
<line x1="12" y1="15" x2="12" y2="3"></line>
</svg>
</button>
</div>
`;
// Click on card or "Open" button opens the spreadsheet
card.addEventListener('click', (e) => {
// Prevent trigger if download button was clicked
if (e.target.closest('.btn-card-download')) {
e.stopPropagation();
window.location.href = `/api/files/${encodeURIComponent(file.id)}/download`;
return;
}
navigateToSpreadsheet(file.id, file.originalName);
});
filesGrid.appendChild(card);
});
}
// ==========================================
// File Upload Handling
// ==========================================
function validateExcelFile(file) {
if (!file) return false;
const name = file.name.toLowerCase();
return name.endsWith('.xlsx') || name.endsWith('.xls');
}
async function handleFileUpload(file) {
if (!file) return;
if (!validateExcelFile(file)) {
showNotification('Invalid file type. Please upload a valid Excel file (.xlsx or .xls).');
return;
}
hideNotification();
uploadProgress.style.display = 'flex';
const formData = new FormData();
formData.append('file', file);
try {
const res = await fetch('/api/upload', {
method: 'POST',
body: formData
});
const data = await res.json();
if (!res.ok) {
throw new Error(data.error || 'Failed to upload Excel file.');
}
showNotification(`"${data.file.originalName}" uploaded successfully!`, true);
// Refresh files list
await loadFilesList();
} catch (err) {
showNotification('Upload Error: ' + err.message);
} finally {
uploadProgress.style.display = 'none';
fileInput.value = '';
}
}
fileInput.addEventListener('change', (e) => {
if (e.target.files && e.target.files[0]) {
handleFileUpload(e.target.files[0]);
}
});
// Drag and Drop
['dragenter', 'dragover'].forEach(eventName => {
dropzone.addEventListener(eventName, (e) => {
e.preventDefault();
e.stopPropagation();
dropzone.classList.add('drag-over');
});
});
['dragleave', 'drop'].forEach(eventName => {
dropzone.addEventListener(eventName, (e) => {
e.preventDefault();
e.stopPropagation();
dropzone.classList.remove('drag-over');
});
});
dropzone.addEventListener('drop', (e) => {
const dt = e.dataTransfer;
if (dt && dt.files && dt.files[0]) {
handleFileUpload(dt.files[0]);
}
});
// ==========================================
// Spreadsheet Viewer & Worksheet Switching
// ==========================================
async function loadFileDetailsAndSheet(fileId, targetSheet = null) {
if (isDirty) {
if (!confirm('You have unsaved changes in this sheet. Switch without saving?')) {
return;
}
}
isDirty = false;
unsavedBadge.style.display = 'none';
gridLoading.style.display = 'flex';
sheetEmptyMsg.style.display = 'none';
sheetThead.innerHTML = '';
sheetTbody.innerHTML = '';
sheetTabsList.innerHTML = '';
renderedRowCount = 0;
renderedRowsInfo.textContent = 'Loading...';
try {
let url = `/api/files/${encodeURIComponent(fileId)}/sheet`;
if (targetSheet) {
url += `?name=${encodeURIComponent(targetSheet)}`;
}
const res = await fetch(url);
const data = await res.json();
if (!res.ok) {
throw new Error(data.error || 'Failed to load sheet data.');
}
currentSheetNames = data.sheetNames || [];
currentActiveSheet = data.sheetName;
currentSheetRows = data.rows || [];
currentTotalCols = data.totalCols;
// Render Sheet Tabs
renderSheetTabs(currentSheetNames, currentActiveSheet);
// Render Grid
renderSpreadsheetGrid(currentSheetRows, data.totalCols);
// Update stats badge
sheetStats.textContent = `Sheet: ${currentActiveSheet} (${data.totalRows} rows × ${data.totalCols} cols)`;
} catch (err) {
showNotification('Error loading spreadsheet: ' + err.message);
} finally {
gridLoading.style.display = 'none';
}
}
function renderSheetTabs(sheetNames, activeSheet) {
sheetTabsList.innerHTML = '';
sheetNames.forEach(name => {
const tab = document.createElement('button');
tab.className = 'sheet-tab' + (name === activeSheet ? ' active' : '');
tab.innerHTML = `
<svg width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<line x1="3" y1="3" x2="21" y2="3"></line>
<line x1="3" y1="9" x2="21" y2="9"></line>
<line x1="3" y1="15" x2="21" y2="15"></line>
<line x1="3" y1="21" x2="21" y2="21"></line>
</svg>
<span>${escapeHtml(name)}</span>
`;
tab.addEventListener('click', () => {
if (name !== currentActiveSheet) {
loadFileDetailsAndSheet(currentFileId, name);
}
});
sheetTabsList.appendChild(tab);
});
}
function renderSpreadsheetGrid(rows, totalCols) {
sheetThead.innerHTML = '';
sheetTbody.innerHTML = '';
renderedRowCount = 0;
if (!rows || rows.length === 0 || totalCols === 0) {
sheetEmptyMsg.style.display = 'block';
renderedRowsInfo.textContent = '0 rows';
return;
}
sheetEmptyMsg.style.display = 'none';
// 1. Build Header Row with Excel Column Letters (A, B, C...)
const headerRow = document.createElement('tr');
// Corner cell (top-left intersection for row & column headers)
const cornerCell = document.createElement('th');
cornerCell.className = 'corner-cell';
cornerCell.textContent = '';
headerRow.appendChild(cornerCell);
// Column letters: A, B, C...
for (let c = 0; c < totalCols; c++) {
const th = document.createElement('th');
th.textContent = getExcelColLetter(c);
th.title = `Column ${getExcelColLetter(c)}`;
headerRow.appendChild(th);
}
sheetThead.appendChild(headerRow);
// 2. Render initial chunk of rows
appendRowChunk();
// Reset scroll position to top
gridViewport.scrollTop = 0;
gridViewport.scrollLeft = 0;
}
// Progressive rendering for large datasets
function appendRowChunk() {
if (renderedRowCount >= currentSheetRows.length) return;
const nextBatchEnd = Math.min(renderedRowCount + CHUNK_SIZE, currentSheetRows.length);
const fragment = document.createDocumentFragment();
for (let r = renderedRowCount; r < nextBatchEnd; r++) {
const rowData = currentSheetRows[r];
const tr = document.createElement('tr');
// Left-hand row number cell (1-indexed)
const rowHeader = document.createElement('th');
rowHeader.className = 'row-header';
rowHeader.textContent = r + 1;
tr.appendChild(rowHeader);
// Data cells (editable)
for (let c = 0; c < rowData.length; c++) {
const td = document.createElement('td');
const val = rowData[c];
td.textContent = (val !== undefined && val !== null) ? val : '';
td.className = 'editable-cell';
td.contentEditable = 'true';
td.spellcheck = false;
td.dataset.row = r;
td.dataset.col = c;
// Track edits
td.addEventListener('input', () => {
const newVal = td.innerText;
currentSheetRows[r][c] = newVal;
td.classList.add('cell-modified');
if (!isDirty) {
isDirty = true;
unsavedBadge.style.display = 'inline-flex';
}
});
// Navigation on Enter
td.addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
e.preventDefault();
const nextRow = r + 1;
const nextCell = sheetTbody.querySelector(`td[data-row="${nextRow}"][data-col="${c}"]`);
if (nextCell) {
nextCell.focus();
} else {
td.blur();
}
}
});
tr.appendChild(td);
}
fragment.appendChild(tr);
}
sheetTbody.appendChild(fragment);
renderedRowCount = nextBatchEnd;
// Update row count footer
renderedRowsInfo.textContent = `Showing ${renderedRowCount} of ${currentSheetRows.length} rows`;
}
// Add Row Button Handler
btnAddRow.addEventListener('click', () => {
if (!currentFileId) return;
const cols = currentTotalCols > 0 ? currentTotalCols : (currentSheetRows[0] ? currentSheetRows[0].length : 5);
const newRow = new Array(cols).fill('');
const rowIndex = currentSheetRows.length;
currentSheetRows.push(newRow);
const tr = document.createElement('tr');
const rowHeader = document.createElement('th');
rowHeader.className = 'row-header';
rowHeader.textContent = rowIndex + 1;
tr.appendChild(rowHeader);
for (let c = 0; c < cols; c++) {
const td = document.createElement('td');
td.textContent = '';
td.className = 'editable-cell cell-modified';
td.contentEditable = 'true';
td.spellcheck = false;
td.dataset.row = rowIndex;
td.dataset.col = c;
td.addEventListener('input', () => {
currentSheetRows[rowIndex][c] = td.innerText;
td.classList.add('cell-modified');
if (!isDirty) {
isDirty = true;
unsavedBadge.style.display = 'inline-flex';
}
});
td.addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
e.preventDefault();
td.blur();
}
});
tr.appendChild(td);
}
sheetTbody.appendChild(tr);
renderedRowCount++;
sheetStats.textContent = `Sheet: ${currentActiveSheet} (${currentSheetRows.length} rows × ${cols} cols)`;
renderedRowsInfo.textContent = `Showing ${renderedRowCount} of ${currentSheetRows.length} rows`;
if (!isDirty) {
isDirty = true;
unsavedBadge.style.display = 'inline-flex';
}
// Scroll to bottom & focus the new row's first cell
gridViewport.scrollTop = gridViewport.scrollHeight;
const firstCell = tr.querySelector('td');
if (firstCell) firstCell.focus();
});
// Save Sheet Button Handler
btnSaveSheet.addEventListener('click', async () => {
if (!currentFileId || !currentActiveSheet) return;
const originalBtnHtml = btnSaveSheet.innerHTML;
btnSaveSheet.disabled = true;
btnSaveSheet.innerHTML = `
<div class="spinner" style="width:14px;height:14px;border-width:2px;display:inline-block;vertical-align:middle;margin-right:4px;"></div>
Saving...
`;
try {
const res = await fetch(`/api/files/${encodeURIComponent(currentFileId)}/sheet`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
sheetName: currentActiveSheet,
rows: currentSheetRows
})
});
const data = await res.json();
if (!res.ok) {
throw new Error(data.error || 'Failed to save worksheet.');
}
isDirty = false;
unsavedBadge.style.display = 'none';
// Clear modified styling on all cells
sheetTbody.querySelectorAll('.cell-modified').forEach(cell => {
cell.classList.remove('cell-modified');
});
showNotification(`"${currentActiveSheet}" saved successfully to Excel file!`, true);
} catch (err) {
showNotification('Save Error: ' + err.message);
} finally {
btnSaveSheet.disabled = false;
btnSaveSheet.innerHTML = originalBtnHtml;
}
});
// Infinite scroll listener on table viewport
gridViewport.addEventListener('scroll', () => {
if (renderedRowCount >= currentSheetRows.length) return;
const scrollBottom = gridViewport.scrollHeight - gridViewport.scrollTop - gridViewport.clientHeight;
// Trigger next chunk when user scrolls within 300px of bottom
if (scrollBottom < 300) {
appendRowChunk();
}
});
function escapeHtml(text) {
if (!text) return '';
return String(text)
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#039;');
}
// Check URL hash on startup for direct link support
function checkUrlHash() {
const hash = window.location.hash;
if (hash && hash.startsWith('#file=')) {
const fileId = decodeURIComponent(hash.substring(6));
if (fileId) {
navigateToSpreadsheet(fileId, fileId);
return;
}
}
loadFilesList();
}
window.addEventListener('popstate', () => {
checkUrlHash();
});
// Initial load
checkUrlHash();
});