400 lines
15 KiB
HTML
400 lines
15 KiB
HTML
{% extends "base.html" %}
|
||
|
||
{% block content %}
|
||
<!-- Time Entry Form -->
|
||
<section class="card entry-card">
|
||
<div class="card-header">
|
||
<h2>Log Time Entry</h2>
|
||
<span class="card-badge">New Entry</span>
|
||
</div>
|
||
<form id="timeEntryForm" class="entry-form">
|
||
<div class="form-grid">
|
||
<div class="form-group">
|
||
<label for="taskSelect">Task</label>
|
||
<div class="select-wrapper">
|
||
<select id="taskSelect" required>
|
||
<option value="">Select a task...</option>
|
||
</select>
|
||
<div class="select-arrow">▼</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="form-group">
|
||
<label for="dateInput">Date</label>
|
||
<input type="date" id="dateInput" required>
|
||
</div>
|
||
|
||
<div class="form-group">
|
||
<label for="timeInput">Hours Worked</label>
|
||
<input type="text" id="timeInput" placeholder="e.g. 1:30 or 1.5" required>
|
||
<span class="input-hint">Formats: 1:30, 1.5, 90m, 1h 30m</span>
|
||
</div>
|
||
|
||
<div class="form-group form-group-full">
|
||
<label for="notesInput">Notes (optional)</label>
|
||
<input type="text" id="notesInput" placeholder="Brief description of work done...">
|
||
</div>
|
||
</div>
|
||
|
||
<div class="form-actions">
|
||
<button type="submit" class="btn btn-primary">
|
||
<span class="btn-icon">+</span>
|
||
Add Entry
|
||
</button>
|
||
</div>
|
||
</form>
|
||
</section>
|
||
|
||
<!-- Add Task Modal -->
|
||
<div id="addTaskModal" class="modal">
|
||
<div class="modal-content">
|
||
<div class="modal-header">
|
||
<h3>Add New Task</h3>
|
||
<button class="modal-close" onclick="closeAddTaskModal()">×</button>
|
||
</div>
|
||
<div class="modal-body">
|
||
<div class="form-group">
|
||
<label for="newTaskName">Task Name</label>
|
||
<input type="text" id="newTaskName" placeholder="Enter task name..." autofocus>
|
||
</div>
|
||
</div>
|
||
<div class="modal-footer">
|
||
<button class="btn btn-secondary" onclick="closeAddTaskModal()">Cancel</button>
|
||
<button class="btn btn-primary" onclick="saveNewTask()">Add Task</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Weekly Summary -->
|
||
<section class="card summary-card">
|
||
<div class="card-header">
|
||
<h2>Weekly Summary</h2>
|
||
<div class="week-selector">
|
||
<button class="btn btn-icon" onclick="changeWeek(-1)" title="Previous Week">◀</button>
|
||
<span id="weekLabel" class="week-label">This Week</span>
|
||
<button class="btn btn-icon" onclick="changeWeek(1)" title="Next Week">▶</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div id="summaryContent" class="summary-content">
|
||
<div class="loading">Loading summary...</div>
|
||
</div>
|
||
|
||
<div class="summary-actions">
|
||
<button class="btn btn-secondary" onclick="printTimesheet()">
|
||
<span class="btn-icon">🖨</span>
|
||
Print Timesheet
|
||
</button>
|
||
</div>
|
||
</section>
|
||
|
||
<!-- Recent Entries -->
|
||
<section class="card entries-card">
|
||
<div class="card-header">
|
||
<h2>Recent Entries</h2>
|
||
<span class="entry-count" id="entryCount">0 entries</span>
|
||
</div>
|
||
<div id="recentEntries" class="entries-list">
|
||
<div class="loading">Loading entries...</div>
|
||
</div>
|
||
</section>
|
||
|
||
<!-- Print Template -->
|
||
<div id="printTemplate" class="print-template">
|
||
<div class="print-header">
|
||
<h1>🦝 Raccoon Timekeeper - Weekly Timesheet</h1>
|
||
<div class="print-user">{{ current_user.display_name or current_user.username }}</div>
|
||
<div class="print-period" id="printPeriod"></div>
|
||
</div>
|
||
<div id="printContent"></div>
|
||
</div>
|
||
{% endblock %}
|
||
|
||
{% block extra_js %}
|
||
<script>
|
||
document.addEventListener('DOMContentLoaded', function() {
|
||
loadTasks();
|
||
setDefaultDate();
|
||
loadWeeklySummary();
|
||
loadRecentEntries();
|
||
|
||
document.getElementById('timeEntryForm').addEventListener('submit', handleSubmit);
|
||
document.getElementById('taskSelect').addEventListener('change', handleTaskChange);
|
||
document.getElementById('newTaskName').addEventListener('keypress', function(e) {
|
||
if (e.key === 'Enter') { e.preventDefault(); saveNewTask(); }
|
||
});
|
||
});
|
||
|
||
function setDefaultDate() {
|
||
document.getElementById('dateInput').value = new Date().toISOString().split('T')[0];
|
||
}
|
||
|
||
async function loadTasks() {
|
||
try {
|
||
const response = await fetch('/api/tasks');
|
||
const tasks = await response.json();
|
||
populateTasks(tasks);
|
||
} catch (error) {
|
||
showToast('Error loading tasks', 'error');
|
||
}
|
||
}
|
||
|
||
function populateTasks(tasks) {
|
||
const select = document.getElementById('taskSelect');
|
||
select.innerHTML = '<option value="">Select a task...</option>';
|
||
tasks.forEach(task => {
|
||
const option = document.createElement('option');
|
||
option.value = task.id;
|
||
option.textContent = task.name;
|
||
select.appendChild(option);
|
||
});
|
||
const addOption = document.createElement('option');
|
||
addOption.value = '__ADD_NEW__';
|
||
addOption.textContent = '➕ Add new task...';
|
||
select.appendChild(addOption);
|
||
}
|
||
|
||
function handleTaskChange(e) {
|
||
if (e.target.value === '__ADD_NEW__') {
|
||
e.target.value = '';
|
||
openAddTaskModal();
|
||
}
|
||
}
|
||
|
||
function openAddTaskModal() {
|
||
document.getElementById('addTaskModal').classList.add('active');
|
||
document.getElementById('newTaskName').value = '';
|
||
document.getElementById('newTaskName').focus();
|
||
}
|
||
|
||
function closeAddTaskModal() {
|
||
document.getElementById('addTaskModal').classList.remove('active');
|
||
}
|
||
|
||
async function saveNewTask() {
|
||
const taskName = document.getElementById('newTaskName').value.trim();
|
||
if (!taskName) { showToast('Enter a task name', 'error'); return; }
|
||
|
||
try {
|
||
const response = await fetch('/api/tasks', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({ name: taskName })
|
||
});
|
||
const result = await response.json();
|
||
|
||
if (result.success) {
|
||
showToast('Task added!', 'success');
|
||
closeAddTaskModal();
|
||
await loadTasks();
|
||
document.getElementById('taskSelect').value = result.task.id;
|
||
} else {
|
||
showToast(result.message, 'error');
|
||
}
|
||
} catch (error) {
|
||
showToast('Error adding task', 'error');
|
||
}
|
||
}
|
||
|
||
async function handleSubmit(e) {
|
||
e.preventDefault();
|
||
|
||
const data = {
|
||
task_id: document.getElementById('taskSelect').value,
|
||
date: document.getElementById('dateInput').value,
|
||
time: document.getElementById('timeInput').value,
|
||
notes: document.getElementById('notesInput').value
|
||
};
|
||
|
||
if (!data.task_id || !data.date || !data.time) {
|
||
showToast('Fill in all required fields', 'error');
|
||
return;
|
||
}
|
||
|
||
try {
|
||
const response = await fetch('/api/entries', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify(data)
|
||
});
|
||
const result = await response.json();
|
||
|
||
if (result.success) {
|
||
showToast('Time entry added!', 'success');
|
||
document.getElementById('timeInput').value = '';
|
||
document.getElementById('notesInput').value = '';
|
||
loadWeeklySummary();
|
||
loadRecentEntries();
|
||
} else {
|
||
showToast(result.message, 'error');
|
||
}
|
||
} catch (error) {
|
||
showToast('Error adding entry', 'error');
|
||
}
|
||
}
|
||
|
||
let currentWeekOffset = 0;
|
||
|
||
function changeWeek(direction) {
|
||
currentWeekOffset += direction;
|
||
loadWeeklySummary();
|
||
}
|
||
|
||
function getWeekStart(offset = 0) {
|
||
const today = new Date();
|
||
const day = today.getDay();
|
||
const diff = today.getDate() - day + (day === 0 ? -6 : 1) + (offset * 7);
|
||
const monday = new Date(today.setDate(diff));
|
||
monday.setHours(0, 0, 0, 0);
|
||
return monday;
|
||
}
|
||
|
||
async function loadWeeklySummary() {
|
||
const weekStart = getWeekStart(currentWeekOffset);
|
||
const weekEnd = new Date(weekStart);
|
||
weekEnd.setDate(weekEnd.getDate() + 6);
|
||
|
||
const options = { month: 'short', day: 'numeric' };
|
||
document.getElementById('weekLabel').textContent =
|
||
`${weekStart.toLocaleDateString('en-AU', options)} - ${weekEnd.toLocaleDateString('en-AU', options)}, ${weekEnd.getFullYear()}`;
|
||
|
||
try {
|
||
const response = await fetch(`/api/weekly-summary?week_start=${weekStart.toISOString()}`);
|
||
const data = await response.json();
|
||
renderWeeklySummary(data);
|
||
} catch (error) {
|
||
document.getElementById('summaryContent').innerHTML = '<div class="empty-state">⚠️ Error loading summary</div>';
|
||
}
|
||
}
|
||
|
||
function renderWeeklySummary(data) {
|
||
const container = document.getElementById('summaryContent');
|
||
|
||
if (!data.tasks || data.tasks.length === 0) {
|
||
container.innerHTML = '<div class="empty-state">📊 No entries this week</div>';
|
||
window.currentWeekData = null;
|
||
return;
|
||
}
|
||
|
||
const days = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'];
|
||
|
||
let html = `<div class="summary-table-wrapper"><table class="summary-table">
|
||
<thead><tr><th class="task-col">Task</th>
|
||
${days.map((d, i) => `<th class="day-col">${d}</th>`).join('')}
|
||
<th class="total-col">Total</th></tr></thead><tbody>`;
|
||
|
||
data.tasks.forEach(task => {
|
||
html += `<tr><td class="task-name">${task.task_name}</td>
|
||
${days.map((_, i) => {
|
||
const mins = task.days[i] || 0;
|
||
return `<td class="time-cell ${mins > 0 ? 'has-time' : ''}">${mins > 0 ? formatMinutes(mins) : '-'}</td>`;
|
||
}).join('')}
|
||
<td class="total-cell">${formatMinutes(task.total_minutes)}</td></tr>`;
|
||
});
|
||
|
||
html += `<tr class="totals-row"><td><strong>Daily Total</strong></td>
|
||
${days.map((_, i) => `<td class="time-cell">${data.daily_totals[i] > 0 ? formatMinutes(data.daily_totals[i]) : '-'}</td>`).join('')}
|
||
<td class="grand-total">${formatMinutes(data.grand_total)}</td></tr>`;
|
||
|
||
html += `</tbody></table></div>
|
||
<div class="summary-stats">
|
||
<div class="stat-card"><span class="stat-value">${formatMinutes(data.grand_total)}</span><span class="stat-label">Total Hours</span></div>
|
||
<div class="stat-card"><span class="stat-value">${data.tasks.length}</span><span class="stat-label">Tasks Worked</span></div>
|
||
<div class="stat-card"><span class="stat-value">${data.entries.length}</span><span class="stat-label">Time Entries</span></div>
|
||
</div>`;
|
||
|
||
container.innerHTML = html;
|
||
window.currentWeekData = data;
|
||
}
|
||
|
||
async function loadRecentEntries() {
|
||
try {
|
||
const response = await fetch('/api/entries');
|
||
const entries = await response.json();
|
||
renderRecentEntries(entries);
|
||
} catch (error) {
|
||
document.getElementById('recentEntries').innerHTML = '<div class="empty-state">⚠️ Error loading entries</div>';
|
||
}
|
||
}
|
||
|
||
function renderRecentEntries(entries) {
|
||
const container = document.getElementById('recentEntries');
|
||
document.getElementById('entryCount').textContent = `${entries.length} entries`;
|
||
|
||
if (!entries.length) {
|
||
container.innerHTML = '<div class="empty-state">📝 No recent entries</div>';
|
||
return;
|
||
}
|
||
|
||
let html = '';
|
||
entries.slice(0, 20).forEach(entry => {
|
||
const date = new Date(entry.date);
|
||
const dateStr = date.toLocaleDateString('en-AU', { weekday: 'short', month: 'short', day: 'numeric' });
|
||
|
||
html += `<div class="entry-item" data-id="${entry.id}">
|
||
<div class="entry-main">
|
||
<span class="entry-task">${entry.task_name}</span>
|
||
<span class="entry-time">${formatMinutes(entry.total_minutes)}</span>
|
||
</div>
|
||
<div class="entry-details">
|
||
<span class="entry-date">${dateStr}</span>
|
||
${entry.notes ? `<span class="entry-notes">${entry.notes}</span>` : ''}
|
||
</div>
|
||
<button class="entry-delete" onclick="deleteEntry(${entry.id})" title="Delete">×</button>
|
||
</div>`;
|
||
});
|
||
|
||
container.innerHTML = html;
|
||
}
|
||
|
||
async function deleteEntry(entryId) {
|
||
if (!confirm('Delete this entry?')) return;
|
||
|
||
try {
|
||
const response = await fetch(`/api/entries/${entryId}`, { method: 'DELETE' });
|
||
const result = await response.json();
|
||
|
||
if (result.success) {
|
||
showToast('Entry deleted', 'success');
|
||
loadWeeklySummary();
|
||
loadRecentEntries();
|
||
} else {
|
||
showToast(result.message, 'error');
|
||
}
|
||
} catch (error) {
|
||
showToast('Error deleting entry', 'error');
|
||
}
|
||
}
|
||
|
||
function printTimesheet() {
|
||
if (!window.currentWeekData) { showToast('No data to print', 'error'); return; }
|
||
|
||
const data = window.currentWeekData;
|
||
const days = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday'];
|
||
const weekStart = new Date(data.week_start);
|
||
const weekEnd = new Date(data.week_end);
|
||
const options = { year: 'numeric', month: 'long', day: 'numeric' };
|
||
|
||
document.getElementById('printPeriod').textContent =
|
||
`${weekStart.toLocaleDateString('en-AU', options)} - ${weekEnd.toLocaleDateString('en-AU', options)}`;
|
||
|
||
let html = `<table class="print-table"><thead><tr><th>Task</th>
|
||
${days.map(d => `<th>${d}</th>`).join('')}<th>Total</th></tr></thead><tbody>`;
|
||
|
||
data.tasks.forEach(task => {
|
||
html += `<tr><td>${task.task_name}</td>
|
||
${days.map((_, i) => `<td>${task.days[i] > 0 ? formatMinutes(task.days[i]) : '-'}</td>`).join('')}
|
||
<td><strong>${formatMinutes(task.total_minutes)}</strong></td></tr>`;
|
||
});
|
||
|
||
html += `<tr class="total-row"><td><strong>Daily Total</strong></td>
|
||
${days.map((_, i) => `<td><strong>${data.daily_totals[i] > 0 ? formatMinutes(data.daily_totals[i]) : '-'}</strong></td>`).join('')}
|
||
<td class="grand-total"><strong>${formatMinutes(data.grand_total)}</strong></td></tr></tbody></table>
|
||
<div class="print-summary"><p><strong>Total Hours:</strong> ${formatMinutes(data.grand_total)} (${(data.grand_total / 60).toFixed(2)} decimal)</p></div>`;
|
||
|
||
document.getElementById('printContent').innerHTML = html;
|
||
window.print();
|
||
}
|
||
</script>
|
||
{% endblock %}
|