Files
zynktime/static/js/app.js
T

547 lines
18 KiB
JavaScript

// State
let selectedFile = null;
let projects = {};
let lastEvents = [];
let previewData = [];
// DOM Elements
const uploadZone = document.getElementById('uploadZone');
const fileInput = document.getElementById('fileInput');
const browseLink = document.getElementById('browseLink');
const filePreview = document.getElementById('filePreview');
const fileName = document.getElementById('fileName');
const fileSize = document.getElementById('fileSize');
const removeFileBtn = document.getElementById('removeFile');
const projectSelect = document.getElementById('projectSelect');
const submitBtn = document.getElementById('submitBtn');
const statusBadge = document.getElementById('statusBadge');
const resultsSection = document.getElementById('resultsSection');
const resultsContent = document.getElementById('resultsContent');
const projectsList = document.getElementById('projectsList');
const refreshProjectsBtn = document.getElementById('refreshProjects');
const loadingOverlay = document.getElementById('loadingOverlay');
const toastContainer = document.getElementById('toastContainer');
const startDateInput = document.getElementById('startDate');
const endDateInput = document.getElementById('endDate');
const loadEventsBtn = document.getElementById('loadEvents');
const approveEventsBtn = document.getElementById('approveEvents');
const eventsList = document.getElementById('eventsList');
const eventsTotal = document.getElementById('eventsTotal');
// Initialize
document.addEventListener('DOMContentLoaded', () => {
checkHealth();
loadProjects();
setDefaultDateRange();
setupEventListeners();
});
// Event Listeners
function setupEventListeners() {
// File upload
uploadZone.addEventListener('click', () => fileInput.click());
browseLink.addEventListener('click', (e) => {
e.stopPropagation();
fileInput.click();
});
fileInput.addEventListener('change', handleFileSelect);
removeFileBtn.addEventListener('click', (e) => {
e.stopPropagation();
removeFile();
});
// Drag and drop
uploadZone.addEventListener('dragover', handleDragOver);
uploadZone.addEventListener('dragleave', handleDragLeave);
uploadZone.addEventListener('drop', handleDrop);
// Submit
submitBtn.addEventListener('click', handleSubmit);
// Refresh projects
refreshProjectsBtn.addEventListener('click', () => {
refreshProjectsBtn.classList.add('spin');
loadProjects().finally(() => {
setTimeout(() => refreshProjectsBtn.classList.remove('spin'), 500);
});
});
// Approvals
loadEventsBtn.addEventListener('click', fetchEvents);
approveEventsBtn.addEventListener('click', approveEvents);
}
// API Functions
async function checkHealth() {
try {
const response = await fetch('/health');
const data = await response.json();
const statusText = statusBadge.querySelector('.status-text');
if (data.status === 'healthy') {
statusBadge.classList.add('healthy');
statusText.textContent = 'Connected';
} else {
statusBadge.classList.add('unhealthy');
statusText.textContent = 'No API Key';
showToast('Please configure KLEER_API_KEY environment variable', 'warning');
}
} catch (error) {
statusBadge.classList.add('unhealthy');
statusBadge.querySelector('.status-text').textContent = 'Offline';
showToast('Cannot connect to server', 'error');
}
}
async function loadProjects() {
try {
const response = await fetch('/projects');
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
projects = await response.json();
populateProjectSelect();
displayProjects();
} catch (error) {
console.error('Error loading projects:', error);
projectSelect.innerHTML = '<option value="">Failed to load projects</option>';
projectsList.innerHTML = `<div class="loading" style="color: var(--danger);">Failed to load projects: ${error.message}</div>`;
showToast('Failed to load projects', 'error');
}
}
function populateProjectSelect() {
projectSelect.innerHTML = '<option value="">Select a project...</option>';
Object.keys(projects).forEach(projectName => {
const option = document.createElement('option');
option.value = projectName;
option.textContent = projectName;
projectSelect.appendChild(option);
});
projectSelect.disabled = false;
updateSubmitButton();
}
function displayProjects() {
const projectNames = Object.keys(projects);
if (projectNames.length === 0) {
projectsList.innerHTML = '<div class="loading">No projects found</div>';
return;
}
projectsList.innerHTML = '';
projectNames.forEach(projectName => {
const project = projects[projectName];
const projectItem = document.createElement('div');
projectItem.className = 'project-item';
projectItem.innerHTML = `
<div class="project-name">${projectName}</div>
<div class="project-meta">
<span>ID: ${project.id}</span>
<span>Activity: ${project.activity.id || 'N/A'}</span>
</div>
`;
projectsList.appendChild(projectItem);
});
}
// File Handling
function handleFileSelect(e) {
const file = e.target.files[0];
if (file) {
if (isSupportedFile(file.name)) {
setFile(file);
} else {
showToast('Please select a CSV or XLSX file', 'warning');
fileInput.value = '';
}
}
}
function handleDragOver(e) {
e.preventDefault();
uploadZone.classList.add('drag-over');
}
function handleDragLeave(e) {
e.preventDefault();
uploadZone.classList.remove('drag-over');
}
function handleDrop(e) {
e.preventDefault();
uploadZone.classList.remove('drag-over');
const file = e.dataTransfer.files[0];
if (file) {
if (isSupportedFile(file.name)) {
setFile(file);
} else {
showToast('Please drop a CSV or XLSX file', 'warning');
}
}
}
function isSupportedFile(fileName) {
const lower = fileName.toLowerCase();
return lower.endsWith('.csv') || lower.endsWith('.xlsx');
}
function setFile(file) {
selectedFile = file;
// Show file preview
uploadZone.style.display = 'none';
filePreview.style.display = 'block';
fileName.textContent = file.name;
fileSize.textContent = formatFileSize(file.size);
updateSubmitButton();
generatePreview(file);
}
async function generatePreview(file) {
const formData = new FormData();
formData.append('file', file);
showLoading(true);
try {
const response = await fetch('/preview-time', {
method: 'POST',
body: formData
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.detail || `HTTP ${response.status}`);
}
renderPreview(data);
} catch (error) {
console.error('Preview error:', error);
showToast('Failed to generate preview: ' + error.message, 'error');
document.getElementById('previewContainer').style.display = 'none';
} finally {
showLoading(false);
}
}
function renderPreview(data) {
previewData = data.map(item => ({...item, selected: true}));
const container = document.getElementById('previewContainer');
container.style.display = 'block';
updatePreviewTable();
document.getElementById('selectAllPreview').onchange = (e) => {
const checked = e.target.checked;
previewData.forEach(item => item.selected = checked);
updatePreviewTable();
};
}
// Ensure togglePreviewItem is available globally for inline onclick handlers
window.togglePreviewItem = function(index) {
previewData[index].selected = !previewData[index].selected;
// Update "select all" checkbox state
const allSelected = previewData.every(item => item.selected);
const noneSelected = previewData.every(item => !item.selected);
const selectAllCheckbox = document.getElementById('selectAllPreview');
selectAllCheckbox.checked = allSelected;
selectAllCheckbox.indeterminate = !allSelected && !noneSelected;
updatePreviewTable();
};
function updatePreviewTable() {
const tbody = document.getElementById('previewTableBody');
tbody.innerHTML = '';
let totalHours = 0;
let selectedCount = 0;
previewData.forEach((item, index) => {
if (item.selected) {
totalHours += item.hours;
selectedCount++;
}
const tr = document.createElement('tr');
if (!item.selected) tr.classList.add('excluded');
tr.innerHTML = `
<td><input type="checkbox" ${item.selected ? 'checked' : ''} onclick="event.stopPropagation(); togglePreviewItem(${index})"></td>
<td>${item.date}</td>
<td>${item.hours.toFixed(2)}</td>
`;
tr.onclick = () => togglePreviewItem(index);
tbody.appendChild(tr);
});
document.getElementById('previewTotalHours').textContent = totalHours.toFixed(2);
updateSubmitButton();
}
function removeFile() {
selectedFile = null;
previewData = [];
fileInput.value = '';
uploadZone.style.display = 'block';
filePreview.style.display = 'none';
document.getElementById('previewContainer').style.display = 'none';
updateSubmitButton();
}
function updateSubmitButton() {
const hasSelectedDates = previewData.length === 0 || previewData.some(item => item.selected);
submitBtn.disabled = !(selectedFile && projectSelect.value && hasSelectedDates);
}
function setDefaultDateRange() {
const today = new Date();
const start = new Date(today);
start.setDate(today.getDate() - 7);
startDateInput.value = start.toISOString().slice(0, 10);
endDateInput.value = today.toISOString().slice(0, 10);
}
// Submit Handler
async function handleSubmit() {
if (!selectedFile || !projectSelect.value) {
showToast('Please select a file and project', 'warning');
return;
}
const formData = new FormData();
formData.append('file', selectedFile);
const projectName = projectSelect.value;
const excludedDates = previewData
.filter(item => !item.selected)
.map(item => item.date)
.join(',');
showLoading(true);
try {
const url = `/report-time?project_name=${encodeURIComponent(projectName)}&excluded_dates=${encodeURIComponent(excludedDates)}`;
const response = await fetch(url, {
method: 'POST',
body: formData
});
const data = await response.json();
if (!response.ok) {
throw new Error(data.detail || `HTTP ${response.status}`);
}
// Show success
displayResults(data);
showToast('Time reported successfully!', 'success');
// Reset form
removeFile();
projectSelect.value = '';
updateSubmitButton();
} catch (error) {
console.error('Error submitting:', error);
showToast(`Error: ${error.message}`, 'error');
} finally {
showLoading(false);
}
}
// Results Display
function displayResults(data) {
resultsContent.innerHTML = `
<div class="result-success">
<h3>
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"></path>
<polyline points="22 4 12 14.01 9 11.01"></polyline>
</svg>
${data.message}
</h3>
<div class="result-stats">
<div class="stat-item">
<div class="stat-value">${data.reported_days}</div>
<div class="stat-label">Days Reported</div>
</div>
<div class="stat-item">
<div class="stat-value">${data.total_hours.toFixed(2)}</div>
<div class="stat-label">Total Hours</div>
</div>
<div class="stat-item">
<div class="stat-value">${data.project_name}</div>
<div class="stat-label">Project</div>
</div>
<div class="stat-item">
<div class="stat-value">${data.user_name}</div>
<div class="stat-label">User</div>
</div>
</div>
</div>
`;
resultsSection.style.display = 'block';
resultsSection.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
}
// Utility Functions
function formatFileSize(bytes) {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return Math.round(bytes / Math.pow(k, i) * 100) / 100 + ' ' + sizes[i];
}
function showLoading(show) {
loadingOverlay.style.display = show ? 'flex' : 'none';
}
function showToast(message, type = 'info') {
const toast = document.createElement('div');
toast.className = `toast ${type}`;
toast.innerHTML = `
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
${getToastIcon(type)}
</svg>
<span>${message}</span>
`;
toastContainer.appendChild(toast);
setTimeout(() => {
toast.style.animation = 'slideInRight 0.3s ease reverse';
setTimeout(() => toast.remove(), 300);
}, 3000);
}
function getToastIcon(type) {
const icons = {
success: '<path d="M22 11.08V12a10 10 0 1 1-5.93-9.14"></path><polyline points="22 4 12 14.01 9 11.01"></polyline>',
error: '<circle cx="12" cy="12" r="10"></circle><line x1="15" y1="9" x2="9" y2="15"></line><line x1="9" y1="9" x2="15" y2="15"></line>',
warning: '<path d="M10.29 3.86L1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"></path><line x1="12" y1="9" x2="12" y2="13"></line><line x1="12" y1="17" x2="12.01" y2="17"></line>',
info: '<circle cx="12" cy="12" r="10"></circle><line x1="12" y1="16" x2="12" y2="12"></line><line x1="12" y1="8" x2="12.01" y2="8"></line>'
};
return icons[type] || icons.info;
}
// Project select change handler
projectSelect.addEventListener('change', updateSubmitButton);
// Approvals helpers
function renderEvents(events) {
if (!events || events.length === 0) {
eventsList.innerHTML = '<div class="loading">No events found for range</div>';
eventsTotal.style.display = 'none';
return;
}
let totalHours = 0;
eventsList.innerHTML = '';
events.forEach(event => {
const approved = event.approved;
const hours = parseFloat(event.hours) || 0;
totalHours += hours;
const item = document.createElement('div');
item.className = 'event-item';
item.innerHTML = `
<div>
<div class="project-name">${event.date || 'Unknown date'}</div>
<div class="event-meta">
<span>Hours: ${hours.toFixed(2)}</span>
<span>Activity: ${event.activity?.id ?? '-'}</span>
<span>Project: ${event["client-project"]?.id ?? '-'}</span>
</div>
</div>
<div class="event-status ${approved ? 'approved' : 'pending'}">
${approved ? 'Approved' : 'Pending'}
</div>
`;
eventsList.appendChild(item);
});
eventsTotal.innerHTML = `Total Hours: <strong>${totalHours.toFixed(2)}</strong>`;
eventsTotal.style.display = 'block';
}
function getDateRange() {
const startDate = startDateInput.value;
const endDate = endDateInput.value;
if (!startDate || !endDate) {
showToast('Please select start and end dates', 'warning');
return null;
}
if (new Date(startDate) > new Date(endDate)) {
showToast('Start date cannot be after end date', 'warning');
return null;
}
return { startDate, endDate };
}
async function fetchEvents() {
const range = getDateRange();
if (!range) return;
showLoading(true);
try {
const response = await fetch(`/events?start_date=${range.startDate}&end_date=${range.endDate}`);
const data = await response.json();
if (!response.ok) {
throw new Error(data.detail || `HTTP ${response.status}`);
}
lastEvents = data.events || [];
renderEvents(lastEvents);
showToast('Events loaded', 'success');
} catch (error) {
console.error('Error fetching events:', error);
showToast(`Failed to load events: ${error.message}`, 'error');
} finally {
showLoading(false);
}
}
async function approveEvents() {
const range = getDateRange();
if (!range) return;
showLoading(true);
try {
const response = await fetch(
`/approve-events?start_date=${range.startDate}&end_date=${range.endDate}`,
{ method: 'POST' }
);
const data = await response.json();
if (!response.ok) {
throw new Error(data.detail || `HTTP ${response.status}`);
}
showToast('Events approved', 'success');
await fetchEvents();
} catch (error) {
console.error('Error approving events:', error);
showToast(`Failed to approve events: ${error.message}`, 'error');
} finally {
showLoading(false);
}
}