mirror of
https://github.com/DRYTRIX/TimeTracker.git
synced 2026-01-18 18:29:53 -06:00
Merge pull request #50 from DRYTRIX/Feature-Translations
feat(i18n): add translations, locale switcher, and user language pref…
This commit is contained in:
@@ -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 && \
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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')
|
||||
|
||||
@@ -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':
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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; }
|
||||
}
|
||||
|
||||
|
||||
@@ -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 }}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
60
app/utils/i18n.py
Normal 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
|
||||
|
||||
|
||||
@@ -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
5
babel.cfg
Normal file
@@ -0,0 +1,5 @@
|
||||
[python: **.py]
|
||||
[jinja2: **/templates/**.html]
|
||||
extensions=jinja2.ext.autoescape,jinja2.ext.with_
|
||||
encoding = utf-8
|
||||
|
||||
@@ -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 "$@"
|
||||
}
|
||||
|
||||
43
migrations/versions/011_add_user_preferred_language.py
Normal file
43
migrations/versions/011_add_user_preferred_language.py
Normal 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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
32
scripts/extract_translations.py
Normal file
32
scripts/extract_translations.py
Normal 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()
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
2
translations/.keep
Normal file
@@ -0,0 +1,2 @@
|
||||
|
||||
|
||||
2
translations/de/LC_MESSAGES/.keep
Normal file
2
translations/de/LC_MESSAGES/.keep
Normal file
@@ -0,0 +1,2 @@
|
||||
|
||||
|
||||
85
translations/de/LC_MESSAGES/messages.po
Normal file
85
translations/de/LC_MESSAGES/messages.po
Normal 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 "
|
||||
|
||||
|
||||
84
translations/en/LC_MESSAGES/messages.po
Normal file
84
translations/en/LC_MESSAGES/messages.po
Normal 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 "
|
||||
|
||||
|
||||
2
translations/fi/LC_MESSAGES/.keep
Normal file
2
translations/fi/LC_MESSAGES/.keep
Normal file
@@ -0,0 +1,2 @@
|
||||
|
||||
|
||||
85
translations/fi/LC_MESSAGES/messages.po
Normal file
85
translations/fi/LC_MESSAGES/messages.po
Normal 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ää: "
|
||||
|
||||
|
||||
2
translations/fr/LC_MESSAGES/.keep
Normal file
2
translations/fr/LC_MESSAGES/.keep
Normal file
@@ -0,0 +1,2 @@
|
||||
|
||||
|
||||
85
translations/fr/LC_MESSAGES/messages.po
Normal file
85
translations/fr/LC_MESSAGES/messages.po
Normal 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 "
|
||||
|
||||
|
||||
2
translations/it/LC_MESSAGES/.keep
Normal file
2
translations/it/LC_MESSAGES/.keep
Normal file
@@ -0,0 +1,2 @@
|
||||
|
||||
|
||||
85
translations/it/LC_MESSAGES/messages.po
Normal file
85
translations/it/LC_MESSAGES/messages.po
Normal 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 "
|
||||
|
||||
|
||||
2
translations/nl/LC_MESSAGES/.keep
Normal file
2
translations/nl/LC_MESSAGES/.keep
Normal file
@@ -0,0 +1,2 @@
|
||||
|
||||
|
||||
85
translations/nl/LC_MESSAGES/messages.po
Normal file
85
translations/nl/LC_MESSAGES/messages.po
Normal 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 "
|
||||
|
||||
|
||||
Reference in New Issue
Block a user