import { TreeEngine } from './js/tree-engine.js';
import { UIRender } from './js/ui-render.js';
// 🔐 SECURITY: Session management
class SessionManager {
static TOKEN_KEY = 'apsevtis_session_token';
static saveToken(token) {
sessionStorage.setItem(this.TOKEN_KEY, token);
}
static getToken() {
return sessionStorage.getItem(this.TOKEN_KEY);
}
static clearToken() {
sessionStorage.removeItem(this.TOKEN_KEY);
}
static isAuthenticated() {
return !!this.getToken();
}
}
// 📦 State management
const AppState = {
data: null,
currentYear: null,
availableYears: [],
isLoading: false
};
// 🎯 DOM Elements (cached for performance)
const DOM = {
loginScreen: document.getElementById('login-screen'),
loginForm: document.getElementById('login-form'),
loginBtn: document.getElementById('login-btn'),
loginBtnText: document.getElementById('login-btn-text'),
loginBtnLoading: document.getElementById('login-btn-loading'),
loginError: document.getElementById('login-error'),
loginErrorText: document.getElementById('login-error-text'),
passwordInput: document.getElementById('client-password'),
appContainer: document.getElementById('app-container'),
dashboard: document.getElementById('dashboard'),
uploadZone: document.getElementById('uploadZone'),
manualUpload: document.getElementById('manualUpload'),
loader: document.getElementById('loader'),
statusText: document.getElementById('statusText'),
yearIndicator: document.getElementById('yearIndicator'),
searchInput: document.getElementById('searchInput'),
fileInput: document.getElementById('fileInput'),
logoutBtn: document.getElementById('logout-btn'),
costTotal: document.getElementById('costTotal'),
revTotal: document.getElementById('revTotal'),
profitTotal: document.getElementById('profitTotal'),
tableBody: document.getElementById('tableBody')
};
// ✅ SECURITY: XSS-safe text setter
function setTextContent(element, text) {
if (element) element.textContent = text;
}
// ✅ SECURITY: Sanitize HTML (basic)
function escapeHtml(unsafe) {
return unsafe
.replace(/&/g, "&")
.replace(//g, ">")
.replace(/"/g, """)
.replace(/'/g, "'");
}
// 🔐 LOGIN HANDLER
async function handleLogin(event) {
event.preventDefault();
const password = DOM.passwordInput.value.trim();
if (!password) {
showLoginError('Zadejte prosím heslo');
return;
}
setLoadingState(true);
hideLoginError();
try {
const response = await fetch('/api/verify', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ password })
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.error || 'Přihlášení selhalo');
}
if (!data.token) {
throw new Error('Server nevrátil autentizační token');
}
// ✅ Uložení session tokenu
SessionManager.saveToken(data.token);
console.log('✅ Přihlášení úspěšné');
// Přechod na dashboard
DOM.loginScreen.classList.add('hidden');
DOM.appContainer.classList.remove('hidden');
// Načtení dat
await loadDataFromCloud();
} catch (error) {
console.error('Login error:', error);
showLoginError(error.message);
} finally {
setLoadingState(false);
}
}
function setLoadingState(isLoading) {
DOM.loginBtn.disabled = isLoading;
if (isLoading) {
DOM.loginBtnText.classList.add('hidden');
DOM.loginBtnLoading.classList.remove('hidden');
} else {
DOM.loginBtnText.classList.remove('hidden');
DOM.loginBtnLoading.classList.add('hidden');
}
}
function showLoginError(message) {
setTextContent(DOM.loginErrorText, message);
DOM.loginError.classList.remove('hidden');
DOM.passwordInput.focus();
DOM.passwordInput.select();
}
function hideLoginError() {
DOM.loginError.classList.add('hidden');
}
// 🚪 LOGOUT HANDLER
function handleLogout() {
if (!confirm('Opravdu se chcete odhlásit?')) return;
SessionManager.clearToken();
// Reset state
AppState.data = null;
AppState.currentYear = null;
AppState.availableYears = [];
// Vyčištění formuláře
DOM.passwordInput.value = '';
// Přechod na login
DOM.appContainer.classList.add('hidden');
DOM.loginScreen.classList.remove('hidden');
console.log('✅ Odhlášení úspěšné');
}
// 📊 DATA LOADING
async function loadDataFromCloud() {
const token = SessionManager.getToken();
if (!token) {
console.error('❌ Chybí autentizační token');
handleLogout();
return;
}
updateStatus('Načítám data z cloudu...', 'loading');
try {
const response = await fetch('/api/get-data', {
method: 'GET',
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
}
});
// ✅ Kontrola 401 Unauthorized
if (response.status === 401 || response.status === 403) {
SessionManager.clearToken();
throw new Error('Session vypršela. Přihlaste se znovu.');
}
if (!response.ok) {
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
const contentType = response.headers.get('content-type');
if (!contentType?.includes('application/json')) {
throw new Error(`Neplatný Content-Type: ${contentType}`);
}
const data = await response.json();
displayAnalytics(data);
updateStatus('Cloud synchronizován [R2]', 'success');
} catch (error) {
console.warn('R2 Error:', error.message);
if (error.message.includes('Session vypršela')) {
alert(error.message);
handleLogout();
return;
}
// Fallback na manuální upload
DOM.loader.classList.add('hidden');
DOM.manualUpload.classList.remove('hidden');
updateStatus('Cloud nedostupný - vyžadováno ruční nahrání', 'warning');
}
}
// 📈 DATA PROCESSING
function detectAvailableYears(data) {
const years = new Set();
data.forEach(entry => {
if (!entry.Datum) return;
const year = TreeEngine.parseYear(entry.Datum);
if (year) years.add(year);
});
return Array.from(years).sort().reverse();
}
function validateData(data) {
if (!Array.isArray(data)) {
throw new Error('Data musí být pole objektů (JSON array)');
}
if (data.length === 0) {
throw new Error('Soubor neobsahuje žádné transakce');
}
const requiredFields = ['Datum', 'Částka'];
const firstEntry = data[0];
const missingFields = requiredFields.filter(field => !(field in firstEntry));
if (missingFields.length > 0) {
throw new Error(`Chybí povinné sloupce: ${missingFields.join(', ')}`);
}
return true;
}
function displayAnalytics(data, selectedYear = null) {
try {
validateData(data);
AppState.data = data;
AppState.availableYears = detectAvailableYears(data);
if (AppState.availableYears.length === 0) {
throw new Error('V datech nebyl nalezen žádný platný rok');
}
if (!selectedYear || !AppState.availableYears.includes(selectedYear)) {
AppState.currentYear = AppState.availableYears[0];
} else {
AppState.currentYear = selectedYear;
}
const tree = TreeEngine.buildTree(data, AppState.currentYear);
// Update KPIs
setTextContent(DOM.costTotal, tree.costs.total.toLocaleString('cs-CZ') + ' Kč');
setTextContent(DOM.revTotal, tree.revenues.total.toLocaleString('cs-CZ') + ' Kč');
const profit = tree.revenues.total - tree.costs.total;
setTextContent(DOM.profitTotal, profit.toLocaleString('cs-CZ') + ' Kč');
DOM.profitTotal.className = DOM.profitTotal.className.replace(/text-(red|blue)-600/, '');
DOM.profitTotal.classList.add(profit >= 0 ? 'text-blue-600' : 'text-red-600');
createYearSelector();
UIRender.renderTree(tree, 'tableBody');
DOM.uploadZone.classList.add('hidden');
DOM.dashboard.classList.remove('hidden');
DOM.dashboard.classList.add('animate-fade-in');
console.log(`📊 Dostupné roky: ${AppState.availableYears.join(', ')}`);
console.log(`✅ Zobrazuji data pro rok: ${AppState.currentYear}`);
} catch (error) {
showError('Chyba při zpracování dat', error.message);
}
}
function createYearSelector() {
if (AppState.availableYears.length <= 1) {
setTextContent(DOM.yearIndicator, `FY ${AppState.currentYear}`);
DOM.yearIndicator.className = 'bg-blue-100 text-blue-700 px-3 py-1 rounded-full text-xs font-bold font-mono';
return;
}
// ✅ SECURITY: Escape year values
const options = AppState.availableYears.map(year => {
const selected = year === AppState.currentYear ? 'selected' : '';
return `
`;
}).join('');
DOM.yearIndicator.innerHTML = `
`;
const yearSelect = document.getElementById('yearSelect');
if (yearSelect) {
yearSelect.addEventListener('change', handleYearChange);
}
}
function handleYearChange(event) {
AppState.currentYear = event.target.value;
displayAnalytics(AppState.data, AppState.currentYear);
}
// 🎨 UI HELPERS
function updateStatus(message, type = 'loading') {
setTextContent(DOM.statusText, message);
DOM.statusText.className = 'text-xs font-medium uppercase tracking-wider mt-1';
DOM.statusText.classList.remove('loading-pulse', 'text-blue-500', 'text-green-600', 'text-amber-500', 'text-red-600');
switch (type) {
case 'loading':
DOM.statusText.classList.add('text-blue-500', 'loading-pulse');
break;
case 'success':
DOM.statusText.classList.add('text-green-600');
break;
case 'warning':
DOM.statusText.classList.add('text-amber-500');
break;
case 'error':
DOM.statusText.classList.add('text-red-600');
break;
}
}
function showError(title, message) {
DOM.loader.classList.add('hidden');
DOM.manualUpload.classList.add('hidden');
// ✅ SECURITY: Escape user-provided content
const safeTitle = escapeHtml(title);
const safeMessage = escapeHtml(message);
const errorDiv = document.createElement('div');
errorDiv.className = 'text-center';
errorDiv.innerHTML = `
${safeTitle}
${safeMessage}
`;
DOM.uploadZone.innerHTML = '';
DOM.uploadZone.appendChild(errorDiv);
DOM.uploadZone.classList.remove('hidden');
document.getElementById('retry-btn')?.addEventListener('click', () => location.reload());
updateStatus('Chyba při načítání dat', 'error');
console.error(`${title}:`, message);
}
// 📁 FILE UPLOAD HANDLER
function handleFileUpload(event) {
const file = event.target.files[0];
if (!file) return;
if (!file.name.endsWith('.json')) {
showError('Neplatný formát souboru', 'Prosím nahrajte soubor s příponou .json');
return;
}
const maxSize = 10 * 1024 * 1024; // 10 MB
if (file.size > maxSize) {
showError(
'Soubor je příliš velký',
`Maximální velikost je 10 MB (váš soubor: ${(file.size / 1024 / 1024).toFixed(2)} MB)`
);
return;
}
const reader = new FileReader();
reader.onerror = () => {
showError('Chyba při čtení souboru', 'Nepodařilo se načíst soubor z disku');
};
reader.onload = (ev) => {
try {
const data = JSON.parse(ev.target.result);
displayAnalytics(data);
updateStatus(`Zobrazen lokální soubor: ${escapeHtml(file.name)}`, 'success');
} catch (parseError) {
showError(
'Neplatný JSON soubor',
`Soubor obsahuje chyby: ${parseError.message}`
);
}
};
reader.readAsText(file);
}
// 🔍 SEARCH HANDLER (debounced)
let searchTimeout;
function handleSearch(event) {
clearTimeout(searchTimeout);
searchTimeout = setTimeout(() => {
const term = event.target.value.toLowerCase().trim();
const rows = DOM.tableBody.querySelectorAll('tr');
let visibleCount = 0;
rows.forEach(row => {
const rowText = row.textContent.toLowerCase();
const isVisible = !term || rowText.includes(term);
row.style.display = isVisible ? '' : 'none';
if (isVisible) visibleCount++;
});
// ✅ Accessibility: Announce search results
const announcement = term
? `Nalezeno ${visibleCount} výsledků pro "${term}"`
: 'Zobrazeny všechny položky';
console.log(announcement);
// Update status for screen readers
if (term && visibleCount === 0) {
updateStatus('Žádné výsledky vyhledávání', 'warning');
}
}, 300); // 300ms debounce
}
// 🎯 EVENT LISTENERS
function initializeEventListeners() {
// Login
DOM.loginForm?.addEventListener('submit', handleLogin);
// Logout
DOM.logoutBtn?.addEventListener('click', handleLogout);
// File upload
DOM.fileInput?.addEventListener('change', handleFileUpload);
// Search
DOM.searchInput?.addEventListener('input', handleSearch);
// Keyboard shortcuts
document.addEventListener('keydown', handleKeyboardShortcuts);
// Session expiry check (every 5 minutes)
setInterval(checkSessionValidity, 5 * 60 * 1000);
}
// ⌨️ KEYBOARD SHORTCUTS
function handleKeyboardShortcuts(event) {
// Ctrl/Cmd + K: Focus search
if ((event.ctrlKey || event.metaKey) && event.key === 'k') {
event.preventDefault();
DOM.searchInput?.focus();
}
// Escape: Clear search
if (event.key === 'Escape' && DOM.searchInput === document.activeElement) {
DOM.searchInput.value = '';
handleSearch({ target: DOM.searchInput });
}
// Ctrl/Cmd + R: Refresh data (with confirmation)
if ((event.ctrlKey || event.metaKey) && event.key === 'r' && SessionManager.isAuthenticated()) {
if (confirm('Obnovit data z cloudu?')) {
event.preventDefault();
loadDataFromCloud();
}
}
}
// ⏰ SESSION VALIDITY CHECK
async function checkSessionValidity() {
if (!SessionManager.isAuthenticated()) return;
const token = SessionManager.getToken();
try {
const response = await fetch('/api/verify-session', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${token}`
}
});
if (response.status === 401 || response.status === 403) {
console.warn('⚠️ Session expired');
alert('Vaše session vypršela. Budete přesměrováni na přihlášení.');
handleLogout();
}
} catch (error) {
console.error('Session check failed:', error);
// Neodhlašujeme při síťové chybě, jen logujeme
}
}
// 🚀 APP INITIALIZATION
async function initializeApp() {
console.log('🚀 Inicializace Apsevtis Cloud Analytics...');
// Initialize event listeners
initializeEventListeners();
// Check if already authenticated
if (SessionManager.isAuthenticated()) {
console.log('✅ Nalezena aktivní session');
// Skip login screen
DOM.loginScreen.classList.add('hidden');
DOM.appContainer.classList.remove('hidden');
// Load data
await loadDataFromCloud();
} else {
console.log('🔐 Vyžadováno přihlášení');
// Show login screen
DOM.loginScreen.classList.remove('hidden');
DOM.appContainer.classList.add('hidden');
// Focus password input
DOM.passwordInput?.focus();
}
}
// 🎬 START APPLICATION
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initializeApp);
} else {
initializeApp();
}
// 🧹 CLEANUP ON PAGE UNLOAD
window.addEventListener('beforeunload', () => {
// Clear sensitive data from memory
if (AppState.data) {
AppState.data = null;
}
// Note: sessionStorage persists across page reloads but not browser close
console.log('🧹 Cleanup completed');
});
// 🔄 HANDLE VISIBILITY CHANGE (tab switching)
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'visible' && SessionManager.isAuthenticated()) {
console.log('👁️ Tab visible - checking session validity');
checkSessionValidity();
}
});
// 📱 HANDLE ONLINE/OFFLINE STATUS
window.addEventListener('online', () => {
console.log('🌐 Connection restored');
updateStatus('Připojení obnoveno', 'success');
if (SessionManager.isAuthenticated() && !AppState.data) {
loadDataFromCloud();
}
});
window.addEventListener('offline', () => {
console.log('📡 Connection lost');
updateStatus('Offline režim - některé funkce nemusí být dostupné', 'warning');
});
// 🐛 GLOBAL ERROR HANDLER
window.addEventListener('error', (event) => {
console.error('💥 Global error:', event.error);
// Don't show error for known issues
if (event.error?.message?.includes('ResizeObserver')) {
return; // Ignore ResizeObserver errors (browser bug)
}
// Log to console but don't disrupt UX
if (SessionManager.isAuthenticated()) {
updateStatus('Došlo k neočekávané chybě', 'error');
}
});
window.addEventListener('unhandledrejection', (event) => {
console.error('💥 Unhandled promise rejection:', event.reason);
// Prevent default error display
event.preventDefault();
if (SessionManager.isAuthenticated()) {
updateStatus('Chyba při zpracování požadavku', 'error');
}
});
// 🔧 EXPOSE FOR DEBUGGING (only in development)
if (window.location.hostname === 'localhost' || window.location.hostname === '127.0.0.1') {
window.__DEBUG__ = {
AppState,
SessionManager,
reloadData: loadDataFromCloud,
logout: handleLogout,
version: '2.0.0'
};
console.log('🔧 Debug mode enabled. Access via window.__DEBUG__');
}