444 lines
14 KiB
JavaScript
444 lines
14 KiB
JavaScript
// State
|
|
let selectedFile = null;
|
|
let projects = {};
|
|
let lastEvents = [];
|
|
|
|
// 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');
|
|
|
|
// 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();
|
|
}
|
|
|
|
function removeFile() {
|
|
selectedFile = null;
|
|
fileInput.value = '';
|
|
uploadZone.style.display = 'block';
|
|
filePreview.style.display = 'none';
|
|
updateSubmitButton();
|
|
}
|
|
|
|
function updateSubmitButton() {
|
|
submitBtn.disabled = !(selectedFile && projectSelect.value);
|
|
}
|
|
|
|
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;
|
|
|
|
showLoading(true);
|
|
|
|
try {
|
|
const response = await fetch(`/report-time?project_name=${encodeURIComponent(projectName)}`, {
|
|
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>';
|
|
return;
|
|
}
|
|
|
|
eventsList.innerHTML = '';
|
|
events.forEach(event => {
|
|
const approved = event.approved;
|
|
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: ${event.hours ?? '-'}</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);
|
|
});
|
|
}
|
|
|
|
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);
|
|
}
|
|
}
|