Files
TimeTracker/templates/timer/manual_entry.html
T
Dries Peeters 9a1603cfd8 feat(core/auth/ui): proxy-aware config, optional OIDC, i18n v4, health checks
feat(core/auth/ui): proxy-aware config, optional OIDC, i18n v4, health checks

- core: add ProxyFix, robust logging setup, rate-limit defaults; mask DB URL in logs
- db: prefer Postgres when POSTGRES_* envs present; initialization helpers and safe task table migration check
- i18n: upgrade to Flask-Babel v4 with locale selector; compile catalogs; add set-language route
- auth: optional OIDC via Authlib (login, callback, logout); login rate limiting; profile language and theme persistence; ensure admin promotion
- admin: branding logo upload/serve; PDF layout editor with preview/reset; backup/restore with progress; system info; license-server controls
- ui: new base layout with improved nav, mobile tab bar, theme/density toggles, CSRF meta + auto-injection, DataTables/Chart.js, Socket.IO boot
- ops: add /_health and /_ready endpoints; Docker healthcheck targets /_health; enable top-level templates via ChoiceLoader
- deps: update/add Authlib, Flask-Babel 4, and related security/util packages

Refs: app/__init__.py, app/config.py, app/routes/{auth,admin,main}.py, app/templates/base.html, Dockerfile, requirements.txt, templates/*
2025-10-05 17:48:54 +02:00

364 lines
17 KiB
HTML

{% extends "base.html" %}
{% block title %}{{ _('Log Time') }} - {{ app_name }}{% endblock %}
{% block content %}
{% block extra_css %}
<style>
/* Manual Entry alignment tweaks (light/dark consistent) */
.manual-entry .form-label { color: var(--text-primary); }
.manual-entry .card { border: 1px solid var(--border-color); }
.manual-entry .card-body .form-control,
.manual-entry .card-body .form-select { background: #ffffff; }
[data-theme="dark"] .manual-entry .card-body .form-control,
[data-theme="dark"] .manual-entry .card-body .form-select { background: #0f172a; color: var(--text-primary); border-color: var(--border-color); }
.manual-entry .form-control::placeholder { color: var(--text-muted); }
[data-theme="dark"] .manual-entry .form-control::placeholder { color: #64748b; }
.manual-entry .form-check-input { cursor: pointer; }
</style>
{% endblock %}
<div class="container-fluid manual-entry">
{% from "_components.html" import page_header %}
<div class="row g-3">
<div class="col-12">
{% set actions %}
<a href="{{ url_for('main.dashboard') }}" class="btn btn-outline-light btn-sm">
<i class="fas fa-arrow-left"></i> {{ _('Back') }}
</a>
{% endset %}
{{ page_header('fas fa-clock', _('Log Time'), _('Create a manual time entry'), actions) }}
</div>
</div>
<div class="row">
<div class="col-lg-8">
<div class="card shadow-sm border-0">
<div class="card-header d-flex justify-content-between align-items-center">
<h6 class="m-0">
<i class="fas fa-clock me-2 text-primary"></i>{{ _('Manual Entry') }}
</h6>
<div class="d-none d-md-flex gap-2">
<a href="{{ url_for('timer.manual_entry') }}" class="btn-header btn-outline-primary">
<i class="fas fa-rotate-right me-1"></i> {{ _('Reset') }}
</a>
</div>
</div>
<div class="card-body">
<form method="POST" action="{{ url_for('timer.manual_entry') }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div class="row">
<div class="col-md-6">
<div class="mb-3">
<label for="project_id" class="form-label fw-semibold">
<i class="fas fa-project-diagram me-1"></i>{{ _('Project') }} *
</label>
<select class="form-select" id="project_id" name="project_id" required>
<option value=""></option>
{% set selected_project_id = (request.form.get('project_id') or '')|int %}
{% for project in projects %}
<option value="{{ project.id }}" {% if project.id == selected_project_id %}selected{% endif %}>{{ project.name }}</option>
{% endfor %}
</select>
<div class="form-text">{{ _('Select the project to log time for') }}</div>
</div>
</div>
<div class="col-md-6">
{% set preselected_task_id = request.form.get('task_id') or request.args.get('task_id') %}
<div class="mb-3">
<label for="task_id" class="form-label fw-semibold">
<i class="fas fa-tasks me-1"></i>{{ _('Task (optional)') }}
</label>
<select class="form-select" id="task_id" name="task_id" data-selected-task-id="{{ preselected_task_id or '' }}" disabled>
<option value=""></option>
</select>
<div class="form-text">{{ _('Tasks load after selecting a project') }}</div>
</div>
</div>
</div>
<div class="row g-3">
<div class="col-12 col-md-6">
<div class="card border-0 shadow-sm">
<div class="card-body">
<div class="mb-2 fw-semibold text-primary"><i class="fas fa-play me-1"></i>{{ _('Start') }} *</div>
<div class="row g-2">
<div class="col-6">
<label for="start_date" class="form-label fw-semibold">{{ _('Date') }}</label>
<input type="date" class="form-control" name="start_date" id="start_date" required value="{{ request.form.get('start_date','') }}">
</div>
<div class="col-6">
<label for="start_time" class="form-label fw-semibold">{{ _('Time') }}</label>
<input type="time" class="form-control" name="start_time" id="start_time" required value="{{ request.form.get('start_time','') }}">
</div>
</div>
</div>
</div>
</div>
<div class="col-12 col-md-6">
<div class="card border-0 shadow-sm">
<div class="card-body">
<div class="mb-2 fw-semibold text-primary"><i class="fas fa-stop me-1"></i>{{ _('End') }} *</div>
<div class="row g-2">
<div class="col-6">
<label for="end_date" class="form-label fw-semibold">{{ _('Date') }}</label>
<input type="date" class="form-control" name="end_date" id="end_date" required value="{{ request.form.get('end_date','') }}">
</div>
<div class="col-6">
<label for="end_time" class="form-label fw-semibold">{{ _('Time') }}</label>
<input type="time" class="form-control" name="end_time" id="end_time" required value="{{ request.form.get('end_time','') }}">
</div>
</div>
</div>
</div>
</div>
</div>
<div class="my-3">
<label for="notes" class="form-label fw-semibold">
<i class="fas fa-sticky-note me-1"></i>{{ _('Notes') }}
</label>
<textarea class="form-control" id="notes" name="notes" style="height: 100px" placeholder="{{ _('What did you work on?') }}">{{ request.form.get('notes','') }}</textarea>
</div>
<div class="row g-3 align-items-center">
<div class="col-12 col-md-8">
<div class="mb-3 mb-md-0">
<label for="tags" class="form-label fw-semibold">
<i class="fas fa-tags me-1"></i>{{ _('Tags') }}
</label>
<input type="text" class="form-control" id="tags" name="tags" placeholder="{{ _('tag1, tag2, tag3') }}" value="{{ request.form.get('tags','') }}">
<div class="form-text">{{ _('Separate tags with commas') }}</div>
</div>
</div>
<div class="col-12 col-md-4">
<div class="mb-3 mb-md-0">
<label class="form-label fw-semibold d-block" for="billable">
<i class="fas fa-dollar-sign me-1"></i>{{ _('Billable') }}
</label>
<div class="form-check form-switch d-flex align-items-center">
<input class="form-check-input" type="checkbox" id="billable" name="billable" {% if request.form.get('billable') %}checked{% else %}checked{% endif %}>
<span class="ms-2 text-muted small">{{ _('Include in invoices') }}</span>
</div>
</div>
</div>
</div>
<div class="d-flex justify-content-between flex-column flex-md-row mt-3">
<a href="{{ url_for('main.dashboard') }}" class="btn btn-outline-secondary mb-2 mb-md-0">
<i class="fas fa-arrow-left me-1"></i> {{ _('Back') }}
</a>
<div class="btn-group" role="group">
<button type="submit" class="btn btn-primary">
<i class="fas fa-save me-2"></i>{{ _('Save Entry') }}
</button>
<button type="reset" class="btn btn-outline-primary">
<i class="fas fa-broom me-2"></i>{{ _('Clear') }}
</button>
</div>
</div>
</form>
</div>
</div>
</div>
<div class="col-lg-4">
<div class="card shadow-sm border-0">
<div class="card-header">
<h6 class="m-0">
<i class="fas fa-lightbulb me-2 text-warning"></i>{{ _('Quick Tips') }}
</h6>
</div>
<div class="card-body">
<div class="tip-item mb-3 d-flex gap-3">
<div class="tip-icon text-primary"><i class="fas fa-tasks"></i></div>
<div class="tip-content">
<strong>{{ _('Use Tasks') }}</strong>
<p class="small text-muted mb-0">{{ _('Categorize time by selecting a task after choosing a project.') }}</p>
</div>
</div>
<div class="tip-item mb-3 d-flex gap-3">
<div class="tip-icon text-success"><i class="fas fa-dollar-sign"></i></div>
<div class="tip-content">
<strong>{{ _('Billable Time') }}</strong>
<p class="small text-muted mb-0">{{ _('Enable billable to include this entry in invoices.') }}</p>
</div>
</div>
<div class="tip-item d-flex gap-3">
<div class="tip-icon text-info"><i class="fas fa-tags"></i></div>
<div class="tip-content">
<strong>{{ _('Tag Entries') }}</strong>
<p class="small text-muted mb-0">{{ _('Add tags to filter entries in reports later.') }}</p>
</div>
</div>
</div>
</div>
</div>
</div>
</div>
<style>
.form-floating > label {
background-color: transparent;
color: var(--text-secondary);
}
.form-floating > .form-control:focus ~ label,
.form-floating > .form-control:not(:placeholder-shown) ~ label,
.form-floating > .form-select:focus ~ label,
.form-floating > .form-select:not([value=""]) ~ label {
color: var(--primary-color);
}
/* Ensure task field label gets proper dark mode styling */
#task_id:focus ~ label,
#task_id:not([value=""]) ~ label {
color: var(--primary-color) !important;
}
.form-control:focus, .form-select:focus {
border-color: var(--primary-color);
box-shadow: 0 0 0 0.2rem rgba(59, 130, 246, 0.15);
}
/* Fix form-text visibility in both light and dark modes */
.form-text {
color: var(--text-muted) !important;
font-size: 0.875rem;
margin-top: 0.5rem;
display: block;
position: relative;
z-index: 1;
}
[data-theme="dark"] .form-text {
color: var(--text-muted) !important;
}
.tip-icon { font-size: 18px; }
/* Match invoices page look-and-feel */
.card-header {
border-bottom: 1px solid var(--border-color);
}
.card.shadow-sm {
border: 1px solid var(--border-color);
}
.btn-outline-primary {
border-width: 1px;
}
.btn-outline-primary:hover {
color: #fff;
}
</style>
<script type="application/json" id="i18n-json-timer-manual">
{
"no_task": {{ _('No task')|tojson }},
"failed_load": {{ _('Failed to load tasks')|tojson }}
}
</script>
<script>
var i18nTimerManual = (function(){ try{ var el=document.getElementById('i18n-json-timer-manual'); return el?JSON.parse(el.textContent):{}; }catch(e){ return {}; } })();
document.addEventListener('DOMContentLoaded', function() {
// Set default dates to today
const today = new Date().toISOString().split('T')[0];
const now = new Date().toTimeString().slice(0, 5);
if (!document.getElementById('start_date').value) {
document.getElementById('start_date').value = today;
}
if (!document.getElementById('end_date').value) {
document.getElementById('end_date').value = today;
}
if (!document.getElementById('start_time').value) {
document.getElementById('start_time').value = now;
}
if (!document.getElementById('end_time').value) {
document.getElementById('end_time').value = now;
}
// Mobile-specific improvements
if (window.innerWidth <= 768) {
// Add mobile-specific classes
const form = document.querySelector('form');
form.classList.add('mobile-form');
// Improve touch targets
const inputs = document.querySelectorAll('.form-control, .form-select');
inputs.forEach(input => {
input.classList.add('touch-target');
});
// Improve buttons
const buttons = document.querySelectorAll('.btn');
buttons.forEach(btn => {
btn.classList.add('touch-target');
});
}
// Handle mobile viewport changes
window.addEventListener('resize', function() {
if (window.innerWidth <= 768) {
document.body.classList.add('mobile-view');
} else {
document.body.classList.remove('mobile-view');
}
});
// Dynamic task loading based on project selection
const projectSelect = document.getElementById('project_id');
const taskSelect = document.getElementById('task_id');
const L = { noTask: i18nTimerManual.no_task || 'No task', failedLoad: i18nTimerManual.failed_load || 'Failed to load tasks' };
async function loadTasksForProject(projectId) {
if (!projectId) {
taskSelect.innerHTML = '<option value="">' + L.noTask + '</option>';
taskSelect.disabled = true;
return;
}
try {
const resp = await fetch(`/api/tasks?project_id=${projectId}`);
if (!resp.ok) throw new Error(L.failedLoad);
const data = await resp.json();
const tasks = Array.isArray(data.tasks) ? data.tasks : [];
taskSelect.innerHTML = '<option value="">' + L.noTask + '</option>';
tasks.forEach(t => {
const opt = document.createElement('option');
opt.value = String(t.id);
opt.textContent = t.name;
taskSelect.appendChild(opt);
});
// Preselect if provided
const preId = taskSelect.getAttribute('data-selected-task-id');
if (preId) {
const found = Array.from(taskSelect.options).some(o => o.value === preId);
if (found) taskSelect.value = preId;
// Clear after first use
taskSelect.setAttribute('data-selected-task-id', '');
}
taskSelect.disabled = false;
} catch (e) {
// On error, keep disabled
taskSelect.innerHTML = '<option value="">' + L.noTask + '</option>';
taskSelect.disabled = true;
}
}
// Initial load if project is already selected (from query/form)
if (projectSelect && projectSelect.value) {
loadTasksForProject(projectSelect.value);
}
// Reload tasks when project changes
projectSelect.addEventListener('change', function() {
// Clear any previous selection
taskSelect.value = '';
taskSelect.setAttribute('data-selected-task-id', '');
loadTasksForProject(this.value);
});
});
</script>
{% endblock %}