Merge pull request #50 from DRYTRIX/Feature-Translations

feat(i18n): add translations, locale switcher, and user language pref…
This commit is contained in:
Dries Peeters
2025-09-11 23:09:07 +02:00
committed by GitHub
56 changed files with 1609 additions and 622 deletions

View File

@@ -54,6 +54,10 @@ RUN pip install --no-cache-dir -r requirements.txt
# Copy project
COPY . .
# Ensure translation catalogs are writable by the app user
RUN mkdir -p /app/translations && \
chmod -R 775 /app/translations || true
# Create data and logs directories with proper permissions
RUN mkdir -p /data /data/uploads /app/logs && chmod 755 /data && chmod 755 /data/uploads && chmod 755 /app/logs
@@ -78,7 +82,7 @@ RUN chmod +x /app/start.py /app/docker/init-database.py /app/docker/init-databas
# Create non-root user
RUN useradd -m -u 1000 timetracker && \
chown -R timetracker:timetracker /app /data /app/logs /app/instance /app/app/static/uploads /app/static/uploads
chown -R timetracker:timetracker /app /data /app/logs /app/instance /app/app/static/uploads /app/static/uploads /app/translations
# Verify startup script exists and is accessible
RUN ls -la /app/start.py && \

View File

@@ -1,12 +1,13 @@
import os
import logging
from datetime import timedelta
from flask import Flask, request
from flask import Flask, request, session
from flask_sqlalchemy import SQLAlchemy
from flask_migrate import Migrate
from flask_login import LoginManager
from flask_socketio import SocketIO
from dotenv import load_dotenv
from flask_babel import Babel, _
import re
from jinja2 import ChoiceLoader, FileSystemLoader
from werkzeug.middleware.proxy_fix import ProxyFix
@@ -19,6 +20,7 @@ db = SQLAlchemy()
migrate = Migrate()
login_manager = LoginManager()
socketio = SocketIO()
babel = Babel()
def create_app(config=None):
"""Application factory pattern"""
@@ -68,6 +70,62 @@ def create_app(config=None):
login_manager.init_app(app)
socketio.init_app(app, cors_allowed_origins="*")
# Ensure translations exist and configure absolute translation directories before Babel init
try:
translations_dirs = (app.config.get('BABEL_TRANSLATION_DIRECTORIES') or 'translations').split(',')
base_path = os.path.abspath(os.path.join(os.path.dirname(__file__), '..'))
abs_dirs = []
for d in translations_dirs:
d = d.strip()
if not d:
continue
abs_dirs.append(d if os.path.isabs(d) else os.path.abspath(os.path.join(base_path, d)))
if abs_dirs:
app.config['BABEL_TRANSLATION_DIRECTORIES'] = os.pathsep.join(abs_dirs)
# Best-effort compile with Babel CLI if available, else Python fallback
try:
import subprocess
subprocess.run(['pybabel', 'compile', '-d', abs_dirs[0]], check=False)
except Exception:
pass
from app.utils.i18n import ensure_translations_compiled
for d in abs_dirs:
ensure_translations_compiled(d)
except Exception:
pass
# Internationalization: locale selector compatible with Flask-Babel v4+
def _select_locale():
try:
# 1) User preference from DB
from flask_login import current_user
if current_user and getattr(current_user, 'is_authenticated', False):
pref = getattr(current_user, 'preferred_language', None)
if pref:
return pref
# 2) Session override (set-language route)
if 'preferred_language' in session:
return session.get('preferred_language')
# 3) Best match with Accept-Language
supported = list(app.config.get('LANGUAGES', {}).keys()) or ['en']
return request.accept_languages.best_match(supported) or app.config.get('BABEL_DEFAULT_LOCALE', 'en')
except Exception:
return app.config.get('BABEL_DEFAULT_LOCALE', 'en')
babel.init_app(
app,
default_locale=app.config.get('BABEL_DEFAULT_LOCALE', 'en'),
default_timezone=app.config.get('TZ', 'Europe/Rome'),
locale_selector=_select_locale,
)
# Ensure gettext helpers available in Jinja
try:
from flask_babel import gettext as _gettext, ngettext as _ngettext
app.jinja_env.globals.update(_=_gettext, ngettext=_ngettext)
except Exception:
pass
# Log effective database URL (mask password)
db_url = app.config.get('SQLALCHEMY_DATABASE_URI', '')
try:
@@ -80,6 +138,8 @@ def create_app(config=None):
login_manager.login_view = 'auth.login'
login_manager.login_message = 'Please log in to access this page.'
login_manager.login_message_category = 'info'
# Internationalization selector handled via babel.init_app(locale_selector=...)
# Register user loader
@login_manager.user_loader
@@ -188,6 +248,8 @@ def create_app(config=None):
from app.utils.context_processors import register_context_processors
register_context_processors(app)
# (translations compiled and directories set before Babel init)
# Register template filters
from app.utils.template_filters import register_template_filters
register_template_filters(app)

View File

@@ -69,6 +69,19 @@ class Config:
'Strict-Transport-Security': 'max-age=31536000; includeSubDomains'
}
# Internationalization
LANGUAGES = {
'en': 'English',
'nl': 'Nederlands',
'de': 'Deutsch',
'fr': 'Français',
'it': 'Italiano',
'fi': 'Suomi',
}
BABEL_DEFAULT_LOCALE = os.getenv('DEFAULT_LOCALE', 'en')
# Comma-separated list of translation directories relative to instance root
BABEL_TRANSLATION_DIRECTORIES = os.getenv('BABEL_TRANSLATION_DIRECTORIES', 'translations')
# Versioning
# Prefer explicit app version from environment (e.g., Git tag)
APP_VERSION = os.getenv('APP_VERSION', os.getenv('GITHUB_TAG', None))

View File

@@ -16,6 +16,7 @@ class User(UserMixin, db.Model):
last_login = db.Column(db.DateTime, nullable=True)
is_active = db.Column(db.Boolean, default=True, nullable=False)
theme_preference = db.Column(db.String(10), default=None, nullable=True) # 'light' | 'dark' | None=system
preferred_language = db.Column(db.String(8), default=None, nullable=True) # e.g., 'en', 'de'
# Relationships
time_entries = db.relationship('TimeEntry', backref='user', lazy='dynamic', cascade='all, delete-orphan')

View File

@@ -1,4 +1,5 @@
from flask import Blueprint, render_template, request, redirect, url_for, flash, current_app, send_from_directory, send_file
from flask_babel import gettext as _
from flask_login import login_required, current_user
from app import db
from app.models import User, Project, TimeEntry, Settings
@@ -26,7 +27,7 @@ def admin_required(f):
@wraps(f)
def decorated_function(*args, **kwargs):
if not current_user.is_authenticated or not current_user.is_admin:
flash('Administrator access required', 'error')
flash(_('Administrator access required'), 'error')
return redirect(url_for('main.dashboard'))
return f(*args, **kwargs)
return decorated_function

View File

@@ -4,6 +4,8 @@ from app import db
from app.models import User
from app.config import Config
from app.utils.db import safe_commit
from flask_babel import gettext as _
auth_bp = Blueprint('auth', __name__)
@@ -25,7 +27,7 @@ def login():
current_app.logger.info("POST /login (username=%s) from %s", username or '<empty>', request.headers.get('X-Forwarded-For') or request.remote_addr)
if not username:
flash('Username is required', 'error')
flash(_('Username is required'), 'error')
return render_template('auth/login.html')
# Normalize admin usernames from config
@@ -47,12 +49,12 @@ def login():
db.session.add(user)
if not safe_commit('self_register_user', {'username': username}):
current_app.logger.error("Self-registration failed for '%s' due to DB error", username)
flash('Could not create your account due to a database error. Please try again later.', 'error')
flash(_('Could not create your account due to a database error. Please try again later.'), 'error')
return render_template('auth/login.html')
current_app.logger.info("Created new user '%s'", username)
flash(f'Welcome! Your account has been created.', 'success')
flash(_('Welcome! Your account has been created.'), 'success')
else:
flash('User not found. Please contact an administrator.', 'error')
flash(_('User not found. Please contact an administrator.'), 'error')
return render_template('auth/login.html')
else:
# If existing user matches admin usernames, ensure admin role
@@ -60,12 +62,12 @@ def login():
user.role = 'admin'
if not safe_commit('promote_admin_user', {'username': username}):
current_app.logger.error("Failed to promote '%s' to admin due to DB error", username)
flash('Could not update your account role due to a database error.', 'error')
flash(_('Could not update your account role due to a database error.'), 'error')
return render_template('auth/login.html')
# Check if user is active
if not user.is_active:
flash('Account is disabled. Please contact an administrator.', 'error')
flash(_('Account is disabled. Please contact an administrator.'), 'error')
return render_template('auth/login.html')
# Log in the user
@@ -79,11 +81,11 @@ def login():
next_page = url_for('main.dashboard')
current_app.logger.info("Redirecting '%s' to %s", user.username, next_page)
flash(f'Welcome back, {user.username}!', 'success')
flash(_('Welcome back, %(username)s!', username=user.username), 'success')
return redirect(next_page)
except Exception as e:
current_app.logger.exception("Login error: %s", e)
flash('Unexpected error during login. Please try again or check server logs.', 'error')
flash(_('Unexpected error during login. Please try again or check server logs.'), 'error')
return render_template('auth/login.html')
return render_template('auth/login.html')
@@ -94,7 +96,7 @@ def logout():
"""Logout the current user"""
username = current_user.username
logout_user()
flash(f'Goodbye, {username}!', 'info')
flash(_('Goodbye, %(username)s!', username=username), 'info')
return redirect(url_for('auth.login'))
@auth_bp.route('/profile')
@@ -111,12 +113,19 @@ def edit_profile():
# Update real name if provided
full_name = request.form.get('full_name', '').strip()
current_user.full_name = full_name or None
# Update preferred language
preferred_language = (request.form.get('preferred_language') or '').strip().lower()
available = (current_app.config.get('LANGUAGES') or {}).keys()
if preferred_language in available:
current_user.preferred_language = preferred_language
# Also set session so it applies immediately
session['preferred_language'] = preferred_language
try:
db.session.commit()
flash('Profile updated successfully', 'success')
flash(_('Profile updated successfully'), 'success')
except Exception:
db.session.rollback()
flash('Could not update your profile due to a database error.', 'error')
flash(_('Could not update your profile due to a database error.'), 'error')
return redirect(url_for('auth.profile'))
return render_template('auth/edit_profile.html')

View File

@@ -1,4 +1,5 @@
from flask import Blueprint, render_template, request, redirect, url_for, flash, current_app
from flask_babel import gettext as _
from flask_login import login_required, current_user
from app import db
from app.models import Client, Project
@@ -41,7 +42,7 @@ def list_clients():
def create_client():
"""Create a new client"""
if not current_user.is_admin:
flash('Only administrators can create clients', 'error')
flash(_('Only administrators can create clients'), 'error')
return redirect(url_for('clients.list_clients'))
if request.method == 'POST':

View File

@@ -1,4 +1,5 @@
from flask import Blueprint, render_template, request, redirect, url_for, flash, jsonify, send_file
from flask_babel import gettext as _
from flask_login import login_required, current_user
from app import db
from app.models import User, Project, TimeEntry, Invoice, InvoiceItem, Settings

View File

@@ -1,4 +1,4 @@
from flask import Blueprint, render_template, request, redirect, url_for, flash
from flask import Blueprint, render_template, request, redirect, url_for, flash, session
from flask_login import login_required, current_user
from app.models import User, Project, TimeEntry, Settings
from datetime import datetime, timedelta
@@ -80,6 +80,31 @@ def help():
"""Help page"""
return render_template('main/help.html')
@main_bp.route('/i18n/set-language', methods=['POST', 'GET'])
def set_language():
"""Set preferred UI language via session or user profile."""
lang = request.args.get('lang') or (request.form.get('lang') if request.method == 'POST' else None) or (request.json.get('lang') if request.is_json else None) or 'en'
lang = lang.strip().lower()
from flask import current_app
supported = list(current_app.config.get('LANGUAGES', {}).keys()) or ['en']
if lang not in supported:
lang = current_app.config.get('BABEL_DEFAULT_LOCALE', 'en')
# Persist in session for guests
session['preferred_language'] = lang
# If authenticated, persist to user profile
try:
from flask_login import current_user
from app.utils.db import safe_commit
if current_user and getattr(current_user, 'is_authenticated', False):
if getattr(current_user, 'preferred_language', None) != lang:
current_user.preferred_language = lang
safe_commit('set_language', {'user_id': current_user.id, 'lang': lang})
except Exception:
pass
# Redirect back if referer exists
next_url = request.headers.get('Referer') or url_for('main.dashboard')
return redirect(next_url)
@main_bp.route('/search')
@login_required
def search():

View File

@@ -1,4 +1,5 @@
from flask import Blueprint, render_template, request, redirect, url_for, flash, current_app
from flask_babel import gettext as _
from flask_login import login_required, current_user
from app import db
from app.models import Project, TimeEntry, Task, Client

View File

@@ -1,4 +1,5 @@
from flask import Blueprint, render_template, request, redirect, url_for, flash, jsonify
from flask_babel import gettext as _
from flask_login import login_required, current_user
from app import db
from app.models import Task, Project, User, TimeEntry, TaskActivity

View File

@@ -1,4 +1,5 @@
from flask import Blueprint, render_template, request, redirect, url_for, flash, jsonify, current_app
from flask_babel import gettext as _
from flask_login import login_required, current_user
from app import db, socketio
from app.models import User, Project, TimeEntry, Task, Settings

View File

@@ -1302,6 +1302,19 @@ main {
min-height: var(--navbar-height);
}
/* Prevent overflow issues with longer localized labels */
.navbar .container {
overflow: visible;
}
/* Allow left nav items to consume available space; keep right group fixed */
.navbar-nav.me-auto { flex: 1 1 auto; min-width: 0; }
.navbar-nav.ms-auto { flex: 0 0 auto; }
/* Allow wrapping to a second row on tighter widths (desktop too) */
.navbar-nav { flex-wrap: wrap; row-gap: 0.25rem; column-gap: 0.25rem; }
.navbar-nav .nav-item { flex: 0 1 auto; min-width: 0; }
/* body attribute now handled by CSS vars above */
[data-theme="dark"] .navbar-collapse {
background: #0b1220 !important;
@@ -1358,6 +1371,9 @@ main {
display: flex;
align-items: center;
gap: 0.5rem;
white-space: nowrap; /* keep single label per link */
overflow: hidden; /* enable ellipsis if needed */
text-overflow: ellipsis;
}
.navbar-nav .nav-link:hover {
@@ -2300,5 +2316,7 @@ h6 { font-size: 1rem; }
display: flex;
align-items: center;
}
/* Slightly tighter spacing on dense labels */
.navbar-nav .nav-link { padding: 0.7rem 1rem; }
}

View File

@@ -2,9 +2,9 @@
<div class="card stats-card hover-lift mb-4">
<div class="card-body d-flex flex-column flex-md-row justify-content-between align-items-start align-items-md-center">
<div class="mb-3 mb-md-0">
<h1 class="h3 mb-1">{% if icon_class %}<i class="{{ icon_class }} me-2"></i>{% endif %}{{ title_text }}</h1>
<h1 class="h3 mb-1">{% if icon_class %}<i class="{{ icon_class }} me-2"></i>{% endif %}{{ _(title_text) }}</h1>
{% if subtitle_text %}
<p class="mb-0 text-muted">{{ subtitle_text }}</p>
<p class="mb-0 text-muted">{{ _(subtitle_text) }}</p>
{% endif %}
</div>
<div class="d-flex flex-wrap gap-2 align-items-center">
@@ -24,7 +24,7 @@
</div>
</div>
<div class="flex-grow-1 ms-3">
<div class="summary-label">{{ label }}</div>
<div class="summary-label">{{ _(label) }}</div>
<div class="summary-value">{{ value }}</div>
</div>
</div>
@@ -37,8 +37,8 @@
<div class="bg-light rounded-circle d-flex align-items-center justify-content-center mx-auto mb-4" style="width: 80px; height: 80px;">
<i class="{{ icon_class }} fa-2x text-muted"></i>
</div>
<h4 class="text-muted mb-2">{{ title }}</h4>
<p class="text-muted mb-4">{{ message }}</p>
<h4 class="text-muted mb-2">{{ _(title) }}</h4>
<p class="text-muted mb-4">{{ _(message) }}</p>
{% if actions_html %}
<div class="d-flex flex-column flex-sm-row gap-2 justify-content-center">
{{ actions_html|safe }}

View File

@@ -32,6 +32,16 @@
<div class="form-text">Shown in tasks and reports when provided.</div>
</div>
<div class="mb-3">
<label class="form-label">Language</label>
<select name="preferred_language" class="form-select">
{% for code, label in config['LANGUAGES'].items() %}
<option value="{{ code }}" {% if (current_user.preferred_language or current_language_code) == code %}selected{% endif %}>{{ label }}</option>
{% endfor %}
</select>
<div class="form-text">Choose your interface language.</div>
</div>
<div class="mb-3">
<label class="form-label">Role</label>
<input type="text" class="form-control" value="{{ current_user.role|capitalize }}" disabled>

View File

@@ -1,6 +1,6 @@
{% extends "base.html" %}
{% block title %}Login - {{ app_name }}{% endblock %}
{% block title %}{{ _('Login') }} - {{ app_name }}{% endblock %}
{% block content %}
<div class="row justify-content-center">
@@ -13,17 +13,17 @@
{% else %}
<img src="{{ url_for('static', filename='images/drytrix-logo.svg') }}" alt="DryTrix Logo" class="mb-3" width="64" height="64">
{% endif %}
<h2 class="card-title mb-2">Welcome to TimeTracker</h2>
<p class="text-muted mb-0">Powered by <strong>DryTrix</strong></p>
<h2 class="card-title mb-2">{{ _('Welcome to TimeTracker') }}</h2>
<p class="text-muted mb-0">{{ _('Powered by') }} <strong>DryTrix</strong></p>
</div>
<p class="text-muted mb-4">
Enter your username to start tracking time
{{ _('Enter your username to start tracking time') }}
</p>
<form method="POST" action="{{ url_for('auth.login') }}" autocomplete="on" novalidate>
<div class="mb-3">
<label for="username" class="form-label">Username</label>
<label for="username" class="form-label">{{ _('Username') }}</label>
<div class="input-group">
<span class="input-group-text">
<i class="fas fa-user"></i>
@@ -32,14 +32,14 @@
class="form-control"
id="username"
name="username"
placeholder="Enter your username"
placeholder="{{ _('Enter your username') }}"
required
autofocus>
</div>
</div>
<button type="submit" class="btn btn-primary w-100" data-original-text="Continue">
<i class="fas fa-sign-in-alt me-2"></i>Continue
<button type="submit" class="btn btn-primary w-100" data-original-text="{{ _('Continue') }}">
<i class="fas fa-sign-in-alt me-2"></i>{{ _('Continue') }}
</button>
</form>
@@ -47,13 +47,13 @@
<div class="alert alert-info" role="alert">
<i class="fas fa-info-circle me-2"></i>
<strong>Internal Tool:</strong> This is a private time tracking application for internal use only.
<strong>{{ _('Internal Tool:') }}</strong> {{ _('This is a private time tracking application for internal use only.') }}
</div>
{% if settings and settings.allow_self_register %}
<p class="text-muted small">
<i class="fas fa-user-plus me-1"></i>
New users will be created automatically
{{ _('New users will be created automatically') }}
</p>
{% endif %}
</div>
@@ -61,9 +61,9 @@
<div class="text-center mt-3">
<small class="text-muted">
Version {{ app_version }} |
<a href="{{ url_for('main.about') }}" class="text-decoration-none">About</a> |
<a href="{{ url_for('main.help') }}" class="text-decoration-none">Help</a>
{{ _('Version') }} {{ app_version }} |
<a href="{{ url_for('main.about') }}" class="text-decoration-none">{{ _('About') }}</a> |
<a href="{{ url_for('main.help') }}" class="text-decoration-none">{{ _('Help') }}</a>
</small>
</div>
</div>
@@ -83,7 +83,7 @@
const username = (usernameEl && usernameEl.value ? usernameEl.value : '').trim();
if (!username) {
e.preventDefault();
alert('Please enter a username');
alert('{{ _('Please enter a username') }}');
return false;
}
// Show loading state
@@ -91,13 +91,13 @@
if (submitBtn) {
const originalText = submitBtn.innerHTML;
submitBtn.setAttribute('data-original-text', originalText);
submitBtn.innerHTML = '<i class="fas fa-spinner fa-spin me-2"></i>Signing in...';
submitBtn.innerHTML = '<i class="fas fa-spinner fa-spin me-2"></i>{{ _('Signing in...') }}';
submitBtn.disabled = true;
// Fallback: re-enable after 8s in case of network/proxy issues
setTimeout(() => {
if (submitBtn.disabled) {
submitBtn.disabled = false;
submitBtn.innerHTML = submitBtn.getAttribute('data-original-text') || 'Continue';
submitBtn.innerHTML = submitBtn.getAttribute('data-original-text') || '{{ _('Continue') }}';
console.warn('Login submission appears stalled. Button re-enabled.');
}
}, 8000);

View File

@@ -46,6 +46,10 @@
<div class="col-sm-4 text-muted">Last login</div>
<div class="col-sm-8">{{ current_user.last_login.strftime('%Y-%m-%d %H:%M') if current_user.last_login else '—' }}</div>
</div>
<div class="row mb-3">
<div class="col-sm-4 text-muted">Language</div>
<div class="col-sm-8">{{ current_language_label }}</div>
</div>
<div class="row">
<div class="col-sm-4 text-muted">Total hours</div>
<div class="col-sm-8"><span class="badge bg-success">{{ current_user.total_hours }}</span></div>

View File

@@ -1,5 +1,5 @@
<!DOCTYPE html>
<html lang="en">
<html lang="{{ current_locale or 'en' }}">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
@@ -64,7 +64,7 @@
{% else %}
<img src="{{ url_for('static', filename='images/drytrix-logo.svg') }}" alt="DryTrix Logo" width="36" height="36">
{% endif %}
<span class="text-dark fw-bold">Time Tracker</span>
<span class="text-dark fw-bold">{{ _('Time Tracker') }}</span>
</a>
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav" aria-controls="navbarNav" aria-expanded="false" aria-label="Toggle navigation">
@@ -76,55 +76,56 @@
<ul class="navbar-nav me-auto">
<li class="nav-item">
<a class="nav-link {% if request.endpoint == 'main.dashboard' %}active{% endif %}" {% if request.endpoint == 'main.dashboard' %}aria-current="page"{% endif %} href="{{ url_for('main.dashboard') }}">
<i class="fas fa-tachometer-alt"></i>Dashboard
<i class="fas fa-tachometer-alt"></i>{{ _('Dashboard') }}
</a>
</li>
<li class="nav-item">
<a class="nav-link {% if request.endpoint and 'projects.' in request.endpoint %}active{% endif %}" {% if request.endpoint and 'projects.' in request.endpoint %}aria-current="page"{% endif %} href="{{ url_for('projects.list_projects') }}">
<i class="fas fa-project-diagram"></i>Projects
<i class="fas fa-project-diagram"></i>{{ _('Projects') }}
</a>
</li>
<li class="nav-item">
<a class="nav-link {% if request.endpoint and 'clients.' in request.endpoint %}active{% endif %}" {% if request.endpoint and 'clients.' in request.endpoint %}aria-current="page"{% endif %} href="{{ url_for('clients.list_clients') }}">
<i class="fas fa-building"></i>Clients
<i class="fas fa-building"></i>{{ _('Clients') }}
</a>
</li>
<li class="nav-item">
<a class="nav-link {% if request.endpoint and 'tasks.' in request.endpoint %}active{% endif %}" {% if request.endpoint and 'tasks.' in request.endpoint %}aria-current="page"{% endif %} href="{{ url_for('tasks.list_tasks') }}">
<i class="fas fa-tasks"></i>Tasks
<i class="fas fa-tasks"></i>{{ _('Tasks') }}
</a>
</li>
<li class="nav-item">
<a class="nav-link {% if request.endpoint and 'timer.' in request.endpoint %}active{% endif %}" {% if request.endpoint and 'timer.' in request.endpoint %}aria-current="page"{% endif %} href="{{ url_for('timer.manual_entry') }}">
<i class="fas fa-plus"></i>Log Time
<i class="fas fa-plus"></i>{{ _('Log Time') }}
</a>
</li>
<li class="nav-item">
<a class="nav-link {% if request.endpoint and 'reports.' in request.endpoint %}active{% endif %}" {% if request.endpoint and 'reports.' in request.endpoint %}aria-current="page"{% endif %} href="{{ url_for('reports.reports') }}">
<i class="fas fa-chart-bar"></i>Reports
<i class="fas fa-chart-bar"></i>{{ _('Reports') }}
</a>
</li>
<li class="nav-item">
<a class="nav-link {% if request.endpoint and 'invoices.' in request.endpoint %}active{% endif %}" {% if request.endpoint and 'invoices.' in request.endpoint %}aria-current="page"{% endif %} href="{{ url_for('invoices.list_invoices') }}">
<i class="fas fa-file-invoice-dollar"></i>Invoices
<i class="fas fa-file-invoice-dollar"></i>{{ _('Invoices') }}
</a>
</li>
<li class="nav-item">
<a class="nav-link {% if request.endpoint and 'analytics.' in request.endpoint %}active{% endif %}" {% if request.endpoint and 'analytics.' in request.endpoint %}aria-current="page"{% endif %} href="{{ url_for('analytics.analytics_dashboard') }}">
<i class="fas fa-chart-line"></i>Analytics
<i class="fas fa-chart-line"></i>{{ _('Analytics') }}
</a>
</li>
{% if current_user.is_admin %}
<li class="nav-item">
<a class="nav-link {% if request.endpoint and 'admin.' in request.endpoint %}active{% endif %}" {% if request.endpoint and 'admin.' in request.endpoint %}aria-current="page"{% endif %} href="{{ url_for('admin.admin_dashboard') }}">
<i class="fas fa-cog"></i>Admin
<i class="fas fa-cog"></i>{{ _('Admin') }}
</a>
</li>
{% endif %}
</ul>
<ul class="navbar-nav">
<ul class="navbar-nav ms-auto">
{% if current_user.is_authenticated %}
<li class="nav-item dropdown">
<a class="nav-link dropdown-toggle d-flex align-items-center" href="#" id="navbarDropdown" role="button" data-bs-toggle="dropdown" data-bs-display="static" aria-expanded="false">
<div class="bg-primary bg-opacity-10 rounded-circle d-flex align-items-center justify-content-center me-2" style="width: 36px; height: 36px;">
@@ -134,14 +135,15 @@
</a>
<ul class="dropdown-menu dropdown-menu-end">
<li><a class="dropdown-item" href="{{ url_for('auth.profile') }}">
<i class="fas fa-user-circle me-2"></i>Profile
<i class="fas fa-user-circle me-2"></i>{{ _('Profile') }}
</a></li>
<li><hr class="dropdown-divider"></li>
<li><a class="dropdown-item" href="{{ url_for('auth.logout') }}">
<i class="fas fa-sign-out-alt me-2"></i>Logout
<i class="fas fa-sign-out-alt me-2"></i>{{ _('Logout') }}
</a></li>
</ul>
</li>
{% endif %}
</ul>
{% endif %}
</div>
@@ -169,21 +171,21 @@
<!-- Mobile Bottom Tab Bar -->
{% if current_user.is_authenticated %}
<div class="mobile-tabbar d-lg-none">
<a href="{{ url_for('main.dashboard') }}" class="tab-item {% if request.endpoint == 'main.dashboard' %}active{% endif %}" aria-label="Dashboard">
<a href="{{ url_for('main.dashboard') }}" class="tab-item {% if request.endpoint == 'main.dashboard' %}active{% endif %}" aria-label="{{ _('Dashboard') }}">
<i class="fas fa-tachometer-alt tab-icon"></i>
<span>Home</span>
<span>{{ _('Home') }}</span>
</a>
<a href="{{ url_for('timer.manual_entry') }}" class="tab-item {% if request.endpoint and 'timer.' in request.endpoint %}active{% endif %}" aria-label="Log Time">
<a href="{{ url_for('timer.manual_entry') }}" class="tab-item {% if request.endpoint and 'timer.' in request.endpoint %}active{% endif %}" aria-label="{{ _('Log Time') }}">
<i class="fas fa-plus tab-icon"></i>
<span>Log</span>
<span>{{ _('Log') }}</span>
</a>
<a href="{{ url_for('tasks.list_tasks') }}" class="tab-item {% if request.endpoint and 'tasks.' in request.endpoint %}active{% endif %}" aria-label="Tasks">
<a href="{{ url_for('tasks.list_tasks') }}" class="tab-item {% if request.endpoint and 'tasks.' in request.endpoint %}active{% endif %}" aria-label="{{ _('Tasks') }}">
<i class="fas fa-tasks tab-icon"></i>
<span>Tasks</span>
<span>{{ _('Tasks') }}</span>
</a>
<a href="{{ url_for('reports.reports') }}" class="tab-item {% if request.endpoint and 'reports.' in request.endpoint %}active{% endif %}" aria-label="Reports">
<a href="{{ url_for('reports.reports') }}" class="tab-item {% if request.endpoint and 'reports.' in request.endpoint %}active{% endif %}" aria-label="{{ _('Reports') }}">
<i class="fas fa-chart-bar tab-icon"></i>
<span>Reports</span>
<span>{{ _('Reports') }}</span>
</a>
</div>
{% endif %}
@@ -200,11 +202,11 @@
<div class="col-md-6 text-md-end">
<div class="d-flex justify-content-md-end gap-3 align-items-center">
<small class="text-muted">{{ app_version }}</small>
<small><a href="{{ url_for('main.about') }}" class="text-decoration-none">About</a></small>
<small><a href="{{ url_for('main.help') }}" class="text-decoration-none">Help</a></small>
<small><a href="{{ url_for('main.about') }}" class="text-decoration-none">{{ _('About') }}</a></small>
<small><a href="{{ url_for('main.help') }}" class="text-decoration-none">{{ _('Help') }}</a></small>
<small>
<a href="https://buymeacoffee.com/DryTrix" target="_blank" rel="noopener" class="text-decoration-none">
<i class="fas fa-mug-hot me-1"></i> Buy me a coffee
<i class="fas fa-mug-hot me-1"></i> {{ _('Buy me a coffee') }}
</a>
</small>
</div>

View File

@@ -1,6 +1,6 @@
{% extends "base.html" %}
{% block title %}Page Not Found - {{ app_name }}{% endblock %}
{% block title %}{{ _('Page Not Found') }} - {{ app_name }}{% endblock %}
{% block content %}
<div class="container">
@@ -9,16 +9,16 @@
<div class="py-5">
<i class="fas fa-exclamation-triangle fa-5x text-warning mb-4"></i>
<h1 class="display-4 text-muted">404</h1>
<h2 class="h4 mb-3">Page Not Found</h2>
<h2 class="h4 mb-3">{{ _('Page Not Found') }}</h2>
<p class="text-muted mb-4">
The page you're looking for doesn't exist or has been moved.
{{ _("The page you're looking for doesn't exist or has been moved.") }}
</p>
<div class="d-grid gap-2 d-md-block">
<a href="{{ url_for('main.dashboard') }}" class="btn btn-primary">
<i class="fas fa-home"></i> Go to Dashboard
<i class="fas fa-home"></i> {{ _('Go to Dashboard') }}
</a>
<a href="javascript:history.back()" class="btn btn-outline-secondary">
<i class="fas fa-arrow-left"></i> Go Back
<i class="fas fa-arrow-left"></i> {{ _('Go Back') }}
</a>
</div>
</div>

View File

@@ -1,6 +1,6 @@
{% extends "base.html" %}
{% block title %}Tasks - Time Tracker{% endblock %}
{% block title %}{{ _('Tasks') }} - Time Tracker{% endblock %}
{% block content %}
<div class="container-fluid">
@@ -12,17 +12,17 @@
{% set actions %}
<div class="d-flex align-items-center gap-2">
<a href="{{ url_for('tasks.list_tasks', view='board', search=search, status=status, priority=priority, project_id=project_id, assigned_to=assigned_to, overdue=1 if overdue else None) }}" class="btn-header btn-outline-primary {% if view != 'table' %}active{% endif %}">
<i class="fas fa-columns"></i> Board
<i class="fas fa-columns"></i> {{ _('Board') }}
</a>
<a href="{{ url_for('tasks.list_tasks', view='table', search=search, status=status, priority=priority, project_id=project_id, assigned_to=assigned_to, overdue=1 if overdue else None) }}" class="btn-header btn-outline-primary {% if view == 'table' %}active{% endif %}">
<i class="fas fa-table"></i> Table
<i class="fas fa-table"></i> {{ _('Table') }}
</a>
<a href="{{ url_for('tasks.create_task') }}" class="btn-header btn-primary">
<i class="fas fa-plus"></i> New Task
<i class="fas fa-plus"></i> {{ _('New Task') }}
</a>
</div>
{% endset %}
{{ page_header('fas fa-tasks', 'Tasks', 'Plan and track work • ' ~ (tasks|length) ~ ' total', actions) }}
{{ page_header('fas fa-tasks', _('Tasks'), _('Plan and track work') ~ ' • ' ~ (tasks|length) ~ ' ' ~ _('total'), actions) }}
</div>
</div>
@@ -40,7 +40,7 @@
</div>
</div>
<div class="flex-grow-1 ms-3">
<div class="summary-label">To Do</div>
<div class="summary-label">{{ _('To Do') }}</div>
<div class="summary-value">{{ tasks|selectattr('status', 'equalto', 'todo')|list|length }}</div>
</div>
</div>
@@ -57,7 +57,7 @@
</div>
</div>
<div class="flex-grow-1 ms-3">
<div class="summary-label">In Progress</div>
<div class="summary-label">{{ _('In Progress') }}</div>
<div class="summary-value">{{ tasks|selectattr('status', 'equalto', 'in_progress')|list|length }}</div>
</div>
</div>
@@ -74,7 +74,7 @@
</div>
</div>
<div class="flex-grow-1 ms-3">
<div class="summary-label">Review</div>
<div class="summary-label">{{ _('Review') }}</div>
<div class="summary-value">{{ tasks|selectattr('status', 'equalto', 'review')|list|length }}</div>
</div>
</div>
@@ -91,7 +91,7 @@
</div>
</div>
<div class="flex-grow-1 ms-3">
<div class="summary-label">Completed</div>
<div class="summary-label">{{ _('Completed') }}</div>
<div class="summary-value">{{ tasks|selectattr('status', 'equalto', 'done')|list|length }}</div>
</div>
</div>
@@ -104,44 +104,44 @@
<div class="card mobile-card mb-4">
<div class="card-header">
<h6 class="mb-0">
<i class="fas fa-filter me-2 text-muted"></i>Filter Tasks
<i class="fas fa-filter me-2 text-muted"></i>{{ _('Filter Tasks') }}
</h6>
</div>
<div class="card-body">
<form method="GET" class="row g-3">
<div class="col-md-4 col-sm-6">
<label for="search" class="form-label">Search</label>
<label for="search" class="form-label">{{ _('Search') }}</label>
<div class="input-group">
<span class="input-group-text"><i class="fas fa-search"></i></span>
<input type="text" class="form-control" id="search" name="search"
value="{{ search }}" placeholder="Task name or description">
value="{{ search }}" placeholder="{{ _('Task name or description') }}">
</div>
</div>
<div class="col-md-2 col-sm-6">
<label for="status" class="form-label">Status</label>
<label for="status" class="form-label">{{ _('Status') }}</label>
<select class="form-select" id="status" name="status">
<option value="">All Statuses</option>
<option value="todo" {% if status == 'todo' %}selected{% endif %}>To Do</option>
<option value="in_progress" {% if status == 'in_progress' %}selected{% endif %}>In Progress</option>
<option value="review" {% if status == 'review' %}selected{% endif %}>Review</option>
<option value="done" {% if status == 'done' %}selected{% endif %}>Done</option>
<option value="cancelled" {% if status == 'cancelled' %}selected{% endif %}>Cancelled</option>
<option value="">{{ _('All Statuses') }}</option>
<option value="todo" {% if status == 'todo' %}selected{% endif %}>{{ _('To Do') }}</option>
<option value="in_progress" {% if status == 'in_progress' %}selected{% endif %}>{{ _('In Progress') }}</option>
<option value="review" {% if status == 'review' %}selected{% endif %}>{{ _('Review') }}</option>
<option value="done" {% if status == 'done' %}selected{% endif %}>{{ _('Done') }}</option>
<option value="cancelled" {% if status == 'cancelled' %}selected{% endif %}>{{ _('Cancelled') }}</option>
</select>
</div>
<div class="col-md-2 col-sm-6">
<label for="priority" class="form-label">Priority</label>
<label for="priority" class="form-label">{{ _('Priority') }}</label>
<select class="form-select" id="priority" name="priority">
<option value="">All Priorities</option>
<option value="low" {% if priority == 'low' %}selected{% endif %}>Low</option>
<option value="medium" {% if priority == 'medium' %}selected{% endif %}>Medium</option>
<option value="high" {% if priority == 'high' %}selected{% endif %}>High</option>
<option value="urgent" {% if priority == 'urgent' %}selected{% endif %}>Urgent</option>
<option value="">{{ _('All Priorities') }}</option>
<option value="low" {% if priority == 'low' %}selected{% endif %}>{{ _('Low') }}</option>
<option value="medium" {% if priority == 'medium' %}selected{% endif %}>{{ _('Medium') }}</option>
<option value="high" {% if priority == 'high' %}selected{% endif %}>{{ _('High') }}</option>
<option value="urgent" {% if priority == 'urgent' %}selected{% endif %}>{{ _('Urgent') }}</option>
</select>
</div>
<div class="col-md-2 col-sm-6">
<label for="project_id" class="form-label">Project</label>
<label for="project_id" class="form-label">{{ _('Project') }}</label>
<select class="form-select" id="project_id" name="project_id">
<option value="">All Projects</option>
<option value="">{{ _('All Projects') }}</option>
{% for project in projects %}
<option value="{{ project.id }}" {% if project_id == project.id %}selected{% endif %}>
{{ project.name }}
@@ -150,9 +150,9 @@
</select>
</div>
<div class="col-md-2 col-sm-6">
<label for="assigned_to" class="form-label">Assigned To</label>
<label for="assigned_to" class="form-label">{{ _('Assigned To') }}</label>
<select class="form-select" id="assigned_to" name="assigned_to">
<option value="">All Users</option>
<option value="">{{ _('All Users') }}</option>
{% for user in users %}
<option value="{{ user.id }}" {% if assigned_to == user.id %}selected{% endif %}>
{{ user.display_name }}
@@ -164,17 +164,17 @@
<div class="form-check">
<input class="form-check-input" type="checkbox" value="1" id="overdue" name="overdue" {% if overdue %}checked{% endif %}>
<label class="form-check-label" for="overdue">
Show overdue only
{{ _('Show overdue only') }}
</label>
</div>
</div>
<div class="col-12">
<div class="d-flex gap-2">
<button type="submit" class="btn btn-primary">
<i class="fas fa-search me-2"></i>Apply Filters
<i class="fas fa-search me-2"></i>{{ _('Apply Filters') }}
</button>
<a href="{{ url_for('tasks.list_tasks') }}" class="btn btn-outline-secondary">
<i class="fas fa-times me-2"></i>Clear
<i class="fas fa-times me-2"></i>{{ _('Clear') }}
</a>
</div>
</div>
@@ -190,16 +190,16 @@
<table class="table table-hover align-middle">
<thead>
<tr>
<th>ID</th>
<th>Task</th>
<th>Project</th>
<th>Status</th>
<th>Priority</th>
<th>Assignee</th>
<th>Due</th>
<th>Est.</th>
<th>Progress</th>
<th class="text-end">Actions</th>
<th>{{ _('ID') }}</th>
<th>{{ _('Task') }}</th>
<th>{{ _('Project') }}</th>
<th>{{ _('Status') }}</th>
<th>{{ _('Priority') }}</th>
<th>{{ _('Assignee') }}</th>
<th>{{ _('Due') }}</th>
<th>{{ _('Est.') }}</th>
<th>{{ _('Progress') }}</th>
<th class="text-end">{{ _('Actions') }}</th>
</tr>
</thead>
<tbody>
@@ -257,14 +257,14 @@
<div class="bg-light rounded-circle d-flex align-items-center justify-content-center mx-auto mb-4" style="width: 80px; height: 80px;">
<i class="fas fa-tasks fa-2x text-muted"></i>
</div>
<h3 class="text-muted mb-3">No tasks found</h3>
<p class="text-muted mb-4">Try adjusting your filters or create your first task to get started.</p>
<h3 class="text-muted mb-3">{{ _('No tasks found') }}</h3>
<p class="text-muted mb-4">{{ _('Try adjusting your filters or create your first task to get started.') }}</p>
<div class="d-flex flex-column flex-sm-row gap-2 justify-content-center">
<a href="{{ url_for('tasks.create_task') }}" class="btn btn-primary">
<i class="fas fa-plus me-2"></i>Create Your First Task
<i class="fas fa-plus me-2"></i>{{ _('Create Your First Task') }}
</a>
<a href="{{ url_for('tasks.list_tasks') }}" class="btn btn-outline-secondary">
<i class="fas fa-times me-2"></i>Clear Filters
<i class="fas fa-times me-2"></i>{{ _('Clear Filters') }}
</a>
</div>
</div>

View File

@@ -1,4 +1,5 @@
from flask import g, request
from flask import g, request, current_app
from flask_babel import get_locale
from app.models import Settings
from app.utils.timezone import get_timezone_offset_for_timezone
@@ -56,11 +57,25 @@ def register_context_processors(app):
except Exception:
version_value = 'dev-0'
# Current locale code (e.g., 'en', 'de')
try:
current_locale = str(get_locale())
except Exception:
current_locale = 'en'
# Normalize to short code for comparisons (e.g., 'en' from 'en_US')
short_locale = (current_locale.split('_', 1)[0] if current_locale else 'en')
available_languages = current_app.config.get('LANGUAGES', {}) or {}
current_language_label = available_languages.get(short_locale, short_locale.upper())
return {
'app_name': 'Time Tracker',
'app_version': version_value,
'timezone': timezone_name,
'timezone_offset': get_timezone_offset_for_timezone(timezone_name)
'timezone_offset': get_timezone_offset_for_timezone(timezone_name),
'current_locale': current_locale,
'current_language_code': short_locale,
'current_language_label': current_language_label,
'config': current_app.config
}
@app.before_request

60
app/utils/i18n.py Normal file
View File

@@ -0,0 +1,60 @@
import os
import io
import time
from typing import Optional
def _needs_compile(po_path: str, mo_path: str) -> bool:
try:
if not os.path.exists(mo_path):
return True
return os.path.getmtime(po_path) > os.path.getmtime(mo_path)
except Exception:
return True
def compile_po_to_mo(po_path: str, mo_path: str) -> bool:
"""Compile a .po file to .mo using Babel's message tools if available.
Returns True on success, False otherwise.
"""
try:
from babel.messages.pofile import read_po
from babel.messages.mofile import write_mo
with open(po_path, 'r', encoding='utf-8') as po_file:
catalog = read_po(po_file)
os.makedirs(os.path.dirname(mo_path), exist_ok=True)
with open(mo_path, 'wb') as mo_file:
write_mo(mo_file, catalog)
return True
except Exception:
return False
def ensure_translations_compiled(translations_dir: str) -> None:
"""Compile all .po catalogs under translations_dir if missing/stale.
Structure expected: translations/<lang>/LC_MESSAGES/messages.po
"""
try:
if not translations_dir:
return
if not os.path.isabs(translations_dir):
# Resolve relative to current working directory
translations_dir = os.path.abspath(translations_dir)
if not os.path.isdir(translations_dir):
return
for lang in os.listdir(translations_dir):
lang_dir = os.path.join(translations_dir, lang, 'LC_MESSAGES')
if not os.path.isdir(lang_dir):
continue
po_path = os.path.join(lang_dir, 'messages.po')
mo_path = os.path.join(lang_dir, 'messages.mo')
if os.path.exists(po_path) and _needs_compile(po_path, mo_path):
compile_po_to_mo(po_path, mo_path)
except Exception:
# Non-fatal; i18n will fall back to msgid if mo missing
pass

View File

@@ -16,10 +16,12 @@ class LicenseServerClient:
"""Client for communicating with the DryLicenseServer"""
def __init__(self, app_identifier: str = "timetracker", app_version: str = "1.0.0"):
# Hardcoded server configuration (no license required)
# IP address is hardcoded in code - clients cannot change this
self.server_url = "http://host.docker.internal:8081" # Hardcoded IP as requested
self.api_key = "no-license-required" # Placeholder since no license needed
# Server configuration (env-overridable)
# Defaults target the dev API port per docs (HTTP 8082). In prod, set HTTPS 8443 via env.
default_server_url = "http://metrics.drytrix.com:58082"
configured_server_url = default_server_url
self.server_url = self._normalize_base_url(configured_server_url)
self.api_key = "no-license-required"
self.app_identifier = app_identifier
self.app_version = app_version
@@ -28,11 +30,23 @@ class LicenseServerClient:
self.registration_token = None
self.is_registered = False
self.heartbeat_thread = None
self.heartbeat_interval = 3600 # 1 hour
# Timing configuration
self.heartbeat_interval = int(os.getenv("LICENSE_HEARTBEAT_SECONDS", "3600")) # default: 1 hour
self.request_timeout = int(os.getenv("LICENSE_SERVER_TIMEOUT_SECONDS", "30")) # default: 30s per docs
self.running = False
# System information
self.system_info = self._collect_system_info()
logger.info(f"License server configured: base='{self.server_url}', app='{self.app_identifier}', version='{self.app_version}'")
if not self.api_key:
logger.warning("X-API-Key is empty; server may reject requests. Set LICENSE_SERVER_API_KEY.")
# Registration synchronization and persistence
self._registration_lock = threading.Lock()
self._registration_in_progress = False
self._state_file_path = self._compute_state_file_path()
self._load_persisted_state()
# Offline storage for failed requests
self.offline_data = []
@@ -85,6 +99,73 @@ class LicenseServerClient:
"architecture": "unknown",
"analytics_disabled": False
}
def _normalize_base_url(self, base_url: str) -> str:
"""Normalize base URL to avoid duplicate '/api/v1' when building endpoints.
Accepts values with or without trailing slash and with or without '/api/v1'.
Always returns a URL WITHOUT trailing slash and WITHOUT '/api/v1'.
"""
try:
if not base_url:
return ""
url = base_url.strip().rstrip("/")
# If caller provided a base that already includes '/api/v1', strip it.
if url.endswith("/api/v1"):
url = url[: -len("/api/v1")]
url = url.rstrip("/")
return url
except Exception:
# Fallback to provided value if normalization fails
return base_url
def _compute_state_file_path(self) -> str:
"""Compute a per-user path to persist license client state (instance id, token)."""
try:
if os.name == "nt":
base_dir = os.getenv("APPDATA") or os.path.expanduser("~")
app_dir = os.path.join(base_dir, "TimeTracker")
else:
app_dir = os.path.join(os.path.expanduser("~"), ".timetracker")
os.makedirs(app_dir, exist_ok=True)
return os.path.join(app_dir, "license_client_state.json")
except Exception:
# Fallback to current directory
return os.path.join(os.getcwd(), "license_client_state.json")
def _load_persisted_state(self):
"""Load previously persisted state if available (instance id, token)."""
try:
if self._state_file_path and os.path.exists(self._state_file_path):
with open(self._state_file_path, "r", encoding="utf-8") as f:
state = json.load(f)
loaded_instance_id = state.get("instance_id")
loaded_token = state.get("registration_token")
if loaded_instance_id and not self.instance_id:
self.instance_id = loaded_instance_id
if loaded_token:
self.registration_token = loaded_token
logger.info(f"Loaded persisted license client state from '{self._state_file_path}'")
except Exception as e:
logger.warning(f"Failed to load persisted license client state: {e}")
def _persist_state(self):
"""Persist current state (instance id, token) to disk."""
try:
if not self._state_file_path:
return
state = {
"instance_id": self.instance_id,
"registration_token": self.registration_token,
"app_identifier": self.app_identifier,
"app_version": self.app_version,
"updated_at": datetime.now().isoformat()
}
with open(self._state_file_path, "w", encoding="utf-8") as f:
json.dump(state, f, indent=2)
logger.debug(f"Persisted license client state to '{self._state_file_path}'")
except Exception as e:
logger.warning(f"Failed to persist license client state: {e}")
def get_detailed_error_info(self, response) -> Dict[str, Any]:
"""Extract detailed error information from a failed response"""
@@ -129,9 +210,9 @@ class LicenseServerClient:
try:
if method.upper() == "GET":
response = requests.get(url, headers=headers, timeout=10)
response = requests.get(url, headers=headers, timeout=self.request_timeout)
elif method.upper() == "POST":
response = requests.post(url, headers=headers, json=data, timeout=10)
response = requests.post(url, headers=headers, json=data, timeout=self.request_timeout)
else:
logger.error(f"Unsupported HTTP method: {method}")
return None
@@ -166,7 +247,7 @@ class LicenseServerClient:
return None
except requests.exceptions.Timeout as e:
logger.error(f"Phone home request timed out after 10 seconds: {e}")
logger.error(f"Phone home request timed out after {self.request_timeout} seconds: {e}")
return None
except requests.exceptions.ConnectionError as e:
logger.error(f"Phone home connection error: {e}")
@@ -186,44 +267,46 @@ class LicenseServerClient:
def register_instance(self) -> bool:
"""Register this instance with the phone home service"""
if self.is_registered:
logger.info("Instance already registered")
return True
# Generate instance ID if not exists
if not self.instance_id:
self.instance_id = str(uuid.uuid4())
registration_data = {
"app_identifier": self.app_identifier,
"version": self.app_version,
"instance_id": self.instance_id,
"system_metadata": self.system_info,
"country": "Unknown", # Could be enhanced with IP geolocation
"city": "Unknown",
"license_id": "NO-LICENSE-REQUIRED"
}
logger.info(f"Registering instance {self.instance_id} with phone home service at {self.server_url}")
logger.info(f"App identifier: {self.app_identifier}, Version: {self.app_version}")
logger.debug(f"System info: {json.dumps(self.system_info, indent=2)}")
logger.debug(f"Full registration data: {json.dumps(registration_data, indent=2)}")
response = self._make_request("/api/v1/register", "POST", registration_data)
if response and "instance_id" in response:
self.instance_id = response["instance_id"]
self.registration_token = response.get("token")
self.is_registered = True
logger.info(f"Successfully registered instance {self.instance_id}")
if self.registration_token:
logger.debug(f"Received registration token: {self.registration_token[:10]}...")
return True
else:
logger.error(f"Registration failed - no valid response from phone home service")
logger.error(f"Expected 'instance_id' in response, but got: {response}")
logger.info(f"Phone home service at {self.server_url} is not available - continuing without registration")
return False
with self._registration_lock:
if self.is_registered:
logger.info("Instance already registered")
return True
# Generate instance ID if not exists (prefer persisted one)
if not self.instance_id:
self.instance_id = str(uuid.uuid4())
registration_data = {
"app_identifier": self.app_identifier,
"version": self.app_version,
"instance_id": self.instance_id,
"system_metadata": self.system_info,
"country": "Unknown", # Could be enhanced with IP geolocation
"city": "Unknown",
"license_id": "NO-LICENSE-REQUIRED"
}
logger.info(f"Registering instance {self.instance_id} with phone home service at {self.server_url}")
logger.info(f"App identifier: {self.app_identifier}, Version: {self.app_version}")
logger.debug(f"System info: {json.dumps(self.system_info, indent=2)}")
logger.debug(f"Full registration data: {json.dumps(registration_data, indent=2)}")
response = self._make_request("/api/v1/register", "POST", registration_data)
if response and "instance_id" in response:
self.instance_id = response["instance_id"]
self.registration_token = response.get("token")
self.is_registered = True
self._persist_state()
logger.info(f"Successfully registered instance {self.instance_id}")
if self.registration_token:
logger.debug(f"Received registration token: {self.registration_token[:10]}...")
return True
else:
logger.error(f"Registration failed - no valid response from phone home service")
logger.error(f"Expected 'instance_id' in response, but got: {response}")
logger.info(f"Phone home service at {self.server_url} is not available - continuing without registration")
return False
def validate_license(self) -> bool:
"""Validate license (always returns True since no license required)"""
@@ -237,6 +320,26 @@ class LicenseServerClient:
"instance_id": self.instance_id
}
# Log the complete license validation request (URL, method, headers, body)
validation_url = f"{self.server_url}/api/v1/validate"
validation_headers = {
"X-API-Key": self.api_key,
"Content-Type": "application/json"
}
try:
logger.info("Complete license validation request:")
logger.info(json.dumps({
"url": validation_url,
"method": "POST",
"headers": validation_headers,
"body": validation_data
}, indent=2))
except Exception:
# Fallback logging if JSON serialization fails for any reason
logger.info(f"License validation URL: {validation_url}")
logger.info(f"License validation headers: {validation_headers}")
logger.info(f"License validation body: {validation_data}")
logger.info("Validating phone home token (no license required)")
response = self._make_request("/api/v1/validate", "POST", validation_data)
@@ -307,7 +410,8 @@ class LicenseServerClient:
def _store_offline_data(self, data_points: List[Dict[str, Any]]):
"""Store data points for offline transmission"""
for point in data_points:
point["timestamp"] = datetime.utcnow().isoformat()
# Use local time per project preference
point["timestamp"] = datetime.now().isoformat()
self.offline_data.append(point)
# Keep only the most recent data points

5
babel.cfg Normal file
View File

@@ -0,0 +1,5 @@
[python: **.py]
[jinja2: **/templates/**.html]
extensions=jinja2.ext.autoescape,jinja2.ext.with_
encoding = utf-8

View File

@@ -223,6 +223,51 @@ execute_migration_strategy() {
esac
}
# Compile translations (.po -> .mo) if needed
compile_translations() {
log "Compiling translation catalogs (if needed)..."
# Try pybabel if available
if command_exists pybabel; then
# Ensure writable permissions before compiling
chmod -R u+rwX,g+rwX /app/translations 2>/dev/null || true
if pybabel compile -d /app/translations >/dev/null 2>&1; then
log "✓ Translations compiled via pybabel"
return 0
else
log "⚠ pybabel compile failed or no catalogs to compile"
fi
else
log "pybabel not available; trying Python fallback"
fi
# Python fallback using app.utils.i18n
if python - <<'PY'
try:
import os
from app.utils.i18n import ensure_translations_compiled
try:
import pathlib
p = pathlib.Path('/app/translations')
for sub in p.glob('**/LC_MESSAGES'):
try:
os.chmod(str(sub), 0o775)
except Exception:
pass
except Exception:
pass
ensure_translations_compiled('/app/translations')
print('Python fallback: ensure_translations_compiled executed')
except Exception as e:
print(f'Python fallback failed: {e}')
PY
then
log "✓ Translations compiled via Python fallback"
return 0
else
log "⚠ Could not compile translations (will fallback to msgid)"
return 1
fi
}
# Function to execute fresh database initialization
execute_fresh_init() {
local db_url="$1"
@@ -851,6 +896,8 @@ main() {
log "Final migration status: $final_status"
# Start the application
# Best-effort compile translations before starting
compile_translations || true
log "Starting TimeTracker application..."
exec "$@"
}

View File

@@ -0,0 +1,43 @@
"""add user preferred_language column
Revision ID: 011
Revises: 010
Create Date: 2025-09-11 00:00:00
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '011'
down_revision = '010'
branch_labels = None
depends_on = None
def upgrade() -> None:
bind = op.get_bind()
inspector = sa.inspect(bind)
if 'users' not in inspector.get_table_names():
return
# Check existing columns defensively
columns = {c['name'] for c in inspector.get_columns('users')}
if 'preferred_language' not in columns:
op.add_column('users', sa.Column('preferred_language', sa.String(length=8), nullable=True))
def downgrade() -> None:
bind = op.get_bind()
inspector = sa.inspect(bind)
if 'users' not in inspector.get_table_names():
return
columns = {c['name'] for c in inspector.get_columns('users')}
if 'preferred_language' in columns:
try:
op.drop_column('users', 'preferred_language')
except Exception:
# Some backends might fail if column involved in indexes; ignore for safety
pass

View File

@@ -29,6 +29,10 @@ reportlab==4.0.7
# Background tasks
APScheduler==3.10.4
# Internationalization
Flask-Babel==4.0.0
Babel==2.14.0
# Development and testing
pytest==7.4.3
pytest-flask==1.3.0

View File

@@ -0,0 +1,32 @@
import os
import subprocess
def run(cmd: list[str]) -> int:
print("$", " ".join(cmd))
return subprocess.call(cmd)
def main():
# Requires Flask-Babel/Babel installed
os.makedirs('translations', exist_ok=True)
# Extract messages
run(['pybabel', 'extract', '-F', 'babel.cfg', '-o', 'messages.pot', '.'])
# Initialize languages if not already
languages = ['en', 'nl', 'de', 'fr', 'it', 'fi']
for lang in languages:
po_dir = os.path.join('translations', lang, 'LC_MESSAGES')
po_path = os.path.join(po_dir, 'messages.po')
if not os.path.exists(po_path):
run(['pybabel', 'init', '-i', 'messages.pot', '-d', 'translations', '-l', lang])
# Update catalogs
run(['pybabel', 'update', '-i', 'messages.pot', '-d', 'translations'])
# Compile
run(['pybabel', 'compile', '-d', 'translations'])
if __name__ == '__main__':
main()

View File

@@ -1,6 +1,6 @@
{% extends "base.html" %}
{% block title %}User Management - {{ app_name }}{% endblock %}
{% block title %}{{ _('User Management') }} - {{ app_name }}{% endblock %}
{% block content %}
<div class="container-fluid">
@@ -9,10 +9,10 @@
<div class="col-12">
{% set actions %}
<a href="{{ url_for('admin.create_user') }}" class="btn-header btn-primary">
<i class="fas fa-user-plus"></i> New User
<i class="fas fa-user-plus"></i> {{ _('New User') }}
</a>
{% endset %}
{{ page_header('fas fa-users', 'User Management', 'Manage users • ' ~ (users|length) ~ ' total', actions) }}
{{ page_header('fas fa-users', _('User Management'), _('Manage users') ~ ' • ' ~ (users|length) ~ ' ' ~ _('total'), actions) }}
</div>
</div>
@@ -23,7 +23,7 @@
<div class="card-body">
<i class="fas fa-users fa-2x text-primary mb-2"></i>
<h4 class="text-primary">{{ stats.total_users }}</h4>
<p class="text-muted mb-0">Total Users</p>
<p class="text-muted mb-0">{{ _('Total Users') }}</p>
</div>
</div>
</div>
@@ -32,7 +32,7 @@
<div class="card-body">
<i class="fas fa-user-check fa-2x text-success mb-2"></i>
<h4 class="text-success">{{ stats.active_users }}</h4>
<p class="text-muted mb-0">Active Users</p>
<p class="text-muted mb-0">{{ _('Active Users') }}</p>
</div>
</div>
</div>
@@ -41,7 +41,7 @@
<div class="card-body">
<i class="fas fa-user-shield fa-2x text-warning mb-2"></i>
<h4 class="text-warning">{{ stats.admin_users }}</h4>
<p class="text-muted mb-0">Admin Users</p>
<p class="text-muted mb-0">{{ _('Admin Users') }}</p>
</div>
</div>
</div>
@@ -50,7 +50,7 @@
<div class="card-body">
<i class="fas fa-clock fa-2x text-info mb-2"></i>
<h4 class="text-info">{{ "%.1f"|format(stats.total_hours) }}h</h4>
<p class="text-muted mb-0">Total Hours</p>
<p class="text-muted mb-0">{{ _('Total Hours') }}</p>
</div>
</div>
</div>
@@ -63,14 +63,14 @@
<div class="card-header bg-white py-3">
<div class="d-flex justify-content-between align-items-center">
<h6 class="m-0 font-weight-bold text-primary">
<i class="fas fa-list me-2"></i>All Users
<i class="fas fa-list me-2"></i>{{ _('All Users') }}
</h6>
<div class="d-flex gap-2">
<div class="input-group input-group-sm" style="width: 280px;">
<span class="input-group-text bg-light border-end-0">
<i class="fas fa-search text-muted"></i>
</span>
<input type="text" class="form-control border-start-0" id="searchInput" placeholder="Search users...">
<input type="text" class="form-control border-start-0" id="searchInput" placeholder="{{ _('Search users...') }}">
</div>
</div>
</div>
@@ -81,13 +81,13 @@
<table class="table table-hover mb-0" id="usersTable">
<thead>
<tr>
<th>User</th>
<th>Role</th>
<th>Status</th>
<th>Created</th>
<th>Last Login</th>
<th>Total Hours</th>
<th class="text-center">Actions</th>
<th>{{ _('User') }}</th>
<th>{{ _('Role') }}</th>
<th>{{ _('Status') }}</th>
<th>{{ _('Created') }}</th>
<th>{{ _('Last Login') }}</th>
<th>{{ _('Total Hours') }}</th>
<th class="text-center">{{ _('Actions') }}</th>
</tr>
</thead>
<tbody>
@@ -98,23 +98,23 @@
<strong>{{ user.display_name }}</strong>
{% if user.active_timer %}
<br><small class="text-warning">
<i class="fas fa-clock"></i> Timer Running
<i class="fas fa-clock"></i> {{ _('Timer Running') }}
</small>
{% endif %}
</div>
</td>
<td>
{% if user.role == 'admin' %}
<span class="badge bg-warning">Admin</span>
<span class="badge bg-warning">{{ _('Admin') }}</span>
{% else %}
<span class="badge bg-secondary">User</span>
<span class="badge bg-secondary">{{ _('User') }}</span>
{% endif %}
</td>
<td>
{% if user.is_active %}
<span class="badge bg-success">Active</span>
<span class="badge bg-success">{{ _('Active') }}</span>
{% else %}
<span class="badge bg-danger">Inactive</span>
<span class="badge bg-danger">{{ _('Inactive') }}</span>
{% endif %}
</td>
<td>{{ user.created_at.strftime('%Y-%m-%d') }}</td>
@@ -122,7 +122,7 @@
{% if user.last_login %}
{{ user.last_login.strftime('%Y-%m-%d %H:%M') }}
{% else %}
<span class="text-muted">Never</span>
<span class="text-muted">{{ _('Never') }}</span>
{% endif %}
</td>
<td>
@@ -131,11 +131,11 @@
<td class="text-center actions-cell">
<div class="btn-group btn-group-sm" role="group">
<a href="{{ url_for('admin.edit_user', user_id=user.id) }}"
class="btn btn-sm btn-action btn-action--edit" title="Edit">
class="btn btn-sm btn-action btn-action--edit" title="{{ _('Edit') }}">
<i class="fas fa-edit"></i>
</a>
{% if user.id != current_user.id %}
<button type="button" class="btn btn-sm btn-action btn-action--danger" title="Delete"
<button type="button" class="btn btn-sm btn-action btn-action--danger" title="{{ _('Delete') }}"
onclick="showDeleteUserModal('{{ user.id }}', '{{ user.username }}')">
<i class="fas fa-trash"></i>
</button>
@@ -151,10 +151,10 @@
<div class="text-center py-5">
<div class="empty-state">
<i class="fas fa-users fa-3x text-muted mb-3"></i>
<h5 class="text-muted">No users found</h5>
<p class="text-muted mb-4">Create your first user to get started with administration.</p>
<h5 class="text-muted">{{ _('No users found') }}</h5>
<p class="text-muted mb-4">{{ _('Create your first user to get started with administration.') }}</p>
<a href="{{ url_for('admin.create_user') }}" class="btn btn-primary">
<i class="fas fa-user-plus me-2"></i> Create First User
<i class="fas fa-user-plus me-2"></i> {{ _('Create First User') }}
</a>
</div>
</div>
@@ -171,25 +171,25 @@
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">
<i class="fas fa-trash me-2 text-danger"></i>Delete User
<i class="fas fa-trash me-2 text-danger"></i>{{ _('Delete User') }}
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<div class="alert alert-danger">
<i class="fas fa-exclamation-triangle me-2"></i>
<strong>Warning:</strong> This action cannot be undone.
<strong>{{ _('Warning:') }}</strong> {{ _('This action cannot be undone.') }}
</div>
<p>Are you sure you want to delete the user <strong id="deleteUserName"></strong>?</p>
<p class="text-muted mb-0">This will permanently remove the user and all their data cannot be recovered.</p>
<p>{{ _('Are you sure you want to delete the user') }} <strong id="deleteUserName"></strong>?</p>
<p class="text-muted mb-0">{{ _('This will permanently remove the user and all their data cannot be recovered.') }}</p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">
<i class="fas fa-times me-1"></i>Cancel
<i class="fas fa-times me-1"></i>{{ _('Cancel') }}
</button>
<form method="POST" id="deleteUserForm" class="d-inline">
<button type="submit" class="btn btn-danger">
<i class="fas fa-trash me-2"></i>Delete User
<i class="fas fa-trash me-2"></i>{{ _('Delete User') }}
</button>
</form>
</div>

View File

@@ -1,6 +1,6 @@
{% extends "base.html" %}
{% block title %}Create Client - {{ app_name }}{% endblock %}
{% block title %}{{ _('Create Client') }} - {{ app_name }}{% endblock %}
{% block content %}
<div class="container-fluid">
@@ -8,10 +8,10 @@
<div class="col-12">
<div class="d-flex justify-content-between align-items-center mb-4">
<h1 class="h3 mb-0">
<i class="fas fa-plus text-primary"></i> Create New Client
<i class="fas fa-plus text-primary"></i> {{ _('Create New Client') }}
</h1>
<a href="{{ url_for('clients.list_clients') }}" class="btn btn-secondary">
<i class="fas fa-arrow-left"></i> Back to Clients
<i class="fas fa-arrow-left"></i> {{ _('Back to Clients') }}
</a>
</div>
</div>
@@ -22,7 +22,7 @@
<div class="card">
<div class="card-header">
<h5 class="mb-0">
<i class="fas fa-info-circle"></i> Client Information
<i class="fas fa-info-circle"></i> {{ _('Client Information') }}
</h5>
</div>
<div class="card-body">
@@ -30,41 +30,41 @@
<div class="row">
<div class="col-md-6">
<div class="mb-3">
<label for="name" class="form-label">Client Name *</label>
<label for="name" class="form-label">{{ _('Client Name') }} *</label>
<input type="text" class="form-control" id="name" name="name" required
value="{{ request.form.get('name','') }}" placeholder="Enter client name">
value="{{ request.form.get('name','') }}" placeholder="{{ _('Enter client name') }}">
</div>
</div>
<div class="col-md-6">
<div class="mb-3">
<label for="default_hourly_rate" class="form-label">Default Hourly Rate</label>
<label for="default_hourly_rate" class="form-label">{{ _('Default Hourly Rate') }}</label>
<input type="number" step="0.01" min="0" class="form-control" id="default_hourly_rate"
name="default_hourly_rate" value="{{ request.form.get('default_hourly_rate','') }}"
placeholder="e.g. 75.00">
<div class="form-text">This rate will be automatically filled when creating projects for this client</div>
placeholder="{{ _('e.g. 75.00') }}">
<div class="form-text">{{ _('This rate will be automatically filled when creating projects for this client') }}</div>
</div>
</div>
</div>
<div class="mb-3">
<label for="description" class="form-label">Description</label>
<label for="description" class="form-label">{{ _('Description') }}</label>
<textarea class="form-control" id="description" name="description" rows="3"
placeholder="Brief description of the client or project scope">{{ request.form.get('description','') }}</textarea>
placeholder="{{ _('Brief description of the client or project scope') }}">{{ request.form.get('description','') }}</textarea>
</div>
<div class="row">
<div class="col-md-6">
<div class="mb-3">
<label for="contact_person" class="form-label">Contact Person</label>
<label for="contact_person" class="form-label">{{ _('Contact Person') }}</label>
<input type="text" class="form-control" id="contact_person" name="contact_person"
value="{{ request.form.get('contact_person','') }}" placeholder="Primary contact name">
value="{{ request.form.get('contact_person','') }}" placeholder="{{ _('Primary contact name') }}">
</div>
</div>
<div class="col-md-6">
<div class="mb-3">
<label for="email" class="form-label">Email</label>
<label for="email" class="form-label">{{ _('Email') }}</label>
<input type="email" class="form-control" id="email" name="email"
value="{{ request.form.get('email','') }}" placeholder="contact@client.com">
value="{{ request.form.get('email','') }}" placeholder="{{ _('contact@client.com') }}">
</div>
</div>
</div>
@@ -72,26 +72,26 @@
<div class="row">
<div class="col-md-6">
<div class="mb-3">
<label for="phone" class="form-label">Phone</label>
<label for="phone" class="form-label">{{ _('Phone') }}</label>
<input type="tel" class="form-control" id="phone" name="phone"
value="{{ request.form.get('phone','') }}" placeholder="+1 (555) 123-4567">
value="{{ request.form.get('phone','') }}" placeholder="{{ _('+1 (555) 123-4567') }}">
</div>
</div>
<div class="col-md-6">
<div class="mb-3">
<label for="address" class="form-label">Address</label>
<label for="address" class="form-label">{{ _('Address') }}</label>
<textarea class="form-control" id="address" name="address" rows="2"
placeholder="Client address">{{ request.form.get('address','') }}</textarea>
placeholder="{{ _('Client address') }}">{{ request.form.get('address','') }}</textarea>
</div>
</div>
</div>
<div class="d-flex justify-content-between">
<a href="{{ url_for('clients.list_clients') }}" class="btn btn-secondary">
<i class="fas fa-times"></i> Cancel
<i class="fas fa-times"></i> {{ _('Cancel') }}
</a>
<button type="submit" class="btn btn-primary">
<i class="fas fa-save"></i> Create Client
<i class="fas fa-save"></i> {{ _('Create Client') }}
</button>
</div>
</form>
@@ -103,21 +103,21 @@
<div class="card">
<div class="card-header">
<h5 class="mb-0">
<i class="fas fa-info-circle"></i> Help
<i class="fas fa-info-circle"></i> {{ _('Help') }}
</h5>
</div>
<div class="card-body">
<h6>Client Name</h6>
<p class="text-muted small">Choose a clear, descriptive name for the client organization.</p>
<h6>{{ _('Client Name') }}</h6>
<p class="text-muted small">{{ _('Choose a clear, descriptive name for the client organization.') }}</p>
<h6>Default Hourly Rate</h6>
<p class="text-muted small">Set the standard hourly rate for this client. This will automatically populate when creating new projects.</p>
<h6>{{ _('Default Hourly Rate') }}</h6>
<p class="text-muted small">{{ _('Set the standard hourly rate for this client. This will automatically populate when creating new projects.') }}</p>
<h6>Contact Information</h6>
<p class="text-muted small">Add contact details for easy communication and record keeping.</p>
<h6>{{ _('Contact Information') }}</h6>
<p class="text-muted small">{{ _('Add contact details for easy communication and record keeping.') }}</p>
<h6>Description</h6>
<p class="text-muted small">Provide context about the client relationship or typical project types.</p>
<h6>{{ _('Description') }}</h6>
<p class="text-muted small">{{ _('Provide context about the client relationship or typical project types.') }}</p>
</div>
</div>
</div>

View File

@@ -1,6 +1,6 @@
{% extends "base.html" %}
{% block title %}Edit Client - {{ app_name }}{% endblock %}
{% block title %}{{ _('Edit Client') }} - {{ app_name }}{% endblock %}
{% block content %}
<div class="container-fluid">

View File

@@ -1,6 +1,6 @@
{% extends "base.html" %}
{% block title %}Clients - {{ app_name }}{% endblock %}
{% block title %}{{ _('Clients') }} - {{ app_name }}{% endblock %}
{% block content %}
<div class="container-fluid">
@@ -10,11 +10,11 @@
{% set actions %}
{% if current_user.is_admin %}
<a href="{{ url_for('clients.create_client') }}" class="btn-header btn-primary">
<i class="fas fa-plus"></i> New Client
<i class="fas fa-plus"></i> {{ _('New Client') }}
</a>
{% endif %}
{% endset %}
{{ page_header('fas fa-building', 'Clients', 'Manage customers and contacts • ' ~ (clients|length) ~ ' total', actions) }}
{{ page_header('fas fa-building', _('Clients'), _('Manage customers and contacts') ~ ' • ' ~ (clients|length) ~ ' ' ~ _('total'), actions) }}
</div>
</div>
@@ -25,7 +25,7 @@
<div class="card-header bg-white py-3">
<div class="d-flex justify-content-between align-items-center">
<h6 class="m-0 font-weight-bold text-primary">
<i class="fas fa-list me-2"></i>All Clients
<i class="fas fa-list me-2"></i>{{ _('All Clients') }}
</h6>
<div class="d-flex gap-2">
<div class="input-group input-group-sm" style="width: 250px;">
@@ -33,7 +33,7 @@
<i class="fas fa-search text-muted"></i>
</span>
<input type="text" class="form-control border-start-0" id="searchInput"
placeholder="Search clients...">
placeholder="{{ _('Search clients...') }}">
</div>
</div>
</div>
@@ -44,14 +44,14 @@
<table class="table table-hover mb-0" id="clientsTable">
<thead class="table-light">
<tr>
<th>Name</th>
<th>Contact Person</th>
<th>Email</th>
<th>Phone</th>
<th>Default Rate</th>
<th>Projects</th>
<th>Status</th>
<th>Actions</th>
<th>{{ _('Name') }}</th>
<th>{{ _('Contact Person') }}</th>
<th>{{ _('Email') }}</th>
<th>{{ _('Phone') }}</th>
<th>{{ _('Default Rate') }}</th>
<th>{{ _('Projects') }}</th>
<th>{{ _('Status') }}</th>
<th>{{ _('Actions') }}</th>
</tr>
</thead>
<tbody>
@@ -89,8 +89,8 @@
</td>
<td>
{% set status_map = {
'active': {'bg': 'bg-success', 'label': 'Active'},
'inactive': {'bg': 'bg-secondary', 'label': 'Inactive'}
'active': {'bg': 'bg-success', 'label': _('Active')},
'inactive': {'bg': 'bg-secondary', 'label': _('Inactive')}
} %}
{% set sc = status_map.get(client.status, status_map['inactive']) %}
<span class="status-badge {{ sc.bg }} text-white">{{ sc.label }}</span>
@@ -98,27 +98,27 @@
<td>
<div class="btn-group btn-group-sm" role="group">
<a href="{{ url_for('clients.view_client', client_id=client.id) }}"
class="btn btn-sm btn-action btn-action--view" title="View">
class="btn btn-sm btn-action btn-action--view" title="{{ _('View') }}">
<i class="fas fa-eye"></i>
</a>
{% if current_user.is_admin %}
<a href="{{ url_for('clients.edit_client', client_id=client.id) }}"
class="btn btn-sm btn-action btn-action--edit" title="Edit">
class="btn btn-sm btn-action btn-action--edit" title="{{ _('Edit') }}">
<i class="fas fa-edit"></i>
</a>
{% if client.status == 'active' %}
<button type="button" class="btn btn-sm btn-action btn-action--warning" title="Archive"
<button type="button" class="btn btn-sm btn-action btn-action--warning" title="{{ _('Archive') }}"
onclick="confirmArchiveClient('{{ client.id }}', '{{ client.name }}')">
<i class="fas fa-archive"></i>
</button>
{% else %}
<button type="button" class="btn btn-sm btn-action btn-action--success" title="Activate"
<button type="button" class="btn btn-sm btn-action btn-action--success" title="{{ _('Activate') }}"
onclick="confirmActivateClient('{{ client.id }}', '{{ client.name }}')">
<i class="fas fa-check"></i>
</button>
{% endif %}
{% if client.total_projects == 0 %}
<button type="button" class="btn btn-sm btn-action btn-action--danger" title="Delete"
<button type="button" class="btn btn-sm btn-action btn-action--danger" title="{{ _('Delete') }}"
onclick="confirmDeleteClient('{{ client.id }}', '{{ client.name }}')">
<i class="fas fa-trash"></i>
</button>
@@ -134,11 +134,11 @@
{% else %}
<div class="text-center py-5">
<i class="fas fa-building fa-3x text-muted mb-3"></i>
<h4 class="text-muted">No Clients Found</h4>
<p class="text-muted">Get started by creating your first client.</p>
<h4 class="text-muted">{{ _('No Clients Found') }}</h4>
<p class="text-muted">{{ _('Get started by creating your first client.') }}</p>
{% if current_user.is_admin %}
<a href="{{ url_for('clients.create_client') }}" class="btn btn-primary">
<i class="fas fa-plus"></i> Create First Client
<i class="fas fa-plus"></i> {{ _('Create First Client') }}
</a>
{% endif %}
</div>

View File

@@ -10,14 +10,14 @@
{% set actions %}
{% if current_user.is_admin %}
<a href="{{ url_for('clients.edit_client', client_id=client.id) }}" class="btn btn-outline-light btn-sm me-2">
<i class="fas fa-edit"></i> Edit Client
<i class="fas fa-edit"></i> {{ _('Edit Client') }}
</a>
{% endif %}
<a href="{{ url_for('clients.list_clients') }}" class="btn btn-outline-light btn-sm">
<i class="fas fa-arrow-left"></i> Back to Clients
<i class="fas fa-arrow-left"></i> {{ _('Back to Clients') }}
</a>
{% endset %}
{{ page_header('fas fa-building', client.name, 'Client details and project overview', actions) }}
{{ page_header('fas fa-building', client.name, _('Client details and project overview'), actions) }}
</div>
</div>
@@ -29,7 +29,7 @@
<i class="fas fa-project-diagram"></i>
</div>
<div>
<div class="summary-label">Total Projects</div>
<div class="summary-label">{{ _('Total Projects') }}</div>
<div class="summary-value">{{ client.total_projects }}</div>
</div>
</div>
@@ -42,7 +42,7 @@
<i class="fas fa-toggle-on"></i>
</div>
<div>
<div class="summary-label">Active Projects</div>
<div class="summary-label">{{ _('Active Projects') }}</div>
<div class="summary-value">{{ client.active_projects }}</div>
</div>
</div>
@@ -55,7 +55,7 @@
<i class="fas fa-clock"></i>
</div>
<div>
<div class="summary-label">Total Hours</div>
<div class="summary-label">{{ _('Total Hours') }}</div>
<div class="summary-value">{{ "%.1f"|format(client.total_hours) }}</div>
</div>
</div>
@@ -68,7 +68,7 @@
<i class="fas fa-sack-dollar"></i>
</div>
<div>
<div class="summary-label">Est. Total Cost</div>
<div class="summary-label">{{ _('Est. Total Cost') }}</div>
<div class="summary-value">{{ "%.2f"|format(client.estimated_total_cost) }} {{ currency }}</div>
</div>
</div>
@@ -82,32 +82,32 @@
<div class="card mb-4 shadow-sm border-0">
<div class="card-header bg-white py-3">
<h6 class="m-0 font-weight-bold text-primary">
<i class="fas fa-info-circle me-2"></i>Client Information
<i class="fas fa-info-circle me-2"></i>{{ _('Client Information') }}
</h6>
</div>
<div class="card-body">
<div class="mb-3">
<div class="detail-row">
<span class="detail-label">Status</span>
<span class="detail-label">{{ _('Status') }}</span>
<span class="detail-value">
{% if client.status == 'active' %}
<span class="status-badge-large bg-success text-white">Active</span>
<span class="status-badge-large bg-success text-white">{{ _('Active') }}</span>
{% else %}
<span class="status-badge-large bg-secondary text-white">Archived</span>
<span class="status-badge-large bg-secondary text-white">{{ _('Archived') }}</span>
{% endif %}
</span>
</div>
</div>
{% if client.description %}
<div class="mb-3">
<div class="section-title text-primary mb-2">Description</div>
<div class="section-title text-primary mb-2">{{ _('Description') }}</div>
<div class="content-box">{{ client.description }}</div>
</div>
{% endif %}
{% if client.contact_person %}
<div class="mb-3">
<div class="detail-row">
<span class="detail-label">Contact Person</span>
<span class="detail-label">{{ _('Contact Person') }}</span>
<span class="detail-value">{{ client.contact_person }}</span>
</div>
</div>
@@ -115,7 +115,7 @@
{% if client.email %}
<div class="mb-3">
<div class="detail-row">
<span class="detail-label">Email</span>
<span class="detail-label">{{ _('Email') }}</span>
<span class="detail-value"><a href="mailto:{{ client.email }}">{{ client.email }}</a></span>
</div>
</div>
@@ -123,21 +123,21 @@
{% if client.phone %}
<div class="mb-3">
<div class="detail-row">
<span class="detail-label">Phone</span>
<span class="detail-label">{{ _('Phone') }}</span>
<span class="detail-value">{{ client.phone }}</span>
</div>
</div>
{% endif %}
{% if client.address %}
<div class="mb-3">
<div class="section-title text-primary mb-2">Address</div>
<div class="section-title text-primary mb-2">{{ _('Address') }}</div>
<div class="content-box">{{ client.address }}</div>
</div>
{% endif %}
{% if client.default_hourly_rate %}
<div class="mb-3">
<div class="detail-row">
<span class="detail-label">Default Hourly Rate</span>
<span class="detail-label">{{ _('Default Hourly Rate') }}</span>
<span class="detail-value">{{ "%.2f"|format(client.default_hourly_rate) }} {{ currency }}</span>
</div>
</div>
@@ -149,29 +149,29 @@
<div class="card mb-4 shadow-sm border-0">
<div class="card-header bg-white py-3">
<h6 class="m-0 font-weight-bold text-primary">
<i class="fas fa-chart-bar me-2"></i>Statistics
<i class="fas fa-chart-bar me-2"></i>{{ _('Statistics') }}
</h6>
</div>
<div class="card-body">
<div class="row text-center">
<div class="col-6">
<h4 class="text-primary">{{ client.total_projects }}</h4>
<small class="text-muted">Total Projects</small>
<small class="text-muted">{{ _('Total Projects') }}</small>
</div>
<div class="col-6">
<h4 class="text-success">{{ client.active_projects }}</h4>
<small class="text-muted">Active Projects</small>
<small class="text-muted">{{ _('Active Projects') }}</small>
</div>
</div>
<hr>
<div class="row text-center">
<div class="col-6">
<h4 class="text-info">{{ "%.1f"|format(client.total_hours) }}</h4>
<small class="text-muted">Total Hours</small>
<small class="text-muted">{{ _('Total Hours') }}</small>
</div>
<div class="col-6">
<h4 class="text-warning">{{ "%.2f"|format(client.estimated_total_cost) }}</h4>
<small class="text-muted">Est. Total Cost</small>
<small class="text-muted">{{ _('Est. Total Cost') }}</small>
</div>
</div>
</div>
@@ -182,28 +182,28 @@
<div class="card shadow-sm border-0">
<div class="card-header bg-white py-3">
<h6 class="m-0 font-weight-bold text-primary">
<i class="fas fa-cog me-2"></i>Status & Actions
<i class="fas fa-cog me-2"></i>{{ _('Status & Actions') }}
</h6>
</div>
<div class="card-body">
{% if client.status == 'active' %}
<form method="POST" action="{{ url_for('clients.archive_client', client_id=client.id) }}" class="mb-2" onsubmit="return confirm('Are you sure you want to archive this client?')">
<form method="POST" action="{{ url_for('clients.archive_client', client_id=client.id) }}" class="mb-2" onsubmit="return confirm('{{ _('Are you sure you want to archive this client?') }}')">
<button type="submit" class="btn btn-outline-secondary w-100">
<i class="fas fa-archive me-2"></i>Archive Client
<i class="fas fa-archive me-2"></i>{{ _('Archive Client') }}
</button>
</form>
{% else %}
<form method="POST" action="{{ url_for('clients.activate_client', client_id=client.id) }}" class="mb-2">
<button type="submit" class="btn btn-primary w-100">
<i class="fas fa-check me-2"></i>Activate Client
<i class="fas fa-check me-2"></i>{{ _('Activate Client') }}
</button>
</form>
{% endif %}
{% if client.total_projects == 0 %}
<form method="POST" action="{{ url_for('clients.delete_client', client_id=client.id) }}" onsubmit="return confirm('Are you sure you want to delete this client? This action cannot be undone.')">
<form method="POST" action="{{ url_for('clients.delete_client', client_id=client.id) }}" onsubmit="return confirm('{{ _('Are you sure you want to delete this client? This action cannot be undone.') }}')">
<button type="submit" class="btn btn-danger w-100">
<i class="fas fa-trash me-2"></i>Delete Client
<i class="fas fa-trash me-2"></i>{{ _('Delete Client') }}
</button>
</form>
{% endif %}
@@ -217,12 +217,12 @@
<div class="card shadow-sm border-0">
<div class="card-header bg-white py-3 d-flex justify-content-between align-items-center">
<h6 class="m-0 font-weight-bold text-primary">
<i class="fas fa-project-diagram me-2"></i>Projects
<span class="badge badge-soft-secondary ms-2">{{ projects|length }} total</span>
<i class="fas fa-project-diagram me-2"></i>{{ _('Projects') }}
<span class="badge badge-soft-secondary ms-2">{{ projects|length }} {{ _('total') }}</span>
</h6>
{% if current_user.is_admin %}
<a href="{{ url_for('projects.create_project') }}" class="btn btn-sm btn-outline-primary">
<i class="fas fa-plus me-1"></i> New Project
<i class="fas fa-plus me-1"></i> {{ _('New Project') }}
</a>
{% endif %}
</div>
@@ -232,13 +232,13 @@
<table class="table table-hover mb-0">
<thead class="table-light">
<tr>
<th>Project Name</th>
<th>Status</th>
<th>Billable</th>
<th>Hourly Rate</th>
<th>Total Hours</th>
<th>Est. Cost</th>
<th>Actions</th>
<th>{{ _('Project Name') }}</th>
<th>{{ _('Status') }}</th>
<th>{{ _('Billable') }}</th>
<th>{{ _('Hourly Rate') }}</th>
<th>{{ _('Total Hours') }}</th>
<th>{{ _('Est. Cost') }}</th>
<th>{{ _('Actions') }}</th>
</tr>
</thead>
<tbody>
@@ -254,16 +254,16 @@
</td>
<td>
{% if project.status == 'active' %}
<span class="badge badge-soft-success">Active</span>
<span class="badge badge-soft-success">{{ _('Active') }}</span>
{% else %}
<span class="badge badge-soft-secondary">Archived</span>
<span class="badge badge-soft-secondary">{{ _('Archived') }}</span>
{% endif %}
</td>
<td>
{% if project.billable %}
<span class="badge badge-soft-primary">Yes</span>
<span class="badge badge-soft-primary">{{ _('Yes') }}</span>
{% else %}
<span class="badge badge-soft-secondary">No</span>
<span class="badge badge-soft-secondary">{{ _('No') }}</span>
{% endif %}
</td>
<td>
@@ -284,12 +284,12 @@
<td>
<div class="btn-group" role="group">
<a href="{{ url_for('projects.view_project', project_id=project.id) }}"
class="btn btn-sm btn-action btn-action--view" title="View">
class="btn btn-sm btn-action btn-action--view" title="{{ _('View') }}">
<i class="fas fa-eye"></i>
</a>
{% if current_user.is_admin %}
<a href="{{ url_for('projects.edit_project', project_id=project.id) }}"
class="btn btn-sm btn-action btn-action--edit" title="Edit">
class="btn btn-sm btn-action btn-action--edit" title="{{ _('Edit') }}">
<i class="fas fa-edit"></i>
</a>
{% endif %}
@@ -304,11 +304,11 @@
<div class="text-center py-5">
<div class="empty-state">
<i class="fas fa-project-diagram fa-3x text-muted mb-3"></i>
<h5 class="text-muted">No projects found</h5>
<p class="text-muted mb-3">This client doesn't have any projects yet.</p>
<h5 class="text-muted">{{ _('No projects found') }}</h5>
<p class="text-muted mb-3">{{ _("This client doesn't have any projects yet.") }}</p>
{% if current_user.is_admin %}
<a href="{{ url_for('projects.create_project') }}" class="btn btn-primary">
<i class="fas fa-plus me-2"></i> Create First Project
<i class="fas fa-plus me-2"></i> {{ _('Create First Project') }}
</a>
{% endif %}
</div>

View File

@@ -1,6 +1,6 @@
{% extends "base.html" %}
{% block title %}Invoices - TimeTracker{% endblock %}
{% block title %}{{ _('Invoices') }} - TimeTracker{% endblock %}
{% block content %}
<div class="container-fluid">
@@ -9,10 +9,10 @@
<div class="col-12">
{% set actions %}
<a href="{{ url_for('invoices.create_invoice') }}" class="btn-header btn-primary">
<i class="fas fa-plus"></i> Create Invoice
<i class="fas fa-plus"></i> {{ _('Create Invoice') }}
</a>
{% endset %}
{{ page_header('fas fa-file-invoice-dollar', 'Invoices', 'Billing overview • ' ~ summary.total_invoices ~ ' total', actions) }}
{{ page_header('fas fa-file-invoice-dollar', _('Invoices'), _('Billing overview') ~ ' • ' ~ summary.total_invoices ~ ' ' ~ _('total'), actions) }}
</div>
</div>
@@ -28,7 +28,7 @@
</div>
</div>
<div class="flex-grow-1 ms-3">
<div class="summary-label">Total Invoices</div>
<div class="summary-label">{{ _('Total Invoices') }}</div>
<div class="summary-value">{{ summary.total_invoices }}</div>
</div>
</div>
@@ -46,7 +46,7 @@
</div>
</div>
<div class="flex-grow-1 ms-3">
<div class="summary-label">Total Amount</div>
<div class="summary-label">{{ _('Total Amount') }}</div>
<div class="summary-value">{{ "%.2f"|format(summary.total_amount) }} {{ currency }}</div>
</div>
</div>
@@ -64,7 +64,7 @@
</div>
</div>
<div class="flex-grow-1 ms-3">
<div class="summary-label">Outstanding</div>
<div class="summary-label">{{ _('Outstanding') }}</div>
<div class="summary-value">{{ "%.2f"|format(summary.outstanding_amount) }} {{ currency }}</div>
</div>
</div>
@@ -82,7 +82,7 @@
</div>
</div>
<div class="flex-grow-1 ms-3">
<div class="summary-label">Overdue</div>
<div class="summary-label">{{ _('Overdue') }}</div>
<div class="summary-value">{{ "%.2f"|format(summary.overdue_amount) }} {{ currency }}</div>
</div>
</div>
@@ -96,7 +96,7 @@
<div class="card-header bg-white py-3">
<div class="d-flex justify-content-between align-items-center">
<h6 class="m-0 font-weight-bold text-primary">
<i class="fas fa-list me-2"></i>All Invoices
<i class="fas fa-list me-2"></i>{{ _('All Invoices') }}
</h6>
<div class="d-flex gap-2">
<div class="input-group input-group-sm" style="width: 250px;">
@@ -104,7 +104,7 @@
<i class="fas fa-search text-muted"></i>
</span>
<input type="text" class="form-control border-start-0" id="searchInput"
placeholder="Search invoices...">
placeholder="{{ _('Search invoices...') }}">
</div>
</div>
</div>
@@ -115,14 +115,14 @@
<table class="table table-hover mb-0" id="invoicesTable">
<thead class="table-light">
<tr>
<th class="border-0">Invoice #</th>
<th class="border-0">Client</th>
<th class="border-0">Project</th>
<th class="border-0">Issue Date</th>
<th class="border-0">Due Date</th>
<th class="border-0">Amount</th>
<th class="border-0">Status</th>
<th class="border-0 text-center">Actions</th>
<th class="border-0">{{ _('Invoice #') }}</th>
<th class="border-0">{{ _('Client') }}</th>
<th class="border-0">{{ _('Project') }}</th>
<th class="border-0">{{ _('Issue Date') }}</th>
<th class="border-0">{{ _('Due Date') }}</th>
<th class="border-0">{{ _('Amount') }}</th>
<th class="border-0">{{ _('Status') }}</th>
<th class="border-0 text-center">{{ _('Actions') }}</th>
</tr>
</thead>
<tbody>
@@ -160,7 +160,7 @@
</div>
<small class="text-danger">
<i class="fas fa-exclamation-circle me-1"></i>
{{ invoice.days_overdue }} days overdue
{{ invoice.days_overdue }} {{ _('days overdue') }}
</small>
</div>
{% else %}
@@ -177,53 +177,53 @@
</td>
<td class="align-middle">
{% set status_config = {
'draft': {'color': 'secondary', 'icon': 'edit', 'bg': 'bg-secondary'},
'sent': {'color': 'info', 'icon': 'paper-plane', 'bg': 'bg-info'},
'paid': {'color': 'success', 'icon': 'check-circle', 'bg': 'bg-success'},
'overdue': {'color': 'danger', 'icon': 'exclamation-triangle', 'bg': 'bg-danger'},
'cancelled': {'color': 'dark', 'icon': 'times-circle', 'bg': 'bg-dark'}
'draft': {'color': 'secondary', 'icon': 'edit', 'bg': 'bg-secondary', 'label': _('Draft')},
'sent': {'color': 'info', 'icon': 'paper-plane', 'bg': 'bg-info', 'label': _('Sent')},
'paid': {'color': 'success', 'icon': 'check-circle', 'bg': 'bg-success', 'label': _('Paid')},
'overdue': {'color': 'danger', 'icon': 'exclamation-triangle', 'bg': 'bg-danger', 'label': _('Overdue')},
'cancelled': {'color': 'dark', 'icon': 'times-circle', 'bg': 'bg-dark', 'label': _('Cancelled')}
} %}
{% set config = status_config.get(invoice.status, status_config.draft) %}
<span class="status-badge {{ config.bg }} text-white">
<i class="fas fa-{{ config.icon }} me-1"></i>
{{ invoice.status|title }}
{{ config.label }}
</span>
</td>
<td class="align-middle text-center">
<div class="btn-group btn-group-sm" role="group">
<a href="{{ url_for('invoices.view_invoice', invoice_id=invoice.id) }}"
class="btn btn-sm btn-action btn-action--view"
title="View Invoice">
title="{{ _('View Invoice') }}">
<i class="fas fa-eye"></i>
</a>
<a href="{{ url_for('invoices.edit_invoice', invoice_id=invoice.id) }}"
class="btn btn-sm btn-action btn-action--edit"
title="Edit Invoice">
title="{{ _('Edit Invoice') }}">
<i class="fas fa-edit"></i>
</a>
<button type="button"
class="btn btn-sm btn-action btn-action--more dropdown-toggle dropdown-toggle-split"
data-bs-toggle="dropdown"
aria-expanded="false">
<span class="visually-hidden">More actions</span>
<span class="visually-hidden">{{ _('More actions') }}</span>
</button>
<ul class="dropdown-menu dropdown-menu-end" data-bs-auto-close="true">
<li>
<a class="dropdown-item"
href="{{ url_for('invoices.export_invoice_csv', invoice_id=invoice.id) }}">
<i class="fas fa-download me-2"></i> Export CSV
<i class="fas fa-download me-2"></i> {{ _('Export CSV') }}
</a>
</li>
<li>
<a class="dropdown-item"
href="{{ url_for('invoices.duplicate_invoice', invoice_id=invoice.id) }}">
<i class="fas fa-copy me-2"></i> Duplicate
<i class="fas fa-copy me-2"></i> {{ _('Duplicate') }}
</a>
</li>
<li>
<a class="dropdown-item"
href="{{ url_for('invoices.generate_from_time', invoice_id=invoice.id) }}">
<i class="fas fa-clock me-2"></i> Generate from Time
<i class="fas fa-clock me-2"></i> {{ _('Generate from Time') }}
</a>
</li>
<li><hr class="dropdown-divider"></li>
@@ -231,9 +231,9 @@
<form method="POST"
action="{{ url_for('invoices.delete_invoice', invoice_id=invoice.id) }}"
class="d-inline"
onsubmit="return confirm('Are you sure you want to delete this invoice? This action cannot be undone.')">
onsubmit="return confirm('{{ _('Are you sure you want to delete this invoice? This action cannot be undone.') }}')">
<button type="submit" class="dropdown-item text-danger">
<i class="fas fa-trash me-2"></i> Delete
<i class="fas fa-trash me-2"></i> {{ _('Delete') }}
</button>
</form>
</li>
@@ -249,10 +249,10 @@
<div class="text-center py-5">
<div class="empty-state">
<i class="fas fa-file-invoice fa-3x text-muted mb-3"></i>
<h5 class="text-muted">No invoices found</h5>
<p class="text-muted mb-4">Create your first invoice to get started with billing.</p>
<h5 class="text-muted">{{ _('No invoices found') }}</h5>
<p class="text-muted mb-4">{{ _('Create your first invoice to get started with billing.') }}</p>
<a href="{{ url_for('invoices.create_invoice') }}" class="btn btn-primary btn-lg">
<i class="fas fa-plus me-2"></i> Create Your First Invoice
<i class="fas fa-plus me-2"></i> {{ _('Create Your First Invoice') }}
</a>
</div>
</div>
@@ -413,9 +413,9 @@ $(document).ready(function() {
order: [[3, 'desc']], // Sort by issue date descending
pageLength: 25,
language: {
search: "Search invoices:",
lengthMenu: "Show _MENU_ invoices per page",
info: "Showing _START_ to _END_ of _TOTAL_ invoices"
search: "{{ _('Search invoices:') }}",
lengthMenu: "{{ _('Show _MENU_ invoices per page') }}",
info: "{{ _('Showing _START_ to _END_ of _TOTAL_ invoices') }}"
},
responsive: true,
dom: '<"row"<"col-sm-12 col-md-6"l><"col-sm-12 col-md-6"f>>' +
@@ -459,7 +459,7 @@ function filterByStatus(status) {
// Enhanced confirmation for delete
function confirmDelete(invoiceNumber) {
return confirm(`Are you sure you want to delete invoice ${invoiceNumber}? This action cannot be undone.`);
return confirm(`{{ _('Are you sure you want to delete invoice') }} ${invoiceNumber}? {{ _('This action cannot be undone.') }}`);
}
// Quick status update (if implemented in backend)

View File

@@ -1,13 +1,13 @@
{% extends "base.html" %}
{% block title %}About - {{ app_name }}{% endblock %}
{% block title %}{{ _('About') }} - {{ app_name }}{% endblock %}
{% block content %}
{% from "_components.html" import page_header %}
<div class="container-fluid">
<div class="row">
<div class="col-12">
{{ page_header('fas fa-info-circle', 'About', 'Learn more about ' ~ app_name, None) }}
{{ page_header('fas fa-info-circle', _('About'), _('Learn more about ') ~ app_name, None) }}
</div>
</div>
<div class="row justify-content-center">
@@ -18,25 +18,25 @@
{% else %}
<img src="{{ url_for('static', filename='images/drytrix-logo.svg') }}" alt="DryTrix Logo" class="mb-4" width="80" height="80">
{% endif %}
<h1 class="h2 mb-3">About TimeTracker</h1>
<h1 class="h2 mb-3">{{ _('About TimeTracker') }}</h1>
<p class="lead text-muted">
A simple, efficient time tracking solution for teams and individuals.
{{ _('A simple, efficient time tracking solution for teams and individuals.') }}
</p>
<p class="text-muted">
<strong>Developed by DryTrix</strong>
<strong>{{ _('Developed by DryTrix') }}</strong>
</p>
</div>
<div class="card mb-4">
<div class="card-header">
<h5 class="mb-0">
<i class="fas fa-info-circle"></i> What is {{ app_name }}?
<i class="fas fa-info-circle"></i> {{ _('What is') }} {{ app_name }}?
</h5>
</div>
<div class="card-body">
<p>
{{ app_name }} is a web-based time tracking application designed for internal use within organizations.
It provides a simple and intuitive interface for tracking time spent on various projects and tasks.
{{ _('%(app)s is a web-based time tracking application designed for internal use within organizations.', app=app_name) }}
{{ _('It provides a simple and intuitive interface for tracking time spent on various projects and tasks.') }}
</p>
<p>
Built with modern web technologies, it offers real-time timer functionality, comprehensive reporting,

View File

@@ -1,6 +1,6 @@
{% extends "base.html" %}
{% block title %}Client - {{ client_name }} - {{ app_name }}{% endblock %}
{% block title %}{{ _('Client') }} - {{ client_name }} - {{ app_name }}{% endblock %}
{% block content %}
<div class="container-fluid">
@@ -11,7 +11,7 @@
<i class="fas fa-briefcase text-primary"></i> {{ client_name }}
</h1>
<a href="{{ url_for('projects.list_clients') }}" class="btn btn-outline-secondary">
<i class="fas fa-arrow-left"></i> Back to Clients
<i class="fas fa-arrow-left"></i> {{ _('Back to Clients') }}
</a>
</div>
</div>
@@ -21,7 +21,7 @@
<div class="col-12">
<div class="card">
<div class="card-header">
<h5 class="mb-0"><i class="fas fa-list me-1"></i> Projects ({{ projects|length }})</h5>
<h5 class="mb-0"><i class="fas fa-list me-1"></i> {{ _('Projects') }} ({{ projects|length }})</h5>
</div>
<div class="card-body">
{% if projects %}
@@ -29,11 +29,11 @@
<table class="table table-hover">
<thead>
<tr>
<th>Project</th>
<th>Status</th>
<th>Total Hours</th>
<th>Billable Hours</th>
<th>Actions</th>
<th>{{ _('Project') }}</th>
<th>{{ _('Status') }}</th>
<th>{{ _('Total Hours') }}</th>
<th>{{ _('Billable Hours') }}</th>
<th>{{ _('Actions') }}</th>
</tr>
</thead>
<tbody>
@@ -49,9 +49,9 @@
</td>
<td>
{% if project.status == 'active' %}
<span class="badge bg-success">Active</span>
<span class="badge bg-success">{{ _('Active') }}</span>
{% else %}
<span class="badge bg-secondary">Archived</span>
<span class="badge bg-secondary">{{ _('Archived') }}</span>
{% endif %}
</td>
<td><strong>{{ "%.1f"|format(project.total_hours) }}h</strong></td>
@@ -64,7 +64,7 @@
</td>
<td>
<a href="{{ url_for('projects.view_project', project_id=project.id) }}" class="btn btn-sm btn-outline-primary">
<i class="fas fa-eye"></i> View
<i class="fas fa-eye"></i> {{ _('View') }}
</a>
</td>
</tr>
@@ -75,7 +75,7 @@
{% else %}
<div class="text-center py-5">
<i class="fas fa-folder-open fa-3x text-muted mb-3"></i>
<h4 class="text-muted">No Projects for this Client</h4>
<h4 class="text-muted">{{ _('No Projects for this Client') }}</h4>
</div>
{% endif %}
</div>

View File

@@ -1,6 +1,6 @@
{% extends "base.html" %}
{% block title %}Clients - {{ app_name }}{% endblock %}
{% block title %}{{ _('Clients') }} - {{ app_name }}{% endblock %}
{% block content %}
<div class="container-fluid">
@@ -8,7 +8,7 @@
<div class="col-12">
<div class="d-flex justify-content-between align-items-center mb-4">
<h1 class="h3 mb-0">
<i class="fas fa-building text-primary"></i> Clients
<i class="fas fa-building text-primary"></i> {{ _('Clients') }}
</h1>
</div>
</div>
@@ -18,7 +18,7 @@
<div class="col-12">
<div class="card">
<div class="card-header">
<h5 class="mb-0"><i class="fas fa-list me-1"></i> Client List ({{ clients|length }})</h5>
<h5 class="mb-0"><i class="fas fa-list me-1"></i> {{ _('Client List') }} ({{ clients|length }})</h5>
</div>
<div class="card-body">
{% if clients %}
@@ -33,9 +33,9 @@
<p class="card-text text-muted small mt-2">{{ client.description[:50] }}{% if client.description|length > 50 %}...{% endif %}</p>
{% endif %}
<div class="mt-2">
<span class="badge bg-primary">{{ client.total_projects }} projects</span>
<span class="badge bg-primary">{{ client.total_projects }} {{ _('projects') }}</span>
{% if client.default_hourly_rate %}
<span class="badge bg-success ms-1">{{ "%.2f"|format(client.default_hourly_rate) }} {{ currency }}/hr</span>
<span class="badge bg-success ms-1">{{ "%.2f"|format(client.default_hourly_rate) }} {{ currency }}/{{ _('hr') }}</span>
{% endif %}
</div>
</div>
@@ -47,7 +47,7 @@
{% else %}
<div class="text-center py-5">
<i class="fas fa-briefcase fa-3x text-muted mb-3"></i>
<h4 class="text-muted">No Clients Found</h4>
<h4 class="text-muted">{{ _('No Clients Found') }}</h4>
</div>
{% endif %}
</div>

View File

@@ -1,6 +1,6 @@
{% extends "base.html" %}
{% block title %}Create Project - {{ app_name }}{% endblock %}
{% block title %}{{ _('Create Project') }} - {{ app_name }}{% endblock %}
{% block content %}
<div class="container mt-4">
@@ -14,8 +14,8 @@
<i class="fas fa-project-diagram text-primary fa-lg"></i>
</div>
<div>
<h1 class="h2 mb-1">Create New Project</h1>
<p class="text-muted mb-0">Set up a new project to organize your work and track time effectively</p>
<h1 class="h2 mb-1">{{ _('Create New Project') }}</h1>
<p class="text-muted mb-0">{{ _('Set up a new project to organize your work and track time effectively') }}</p>
</div>
</div>
</div>
@@ -29,7 +29,7 @@
<div class="card mobile-card">
<div class="card-header">
<h6 class="mb-0">
<i class="fas fa-edit me-2 text-primary"></i>Project Information
<i class="fas fa-edit me-2 text-primary"></i>{{ _('Project Information') }}
</h6>
</div>
<div class="card-body">
@@ -39,20 +39,20 @@
<div class="col-md-8">
<div class="mb-3">
<label for="name" class="form-label fw-semibold">
<i class="fas fa-tag me-2 text-primary"></i>Project Name <span class="text-danger">*</span>
<i class="fas fa-tag me-2 text-primary"></i>{{ _('Project Name') }} <span class="text-danger">*</span>
</label>
<input type="text" class="form-control form-control-lg" id="name" name="name" required
value="{{ request.form.get('name','') }}" placeholder="Enter a descriptive project name">
<small class="form-text text-muted">Choose a clear, descriptive name that explains the project scope</small>
value="{{ request.form.get('name','') }}" placeholder="{{ _('Enter a descriptive project name') }}">
<small class="form-text text-muted">{{ _('Choose a clear, descriptive name that explains the project scope') }}</small>
</div>
</div>
<div class="col-md-4">
<div class="mb-3">
<label for="client_id" class="form-label fw-semibold">
<i class="fas fa-user me-2 text-info"></i>Client <span class="text-danger">*</span>
<i class="fas fa-user me-2 text-info"></i>{{ _('Client') }} <span class="text-danger">*</span>
</label>
<select class="form-select form-select-lg" id="client_id" name="client_id" required>
<option value="">Select a client...</option>
<option value="">{{ _('Select a client...') }}</option>
{% for client in clients %}
<option value="{{ client.id }}"
{% if request.form.get('client_id') == client.id|string %}selected{% endif %}
@@ -63,7 +63,7 @@
</select>
<div class="form-text">
<a href="{{ url_for('clients.create_client') }}" class="text-decoration-none">
<i class="fas fa-plus"></i> Create new client
<i class="fas fa-plus"></i> {{ _('Create new client') }}
</a>
</div>
</div>
@@ -73,11 +73,11 @@
<!-- Description -->
<div class="mb-4">
<label for="description" class="form-label fw-semibold">
<i class="fas fa-align-left me-2 text-primary"></i>Description
<i class="fas fa-align-left me-2 text-primary"></i>{{ _('Description') }}
</label>
<textarea class="form-control" id="description" name="description" rows="4"
placeholder="Provide detailed information about the project, objectives, and deliverables...">{{ request.form.get('description','') }}</textarea>
<small class="form-text text-muted">Optional: Add context, objectives, or specific requirements for the project</small>
placeholder="{{ _('Provide detailed information about the project, objectives, and deliverables...') }}">{{ request.form.get('description','') }}</textarea>
<small class="form-text text-muted">{{ _('Optional: Add context, objectives, or specific requirements for the project') }}</small>
</div>
<!-- Billing Settings -->
@@ -87,20 +87,20 @@
<div class="form-check">
<input class="form-check-input" type="checkbox" id="billable" name="billable" {% if request.form.get('billable') %}checked{% endif %}>
<label class="form-check-label fw-semibold" for="billable">
<i class="fas fa-dollar-sign me-2 text-success"></i>Billable Project
<i class="fas fa-dollar-sign me-2 text-success"></i>{{ _('Billable Project') }}
</label>
<small class="form-text text-muted d-block">Enable billing for this project</small>
<small class="form-text text-muted d-block">{{ _('Enable billing for this project') }}</small>
</div>
</div>
</div>
<div class="col-md-6">
<div class="mb-3">
<label for="hourly_rate" class="form-label fw-semibold">
<i class="fas fa-clock me-2 text-warning"></i>Hourly Rate
<i class="fas fa-clock me-2 text-warning"></i>{{ _('Hourly Rate') }}
</label>
<input type="number" step="0.01" min="0" class="form-control" id="hourly_rate" name="hourly_rate"
value="{{ request.form.get('hourly_rate','') }}" placeholder="e.g. 75.00">
<small class="form-text text-muted">Leave empty for non-billable projects</small>
value="{{ request.form.get('hourly_rate','') }}" placeholder="{{ _('e.g. 75.00') }}">
<small class="form-text text-muted">{{ _('Leave empty for non-billable projects') }}</small>
</div>
</div>
</div>
@@ -108,20 +108,20 @@
<!-- Billing Reference -->
<div class="mb-4">
<label for="billing_ref" class="form-label fw-semibold">
<i class="fas fa-receipt me-2 text-secondary"></i>Billing Reference
<i class="fas fa-receipt me-2 text-secondary"></i>{{ _('Billing Reference') }}
</label>
<input type="text" class="form-control" id="billing_ref" name="billing_ref"
value="{{ request.form.get('billing_ref','') }}" placeholder="PO number, contract reference, etc.">
<small class="form-text text-muted">Optional: Add a reference number or identifier for billing purposes</small>
value="{{ request.form.get('billing_ref','') }}" placeholder="{{ _('PO number, contract reference, etc.') }}">
<small class="form-text text-muted">{{ _('Optional: Add a reference number or identifier for billing purposes') }}</small>
</div>
<!-- Form Actions -->
<div class="d-flex gap-3 pt-3 border-top">
<button type="submit" class="btn btn-primary btn-lg">
<i class="fas fa-save me-2"></i>Create Project
<i class="fas fa-save me-2"></i>{{ _('Create Project') }}
</button>
<a href="{{ url_for('projects.list_projects') }}" class="btn btn-outline-secondary btn-lg">
<i class="fas fa-times me-2"></i>Cancel
<i class="fas fa-times me-2"></i>{{ _('Cancel') }}
</a>
</div>
</form>
@@ -135,7 +135,7 @@
<div class="card mobile-card mb-4">
<div class="card-header">
<h6 class="mb-0">
<i class="fas fa-lightbulb me-2 text-warning"></i>Project Creation Tips
<i class="fas fa-lightbulb me-2 text-warning"></i>{{ _('Project Creation Tips') }}
</h6>
</div>
<div class="card-body">
@@ -145,8 +145,8 @@
<i class="fas fa-check text-primary fa-xs"></i>
</div>
<div>
<small class="fw-semibold d-block">Clear Naming</small>
<small class="text-muted">Use descriptive names that clearly indicate the project's purpose</small>
<small class="fw-semibold d-block">{{ _('Clear Naming') }}</small>
<small class="text-muted">{{ _("Use descriptive names that clearly indicate the project's purpose") }}</small>
</div>
</div>
</div>
@@ -157,8 +157,8 @@
<i class="fas fa-dollar-sign text-success fa-xs"></i>
</div>
<div>
<small class="fw-semibold d-block">Billing Setup</small>
<small class="text-muted">Set appropriate hourly rates based on project complexity and client budget</small>
<small class="fw-semibold d-block">{{ _('Billing Setup') }}</small>
<small class="text-muted">{{ _('Set appropriate hourly rates based on project complexity and client budget') }}</small>
</div>
</div>
</div>
@@ -169,8 +169,8 @@
<i class="fas fa-info-circle text-info fa-xs"></i>
</div>
<div>
<small class="fw-semibold d-block">Detailed Description</small>
<small class="text-muted">Include project objectives, deliverables, and key requirements</small>
<small class="fw-semibold d-block">{{ _('Detailed Description') }}</small>
<small class="text-muted">{{ _('Include project objectives, deliverables, and key requirements') }}</small>
</div>
</div>
</div>
@@ -181,8 +181,8 @@
<i class="fas fa-user text-warning fa-xs"></i>
</div>
<div>
<small class="fw-semibold d-block">Client Selection</small>
<small class="text-muted">Choose the right client to ensure proper project organization</small>
<small class="fw-semibold d-block">{{ _('Client Selection') }}</small>
<small class="text-muted">{{ _('Choose the right client to ensure proper project organization') }}</small>
</div>
</div>
</div>
@@ -193,28 +193,28 @@
<div class="card mobile-card mb-4">
<div class="card-header">
<h6 class="mb-0">
<i class="fas fa-info-circle me-2 text-info"></i>Billing Guide
<i class="fas fa-info-circle me-2 text-info"></i>{{ _('Billing Guide') }}
</h6>
</div>
<div class="card-body">
<div class="billing-guide-item mb-3">
<div class="d-flex align-items-center mb-2">
<span class="billing-badge billing-billable me-2">Billable</span>
<small class="text-muted">Track time and bill client</small>
<span class="billing-badge billing-billable me-2">{{ _('Billable') }}</span>
<small class="text-muted">{{ _('Track time and bill client') }}</small>
</div>
</div>
<div class="billing-guide-item mb-3">
<div class="d-flex align-items-center mb-2">
<span class="billing-badge billing-non-billable me-2">Non-Billable</span>
<small class="text-muted">Track time without billing</small>
<span class="billing-badge billing-non-billable me-2">{{ _('Non-Billable') }}</span>
<small class="text-muted">{{ _('Track time without billing') }}</small>
</div>
</div>
<div class="billing-guide-item">
<div class="d-flex align-items-center mb-2">
<span class="billing-badge billing-rate me-2">Rate Setting</span>
<small class="text-muted">Set appropriate hourly rates</small>
<span class="billing-badge billing-rate me-2">{{ _('Rate Setting') }}</span>
<small class="text-muted">{{ _('Set appropriate hourly rates') }}</small>
</div>
</div>
</div>
@@ -224,7 +224,7 @@
<div class="card mobile-card">
<div class="card-header">
<h6 class="mb-0">
<i class="fas fa-tasks me-2 text-secondary"></i>Project Management
<i class="fas fa-tasks me-2 text-secondary"></i>{{ _('Project Management') }}
</h6>
</div>
<div class="card-body">
@@ -234,8 +234,8 @@
<i class="fas fa-clock text-primary fa-xs"></i>
</div>
<div>
<small class="fw-semibold d-block">Time Tracking</small>
<small class="text-muted">Log time entries for accurate project tracking</small>
<small class="fw-semibold d-block">{{ _('Time Tracking') }}</small>
<small class="text-muted">{{ _('Log time entries for accurate project tracking') }}</small>
</div>
</div>
</div>
@@ -246,8 +246,8 @@
<i class="fas fa-list text-success fa-xs"></i>
</div>
<div>
<small class="fw-semibold d-block">Task Creation</small>
<small class="text-muted">Break down projects into manageable tasks</small>
<small class="fw-semibold d-block">{{ _('Task Creation') }}</small>
<small class="text-muted">{{ _('Break down projects into manageable tasks') }}</small>
</div>
</div>
</div>
@@ -258,8 +258,8 @@
<i class="fas fa-chart-bar text-info fa-xs"></i>
</div>
<div>
<small class="fw-semibold d-block">Progress Monitoring</small>
<small class="text-muted">Track project progress and time allocation</small>
<small class="fw-semibold d-block">{{ _('Progress Monitoring') }}</small>
<small class="text-muted">{{ _('Track project progress and time allocation') }}</small>
</div>
</div>
</div>

View File

@@ -1,6 +1,6 @@
{% extends "base.html" %}
{% block title %}Edit Project - {{ app_name }}{% endblock %}
{% block title %}{{ _('Edit Project') }} - {{ app_name }}{% endblock %}
{% block content %}
<div class="container-fluid">
@@ -10,18 +10,18 @@
<div>
<nav aria-label="breadcrumb">
<ol class="breadcrumb">
<li class="breadcrumb-item"><a href="{{ url_for('projects.list_projects') }}">Projects</a></li>
<li class="breadcrumb-item"><a href="{{ url_for('projects.list_projects') }}">{{ _('Projects') }}</a></li>
<li class="breadcrumb-item"><a href="{{ url_for('projects.view_project', project_id=project.id) }}">{{ project.name }}</a></li>
<li class="breadcrumb-item active">Edit</li>
<li class="breadcrumb-item active">{{ _('Edit') }}</li>
</ol>
</nav>
<h1 class="h3 mb-0">
<i class="fas fa-project-diagram text-primary"></i> Edit Project
<i class="fas fa-project-diagram text-primary"></i> {{ _('Edit Project') }}
</h1>
</div>
<div>
<a href="{{ url_for('projects.view_project', project_id=project.id) }}" class="btn btn-secondary">
<i class="fas fa-arrow-left"></i> Back to Project
<i class="fas fa-arrow-left"></i> {{ _('Back to Project') }}
</a>
</div>
</div>
@@ -33,7 +33,7 @@
<div class="card">
<div class="card-header">
<h5 class="mb-0">
<i class="fas fa-info-circle"></i> Project Information
<i class="fas fa-info-circle"></i> {{ _('Project Information') }}
</h5>
</div>
<div class="card-body">
@@ -41,15 +41,15 @@
<div class="row">
<div class="col-md-8">
<div class="mb-3">
<label for="name" class="form-label">Project Name *</label>
<label for="name" class="form-label">{{ _('Project Name') }} *</label>
<input type="text" class="form-control" id="name" name="name" required value="{{ request.form.get('name', project.name) }}">
</div>
</div>
<div class="col-md-4">
<div class="mb-3">
<label for="client_id" class="form-label">Client *</label>
<label for="client_id" class="form-label">{{ _('Client') }} *</label>
<select class="form-control" id="client_id" name="client_id" required>
<option value="">Select a client...</option>
<option value="">{{ _('Select a client...') }}</option>
{% for client in clients %}
<option value="{{ client.id }}"
{% if request.form.get('client_id', project.client_id) == client.id %}selected{% endif %}
@@ -60,7 +60,7 @@
</select>
<div class="form-text">
<a href="{{ url_for('clients.create_client') }}" class="text-decoration-none">
<i class="fas fa-plus"></i> Create new client
<i class="fas fa-plus"></i> {{ _('Create new client') }}
</a>
</div>
</div>
@@ -68,7 +68,7 @@
</div>
<div class="mb-3">
<label for="description" class="form-label">Description</label>
<label for="description" class="form-label">{{ _('Description') }}</label>
<textarea class="form-control" id="description" name="description" rows="3">{{ request.form.get('description', project.description or '') }}</textarea>
</div>
@@ -77,30 +77,30 @@
<div class="mb-3">
<div class="form-check">
<input class="form-check-input" type="checkbox" id="billable" name="billable" {% if (request.form and request.form.get('billable')) or (not request.form and project.billable) %}checked{% endif %}>
<label class="form-check-label" for="billable">Billable</label>
<label class="form-check-label" for="billable">{{ _('Billable') }}</label>
</div>
</div>
</div>
<div class="col-md-6">
<div class="mb-3">
<label for="hourly_rate" class="form-label">Hourly Rate</label>
<label for="hourly_rate" class="form-label">{{ _('Hourly Rate') }}</label>
<input type="number" step="0.01" min="0" class="form-control" id="hourly_rate" name="hourly_rate" value="{{ request.form.get('hourly_rate', project.hourly_rate or '') }}" placeholder="e.g. 75.00">
<div class="form-text">Leave empty for non-billable projects</div>
<div class="form-text">{{ _('Leave empty for non-billable projects') }}</div>
</div>
</div>
</div>
<div class="mb-3">
<label for="billing_ref" class="form-label">Billing Reference</label>
<label for="billing_ref" class="form-label">{{ _('Billing Reference') }}</label>
<input type="text" class="form-control" id="billing_ref" name="billing_ref" value="{{ request.form.get('billing_ref', project.billing_ref or '') }}" placeholder="Optional">
</div>
<div class="d-flex justify-content-between">
<a href="{{ url_for('projects.view_project', project_id=project.id) }}" class="btn btn-secondary">
<i class="fas fa-times"></i> Cancel
<i class="fas fa-times"></i> {{ _('Cancel') }}
</a>
<button type="submit" class="btn btn-primary">
<i class="fas fa-save"></i> Save Changes
<i class="fas fa-save"></i> {{ _('Save Changes') }}
</button>
</div>
</form>

View File

@@ -1,6 +1,6 @@
{% extends "base.html" %}
{% block title %}{% if project %}Edit{% else %}New{% endif %} Project - {{ app_name }}{% endblock %}
{% block title %}{% if project %}{{ _('Edit') }}{% else %}{{ _('New') }}{% endif %} {{ _('Project') }} - {{ app_name }}{% endblock %}
{% block content %}
<div class="container-fluid">
@@ -10,18 +10,18 @@
<div>
<nav aria-label="breadcrumb">
<ol class="breadcrumb">
<li class="breadcrumb-item"><a href="{{ url_for('projects.list_projects') }}">Projects</a></li>
<li class="breadcrumb-item active">{% if project %}Edit{% else %}New{% endif %}</li>
<li class="breadcrumb-item"><a href="{{ url_for('projects.list_projects') }}">{{ _('Projects') }}</a></li>
<li class="breadcrumb-item active">{% if project %}{{ _('Edit') }}{% else %}{{ _('New') }}{% endif %}</li>
</ol>
</nav>
<h1 class="h3 mb-0">
<i class="fas fa-project-diagram text-primary"></i>
{% if project %}Edit Project{% else %}New Project{% endif %}
{% if project %}{{ _('Edit Project') }}{% else %}{{ _('New Project') }}{% endif %}
</h1>
</div>
<div>
<a href="{{ url_for('projects.list_projects') }}" class="btn btn-secondary">
<i class="fas fa-arrow-left"></i> Back to Projects
<i class="fas fa-arrow-left"></i> {{ _('Back to Projects') }}
</a>
</div>
</div>
@@ -33,7 +33,7 @@
<div class="card">
<div class="card-header">
<h5 class="mb-0">
<i class="fas fa-edit"></i> Project Information
<i class="fas fa-edit"></i> {{ _('Project Information') }}
</h5>
</div>
<div class="card-body">
@@ -127,7 +127,7 @@
{% endfor %}
</div>
{% endif %}
<div class="form-text">Leave empty for non-billable projects</div>
<div class="form-text">{{ _('Leave empty for non-billable projects') }}</div>
</div>
</div>
<div class="col-md-6">
@@ -148,11 +148,11 @@
<div class="d-flex justify-content-between">
<a href="{{ url_for('projects.list_projects') }}" class="btn btn-secondary">
<i class="fas fa-times"></i> Cancel
<i class="fas fa-times"></i> {{ _('Cancel') }}
</a>
<button type="submit" class="btn btn-primary">
<i class="fas fa-save"></i>
{% if project %}Update Project{% else %}Create Project{% endif %}
{% if project %}{{ _('Update Project') }}{% else %}{{ _('Create Project') }}{% endif %}
</button>
</div>
</form>
@@ -164,30 +164,30 @@
<div class="card">
<div class="card-header">
<h5 class="mb-0">
<i class="fas fa-info-circle"></i> Help
<i class="fas fa-info-circle"></i> {{ _('Help') }}
</h5>
</div>
<div class="card-body">
<h6>Project Name</h6>
<p class="text-muted small">Choose a descriptive name that clearly identifies the project.</p>
<h6>{{ _('Project Name') }}</h6>
<p class="text-muted small">{{ _('Choose a descriptive name that clearly identifies the project.') }}</p>
<h6>Client</h6>
<p class="text-muted small">Optional client name for organization. You can group projects by client.</p>
<h6>{{ _('Client') }}</h6>
<p class="text-muted small">{{ _('Optional client name for organization. You can group projects by client.') }}</p>
<h6>Description</h6>
<p class="text-muted small">Provide details about the project scope, objectives, or any relevant information.</p>
<h6>{{ _('Description') }}</h6>
<p class="text-muted small">{{ _('Provide details about the project scope, objectives, or any relevant information.') }}</p>
<h6>Billable</h6>
<p class="text-muted small">Check this if time spent on this project should be tracked for billing purposes.</p>
<h6>{{ _('Billable') }}</h6>
<p class="text-muted small">{{ _('Check this if time spent on this project should be tracked for billing purposes.') }}</p>
<h6>Hourly Rate</h6>
<p class="text-muted small">Set the hourly rate for billable time. Leave empty for non-billable projects.</p>
<h6>{{ _('Hourly Rate') }}</h6>
<p class="text-muted small">{{ _('Set the hourly rate for billable time. Leave empty for non-billable projects.') }}</p>
<h6>Billing Reference</h6>
<p class="text-muted small">Optional reference number or code for billing systems.</p>
<h6>{{ _('Billing Reference') }}</h6>
<p class="text-muted small">{{ _('Optional reference number or code for billing systems.') }}</p>
<h6>Status</h6>
<p class="text-muted small">Active projects can have time tracked. Archived projects are hidden from timers but retain data.</p>
<h6>{{ _('Status') }}</h6>
<p class="text-muted small">{{ _('Active projects can have time tracked. Archived projects are hidden from timers but retain data.') }}</p>
</div>
</div>
@@ -195,23 +195,23 @@
<div class="card mt-3">
<div class="card-header">
<h5 class="mb-0">
<i class="fas fa-chart-bar"></i> Current Statistics
<i class="fas fa-chart-bar"></i> {{ _('Current Statistics') }}
</h5>
</div>
<div class="card-body">
<div class="row text-center">
<div class="col-6 mb-3">
<div class="h5 text-primary">{{ "%.1f"|format(project.total_hours) }}</div>
<small class="text-muted">Total Hours</small>
<small class="text-muted">{{ _('Total Hours') }}</small>
</div>
<div class="col-6 mb-3">
<div class="h5 text-success">{{ "%.1f"|format(project.total_billable_hours) }}</div>
<small class="text-muted">Billable Hours</small>
<small class="text-muted">{{ _('Billable Hours') }}</small>
</div>
{% if project.billable and project.hourly_rate %}
<div class="col-12">
<div class="h5 text-success">{{ currency }} {{ "%.2f"|format(project.estimated_cost) }}</div>
<small class="text-muted">Estimated Cost</small>
<small class="text-muted">{{ _('Estimated Cost') }}</small>
</div>
{% endif %}
</div>

View File

@@ -1,6 +1,6 @@
{% extends "base.html" %}
{% block title %}Projects - {{ app_name }}{% endblock %}
{% block title %}{{ _('Projects') }} - {{ app_name }}{% endblock %}
{% block content %}
<div class="container-fluid">
@@ -10,11 +10,11 @@
{% set actions %}
{% if current_user.is_admin %}
<a href="{{ url_for('projects.create_project') }}" class="btn-header btn-primary">
<i class="fas fa-plus"></i> New Project
<i class="fas fa-plus"></i> {{ _('New Project') }}
</a>
{% endif %}
{% endset %}
{{ page_header('fas fa-project-diagram', 'Projects', 'Manage all projects • ' ~ (projects|length) ~ ' total', actions) }}
{{ page_header('fas fa-project-diagram', _('Projects'), _('Manage all projects') ~ ' • ' ~ (projects|length) ~ ' ' ~ _('total'), actions) }}
</div>
</div>
@@ -33,7 +33,7 @@
<div class="card-body p-3 d-flex align-items-center">
<div class="summary-icon bg-primary bg-opacity-10 text-primary"><i class="fas fa-list"></i></div>
<div class="ms-3">
<div class="summary-label">Total Projects</div>
<div class="summary-label">{{ _('Total Projects') }}</div>
<div class="summary-value">{{ projects|length }}</div>
</div>
</div>
@@ -44,7 +44,7 @@
<div class="card-body p-3 d-flex align-items-center">
<div class="summary-icon bg-success bg-opacity-10 text-success"><i class="fas fa-check-circle"></i></div>
<div class="ms-3">
<div class="summary-label">Active</div>
<div class="summary-label">{{ _('Active') }}</div>
<div class="summary-value">{{ _active }}</div>
</div>
</div>
@@ -55,7 +55,7 @@
<div class="card-body p-3 d-flex align-items-center">
<div class="summary-icon bg-secondary bg-opacity-10 text-secondary"><i class="fas fa-archive"></i></div>
<div class="ms-3">
<div class="summary-label">Archived</div>
<div class="summary-label">{{ _('Archived') }}</div>
<div class="summary-value">{{ _archived }}</div>
</div>
</div>
@@ -66,7 +66,7 @@
<div class="card-body p-3 d-flex align-items-center">
<div class="summary-icon bg-info bg-opacity-10 text-info"><i class="fas fa-hourglass-half"></i></div>
<div class="ms-3">
<div class="summary-label">Total Hours</div>
<div class="summary-label">{{ _('Total Hours') }}</div>
<div class="summary-value">{{ '%.1f'|format(_total_hours) }}h</div>
</div>
</div>
@@ -80,39 +80,39 @@
<div class="card mobile-card">
<div class="card-header">
<h6 class="mb-0">
<i class="fas fa-filter me-2 text-primary"></i>Filters
<i class="fas fa-filter me-2 text-primary"></i>{{ _('Filters') }}
</h6>
</div>
<div class="card-body">
<form method="GET" class="row g-3 mobile-form">
<div class="col-12 col-md-4 mobile-form-group">
<label for="status" class="form-label">Status</label>
<label for="status" class="form-label">{{ _('Status') }}</label>
<select class="form-select touch-target" id="status" name="status">
<option value="">All Statuses</option>
<option value="active" {% if request.args.get('status') == 'active' %}selected{% endif %}>Active</option>
<option value="archived" {% if request.args.get('status') == 'archived' %}selected{% endif %}>Archived</option>
<option value="">{{ _('All Statuses') }}</option>
<option value="active" {% if request.args.get('status') == 'active' %}selected{% endif %}>{{ _('Active') }}</option>
<option value="archived" {% if request.args.get('status') == 'archived' %}selected{% endif %}>{{ _('Archived') }}</option>
</select>
</div>
<div class="col-12 col-md-4 mobile-form-group">
<label for="client" class="form-label">Client</label>
<label for="client" class="form-label">{{ _('Client') }}</label>
<select class="form-select touch-target" id="client" name="client">
<option value="">All Clients</option>
<option value="">{{ _('All Clients') }}</option>
{% for client in clients %}
<option value="{{ client }}" {% if request.args.get('client') == client %}selected{% endif %}>{{ client }}</option>
{% endfor %}
</select>
</div>
<div class="col-12 col-md-4 mobile-form-group">
<label for="search" class="form-label">Search</label>
<label for="search" class="form-label">{{ _('Search') }}</label>
<input type="text" class="form-control touch-target" id="search" name="search"
value="{{ request.args.get('search', '') }}" placeholder="Project name or description">
value="{{ request.args.get('search', '') }}" placeholder="{{ _('Project name or description') }}">
</div>
<div class="col-12 d-flex flex-column flex-md-row gap-2 mobile-stack">
<button type="submit" class="btn btn-primary mobile-btn flex-fill fw-semibold" style="height: 44px; display: flex; align-items: center; justify-content: center;">
<i class="fas fa-search me-1"></i>Filter
<i class="fas fa-search me-1"></i>{{ _('Filter') }}
</button>
<a href="{{ url_for('projects.list_projects') }}" class="btn btn-outline-secondary mobile-btn flex-fill fw-semibold" style="height: 44px; display: flex; align-items: center; justify-content: center;">
<i class="fas fa-times me-1"></i>Clear
<i class="fas fa-times me-1"></i>{{ _('Clear') }}
</a>
</div>
</form>
@@ -128,7 +128,7 @@
<div class="card-header bg-white py-3">
<div class="d-flex justify-content-between align-items-center">
<h6 class="m-0 font-weight-bold text-primary">
<i class="fas fa-list me-2"></i>All Projects
<i class="fas fa-list me-2"></i>{{ _('All Projects') }}
</h6>
<div class="d-flex gap-2">
<div class="input-group input-group-sm" style="width: 250px;">
@@ -136,7 +136,7 @@
<i class="fas fa-search text-muted"></i>
</span>
<input type="text" class="form-control border-start-0" id="searchInput"
placeholder="Search projects...">
placeholder="{{ _('Search projects...') }}">
</div>
</div>
</div>
@@ -147,12 +147,12 @@
<table class="table table-hover mb-0" id="projectsTable">
<thead class="table-light">
<tr>
<th class="border-0">Project</th>
<th class="border-0">Client</th>
<th class="border-0">Status</th>
<th class="border-0">Hours</th>
<th class="border-0">Rate</th>
<th class="border-0 text-center">Actions</th>
<th class="border-0">{{ _('Project') }}</th>
<th class="border-0">{{ _('Client') }}</th>
<th class="border-0">{{ _('Status') }}</th>
<th class="border-0">{{ _('Hours') }}</th>
<th class="border-0">{{ _('Rate') }}</th>
<th class="border-0 text-center">{{ _('Actions') }}</th>
</tr>
</thead>
<tbody>
@@ -173,9 +173,9 @@
</td>
<td data-label="Status">
{% if project.status == 'active' %}
<span class="status-badge bg-success text-white">Active</span>
<span class="status-badge bg-success text-white">{{ _('Active') }}</span>
{% else %}
<span class="status-badge bg-secondary text-white">Archived</span>
<span class="status-badge bg-secondary text-white">{{ _('Archived') }}</span>
{% endif %}
</td>
<td data-label="Hours" class="align-middle" style="min-width:180px;">
@@ -184,7 +184,7 @@
{% set pct = (billable_h / total_h * 100) if total_h > 0 else 0 %}
<div class="d-flex justify-content-between align-items-center mb-1">
<strong>{{ "%.1f"|format(total_h) }} h</strong>
<small class="text-success">{{ "%.1f"|format(billable_h) }} h billable</small>
<small class="text-success">{{ "%.1f"|format(billable_h) }} h {{ _('billable') }}</small>
</div>
<div class="progress bg-light rounded-pill" style="height:6px;">
<div class="progress-bar bg-success rounded-pill" role="progressbar" data-pct="{{ pct|round(0, 'floor') }}" aria-valuenow="{{ pct|round(0, 'floor') }}" aria-valuemin="0" aria-valuemax="100"></div>
@@ -200,15 +200,15 @@
<td class="actions-cell" data-label="Actions">
<div class="btn-group btn-group-sm" role="group">
<a href="{{ url_for('projects.view_project', project_id=project.id) }}"
class="btn btn-sm btn-action btn-action--view touch-target" title="View project">
class="btn btn-sm btn-action btn-action--view touch-target" title="{{ _('View project') }}">
<i class="fas fa-eye"></i>
</a>
{% if current_user.is_admin %}
<a href="{{ url_for('projects.edit_project', project_id=project.id) }}"
class="btn btn-sm btn-action btn-action--edit touch-target" title="Edit project">
class="btn btn-sm btn-action btn-action--edit touch-target" title="{{ _('Edit project') }}">
<i class="fas fa-edit"></i>
</a>
<button type="button" class="btn btn-sm btn-action btn-action--danger touch-target" title="Delete project"
<button type="button" class="btn btn-sm btn-action btn-action--danger touch-target" title="{{ _('Delete project') }}"
onclick="showDeleteProjectModal('{{ project.id }}', '{{ project.name }}')">
<i class="fas fa-trash"></i>
</button>
@@ -224,11 +224,11 @@
<div class="text-center py-5">
<div class="empty-state">
<i class="fas fa-project-diagram fa-3x text-muted mb-3"></i>
<h5 class="text-muted">No projects found</h5>
<p class="text-muted mb-4">Create your first project to get started.</p>
<h5 class="text-muted">{{ _('No projects found') }}</h5>
<p class="text-muted mb-4">{{ _('Create your first project to get started.') }}</p>
{% if current_user.is_admin %}
<a href="{{ url_for('projects.create_project') }}" class="btn btn-primary touch-target">
<i class="fas fa-plus me-2"></i> Create Project
<i class="fas fa-plus me-2"></i> {{ _('Create Project') }}
</a>
{% endif %}
</div>
@@ -246,25 +246,25 @@
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">
<i class="fas fa-trash me-2 text-danger"></i>Delete Project
<i class="fas fa-trash me-2 text-danger"></i>{{ _('Delete Project') }}
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<div class="alert alert-warning">
<i class="fas fa-exclamation-triangle me-2"></i>
<strong>Warning:</strong> This action cannot be undone.
<strong>{{ _('Warning:') }}</strong> {{ _('This action cannot be undone.') }}
</div>
<p>Are you sure you want to delete the project <strong id="deleteProjectName"></strong>?</p>
<p class="text-muted mb-0">All associated time entries will also be deleted.</p>
<p>{{ _('Are you sure you want to delete the project') }} <strong id="deleteProjectName"></strong>?</p>
<p class="text-muted mb-0">{{ _('All associated time entries will also be deleted.') }}</p>
</div>
<div class="modal-footer d-flex flex-column flex-md-row">
<button type="button" class="btn btn-secondary mb-2 mb-md-0 me-md-2 touch-target" data-bs-dismiss="modal">
<i class="fas fa-times me-1"></i>Cancel
<i class="fas fa-times me-1"></i>{{ _('Cancel') }}
</button>
<form method="POST" id="deleteProjectForm" class="d-inline">
<button type="submit" class="btn btn-danger touch-target">
<i class="fas fa-trash me-2"></i>Delete Project
<i class="fas fa-trash me-2"></i>{{ _('Delete Project') }}
</button>
</form>
</div>

View File

@@ -11,7 +11,7 @@
<div>
<nav aria-label="breadcrumb" class="mb-1">
<ol class="breadcrumb">
<li class="breadcrumb-item"><a href="{{ url_for('projects.list_projects') }}">Projects</a></li>
<li class="breadcrumb-item"><a href="{{ url_for('projects.list_projects') }}">{{ _('Projects') }}</a></li>
<li class="breadcrumb-item active">{{ project.name }}</li>
</ol>
</nav>
@@ -22,20 +22,20 @@
</div>
<div class="ms-3">
{% if project.status == 'active' %}
<span class="status-badge bg-success text-white"><i class="fas fa-check-circle me-2"></i>Active</span>
<span class="status-badge bg-success text-white"><i class="fas fa-check-circle me-2"></i>{{ _('Active') }}</span>
{% else %}
<span class="status-badge bg-secondary text-white"><i class="fas fa-archive me-2"></i>Archived</span>
<span class="status-badge bg-secondary text-white"><i class="fas fa-archive me-2"></i>{{ _('Archived') }}</span>
{% endif %}
</div>
</div>
<div class="btn-group" role="group">
{% if current_user.is_admin %}
<a href="{{ url_for('projects.edit_project', project_id=project.id) }}" class="btn btn-secondary">
<i class="fas fa-edit me-1"></i> Edit
<i class="fas fa-edit me-1"></i> {{ _('Edit') }}
</a>
{% endif %}
<a href="{{ url_for('main.dashboard') }}" class="btn btn-outline-secondary">
<i class="fas fa-arrow-left me-1"></i> Back
<i class="fas fa-arrow-left me-1"></i> {{ _('Back') }}
</a>
<!-- Start Timer removed on project page -->
</div>
@@ -52,44 +52,44 @@
<div class="col-md-6">
<div class="invoice-section">
<h6 class="section-title text-primary mb-3">
<i class="fas fa-info-circle me-2"></i>General
<i class="fas fa-info-circle me-2"></i>{{ _('General') }}
</h6>
<div class="detail-row"><span class="detail-label">Name</span><span class="detail-value">{{ project.name }}</span></div>
<div class="detail-row"><span class="detail-label">Client</span>
<div class="detail-row"><span class="detail-label">{{ _('Name') }}</span><span class="detail-value">{{ project.name }}</span></div>
<div class="detail-row"><span class="detail-label">{{ _('Client') }}</span>
<span class="detail-value">
{% if project.client_obj %}
<a href="{{ url_for('clients.view_client', client_id=project.client_id) }}">{{ project.client_obj.name }}</a>
{% else %}<span class="text-muted">-</span>{% endif %}
</span>
</div>
<div class="detail-row"><span class="detail-label">Status</span>
<span class="detail-value">{% if project.status == 'active' %}Active{% else %}Archived{% endif %}</span>
<div class="detail-row"><span class="detail-label">{{ _('Status') }}</span>
<span class="detail-value">{% if project.status == 'active' %}{{ _('Active') }}{% else %}{{ _('Archived') }}{% endif %}</span>
</div>
<div class="detail-row"><span class="detail-label">Created</span><span class="detail-value">{{ project.created_at.strftime('%B %d, %Y') }}</span></div>
<div class="detail-row"><span class="detail-label">{{ _('Created') }}</span><span class="detail-value">{{ project.created_at.strftime('%B %d, %Y') }}</span></div>
</div>
</div>
<div class="col-md-6">
<div class="invoice-section">
<h6 class="section-title text-primary mb-3">
<i class="fas fa-cog me-2"></i>Billing
<i class="fas fa-cog me-2"></i>{{ _('Billing') }}
</h6>
<div class="detail-row"><span class="detail-label">Billable</span>
<span class="detail-value">{% if project.billable %}Yes{% else %}No{% endif %}</span>
<div class="detail-row"><span class="detail-label">{{ _('Billable') }}</span>
<span class="detail-value">{% if project.billable %}{{ _('Yes') }}{% else %}{{ _('No') }}{% endif %}</span>
</div>
{% if project.billable and project.hourly_rate %}
<div class="detail-row"><span class="detail-label">Hourly Rate</span><span class="detail-value">{{ currency }} {{ "%.2f"|format(project.hourly_rate) }}</span></div>
<div class="detail-row"><span class="detail-label">{{ _('Hourly Rate') }}</span><span class="detail-value">{{ currency }} {{ "%.2f"|format(project.hourly_rate) }}</span></div>
{% endif %}
{% if project.billing_ref %}
<div class="detail-row"><span class="detail-label">Billing Ref</span><span class="detail-value">{{ project.billing_ref }}</span></div>
<div class="detail-row"><span class="detail-label">{{ _('Billing Ref') }}</span><span class="detail-value">{{ project.billing_ref }}</span></div>
{% endif %}
<div class="detail-row"><span class="detail-label">Last Updated</span><span class="detail-value">{{ project.updated_at.strftime('%B %d, %Y') }}</span></div>
<div class="detail-row"><span class="detail-label">{{ _('Last Updated') }}</span><span class="detail-value">{{ project.updated_at.strftime('%B %d, %Y') }}</span></div>
</div>
</div>
</div>
{% if project.description %}
<div class="mt-3">
<h6 class="section-title text-primary mb-2">Description</h6>
<h6 class="section-title text-primary mb-2">{{ _('Description') }}</h6>
<div class="content-box">{{ project.description }}</div>
</div>
{% endif %}
@@ -101,23 +101,23 @@
<div class="card shadow-sm border-0">
<div class="card-header bg-light py-3">
<h6 class="m-0 font-weight-bold text-primary">
<i class="fas fa-chart-bar me-2"></i>Statistics
<i class="fas fa-chart-bar me-2"></i>{{ _('Statistics') }}
</h6>
</div>
<div class="card-body">
<div class="row text-center">
<div class="col-6 mb-3">
<div class="h4 text-primary">{{ "%.1f"|format(project.total_hours) }}</div>
<small class="text-muted">Total Hours</small>
<small class="text-muted">{{ _('Total Hours') }}</small>
</div>
<div class="col-6 mb-3">
<div class="h4 text-success">{{ "%.1f"|format(project.total_billable_hours) }}</div>
<small class="text-muted">Billable Hours</small>
<small class="text-muted">{{ _('Billable Hours') }}</small>
</div>
{% if project.billable and project.hourly_rate %}
<div class="col-12">
<div class="h4 text-success">{{ currency }} {{ "%.2f"|format(project.estimated_cost) }}</div>
<small class="text-muted">Estimated Cost</small>
<small class="text-muted">{{ _('Estimated Cost') }}</small>
</div>
{% endif %}
</div>
@@ -128,7 +128,7 @@
<div class="card mt-3 shadow-sm border-0">
<div class="card-header bg-light py-3">
<h6 class="m-0 font-weight-bold text-primary">
<i class="fas fa-users me-2"></i>User Breakdown
<i class="fas fa-users me-2"></i>{{ _('User Breakdown') }}
</h6>
</div>
<div class="card-body">
@@ -150,14 +150,14 @@
<div class="card">
<div class="card-header d-flex justify-content-between align-items-center">
<h5 class="mb-0">
<i class="fas fa-tasks"></i> Tasks
<i class="fas fa-tasks"></i> {{ _('Tasks') }}
</h5>
<div>
<a href="{{ url_for('tasks.create_task') }}?project_id={{ project.id }}" class="btn-header btn-primary">
<i class="fas fa-plus"></i> New Task
<i class="fas fa-plus"></i> {{ _('New Task') }}
</a>
<a href="{{ url_for('tasks.list_tasks', project_id=project.id) }}" class="btn btn-sm btn-outline-primary">
<i class="fas fa-list"></i> View All
<i class="fas fa-list"></i> {{ _('View All') }}
</a>
</div>
</div>
@@ -169,10 +169,10 @@
{% from "_components.html" import empty_state %}
{% set actions %}
<a href="{{ url_for('tasks.create_task') }}?project_id={{ project.id }}" class="btn btn-success btn-sm">
<i class="fas fa-plus"></i> Create First Task
<i class="fas fa-plus"></i> {{ _('Create First Task') }}
</a>
{% endset %}
{{ empty_state('fas fa-tasks', 'No Tasks Yet', 'Break down this project into manageable tasks to track progress.', actions) }}
{{ empty_state('fas fa-tasks', _('No Tasks Yet'), _('Break down this project into manageable tasks to track progress.'), actions) }}
</div>
{% endif %}
</div>
@@ -186,10 +186,10 @@
<div class="card shadow-sm border-0">
<div class="card-header bg-white py-3 d-flex justify-content-between align-items-center">
<h6 class="m-0 font-weight-bold text-primary">
<i class="fas fa-clock me-2"></i>Time Entries
<i class="fas fa-clock me-2"></i>{{ _('Time Entries') }}
</h6>
<a href="{{ url_for('reports.project_report', project_id=project.id) }}" class="btn btn-sm btn-outline-primary">
<i class="fas fa-chart-line"></i> View Report
<i class="fas fa-chart-line"></i> {{ _('View Report') }}
</a>
</div>
<div class="card-body">
@@ -198,14 +198,14 @@
<table class="table table-hover">
<thead>
<tr>
<th>User</th>
<th>Date</th>
<th>Time</th>
<th>Duration</th>
<th>Notes</th>
<th>Tags</th>
<th>Billable</th>
<th>Actions</th>
<th>{{ _('User') }}</th>
<th>{{ _('Date') }}</th>
<th>{{ _('Time') }}</th>
<th>{{ _('Duration') }}</th>
<th>{{ _('Notes') }}</th>
<th>{{ _('Tags') }}</th>
<th>{{ _('Billable') }}</th>
<th>{{ _('Actions') }}</th>
</tr>
</thead>
<tbody>
@@ -218,7 +218,7 @@
{% if entry.end_time %}
{{ entry.end_time.strftime('%H:%M') }}
{% else %}
<span class="text-warning">Running</span>
<span class="text-warning">{{ _('Running') }}</span>
{% endif %}
</td>
<td>
@@ -242,19 +242,19 @@
</td>
<td>
{% if entry.billable %}
<span class="badge bg-success">Yes</span>
<span class="badge bg-success">{{ _('Yes') }}</span>
{% else %}
<span class="badge bg-secondary">No</span>
<span class="badge bg-secondary">{{ _('No') }}</span>
{% endif %}
</td>
<td>
<div class="btn-group" role="group">
<a href="{{ url_for('timer.edit_timer', timer_id=entry.id) }}"
class="btn btn-sm btn-outline-primary" title="Edit">
class="btn btn-sm btn-outline-primary" title="{{ _('Edit') }}">
<i class="fas fa-edit"></i>
</a>
{% if current_user.is_admin or entry.user_id == current_user.id %}
<button type="button" class="btn btn-sm btn-outline-danger" title="Delete"
<button type="button" class="btn btn-sm btn-outline-danger" title="{{ _('Delete') }}"
onclick="showDeleteEntryModal('{{ entry.id }}', '{{ entry.project.name }}', '{{ entry.duration_formatted }}')">
<i class="fas fa-trash"></i>
</button>
@@ -273,7 +273,7 @@
<ul class="pagination justify-content-center">
{% if pagination.has_prev %}
<li class="page-item">
<a class="page-link" href="{{ url_for('projects.view_project', project_id=project.id, page=pagination.prev_num) }}">Previous</a>
<a class="page-link" href="{{ url_for('projects.view_project', project_id=project.id, page=pagination.prev_num) }}">{{ _('Previous') }}</a>
</li>
{% endif %}
@@ -290,14 +290,14 @@
{% endif %}
{% else %}
<li class="page-item disabled">
<span class="page-link">...</span>
<span class="page-link"></span>
</li>
{% endif %}
{% endfor %}
{% if pagination.has_next %}
<li class="page-item">
<a class="page-link" href="{{ url_for('projects.view_project', project_id=project.id, page=pagination.next_num) }}">Next</a>
<a class="page-link" href="{{ url_for('projects.view_project', project_id=project.id, page=pagination.next_num) }}">{{ _('Next') }}</a>
</li>
{% endif %}
</ul>
@@ -305,7 +305,7 @@
{% endif %}
{% else %}
{% from "_components.html" import empty_state %}
{{ empty_state('fas fa-clock', 'No Time Entries', 'No time has been tracked for this project yet.') }}
{{ empty_state('fas fa-clock', _('No Time Entries'), _('No time has been tracked for this project yet.')) }}
{% endif %}
</div>
</div>
@@ -319,25 +319,25 @@
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title">
<i class="fas fa-trash me-2 text-danger"></i>Delete Time Entry
<i class="fas fa-trash me-2 text-danger"></i>{{ _('Delete Time Entry') }}
</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal"></button>
</div>
<div class="modal-body">
<div class="alert alert-warning">
<i class="fas fa-exclamation-triangle me-2"></i>
<strong>Warning:</strong> This action cannot be undone.
<strong>{{ _('Warning:') }}</strong> {{ _('This action cannot be undone.') }}
</div>
<p>Are you sure you want to delete the time entry for <strong id="deleteEntryProjectName"></strong>?</p>
<p class="text-muted mb-0">Duration: <strong id="deleteEntryDuration"></strong></p>
<p>{{ _('Are you sure you want to delete the time entry for') }} <strong id="deleteEntryProjectName"></strong>?</p>
<p class="text-muted mb-0">{{ _('Duration:') }} <strong id="deleteEntryDuration"></strong></p>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">
<i class="fas fa-times me-1"></i>Cancel
<i class="fas fa-times me-1"></i>{{ _('Cancel') }}
</button>
<form method="POST" id="deleteEntryForm" class="d-inline">
<button type="submit" class="btn btn-danger">
<i class="fas fa-trash me-2"></i>Delete Entry
<i class="fas fa-trash me-2"></i>{{ _('Delete Entry') }}
</button>
</form>
</div>

View File

@@ -1,6 +1,6 @@
{% extends "base.html" %}
{% block title %}Edit Time Entry - {{ app_name }}{% endblock %}
{% block title %}{{ _('Edit Time Entry') }} - {{ app_name }}{% endblock %}
{% block extra_js %}
{% if current_user.is_admin %}
@@ -183,11 +183,11 @@ document.addEventListener('DOMContentLoaded', function() {
<div class="card-header d-flex align-items-center justify-content-between">
<div class="d-flex align-items-center">
<i class="fas fa-edit me-2 text-primary"></i>
<h5 class="mb-0">Edit Time Entry</h5>
<h5 class="mb-0">{{ _('Edit Time Entry') }}</h5>
</div>
{% if current_user.is_admin %}
<span class="badge bg-warning text-dark">
<i class="fas fa-shield-alt me-1"></i>Admin Mode
<i class="fas fa-shield-alt me-1"></i>{{ _('Admin Mode') }}
</span>
{% endif %}
</div>
@@ -197,13 +197,13 @@ document.addEventListener('DOMContentLoaded', function() {
<!-- Admin view with editable fields -->
<div class="alert alert-info mb-3">
<i class="fas fa-info-circle me-2"></i>
<strong>Admin Mode:</strong> You can edit all fields of this time entry, including project, task, start/end times, and source.
<strong>{{ _('Admin Mode:') }}</strong> {{ _('You can edit all fields of this time entry, including project, task, start/end times, and source.') }}
</div>
<form method="POST" action="{{ url_for('timer.edit_timer', timer_id=timer.id) }}">
<div class="row g-3 mb-3">
<div class="col-md-6">
<label for="project_id" class="form-label fw-semibold">
<i class="fas fa-project-diagram me-1"></i>Project
<i class="fas fa-project-diagram me-1"></i>{{ _('Project') }}
</label>
<select class="form-select" id="project_id" name="project_id" required>
{% for project in projects %}
@@ -212,11 +212,11 @@ document.addEventListener('DOMContentLoaded', function() {
</option>
{% endfor %}
</select>
<div class="form-text">Select the project this time entry belongs to</div>
<div class="form-text">{{ _('Select the project this time entry belongs to') }}</div>
</div>
<div class="col-md-6">
<label for="task_id" class="form-label fw-semibold">
<i class="fas fa-tasks me-1"></i>Task (Optional)
<i class="fas fa-tasks me-1"></i>{{ _('Task (Optional)') }}
</label>
<select class="form-select" id="task_id" name="task_id">
<option value="">No Task</option>
@@ -226,7 +226,7 @@ document.addEventListener('DOMContentLoaded', function() {
</option>
{% endfor %}
</select>
<div class="form-text">Select a specific task within the project</div>
<div class="form-text">{{ _('Select a specific task within the project') }}</div>
</div>
</div>
<!-- Confirm Changes Modal (Admin) -->
@@ -234,23 +234,23 @@ document.addEventListener('DOMContentLoaded', function() {
<div class="modal-dialog modal-dialog-centered">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title"><i class="fas fa-exclamation-triangle text-warning me-2"></i>Confirm Changes</h5>
<h5 class="modal-title"><i class="fas fa-exclamation-triangle text-warning me-2"></i>{{ _('Confirm Changes') }}</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
<p class="mb-3">You are about to apply the following changes:</p>
<p class="mb-3">{{ _('You are about to apply the following changes:') }}</p>
<div id="confirmChangesSummary"></div>
<div class="alert alert-warning mt-3 mb-0">
<i class="fas fa-info-circle me-1"></i>
These updates will modify this time entry permanently.
{{ _('These updates will modify this time entry permanently.') }}
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">
<i class="fas fa-times me-1"></i>Cancel
<i class="fas fa-times me-1"></i>{{ _('Cancel') }}
</button>
<button type="button" class="btn btn-primary" id="confirmChangesConfirmBtn">
<i class="fas fa-check me-1"></i>Confirm & Save
<i class="fas fa-check me-1"></i>{{ _('Confirm & Save') }}
</button>
</div>
</div>
@@ -260,63 +260,63 @@ document.addEventListener('DOMContentLoaded', function() {
<div class="row g-3 mb-3">
<div class="col-md-6">
<label for="start_date" class="form-label fw-semibold">
<i class="fas fa-clock me-1"></i>Start Date
<i class="fas fa-clock me-1"></i>{{ _('Start Date') }}
</label>
<input type="date" class="form-control" id="start_date" name="start_date"
value="{{ timer.start_time.strftime('%Y-%m-%d') }}" required>
<div class="form-text">When the work started</div>
<div class="form-text">{{ _('When the work started') }}</div>
</div>
<div class="col-md-6">
<label for="start_time" class="form-label fw-semibold">
<i class="fas fa-clock me-1"></i>Start Time
<i class="fas fa-clock me-1"></i>{{ _('Start Time') }}
</label>
<input type="time" class="form-control" id="start_time" name="start_time"
value="{{ timer.start_time.strftime('%H:%M') }}" required>
<div class="form-text">Time the work started</div>
<div class="form-text">{{ _('Time the work started') }}</div>
</div>
</div>
<div class="row g-3 mb-3">
<div class="col-md-6">
<label for="end_date" class="form-label fw-semibold">
<i class="fas fa-stop-circle me-1"></i>End Date
<i class="fas fa-stop-circle me-1"></i>{{ _('End Date') }}
</label>
<input type="date" class="form-control" id="end_date" name="end_date"
value="{{ timer.end_time.strftime('%Y-%m-%d') if timer.end_time else '' }}">
<div class="form-text">When the work ended (leave empty if still running)</div>
<div class="form-text">{{ _('When the work ended (leave empty if still running)') }}</div>
</div>
<div class="col-md-6">
<label for="end_time" class="form-label fw-semibold">
<i class="fas fa-stop-circle me-1"></i>End Time
<i class="fas fa-stop-circle me-1"></i>{{ _('End Time') }}
</label>
<input type="time" class="form-control" id="end_time" name="end_time"
value="{{ timer.end_time.strftime('%H:%M') if timer.end_time else '' }}">
<div class="form-text">Time the work ended</div>
<div class="form-text">{{ _('Time the work ended') }}</div>
</div>
</div>
<div class="row g-3 mb-3">
<div class="col-md-4">
<label for="source" class="form-label fw-semibold">
<i class="fas fa-tag me-1"></i>Source
<i class="fas fa-tag me-1"></i>{{ _('Source') }}
</label>
<select class="form-select" id="source" name="source">
<option value="manual" {% if timer.source == 'manual' %}selected{% endif %}>Manual</option>
<option value="auto" {% if timer.source == 'auto' %}selected{% endif %}>Automatic</option>
<option value="manual" {% if timer.source == 'manual' %}selected{% endif %}>{{ _('Manual') }}</option>
<option value="auto" {% if timer.source == 'auto' %}selected{% endif %}>{{ _('Automatic') }}</option>
</select>
<div class="form-text">How this entry was created</div>
<div class="form-text">{{ _('How this entry was created') }}</div>
</div>
<div class="col-md-4 d-flex align-items-center">
<div class="form-check form-switch mt-4">
<input class="form-check-input" type="checkbox" id="billable" name="billable" {% if timer.billable %}checked{% endif %}>
<label class="form-check-label fw-semibold" for="billable">
<i class="fas fa-dollar-sign me-1"></i>Billable
<i class="fas fa-dollar-sign me-1"></i>{{ _('Billable') }}
</label>
</div>
</div>
<div class="col-md-4">
<div class="form-control-plaintext">
<strong>Duration:</strong> <span id="adminEditDuration">{{ timer.duration_formatted }}</span>
<strong>{{ _('Duration:') }}</strong> <span id="adminEditDuration">{{ timer.duration_formatted }}</span>
</div>
</div>
</div>
@@ -325,31 +325,31 @@ document.addEventListener('DOMContentLoaded', function() {
<div class="row g-3">
<div class="col-md-6">
<div class="form-control-plaintext">
<strong>Project:</strong> {{ timer.project.name }}
<strong>{{ _('Project:') }}</strong> {{ timer.project.name }}
</div>
</div>
<div class="col-md-3">
<div class="form-control-plaintext">
<strong>Start:</strong> {{ timer.start_time.strftime('%Y-%m-%d %H:%M') }}
<strong>{{ _('Start:') }}</strong> {{ timer.start_time.strftime('%Y-%m-%d %H:%M') }}
</div>
</div>
<div class="col-md-3">
<div class="form-control-plaintext">
<strong>End:</strong>
<strong>{{ _('End:') }}</strong>
{% if timer.end_time %}
{{ timer.end_time.strftime('%Y-%m-%d %H:%M') }}
{% else %}
<span class="text-warning">Running</span>
<span class="text-warning">{{ _('Running') }}</span>
{% endif %}
</div>
</div>
</div>
<div class="mt-2">
<span class="badge bg-primary">Duration: {{ timer.duration_formatted }}</span>
<span class="badge bg-primary">{{ _('Duration:') }} {{ timer.duration_formatted }}</span>
{% if timer.source == 'manual' %}
<span class="badge bg-secondary">Manual</span>
<span class="badge bg-secondary">{{ _('Manual') }}</span>
{% else %}
<span class="badge bg-info">Automatic</span>
<span class="badge bg-info">{{ _('Automatic') }}</span>
{% endif %}
</div>
{% endif %}
@@ -359,19 +359,19 @@ document.addEventListener('DOMContentLoaded', function() {
<!-- Admin form fields -->
<div class="mb-4">
<label for="notes" class="form-label fw-semibold">
<i class="fas fa-sticky-note me-1"></i>Notes
<i class="fas fa-sticky-note me-1"></i>{{ _('Notes') }}
</label>
<textarea class="form-control" id="notes" name="notes" rows="3" placeholder="Describe what you worked on">{{ timer.notes or '' }}</textarea>
<textarea class="form-control" id="notes" name="notes" rows="3" placeholder="{{ _('Describe what you worked on') }}">{{ timer.notes or '' }}</textarea>
</div>
<div class="row">
<div class="col-md-8">
<div class="mb-4">
<label for="tags" class="form-label fw-semibold">
<i class="fas fa-tags me-1"></i>Tags
<i class="fas fa-tags me-1"></i>{{ _('Tags') }}
</label>
<input type="text" class="form-control" id="tags" name="tags" placeholder="tag1, tag2" value="{{ timer.tags or '' }}">
<div class="form-text">Separate tags with commas</div>
<input type="text" class="form-control" id="tags" name="tags" placeholder="{{ _('tag1, tag2') }}" value="{{ timer.tags or '' }}">
<div class="form-text">{{ _('Separate tags with commas') }}</div>
</div>
</div>
</div>
@@ -409,7 +409,7 @@ document.addEventListener('DOMContentLoaded', function() {
<div class="form-check form-switch mt-4">
<input class="form-check-input" type="checkbox" id="billable" name="billable" {% if timer.billable %}checked{% endif %}>
<label class="form-check-label fw-semibold" for="billable">
<i class="fas fa-dollar-sign me-1"></i>Billable
<i class="fas fa-dollar-sign me-1"></i>{{ _('Billable') }}
</label>
</div>
</div>

View File

@@ -1,6 +1,6 @@
{% extends "base.html" %}
{% block title %}Log Time - {{ app_name }}{% endblock %}
{% block title %}{{ _('Log Time') }} - {{ app_name }}{% endblock %}
{% block content %}
{% block extra_css %}
@@ -23,10 +23,10 @@
<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
<i class="fas fa-arrow-left"></i> {{ _('Back') }}
</a>
{% endset %}
{{ page_header('fas fa-clock', 'Log Time', 'Create a manual time entry', actions) }}
{{ page_header('fas fa-clock', _('Log Time'), _('Create a manual time entry'), actions) }}
</div>
</div>
@@ -35,11 +35,11 @@
<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
<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
<i class="fas fa-rotate-right me-1"></i> {{ _('Reset') }}
</a>
</div>
</div>
@@ -49,7 +49,7 @@
<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 *
<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>
@@ -58,19 +58,19 @@
<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 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)
<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 class="form-text">{{ _('Tasks load after selecting a project') }}</div>
</div>
</div>
</div>
@@ -79,14 +79,14 @@
<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="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>
<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>
<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>
@@ -96,14 +96,14 @@
<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="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>
<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>
<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>
@@ -114,29 +114,29 @@
<div class="my-3">
<label for="notes" class="form-label fw-semibold">
<i class="fas fa-sticky-note me-1"></i>Notes
<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>
<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
<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>
<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
<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>
<span class="ms-2 text-muted small">{{ _('Include in invoices') }}</span>
</div>
</div>
</div>
@@ -144,14 +144,14 @@
<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
<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
<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
<i class="fas fa-broom me-2"></i>{{ _('Clear') }}
</button>
</div>
</div>
@@ -164,29 +164,29 @@
<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
<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>
<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>
<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>
<strong>{{ _('Tag Entries') }}</strong>
<p class="small text-muted mb-0">{{ _('Add tags to filter entries in reports later.') }}</p>
</div>
</div>
</div>
@@ -300,18 +300,20 @@ document.addEventListener('DOMContentLoaded', function() {
const projectSelect = document.getElementById('project_id');
const taskSelect = document.getElementById('task_id');
const L = { noTask: "{{ _('No task') }}", failedLoad: "{{ _('Failed to load tasks') }}" };
async function loadTasksForProject(projectId) {
if (!projectId) {
taskSelect.innerHTML = '<option value="">No task</option>';
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('Failed to load tasks');
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="">No task</option>';
taskSelect.innerHTML = '<option value="">' + L.noTask + '</option>';
tasks.forEach(t => {
const opt = document.createElement('option');
opt.value = String(t.id);
@@ -329,7 +331,7 @@ document.addEventListener('DOMContentLoaded', function() {
taskSelect.disabled = false;
} catch (e) {
// On error, keep disabled
taskSelect.innerHTML = '<option value="">No task</option>';
taskSelect.innerHTML = '<option value="">' + L.noTask + '</option>';
taskSelect.disabled = true;
}
}

2
translations/.keep Normal file
View File

@@ -0,0 +1,2 @@

View File

@@ -0,0 +1,2 @@

View File

@@ -0,0 +1,85 @@
msgid ""
msgstr ""
"Project-Id-Version: TimeTracker\n"
"Language: de\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
# Navbar and common
msgid "Time Tracker"
msgstr "Zeiterfassung"
msgid "Dashboard"
msgstr "Übersicht"
msgid "Projects"
msgstr "Projekte"
msgid "Clients"
msgstr "Kunden"
msgid "Tasks"
msgstr "Aufgaben"
msgid "Log Time"
msgstr "Zeit erfassen"
msgid "Reports"
msgstr "Berichte"
msgid "Invoices"
msgstr "Rechnungen"
msgid "Analytics"
msgstr "Analysen"
msgid "Admin"
msgstr "Admin"
msgid "Profile"
msgstr "Profil"
msgid "Logout"
msgstr "Abmelden"
msgid "Language"
msgstr "Sprache"
msgid "Home"
msgstr "Start"
msgid "Log"
msgstr "Log"
msgid "About"
msgstr "Über"
msgid "Help"
msgstr "Hilfe"
msgid "Buy me a coffee"
msgstr "Spendier mir einen Kaffee"
# About page
msgid "About TimeTracker"
msgstr "Über TimeTracker"
msgid "Developed by DryTrix"
msgstr "Entwickelt von DryTrix"
msgid "What is"
msgstr "Was ist"
msgid "A simple, efficient time tracking solution for teams and individuals."
msgstr "Eine einfache, effiziente Zeiterfassungslösung für Teams und Einzelpersonen."
msgid "It provides a simple and intuitive interface for tracking time spent on various projects and tasks."
msgstr "Sie bietet eine einfache und intuitive Oberfläche zur Erfassung der auf Projekte und Aufgaben verwendeten Zeit."
msgid "%(app)s is a web-based time tracking application designed for internal use within organizations."
msgstr "%(app)s ist eine webbasierte Zeiterfassungsanwendung für die interne Nutzung in Organisationen."
msgid "Learn more about "
msgstr "Erfahre mehr über "

View File

@@ -0,0 +1,84 @@
msgid ""
msgstr ""
"Project-Id-Version: TimeTracker\n"
"Language: en\n"
"Content-Type: text/plain; charset=UTF-8\n"
# English defaults mirror msgid; keep for completeness
msgid "Time Tracker"
msgstr "Time Tracker"
msgid "Dashboard"
msgstr "Dashboard"
msgid "Projects"
msgstr "Projects"
msgid "Clients"
msgstr "Clients"
msgid "Tasks"
msgstr "Tasks"
msgid "Log Time"
msgstr "Log Time"
msgid "Reports"
msgstr "Reports"
msgid "Invoices"
msgstr "Invoices"
msgid "Analytics"
msgstr "Analytics"
msgid "Admin"
msgstr "Admin"
msgid "Profile"
msgstr "Profile"
msgid "Logout"
msgstr "Logout"
msgid "Language"
msgstr "Language"
msgid "Home"
msgstr "Home"
msgid "Log"
msgstr "Log"
msgid "About"
msgstr "About"
msgid "Help"
msgstr "Help"
msgid "Buy me a coffee"
msgstr "Buy me a coffee"
# About page
msgid "About TimeTracker"
msgstr "About TimeTracker"
msgid "Developed by DryTrix"
msgstr "Developed by DryTrix"
msgid "What is"
msgstr "What is"
msgid "A simple, efficient time tracking solution for teams and individuals."
msgstr "A simple, efficient time tracking solution for teams and individuals."
msgid "It provides a simple and intuitive interface for tracking time spent on various projects and tasks."
msgstr "It provides a simple and intuitive interface for tracking time spent on various projects and tasks."
msgid "%(app)s is a web-based time tracking application designed for internal use within organizations."
msgstr "%(app)s is a web-based time tracking application designed for internal use within organizations."
msgid "Learn more about "
msgstr "Learn more about "

View File

@@ -0,0 +1,2 @@

View File

@@ -0,0 +1,85 @@
msgid ""
msgstr ""
"Project-Id-Version: TimeTracker\n"
"Language: fi\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
# Navbar and common
msgid "Time Tracker"
msgstr "Ajanseuranta"
msgid "Dashboard"
msgstr "Koontinäyttö"
msgid "Projects"
msgstr "Projektit"
msgid "Clients"
msgstr "Asiakkaat"
msgid "Tasks"
msgstr "Tehtävät"
msgid "Log Time"
msgstr "Kirjaa aikaa"
msgid "Reports"
msgstr "Raportit"
msgid "Invoices"
msgstr "Laskut"
msgid "Analytics"
msgstr "Analytiikka"
msgid "Admin"
msgstr "Ylläpito"
msgid "Profile"
msgstr "Profiili"
msgid "Logout"
msgstr "Kirjaudu ulos"
msgid "Language"
msgstr "Kieli"
msgid "Home"
msgstr "Etusivu"
msgid "Log"
msgstr "Loki"
msgid "About"
msgstr "Tietoa"
msgid "Help"
msgstr "Ohje"
msgid "Buy me a coffee"
msgstr "Tarjoa kahvi"
# About page
msgid "About TimeTracker"
msgstr "Tietoa TimeTrackerista"
msgid "Developed by DryTrix"
msgstr "Kehittänyt DryTrix"
msgid "What is"
msgstr "Mikä on"
msgid "A simple, efficient time tracking solution for teams and individuals."
msgstr "Yksinkertainen ja tehokas ajanseurantaratkaisu tiimeille ja yksittäisille käyttäjille."
msgid "It provides a simple and intuitive interface for tracking time spent on various projects and tasks."
msgstr "Tarjoaa yksinkertaisen ja intuitiivisen käyttöliittymän ajan seuraamiseen eri projekteissa ja tehtävissä."
msgid "%(app)s is a web-based time tracking application designed for internal use within organizations."
msgstr "%(app)s on verkkopohjainen ajanseurantasovellus, joka on suunniteltu organisaatioiden sisäiseen käyttöön."
msgid "Learn more about "
msgstr "Lue lisää: "

View File

@@ -0,0 +1,2 @@

View File

@@ -0,0 +1,85 @@
msgid ""
msgstr ""
"Project-Id-Version: TimeTracker\n"
"Language: fr\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Plural-Forms: nplurals=2; plural=(n > 1);\n"
# Navbar and common
msgid "Time Tracker"
msgstr "Suivi du temps"
msgid "Dashboard"
msgstr "Tableau de bord"
msgid "Projects"
msgstr "Projets"
msgid "Clients"
msgstr "Clients"
msgid "Tasks"
msgstr "Tâches"
msgid "Log Time"
msgstr "Enregistrer le temps"
msgid "Reports"
msgstr "Rapports"
msgid "Invoices"
msgstr "Factures"
msgid "Analytics"
msgstr "Analytique"
msgid "Admin"
msgstr "Admin"
msgid "Profile"
msgstr "Profil"
msgid "Logout"
msgstr "Déconnexion"
msgid "Language"
msgstr "Langue"
msgid "Home"
msgstr "Accueil"
msgid "Log"
msgstr "Journal"
msgid "About"
msgstr "À propos"
msgid "Help"
msgstr "Aide"
msgid "Buy me a coffee"
msgstr "Offrez-moi un café"
# About page
msgid "About TimeTracker"
msgstr "À propos de TimeTracker"
msgid "Developed by DryTrix"
msgstr "Développé par DryTrix"
msgid "What is"
msgstr "Qu'est-ce que"
msgid "A simple, efficient time tracking solution for teams and individuals."
msgstr "Une solution de suivi du temps simple et efficace pour les équipes et les individus."
msgid "It provides a simple and intuitive interface for tracking time spent on various projects and tasks."
msgstr "Elle offre une interface simple et intuitive pour suivre le temps passé sur divers projets et tâches."
msgid "%(app)s is a web-based time tracking application designed for internal use within organizations."
msgstr "%(app)s est une application de suivi du temps basée sur le web, conçue pour une utilisation interne au sein des organisations."
msgid "Learn more about "
msgstr "En savoir plus sur "

View File

@@ -0,0 +1,2 @@

View File

@@ -0,0 +1,85 @@
msgid ""
msgstr ""
"Project-Id-Version: TimeTracker\n"
"Language: it\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
# Navbar and common
msgid "Time Tracker"
msgstr "Rilevazione tempi"
msgid "Dashboard"
msgstr "Cruscotto"
msgid "Projects"
msgstr "Progetti"
msgid "Clients"
msgstr "Clienti"
msgid "Tasks"
msgstr "Attività"
msgid "Log Time"
msgstr "Registra tempo"
msgid "Reports"
msgstr "Report"
msgid "Invoices"
msgstr "Fatture"
msgid "Analytics"
msgstr "Analisi"
msgid "Admin"
msgstr "Admin"
msgid "Profile"
msgstr "Profilo"
msgid "Logout"
msgstr "Esci"
msgid "Language"
msgstr "Lingua"
msgid "Home"
msgstr "Home"
msgid "Log"
msgstr "Registro"
msgid "About"
msgstr "Informazioni"
msgid "Help"
msgstr "Aiuto"
msgid "Buy me a coffee"
msgstr "Offrimi un caffè"
# About page
msgid "About TimeTracker"
msgstr "Informazioni su TimeTracker"
msgid "Developed by DryTrix"
msgstr "Sviluppato da DryTrix"
msgid "What is"
msgstr "Che cos'è"
msgid "A simple, efficient time tracking solution for teams and individuals."
msgstr "Una soluzione semplice ed efficiente per la rilevazione dei tempi per team e singoli."
msgid "It provides a simple and intuitive interface for tracking time spent on various projects and tasks."
msgstr "Offre un'interfaccia semplice e intuitiva per tracciare il tempo speso su vari progetti e attività."
msgid "%(app)s is a web-based time tracking application designed for internal use within organizations."
msgstr "%(app)s è un'applicazione web per la rilevazione dei tempi progettata per l'uso interno nelle organizzazioni."
msgid "Learn more about "
msgstr "Scopri di più su "

View File

@@ -0,0 +1,2 @@

View File

@@ -0,0 +1,85 @@
msgid ""
msgstr ""
"Project-Id-Version: TimeTracker\n"
"Language: nl\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
# Navbar and common
msgid "Time Tracker"
msgstr "Tijdregistratie"
msgid "Dashboard"
msgstr "Dashboard"
msgid "Projects"
msgstr "Projecten"
msgid "Clients"
msgstr "Klanten"
msgid "Tasks"
msgstr "Taken"
msgid "Log Time"
msgstr "Tijd loggen"
msgid "Reports"
msgstr "Rapporten"
msgid "Invoices"
msgstr "Facturen"
msgid "Analytics"
msgstr "Analyses"
msgid "Admin"
msgstr "Beheer"
msgid "Profile"
msgstr "Profiel"
msgid "Logout"
msgstr "Afmelden"
msgid "Language"
msgstr "Taal"
msgid "Home"
msgstr "Start"
msgid "Log"
msgstr "Log"
msgid "About"
msgstr "Over"
msgid "Help"
msgstr "Help"
msgid "Buy me a coffee"
msgstr "Trakteer me op een koffie"
# About page
msgid "About TimeTracker"
msgstr "Over TimeTracker"
msgid "Developed by DryTrix"
msgstr "Ontwikkeld door DryTrix"
msgid "What is"
msgstr "Wat is"
msgid "A simple, efficient time tracking solution for teams and individuals."
msgstr "Een eenvoudige, efficiënte tijdregistratie-oplossing voor teams en individuen."
msgid "It provides a simple and intuitive interface for tracking time spent on various projects and tasks."
msgstr "Het biedt een eenvoudige en intuïtieve interface om tijd bij te houden voor verschillende projecten en taken."
msgid "%(app)s is a web-based time tracking application designed for internal use within organizations."
msgstr "%(app)s is een webgebaseerde tijdregistratie-applicatie ontworpen voor intern gebruik binnen organisaties."
msgid "Learn more about "
msgstr "Meer informatie over "