Merge pull request #199 from DRYTRIX/RC

Rc
This commit is contained in:
Dries Peeters
2025-11-01 12:01:33 +01:00
committed by GitHub
142 changed files with 37181 additions and 1026 deletions
+59 -9
View File
@@ -276,28 +276,78 @@ jobs:
- name: Test Docker container startup
run: |
docker run -d --name test-container \
# Start container
CONTAINER_ID=$(docker run -d --name test-container \
-p 8080:8080 \
-e DATABASE_URL="sqlite:////app/test.db" \
-e SECRET_KEY="test-secret-key-for-ci-only-$(openssl rand -hex 32)" \
-e FLASK_ENV="development" \
timetracker-test:pr-${{ github.event.pull_request.number || 'dev' }}
timetracker-test:pr-${{ github.event.pull_request.number || 'dev' }})
# Wait for container to be ready
for i in {1..30}; do
echo "🐳 Started container: $CONTAINER_ID"
# Wait for container to be ready (increased timeout for migrations)
HEALTH_CHECK_PASSED=false
for i in {1..60}; do
# Check if container is still running
if ! docker ps -q --filter "name=test-container" | grep -q .; then
echo "❌ Container exited unexpectedly!"
echo ""
echo "📋 Container logs:"
docker logs test-container
echo ""
echo "🔍 Container status:"
docker ps -a --filter "name=test-container"
exit 1
fi
# Try health check
if curl -f http://localhost:8080/_health >/dev/null 2>&1; then
echo "✅ Container health check passed"
echo "✅ Container health check passed (attempt $i/60)"
HEALTH_CHECK_PASSED=true
break
fi
echo "⏳ Waiting for container... ($i/30)"
# Show progress
if [ $((i % 10)) -eq 0 ]; then
echo "⏳ Still waiting for container... ($i/60)"
echo "📊 Last 10 log lines:"
docker logs --tail 10 test-container
else
echo "⏳ Waiting for container... ($i/60)"
fi
sleep 2
done
# Show logs
# Show full logs for debugging
echo ""
echo "📋 Full container logs:"
docker logs test-container
echo ""
# Final health check
curl -f http://localhost:8080/_health || exit 1
# Check if health check passed
if [ "$HEALTH_CHECK_PASSED" = false ]; then
echo "❌ Health check never passed after 120 seconds"
echo ""
echo "🔍 Container inspect:"
docker inspect test-container
echo ""
echo "🔍 Container status:"
docker ps -a --filter "name=test-container"
exit 1
fi
# Final health check with detailed output
echo "🔍 Final health check:"
curl -v http://localhost:8080/_health || {
echo "❌ Final health check failed"
echo "📋 Latest logs:"
docker logs --tail 50 test-container
exit 1
}
echo "✅ Docker container test completed successfully"
# Cleanup
docker stop test-container
+16 -1
View File
@@ -770,6 +770,11 @@ def create_app(config=None):
from app.routes.expenses import expenses_bp
from app.routes.permissions import permissions_bp
from app.routes.calendar import calendar_bp
from app.routes.expense_categories import expense_categories_bp
from app.routes.mileage import mileage_bp
from app.routes.per_diem import per_diem_bp
from app.routes.budget_alerts import budget_alerts_bp
from app.routes.import_export import import_export_bp
app.register_blueprint(auth_bp)
app.register_blueprint(main_bp)
@@ -798,6 +803,11 @@ def create_app(config=None):
app.register_blueprint(expenses_bp)
app.register_blueprint(permissions_bp)
app.register_blueprint(calendar_bp)
app.register_blueprint(expense_categories_bp)
app.register_blueprint(mileage_bp)
app.register_blueprint(per_diem_bp)
app.register_blueprint(budget_alerts_bp)
app.register_blueprint(import_export_bp)
# Exempt API blueprints from CSRF protection (JSON API uses token authentication, not CSRF tokens)
# Only if CSRF is enabled
@@ -851,8 +861,13 @@ def create_app(config=None):
# Register context processors
from app.utils.context_processors import register_context_processors
register_context_processors(app)
# Register i18n template filters
from app.utils.i18n_helpers import register_i18n_filters
register_i18n_filters(app)
# (translations compiled and directories set before Babel init)
+5
View File
@@ -118,7 +118,12 @@ class Config:
'fr': 'Français',
'it': 'Italiano',
'fi': 'Suomi',
'es': 'Español',
'ar': 'العربية',
'he': 'עברית',
}
# RTL languages
RTL_LANGUAGES = {'ar', 'he'}
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')
+8
View File
@@ -11,6 +11,9 @@ from .payments import Payment, CreditNote, InvoiceReminderSchedule
from .reporting import SavedReportView, ReportEmailSchedule
from .client import Client
from .task_activity import TaskActivity
from .expense_category import ExpenseCategory
from .mileage import Mileage
from .per_diem import PerDiem, PerDiemRate
from .extra_good import ExtraGood
from .comment import Comment
from .focus_session import FocusSession
@@ -28,6 +31,8 @@ from .expense import Expense
from .permission import Permission, Role
from .api_token import ApiToken
from .calendar_event import CalendarEvent
from .budget_alert import BudgetAlert
from .import_export import DataImport, DataExport
__all__ = [
"User",
@@ -65,4 +70,7 @@ __all__ = [
"Role",
"ApiToken",
"CalendarEvent",
"BudgetAlert",
"DataImport",
"DataExport",
]
+150
View File
@@ -0,0 +1,150 @@
from datetime import datetime, timedelta
from app import db
class BudgetAlert(db.Model):
"""Budget alert model for tracking project budget warnings and notifications"""
__tablename__ = 'budget_alerts'
id = db.Column(db.Integer, primary_key=True)
project_id = db.Column(db.Integer, db.ForeignKey('projects.id'), nullable=False, index=True)
# Alert details
alert_type = db.Column(db.String(20), nullable=False) # 'warning_80', 'warning_100', 'over_budget'
alert_level = db.Column(db.String(20), nullable=False) # 'info', 'warning', 'critical'
budget_consumed_percent = db.Column(db.Numeric(5, 2), nullable=False) # Percentage of budget consumed
budget_amount = db.Column(db.Numeric(10, 2), nullable=False) # Budget at time of alert
consumed_amount = db.Column(db.Numeric(10, 2), nullable=False) # Amount consumed at time of alert
# Alert message and status
message = db.Column(db.Text, nullable=False)
is_acknowledged = db.Column(db.Boolean, default=False, nullable=False)
acknowledged_by = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=True, index=True)
acknowledged_at = db.Column(db.DateTime, nullable=True)
# Metadata
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False, index=True)
# Relationships
project = db.relationship('Project', backref=db.backref('budget_alerts', lazy='dynamic'))
def __init__(self, project_id, alert_type, alert_level, budget_consumed_percent,
budget_amount, consumed_amount, message):
self.project_id = project_id
self.alert_type = alert_type
self.alert_level = alert_level
self.budget_consumed_percent = budget_consumed_percent
self.budget_amount = budget_amount
self.consumed_amount = consumed_amount
self.message = message
def __repr__(self):
return f'<BudgetAlert {self.alert_type} for Project {self.project_id}>'
def acknowledge(self, user_id):
"""Mark this alert as acknowledged by a user"""
self.is_acknowledged = True
self.acknowledged_by = user_id
self.acknowledged_at = datetime.utcnow()
db.session.commit()
def to_dict(self):
"""Convert budget alert to dictionary for API responses"""
return {
'id': self.id,
'project_id': self.project_id,
'project_name': self.project.name if self.project else None,
'alert_type': self.alert_type,
'alert_level': self.alert_level,
'budget_consumed_percent': float(self.budget_consumed_percent),
'budget_amount': float(self.budget_amount),
'consumed_amount': float(self.consumed_amount),
'message': self.message,
'is_acknowledged': self.is_acknowledged,
'acknowledged_by': self.acknowledged_by,
'acknowledged_at': self.acknowledged_at.isoformat() if self.acknowledged_at else None,
'created_at': self.created_at.isoformat() if self.created_at else None,
}
@classmethod
def get_active_alerts(cls, project_id=None, acknowledged=False):
"""Get active alerts, optionally filtered by project"""
query = cls.query.filter_by(is_acknowledged=acknowledged)
if project_id:
query = query.filter_by(project_id=project_id)
return query.order_by(cls.created_at.desc()).all()
@classmethod
def create_alert(cls, project_id, alert_type, budget_consumed_percent,
budget_amount, consumed_amount):
"""Create a new budget alert"""
# Determine alert level based on type
alert_levels = {
'warning_80': 'warning',
'warning_100': 'critical',
'over_budget': 'critical'
}
alert_level = alert_levels.get(alert_type, 'info')
# Generate alert message
message = cls._generate_message(alert_type, budget_consumed_percent,
budget_amount, consumed_amount)
# Check if similar alert already exists (avoid duplicates)
recent_alert = cls.query.filter_by(
project_id=project_id,
alert_type=alert_type,
is_acknowledged=False
).filter(
cls.created_at >= datetime.utcnow() - timedelta(hours=24)
).first()
if recent_alert:
return recent_alert
# Create new alert
alert = cls(
project_id=project_id,
alert_type=alert_type,
alert_level=alert_level,
budget_consumed_percent=budget_consumed_percent,
budget_amount=budget_amount,
consumed_amount=consumed_amount,
message=message
)
db.session.add(alert)
db.session.commit()
return alert
@staticmethod
def _generate_message(alert_type, budget_consumed_percent, budget_amount, consumed_amount):
"""Generate alert message based on alert type"""
messages = {
'warning_80': f'Warning: Project has consumed {budget_consumed_percent:.1f}% of budget (${consumed_amount:.2f} of ${budget_amount:.2f})',
'warning_100': f'Alert: Project has reached 100% of budget (${consumed_amount:.2f} of ${budget_amount:.2f})',
'over_budget': f'Critical: Project is over budget by ${consumed_amount - budget_amount:.2f} ({budget_consumed_percent:.1f}% consumed)'
}
return messages.get(alert_type, 'Budget alert')
@classmethod
def get_alert_summary(cls, project_id=None):
"""Get summary statistics for budget alerts"""
query = cls.query
if project_id:
query = query.filter_by(project_id=project_id)
total_alerts = query.count()
unacknowledged_alerts = query.filter_by(is_acknowledged=False).count()
critical_alerts = query.filter_by(alert_level='critical', is_acknowledged=False).count()
return {
'total_alerts': total_alerts,
'unacknowledged_alerts': unacknowledged_alerts,
'critical_alerts': critical_alerts
}
+144
View File
@@ -0,0 +1,144 @@
from datetime import datetime
from decimal import Decimal
from app import db
from sqlalchemy import Index
class ExpenseCategory(db.Model):
"""Expense category model with budget tracking"""
__tablename__ = 'expense_categories'
id = db.Column(db.Integer, primary_key=True)
name = db.Column(db.String(100), nullable=False, unique=True, index=True)
description = db.Column(db.Text, nullable=True)
code = db.Column(db.String(20), nullable=True, unique=True, index=True) # Short code for quick reference
color = db.Column(db.String(7), nullable=True) # Hex color for UI (e.g., #FF5733)
icon = db.Column(db.String(50), nullable=True) # Icon name for UI
# Budget settings
monthly_budget = db.Column(db.Numeric(10, 2), nullable=True)
quarterly_budget = db.Column(db.Numeric(10, 2), nullable=True)
yearly_budget = db.Column(db.Numeric(10, 2), nullable=True)
budget_threshold_percent = db.Column(db.Integer, nullable=False, default=80) # Alert when exceeded
# Settings
requires_receipt = db.Column(db.Boolean, default=True, nullable=False)
requires_approval = db.Column(db.Boolean, default=True, nullable=False)
default_tax_rate = db.Column(db.Numeric(5, 2), nullable=True)
is_active = db.Column(db.Boolean, default=True, nullable=False)
# Metadata
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
def __init__(self, name, **kwargs):
self.name = name.strip()
self.description = kwargs.get('description', '').strip() if kwargs.get('description') else None
self.code = kwargs.get('code', '').strip() if kwargs.get('code') else None
self.color = kwargs.get('color')
self.icon = kwargs.get('icon')
self.monthly_budget = Decimal(str(kwargs.get('monthly_budget'))) if kwargs.get('monthly_budget') else None
self.quarterly_budget = Decimal(str(kwargs.get('quarterly_budget'))) if kwargs.get('quarterly_budget') else None
self.yearly_budget = Decimal(str(kwargs.get('yearly_budget'))) if kwargs.get('yearly_budget') else None
self.budget_threshold_percent = kwargs.get('budget_threshold_percent', 80)
self.requires_receipt = kwargs.get('requires_receipt', True)
self.requires_approval = kwargs.get('requires_approval', True)
self.default_tax_rate = Decimal(str(kwargs.get('default_tax_rate'))) if kwargs.get('default_tax_rate') else None
self.is_active = kwargs.get('is_active', True)
def __repr__(self):
return f'<ExpenseCategory {self.name}>'
def get_spent_amount(self, start_date, end_date):
"""Get total amount spent in this category for date range"""
from app.models.expense import Expense
query = db.session.query(
db.func.sum(Expense.amount + db.func.coalesce(Expense.tax_amount, 0))
).filter(
Expense.category == self.name,
Expense.status.in_(['approved', 'reimbursed']),
Expense.expense_date >= start_date,
Expense.expense_date <= end_date
)
total = query.scalar() or Decimal('0')
return float(total)
def get_budget_utilization(self, period='monthly'):
"""Get budget utilization percentage for the current period"""
from datetime import date
today = date.today()
if period == 'monthly':
start_date = date(today.year, today.month, 1)
budget = self.monthly_budget
elif period == 'quarterly':
quarter = (today.month - 1) // 3 + 1
start_month = (quarter - 1) * 3 + 1
start_date = date(today.year, start_month, 1)
budget = self.quarterly_budget
elif period == 'yearly':
start_date = date(today.year, 1, 1)
budget = self.yearly_budget
else:
return None
if not budget or budget == 0:
return None
spent = self.get_spent_amount(start_date, today)
utilization = (spent / float(budget)) * 100
return {
'spent': spent,
'budget': float(budget),
'utilization_percent': round(utilization, 2),
'remaining': float(budget) - spent,
'over_threshold': utilization >= self.budget_threshold_percent
}
def to_dict(self):
"""Convert category to dictionary for API responses"""
return {
'id': self.id,
'name': self.name,
'description': self.description,
'code': self.code,
'color': self.color,
'icon': self.icon,
'monthly_budget': float(self.monthly_budget) if self.monthly_budget else None,
'quarterly_budget': float(self.quarterly_budget) if self.quarterly_budget else None,
'yearly_budget': float(self.yearly_budget) if self.yearly_budget else None,
'budget_threshold_percent': self.budget_threshold_percent,
'requires_receipt': self.requires_receipt,
'requires_approval': self.requires_approval,
'default_tax_rate': float(self.default_tax_rate) if self.default_tax_rate else None,
'is_active': self.is_active,
'created_at': self.created_at.isoformat() if self.created_at else None,
'updated_at': self.updated_at.isoformat() if self.updated_at else None
}
@classmethod
def get_active_categories(cls):
"""Get all active categories"""
return cls.query.filter_by(is_active=True).order_by(cls.name).all()
@classmethod
def get_categories_over_budget(cls, period='monthly'):
"""Get categories that are over their budget threshold"""
categories = cls.get_active_categories()
over_budget = []
for category in categories:
utilization = category.get_budget_utilization(period)
if utilization and utilization['over_threshold']:
over_budget.append({
'category': category,
'utilization': utilization
})
return over_budget
+220
View File
@@ -0,0 +1,220 @@
"""
Import/Export tracking models for data import/export operations
"""
from datetime import datetime
from app import db
class DataImport(db.Model):
"""Model to track import operations"""
__tablename__ = 'data_imports'
id = db.Column(db.Integer, primary_key=True)
user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False, index=True)
import_type = db.Column(db.String(50), nullable=False) # 'csv', 'toggl', 'harvest', 'backup'
source_file = db.Column(db.String(500), nullable=True) # Original filename
status = db.Column(db.String(20), default='pending', nullable=False) # 'pending', 'processing', 'completed', 'failed', 'partial'
total_records = db.Column(db.Integer, default=0)
successful_records = db.Column(db.Integer, default=0)
failed_records = db.Column(db.Integer, default=0)
error_log = db.Column(db.Text, nullable=True) # JSON string of errors
import_summary = db.Column(db.Text, nullable=True) # JSON string with details
started_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
completed_at = db.Column(db.DateTime, nullable=True)
# Relationship
user = db.relationship('User', backref=db.backref('imports', lazy='dynamic'))
def __init__(self, user_id, import_type, source_file=None):
self.user_id = user_id
self.import_type = import_type
self.source_file = source_file
self.status = 'pending'
self.total_records = 0
self.successful_records = 0
self.failed_records = 0
def __repr__(self):
return f'<DataImport {self.id}: {self.import_type} by {self.user.username}>'
def start_processing(self):
"""Mark import as processing"""
self.status = 'processing'
db.session.commit()
def complete(self):
"""Mark import as completed"""
self.status = 'completed'
self.completed_at = datetime.utcnow()
db.session.commit()
def fail(self, error_message=None):
"""Mark import as failed"""
self.status = 'failed'
self.completed_at = datetime.utcnow()
if error_message:
import json
errors = []
if self.error_log:
try:
errors = json.loads(self.error_log)
except:
pass
errors.append({'error': error_message, 'timestamp': datetime.utcnow().isoformat()})
self.error_log = json.dumps(errors)
db.session.commit()
def partial_complete(self):
"""Mark import as partially completed (some records failed)"""
self.status = 'partial'
self.completed_at = datetime.utcnow()
db.session.commit()
def update_progress(self, total, successful, failed):
"""Update import progress"""
self.total_records = total
self.successful_records = successful
self.failed_records = failed
if failed > 0 and successful > 0:
self.status = 'partial'
elif failed > 0:
self.status = 'failed'
db.session.commit()
def add_error(self, error_message, record_data=None):
"""Add an error to the error log"""
import json
errors = []
if self.error_log:
try:
errors = json.loads(self.error_log)
except:
pass
error_entry = {
'error': error_message,
'timestamp': datetime.utcnow().isoformat()
}
if record_data:
error_entry['record'] = record_data
errors.append(error_entry)
self.error_log = json.dumps(errors)
db.session.commit()
def set_summary(self, summary_dict):
"""Set import summary"""
import json
self.import_summary = json.dumps(summary_dict)
db.session.commit()
def to_dict(self):
"""Convert to dictionary"""
import json
return {
'id': self.id,
'user_id': self.user_id,
'user': self.user.username if self.user else None,
'import_type': self.import_type,
'source_file': self.source_file,
'status': self.status,
'total_records': self.total_records,
'successful_records': self.successful_records,
'failed_records': self.failed_records,
'error_log': json.loads(self.error_log) if self.error_log else [],
'import_summary': json.loads(self.import_summary) if self.import_summary else {},
'started_at': self.started_at.isoformat() if self.started_at else None,
'completed_at': self.completed_at.isoformat() if self.completed_at else None,
}
class DataExport(db.Model):
"""Model to track export operations"""
__tablename__ = 'data_exports'
id = db.Column(db.Integer, primary_key=True)
user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False, index=True)
export_type = db.Column(db.String(50), nullable=False) # 'full', 'filtered', 'backup', 'gdpr'
export_format = db.Column(db.String(20), nullable=False) # 'json', 'csv', 'xlsx', 'zip'
file_path = db.Column(db.String(500), nullable=True) # Path to generated file
file_size = db.Column(db.Integer, nullable=True) # File size in bytes
status = db.Column(db.String(20), default='pending', nullable=False) # 'pending', 'processing', 'completed', 'failed'
filters = db.Column(db.Text, nullable=True) # JSON string with export filters
record_count = db.Column(db.Integer, default=0)
error_message = db.Column(db.Text, nullable=True)
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
completed_at = db.Column(db.DateTime, nullable=True)
expires_at = db.Column(db.DateTime, nullable=True) # When file should be deleted
# Relationship
user = db.relationship('User', backref=db.backref('exports', lazy='dynamic'))
def __init__(self, user_id, export_type, export_format='json', filters=None):
self.user_id = user_id
self.export_type = export_type
self.export_format = export_format
self.status = 'pending'
self.record_count = 0
if filters:
import json
self.filters = json.dumps(filters)
def __repr__(self):
return f'<DataExport {self.id}: {self.export_type} by {self.user.username}>'
def start_processing(self):
"""Mark export as processing"""
self.status = 'processing'
db.session.commit()
def complete(self, file_path, file_size, record_count):
"""Mark export as completed"""
self.status = 'completed'
self.file_path = file_path
self.file_size = file_size
self.record_count = record_count
self.completed_at = datetime.utcnow()
# Set expiration to 7 days from now
self.expires_at = datetime.utcnow() + timedelta(days=7)
db.session.commit()
def fail(self, error_message):
"""Mark export as failed"""
self.status = 'failed'
self.error_message = error_message
self.completed_at = datetime.utcnow()
db.session.commit()
def is_expired(self):
"""Check if export has expired"""
if not self.expires_at:
return False
return datetime.utcnow() > self.expires_at
def to_dict(self):
"""Convert to dictionary"""
import json
return {
'id': self.id,
'user_id': self.user_id,
'user': self.user.username if self.user else None,
'export_type': self.export_type,
'export_format': self.export_format,
'file_path': self.file_path,
'file_size': self.file_size,
'status': self.status,
'filters': json.loads(self.filters) if self.filters else {},
'record_count': self.record_count,
'error_message': self.error_message,
'created_at': self.created_at.isoformat() if self.created_at else None,
'completed_at': self.completed_at.isoformat() if self.completed_at else None,
'expires_at': self.expires_at.isoformat() if self.expires_at else None,
'is_expired': self.is_expired(),
}
# Fix missing import
from datetime import timedelta
+249
View File
@@ -0,0 +1,249 @@
from datetime import datetime
from decimal import Decimal
from app import db
from sqlalchemy import Index
class Mileage(db.Model):
"""Mileage tracking for business travel"""
__tablename__ = 'mileage'
id = db.Column(db.Integer, primary_key=True)
user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False, index=True)
project_id = db.Column(db.Integer, db.ForeignKey('projects.id'), nullable=True, index=True)
client_id = db.Column(db.Integer, db.ForeignKey('clients.id'), nullable=True, index=True)
expense_id = db.Column(db.Integer, db.ForeignKey('expenses.id'), nullable=True, index=True)
# Trip details
trip_date = db.Column(db.Date, nullable=False, index=True)
purpose = db.Column(db.String(200), nullable=False)
description = db.Column(db.Text, nullable=True)
# Location information
start_location = db.Column(db.String(200), nullable=False)
end_location = db.Column(db.String(200), nullable=False)
start_odometer = db.Column(db.Numeric(10, 2), nullable=True) # Optional odometer readings
end_odometer = db.Column(db.Numeric(10, 2), nullable=True)
# Distance and calculation
distance_km = db.Column(db.Numeric(10, 2), nullable=False)
distance_miles = db.Column(db.Numeric(10, 2), nullable=True) # Computed or manual
rate_per_km = db.Column(db.Numeric(10, 4), nullable=False) # Rate at time of entry
rate_per_mile = db.Column(db.Numeric(10, 4), nullable=True)
currency_code = db.Column(db.String(3), nullable=False, default='EUR')
# Vehicle information
vehicle_type = db.Column(db.String(50), nullable=True) # 'car', 'motorcycle', 'van', 'truck'
vehicle_description = db.Column(db.String(200), nullable=True) # e.g., "BMW 3 Series"
license_plate = db.Column(db.String(20), nullable=True)
# Calculated amount
calculated_amount = db.Column(db.Numeric(10, 2), nullable=False)
# Round trip
is_round_trip = db.Column(db.Boolean, default=False, nullable=False)
# Status and approval
status = db.Column(db.String(20), default='pending', nullable=False) # 'pending', 'approved', 'rejected', 'reimbursed'
approved_by = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=True, index=True)
approved_at = db.Column(db.DateTime, nullable=True)
rejection_reason = db.Column(db.Text, nullable=True)
# Reimbursement
reimbursed = db.Column(db.Boolean, default=False, nullable=False)
reimbursed_at = db.Column(db.DateTime, nullable=True)
# Notes
notes = db.Column(db.Text, nullable=True)
# Metadata
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
# Relationships
user = db.relationship('User', foreign_keys=[user_id], backref=db.backref('mileage_entries', lazy='dynamic'))
approver = db.relationship('User', foreign_keys=[approved_by], backref=db.backref('approved_mileage', lazy='dynamic'))
project = db.relationship('Project', backref=db.backref('mileage_entries', lazy='dynamic'))
client = db.relationship('Client', backref=db.backref('mileage_entries', lazy='dynamic'))
expense = db.relationship('Expense', backref=db.backref('mileage_entry', uselist=False))
# Indexes for common queries
__table_args__ = (
Index('ix_mileage_user_date', 'user_id', 'trip_date'),
Index('ix_mileage_status_date', 'status', 'trip_date'),
)
def __init__(self, user_id, trip_date, purpose, start_location, end_location,
distance_km, rate_per_km, **kwargs):
self.user_id = user_id
self.trip_date = trip_date
self.purpose = purpose.strip()
self.start_location = start_location.strip()
self.end_location = end_location.strip()
self.distance_km = Decimal(str(distance_km))
self.rate_per_km = Decimal(str(rate_per_km))
# Calculate amount
self.calculated_amount = self.distance_km * self.rate_per_km
# Optional fields
self.description = kwargs.get('description', '').strip() if kwargs.get('description') else None
self.project_id = kwargs.get('project_id')
self.client_id = kwargs.get('client_id')
self.expense_id = kwargs.get('expense_id')
self.start_odometer = Decimal(str(kwargs.get('start_odometer'))) if kwargs.get('start_odometer') else None
self.end_odometer = Decimal(str(kwargs.get('end_odometer'))) if kwargs.get('end_odometer') else None
self.distance_miles = Decimal(str(kwargs.get('distance_miles'))) if kwargs.get('distance_miles') else self.distance_km * Decimal('0.621371')
self.rate_per_mile = Decimal(str(kwargs.get('rate_per_mile'))) if kwargs.get('rate_per_mile') else None
self.currency_code = kwargs.get('currency_code', 'EUR')
self.vehicle_type = kwargs.get('vehicle_type')
self.vehicle_description = kwargs.get('vehicle_description')
self.license_plate = kwargs.get('license_plate')
self.is_round_trip = kwargs.get('is_round_trip', False)
self.notes = kwargs.get('notes', '').strip() if kwargs.get('notes') else None
self.status = kwargs.get('status', 'pending')
def __repr__(self):
return f'<Mileage {self.start_location} -> {self.end_location} ({self.distance_km} km)>'
@property
def total_distance_km(self):
"""Get total distance including round trip if applicable"""
multiplier = 2 if self.is_round_trip else 1
return float(self.distance_km) * multiplier
@property
def total_amount(self):
"""Get total amount including round trip if applicable"""
multiplier = 2 if self.is_round_trip else 1
return float(self.calculated_amount) * multiplier
def approve(self, approved_by_user_id, notes=None):
"""Approve the mileage entry"""
self.status = 'approved'
self.approved_by = approved_by_user_id
self.approved_at = datetime.utcnow()
if notes:
self.notes = (self.notes or '') + f'\n\nApproval notes: {notes}'
self.updated_at = datetime.utcnow()
def reject(self, rejected_by_user_id, reason):
"""Reject the mileage entry"""
self.status = 'rejected'
self.approved_by = rejected_by_user_id
self.approved_at = datetime.utcnow()
self.rejection_reason = reason
self.updated_at = datetime.utcnow()
def mark_as_reimbursed(self):
"""Mark this mileage entry as reimbursed"""
self.reimbursed = True
self.reimbursed_at = datetime.utcnow()
self.status = 'reimbursed'
self.updated_at = datetime.utcnow()
def create_expense(self):
"""Create an expense from this mileage entry"""
from app.models.expense import Expense
if self.expense_id:
return None # Already has an expense
expense = Expense(
user_id=self.user_id,
title=f"Mileage: {self.start_location} to {self.end_location}",
category='travel',
amount=self.total_amount,
expense_date=self.trip_date,
description=f"{self.purpose}\nDistance: {self.total_distance_km} km @ {float(self.rate_per_km)} {self.currency_code}/km",
project_id=self.project_id,
client_id=self.client_id,
currency_code=self.currency_code,
status=self.status
)
return expense
def to_dict(self):
"""Convert mileage entry to dictionary for API responses"""
return {
'id': self.id,
'user_id': self.user_id,
'project_id': self.project_id,
'client_id': self.client_id,
'expense_id': self.expense_id,
'trip_date': self.trip_date.isoformat() if self.trip_date else None,
'purpose': self.purpose,
'description': self.description,
'start_location': self.start_location,
'end_location': self.end_location,
'start_odometer': float(self.start_odometer) if self.start_odometer else None,
'end_odometer': float(self.end_odometer) if self.end_odometer else None,
'distance_km': float(self.distance_km),
'distance_miles': float(self.distance_miles) if self.distance_miles else None,
'rate_per_km': float(self.rate_per_km),
'rate_per_mile': float(self.rate_per_mile) if self.rate_per_mile else None,
'currency_code': self.currency_code,
'vehicle_type': self.vehicle_type,
'vehicle_description': self.vehicle_description,
'license_plate': self.license_plate,
'calculated_amount': float(self.calculated_amount),
'is_round_trip': self.is_round_trip,
'total_distance_km': self.total_distance_km,
'total_amount': self.total_amount,
'status': self.status,
'approved_by': self.approved_by,
'approved_at': self.approved_at.isoformat() if self.approved_at else None,
'rejection_reason': self.rejection_reason,
'reimbursed': self.reimbursed,
'reimbursed_at': self.reimbursed_at.isoformat() if self.reimbursed_at else None,
'notes': self.notes,
'created_at': self.created_at.isoformat() if self.created_at else None,
'updated_at': self.updated_at.isoformat() if self.updated_at else None,
'user': self.user.username if self.user else None,
'project': self.project.name if self.project else None,
'client': self.client.name if self.client else None,
'approver': self.approver.username if self.approver else None
}
@classmethod
def get_default_rates(cls):
"""Get default mileage rates for different vehicle types"""
# These are example rates and should be configurable in settings
return {
'car': {'km': 0.30, 'mile': 0.48, 'currency': 'EUR'},
'motorcycle': {'km': 0.20, 'mile': 0.32, 'currency': 'EUR'},
'van': {'km': 0.35, 'mile': 0.56, 'currency': 'EUR'},
'truck': {'km': 0.40, 'mile': 0.64, 'currency': 'EUR'}
}
@classmethod
def get_pending_approvals(cls, user_id=None):
"""Get mileage entries pending approval"""
query = cls.query.filter_by(status='pending')
if user_id:
query = query.filter(cls.user_id == user_id)
return query.order_by(cls.trip_date.desc()).all()
@classmethod
def get_total_distance(cls, user_id=None, start_date=None, end_date=None):
"""Calculate total distance traveled"""
query = db.session.query(db.func.sum(cls.distance_km))
if user_id:
query = query.filter(cls.user_id == user_id)
if start_date:
query = query.filter(cls.trip_date >= start_date)
if end_date:
query = query.filter(cls.trip_date <= end_date)
query = query.filter(cls.status.in_(['approved', 'reimbursed']))
total = query.scalar() or Decimal('0')
return float(total)
+418
View File
@@ -0,0 +1,418 @@
from datetime import datetime, timedelta
from decimal import Decimal
from app import db
from sqlalchemy import Index
class PerDiemRate(db.Model):
"""Per diem rate configuration for different locations"""
__tablename__ = 'per_diem_rates'
id = db.Column(db.Integer, primary_key=True)
country = db.Column(db.String(100), nullable=False, index=True)
city = db.Column(db.String(100), nullable=True, index=True)
# Rates
full_day_rate = db.Column(db.Numeric(10, 2), nullable=False)
half_day_rate = db.Column(db.Numeric(10, 2), nullable=False)
breakfast_rate = db.Column(db.Numeric(10, 2), nullable=True)
lunch_rate = db.Column(db.Numeric(10, 2), nullable=True)
dinner_rate = db.Column(db.Numeric(10, 2), nullable=True)
incidental_rate = db.Column(db.Numeric(10, 2), nullable=True) # Tips, etc.
currency_code = db.Column(db.String(3), nullable=False, default='EUR')
# Validity period
effective_from = db.Column(db.Date, nullable=False, index=True)
effective_to = db.Column(db.Date, nullable=True, index=True)
# Settings
is_active = db.Column(db.Boolean, default=True, nullable=False)
notes = db.Column(db.Text, nullable=True)
# Metadata
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
__table_args__ = (
Index('ix_per_diem_rates_country_city', 'country', 'city'),
Index('ix_per_diem_rates_effective', 'effective_from', 'effective_to'),
)
def __init__(self, country, full_day_rate, half_day_rate, effective_from, **kwargs):
self.country = country.strip()
self.city = kwargs.get('city', '').strip() if kwargs.get('city') else None
self.full_day_rate = Decimal(str(full_day_rate))
self.half_day_rate = Decimal(str(half_day_rate))
self.breakfast_rate = Decimal(str(kwargs.get('breakfast_rate'))) if kwargs.get('breakfast_rate') else None
self.lunch_rate = Decimal(str(kwargs.get('lunch_rate'))) if kwargs.get('lunch_rate') else None
self.dinner_rate = Decimal(str(kwargs.get('dinner_rate'))) if kwargs.get('dinner_rate') else None
self.incidental_rate = Decimal(str(kwargs.get('incidental_rate'))) if kwargs.get('incidental_rate') else None
self.currency_code = kwargs.get('currency_code', 'EUR')
self.effective_from = effective_from
self.effective_to = kwargs.get('effective_to')
self.is_active = kwargs.get('is_active', True)
self.notes = kwargs.get('notes', '').strip() if kwargs.get('notes') else None
def __repr__(self):
location = f"{self.city}, {self.country}" if self.city else self.country
return f'<PerDiemRate {location}: {self.full_day_rate} {self.currency_code}>'
def to_dict(self):
"""Convert rate to dictionary for API responses"""
return {
'id': self.id,
'country': self.country,
'city': self.city,
'full_day_rate': float(self.full_day_rate),
'half_day_rate': float(self.half_day_rate),
'breakfast_rate': float(self.breakfast_rate) if self.breakfast_rate else None,
'lunch_rate': float(self.lunch_rate) if self.lunch_rate else None,
'dinner_rate': float(self.dinner_rate) if self.dinner_rate else None,
'incidental_rate': float(self.incidental_rate) if self.incidental_rate else None,
'currency_code': self.currency_code,
'effective_from': self.effective_from.isoformat() if self.effective_from else None,
'effective_to': self.effective_to.isoformat() if self.effective_to else None,
'is_active': self.is_active,
'notes': self.notes,
'created_at': self.created_at.isoformat() if self.created_at else None,
'updated_at': self.updated_at.isoformat() if self.updated_at else None
}
@classmethod
def get_rate_for_location(cls, country, city=None, date=None):
"""Get applicable per diem rate for a location and date"""
from datetime import date as dt_date
if date is None:
date = dt_date.today()
query = cls.query.filter(
cls.country == country,
cls.is_active == True,
cls.effective_from <= date
)
if city:
# Try to find city-specific rate first
city_rate = query.filter(cls.city == city).filter(
db.or_(cls.effective_to.is_(None), cls.effective_to >= date)
).first()
if city_rate:
return city_rate
# Fall back to country rate
country_rate = query.filter(cls.city.is_(None)).filter(
db.or_(cls.effective_to.is_(None), cls.effective_to >= date)
).first()
return country_rate
class PerDiem(db.Model):
"""Per diem claim for business travel"""
__tablename__ = 'per_diems'
id = db.Column(db.Integer, primary_key=True)
user_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False, index=True)
project_id = db.Column(db.Integer, db.ForeignKey('projects.id'), nullable=True, index=True)
client_id = db.Column(db.Integer, db.ForeignKey('clients.id'), nullable=True, index=True)
expense_id = db.Column(db.Integer, db.ForeignKey('expenses.id'), nullable=True, index=True)
per_diem_rate_id = db.Column(db.Integer, db.ForeignKey('per_diem_rates.id'), nullable=True, index=True)
# Trip details
trip_purpose = db.Column(db.String(200), nullable=False)
description = db.Column(db.Text, nullable=True)
# Date range
start_date = db.Column(db.Date, nullable=False, index=True)
end_date = db.Column(db.Date, nullable=False, index=True)
departure_time = db.Column(db.Time, nullable=True)
return_time = db.Column(db.Time, nullable=True)
# Location
country = db.Column(db.String(100), nullable=False)
city = db.Column(db.String(100), nullable=True)
# Calculation details
full_days = db.Column(db.Integer, default=0, nullable=False)
half_days = db.Column(db.Integer, default=0, nullable=False)
# Meal deductions (if meals were provided)
breakfast_provided = db.Column(db.Integer, default=0, nullable=False) # Number of breakfasts
lunch_provided = db.Column(db.Integer, default=0, nullable=False)
dinner_provided = db.Column(db.Integer, default=0, nullable=False)
# Rates used (stored at time of creation)
full_day_rate = db.Column(db.Numeric(10, 2), nullable=False)
half_day_rate = db.Column(db.Numeric(10, 2), nullable=False)
breakfast_deduction = db.Column(db.Numeric(10, 2), nullable=True)
lunch_deduction = db.Column(db.Numeric(10, 2), nullable=True)
dinner_deduction = db.Column(db.Numeric(10, 2), nullable=True)
# Calculated amount
calculated_amount = db.Column(db.Numeric(10, 2), nullable=False)
currency_code = db.Column(db.String(3), nullable=False, default='EUR')
# Status and approval
status = db.Column(db.String(20), default='pending', nullable=False) # 'pending', 'approved', 'rejected', 'reimbursed'
approved_by = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=True, index=True)
approved_at = db.Column(db.DateTime, nullable=True)
rejection_reason = db.Column(db.Text, nullable=True)
# Reimbursement
reimbursed = db.Column(db.Boolean, default=False, nullable=False)
reimbursed_at = db.Column(db.DateTime, nullable=True)
# Notes
notes = db.Column(db.Text, nullable=True)
# Metadata
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
# Relationships
user = db.relationship('User', foreign_keys=[user_id], backref=db.backref('per_diem_claims', lazy='dynamic'))
approver = db.relationship('User', foreign_keys=[approved_by], backref=db.backref('approved_per_diems', lazy='dynamic'))
project = db.relationship('Project', backref=db.backref('per_diem_claims', lazy='dynamic'))
client = db.relationship('Client', backref=db.backref('per_diem_claims', lazy='dynamic'))
expense = db.relationship('Expense', backref=db.backref('per_diem_claim', uselist=False))
rate = db.relationship('PerDiemRate', backref=db.backref('per_diem_claims', lazy='dynamic'))
# Indexes for common queries
__table_args__ = (
Index('ix_per_diems_user_date', 'user_id', 'start_date'),
Index('ix_per_diems_status_date', 'status', 'start_date'),
)
def __init__(self, user_id, trip_purpose, start_date, end_date, country,
full_day_rate, half_day_rate, **kwargs):
self.user_id = user_id
self.trip_purpose = trip_purpose.strip()
self.start_date = start_date
self.end_date = end_date
self.country = country.strip()
self.city = kwargs.get('city', '').strip() if kwargs.get('city') else None
# Store rates
self.full_day_rate = Decimal(str(full_day_rate))
self.half_day_rate = Decimal(str(half_day_rate))
# Optional fields
self.description = kwargs.get('description', '').strip() if kwargs.get('description') else None
self.project_id = kwargs.get('project_id')
self.client_id = kwargs.get('client_id')
self.expense_id = kwargs.get('expense_id')
self.per_diem_rate_id = kwargs.get('per_diem_rate_id')
self.departure_time = kwargs.get('departure_time')
self.return_time = kwargs.get('return_time')
self.full_days = kwargs.get('full_days', 0)
self.half_days = kwargs.get('half_days', 0)
self.breakfast_provided = kwargs.get('breakfast_provided', 0)
self.lunch_provided = kwargs.get('lunch_provided', 0)
self.dinner_provided = kwargs.get('dinner_provided', 0)
self.breakfast_deduction = Decimal(str(kwargs.get('breakfast_deduction', 0)))
self.lunch_deduction = Decimal(str(kwargs.get('lunch_deduction', 0)))
self.dinner_deduction = Decimal(str(kwargs.get('dinner_deduction', 0)))
self.currency_code = kwargs.get('currency_code', 'EUR')
self.notes = kwargs.get('notes', '').strip() if kwargs.get('notes') else None
self.status = kwargs.get('status', 'pending')
# Calculate amount
self.calculated_amount = self._calculate_amount()
def _calculate_amount(self):
"""Calculate the per diem amount based on days and deductions"""
# Base amount
amount = (self.full_day_rate * self.full_days) + (self.half_day_rate * self.half_days)
# Deduct provided meals
amount -= (self.breakfast_deduction * self.breakfast_provided)
amount -= (self.lunch_deduction * self.lunch_provided)
amount -= (self.dinner_deduction * self.dinner_provided)
return max(Decimal('0'), amount) # Ensure non-negative
def recalculate_amount(self):
"""Recalculate the amount (useful when days or deductions change)"""
self.calculated_amount = self._calculate_amount()
return self.calculated_amount
def __repr__(self):
location = f"{self.city}, {self.country}" if self.city else self.country
return f'<PerDiem {location} ({self.start_date} - {self.end_date})>'
@property
def total_days(self):
"""Get total number of days (full + half)"""
return self.full_days + (self.half_days * 0.5)
@property
def trip_duration(self):
"""Get trip duration in days"""
return (self.end_date - self.start_date).days + 1
def approve(self, approved_by_user_id, notes=None):
"""Approve the per diem claim"""
self.status = 'approved'
self.approved_by = approved_by_user_id
self.approved_at = datetime.utcnow()
if notes:
self.notes = (self.notes or '') + f'\n\nApproval notes: {notes}'
self.updated_at = datetime.utcnow()
def reject(self, rejected_by_user_id, reason):
"""Reject the per diem claim"""
self.status = 'rejected'
self.approved_by = rejected_by_user_id
self.approved_at = datetime.utcnow()
self.rejection_reason = reason
self.updated_at = datetime.utcnow()
def mark_as_reimbursed(self):
"""Mark this per diem claim as reimbursed"""
self.reimbursed = True
self.reimbursed_at = datetime.utcnow()
self.status = 'reimbursed'
self.updated_at = datetime.utcnow()
def create_expense(self):
"""Create an expense from this per diem claim"""
from app.models.expense import Expense
if self.expense_id:
return None # Already has an expense
location = f"{self.city}, {self.country}" if self.city else self.country
expense = Expense(
user_id=self.user_id,
title=f"Per Diem: {location}",
category='meals',
amount=self.calculated_amount,
expense_date=self.start_date,
description=f"{self.trip_purpose}\n{self.start_date} to {self.end_date} ({self.total_days} days)",
project_id=self.project_id,
client_id=self.client_id,
currency_code=self.currency_code,
status=self.status
)
return expense
def to_dict(self):
"""Convert per diem claim to dictionary for API responses"""
return {
'id': self.id,
'user_id': self.user_id,
'project_id': self.project_id,
'client_id': self.client_id,
'expense_id': self.expense_id,
'per_diem_rate_id': self.per_diem_rate_id,
'trip_purpose': self.trip_purpose,
'description': self.description,
'start_date': self.start_date.isoformat() if self.start_date else None,
'end_date': self.end_date.isoformat() if self.end_date else None,
'departure_time': self.departure_time.isoformat() if self.departure_time else None,
'return_time': self.return_time.isoformat() if self.return_time else None,
'country': self.country,
'city': self.city,
'full_days': self.full_days,
'half_days': self.half_days,
'total_days': self.total_days,
'trip_duration': self.trip_duration,
'breakfast_provided': self.breakfast_provided,
'lunch_provided': self.lunch_provided,
'dinner_provided': self.dinner_provided,
'full_day_rate': float(self.full_day_rate),
'half_day_rate': float(self.half_day_rate),
'breakfast_deduction': float(self.breakfast_deduction) if self.breakfast_deduction else None,
'lunch_deduction': float(self.lunch_deduction) if self.lunch_deduction else None,
'dinner_deduction': float(self.dinner_deduction) if self.dinner_deduction else None,
'calculated_amount': float(self.calculated_amount),
'currency_code': self.currency_code,
'status': self.status,
'approved_by': self.approved_by,
'approved_at': self.approved_at.isoformat() if self.approved_at else None,
'rejection_reason': self.rejection_reason,
'reimbursed': self.reimbursed,
'reimbursed_at': self.reimbursed_at.isoformat() if self.reimbursed_at else None,
'notes': self.notes,
'created_at': self.created_at.isoformat() if self.created_at else None,
'updated_at': self.updated_at.isoformat() if self.updated_at else None,
'user': self.user.username if self.user else None,
'project': self.project.name if self.project else None,
'client': self.client.name if self.client else None,
'approver': self.approver.username if self.approver else None
}
@classmethod
def calculate_days_from_dates(cls, start_date, end_date, departure_time=None, return_time=None):
"""
Calculate full and half days based on departure and return times.
Rules:
- Departure before 12:00 = full day
- Departure after 12:00 = half day
- Return after 12:00 = full day
- Return before 12:00 = half day
- Middle days = full days
"""
from datetime import time as dt_time
if start_date > end_date:
return {'full_days': 0, 'half_days': 0}
trip_days = (end_date - start_date).days + 1
if trip_days == 1:
# Single day trip
if departure_time and return_time:
# Check if it qualifies for a full day (>= 8 hours)
departure_datetime = datetime.combine(start_date, departure_time)
return_datetime = datetime.combine(end_date, return_time)
hours = (return_datetime - departure_datetime).total_seconds() / 3600
if hours >= 8:
return {'full_days': 1, 'half_days': 0}
else:
return {'full_days': 0, 'half_days': 1}
else:
# Default to half day for single day
return {'full_days': 0, 'half_days': 1}
full_days = 0
half_days = 0
# First day
noon = dt_time(12, 0)
if departure_time and departure_time < noon:
full_days += 1
else:
half_days += 1
# Middle days (all full days)
if trip_days > 2:
full_days += (trip_days - 2)
# Last day
if return_time and return_time >= noon:
full_days += 1
else:
half_days += 1
return {'full_days': full_days, 'half_days': half_days}
@classmethod
def get_pending_approvals(cls, user_id=None):
"""Get per diem claims pending approval"""
query = cls.query.filter_by(status='pending')
if user_id:
query = query.filter(cls.user_id == user_id)
return query.order_by(cls.start_date.desc()).all()
+1 -1
View File
@@ -26,7 +26,7 @@ class Task(db.Model):
# project relationship is defined via backref in Project model
assigned_user = db.relationship('User', foreign_keys=[assigned_to], backref='assigned_tasks')
creator = db.relationship('User', foreign_keys=[created_by], backref='created_tasks')
time_entries = db.relationship('TimeEntry', backref='task', lazy='dynamic', cascade='all, delete-orphan')
time_entries = db.relationship('TimeEntry', backref='task', lazy='dynamic')
# comments relationship is defined via backref in Comment model
def __init__(self, project_id, name, description=None, priority='medium', estimated_hours=None,
+120 -11
View File
@@ -396,6 +396,7 @@ def pdf_layout():
html_template = request.form.get('invoice_pdf_template_html', '')
css_template = request.form.get('invoice_pdf_template_css', '')
design_json = request.form.get('design_json', '')
settings_obj.invoice_pdf_template_html = html_template
settings_obj.invoice_pdf_template_css = css_template
settings_obj.invoice_pdf_design_json = design_json
@@ -437,6 +438,7 @@ def pdf_layout():
def pdf_layout_reset():
"""Reset PDF layout to defaults (clear custom templates)."""
settings_obj = Settings.get_settings()
settings_obj.invoice_pdf_template_html = ''
settings_obj.invoice_pdf_template_css = ''
settings_obj.invoice_pdf_design_json = ''
@@ -447,6 +449,55 @@ def pdf_layout_reset():
return redirect(url_for('admin.pdf_layout'))
@admin_bp.route('/admin/pdf-layout/debug', methods=['GET'])
@login_required
@admin_or_permission_required('manage_settings')
def pdf_layout_debug():
"""Debug endpoint to show what's saved in the database"""
settings_obj = Settings.get_settings()
html = settings_obj.invoice_pdf_template_html or ''
css = settings_obj.invoice_pdf_template_css or ''
design_json = settings_obj.invoice_pdf_design_json or ''
# Check for bugs
has_all_bug = 'invoice.items.all()' in html
has_if_bug = 'invoice.items and invoice.items.all()' in html
# Get invoice info for testing
from app.models import Invoice
test_invoice = Invoice.query.order_by(Invoice.id.desc()).first()
debug_info = {
'saved_template': {
'html_length': len(html),
'css_length': len(css),
'design_json_length': len(design_json),
'has_html': bool(html),
'has_bugs': has_all_bug or has_if_bug,
'bugs_found': []
},
'test_invoice': {
'exists': test_invoice is not None,
'invoice_number': test_invoice.invoice_number if test_invoice else None,
'items_count': test_invoice.items.count() if test_invoice else 0,
}
}
if has_all_bug:
debug_info['saved_template']['bugs_found'].append('invoice.items.all() found in template')
if has_if_bug:
debug_info['saved_template']['bugs_found'].append('invoice.items and invoice.items.all() found in template')
# Show snippets of problematic code
if has_all_bug or has_if_bug:
import re
matches = re.finditer(r'.{0,50}invoice\.items\.all\(\).{0,50}', html)
debug_info['saved_template']['bug_snippets'] = [m.group() for m in matches]
return jsonify(debug_info)
@admin_bp.route('/admin/pdf-layout/default', methods=['GET'])
@login_required
@admin_or_permission_required('manage_settings')
@@ -516,21 +567,77 @@ def pdf_layout_preview():
)
# Ensure at least one sample item to avoid undefined 'item' in templates that reference it outside loops
sample_item = SimpleNamespace(description='Sample item', quantity=1.0, unit_price=0.0, total_amount=0.0, time_entry_ids='')
try:
if not getattr(invoice, 'items', None):
invoice.items = [sample_item]
except Exception:
# Create a wrapper object with converted Query objects to lists
# We can't modify SQLAlchemy model attributes directly, so we create a wrapper
invoice_wrapper = SimpleNamespace()
# Copy all simple attributes from the invoice
for attr in ['id', 'invoice_number', 'project_id', 'client_name', 'client_email',
'client_address', 'client_id', 'issue_date', 'due_date', 'status',
'subtotal', 'tax_rate', 'tax_amount', 'total_amount', 'currency_code',
'notes', 'terms', 'payment_date', 'payment_method', 'payment_reference',
'payment_notes', 'amount_paid', 'payment_status', 'created_by',
'created_at', 'updated_at']:
try:
invoice.items = [sample_item]
except Exception:
setattr(invoice_wrapper, attr, getattr(invoice, attr))
except AttributeError:
pass
# Ensure extra_goods attribute exists
# Copy relationship attributes (project, client)
try:
if not hasattr(invoice, 'extra_goods'):
invoice.extra_goods = []
invoice_wrapper.project = invoice.project
except:
invoice_wrapper.project = SimpleNamespace(name='Sample Project', description='')
try:
invoice_wrapper.client = invoice.client
except:
invoice_wrapper.client = None
# Convert items from Query to list
try:
if hasattr(invoice, 'items') and hasattr(invoice.items, 'all'):
# It's a SQLAlchemy Query object - call .all() to get list
items_list = invoice.items.all()
if not items_list:
# No items in database, add sample
items_list = [sample_item]
invoice_wrapper.items = items_list
elif hasattr(invoice, 'items') and isinstance(invoice.items, list):
# Already a list
invoice_wrapper.items = invoice.items if invoice.items else [sample_item]
else:
# Fallback
invoice_wrapper.items = [sample_item]
except Exception as e:
print(f"Error converting invoice items: {e}")
invoice_wrapper.items = [sample_item]
# Convert extra_goods from Query to list
try:
if hasattr(invoice, 'extra_goods') and hasattr(invoice.extra_goods, 'all'):
invoice_wrapper.extra_goods = invoice.extra_goods.all()
elif hasattr(invoice, 'extra_goods') and isinstance(invoice.extra_goods, list):
invoice_wrapper.extra_goods = invoice.extra_goods
else:
invoice_wrapper.extra_goods = []
except Exception:
pass
invoice_wrapper.extra_goods = []
# Convert expenses from Query to list
try:
if hasattr(invoice, 'expenses') and hasattr(invoice.expenses, 'all'):
invoice_wrapper.expenses = invoice.expenses.all()
elif hasattr(invoice, 'expenses') and isinstance(invoice.expenses, list):
invoice_wrapper.expenses = invoice.expenses
else:
invoice_wrapper.expenses = []
except Exception:
invoice_wrapper.expenses = []
# Use the wrapper instead of the original invoice
invoice = invoice_wrapper
# Helper: sanitize Jinja blocks to fix entities/smart quotes inserted by editor
def _sanitize_jinja_blocks(raw: str) -> str:
try:
@@ -624,7 +731,9 @@ def pdf_layout_preview():
item=sample_item,
)
except Exception as e:
body_html = f"<div style='color:red'>Template error: {str(e)}</div>" + sanitized
import traceback
error_details = traceback.format_exc()
body_html = f"<div style='color:red; padding:20px; border:2px solid red; margin:20px;'><h3>Template error:</h3><pre>{str(e)}</pre><pre>{error_details}</pre></div>" + sanitized
# Build complete HTML page with embedded styles
page_html = f"""<!DOCTYPE html>
<html>
+139
View File
@@ -1298,6 +1298,145 @@ def serve_editor_image(filename):
folder = get_editor_upload_folder()
return send_from_directory(folder, filename)
# ================================
# Activity Feed API
# ================================
@api_bp.route('/api/activities')
@login_required
def get_activities():
"""Get recent activities with filtering"""
from app.models import Activity
from sqlalchemy import and_
# Get query parameters
limit = request.args.get('limit', 50, type=int)
page = request.args.get('page', 1, type=int)
user_id = request.args.get('user_id', type=int)
entity_type = request.args.get('entity_type', '').strip()
action = request.args.get('action', '').strip()
start_date = request.args.get('start_date', '').strip()
end_date = request.args.get('end_date', '').strip()
# Build query
query = Activity.query
# Filter by user (admins can see all, users see only their own)
if not current_user.is_admin:
query = query.filter_by(user_id=current_user.id)
elif user_id:
query = query.filter_by(user_id=user_id)
# Filter by entity type
if entity_type:
query = query.filter_by(entity_type=entity_type)
# Filter by action
if action:
query = query.filter_by(action=action)
# Filter by date range
if start_date:
try:
start_dt = datetime.fromisoformat(start_date)
query = query.filter(Activity.created_at >= start_dt)
except ValueError:
pass
if end_date:
try:
end_dt = datetime.fromisoformat(end_date)
query = query.filter(Activity.created_at <= end_dt)
except ValueError:
pass
# Get total count
total = query.count()
# Apply ordering and pagination
activities = query.order_by(Activity.created_at.desc()).paginate(
page=page,
per_page=limit,
error_out=False
)
return jsonify({
'activities': [a.to_dict() for a in activities.items],
'total': total,
'pages': activities.pages,
'current_page': activities.page,
'has_next': activities.has_next,
'has_prev': activities.has_prev
})
@api_bp.route('/api/activities/stats')
@login_required
def get_activity_stats():
"""Get activity statistics"""
from app.models import Activity
from sqlalchemy import func
# Get date range (default to last 7 days)
days = request.args.get('days', 7, type=int)
since = datetime.utcnow() - timedelta(days=days)
# Build base query
query = Activity.query.filter(Activity.created_at >= since)
# Filter by user if not admin
if not current_user.is_admin:
query = query.filter_by(user_id=current_user.id)
# Get counts by entity type
entity_counts = db.session.query(
Activity.entity_type,
func.count(Activity.id).label('count')
).filter(Activity.created_at >= since)
if not current_user.is_admin:
entity_counts = entity_counts.filter_by(user_id=current_user.id)
entity_counts = entity_counts.group_by(Activity.entity_type).all()
# Get counts by action
action_counts = db.session.query(
Activity.action,
func.count(Activity.id).label('count')
).filter(Activity.created_at >= since)
if not current_user.is_admin:
action_counts = action_counts.filter_by(user_id=current_user.id)
action_counts = action_counts.group_by(Activity.action).all()
# Get most active users (admins only)
user_activity = []
if current_user.is_admin:
user_activity = db.session.query(
User.username,
User.display_name,
func.count(Activity.id).label('count')
).join(
Activity, User.id == Activity.user_id
).filter(
Activity.created_at >= since
).group_by(
User.id, User.username, User.display_name
).order_by(
func.count(Activity.id).desc()
).limit(10).all()
return jsonify({
'total_activities': query.count(),
'entity_counts': {entity: count for entity, count in entity_counts},
'action_counts': {action: count for action, count in action_counts},
'user_activity': [
{'username': u[0], 'display_name': u[1], 'count': u[2]}
for u in user_activity
],
'period_days': days
})
# WebSocket event handlers
@socketio.on('connect')
def handle_connect():
+458
View File
@@ -0,0 +1,458 @@
"""
Budget Alerts Routes
This module provides API endpoints for managing budget alerts and forecasting.
"""
from flask import Blueprint, render_template, request, jsonify, flash, redirect, url_for
from flask_login import login_required, current_user
from app import db, log_event, track_event
from app.models import Project, BudgetAlert, User
from app.utils.budget_forecasting import (
calculate_burn_rate,
estimate_completion_date,
analyze_resource_allocation,
analyze_cost_trends,
get_budget_status,
check_budget_alerts
)
from datetime import datetime, timedelta
from sqlalchemy import func
budget_alerts_bp = Blueprint('budget_alerts', __name__)
@budget_alerts_bp.route('/budget/dashboard')
@login_required
def budget_dashboard():
"""Budget alerts and forecasting dashboard"""
# Get projects with budgets
if current_user.is_admin:
projects = Project.query.filter(
Project.budget_amount.isnot(None),
Project.status == 'active'
).order_by(Project.name).all()
else:
# For non-admin users, show only projects they've worked on
from sqlalchemy import distinct
from app.models import TimeEntry
user_project_ids = db.session.query(distinct(TimeEntry.project_id)).filter(
TimeEntry.user_id == current_user.id
).all()
user_project_ids = [pid[0] for pid in user_project_ids]
projects = Project.query.filter(
Project.id.in_(user_project_ids),
Project.budget_amount.isnot(None),
Project.status == 'active'
).order_by(Project.name).all()
# Get budget status for each project
project_budgets = []
for project in projects:
budget_status = get_budget_status(project.id)
if budget_status:
project_budgets.append(budget_status)
# Get active alerts
if current_user.is_admin:
active_alerts = BudgetAlert.get_active_alerts(acknowledged=False)
else:
# For non-admin, get alerts for their projects
active_alerts = BudgetAlert.query.filter(
BudgetAlert.is_acknowledged == False,
BudgetAlert.project_id.in_(user_project_ids)
).order_by(BudgetAlert.created_at.desc()).all()
# Get alert statistics
alert_stats = {
'total_unacknowledged': len(active_alerts),
'critical_alerts': len([a for a in active_alerts if a.alert_level == 'critical']),
'warning_alerts': len([a for a in active_alerts if a.alert_level == 'warning']),
}
log_event('budget_dashboard_viewed', user_id=current_user.id)
return render_template('budget/dashboard.html',
projects=project_budgets,
active_alerts=active_alerts,
alert_stats=alert_stats)
@budget_alerts_bp.route('/api/budget/burn-rate/<int:project_id>')
@login_required
def get_burn_rate(project_id):
"""Get burn rate for a project"""
project = Project.query.get_or_404(project_id)
# Check permissions
if not current_user.is_admin:
# Check if user has worked on this project
from app.models import TimeEntry
has_access = TimeEntry.query.filter_by(
project_id=project_id,
user_id=current_user.id
).first() is not None
if not has_access:
return jsonify({'error': 'Access denied'}), 403
days = request.args.get('days', 30, type=int)
burn_rate = calculate_burn_rate(project_id, days)
if burn_rate is None:
return jsonify({'error': 'Project not found or no data available'}), 404
log_event('budget_burn_rate_viewed', user_id=current_user.id, project_id=project_id)
return jsonify(burn_rate)
@budget_alerts_bp.route('/api/budget/completion-estimate/<int:project_id>')
@login_required
def get_completion_estimate(project_id):
"""Get estimated completion date for a project"""
project = Project.query.get_or_404(project_id)
# Check permissions
if not current_user.is_admin:
from app.models import TimeEntry
has_access = TimeEntry.query.filter_by(
project_id=project_id,
user_id=current_user.id
).first() is not None
if not has_access:
return jsonify({'error': 'Access denied'}), 403
days = request.args.get('days', 30, type=int)
estimate = estimate_completion_date(project_id, days)
if estimate is None:
return jsonify({'error': 'Project not found or no budget set'}), 404
log_event('budget_completion_estimate_viewed', user_id=current_user.id, project_id=project_id)
return jsonify(estimate)
@budget_alerts_bp.route('/api/budget/resource-allocation/<int:project_id>')
@login_required
def get_resource_allocation(project_id):
"""Get resource allocation analysis for a project"""
project = Project.query.get_or_404(project_id)
# Check permissions
if not current_user.is_admin:
from app.models import TimeEntry
has_access = TimeEntry.query.filter_by(
project_id=project_id,
user_id=current_user.id
).first() is not None
if not has_access:
return jsonify({'error': 'Access denied'}), 403
days = request.args.get('days', 30, type=int)
allocation = analyze_resource_allocation(project_id, days)
if allocation is None:
return jsonify({'error': 'Project not found'}), 404
log_event('budget_resource_allocation_viewed', user_id=current_user.id, project_id=project_id)
return jsonify(allocation)
@budget_alerts_bp.route('/api/budget/cost-trends/<int:project_id>')
@login_required
def get_cost_trends(project_id):
"""Get cost trend analysis for a project"""
project = Project.query.get_or_404(project_id)
# Check permissions
if not current_user.is_admin:
from app.models import TimeEntry
has_access = TimeEntry.query.filter_by(
project_id=project_id,
user_id=current_user.id
).first() is not None
if not has_access:
return jsonify({'error': 'Access denied'}), 403
days = request.args.get('days', 90, type=int)
granularity = request.args.get('granularity', 'week')
if granularity not in ['day', 'week', 'month']:
return jsonify({'error': 'Invalid granularity. Use day, week, or month'}), 400
trends = analyze_cost_trends(project_id, days, granularity)
if trends is None:
return jsonify({'error': 'Project not found'}), 404
log_event('budget_cost_trends_viewed', user_id=current_user.id, project_id=project_id)
return jsonify(trends)
@budget_alerts_bp.route('/api/budget/status/<int:project_id>')
@login_required
def get_project_budget_status(project_id):
"""Get comprehensive budget status for a project"""
project = Project.query.get_or_404(project_id)
# Check permissions
if not current_user.is_admin:
from app.models import TimeEntry
has_access = TimeEntry.query.filter_by(
project_id=project_id,
user_id=current_user.id
).first() is not None
if not has_access:
return jsonify({'error': 'Access denied'}), 403
budget_status = get_budget_status(project_id)
if budget_status is None:
return jsonify({'error': 'Project not found or no budget set'}), 404
return jsonify(budget_status)
@budget_alerts_bp.route('/api/budget/alerts')
@login_required
def get_alerts():
"""Get budget alerts"""
project_id = request.args.get('project_id', type=int)
acknowledged = request.args.get('acknowledged', 'false').lower() == 'true'
if current_user.is_admin:
alerts = BudgetAlert.get_active_alerts(project_id=project_id, acknowledged=acknowledged)
else:
# For non-admin, get alerts for their projects
from sqlalchemy import distinct
from app.models import TimeEntry
user_project_ids = db.session.query(distinct(TimeEntry.project_id)).filter(
TimeEntry.user_id == current_user.id
).all()
user_project_ids = [pid[0] for pid in user_project_ids]
query = BudgetAlert.query.filter(
BudgetAlert.is_acknowledged == acknowledged,
BudgetAlert.project_id.in_(user_project_ids)
)
if project_id:
query = query.filter_by(project_id=project_id)
alerts = query.order_by(BudgetAlert.created_at.desc()).all()
return jsonify({
'alerts': [alert.to_dict() for alert in alerts],
'count': len(alerts)
})
@budget_alerts_bp.route('/api/budget/alerts/<int:alert_id>/acknowledge', methods=['POST'])
@login_required
def acknowledge_alert(alert_id):
"""Acknowledge a budget alert"""
alert = BudgetAlert.query.get_or_404(alert_id)
# Check permissions
if not current_user.is_admin:
from app.models import TimeEntry
has_access = TimeEntry.query.filter_by(
project_id=alert.project_id,
user_id=current_user.id
).first() is not None
if not has_access:
return jsonify({'error': 'Access denied'}), 403
if alert.is_acknowledged:
return jsonify({'message': 'Alert already acknowledged'}), 200
alert.acknowledge(current_user.id)
log_event('budget_alert_acknowledged', user_id=current_user.id,
alert_id=alert_id, project_id=alert.project_id)
return jsonify({
'message': 'Alert acknowledged successfully',
'alert': alert.to_dict()
})
@budget_alerts_bp.route('/api/budget/check-alerts/<int:project_id>', methods=['POST'])
@login_required
def check_project_alerts(project_id):
"""Manually check and create alerts for a project (admin only)"""
if not current_user.is_admin:
return jsonify({'error': 'Admin access required'}), 403
project = Project.query.get_or_404(project_id)
alerts_to_create = check_budget_alerts(project_id)
created_alerts = []
for alert_data in alerts_to_create:
alert = BudgetAlert.create_alert(
project_id=alert_data['project_id'],
alert_type=alert_data['type'],
budget_consumed_percent=alert_data['budget_consumed_percent'],
budget_amount=alert_data['budget_amount'],
consumed_amount=alert_data['consumed_amount']
)
created_alerts.append(alert.to_dict())
log_event('budget_alerts_checked', user_id=current_user.id, project_id=project_id)
return jsonify({
'message': f'Checked alerts for project {project.name}',
'alerts_created': len(created_alerts),
'alerts': created_alerts
})
@budget_alerts_bp.route('/budget/project/<int:project_id>')
@login_required
def project_budget_detail(project_id):
"""Detailed budget view for a specific project"""
project = Project.query.get_or_404(project_id)
# Check permissions
if not current_user.is_admin:
from app.models import TimeEntry
has_access = TimeEntry.query.filter_by(
project_id=project_id,
user_id=current_user.id
).first() is not None
if not has_access:
flash('You do not have access to this project.', 'error')
return redirect(url_for('budget_alerts.budget_dashboard'))
# Get budget status
budget_status = get_budget_status(project_id)
if not budget_status:
flash('This project does not have a budget set.', 'warning')
return redirect(url_for('budget_alerts.budget_dashboard'))
# Get burn rate
burn_rate = calculate_burn_rate(project_id, 30)
# Get completion estimate
completion_estimate = estimate_completion_date(project_id, 30)
# Get resource allocation
resource_allocation = analyze_resource_allocation(project_id, 30)
# Get cost trends
cost_trends = analyze_cost_trends(project_id, 90, 'week')
# Get alerts for this project
alerts = BudgetAlert.query.filter_by(
project_id=project_id,
is_acknowledged=False
).order_by(BudgetAlert.created_at.desc()).all()
log_event('project_budget_detail_viewed', user_id=current_user.id, project_id=project_id)
return render_template('budget/project_detail.html',
project=project,
budget_status=budget_status,
burn_rate=burn_rate,
completion_estimate=completion_estimate,
resource_allocation=resource_allocation,
cost_trends=cost_trends,
alerts=alerts)
@budget_alerts_bp.route('/api/budget/summary')
@login_required
def get_budget_summary():
"""Get summary of all budget alerts and project statuses"""
if current_user.is_admin:
projects = Project.query.filter(
Project.budget_amount.isnot(None),
Project.status == 'active'
).all()
else:
# For non-admin, get projects they've worked on
from sqlalchemy import distinct
from app.models import TimeEntry
user_project_ids = db.session.query(distinct(TimeEntry.project_id)).filter(
TimeEntry.user_id == current_user.id
).all()
user_project_ids = [pid[0] for pid in user_project_ids]
projects = Project.query.filter(
Project.id.in_(user_project_ids),
Project.budget_amount.isnot(None),
Project.status == 'active'
).all()
summary = {
'total_projects': len(projects),
'healthy': 0,
'warning': 0,
'critical': 0,
'over_budget': 0,
'total_budget': 0,
'total_consumed': 0,
'projects': []
}
for project in projects:
budget_status = get_budget_status(project.id)
if budget_status:
summary['total_budget'] += budget_status['budget_amount']
summary['total_consumed'] += budget_status['consumed_amount']
summary[budget_status['status']] += 1
summary['projects'].append(budget_status)
# Get alert statistics
if current_user.is_admin:
alert_stats = BudgetAlert.get_alert_summary()
else:
from sqlalchemy import distinct
from app.models import TimeEntry
user_project_ids = db.session.query(distinct(TimeEntry.project_id)).filter(
TimeEntry.user_id == current_user.id
).all()
user_project_ids = [pid[0] for pid in user_project_ids]
total_alerts = BudgetAlert.query.filter(
BudgetAlert.project_id.in_(user_project_ids)
).count()
unacknowledged_alerts = BudgetAlert.query.filter(
BudgetAlert.project_id.in_(user_project_ids),
BudgetAlert.is_acknowledged == False
).count()
critical_alerts = BudgetAlert.query.filter(
BudgetAlert.project_id.in_(user_project_ids),
BudgetAlert.alert_level == 'critical',
BudgetAlert.is_acknowledged == False
).count()
alert_stats = {
'total_alerts': total_alerts,
'unacknowledged_alerts': unacknowledged_alerts,
'critical_alerts': critical_alerts
}
summary['alert_stats'] = alert_stats
return jsonify(summary)
+79 -1
View File
@@ -1,4 +1,4 @@
from flask import Blueprint, render_template, request, redirect, url_for, flash, current_app, jsonify
from flask import Blueprint, render_template, request, redirect, url_for, flash, current_app, jsonify, Response
from flask_babel import gettext as _
from flask_login import login_required, current_user
import app as app_module
@@ -8,6 +8,8 @@ from datetime import datetime
from decimal import Decimal
from app.utils.db import safe_commit
from app.utils.permissions import admin_or_permission_required
import csv
import io
clients_bp = Blueprint('clients', __name__)
@@ -431,6 +433,82 @@ def bulk_status_change():
return redirect(url_for('clients.list_clients'))
@clients_bp.route('/clients/export')
@login_required
def export_clients():
"""Export clients to CSV"""
status = request.args.get('status', 'active')
search = request.args.get('search', '').strip()
query = Client.query
if status == 'active':
query = query.filter_by(status='active')
elif status == 'inactive':
query = query.filter_by(status='inactive')
if search:
like = f"%{search}%"
query = query.filter(
db.or_(
Client.name.ilike(like),
Client.description.ilike(like),
Client.contact_person.ilike(like),
Client.email.ilike(like)
)
)
clients = query.order_by(Client.name).all()
# Create CSV in memory
output = io.StringIO()
writer = csv.writer(output)
# Write header
writer.writerow([
'ID',
'Name',
'Description',
'Contact Person',
'Email',
'Phone',
'Address',
'Default Hourly Rate',
'Status',
'Active Projects',
'Total Projects',
'Created At',
'Updated At'
])
# Write client data
for client in clients:
writer.writerow([
client.id,
client.name,
client.description or '',
client.contact_person or '',
client.email or '',
client.phone or '',
client.address or '',
client.default_hourly_rate or '',
client.status,
client.active_projects,
client.total_projects,
client.created_at.strftime('%Y-%m-%d %H:%M:%S') if client.created_at else '',
client.updated_at.strftime('%Y-%m-%d %H:%M:%S') if client.updated_at else ''
])
# Create response
output.seek(0)
return Response(
output.getvalue(),
mimetype='text/csv',
headers={
'Content-Disposition': f'attachment; filename=clients_export_{datetime.now().strftime("%Y%m%d_%H%M%S")}.csv'
}
)
@clients_bp.route('/api/clients')
@login_required
def api_clients():
+253
View File
@@ -0,0 +1,253 @@
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, log_event, track_event
from app.models import ExpenseCategory
from datetime import datetime, date
from decimal import Decimal
from app.utils.db import safe_commit
from app.utils.permissions import admin_or_permission_required
expense_categories_bp = Blueprint('expense_categories', __name__)
@expense_categories_bp.route('/expense-categories')
@login_required
@admin_or_permission_required('expense_categories.view')
def list_categories():
"""List all expense categories"""
from app import track_page_view
track_page_view("expense_categories_list")
categories = ExpenseCategory.query.order_by(ExpenseCategory.name).all()
# Get budget utilization for each category
for category in categories:
category.monthly_utilization = category.get_budget_utilization('monthly')
category.yearly_utilization = category.get_budget_utilization('yearly')
return render_template('expense_categories/list.html', categories=categories)
@expense_categories_bp.route('/expense-categories/create', methods=['GET', 'POST'])
@login_required
@admin_or_permission_required('expense_categories.create')
def create_category():
"""Create a new expense category"""
if request.method == 'GET':
return render_template('expense_categories/form.html', category=None)
try:
# Get form data
name = request.form.get('name', '').strip()
description = request.form.get('description', '').strip()
code = request.form.get('code', '').strip()
color = request.form.get('color', '').strip()
icon = request.form.get('icon', '').strip()
# Validate required fields
if not name:
flash(_('Category name is required'), 'error')
return redirect(url_for('expense_categories.create_category'))
# Budget fields
monthly_budget = request.form.get('monthly_budget', '').strip()
quarterly_budget = request.form.get('quarterly_budget', '').strip()
yearly_budget = request.form.get('yearly_budget', '').strip()
budget_threshold_percent = request.form.get('budget_threshold_percent', '80')
# Settings
requires_receipt = request.form.get('requires_receipt') == 'on'
requires_approval = request.form.get('requires_approval') == 'on'
default_tax_rate = request.form.get('default_tax_rate', '').strip()
is_active = request.form.get('is_active') == 'on'
# Create category
category = ExpenseCategory(
name=name,
description=description,
code=code if code else None,
color=color if color else None,
icon=icon if icon else None,
monthly_budget=Decimal(monthly_budget) if monthly_budget else None,
quarterly_budget=Decimal(quarterly_budget) if quarterly_budget else None,
yearly_budget=Decimal(yearly_budget) if yearly_budget else None,
budget_threshold_percent=int(budget_threshold_percent) if budget_threshold_percent else 80,
requires_receipt=requires_receipt,
requires_approval=requires_approval,
default_tax_rate=Decimal(default_tax_rate) if default_tax_rate else None,
is_active=is_active
)
db.session.add(category)
if safe_commit(db):
flash(_('Expense category created successfully'), 'success')
log_event('expense_category_created', user_id=current_user.id, category_id=category.id)
track_event(current_user.id, 'expense_category.created', {'category_id': category.id})
return redirect(url_for('expense_categories.list_categories'))
else:
flash(_('Error creating expense category'), 'error')
return redirect(url_for('expense_categories.create_category'))
except Exception as e:
from flask import current_app
current_app.logger.error(f"Error creating expense category: {e}")
flash(_('Error creating expense category'), 'error')
return redirect(url_for('expense_categories.create_category'))
@expense_categories_bp.route('/expense-categories/<int:category_id>')
@login_required
@admin_or_permission_required('expense_categories.view')
def view_category(category_id):
"""View expense category details"""
category = ExpenseCategory.query.get_or_404(category_id)
from app import track_page_view
track_page_view("expense_category_detail", properties={'category_id': category_id})
# Get budget utilization
monthly_util = category.get_budget_utilization('monthly')
quarterly_util = category.get_budget_utilization('quarterly')
yearly_util = category.get_budget_utilization('yearly')
return render_template(
'expense_categories/view.html',
category=category,
monthly_utilization=monthly_util,
quarterly_utilization=quarterly_util,
yearly_utilization=yearly_util
)
@expense_categories_bp.route('/expense-categories/<int:category_id>/edit', methods=['GET', 'POST'])
@login_required
@admin_or_permission_required('expense_categories.update')
def edit_category(category_id):
"""Edit an expense category"""
category = ExpenseCategory.query.get_or_404(category_id)
if request.method == 'GET':
return render_template('expense_categories/form.html', category=category)
try:
# Get form data
name = request.form.get('name', '').strip()
if not name:
flash(_('Category name is required'), 'error')
return redirect(url_for('expense_categories.edit_category', category_id=category_id))
# Update category fields
category.name = name
category.description = request.form.get('description', '').strip()
category.code = request.form.get('code', '').strip() or None
category.color = request.form.get('color', '').strip() or None
category.icon = request.form.get('icon', '').strip() or None
# Budget fields
monthly_budget = request.form.get('monthly_budget', '').strip()
quarterly_budget = request.form.get('quarterly_budget', '').strip()
yearly_budget = request.form.get('yearly_budget', '').strip()
category.monthly_budget = Decimal(monthly_budget) if monthly_budget else None
category.quarterly_budget = Decimal(quarterly_budget) if quarterly_budget else None
category.yearly_budget = Decimal(yearly_budget) if yearly_budget else None
category.budget_threshold_percent = int(request.form.get('budget_threshold_percent', '80'))
# Settings
category.requires_receipt = request.form.get('requires_receipt') == 'on'
category.requires_approval = request.form.get('requires_approval') == 'on'
default_tax_rate = request.form.get('default_tax_rate', '').strip()
category.default_tax_rate = Decimal(default_tax_rate) if default_tax_rate else None
category.is_active = request.form.get('is_active') == 'on'
category.updated_at = datetime.utcnow()
if safe_commit(db):
flash(_('Expense category updated successfully'), 'success')
log_event('expense_category_updated', user_id=current_user.id, category_id=category.id)
track_event(current_user.id, 'expense_category.updated', {'category_id': category.id})
return redirect(url_for('expense_categories.view_category', category_id=category.id))
else:
flash(_('Error updating expense category'), 'error')
return redirect(url_for('expense_categories.edit_category', category_id=category_id))
except Exception as e:
from flask import current_app
current_app.logger.error(f"Error updating expense category: {e}")
flash(_('Error updating expense category'), 'error')
return redirect(url_for('expense_categories.edit_category', category_id=category_id))
@expense_categories_bp.route('/expense-categories/<int:category_id>/delete', methods=['POST'])
@login_required
@admin_or_permission_required('expense_categories.delete')
def delete_category(category_id):
"""Delete an expense category"""
category = ExpenseCategory.query.get_or_404(category_id)
try:
# Instead of deleting, just deactivate
category.is_active = False
category.updated_at = datetime.utcnow()
if safe_commit(db):
flash(_('Expense category deactivated successfully'), 'success')
log_event('expense_category_deleted', user_id=current_user.id, category_id=category_id)
track_event(current_user.id, 'expense_category.deleted', {'category_id': category_id})
else:
flash(_('Error deactivating expense category'), 'error')
except Exception as e:
from flask import current_app
current_app.logger.error(f"Error deactivating expense category: {e}")
flash(_('Error deactivating expense category'), 'error')
return redirect(url_for('expense_categories.list_categories'))
# API endpoints
@expense_categories_bp.route('/api/expense-categories', methods=['GET'])
@login_required
def api_list_categories():
"""API endpoint to list expense categories"""
categories = ExpenseCategory.get_active_categories()
return jsonify({
'categories': [category.to_dict() for category in categories],
'count': len(categories)
})
@expense_categories_bp.route('/api/expense-categories/<int:category_id>', methods=['GET'])
@login_required
def api_get_category(category_id):
"""API endpoint to get a single expense category"""
category = ExpenseCategory.query.get_or_404(category_id)
return jsonify(category.to_dict())
@expense_categories_bp.route('/api/expense-categories/budget-alerts', methods=['GET'])
@login_required
@admin_or_permission_required('expense_categories.view')
def api_budget_alerts():
"""API endpoint to get categories over budget threshold"""
period = request.args.get('period', 'monthly')
over_budget = ExpenseCategory.get_categories_over_budget(period)
return jsonify({
'period': period,
'alerts': [
{
'category': item['category'].to_dict(),
'utilization': item['utilization']
}
for item in over_budget
],
'count': len(over_budget)
})
+263
View File
@@ -6,10 +6,12 @@ from app.models import Expense, Project, Client, User
from datetime import datetime, date, timedelta
from decimal import Decimal
from app.utils.db import safe_commit
from app.utils.ocr import scan_receipt, get_suggested_expense_data, is_ocr_available
import csv
import io
import os
from werkzeug.utils import secure_filename
import json
expenses_bp = Blueprint('expenses', __name__)
@@ -883,3 +885,264 @@ def api_get_expense(expense_id):
return jsonify(expense.to_dict())
@expenses_bp.route('/api/expenses/scan-receipt', methods=['POST'])
@login_required
def api_scan_receipt():
"""API endpoint to scan a receipt image using OCR"""
if not is_ocr_available():
return jsonify({
'error': 'OCR not available',
'message': 'Please install Tesseract OCR and pytesseract'
}), 503
# Check if file is in request
if 'receipt_file' not in request.files:
return jsonify({'error': 'No file provided'}), 400
file = request.files['receipt_file']
if not file or not file.filename:
return jsonify({'error': 'No file selected'}), 400
if not allowed_file(file.filename):
return jsonify({'error': 'Invalid file type'}), 400
try:
# Save file temporarily
filename = secure_filename(file.filename)
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
filename = f"temp_{timestamp}_{filename}"
temp_dir = os.path.join(current_app.root_path, '..', 'uploads', 'temp')
os.makedirs(temp_dir, exist_ok=True)
temp_path = os.path.join(temp_dir, filename)
file.save(temp_path)
# Scan receipt
ocr_lang = request.form.get('lang', 'eng')
receipt_data = scan_receipt(temp_path, lang=ocr_lang)
# Get suggested expense data
suggestions = get_suggested_expense_data(receipt_data)
# Clean up temp file
try:
os.remove(temp_path)
except Exception:
pass
# Log event
log_event('receipt_scanned', user_id=current_user.id)
track_event(current_user.id, 'receipt.scanned', {
'has_amount': bool(receipt_data.get('total')),
'has_vendor': bool(receipt_data.get('vendor')),
'has_date': bool(receipt_data.get('date'))
})
return jsonify({
'success': True,
'receipt_data': receipt_data,
'suggestions': suggestions
})
except Exception as e:
current_app.logger.error(f"Error scanning receipt: {e}")
return jsonify({
'error': 'Failed to scan receipt',
'message': str(e)
}), 500
@expenses_bp.route('/expenses/scan-receipt', methods=['GET', 'POST'])
@login_required
def scan_receipt_page():
"""Page for scanning receipts with OCR"""
if request.method == 'GET':
return render_template('expenses/scan_receipt.html', ocr_available=is_ocr_available())
# POST - handle receipt scanning
if not is_ocr_available():
flash(_('OCR is not available. Please contact your administrator.'), 'error')
return redirect(url_for('expenses.scan_receipt_page'))
if 'receipt_file' not in request.files:
flash(_('No file provided'), 'error')
return redirect(url_for('expenses.scan_receipt_page'))
file = request.files['receipt_file']
if not file or not file.filename:
flash(_('No file selected'), 'error')
return redirect(url_for('expenses.scan_receipt_page'))
if not allowed_file(file.filename):
flash(_('Invalid file type. Allowed types: png, jpg, jpeg, gif, pdf'), 'error')
return redirect(url_for('expenses.scan_receipt_page'))
try:
# Save file temporarily
filename = secure_filename(file.filename)
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
temp_filename = f"temp_{timestamp}_{filename}"
temp_dir = os.path.join(current_app.root_path, '..', 'uploads', 'temp')
os.makedirs(temp_dir, exist_ok=True)
temp_path = os.path.join(temp_dir, temp_filename)
file.save(temp_path)
# Scan receipt
ocr_lang = request.form.get('lang', 'eng')
receipt_data = scan_receipt(temp_path, lang=ocr_lang)
# Get suggested expense data
suggestions = get_suggested_expense_data(receipt_data)
# Save receipt permanently
filename = f"{timestamp}_{filename}"
upload_dir = os.path.join(current_app.root_path, '..', UPLOAD_FOLDER)
os.makedirs(upload_dir, exist_ok=True)
permanent_path = os.path.join(upload_dir, filename)
os.rename(temp_path, permanent_path)
receipt_path = os.path.join(UPLOAD_FOLDER, filename)
# Store OCR data in session for use in expense creation
from flask import session
session['scanned_receipt'] = {
'receipt_path': receipt_path,
'receipt_data': receipt_data,
'suggestions': suggestions
}
# Log event
log_event('receipt_scanned', user_id=current_user.id)
track_event(current_user.id, 'receipt.scanned', {
'has_amount': bool(receipt_data.get('total')),
'has_vendor': bool(receipt_data.get('vendor')),
'has_date': bool(receipt_data.get('date'))
})
flash(_('Receipt scanned successfully! You can now create an expense with the extracted data.'), 'success')
return redirect(url_for('expenses.create_expense_from_scan'))
except Exception as e:
current_app.logger.error(f"Error scanning receipt: {e}")
flash(_('Error scanning receipt. Please try again or enter the expense manually.'), 'error')
return redirect(url_for('expenses.scan_receipt_page'))
@expenses_bp.route('/expenses/create-from-scan', methods=['GET', 'POST'])
@login_required
def create_expense_from_scan():
"""Create expense from scanned receipt data"""
from flask import session
scanned_data = session.get('scanned_receipt')
if not scanned_data:
flash(_('No scanned receipt data found. Please scan a receipt first.'), 'error')
return redirect(url_for('expenses.scan_receipt_page'))
if request.method == 'GET':
# Get data for form
projects = Project.query.filter_by(status='active').order_by(Project.name).all()
clients = Client.get_active_clients()
categories = Expense.get_expense_categories()
payment_methods = Expense.get_payment_methods()
return render_template(
'expenses/create_from_scan.html',
expense=None,
projects=projects,
clients=clients,
categories=categories,
payment_methods=payment_methods,
suggestions=scanned_data.get('suggestions', {}),
receipt_data=scanned_data.get('receipt_data', {})
)
# POST - create the expense
try:
# Get form data (similar to create_expense)
title = request.form.get('title', '').strip()
description = request.form.get('description', '').strip()
category = request.form.get('category', '').strip()
amount = request.form.get('amount', '0').strip()
currency_code = request.form.get('currency_code', 'EUR').strip()
tax_amount = request.form.get('tax_amount', '0').strip()
expense_date = request.form.get('expense_date', '').strip()
# Validate required fields
if not all([title, category, amount, expense_date]):
flash(_('Please fill in all required fields'), 'error')
return redirect(url_for('expenses.create_expense_from_scan'))
# Parse date
try:
expense_date_obj = datetime.strptime(expense_date, '%Y-%m-%d').date()
except ValueError:
flash(_('Invalid date format'), 'error')
return redirect(url_for('expenses.create_expense_from_scan'))
# Parse amounts
try:
amount_decimal = Decimal(amount)
tax_amount_decimal = Decimal(tax_amount) if tax_amount else Decimal('0')
except (ValueError, Decimal.InvalidOperation):
flash(_('Invalid amount format'), 'error')
return redirect(url_for('expenses.create_expense_from_scan'))
# Create expense with OCR data
expense = Expense(
user_id=current_user.id,
title=title,
category=category,
amount=amount_decimal,
expense_date=expense_date_obj,
description=description,
currency_code=currency_code,
tax_amount=tax_amount_decimal,
project_id=request.form.get('project_id', type=int),
client_id=request.form.get('client_id', type=int),
payment_method=request.form.get('payment_method', '').strip(),
vendor=request.form.get('vendor', '').strip(),
receipt_number=request.form.get('receipt_number', '').strip(),
receipt_path=scanned_data.get('receipt_path'),
notes=request.form.get('notes', '').strip(),
tags=request.form.get('tags', '').strip(),
billable=request.form.get('billable') == 'on',
reimbursable=request.form.get('reimbursable') == 'on'
)
# Store OCR data as JSON
if scanned_data.get('receipt_data'):
# expense.ocr_data = json.dumps(scanned_data['receipt_data']) # Uncomment after migration
pass
db.session.add(expense)
if safe_commit(db):
# Clear scanned data from session
session.pop('scanned_receipt', None)
flash(_('Expense created successfully from scanned receipt'), 'success')
log_event('expense_created_from_scan', user_id=current_user.id, expense_id=expense.id)
track_event(current_user.id, 'expense.created_from_scan', {
'expense_id': expense.id,
'category': category,
'amount': float(amount_decimal)
})
return redirect(url_for('expenses.view_expense', expense_id=expense.id))
else:
flash(_('Error creating expense'), 'error')
return redirect(url_for('expenses.create_expense_from_scan'))
except Exception as e:
current_app.logger.error(f"Error creating expense from scan: {e}")
flash(_('Error creating expense'), 'error')
return redirect(url_for('expenses.create_expense_from_scan'))
+650
View File
@@ -0,0 +1,650 @@
"""
Import/Export routes for data migration and GDPR compliance
"""
from flask import Blueprint, jsonify, request, send_file, current_app, render_template
from flask_login import login_required, current_user
from werkzeug.utils import secure_filename
from app import db
from app.models import DataImport, DataExport, User
from app.utils.data_import import (
import_csv_time_entries,
import_from_toggl,
import_from_harvest,
restore_from_backup,
ImportError as DataImportError
)
from app.utils.data_export import (
export_user_data_gdpr,
export_filtered_data,
create_backup
)
from datetime import datetime, timedelta
import os
import json
import_export_bp = Blueprint('import_export', __name__)
# ============================================================================
# Import Routes
# ============================================================================
@import_export_bp.route('/import-export')
@login_required
def import_export_page():
"""Render the import/export page"""
return render_template('import_export/index.html')
@import_export_bp.route('/api/import/csv', methods=['POST'])
@login_required
def import_csv():
"""
Import time entries from CSV file
Expected multipart/form-data with 'file' field
"""
if 'file' not in request.files:
return jsonify({'error': 'No file provided'}), 400
file = request.files['file']
if file.filename == '':
return jsonify({'error': 'No file selected'}), 400
if not file.filename.endswith('.csv'):
return jsonify({'error': 'File must be a CSV'}), 400
try:
# Read file content
csv_content = file.read().decode('utf-8')
# Create import record
import_record = DataImport(
user_id=current_user.id,
import_type='csv',
source_file=secure_filename(file.filename)
)
db.session.add(import_record)
db.session.commit()
# Perform import
summary = import_csv_time_entries(
user_id=current_user.id,
csv_content=csv_content,
import_record=import_record
)
return jsonify({
'success': True,
'import_id': import_record.id,
'summary': summary
}), 200
except DataImportError as e:
return jsonify({'error': str(e)}), 400
except Exception as e:
current_app.logger.error(f"CSV import error: {str(e)}")
return jsonify({'error': 'Import failed. Please check the file format.'}), 500
@import_export_bp.route('/api/import/toggl', methods=['POST'])
@login_required
def import_toggl():
"""
Import time entries from Toggl Track
Expected JSON body:
{
"api_token": "...",
"workspace_id": "...",
"start_date": "2024-01-01",
"end_date": "2024-12-31"
}
"""
data = request.get_json()
if not data:
return jsonify({'error': 'No data provided'}), 400
api_token = data.get('api_token')
workspace_id = data.get('workspace_id')
start_date_str = data.get('start_date')
end_date_str = data.get('end_date')
if not all([api_token, workspace_id, start_date_str, end_date_str]):
return jsonify({'error': 'Missing required fields'}), 400
try:
# Parse dates
start_date = datetime.strptime(start_date_str, '%Y-%m-%d')
end_date = datetime.strptime(end_date_str, '%Y-%m-%d')
# Create import record
import_record = DataImport(
user_id=current_user.id,
import_type='toggl',
source_file=f'Toggl Workspace {workspace_id}'
)
db.session.add(import_record)
db.session.commit()
# Perform import
summary = import_from_toggl(
user_id=current_user.id,
api_token=api_token,
workspace_id=workspace_id,
start_date=start_date,
end_date=end_date,
import_record=import_record
)
return jsonify({
'success': True,
'import_id': import_record.id,
'summary': summary
}), 200
except DataImportError as e:
return jsonify({'error': str(e)}), 400
except Exception as e:
current_app.logger.error(f"Toggl import error: {str(e)}")
return jsonify({'error': 'Import failed. Please check your credentials and try again.'}), 500
@import_export_bp.route('/api/import/harvest', methods=['POST'])
@login_required
def import_harvest():
"""
Import time entries from Harvest
Expected JSON body:
{
"account_id": "...",
"api_token": "...",
"start_date": "2024-01-01",
"end_date": "2024-12-31"
}
"""
data = request.get_json()
if not data:
return jsonify({'error': 'No data provided'}), 400
account_id = data.get('account_id')
api_token = data.get('api_token')
start_date_str = data.get('start_date')
end_date_str = data.get('end_date')
if not all([account_id, api_token, start_date_str, end_date_str]):
return jsonify({'error': 'Missing required fields'}), 400
try:
# Parse dates
start_date = datetime.strptime(start_date_str, '%Y-%m-%d')
end_date = datetime.strptime(end_date_str, '%Y-%m-%d')
# Create import record
import_record = DataImport(
user_id=current_user.id,
import_type='harvest',
source_file=f'Harvest Account {account_id}'
)
db.session.add(import_record)
db.session.commit()
# Perform import
summary = import_from_harvest(
user_id=current_user.id,
account_id=account_id,
api_token=api_token,
start_date=start_date,
end_date=end_date,
import_record=import_record
)
return jsonify({
'success': True,
'import_id': import_record.id,
'summary': summary
}), 200
except DataImportError as e:
return jsonify({'error': str(e)}), 400
except Exception as e:
current_app.logger.error(f"Harvest import error: {str(e)}")
return jsonify({'error': 'Import failed. Please check your credentials and try again.'}), 500
@import_export_bp.route('/api/import/status/<int:import_id>')
@login_required
def import_status(import_id):
"""Get status of an import operation"""
import_record = DataImport.query.get_or_404(import_id)
# Check permissions
if not current_user.is_admin and import_record.user_id != current_user.id:
return jsonify({'error': 'Unauthorized'}), 403
return jsonify(import_record.to_dict()), 200
@import_export_bp.route('/api/import/history')
@login_required
def import_history():
"""Get import history for current user"""
if current_user.is_admin:
imports = DataImport.query.order_by(DataImport.started_at.desc()).limit(50).all()
else:
imports = DataImport.query.filter_by(user_id=current_user.id).order_by(
DataImport.started_at.desc()
).limit(50).all()
return jsonify({
'imports': [imp.to_dict() for imp in imports]
}), 200
# ============================================================================
# Export Routes
# ============================================================================
@import_export_bp.route('/api/export/gdpr', methods=['POST'])
@login_required
def export_gdpr():
"""
Export all user data for GDPR compliance
Expected JSON body:
{
"format": "json" | "zip"
}
"""
data = request.get_json() or {}
export_format = data.get('format', 'json')
if export_format not in ['json', 'zip']:
return jsonify({'error': 'Invalid format. Use "json" or "zip"'}), 400
try:
# Create export record
export_record = DataExport(
user_id=current_user.id,
export_type='gdpr',
export_format=export_format
)
db.session.add(export_record)
db.session.commit()
export_record.start_processing()
# Perform export
result = export_user_data_gdpr(
user_id=current_user.id,
export_format=export_format
)
export_record.complete(
file_path=result['filepath'],
file_size=result['file_size'],
record_count=result['record_count']
)
return jsonify({
'success': True,
'export_id': export_record.id,
'filename': result['filename'],
'download_url': f'/api/export/download/{export_record.id}'
}), 200
except Exception as e:
current_app.logger.error(f"GDPR export error: {str(e)}")
if 'export_record' in locals():
export_record.fail(str(e))
return jsonify({'error': 'Export failed. Please try again.'}), 500
@import_export_bp.route('/api/export/filtered', methods=['POST'])
@login_required
def export_filtered():
"""
Export filtered data
Expected JSON body:
{
"format": "json" | "csv",
"filters": {
"include_time_entries": true,
"include_projects": false,
"include_expenses": true,
"start_date": "2024-01-01",
"end_date": "2024-12-31",
"project_id": null,
"billable_only": false
}
}
"""
data = request.get_json()
if not data:
return jsonify({'error': 'No data provided'}), 400
export_format = data.get('format', 'json')
filters = data.get('filters', {})
if export_format not in ['json', 'csv']:
return jsonify({'error': 'Invalid format. Use "json" or "csv"'}), 400
try:
# Create export record
export_record = DataExport(
user_id=current_user.id,
export_type='filtered',
export_format=export_format,
filters=filters
)
db.session.add(export_record)
db.session.commit()
export_record.start_processing()
# Perform export
result = export_filtered_data(
user_id=current_user.id,
filters=filters,
export_format=export_format
)
export_record.complete(
file_path=result['filepath'],
file_size=result['file_size'],
record_count=result['record_count']
)
return jsonify({
'success': True,
'export_id': export_record.id,
'filename': result['filename'],
'download_url': f'/api/export/download/{export_record.id}'
}), 200
except Exception as e:
current_app.logger.error(f"Filtered export error: {str(e)}")
if 'export_record' in locals():
export_record.fail(str(e))
return jsonify({'error': 'Export failed. Please try again.'}), 500
@import_export_bp.route('/api/export/backup', methods=['POST'])
@login_required
def export_backup():
"""
Create a full database backup (admin only)
"""
if not current_user.is_admin:
return jsonify({'error': 'Admin access required'}), 403
try:
# Create export record
export_record = DataExport(
user_id=current_user.id,
export_type='backup',
export_format='json'
)
db.session.add(export_record)
db.session.commit()
export_record.start_processing()
# Create backup
result = create_backup(user_id=current_user.id)
export_record.complete(
file_path=result['filepath'],
file_size=result['file_size'],
record_count=result['record_count']
)
return jsonify({
'success': True,
'export_id': export_record.id,
'filename': result['filename'],
'download_url': f'/api/export/download/{export_record.id}'
}), 200
except Exception as e:
current_app.logger.error(f"Backup creation error: {str(e)}")
if 'export_record' in locals():
export_record.fail(str(e))
return jsonify({'error': 'Backup failed. Please try again.'}), 500
@import_export_bp.route('/api/export/download/<int:export_id>')
@login_required
def download_export(export_id):
"""Download an export file"""
export_record = DataExport.query.get_or_404(export_id)
# Check permissions
if not current_user.is_admin and export_record.user_id != current_user.id:
return jsonify({'error': 'Unauthorized'}), 403
# Check if export is complete
if export_record.status != 'completed':
return jsonify({'error': 'Export is not ready yet'}), 400
# Check if file exists
if not export_record.file_path or not os.path.exists(export_record.file_path):
return jsonify({'error': 'Export file not found'}), 404
# Check if expired
if export_record.is_expired():
return jsonify({'error': 'Export has expired'}), 410
return send_file(
export_record.file_path,
as_attachment=True,
download_name=os.path.basename(export_record.file_path)
)
@import_export_bp.route('/api/export/status/<int:export_id>')
@login_required
def export_status(export_id):
"""Get status of an export operation"""
export_record = DataExport.query.get_or_404(export_id)
# Check permissions
if not current_user.is_admin and export_record.user_id != current_user.id:
return jsonify({'error': 'Unauthorized'}), 403
return jsonify(export_record.to_dict()), 200
@import_export_bp.route('/api/export/history')
@login_required
def export_history():
"""Get export history for current user"""
if current_user.is_admin:
exports = DataExport.query.order_by(DataExport.created_at.desc()).limit(50).all()
else:
exports = DataExport.query.filter_by(user_id=current_user.id).order_by(
DataExport.created_at.desc()
).limit(50).all()
return jsonify({
'exports': [exp.to_dict() for exp in exports]
}), 200
# ============================================================================
# Backup/Restore Routes
# ============================================================================
@import_export_bp.route('/api/backup/restore', methods=['POST'])
@login_required
def restore_backup():
"""
Restore from backup file (admin only)
Expected multipart/form-data with 'file' field
"""
if not current_user.is_admin:
return jsonify({'error': 'Admin access required'}), 403
if 'file' not in request.files:
return jsonify({'error': 'No file provided'}), 400
file = request.files['file']
if file.filename == '':
return jsonify({'error': 'No file selected'}), 400
if not file.filename.endswith('.json'):
return jsonify({'error': 'File must be a JSON backup file'}), 400
try:
# Save uploaded file temporarily
backup_dir = os.path.join(current_app.config.get('UPLOAD_FOLDER', '/data/uploads'), 'backups')
os.makedirs(backup_dir, exist_ok=True)
filename = secure_filename(file.filename)
filepath = os.path.join(backup_dir, f'restore_{filename}')
file.save(filepath)
# Create import record
import_record = DataImport(
user_id=current_user.id,
import_type='backup',
source_file=filename
)
db.session.add(import_record)
db.session.commit()
# Perform restore
statistics = restore_from_backup(
user_id=current_user.id,
backup_file_path=filepath
)
# Clean up temporary file
os.remove(filepath)
return jsonify({
'success': True,
'import_id': import_record.id,
'statistics': statistics,
'message': 'Backup restored successfully'
}), 200
except DataImportError as e:
return jsonify({'error': str(e)}), 400
except Exception as e:
current_app.logger.error(f"Backup restore error: {str(e)}")
return jsonify({'error': 'Restore failed. Please check the backup file.'}), 500
# ============================================================================
# Migration Wizard Routes
# ============================================================================
@import_export_bp.route('/api/migration/wizard/start', methods=['POST'])
@login_required
def start_migration_wizard():
"""
Start the migration wizard
Expected JSON body:
{
"source": "toggl" | "harvest" | "csv",
"credentials": {...},
"options": {...}
}
"""
data = request.get_json()
if not data:
return jsonify({'error': 'No data provided'}), 400
source = data.get('source')
if source not in ['toggl', 'harvest', 'csv']:
return jsonify({'error': 'Invalid source'}), 400
# Store wizard state in session or return wizard ID
wizard_id = f"wizard_{current_user.id}_{datetime.utcnow().timestamp()}"
return jsonify({
'success': True,
'wizard_id': wizard_id,
'next_step': 'credentials',
'message': f'Migration wizard started for {source}'
}), 200
@import_export_bp.route('/api/migration/wizard/<wizard_id>/preview', methods=['POST'])
@login_required
def preview_migration(wizard_id):
"""
Preview data before importing
This would fetch a small sample of data to show the user what will be imported
"""
data = request.get_json()
# Implementation would depend on the source
# For now, return a mock preview
return jsonify({
'success': True,
'preview': {
'sample_entries': [],
'total_count': 0,
'date_range': {}
}
}), 200
@import_export_bp.route('/api/migration/wizard/<wizard_id>/execute', methods=['POST'])
@login_required
def execute_migration(wizard_id):
"""
Execute the migration after preview
"""
data = request.get_json()
# This would trigger the actual import based on the wizard configuration
return jsonify({
'success': True,
'message': 'Migration started',
'import_id': None
}), 200
# ============================================================================
# Template Endpoints
# ============================================================================
@import_export_bp.route('/api/import/template/csv')
@login_required
def download_csv_template():
"""Download CSV import template"""
template_content = """project_name,client_name,task_name,start_time,end_time,duration_hours,notes,tags,billable
Example Project,Example Client,Example Task,2024-01-01 09:00:00,2024-01-01 10:30:00,1.5,Meeting with client,meeting;client,true
Another Project,Another Client,,2024-01-01 14:00:00,2024-01-01 16:00:00,2.0,Development work,dev;coding,true
"""
from io import BytesIO
buffer = BytesIO()
buffer.write(template_content.encode('utf-8'))
buffer.seek(0)
return send_file(
buffer,
mimetype='text/csv',
as_attachment=True,
download_name='timetracker_import_template.csv'
)
+32
View File
@@ -9,6 +9,7 @@ import io
import csv
import json
from app.utils.db import safe_commit
from app.utils.excel_export import create_invoices_list_excel
from app.utils.posthog_funnels import (
track_invoice_page_viewed,
track_invoice_project_selected,
@@ -684,3 +685,34 @@ def duplicate_invoice(invoice_id):
flash(f'Invoice {new_invoice_number} created as duplicate', 'success')
return redirect(url_for('invoices.edit_invoice', invoice_id=new_invoice.id))
@invoices_bp.route('/invoices/export/excel')
@login_required
def export_invoices_excel():
"""Export invoice list as Excel file"""
# Get invoices (scope by user unless admin)
if current_user.is_admin:
invoices = Invoice.query.order_by(Invoice.created_at.desc()).all()
else:
invoices = Invoice.query.filter_by(created_by=current_user.id).order_by(Invoice.created_at.desc()).all()
# Create Excel file
output, filename = create_invoices_list_excel(invoices)
# Track Excel export event
log_event("export.excel",
user_id=current_user.id,
export_type="invoices_list",
num_rows=len(invoices))
track_event(current_user.id, "export.excel", {
"export_type": "invoices_list",
"num_rows": len(invoices)
})
return send_file(
output,
mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
as_attachment=True,
download_name=filename
)
+16 -2
View File
@@ -1,6 +1,6 @@
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, WeeklyTimeGoal
from app.models import User, Project, TimeEntry, Settings, WeeklyTimeGoal, TimeEntryTemplate, Activity
from datetime import datetime, timedelta
import pytz
from app import db, track_page_view
@@ -78,6 +78,18 @@ def dashboard():
current_week_goal = WeeklyTimeGoal.get_current_week_goal(current_user.id)
if current_week_goal:
current_week_goal.update_status()
# Get user's time entry templates (most recently used first)
from sqlalchemy import desc
templates = TimeEntryTemplate.query.filter_by(
user_id=current_user.id
).order_by(desc(TimeEntryTemplate.last_used_at)).limit(5).all()
# Get recent activities for activity feed widget
recent_activities = Activity.get_recent(
user_id=None if current_user.is_admin else current_user.id,
limit=10
)
return render_template('main/dashboard.html',
active_timer=active_timer,
@@ -87,7 +99,9 @@ def dashboard():
week_hours=week_hours,
month_hours=month_hours,
top_projects=top_projects,
current_week_goal=current_week_goal)
current_week_goal=current_week_goal,
templates=templates,
recent_activities=recent_activities)
@main_bp.route('/_health')
def health_check():
+466
View File
@@ -0,0 +1,466 @@
from flask import Blueprint, render_template, request, redirect, url_for, flash, jsonify, send_file, current_app
from flask_babel import gettext as _
from flask_login import login_required, current_user
from app import db, log_event, track_event
from app.models import Mileage, Project, Client, Expense
from datetime import datetime, date, timedelta
from decimal import Decimal
from app.utils.db import safe_commit
import csv
import io
mileage_bp = Blueprint('mileage', __name__)
@mileage_bp.route('/mileage')
@login_required
def list_mileage():
"""List all mileage entries with filters"""
from app import track_page_view
track_page_view("mileage_list")
page = request.args.get('page', 1, type=int)
per_page = request.args.get('per_page', 25, type=int)
# Filter parameters
status = request.args.get('status', '').strip()
project_id = request.args.get('project_id', type=int)
client_id = request.args.get('client_id', type=int)
start_date = request.args.get('start_date', '').strip()
end_date = request.args.get('end_date', '').strip()
search = request.args.get('search', '').strip()
# Build query
query = Mileage.query
# Non-admin users can only see their own mileage or mileage they approved
if not current_user.is_admin:
query = query.filter(
db.or_(
Mileage.user_id == current_user.id,
Mileage.approved_by == current_user.id
)
)
# Apply filters
if status:
query = query.filter(Mileage.status == status)
if project_id:
query = query.filter(Mileage.project_id == project_id)
if client_id:
query = query.filter(Mileage.client_id == client_id)
if start_date:
try:
start = datetime.strptime(start_date, '%Y-%m-%d').date()
query = query.filter(Mileage.trip_date >= start)
except ValueError:
pass
if end_date:
try:
end = datetime.strptime(end_date, '%Y-%m-%d').date()
query = query.filter(Mileage.trip_date <= end)
except ValueError:
pass
if search:
like = f"%{search}%"
query = query.filter(
db.or_(
Mileage.purpose.ilike(like),
Mileage.description.ilike(like),
Mileage.start_location.ilike(like),
Mileage.end_location.ilike(like)
)
)
# Paginate
mileage_pagination = query.order_by(Mileage.trip_date.desc()).paginate(
page=page,
per_page=per_page,
error_out=False
)
# Get filter options
projects = Project.query.filter_by(status='active').order_by(Project.name).all()
clients = Client.get_active_clients()
# Calculate totals
total_distance = Mileage.get_total_distance(
user_id=None if current_user.is_admin else current_user.id,
start_date=datetime.strptime(start_date, '%Y-%m-%d').date() if start_date else None,
end_date=datetime.strptime(end_date, '%Y-%m-%d').date() if end_date else None
)
total_amount_query = db.session.query(
db.func.sum(Mileage.calculated_amount * db.case(
(Mileage.is_round_trip, 2),
else_=1
))
).filter(Mileage.status.in_(['approved', 'reimbursed']))
if not current_user.is_admin:
total_amount_query = total_amount_query.filter(Mileage.user_id == current_user.id)
total_amount = total_amount_query.scalar() or 0
return render_template(
'mileage/list.html',
mileage_entries=mileage_pagination.items,
pagination=mileage_pagination,
projects=projects,
clients=clients,
total_distance=total_distance,
total_amount=float(total_amount),
# Pass back filter values
status=status,
project_id=project_id,
client_id=client_id,
start_date=start_date,
end_date=end_date,
search=search
)
@mileage_bp.route('/mileage/create', methods=['GET', 'POST'])
@login_required
def create_mileage():
"""Create a new mileage entry"""
if request.method == 'GET':
projects = Project.query.filter_by(status='active').order_by(Project.name).all()
clients = Client.get_active_clients()
default_rates = Mileage.get_default_rates()
return render_template(
'mileage/form.html',
mileage=None,
projects=projects,
clients=clients,
default_rates=default_rates
)
try:
# Get form data
trip_date = request.form.get('trip_date', '').strip()
purpose = request.form.get('purpose', '').strip()
description = request.form.get('description', '').strip()
start_location = request.form.get('start_location', '').strip()
end_location = request.form.get('end_location', '').strip()
distance_km = request.form.get('distance_km', '').strip()
rate_per_km = request.form.get('rate_per_km', '').strip()
# Validate required fields
if not all([trip_date, purpose, start_location, end_location, distance_km, rate_per_km]):
flash(_('Please fill in all required fields'), 'error')
return redirect(url_for('mileage.create_mileage'))
# Parse date
try:
trip_date_obj = datetime.strptime(trip_date, '%Y-%m-%d').date()
except ValueError:
flash(_('Invalid date format'), 'error')
return redirect(url_for('mileage.create_mileage'))
# Create mileage entry
mileage = Mileage(
user_id=current_user.id,
trip_date=trip_date_obj,
purpose=purpose,
start_location=start_location,
end_location=end_location,
distance_km=Decimal(distance_km),
rate_per_km=Decimal(rate_per_km),
description=description,
project_id=request.form.get('project_id', type=int),
client_id=request.form.get('client_id', type=int),
start_odometer=request.form.get('start_odometer'),
end_odometer=request.form.get('end_odometer'),
vehicle_type=request.form.get('vehicle_type'),
vehicle_description=request.form.get('vehicle_description'),
license_plate=request.form.get('license_plate'),
is_round_trip=request.form.get('is_round_trip') == 'on',
currency_code=request.form.get('currency_code', 'EUR'),
notes=request.form.get('notes')
)
db.session.add(mileage)
# Create expense if requested
if request.form.get('create_expense') == 'on':
expense = mileage.create_expense()
if expense:
db.session.add(expense)
if safe_commit(db):
flash(_('Mileage entry created successfully'), 'success')
log_event('mileage_created', user_id=current_user.id, mileage_id=mileage.id)
track_event(current_user.id, 'mileage.created', {
'mileage_id': mileage.id,
'distance_km': float(distance_km),
'amount': float(mileage.total_amount)
})
return redirect(url_for('mileage.view_mileage', mileage_id=mileage.id))
else:
flash(_('Error creating mileage entry'), 'error')
return redirect(url_for('mileage.create_mileage'))
except Exception as e:
current_app.logger.error(f"Error creating mileage entry: {e}")
flash(_('Error creating mileage entry'), 'error')
return redirect(url_for('mileage.create_mileage'))
@mileage_bp.route('/mileage/<int:mileage_id>')
@login_required
def view_mileage(mileage_id):
"""View mileage entry details"""
mileage = Mileage.query.get_or_404(mileage_id)
# Check permission
if not current_user.is_admin and mileage.user_id != current_user.id and mileage.approved_by != current_user.id:
flash(_('You do not have permission to view this mileage entry'), 'error')
return redirect(url_for('mileage.list_mileage'))
from app import track_page_view
track_page_view("mileage_detail", properties={'mileage_id': mileage_id})
return render_template('mileage/view.html', mileage=mileage)
@mileage_bp.route('/mileage/<int:mileage_id>/edit', methods=['GET', 'POST'])
@login_required
def edit_mileage(mileage_id):
"""Edit a mileage entry"""
mileage = Mileage.query.get_or_404(mileage_id)
# Check permission
if not current_user.is_admin and mileage.user_id != current_user.id:
flash(_('You do not have permission to edit this mileage entry'), 'error')
return redirect(url_for('mileage.view_mileage', mileage_id=mileage_id))
# Cannot edit approved or reimbursed entries without admin privileges
if not current_user.is_admin and mileage.status in ['approved', 'reimbursed']:
flash(_('Cannot edit approved or reimbursed mileage entries'), 'error')
return redirect(url_for('mileage.view_mileage', mileage_id=mileage_id))
if request.method == 'GET':
projects = Project.query.filter_by(status='active').order_by(Project.name).all()
clients = Client.get_active_clients()
default_rates = Mileage.get_default_rates()
return render_template(
'mileage/form.html',
mileage=mileage,
projects=projects,
clients=clients,
default_rates=default_rates
)
try:
# Update fields
trip_date = request.form.get('trip_date', '').strip()
mileage.trip_date = datetime.strptime(trip_date, '%Y-%m-%d').date()
mileage.purpose = request.form.get('purpose', '').strip()
mileage.description = request.form.get('description', '').strip()
mileage.start_location = request.form.get('start_location', '').strip()
mileage.end_location = request.form.get('end_location', '').strip()
mileage.distance_km = Decimal(request.form.get('distance_km', '0'))
mileage.rate_per_km = Decimal(request.form.get('rate_per_km', '0'))
mileage.calculated_amount = mileage.distance_km * mileage.rate_per_km
mileage.project_id = request.form.get('project_id', type=int)
mileage.client_id = request.form.get('client_id', type=int)
mileage.vehicle_type = request.form.get('vehicle_type')
mileage.vehicle_description = request.form.get('vehicle_description')
mileage.license_plate = request.form.get('license_plate')
mileage.is_round_trip = request.form.get('is_round_trip') == 'on'
mileage.currency_code = request.form.get('currency_code', 'EUR')
mileage.notes = request.form.get('notes')
mileage.updated_at = datetime.utcnow()
if safe_commit(db):
flash(_('Mileage entry updated successfully'), 'success')
log_event('mileage_updated', user_id=current_user.id, mileage_id=mileage.id)
track_event(current_user.id, 'mileage.updated', {'mileage_id': mileage.id})
return redirect(url_for('mileage.view_mileage', mileage_id=mileage.id))
else:
flash(_('Error updating mileage entry'), 'error')
return redirect(url_for('mileage.edit_mileage', mileage_id=mileage_id))
except Exception as e:
current_app.logger.error(f"Error updating mileage entry: {e}")
flash(_('Error updating mileage entry'), 'error')
return redirect(url_for('mileage.edit_mileage', mileage_id=mileage_id))
@mileage_bp.route('/mileage/<int:mileage_id>/delete', methods=['POST'])
@login_required
def delete_mileage(mileage_id):
"""Delete a mileage entry"""
mileage = Mileage.query.get_or_404(mileage_id)
# Check permission
if not current_user.is_admin and mileage.user_id != current_user.id:
flash(_('You do not have permission to delete this mileage entry'), 'error')
return redirect(url_for('mileage.view_mileage', mileage_id=mileage_id))
try:
db.session.delete(mileage)
if safe_commit(db):
flash(_('Mileage entry deleted successfully'), 'success')
log_event('mileage_deleted', user_id=current_user.id, mileage_id=mileage_id)
track_event(current_user.id, 'mileage.deleted', {'mileage_id': mileage_id})
else:
flash(_('Error deleting mileage entry'), 'error')
except Exception as e:
current_app.logger.error(f"Error deleting mileage entry: {e}")
flash(_('Error deleting mileage entry'), 'error')
return redirect(url_for('mileage.list_mileage'))
@mileage_bp.route('/mileage/<int:mileage_id>/approve', methods=['POST'])
@login_required
def approve_mileage(mileage_id):
"""Approve a mileage entry"""
if not current_user.is_admin:
flash(_('Only administrators can approve mileage entries'), 'error')
return redirect(url_for('mileage.view_mileage', mileage_id=mileage_id))
mileage = Mileage.query.get_or_404(mileage_id)
if mileage.status != 'pending':
flash(_('Only pending mileage entries can be approved'), 'error')
return redirect(url_for('mileage.view_mileage', mileage_id=mileage_id))
try:
notes = request.form.get('approval_notes', '').strip()
mileage.approve(current_user.id, notes)
if safe_commit(db):
flash(_('Mileage entry approved successfully'), 'success')
log_event('mileage_approved', user_id=current_user.id, mileage_id=mileage_id)
track_event(current_user.id, 'mileage.approved', {'mileage_id': mileage_id})
else:
flash(_('Error approving mileage entry'), 'error')
except Exception as e:
current_app.logger.error(f"Error approving mileage entry: {e}")
flash(_('Error approving mileage entry'), 'error')
return redirect(url_for('mileage.view_mileage', mileage_id=mileage_id))
@mileage_bp.route('/mileage/<int:mileage_id>/reject', methods=['POST'])
@login_required
def reject_mileage(mileage_id):
"""Reject a mileage entry"""
if not current_user.is_admin:
flash(_('Only administrators can reject mileage entries'), 'error')
return redirect(url_for('mileage.view_mileage', mileage_id=mileage_id))
mileage = Mileage.query.get_or_404(mileage_id)
if mileage.status != 'pending':
flash(_('Only pending mileage entries can be rejected'), 'error')
return redirect(url_for('mileage.view_mileage', mileage_id=mileage_id))
try:
reason = request.form.get('rejection_reason', '').strip()
if not reason:
flash(_('Rejection reason is required'), 'error')
return redirect(url_for('mileage.view_mileage', mileage_id=mileage_id))
mileage.reject(current_user.id, reason)
if safe_commit(db):
flash(_('Mileage entry rejected'), 'success')
log_event('mileage_rejected', user_id=current_user.id, mileage_id=mileage_id)
track_event(current_user.id, 'mileage.rejected', {'mileage_id': mileage_id})
else:
flash(_('Error rejecting mileage entry'), 'error')
except Exception as e:
current_app.logger.error(f"Error rejecting mileage entry: {e}")
flash(_('Error rejecting mileage entry'), 'error')
return redirect(url_for('mileage.view_mileage', mileage_id=mileage_id))
@mileage_bp.route('/mileage/<int:mileage_id>/reimburse', methods=['POST'])
@login_required
def mark_reimbursed(mileage_id):
"""Mark a mileage entry as reimbursed"""
if not current_user.is_admin:
flash(_('Only administrators can mark mileage entries as reimbursed'), 'error')
return redirect(url_for('mileage.view_mileage', mileage_id=mileage_id))
mileage = Mileage.query.get_or_404(mileage_id)
if mileage.status != 'approved':
flash(_('Only approved mileage entries can be marked as reimbursed'), 'error')
return redirect(url_for('mileage.view_mileage', mileage_id=mileage_id))
try:
mileage.mark_as_reimbursed()
if safe_commit(db):
flash(_('Mileage entry marked as reimbursed'), 'success')
log_event('mileage_reimbursed', user_id=current_user.id, mileage_id=mileage_id)
track_event(current_user.id, 'mileage.reimbursed', {'mileage_id': mileage_id})
else:
flash(_('Error marking mileage entry as reimbursed'), 'error')
except Exception as e:
current_app.logger.error(f"Error marking mileage entry as reimbursed: {e}")
flash(_('Error marking mileage entry as reimbursed'), 'error')
return redirect(url_for('mileage.view_mileage', mileage_id=mileage_id))
# API endpoints
@mileage_bp.route('/api/mileage', methods=['GET'])
@login_required
def api_list_mileage():
"""API endpoint to list mileage entries"""
status = request.args.get('status', '').strip()
query = Mileage.query
if not current_user.is_admin:
query = query.filter_by(user_id=current_user.id)
if status:
query = query.filter(Mileage.status == status)
entries = query.order_by(Mileage.trip_date.desc()).all()
return jsonify({
'mileage': [entry.to_dict() for entry in entries],
'count': len(entries)
})
@mileage_bp.route('/api/mileage/<int:mileage_id>', methods=['GET'])
@login_required
def api_get_mileage(mileage_id):
"""API endpoint to get a single mileage entry"""
mileage = Mileage.query.get_or_404(mileage_id)
# Check permission
if not current_user.is_admin and mileage.user_id != current_user.id:
return jsonify({'error': 'Permission denied'}), 403
return jsonify(mileage.to_dict())
@mileage_bp.route('/api/mileage/default-rates', methods=['GET'])
@login_required
def api_get_default_rates():
"""API endpoint to get default mileage rates"""
return jsonify(Mileage.get_default_rates())
+70
View File
@@ -6,7 +6,9 @@ from app.models import Payment, Invoice, User, Client
from datetime import datetime, date
from decimal import Decimal, InvalidOperation
from sqlalchemy import func, and_, or_
from flask import send_file
from app.utils.db import safe_commit
from app.utils.excel_export import create_payments_list_excel
payments_bp = Blueprint('payments', __name__)
@@ -469,6 +471,74 @@ def payment_stats():
return jsonify(stats)
@payments_bp.route('/payments/export/excel')
@login_required
def export_payments_excel():
"""Export payments list as Excel file"""
# Get filter parameters
status_filter = request.args.get('status', '')
method_filter = request.args.get('method', '')
date_from = request.args.get('date_from', '')
date_to = request.args.get('date_to', '')
invoice_id = request.args.get('invoice_id', type=int)
# Base query
query = Payment.query
# Apply filters based on user role
if not current_user.is_admin:
# Regular users can only see payments for their own invoices
query = query.join(Invoice).filter(Invoice.created_by == current_user.id)
# Apply additional filters
if status_filter:
query = query.filter(Payment.status == status_filter)
if method_filter:
query = query.filter(Payment.method == method_filter)
if date_from:
try:
date_from_obj = datetime.strptime(date_from, '%Y-%m-%d').date()
query = query.filter(Payment.payment_date >= date_from_obj)
except ValueError:
pass
if date_to:
try:
date_to_obj = datetime.strptime(date_to, '%Y-%m-%d').date()
query = query.filter(Payment.payment_date <= date_to_obj)
except ValueError:
pass
if invoice_id:
query = query.filter(Payment.invoice_id == invoice_id)
# Get payments
payments = query.order_by(Payment.payment_date.desc()).all()
# Create Excel file
output, filename = create_payments_list_excel(payments)
# Track Excel export event
log_event("export.excel",
user_id=current_user.id,
export_type="payments_list",
num_rows=len(payments))
track_event(current_user.id, "export.excel", {
"export_type": "payments_list",
"num_rows": len(payments)
})
return send_file(
output,
mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
as_attachment=True,
download_name=filename
)
def get_user_invoices():
"""Get invoices accessible by current user"""
if current_user.is_admin:
+542
View File
@@ -0,0 +1,542 @@
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, log_event, track_event
from app.models import PerDiem, PerDiemRate, Project, Client
from datetime import datetime, date, time
from decimal import Decimal
from app.utils.db import safe_commit
from app.utils.permissions import admin_or_permission_required
per_diem_bp = Blueprint('per_diem', __name__)
@per_diem_bp.route('/per-diem')
@login_required
def list_per_diem():
"""List all per diem claims with filters"""
from app import track_page_view
track_page_view("per_diem_list")
page = request.args.get('page', 1, type=int)
per_page = request.args.get('per_page', 25, type=int)
# Filter parameters
status = request.args.get('status', '').strip()
project_id = request.args.get('project_id', type=int)
client_id = request.args.get('client_id', type=int)
start_date = request.args.get('start_date', '').strip()
end_date = request.args.get('end_date', '').strip()
# Build query
query = PerDiem.query
# Non-admin users can only see their own claims
if not current_user.is_admin:
query = query.filter(
db.or_(
PerDiem.user_id == current_user.id,
PerDiem.approved_by == current_user.id
)
)
# Apply filters
if status:
query = query.filter(PerDiem.status == status)
if project_id:
query = query.filter(PerDiem.project_id == project_id)
if client_id:
query = query.filter(PerDiem.client_id == client_id)
if start_date:
try:
start = datetime.strptime(start_date, '%Y-%m-%d').date()
query = query.filter(PerDiem.start_date >= start)
except ValueError:
pass
if end_date:
try:
end = datetime.strptime(end_date, '%Y-%m-%d').date()
query = query.filter(PerDiem.end_date <= end)
except ValueError:
pass
# Paginate
per_diem_pagination = query.order_by(PerDiem.start_date.desc()).paginate(
page=page,
per_page=per_page,
error_out=False
)
# Get filter options
projects = Project.query.filter_by(status='active').order_by(Project.name).all()
clients = Client.get_active_clients()
# Calculate totals
total_amount_query = db.session.query(
db.func.sum(PerDiem.calculated_amount)
).filter(PerDiem.status.in_(['approved', 'reimbursed']))
if not current_user.is_admin:
total_amount_query = total_amount_query.filter(PerDiem.user_id == current_user.id)
total_amount = total_amount_query.scalar() or 0
return render_template(
'per_diem/list.html',
per_diem_claims=per_diem_pagination.items,
pagination=per_diem_pagination,
projects=projects,
clients=clients,
total_amount=float(total_amount),
status=status,
project_id=project_id,
client_id=client_id,
start_date=start_date,
end_date=end_date
)
@per_diem_bp.route('/per-diem/create', methods=['GET', 'POST'])
@login_required
def create_per_diem():
"""Create a new per diem claim"""
if request.method == 'GET':
projects = Project.query.filter_by(status='active').order_by(Project.name).all()
clients = Client.get_active_clients()
return render_template(
'per_diem/form.html',
per_diem=None,
projects=projects,
clients=clients
)
try:
# Get form data
trip_purpose = request.form.get('trip_purpose', '').strip()
start_date_str = request.form.get('start_date', '').strip()
end_date_str = request.form.get('end_date', '').strip()
country = request.form.get('country', '').strip()
city = request.form.get('city', '').strip()
# Validate required fields
if not all([trip_purpose, start_date_str, end_date_str, country]):
flash(_('Please fill in all required fields'), 'error')
return redirect(url_for('per_diem.create_per_diem'))
# Parse dates
try:
start_date = datetime.strptime(start_date_str, '%Y-%m-%d').date()
end_date = datetime.strptime(end_date_str, '%Y-%m-%d').date()
except ValueError:
flash(_('Invalid date format'), 'error')
return redirect(url_for('per_diem.create_per_diem'))
if start_date > end_date:
flash(_('Start date must be before end date'), 'error')
return redirect(url_for('per_diem.create_per_diem'))
# Parse times if provided
departure_time = None
return_time = None
departure_time_str = request.form.get('departure_time', '').strip()
return_time_str = request.form.get('return_time', '').strip()
if departure_time_str:
try:
departure_time = datetime.strptime(departure_time_str, '%H:%M').time()
except ValueError:
pass
if return_time_str:
try:
return_time = datetime.strptime(return_time_str, '%H:%M').time()
except ValueError:
pass
# Get or calculate full/half days
auto_calculate = request.form.get('auto_calculate_days') == 'on'
if auto_calculate:
days_calc = PerDiem.calculate_days_from_dates(start_date, end_date, departure_time, return_time)
full_days = days_calc['full_days']
half_days = days_calc['half_days']
else:
full_days = int(request.form.get('full_days', 0))
half_days = int(request.form.get('half_days', 0))
# Get applicable rate
rate = PerDiemRate.get_rate_for_location(country, city, start_date)
if not rate:
flash(_('No per diem rate found for this location. Please configure rates first.'), 'error')
return redirect(url_for('per_diem.create_per_diem'))
# Meal deductions
breakfast_provided = int(request.form.get('breakfast_provided', 0))
lunch_provided = int(request.form.get('lunch_provided', 0))
dinner_provided = int(request.form.get('dinner_provided', 0))
# Create per diem claim
per_diem = PerDiem(
user_id=current_user.id,
trip_purpose=trip_purpose,
start_date=start_date,
end_date=end_date,
country=country,
city=city,
full_day_rate=rate.full_day_rate,
half_day_rate=rate.half_day_rate,
description=request.form.get('description'),
project_id=request.form.get('project_id', type=int),
client_id=request.form.get('client_id', type=int),
per_diem_rate_id=rate.id,
departure_time=departure_time,
return_time=return_time,
full_days=full_days,
half_days=half_days,
breakfast_provided=breakfast_provided,
lunch_provided=lunch_provided,
dinner_provided=dinner_provided,
breakfast_deduction=rate.breakfast_rate or Decimal('0'),
lunch_deduction=rate.lunch_rate or Decimal('0'),
dinner_deduction=rate.dinner_rate or Decimal('0'),
currency_code=rate.currency_code,
notes=request.form.get('notes')
)
db.session.add(per_diem)
# Create expense if requested
if request.form.get('create_expense') == 'on':
expense = per_diem.create_expense()
if expense:
db.session.add(expense)
if safe_commit(db):
flash(_('Per diem claim created successfully'), 'success')
log_event('per_diem_created', user_id=current_user.id, per_diem_id=per_diem.id)
track_event(current_user.id, 'per_diem.created', {
'per_diem_id': per_diem.id,
'amount': float(per_diem.calculated_amount),
'days': per_diem.total_days
})
return redirect(url_for('per_diem.view_per_diem', per_diem_id=per_diem.id))
else:
flash(_('Error creating per diem claim'), 'error')
return redirect(url_for('per_diem.create_per_diem'))
except Exception as e:
current_app.logger.error(f"Error creating per diem claim: {e}")
flash(_('Error creating per diem claim'), 'error')
return redirect(url_for('per_diem.create_per_diem'))
@per_diem_bp.route('/per-diem/<int:per_diem_id>')
@login_required
def view_per_diem(per_diem_id):
"""View per diem claim details"""
per_diem = PerDiem.query.get_or_404(per_diem_id)
# Check permission
if not current_user.is_admin and per_diem.user_id != current_user.id and per_diem.approved_by != current_user.id:
flash(_('You do not have permission to view this per diem claim'), 'error')
return redirect(url_for('per_diem.list_per_diem'))
from app import track_page_view
track_page_view("per_diem_detail", properties={'per_diem_id': per_diem_id})
return render_template('per_diem/view.html', per_diem=per_diem)
@per_diem_bp.route('/per-diem/<int:per_diem_id>/edit', methods=['GET', 'POST'])
@login_required
def edit_per_diem(per_diem_id):
"""Edit a per diem claim"""
per_diem = PerDiem.query.get_or_404(per_diem_id)
# Check permission
if not current_user.is_admin and per_diem.user_id != current_user.id:
flash(_('You do not have permission to edit this per diem claim'), 'error')
return redirect(url_for('per_diem.view_per_diem', per_diem_id=per_diem_id))
# Cannot edit approved or reimbursed claims without admin privileges
if not current_user.is_admin and per_diem.status in ['approved', 'reimbursed']:
flash(_('Cannot edit approved or reimbursed per diem claims'), 'error')
return redirect(url_for('per_diem.view_per_diem', per_diem_id=per_diem_id))
if request.method == 'GET':
projects = Project.query.filter_by(status='active').order_by(Project.name).all()
clients = Client.get_active_clients()
return render_template(
'per_diem/form.html',
per_diem=per_diem,
projects=projects,
clients=clients
)
try:
# Update fields
per_diem.trip_purpose = request.form.get('trip_purpose', '').strip()
per_diem.description = request.form.get('description', '').strip()
per_diem.start_date = datetime.strptime(request.form.get('start_date'), '%Y-%m-%d').date()
per_diem.end_date = datetime.strptime(request.form.get('end_date'), '%Y-%m-%d').date()
per_diem.country = request.form.get('country', '').strip()
per_diem.city = request.form.get('city', '').strip()
per_diem.project_id = request.form.get('project_id', type=int)
per_diem.client_id = request.form.get('client_id', type=int)
per_diem.full_days = int(request.form.get('full_days', 0))
per_diem.half_days = int(request.form.get('half_days', 0))
per_diem.breakfast_provided = int(request.form.get('breakfast_provided', 0))
per_diem.lunch_provided = int(request.form.get('lunch_provided', 0))
per_diem.dinner_provided = int(request.form.get('dinner_provided', 0))
per_diem.notes = request.form.get('notes')
per_diem.updated_at = datetime.utcnow()
# Recalculate amount
per_diem.recalculate_amount()
if safe_commit(db):
flash(_('Per diem claim updated successfully'), 'success')
log_event('per_diem_updated', user_id=current_user.id, per_diem_id=per_diem.id)
track_event(current_user.id, 'per_diem.updated', {'per_diem_id': per_diem.id})
return redirect(url_for('per_diem.view_per_diem', per_diem_id=per_diem.id))
else:
flash(_('Error updating per diem claim'), 'error')
return redirect(url_for('per_diem.edit_per_diem', per_diem_id=per_diem_id))
except Exception as e:
current_app.logger.error(f"Error updating per diem claim: {e}")
flash(_('Error updating per diem claim'), 'error')
return redirect(url_for('per_diem.edit_per_diem', per_diem_id=per_diem_id))
@per_diem_bp.route('/per-diem/<int:per_diem_id>/delete', methods=['POST'])
@login_required
def delete_per_diem(per_diem_id):
"""Delete a per diem claim"""
per_diem = PerDiem.query.get_or_404(per_diem_id)
# Check permission
if not current_user.is_admin and per_diem.user_id != current_user.id:
flash(_('You do not have permission to delete this per diem claim'), 'error')
return redirect(url_for('per_diem.view_per_diem', per_diem_id=per_diem_id))
try:
db.session.delete(per_diem)
if safe_commit(db):
flash(_('Per diem claim deleted successfully'), 'success')
log_event('per_diem_deleted', user_id=current_user.id, per_diem_id=per_diem_id)
track_event(current_user.id, 'per_diem.deleted', {'per_diem_id': per_diem_id})
else:
flash(_('Error deleting per diem claim'), 'error')
except Exception as e:
current_app.logger.error(f"Error deleting per diem claim: {e}")
flash(_('Error deleting per diem claim'), 'error')
return redirect(url_for('per_diem.list_per_diem'))
@per_diem_bp.route('/per-diem/<int:per_diem_id>/approve', methods=['POST'])
@login_required
def approve_per_diem(per_diem_id):
"""Approve a per diem claim"""
if not current_user.is_admin:
flash(_('Only administrators can approve per diem claims'), 'error')
return redirect(url_for('per_diem.view_per_diem', per_diem_id=per_diem_id))
per_diem = PerDiem.query.get_or_404(per_diem_id)
if per_diem.status != 'pending':
flash(_('Only pending per diem claims can be approved'), 'error')
return redirect(url_for('per_diem.view_per_diem', per_diem_id=per_diem_id))
try:
notes = request.form.get('approval_notes', '').strip()
per_diem.approve(current_user.id, notes)
if safe_commit(db):
flash(_('Per diem claim approved successfully'), 'success')
log_event('per_diem_approved', user_id=current_user.id, per_diem_id=per_diem_id)
track_event(current_user.id, 'per_diem.approved', {'per_diem_id': per_diem_id})
else:
flash(_('Error approving per diem claim'), 'error')
except Exception as e:
current_app.logger.error(f"Error approving per diem claim: {e}")
flash(_('Error approving per diem claim'), 'error')
return redirect(url_for('per_diem.view_per_diem', per_diem_id=per_diem_id))
@per_diem_bp.route('/per-diem/<int:per_diem_id>/reject', methods=['POST'])
@login_required
def reject_per_diem(per_diem_id):
"""Reject a per diem claim"""
if not current_user.is_admin:
flash(_('Only administrators can reject per diem claims'), 'error')
return redirect(url_for('per_diem.view_per_diem', per_diem_id=per_diem_id))
per_diem = PerDiem.query.get_or_404(per_diem_id)
if per_diem.status != 'pending':
flash(_('Only pending per diem claims can be rejected'), 'error')
return redirect(url_for('per_diem.view_per_diem', per_diem_id=per_diem_id))
try:
reason = request.form.get('rejection_reason', '').strip()
if not reason:
flash(_('Rejection reason is required'), 'error')
return redirect(url_for('per_diem.view_per_diem', per_diem_id=per_diem_id))
per_diem.reject(current_user.id, reason)
if safe_commit(db):
flash(_('Per diem claim rejected'), 'success')
log_event('per_diem_rejected', user_id=current_user.id, per_diem_id=per_diem_id)
track_event(current_user.id, 'per_diem.rejected', {'per_diem_id': per_diem_id})
else:
flash(_('Error rejecting per diem claim'), 'error')
except Exception as e:
current_app.logger.error(f"Error rejecting per diem claim: {e}")
flash(_('Error rejecting per diem claim'), 'error')
return redirect(url_for('per_diem.view_per_diem', per_diem_id=per_diem_id))
# Per Diem Rates Management
@per_diem_bp.route('/per-diem/rates')
@login_required
@admin_or_permission_required('per_diem_rates.view')
def list_rates():
"""List all per diem rates"""
from app import track_page_view
track_page_view("per_diem_rates_list")
rates = PerDiemRate.query.filter_by(is_active=True).order_by(
PerDiemRate.country, PerDiemRate.city, PerDiemRate.effective_from.desc()
).all()
return render_template('per_diem/rates_list.html', rates=rates)
@per_diem_bp.route('/per-diem/rates/create', methods=['GET', 'POST'])
@login_required
@admin_or_permission_required('per_diem_rates.create')
def create_rate():
"""Create a new per diem rate"""
if request.method == 'GET':
return render_template('per_diem/rate_form.html', rate=None)
try:
country = request.form.get('country', '').strip()
full_day_rate = request.form.get('full_day_rate', '').strip()
half_day_rate = request.form.get('half_day_rate', '').strip()
effective_from = request.form.get('effective_from', '').strip()
if not all([country, full_day_rate, half_day_rate, effective_from]):
flash(_('Please fill in all required fields'), 'error')
return redirect(url_for('per_diem.create_rate'))
rate = PerDiemRate(
country=country,
city=request.form.get('city'),
full_day_rate=Decimal(full_day_rate),
half_day_rate=Decimal(half_day_rate),
breakfast_rate=request.form.get('breakfast_rate') or None,
lunch_rate=request.form.get('lunch_rate') or None,
dinner_rate=request.form.get('dinner_rate') or None,
incidental_rate=request.form.get('incidental_rate') or None,
currency_code=request.form.get('currency_code', 'EUR'),
effective_from=datetime.strptime(effective_from, '%Y-%m-%d').date(),
effective_to=datetime.strptime(request.form.get('effective_to'), '%Y-%m-%d').date() if request.form.get('effective_to') else None,
notes=request.form.get('notes')
)
db.session.add(rate)
if safe_commit(db):
flash(_('Per diem rate created successfully'), 'success')
log_event('per_diem_rate_created', user_id=current_user.id, rate_id=rate.id)
return redirect(url_for('per_diem.list_rates'))
else:
flash(_('Error creating per diem rate'), 'error')
return redirect(url_for('per_diem.create_rate'))
except Exception as e:
current_app.logger.error(f"Error creating per diem rate: {e}")
flash(_('Error creating per diem rate'), 'error')
return redirect(url_for('per_diem.create_rate'))
# API endpoints
@per_diem_bp.route('/api/per-diem', methods=['GET'])
@login_required
def api_list_per_diem():
"""API endpoint to list per diem claims"""
status = request.args.get('status', '').strip()
query = PerDiem.query
if not current_user.is_admin:
query = query.filter_by(user_id=current_user.id)
if status:
query = query.filter(PerDiem.status == status)
claims = query.order_by(PerDiem.start_date.desc()).all()
return jsonify({
'per_diem': [claim.to_dict() for claim in claims],
'count': len(claims)
})
@per_diem_bp.route('/api/per-diem/rates/search', methods=['GET'])
@login_required
def api_search_rates():
"""API endpoint to search for per diem rates"""
country = request.args.get('country', '').strip()
city = request.args.get('city', '').strip()
date_str = request.args.get('date', '').strip()
if not country:
return jsonify({'error': 'Country is required'}), 400
search_date = datetime.strptime(date_str, '%Y-%m-%d').date() if date_str else date.today()
rate = PerDiemRate.get_rate_for_location(country, city, search_date)
if rate:
return jsonify(rate.to_dict())
else:
return jsonify({'error': 'No rate found for this location'}), 404
@per_diem_bp.route('/api/per-diem/calculate-days', methods=['POST'])
@login_required
def api_calculate_days():
"""API endpoint to calculate full/half days from dates and times"""
data = request.get_json()
try:
start_date = datetime.strptime(data['start_date'], '%Y-%m-%d').date()
end_date = datetime.strptime(data['end_date'], '%Y-%m-%d').date()
departure_time = datetime.strptime(data.get('departure_time', ''), '%H:%M').time() if data.get('departure_time') else None
return_time = datetime.strptime(data.get('return_time', ''), '%H:%M').time() if data.get('return_time') else None
result = PerDiem.calculate_days_from_dates(start_date, end_date, departure_time, return_time)
return jsonify(result)
except Exception as e:
return jsonify({'error': str(e)}), 400
+304 -3
View File
@@ -1,4 +1,4 @@
from flask import Blueprint, render_template, request, redirect, url_for, flash, current_app, jsonify, make_response
from flask import Blueprint, render_template, request, redirect, url_for, flash, current_app, jsonify, make_response, Response
from flask_babel import gettext as _
from flask_login import login_required, current_user
from app import db, log_event, track_event
@@ -7,6 +7,8 @@ from datetime import datetime
from decimal import Decimal
from app.utils.db import safe_commit
from app.utils.permissions import admin_or_permission_required, permission_required
import csv
import io
from app.utils.posthog_funnels import (
track_onboarding_first_project,
track_project_setup_started,
@@ -87,6 +89,100 @@ def list_projects():
favorites_only=favorites_only
)
@projects_bp.route('/projects/export')
@login_required
def export_projects():
"""Export projects to CSV"""
status = request.args.get('status', 'active')
client_name = request.args.get('client', '').strip()
search = request.args.get('search', '').strip()
favorites_only = request.args.get('favorites', '').lower() == 'true'
query = Project.query
# Filter by favorites if requested
if favorites_only:
query = query.join(
UserFavoriteProject,
db.and_(
UserFavoriteProject.project_id == Project.id,
UserFavoriteProject.user_id == current_user.id
)
)
# Filter by status
if status == 'active':
query = query.filter(Project.status == 'active')
elif status == 'archived':
query = query.filter(Project.status == 'archived')
elif status == 'inactive':
query = query.filter(Project.status == 'inactive')
if client_name:
query = query.join(Client).filter(Client.name == client_name)
if search:
like = f"%{search}%"
query = query.filter(
db.or_(
Project.name.ilike(like),
Project.description.ilike(like)
)
)
projects = query.order_by(Project.name).all()
# Create CSV in memory
output = io.StringIO()
writer = csv.writer(output)
# Write header
writer.writerow([
'ID',
'Name',
'Code',
'Client',
'Description',
'Status',
'Billable',
'Hourly Rate',
'Budget Amount',
'Budget Threshold %',
'Estimated Hours',
'Billing Reference',
'Created At',
'Updated At'
])
# Write project data
for project in projects:
writer.writerow([
project.id,
project.name,
project.code or '',
project.client if project.client else '',
project.description or '',
project.status,
'Yes' if project.billable else 'No',
project.hourly_rate or '',
project.budget_amount or '',
project.budget_threshold_percent or '',
project.estimated_hours or '',
project.billing_ref or '',
project.created_at.strftime('%Y-%m-%d %H:%M:%S') if project.created_at else '',
project.updated_at.strftime('%Y-%m-%d %H:%M:%S') if hasattr(project, 'updated_at') and project.updated_at else ''
])
# Create response
output.seek(0)
return Response(
output.getvalue(),
mimetype='text/csv',
headers={
'Content-Disposition': f'attachment; filename=projects_export_{datetime.now().strftime("%Y%m%d_%H%M%S")}.csv'
}
)
@projects_bp.route('/projects/create', methods=['GET', 'POST'])
@login_required
@admin_or_permission_required('create_projects')
@@ -329,6 +425,185 @@ def view_project(project_id):
resp.headers['Expires'] = '0'
return resp
@projects_bp.route('/projects/<int:project_id>/dashboard')
@login_required
def project_dashboard(project_id):
"""Project dashboard with comprehensive analytics and visualizations"""
project = Project.query.get_or_404(project_id)
# Track page view
from app import track_page_view
track_page_view("project_dashboard")
# Get time period filter (default to all time)
from datetime import datetime, timedelta
period = request.args.get('period', 'all')
start_date = None
end_date = None
if period == 'week':
start_date = datetime.now() - timedelta(days=7)
elif period == 'month':
start_date = datetime.now() - timedelta(days=30)
elif period == '3months':
start_date = datetime.now() - timedelta(days=90)
elif period == 'year':
start_date = datetime.now() - timedelta(days=365)
# === Budget vs Actual ===
budget_data = {
'budget_amount': float(project.budget_amount) if project.budget_amount else 0,
'consumed_amount': project.budget_consumed_amount,
'remaining_amount': float(project.budget_amount or 0) - project.budget_consumed_amount,
'percentage': round((project.budget_consumed_amount / float(project.budget_amount or 1)) * 100, 1) if project.budget_amount else 0,
'threshold_exceeded': project.budget_threshold_exceeded,
'estimated_hours': project.estimated_hours or 0,
'actual_hours': project.actual_hours,
'remaining_hours': (project.estimated_hours or 0) - project.actual_hours,
'hours_percentage': round((project.actual_hours / (project.estimated_hours or 1)) * 100, 1) if project.estimated_hours else 0
}
# === Task Statistics ===
all_tasks = project.tasks.all()
task_stats = {
'total': len(all_tasks),
'by_status': {},
'completed': 0,
'in_progress': 0,
'todo': 0,
'completion_rate': 0,
'overdue': 0
}
for task in all_tasks:
status = task.status
task_stats['by_status'][status] = task_stats['by_status'].get(status, 0) + 1
if status == 'done':
task_stats['completed'] += 1
elif status == 'in_progress':
task_stats['in_progress'] += 1
elif status == 'todo':
task_stats['todo'] += 1
if task.is_overdue:
task_stats['overdue'] += 1
if task_stats['total'] > 0:
task_stats['completion_rate'] = round((task_stats['completed'] / task_stats['total']) * 100, 1)
# === Team Member Contributions ===
user_totals = project.get_user_totals(start_date=start_date, end_date=end_date)
# Get time entries per user with additional stats
from app.models import User
team_contributions = []
for user_data in user_totals:
username = user_data['username']
total_hours = user_data['total_hours']
# Get user object
user = User.query.filter(
db.or_(
User.username == username,
User.full_name == username
)
).first()
if user:
# Count entries for this user
entry_count = project.time_entries.filter(
TimeEntry.user_id == user.id,
TimeEntry.end_time.isnot(None)
)
if start_date:
entry_count = entry_count.filter(TimeEntry.start_time >= start_date)
if end_date:
entry_count = entry_count.filter(TimeEntry.start_time <= end_date)
entry_count = entry_count.count()
# Count tasks assigned to this user
task_count = project.tasks.filter_by(assigned_to=user.id).count()
team_contributions.append({
'username': username,
'total_hours': total_hours,
'entry_count': entry_count,
'task_count': task_count,
'percentage': round((total_hours / project.total_hours * 100), 1) if project.total_hours > 0 else 0
})
# Sort by total hours descending
team_contributions.sort(key=lambda x: x['total_hours'], reverse=True)
# === Recent Activity ===
recent_activities = Activity.query.filter(
Activity.entity_type.in_(['project', 'task', 'time_entry']),
db.or_(
Activity.entity_id == project_id,
db.and_(
Activity.entity_type == 'task',
Activity.entity_id.in_([t.id for t in all_tasks])
)
)
).order_by(Activity.created_at.desc()).limit(20).all()
# Filter to only project-related activities
project_activities = []
for activity in recent_activities:
if activity.entity_type == 'project' and activity.entity_id == project_id:
project_activities.append(activity)
elif activity.entity_type == 'task':
# Check if task belongs to this project
task = Task.query.get(activity.entity_id)
if task and task.project_id == project_id:
project_activities.append(activity)
# === Time Tracking Timeline (last 30 days) ===
from sqlalchemy import func
timeline_data = []
if start_date or period != 'all':
timeline_start = start_date or (datetime.now() - timedelta(days=30))
# Group time entries by date
daily_hours = db.session.query(
func.date(TimeEntry.start_time).label('date'),
func.sum(TimeEntry.duration_seconds).label('total_seconds')
).filter(
TimeEntry.project_id == project_id,
TimeEntry.end_time.isnot(None),
TimeEntry.start_time >= timeline_start
).group_by(func.date(TimeEntry.start_time)).order_by('date').all()
timeline_data = [
{
'date': str(date),
'hours': round(total_seconds / 3600, 2)
}
for date, total_seconds in daily_hours
]
# === Cost Breakdown ===
cost_data = {
'total_costs': project.total_costs,
'billable_costs': project.total_billable_costs,
'by_category': {}
}
if hasattr(ProjectCost, 'get_costs_by_category'):
cost_breakdown = ProjectCost.get_costs_by_category(project_id, start_date, end_date)
cost_data['by_category'] = cost_breakdown
return render_template(
'projects/dashboard.html',
project=project,
budget_data=budget_data,
task_stats=task_stats,
team_contributions=team_contributions,
recent_activities=project_activities[:10],
timeline_data=timeline_data,
cost_data=cost_data,
period=period
)
@projects_bp.route('/projects/<int:project_id>/edit', methods=['GET', 'POST'])
@login_required
@admin_or_permission_required('edit_projects')
@@ -415,6 +690,18 @@ def edit_project(project_id):
flash('Could not update project due to a database error. Please check server logs.', 'error')
return render_template('projects/edit.html', project=project, clients=Client.get_active_clients())
# Log activity
Activity.log(
user_id=current_user.id,
action='updated',
entity_type='project',
entity_id=project.id,
entity_name=project.name,
description=f'Updated project "{project.name}"',
ip_address=request.remote_addr,
user_agent=request.headers.get('User-Agent')
)
flash(f'Project "{name}" updated successfully', 'success')
return redirect(url_for('projects.view_project', project_id=project.id))
@@ -560,10 +847,24 @@ def delete_project(project_id):
return redirect(url_for('projects.view_project', project_id=project_id))
project_name = project.name
project_id_copy = project.id
# Log activity before deletion
Activity.log(
user_id=current_user.id,
action='deleted',
entity_type='project',
entity_id=project_id_copy,
entity_name=project_name,
description=f'Deleted project "{project_name}"',
ip_address=request.remote_addr,
user_agent=request.headers.get('User-Agent')
)
db.session.delete(project)
if not safe_commit('delete_project', {'project_id': project.id}):
if not safe_commit('delete_project', {'project_id': project_id_copy}):
flash('Could not delete project due to a database error. Please check server logs.', 'error')
return redirect(url_for('projects.view_project', project_id=project.id))
return redirect(url_for('projects.view_project', project_id=project_id_copy))
flash(f'Project "{project_name}" deleted successfully', 'success')
return redirect(url_for('projects.list_projects'))
+219 -4
View File
@@ -1,13 +1,15 @@
from flask import Blueprint, render_template, request, redirect, url_for, flash, jsonify, make_response
from flask import Blueprint, render_template, request, redirect, url_for, flash, jsonify, make_response, Response
from flask_babel import gettext as _
from flask_login import login_required, current_user
import app as app_module
from app import db
from app.models import Task, Project, User, TimeEntry, TaskActivity, KanbanColumn
from app.models import Task, Project, User, TimeEntry, TaskActivity, KanbanColumn, Activity
from datetime import datetime, date
from decimal import Decimal
from app.utils.db import safe_commit
from app.utils.timezone import now_in_app_timezone
import csv
import io
tasks_bp = Blueprint('tasks', __name__)
@@ -168,6 +170,19 @@ def create_task():
"priority": priority
})
# Log activity
Activity.log(
user_id=current_user.id,
action='created',
entity_type='task',
entity_id=task.id,
entity_name=task.name,
description=f'Created task "{task.name}" in project "{project.name}"',
extra_data={'project_id': project_id, 'priority': priority},
ip_address=request.remote_addr,
user_agent=request.headers.get('User-Agent')
)
flash(f'Task "{name}" created successfully', 'success')
return redirect(url_for('tasks.view_task', task_id=task.id))
@@ -335,6 +350,18 @@ def edit_task(task_id):
"project_id": task.project_id
})
# Log activity
Activity.log(
user_id=current_user.id,
action='updated',
entity_type='task',
entity_id=task.id,
entity_name=task.name,
description=f'Updated task "{task.name}"',
ip_address=request.remote_addr,
user_agent=request.headers.get('User-Agent')
)
flash(f'Task "{name}" updated successfully', 'success')
return redirect(url_for('tasks.view_task', task_id=task.id))
@@ -494,10 +521,23 @@ def delete_task(task_id):
task_name = task.name
task_id_for_log = task.id
project_id_for_log = task.project_id
# Log activity before deletion
Activity.log(
user_id=current_user.id,
action='deleted',
entity_type='task',
entity_id=task_id_for_log,
entity_name=task_name,
description=f'Deleted task "{task_name}"',
ip_address=request.remote_addr,
user_agent=request.headers.get('User-Agent')
)
db.session.delete(task)
if not safe_commit('delete_task', {'task_id': task.id}):
if not safe_commit('delete_task', {'task_id': task_id_for_log}):
flash('Could not delete task due to a database error. Please check server logs.', 'error')
return redirect(url_for('tasks.view_task', task_id=task.id))
return redirect(url_for('tasks.view_task', task_id=task_id_for_log))
# Log task deletion
app_module.log_event("task.deleted", user_id=current_user.id, task_id=task_id_for_log, project_id=project_id_for_log)
@@ -734,6 +774,181 @@ def bulk_assign_tasks():
return redirect(url_for('tasks.list_tasks'))
@tasks_bp.route('/tasks/bulk-move-project', methods=['POST'])
@login_required
def bulk_move_project():
"""Move multiple tasks to a different project"""
task_ids = request.form.getlist('task_ids[]')
new_project_id = request.form.get('project_id', type=int)
if not task_ids:
flash('No tasks selected', 'warning')
return redirect(url_for('tasks.list_tasks'))
if not new_project_id:
flash('No project selected', 'error')
return redirect(url_for('tasks.list_tasks'))
# Verify project exists and is active
new_project = Project.query.filter_by(id=new_project_id, status='active').first()
if not new_project:
flash('Invalid project selected', 'error')
return redirect(url_for('tasks.list_tasks'))
updated_count = 0
skipped_count = 0
for task_id_str in task_ids:
try:
task_id = int(task_id_str)
task = Task.query.get(task_id)
if not task:
continue
# Check permissions
if not current_user.is_admin and task.created_by != current_user.id:
skipped_count += 1
continue
# Update task project
old_project_id = task.project_id
task.project_id = new_project_id
# Update related time entries to match the new project
for entry in task.time_entries.all():
entry.project_id = new_project_id
# Log activity
db.session.add(TaskActivity(
task_id=task.id,
user_id=current_user.id,
event='project_change',
details=f"Project changed from {old_project_id} to {new_project_id}"
))
updated_count += 1
except Exception:
skipped_count += 1
if updated_count > 0:
if not safe_commit('bulk_move_project', {'count': updated_count, 'project_id': new_project_id}):
flash('Could not move tasks due to a database error', 'error')
return redirect(url_for('tasks.list_tasks'))
flash(f'Successfully moved {updated_count} task{"s" if updated_count != 1 else ""} to {new_project.name}', 'success')
if skipped_count > 0:
flash(f'Skipped {skipped_count} task{"s" if skipped_count != 1 else ""} (no permission)', 'warning')
return redirect(url_for('tasks.list_tasks'))
@tasks_bp.route('/tasks/export')
@login_required
def export_tasks():
"""Export tasks to CSV"""
# Get the same filters as the list view
status = request.args.get('status', '')
priority = request.args.get('priority', '')
project_id = request.args.get('project_id', type=int)
assigned_to = request.args.get('assigned_to', type=int)
search = request.args.get('search', '').strip()
overdue_param = request.args.get('overdue', '').strip().lower()
overdue = overdue_param in ['1', 'true', 'on', 'yes']
query = Task.query
# Apply filters (same as list_tasks)
if status:
query = query.filter_by(status=status)
if priority:
query = query.filter_by(priority=priority)
if project_id:
query = query.filter_by(project_id=project_id)
if assigned_to:
query = query.filter_by(assigned_to=assigned_to)
if search:
like = f"%{search}%"
query = query.filter(
db.or_(
Task.name.ilike(like),
Task.description.ilike(like)
)
)
# Overdue filter
if overdue:
today_local = now_in_app_timezone().date()
query = query.filter(
Task.due_date < today_local,
Task.status.in_(['todo', 'in_progress', 'review'])
)
# Show user's tasks first, then others
if not current_user.is_admin:
query = query.filter(
db.or_(
Task.assigned_to == current_user.id,
Task.created_by == current_user.id
)
)
tasks = query.order_by(Task.priority.desc(), Task.due_date.asc(), Task.created_at.asc()).all()
# Create CSV in memory
output = io.StringIO()
writer = csv.writer(output)
# Write header
writer.writerow([
'ID',
'Name',
'Description',
'Project',
'Status',
'Priority',
'Assigned To',
'Created By',
'Due Date',
'Estimated Hours',
'Created At',
'Updated At'
])
# Write task data
for task in tasks:
writer.writerow([
task.id,
task.name,
task.description or '',
task.project.name if task.project else '',
task.status,
task.priority,
task.assigned_user.display_name if task.assigned_user else '',
task.creator.display_name if task.creator else '',
task.due_date.strftime('%Y-%m-%d') if task.due_date else '',
task.estimated_hours or '',
task.created_at.strftime('%Y-%m-%d %H:%M:%S') if task.created_at else '',
task.updated_at.strftime('%Y-%m-%d %H:%M:%S') if task.updated_at else ''
])
# Create response
output.seek(0)
return Response(
output.getvalue(),
mimetype='text/csv',
headers={
'Content-Disposition': f'attachment; filename=tasks_export_{datetime.now().strftime("%Y%m%d_%H%M%S")}.csv'
}
)
@tasks_bp.route('/tasks/my-tasks')
@login_required
def my_tasks():
+266 -11
View File
@@ -2,7 +2,7 @@ from flask import Blueprint, render_template, request, redirect, url_for, flash,
from flask_babel import gettext as _
from flask_login import login_required, current_user
from app import db, socketio, log_event, track_event
from app.models import User, Project, TimeEntry, Task, Settings
from app.models import User, Project, TimeEntry, Task, Settings, Activity
from app.utils.timezone import parse_local_datetime, utc_to_local
from datetime import datetime
import json
@@ -21,7 +21,27 @@ def start_timer():
project_id = request.form.get('project_id', type=int)
task_id = request.form.get('task_id', type=int)
notes = request.form.get('notes', '').strip()
current_app.logger.info("POST /timer/start user=%s project_id=%s task_id=%s", current_user.username, project_id, task_id)
template_id = request.form.get('template_id', type=int)
current_app.logger.info("POST /timer/start user=%s project_id=%s task_id=%s template_id=%s", current_user.username, project_id, task_id, template_id)
# Load template data if template_id is provided
if template_id:
from app.models import TimeEntryTemplate
template = TimeEntryTemplate.query.filter_by(
id=template_id,
user_id=current_user.id
).first()
if template:
# Override with template values if not explicitly set
if not project_id and template.project_id:
project_id = template.project_id
if not task_id and template.task_id:
task_id = template.task_id
if not notes and template.default_notes:
notes = template.default_notes
# Mark template as used
template.record_usage()
db.session.commit()
if not project_id:
flash('Project is required', 'error')
@@ -87,6 +107,19 @@ def start_timer():
"has_description": bool(notes)
})
# Log activity
Activity.log(
user_id=current_user.id,
action='started',
entity_type='time_entry',
entity_id=new_timer.id,
entity_name=f'{project.name}' + (f' - {task.name}' if task else ''),
description=f'Started timer for {project.name}' + (f' - {task.name}' if task else ''),
extra_data={'project_id': project_id, 'task_id': task_id},
ip_address=request.remote_addr,
user_agent=request.headers.get('User-Agent')
)
# Check if this is user's first timer (onboarding milestone)
timer_count = TimeEntry.query.filter_by(
user_id=current_user.id,
@@ -121,6 +154,72 @@ def start_timer():
flash(f'Timer started for {project.name}', 'success')
return redirect(url_for('main.dashboard'))
@timer_bp.route('/timer/start/from-template/<int:template_id>', methods=['GET', 'POST'])
@login_required
def start_timer_from_template(template_id):
"""Start a timer directly from a template"""
from app.models import TimeEntryTemplate
# Load template
template = TimeEntryTemplate.query.filter_by(
id=template_id,
user_id=current_user.id
).first_or_404()
# Check if user already has an active timer
active_timer = current_user.active_timer
if active_timer:
flash('You already have an active timer. Stop it before starting a new one.', 'error')
return redirect(url_for('main.dashboard'))
# Validate template has required data
if not template.project_id:
flash('Template must have a project to start a timer', 'error')
return redirect(url_for('time_entry_templates.list_templates'))
# Check if project is active
project = Project.query.get(template.project_id)
if not project or project.status != 'active':
flash('Cannot start timer for this project', 'error')
return redirect(url_for('time_entry_templates.list_templates'))
# Create new timer from template
from app.models.time_entry import local_now
new_timer = TimeEntry(
user_id=current_user.id,
project_id=template.project_id,
task_id=template.task_id,
start_time=local_now(),
notes=template.default_notes,
tags=template.tags,
source='auto',
billable=template.billable
)
db.session.add(new_timer)
# Mark template as used
template.record_usage()
if not safe_commit('start_timer_from_template', {'template_id': template_id}):
flash('Could not start timer due to a database error. Please check server logs.', 'error')
return redirect(url_for('time_entry_templates.list_templates'))
# Track events
log_event("timer.started.from_template",
user_id=current_user.id,
template_id=template_id,
project_id=template.project_id)
track_event(current_user.id, "timer.started.from_template", {
"template_id": template_id,
"template_name": template.name,
"project_id": template.project_id,
"has_task": bool(template.task_id)
})
flash(f'Timer started from template "{template.name}"', 'success')
return redirect(url_for('main.dashboard'))
@timer_bp.route('/timer/start/<int:project_id>')
@login_required
def start_timer_for_project(project_id):
@@ -220,6 +319,21 @@ def stop_timer():
"duration_seconds": duration_seconds
})
# Log activity
project_name = active_timer.project.name if active_timer.project else 'No project'
task_name = active_timer.task.name if active_timer.task else None
Activity.log(
user_id=current_user.id,
action='stopped',
entity_type='time_entry',
entity_id=active_timer.id,
entity_name=f'{project_name}' + (f' - {task_name}' if task_name else ''),
description=f'Stopped timer for {project_name}' + (f' - {task_name}' if task_name else '') + f' - Duration: {active_timer.duration_formatted}',
extra_data={'duration_hours': active_timer.duration_hours, 'project_id': active_timer.project_id, 'task_id': active_timer.task_id},
ip_address=request.remote_addr,
user_agent=request.headers.get('User-Agent')
)
# Check if this is user's first completed time entry (onboarding milestone)
entry_count = TimeEntry.query.filter_by(
user_id=current_user.id
@@ -425,6 +539,29 @@ def manual_entry():
# Get project_id and task_id from query parameters for pre-filling
project_id = request.args.get('project_id', type=int)
task_id = request.args.get('task_id', type=int)
template_id = request.args.get('template', type=int)
# Load template data if template_id is provided
template_data = None
if template_id:
from app.models import TimeEntryTemplate
template = TimeEntryTemplate.query.filter_by(
id=template_id,
user_id=current_user.id
).first()
if template:
template_data = {
'project_id': template.project_id,
'task_id': template.task_id,
'notes': template.default_notes,
'tags': template.tags,
'billable': template.billable
}
# Override with template values if not explicitly set
if not project_id and template.project_id:
project_id = template.project_id
if not task_id and template.task_id:
task_id = template.task_id
if request.method == 'POST':
project_id = request.form.get('project_id', type=int)
@@ -441,24 +578,24 @@ def manual_entry():
if not all([project_id, start_date, start_time, end_date, end_time]):
flash('All fields are required', 'error')
return render_template('timer/manual_entry.html', projects=active_projects,
selected_project_id=project_id, selected_task_id=task_id)
selected_project_id=project_id, selected_task_id=task_id, template_data=template_data)
# Check if project exists
project = Project.query.get(project_id)
if not project:
flash(_('Invalid project selected'), 'error')
return render_template('timer/manual_entry.html', projects=active_projects,
selected_project_id=project_id, selected_task_id=task_id)
selected_project_id=project_id, selected_task_id=task_id, template_data=template_data)
# Check if project is active (not archived or inactive)
if project.status == 'archived':
flash(_('Cannot create time entries for an archived project. Please unarchive the project first.'), 'error')
return render_template('timer/manual_entry.html', projects=active_projects,
selected_project_id=project_id, selected_task_id=task_id)
selected_project_id=project_id, selected_task_id=task_id, template_data=template_data)
elif project.status != 'active':
flash(_('Cannot create time entries for an inactive project'), 'error')
return render_template('timer/manual_entry.html', projects=active_projects,
selected_project_id=project_id, selected_task_id=task_id)
selected_project_id=project_id, selected_task_id=task_id, template_data=template_data)
# Validate task if provided
if task_id:
@@ -466,7 +603,7 @@ def manual_entry():
if not task:
flash('Invalid task selected', 'error')
return render_template('timer/manual_entry.html', projects=active_projects,
selected_project_id=project_id, selected_task_id=task_id)
selected_project_id=project_id, selected_task_id=task_id, template_data=template_data)
# Parse datetime with timezone awareness
try:
@@ -475,13 +612,13 @@ def manual_entry():
except ValueError:
flash('Invalid date/time format', 'error')
return render_template('timer/manual_entry.html', projects=active_projects,
selected_project_id=project_id, selected_task_id=task_id)
selected_project_id=project_id, selected_task_id=task_id, template_data=template_data)
# Validate time range
if end_time_parsed <= start_time_parsed:
flash('End time must be after start time', 'error')
return render_template('timer/manual_entry.html', projects=active_projects,
selected_project_id=project_id, selected_task_id=task_id)
selected_project_id=project_id, selected_task_id=task_id, template_data=template_data)
# Create manual entry
entry = TimeEntry(
@@ -500,7 +637,7 @@ def manual_entry():
if not safe_commit('manual_entry', {'user_id': current_user.id, 'project_id': project_id, 'task_id': task_id}):
flash('Could not create manual entry due to a database error. Please check server logs.', 'error')
return render_template('timer/manual_entry.html', projects=active_projects,
selected_project_id=project_id, selected_task_id=task_id)
selected_project_id=project_id, selected_task_id=task_id, template_data=template_data)
if task_id:
task = Task.query.get(task_id)
@@ -512,7 +649,7 @@ def manual_entry():
return redirect(url_for('main.dashboard'))
return render_template('timer/manual_entry.html', projects=active_projects,
selected_project_id=project_id, selected_task_id=task_id)
selected_project_id=project_id, selected_task_id=task_id, template_data=template_data)
@timer_bp.route('/timer/manual/<int:project_id>')
@login_required
@@ -773,3 +910,121 @@ def duplicate_timer(timer_id):
prefill_billable=timer.billable,
is_duplicate=True,
original_entry=timer)
@timer_bp.route('/timer/resume/<int:timer_id>')
@login_required
def resume_timer(timer_id):
"""Resume an existing time entry - starts a new active timer with same properties"""
timer = TimeEntry.query.get_or_404(timer_id)
# Check if user can resume this timer
if timer.user_id != current_user.id and not current_user.is_admin:
flash('You can only resume your own timers', 'error')
return redirect(url_for('main.dashboard'))
# Check if user already has an active timer
active_timer = current_user.active_timer
if active_timer:
flash('You already have an active timer. Stop it before resuming another one.', 'error')
current_app.logger.info("Resume timer blocked: user already has an active timer")
return redirect(url_for('main.dashboard'))
# Check if project is still active
project = Project.query.get(timer.project_id)
if not project:
flash(_('Project no longer exists'), 'error')
return redirect(url_for('main.dashboard'))
if project.status == 'archived':
flash(_('Cannot start timer for an archived project. Please unarchive the project first.'), 'error')
return redirect(url_for('main.dashboard'))
elif project.status != 'active':
flash(_('Cannot start timer for an inactive project'), 'error')
return redirect(url_for('main.dashboard'))
# Validate task if it exists
if timer.task_id:
task = Task.query.filter_by(id=timer.task_id, project_id=timer.project_id).first()
if not task:
# Task was deleted, continue without it
task_id = None
else:
task_id = timer.task_id
else:
task_id = None
# Create new timer with copied properties
from app.models.time_entry import local_now
new_timer = TimeEntry(
user_id=current_user.id,
project_id=timer.project_id,
task_id=task_id,
start_time=local_now(),
notes=timer.notes,
tags=timer.tags,
source='auto',
billable=timer.billable
)
db.session.add(new_timer)
if not safe_commit('resume_timer', {'user_id': current_user.id, 'original_timer_id': timer_id, 'project_id': timer.project_id}):
flash('Could not resume timer due to a database error. Please check server logs.', 'error')
return redirect(url_for('main.dashboard'))
current_app.logger.info("Resumed timer id=%s from original timer=%s for user=%s project_id=%s",
new_timer.id, timer_id, current_user.username, timer.project_id)
# Track timer resumed event
log_event("timer.resumed",
user_id=current_user.id,
time_entry_id=new_timer.id,
original_timer_id=timer_id,
project_id=timer.project_id,
task_id=task_id,
description=timer.notes)
track_event(current_user.id, "timer.resumed", {
"time_entry_id": new_timer.id,
"original_timer_id": timer_id,
"project_id": timer.project_id,
"task_id": task_id,
"has_notes": bool(timer.notes),
"has_tags": bool(timer.tags)
})
# Log activity
project_name = project.name
task = Task.query.get(task_id) if task_id else None
task_name = task.name if task else None
Activity.log(
user_id=current_user.id,
action='started',
entity_type='time_entry',
entity_id=new_timer.id,
entity_name=f'{project_name}' + (f' - {task_name}' if task_name else ''),
description=f'Resumed timer for {project_name}' + (f' - {task_name}' if task_name else ''),
extra_data={'project_id': timer.project_id, 'task_id': task_id, 'resumed_from': timer_id},
ip_address=request.remote_addr,
user_agent=request.headers.get('User-Agent')
)
# Emit WebSocket event for real-time updates
try:
payload = {
'user_id': current_user.id,
'timer_id': new_timer.id,
'project_name': project_name,
'start_time': new_timer.start_time.isoformat()
}
if task_id:
payload['task_id'] = task_id
payload['task_name'] = task_name
socketio.emit('timer_started', payload)
except Exception as e:
current_app.logger.warning("Socket emit failed for timer_resumed: %s", e)
if task_name:
flash(f'Timer resumed for {project_name} - {task_name}', 'success')
else:
flash(f'Timer resumed for {project_name}', 'success')
return redirect(url_for('main.dashboard'))
+60
View File
@@ -213,3 +213,63 @@ def set_theme():
db.session.rollback()
return jsonify({'error': str(e)}), 500
@user_bp.route('/api/language', methods=['POST'])
@login_required
def set_language():
"""Quick API endpoint to set language (for language switcher)"""
from flask import current_app, session
try:
data = request.get_json()
language = data.get('language')
# Get available languages from config
available_languages = current_app.config.get('LANGUAGES', {})
if language in available_languages:
# Update user preference
current_user.preferred_language = language
db.session.commit()
# Also set in session for immediate effect
session['preferred_language'] = language
return jsonify({
'success': True,
'language': language,
'message': _('Language updated successfully')
})
return jsonify({'error': _('Invalid language')}), 400
except Exception as e:
db.session.rollback()
return jsonify({'error': str(e)}), 500
@user_bp.route('/set-language/<language>')
def set_language_direct(language):
"""Direct route to set language (for non-JS fallback)"""
from flask import current_app, session
# Get available languages from config
available_languages = current_app.config.get('LANGUAGES', {})
if language in available_languages:
# Set in session for immediate effect
session['preferred_language'] = language
# If user is logged in, update their preference
if current_user.is_authenticated:
current_user.preferred_language = language
db.session.commit()
flash(_('Language updated to %(language)s', language=available_languages[language]), 'success')
# Redirect back to referring page or dashboard
next_page = request.referrer or url_for('main.dashboard')
return redirect(next_page)
flash(_('Invalid language'), 'error')
return redirect(url_for('main.dashboard'))
+4 -4
View File
@@ -50,16 +50,16 @@
async function stopTimerQuick(){
try {
const active = await getActiveTimer();
if (!active) { showToast('No active timer', 'warning'); return; }
if (!active) { showToast(window.i18n?.messages?.noActiveTimer || 'No active timer', 'warning'); return; }
const token = document.querySelector('meta[name="csrf-token"]')?.getAttribute('content') || '';
const res = await fetch('/timer/stop', { method: 'POST', headers: { 'X-CSRF-Token': token }, credentials: 'same-origin' });
if (res.ok) {
showToast('Timer stopped', 'info');
showToast(window.i18n?.messages?.timerStopped || 'Timer stopped', 'info');
} else {
showToast('Failed to stop timer', 'danger');
showToast(window.i18n?.messages?.timerStopFailed || 'Failed to stop timer', 'danger');
}
} catch(e) {
showToast('Failed to stop timer', 'danger');
showToast(window.i18n?.messages?.timerStopFailed || 'Failed to stop timer', 'danger');
}
}
+218
View File
@@ -0,0 +1,218 @@
/* RTL (Right-to-Left) Language Support */
/* This file provides comprehensive RTL support for Arabic, Hebrew, and other RTL languages */
html[dir="rtl"] {
direction: rtl;
}
/* Margin and Padding Reversals */
html[dir="rtl"] .ml-1 { margin-left: 0; margin-right: 0.25rem; }
html[dir="rtl"] .mr-1 { margin-right: 0; margin-left: 0.25rem; }
html[dir="rtl"] .ml-2 { margin-left: 0; margin-right: 0.5rem; }
html[dir="rtl"] .mr-2 { margin-right: 0; margin-left: 0.5rem; }
html[dir="rtl"] .ml-3 { margin-left: 0; margin-right: 0.75rem; }
html[dir="rtl"] .mr-3 { margin-right: 0; margin-left: 0.75rem; }
html[dir="rtl"] .ml-4 { margin-left: 0; margin-right: 1rem; }
html[dir="rtl"] .mr-4 { margin-right: 0; margin-left: 1rem; }
html[dir="rtl"] .ml-6 { margin-left: 0; margin-right: 1.5rem; }
html[dir="rtl"] .mr-6 { margin-right: 0; margin-left: 1.5rem; }
html[dir="rtl"] .ml-8 { margin-left: 0; margin-right: 2rem; }
html[dir="rtl"] .mr-8 { margin-right: 0; margin-left: 2rem; }
html[dir="rtl"] .ml-auto { margin-left: 0; margin-right: auto; }
html[dir="rtl"] .mr-auto { margin-right: 0; margin-left: auto; }
html[dir="rtl"] .pl-1 { padding-left: 0; padding-right: 0.25rem; }
html[dir="rtl"] .pr-1 { padding-right: 0; padding-left: 0.25rem; }
html[dir="rtl"] .pl-2 { padding-left: 0; padding-right: 0.5rem; }
html[dir="rtl"] .pr-2 { padding-right: 0; padding-left: 0.5rem; }
html[dir="rtl"] .pl-3 { padding-left: 0; padding-right: 0.75rem; }
html[dir="rtl"] .pr-3 { padding-right: 0; padding-left: 0.75rem; }
html[dir="rtl"] .pl-4 { padding-left: 0; padding-right: 1rem; }
html[dir="rtl"] .pr-4 { padding-right: 0; padding-left: 1rem; }
html[dir="rtl"] .pl-10 { padding-left: 0; padding-right: 2.5rem; }
html[dir="rtl"] .pr-10 { padding-right: 0; padding-left: 2.5rem; }
html[dir="rtl"] .pr-14 { padding-right: 0; padding-left: 3.5rem; }
/* Text Alignment */
html[dir="rtl"] .text-left { text-align: right; }
html[dir="rtl"] .text-right { text-align: left; }
/* Positioning */
html[dir="rtl"] .left-0 { left: auto; right: 0; }
html[dir="rtl"] .right-0 { right: auto; left: 0; }
html[dir="rtl"] .left-2 { left: auto; right: 0.5rem; }
html[dir="rtl"] .right-2 { right: auto; left: 0.5rem; }
/* Sidebar Adjustments */
html[dir="rtl"] #sidebar {
left: auto;
right: 0;
}
html[dir="rtl"] #mainContent {
margin-left: 0;
margin-right: 16rem;
}
html[dir="rtl"] .sidebar-collapsed #sidebar {
right: -12rem;
}
/* Mobile Responsiveness */
@media (max-width: 1024px) {
html[dir="rtl"] #mainContent {
margin-right: 0;
}
}
/* Border Radius Reversals */
html[dir="rtl"] .rounded-l { border-radius: 0 0.25rem 0.25rem 0; }
html[dir="rtl"] .rounded-r { border-radius: 0.25rem 0 0 0.25rem; }
html[dir="rtl"] .rounded-tl { border-top-left-radius: 0; border-top-right-radius: 0.25rem; }
html[dir="rtl"] .rounded-tr { border-top-right-radius: 0; border-top-left-radius: 0.25rem; }
html[dir="rtl"] .rounded-bl { border-bottom-left-radius: 0; border-bottom-right-radius: 0.25rem; }
html[dir="rtl"] .rounded-br { border-bottom-right-radius: 0; border-bottom-left-radius: 0.25rem; }
/* Border Reversals */
html[dir="rtl"] .border-l { border-left: 0; border-right: 1px solid; }
html[dir="rtl"] .border-r { border-right: 0; border-left: 1px solid; }
/* Transform Reversals */
html[dir="rtl"] .rotate-90 { transform: rotate(-90deg); }
html[dir="rtl"] .rotate-180 { transform: rotate(-180deg); }
html[dir="rtl"] .rotate-270 { transform: rotate(-270deg); }
/* Flex Direction */
html[dir="rtl"] .flex-row { flex-direction: row-reverse; }
html[dir="rtl"] .flex-row-reverse { flex-direction: row; }
/* Icons and Chevrons */
html[dir="rtl"] .fa-chevron-left::before { content: "\f054"; } /* chevron-right */
html[dir="rtl"] .fa-chevron-right::before { content: "\f053"; } /* chevron-left */
html[dir="rtl"] .fa-arrow-left::before { content: "\f061"; } /* arrow-right */
html[dir="rtl"] .fa-arrow-right::before { content: "\f060"; } /* arrow-left */
/* Dropdown Menus */
html[dir="rtl"] .dropdown-menu {
left: auto;
right: 0;
}
html[dir="rtl"] [id$="Dropdown"] {
left: auto;
right: 0;
}
/* Search and Input Fields */
html[dir="rtl"] .search-enhanced .search-icon {
left: auto;
right: 0.75rem;
}
html[dir="rtl"] .search-enhanced .search-actions {
right: auto;
left: 0.5rem;
}
/* Forms */
html[dir="rtl"] input[type="text"],
html[dir="rtl"] input[type="email"],
html[dir="rtl"] input[type="password"],
html[dir="rtl"] input[type="number"],
html[dir="rtl"] input[type="search"],
html[dir="rtl"] textarea,
html[dir="rtl"] select {
text-align: right;
}
/* Tables */
html[dir="rtl"] table {
direction: rtl;
}
html[dir="rtl"] th,
html[dir="rtl"] td {
text-align: right;
}
/* Tooltips */
html[dir="rtl"] .tooltip {
direction: rtl;
}
/* Cards and Containers */
html[dir="rtl"] .card {
direction: rtl;
}
/* Buttons with Icons */
html[dir="rtl"] .btn i {
margin-left: 0.5rem;
margin-right: 0;
}
html[dir="rtl"] .btn i:first-child {
margin-left: 0;
margin-right: 0.5rem;
}
html[dir="rtl"] .btn i:last-child {
margin-right: 0;
margin-left: 0.5rem;
}
/* Calendar and Date Pickers */
html[dir="rtl"] .calendar,
html[dir="rtl"] .datepicker {
direction: rtl;
}
/* Progress Bars */
html[dir="rtl"] .progress-bar {
direction: rtl;
}
/* Breadcrumbs */
html[dir="rtl"] .breadcrumb-item + .breadcrumb-item::before {
padding-right: 0;
padding-left: 0.5rem;
content: "\\";
}
/* Navigation */
html[dir="rtl"] nav ul {
padding-left: 0;
padding-right: 0;
}
html[dir="rtl"] nav li {
text-align: right;
}
/* Modal Dialogs */
html[dir="rtl"] .modal {
direction: rtl;
}
html[dir="rtl"] .modal-header,
html[dir="rtl"] .modal-body,
html[dir="rtl"] .modal-footer {
text-align: right;
}
/* Alerts and Notifications */
html[dir="rtl"] .alert {
direction: rtl;
text-align: right;
}
html[dir="rtl"] .toast-notification {
direction: rtl;
text-align: right;
}
/* Badges */
html[dir="rtl"] .badge {
direction: rtl;
}
+1 -1
View File
@@ -31,7 +31,7 @@
async function stopAt(ts){
try {
const r = await fetch('/api/timer/stop_at', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ stop_time: new Date(ts).toISOString() }) });
if (r.ok){ showToast('Timer stopped due to inactivity', 'warning'); location.reload(); }
if (r.ok){ showToast(window.i18n?.messages?.timerStoppedInactivity || 'Timer stopped due to inactivity', 'warning'); location.reload(); }
} catch(e) {}
}
+4 -4
View File
@@ -726,12 +726,12 @@
});
if (res.ok) {
this.showToast('Timer stopped', 'info');
this.showToast(window.i18n?.messages?.timerStopped || 'Timer stopped', 'info');
} else {
this.showToast('Failed to stop timer', 'warning');
this.showToast(window.i18n?.messages?.timerStopFailed || 'Failed to stop timer', 'warning');
}
} catch (e) {
this.showToast('Error stopping timer', 'danger');
this.showToast(window.i18n?.messages?.errorStoppingTimer || 'Error stopping timer', 'danger');
}
}
@@ -785,7 +785,7 @@
if (form) {
form.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true }));
} else {
this.showToast('No form to save', 'warning');
this.showToast(window.i18n?.messages?.noFormToSave || 'No form to save', 'warning');
}
}
+1 -1
View File
@@ -494,7 +494,7 @@
if (timerBtn) {
timerBtn.click();
} else {
window.TimeTrackerUI.showToast('No timer found', 'warning');
window.TimeTrackerUI.showToast(window.i18n?.messages?.noTimerFound || 'No timer found', 'warning');
}
}
+8
View File
@@ -226,6 +226,14 @@ class ToastNotificationManager {
}
getDefaultTitle(type) {
// Try to get translated titles from window.i18n if available
// These are injected by the backend in base template
if (window.i18n && window.i18n.toast) {
const titles = window.i18n.toast;
return titles[type] || titles.info || 'Information';
}
// Fallback to English if translations not loaded
const titles = {
success: 'Success',
error: 'Error',
+13 -13
View File
@@ -1,21 +1,21 @@
{% extends "base.html" %}
{% from "components/ui.html" import page_header, breadcrumb_nav, button, filter_badge %}
{% block title %}API Tokens - Admin{% endblock %}
{% block content %}
<div class="container mx-auto px-4 py-8">
<div class="flex justify-between items-center mb-6">
<div>
<h1 class="text-3xl font-bold text-gray-900 dark:text-white">API Tokens</h1>
<p class="text-gray-600 dark:text-gray-400 mt-2">Manage REST API authentication tokens</p>
</div>
<button onclick="showCreateTokenModal()" class="bg-blue-600 hover:bg-blue-700 text-white px-4 py-2 rounded-lg flex items-center">
<svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"></path>
</svg>
Create Token
</button>
</div>
{% set breadcrumbs = [
{'text': 'Admin', 'url': url_for('admin.admin_dashboard')},
{'text': 'API Tokens'}
] %}
{{ page_header(
icon_class='fas fa-key',
title_text='API Tokens',
subtitle_text='Manage REST API authentication tokens',
breadcrumbs=breadcrumbs,
actions_html='<button onclick="showCreateTokenModal()" class="bg-primary text-white px-4 py-2 rounded-lg hover:bg-primary/90 transition-colors"><i class="fas fa-plus mr-2"></i>Create Token</button>'
) }}
<!-- API Documentation Link -->
<div class="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-4 mb-6">
+13 -7
View File
@@ -1,15 +1,21 @@
{% extends "base.html" %}
{% from "components/ui.html" import page_header, breadcrumb_nav, button, filter_badge %}
{% block title %}Backups Management - Admin{% endblock %}
{% block content %}
<div class="container mx-auto px-4 py-8">
<div class="flex justify-between items-center mb-6">
<div>
<h1 class="text-3xl font-bold text-gray-900 dark:text-white">Backups Management</h1>
<p class="text-gray-600 dark:text-gray-400 mt-2">Create, download, and restore database backups</p>
</div>
</div>
{% set breadcrumbs = [
{'text': 'Admin', 'url': url_for('admin.admin_dashboard')},
{'text': 'Backups Management'}
] %}
{{ page_header(
icon_class='fas fa-database',
title_text='Backups Management',
subtitle_text='Create, download, and restore database backups',
breadcrumbs=breadcrumbs,
actions_html=None
) }}
<!-- Action Cards -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-6 mb-6">
+12 -6
View File
@@ -1,13 +1,19 @@
{% extends "base.html" %}
{% from "components/cards.html" import info_card %}
{% from "components/ui.html" import page_header, breadcrumb_nav, button, filter_badge %}
{% block content %}
<div class="flex flex-col md:flex-row justify-between items-start md:items-center mb-6">
<div>
<h1 class="text-2xl font-bold">Admin Dashboard</h1>
<p class="text-text-muted-light dark:text-text-muted-dark">System overview and management.</p>
</div>
</div>
{% set breadcrumbs = [
{'text': 'Admin Dashboard'}
] %}
{{ page_header(
icon_class='fas fa-cog',
title_text='Admin Dashboard',
subtitle_text='System overview and management',
breadcrumbs=breadcrumbs,
actions_html=None
) }}
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-6">
{{ info_card("Total Users", stats.total_users, "All time") }}
+14 -13
View File
@@ -1,27 +1,28 @@
{% extends "base.html" %}
{% from "components/ui.html" import page_header, breadcrumb_nav, button, filter_badge %}
{% block title %}{{ _('OIDC Debug Dashboard') }} - {{ app_name }}{% endblock %}
{% block content %}
<div class="flex flex-col md:flex-row justify-between items-start md:items-center mb-6">
<div>
<h1 class="text-2xl font-bold"><i class="fas fa-shield-alt mr-2"></i>{{ _('OIDC Debug Dashboard') }}</h1>
<p class="text-text-muted-light dark:text-text-muted-dark">{{ _('Inspect configuration, provider metadata and OIDC users') }}</p>
</div>
<div class="mt-3 md:mt-0">
<a href="{{ url_for('admin.admin_dashboard') }}" class="px-3 py-2 rounded-lg border border-border-light dark:border-border-dark text-sm hover:bg-background-light dark:hover:bg-background-dark">
<i class="fas fa-arrow-left mr-1"></i>{{ _('Back to Dashboard') }}
</a>
</div>
</div>
{% set breadcrumbs = [
{'text': _('Admin'), 'url': url_for('admin.admin_dashboard')},
{'text': _('OIDC Settings')}
] %}
{{ page_header(
icon_class='fas fa-shield-alt',
title_text=_('OIDC Debug Dashboard'),
subtitle_text=_('Inspect configuration, provider metadata and OIDC users'),
breadcrumbs=breadcrumbs,
actions_html='<a href="' + url_for("admin.oidc_test") + '" class="bg-primary text-white px-4 py-2 rounded-lg hover:bg-primary/90 transition-colors"><i class="fas fa-vial mr-2"></i>' + _('Test Configuration') + '</a>'
) }}
<!-- Configuration and Claims -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-6">
<!-- OIDC Configuration -->
<div class="bg-card-light dark:bg-card-dark border border-border-light dark:border-border-dark p-6 rounded-lg shadow">
<div class="flex items-center justify-between mb-4">
<div class="mb-4">
<h2 class="text-lg font-semibold"><i class="fas fa-cog mr-2"></i>{{ _('OIDC Configuration') }}</h2>
<a href="{{ url_for('admin.oidc_test') }}" class="px-3 py-2 rounded-lg bg-primary text-white text-sm hover:opacity-90"><i class="fas fa-vial mr-1"></i>{{ _('Test Configuration') }}</a>
</div>
<div class="divide-y divide-border-light dark:divide-border-dark">
<div class="py-2 flex items-start justify-between gap-6 text-sm">
+13 -12
View File
@@ -1,19 +1,20 @@
{% extends "base.html" %}
{% from "components/ui.html" import page_header, breadcrumb_nav, button, filter_badge %}
{% block content %}
<div class="mb-6">
<a href="{{ url_for('permissions.list_roles') }}" class="text-primary hover:underline flex items-center gap-2">
<svg xmlns="http://www.w3.org/2000/svg" class="h-5 w-5" viewBox="0 0 20 20" fill="currentColor">
<path fill-rule="evenodd" d="M9.707 16.707a1 1 0 01-1.414 0l-6-6a1 1 0 010-1.414l6-6a1 1 0 011.414 1.414L5.414 9H17a1 1 0 110 2H5.414l4.293 4.293a1 1 0 010 1.414z" clip-rule="evenodd" />
</svg>
{{ _('Back to Roles') }}
</a>
</div>
{% set breadcrumbs = [
{'text': _('Admin'), 'url': url_for('admin.admin_dashboard')},
{'text': _('Roles & Permissions'), 'url': url_for('permissions.list_roles')},
{'text': _('System Permissions')}
] %}
<div class="mb-6">
<h1 class="text-2xl font-bold">{{ _('System Permissions') }}</h1>
<p class="text-text-muted-light dark:text-text-muted-dark">{{ _('All available permissions in the system') }}</p>
</div>
{{ page_header(
icon_class='fas fa-lock',
title_text=_('System Permissions'),
subtitle_text=_('All available permissions in the system'),
breadcrumbs=breadcrumbs,
actions_html='<a href="' + url_for("permissions.list_roles") + '" class="bg-gray-600 text-white px-4 py-2 rounded-lg hover:bg-gray-700 transition-colors"><i class="fas fa-arrow-left mr-2"></i>' + _('Back to Roles') + '</a>'
) }}
<div class="space-y-6">
{% for category, permissions in permissions_by_category.items() %}
+17 -12
View File
@@ -1,18 +1,23 @@
{% extends "base.html" %}
{% from "components/ui.html" import page_header, breadcrumb_nav, button, filter_badge %}
{% block content %}
<div class="flex flex-col md:flex-row justify-between items-start md:items-center mb-6">
<div>
<h1 class="text-2xl font-bold">{{ _('Roles & Permissions') }}</h1>
<p class="text-text-muted-light dark:text-text-muted-dark">{{ _('Manage roles and their permissions') }}</p>
</div>
<div class="flex gap-2 mt-4 md:mt-0">
<a href="{{ url_for('permissions.list_permissions') }}" class="bg-secondary text-white px-4 py-2 rounded-lg">{{ _('View Permissions') }}</a>
{% if current_user.is_admin or has_permission('manage_roles') %}
<a href="{{ url_for('permissions.create_role') }}" class="bg-primary text-white px-4 py-2 rounded-lg">{{ _('Create Role') }}</a>
{% endif %}
</div>
</div>
{% set breadcrumbs = [
{'text': _('Admin'), 'url': url_for('admin.admin_dashboard')},
{'text': _('Roles & Permissions')}
] %}
{{ page_header(
icon_class='fas fa-shield-alt',
title_text=_('Roles & Permissions'),
subtitle_text=_('Manage roles and their permissions'),
breadcrumbs=breadcrumbs,
actions_html=''
+ '<div class="flex gap-2">'
+ '<a href="' + url_for("permissions.list_permissions") + '" class="bg-gray-600 text-white px-4 py-2 rounded-lg hover:bg-gray-700 transition-colors">' + _('View Permissions') + '</a>'
+ ('<a href="' + url_for("permissions.create_role") + '" class="bg-primary text-white px-4 py-2 rounded-lg hover:bg-primary/90 transition-colors"><i class="fas fa-plus mr-2"></i>' + _('Create Role') + '</a>' if (current_user.is_admin or has_permission('manage_roles')) else '')
+ '</div>'
) }}
<!-- Statistics Summary -->
<div class="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
+13 -6
View File
@@ -1,12 +1,19 @@
{% extends "base.html" %}
{% from "components/ui.html" import page_header, breadcrumb_nav, button, filter_badge %}
{% block content %}
<div class="flex flex-col md:flex-row justify-between items-start md:items-center mb-6">
<div>
<h1 class="text-2xl font-bold">Settings</h1>
<p class="text-text-muted-light dark:text-text-muted-dark">Configure system-wide application settings.</p>
</div>
</div>
{% set breadcrumbs = [
{'text': 'Admin', 'url': url_for('admin.admin_dashboard')},
{'text': 'System Settings'}
] %}
{{ page_header(
icon_class='fas fa-sliders-h',
title_text='System Settings',
subtitle_text='Configure system-wide application settings',
breadcrumbs=breadcrumbs,
actions_html=None
) }}
<!-- Main Settings Form -->
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow mb-6">
+13 -6
View File
@@ -1,13 +1,20 @@
{% extends "base.html" %}
{% from "components/cards.html" import info_card %}
{% from "components/ui.html" import page_header, breadcrumb_nav, button, filter_badge %}
{% block content %}
<div class="flex flex-col md:flex-row justify-between items-start md:items-center mb-6">
<div>
<h1 class="text-2xl font-bold">System Information</h1>
<p class="text-text-muted-light dark:text-text-muted-dark">Key metrics and statistics about the application.</p>
</div>
</div>
{% set breadcrumbs = [
{'text': 'Admin', 'url': url_for('admin.admin_dashboard')},
{'text': 'System Information'}
] %}
{{ page_header(
icon_class='fas fa-info-circle',
title_text='System Information',
subtitle_text='Key metrics and statistics about the application',
breadcrumbs=breadcrumbs,
actions_html=None
) }}
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
{{ info_card("Total Users", total_users, "All time") }}
+13 -7
View File
@@ -1,13 +1,19 @@
{% extends "base.html" %}
{% from "components/ui.html" import page_header, breadcrumb_nav, button, filter_badge %}
{% block content %}
<div class="flex flex-col md:flex-row justify-between items-start md:items-center mb-6">
<div>
<h1 class="text-2xl font-bold">Manage Users</h1>
<p class="text-text-muted-light dark:text-text-muted-dark">Add, edit, or remove user accounts.</p>
</div>
<a href="{{ url_for('admin.create_user') }}" class="bg-primary text-white px-4 py-2 rounded-lg mt-4 md:mt-0">Create User</a>
</div>
{% set breadcrumbs = [
{'text': 'Admin', 'url': url_for('admin.admin_dashboard')},
{'text': 'Users'}
] %}
{{ page_header(
icon_class='fas fa-users-cog',
title_text='Manage Users',
subtitle_text='Add, edit, or remove user accounts',
breadcrumbs=breadcrumbs,
actions_html='<a href="' + url_for("admin.create_user") + '" class="bg-primary text-white px-4 py-2 rounded-lg hover:bg-primary/90 transition-colors"><i class="fas fa-plus mr-2"></i>Create User</a>'
) }}
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow overflow-x-auto">
<table class="w-full text-left">
+27 -17
View File
@@ -1,26 +1,36 @@
{% extends "base.html" %}
{% from "components/ui.html" import page_header, breadcrumb_nav, button, filter_badge %}
{% block title %}{{ _('Analytics Dashboard') }} - {{ app_name }}{% endblock %}
{% block content %}
{% set breadcrumbs = [
{'text': _('Analytics')}
] %}
{% set analytics_actions %}
<div class="flex items-center gap-2">
<select id="timeRange" class="bg-background-light dark:bg-gray-700 border border-transparent dark:border-transparent rounded-lg py-2 px-3 text-sm text-text-light dark:text-text-dark">
<option value="7">{{ _('Last 7 days') }}</option>
<option value="30" selected>{{ _('Last 30 days') }}</option>
<option value="90">{{ _('Last 90 days') }}</option>
<option value="365">{{ _('Last year') }}</option>
</select>
<button id="refreshCharts" class="bg-primary text-white px-4 py-2 rounded-lg hover:bg-primary/90 transition-colors">
<i class="fas fa-sync-alt mr-2"></i> {{ _('Refresh') }}
</button>
</div>
{% endset %}
{{ page_header(
icon_class='fas fa-chart-line',
title_text=_('Analytics Dashboard'),
subtitle_text=_('Key metrics and insights about your time tracking'),
breadcrumbs=breadcrumbs,
actions_html=analytics_actions
) }}
<div class="container-fluid">
{% from "_components.html" import page_header %}
<div class="row">
<div class="col-12">
{% set actions %}
<select id="timeRange" class="form-select form-select-sm" style="width: auto;">
<option value="7">{{ _('Last 7 days') }}</option>
<option value="30" selected>{{ _('Last 30 days') }}</option>
<option value="90">{{ _('Last 90 days') }}</option>
<option value="365">{{ _('Last year') }}</option>
</select>
<button id="refreshCharts" class="btn btn-outline-light btn-sm">
<i class="fas fa-sync-alt"></i> {{ _('Refresh') }}
</button>
{% endset %}
{{ page_header('fas fa-chart-line', _('Analytics Dashboard'), _('Key metrics and insights'), actions) }}
</div>
</div>
<!-- Summary Cards -->
<div class="row mb-4">
+192 -41
View File
@@ -1,5 +1,5 @@
<!DOCTYPE html>
<html lang="{{ current_locale or 'en' }}">
<html lang="{{ current_language_code or 'en' }}" dir="{{ 'rtl' if is_rtl else 'ltr' }}">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
@@ -30,6 +30,20 @@
<link rel="stylesheet" href="{{ url_for('static', filename='toast-notifications.css') }}">
<link rel="stylesheet" href="{{ url_for('static', filename='keyboard-shortcuts.css') }}">
<style>
/* RTL Support */
html[dir="rtl"] {
direction: rtl;
}
html[dir="rtl"] .ml-auto { margin-left: 0; margin-right: auto; }
html[dir="rtl"] .mr-auto { margin-right: 0; margin-left: auto; }
html[dir="rtl"] .text-left { text-align: right; }
html[dir="rtl"] .text-right { text-align: left; }
html[dir="rtl"] #sidebar { left: auto; right: 0; }
html[dir="rtl"] #mainContent { margin-left: 0; margin-right: 16rem; }
@media (max-width: 1024px) {
html[dir="rtl"] #mainContent { margin-right: 0; }
}
/* Minimal styles to properly align enhanced search UI */
.search-enhanced .search-input-wrapper { position: relative; }
.search-enhanced .search-icon { position: absolute; left: 0.75rem; top: 50%; transform: translateY(-50%); color: #A0AEC0; pointer-events: none; }
@@ -82,6 +96,51 @@
}
{% endif %}
</script>
<!-- i18n translations for JavaScript -->
<script>
window.i18n = {
toast: {
success: '{{ _("Success") }}',
error: '{{ _("Error") }}',
warning: '{{ _("Warning") }}',
info: '{{ _("Information") }}'
},
common: {
loading: '{{ _("Loading...") }}',
saving: '{{ _("Saving...") }}',
deleting: '{{ _("Deleting...") }}',
cancel: '{{ _("Cancel") }}',
confirm: '{{ _("Confirm") }}',
close: '{{ _("Close") }}',
save: '{{ _("Save") }}',
delete: '{{ _("Delete") }}',
edit: '{{ _("Edit") }}',
add: '{{ _("Add") }}',
remove: '{{ _("Remove") }}',
yes: '{{ _("Yes") }}',
no: '{{ _("No") }}',
ok: '{{ _("OK") }}'
},
messages: {
confirmDelete: '{{ _("Are you sure you want to delete this?") }}',
unsavedChanges: '{{ _("You have unsaved changes. Are you sure you want to leave?") }}',
operationFailed: '{{ _("Operation failed") }}',
operationSuccess: '{{ _("Operation completed successfully") }}',
noItemsSelected: '{{ _("No items selected") }}',
invalidInput: '{{ _("Invalid input") }}',
requiredField: '{{ _("This field is required") }}',
noActiveTimer: '{{ _("No active timer") }}',
timerStopped: '{{ _("Timer stopped") }}',
timerStopFailed: '{{ _("Failed to stop timer") }}',
errorStoppingTimer: '{{ _("Error stopping timer") }}',
noFormToSave: '{{ _("No form to save") }}',
noTimerFound: '{{ _("No timer found") }}',
timerStoppedInactivity: '{{ _("Timer stopped due to inactivity") }}'
}
};
</script>
{% block extra_css %}{% endblock %}
</head>
<body class="bg-background-light dark:bg-background-dark text-text-light dark:text-text-dark">
@@ -99,10 +158,11 @@
</div>
<nav class="flex-1">
{% set ep = request.endpoint or '' %}
{% set work_open = ep.startswith('projects.') or ep.startswith('clients.') or ep.startswith('tasks.') or ep.startswith('timer.') or ep.startswith('kanban.') or ep.startswith('time_entry_templates.') %}
{% set finance_open = ep.startswith('reports.') or ep.startswith('invoices.') or ep.startswith('payments.') or ep.startswith('expenses.') %}
{% set work_open = ep.startswith('projects.') or ep.startswith('clients.') or ep.startswith('tasks.') or ep.startswith('timer.') or ep.startswith('kanban.') or ep.startswith('time_entry_templates.') or ep.startswith('weekly_goals.') %}
{% set finance_open = ep.startswith('reports.') or ep.startswith('invoices.') or ep.startswith('payments.') or ep.startswith('expenses.') or ep.startswith('budget_alerts.') or ep.startswith('mileage.') or ep.startswith('per_diem.') %}
{% set analytics_open = ep.startswith('analytics.') %}
{% set admin_open = ep.startswith('admin.') or ep.startswith('permissions.') %}
{% set tools_open = ep.startswith('import_export.') or ep.startswith('saved_filters.') %}
{% set admin_open = ep.startswith('admin.') or ep.startswith('permissions.') or (ep.startswith('expense_categories.') and current_user.is_admin) %}
<div class="flex items-center justify-between mb-4">
<h2 class="text-xs font-semibold text-text-muted-light dark:text-text-muted-dark uppercase tracking-wider sidebar-label">{{ _('Navigation') }}</h2>
<button id="sidebarCollapseBtn" class="p-1.5 rounded hover:bg-background-light dark:hover:bg-background-dark" aria-label="Toggle sidebar" title="Toggle sidebar">
@@ -116,12 +176,6 @@
<span class="ml-3 sidebar-label">{{ _('Dashboard') }}</span>
</a>
</li>
<li class="mt-2">
<a href="{{ url_for('weekly_goals.index') }}" class="flex items-center p-2 rounded-lg {% if ep.startswith('weekly_goals.') %}bg-background-light dark:bg-background-dark text-primary font-semibold{% else %}text-text-light dark:text-text-dark hover:bg-background-light dark:hover:bg-background-dark{% endif %}">
<i class="fas fa-bullseye w-6 text-center"></i>
<span class="ml-3 sidebar-label">{{ _('Weekly Goals') }}</span>
</a>
</li>
<li class="mt-2">
<a href="{{ url_for('calendar.view_calendar') }}" class="flex items-center p-2 rounded-lg {% if ep.startswith('calendar.') %}bg-background-light dark:bg-background-dark text-primary font-semibold{% else %}text-text-light dark:text-text-dark hover:bg-background-light dark:hover:bg-background-dark{% endif %}">
<i class="fas fa-calendar-alt w-6 text-center"></i>
@@ -131,40 +185,58 @@
<li class="mt-2">
<button onclick="toggleDropdown('workDropdown')" data-dropdown="workDropdown" class="w-full flex items-center p-2 rounded-lg {% if work_open %}bg-background-light dark:bg-background-dark text-primary font-semibold{% else %}text-text-light dark:text-text-dark hover:bg-background-light dark:hover:bg-background-dark{% endif %}">
<i class="fas fa-briefcase w-6 text-center"></i>
<span class="ml-3 sidebar-label">{{ _('Work') }}</span>
<span class="ml-3 sidebar-label">{{ _('Time Tracking') }}</span>
<i class="fas fa-chevron-down ml-auto sidebar-label"></i>
</button>
<ul id="workDropdown" class="{% if not work_open %}hidden {% endif %}mt-2 space-y-2 ml-6">
{% set nav_active_timer = ep.startswith('timer.') %}
{% set nav_active_projects = ep.startswith('projects.') %}
{% set nav_active_clients = ep.startswith('clients.') %}
{% set nav_active_tasks = ep.startswith('tasks.') %}
{% set nav_active_templates = ep.startswith('time_entry_templates.') %}
{% set nav_active_kanban = ep.startswith('kanban.') %}
{% set nav_active_timer = ep.startswith('timer.') %}
{% set nav_active_templates = ep.startswith('time_entry_templates.') %}
{% set nav_active_goals = ep.startswith('weekly_goals.') %}
<li>
<a class="block px-2 py-1 rounded {% if nav_active_projects %}text-primary font-semibold bg-background-light dark:bg-background-dark{% else %}text-text-light dark:text-text-dark hover:bg-background-light dark:hover:bg-background-dark{% endif %}" href="{{ url_for('projects.list_projects') }}">{{ _('Projects') }}</a>
<a class="block px-2 py-1 rounded {% if nav_active_timer %}text-primary font-semibold bg-background-light dark:bg-background-dark{% else %}text-text-light dark:text-text-dark hover:bg-background-light dark:hover:bg-background-dark{% endif %}" href="{{ url_for('timer.manual_entry') }}">
<i class="fas fa-clock w-4 mr-2"></i>{{ _('Log Time') }}
</a>
</li>
<li>
<a class="block px-2 py-1 rounded {% if nav_active_clients %}text-primary font-semibold bg-background-light dark:bg-background-dark{% else %}text-text-light dark:text-text-dark hover:bg-background-light dark:hover:bg-background-dark{% endif %}" href="{{ url_for('clients.list_clients') }}">{{ _('Clients') }}</a>
<a class="block px-2 py-1 rounded {% if nav_active_projects %}text-primary font-semibold bg-background-light dark:bg-background-dark{% else %}text-text-light dark:text-text-dark hover:bg-background-light dark:hover:bg-background-dark{% endif %}" href="{{ url_for('projects.list_projects') }}">
<i class="fas fa-folder w-4 mr-2"></i>{{ _('Projects') }}
</a>
</li>
<li>
<a class="block px-2 py-1 rounded {% if nav_active_tasks %}text-primary font-semibold bg-background-light dark:bg-background-dark{% else %}text-text-light dark:text-text-dark hover:bg-background-light dark:hover:bg-background-dark{% endif %}" href="{{ url_for('tasks.list_tasks') }}">{{ _('Tasks') }}</a>
<a class="block px-2 py-1 rounded {% if nav_active_clients %}text-primary font-semibold bg-background-light dark:bg-background-dark{% else %}text-text-light dark:text-text-dark hover:bg-background-light dark:hover:bg-background-dark{% endif %}" href="{{ url_for('clients.list_clients') }}">
<i class="fas fa-users w-4 mr-2"></i>{{ _('Clients') }}
</a>
</li>
<li>
<a class="block px-2 py-1 rounded {% if nav_active_templates %}text-primary font-semibold bg-background-light dark:bg-background-dark{% else %}text-text-light dark:text-text-dark hover:bg-background-light dark:hover:bg-background-dark{% endif %}" href="{{ url_for('time_entry_templates.list_templates') }}">{{ _('Time Entry Templates') }}</a>
<a class="block px-2 py-1 rounded {% if nav_active_tasks %}text-primary font-semibold bg-background-light dark:bg-background-dark{% else %}text-text-light dark:text-text-dark hover:bg-background-light dark:hover:bg-background-dark{% endif %}" href="{{ url_for('tasks.list_tasks') }}">
<i class="fas fa-tasks w-4 mr-2"></i>{{ _('Tasks') }}
</a>
</li>
<li>
<a class="block px-2 py-1 rounded {% if nav_active_kanban %}text-primary font-semibold bg-background-light dark:bg-background-dark{% else %}text-text-light dark:text-text-dark hover:bg-background-light dark:hover:bg-background-dark{% endif %}" href="{{ url_for('kanban.board') }}">{{ _('Kanban') }}</a>
<a class="block px-2 py-1 rounded {% if nav_active_kanban %}text-primary font-semibold bg-background-light dark:bg-background-dark{% else %}text-text-light dark:text-text-dark hover:bg-background-light dark:hover:bg-background-dark{% endif %}" href="{{ url_for('kanban.board') }}">
<i class="fas fa-columns w-4 mr-2"></i>{{ _('Kanban Board') }}
</a>
</li>
<li>
<a class="block px-2 py-1 rounded {% if nav_active_timer %}text-primary font-semibold bg-background-light dark:bg-background-dark{% else %}text-text-light dark:text-text-dark hover:bg-background-light dark:hover:bg-background-dark{% endif %}" href="{{ url_for('timer.manual_entry') }}">{{ _('Log Time') }}</a>
<a class="block px-2 py-1 rounded {% if nav_active_goals %}text-primary font-semibold bg-background-light dark:bg-background-dark{% else %}text-text-light dark:text-text-dark hover:bg-background-light dark:hover:bg-background-dark{% endif %}" href="{{ url_for('weekly_goals.index') }}">
<i class="fas fa-bullseye w-4 mr-2"></i>{{ _('Weekly Goals') }}
</a>
</li>
<li>
<a class="block px-2 py-1 rounded {% if nav_active_templates %}text-primary font-semibold bg-background-light dark:bg-background-dark{% else %}text-text-light dark:text-text-dark hover:bg-background-light dark:hover:bg-background-dark{% endif %}" href="{{ url_for('time_entry_templates.list_templates') }}">
<i class="fas fa-file-lines w-4 mr-2"></i>{{ _('Templates') }}
</a>
</li>
</ul>
</li>
<li class="mt-2">
<button onclick="toggleDropdown('financeDropdown')" data-dropdown="financeDropdown" class="w-full flex items-center p-2 rounded-lg {% if finance_open %}bg-background-light dark:bg-background-dark text-primary font-semibold{% else %}text-text-light dark:text-text-dark hover:bg-background-light dark:hover:bg-background-dark{% endif %}">
<i class="fas fa-dollar-sign w-6 text-center"></i>
<span class="ml-3 sidebar-label">{{ _('Finance') }}</span>
<span class="ml-3 sidebar-label">{{ _('Finance & Expenses') }}</span>
<i class="fas fa-chevron-down ml-auto sidebar-label"></i>
</button>
<ul id="financeDropdown" class="{% if not finance_open %}hidden {% endif %}mt-2 space-y-2 ml-6">
@@ -172,17 +244,43 @@
{% set nav_active_invoices = ep.startswith('invoices.') %}
{% set nav_active_payments = ep.startswith('payments.') %}
{% set nav_active_expenses = ep.startswith('expenses.') %}
{% set nav_active_mileage = ep.startswith('mileage.') %}
{% set nav_active_perdiem = ep.startswith('per_diem.') and not ep.startswith('per_diem.list_rates') %}
{% set nav_active_budget = ep.startswith('budget_alerts.') %}
<li>
<a class="block px-2 py-1 rounded {% if nav_active_reports %}text-primary font-semibold bg-background-light dark:bg-background-dark{% else %}text-text-light dark:text-text-dark hover:bg-background-light dark:hover:bg-background-dark{% endif %}" href="{{ url_for('reports.reports') }}">{{ _('Reports') }}</a>
<a class="block px-2 py-1 rounded {% if nav_active_reports %}text-primary font-semibold bg-background-light dark:bg-background-dark{% else %}text-text-light dark:text-text-dark hover:bg-background-light dark:hover:bg-background-dark{% endif %}" href="{{ url_for('reports.reports') }}">
<i class="fas fa-chart-bar w-4 mr-2"></i>{{ _('Reports') }}
</a>
</li>
<li>
<a class="block px-2 py-1 rounded {% if nav_active_invoices %}text-primary font-semibold bg-background-light dark:bg-background-dark{% else %}text-text-light dark:text-text-dark hover:bg-background-light dark:hover:bg-background-dark{% endif %}" href="{{ url_for('invoices.list_invoices') }}">{{ _('Invoices') }}</a>
<a class="block px-2 py-1 rounded {% if nav_active_invoices %}text-primary font-semibold bg-background-light dark:bg-background-dark{% else %}text-text-light dark:text-text-dark hover:bg-background-light dark:hover:bg-background-dark{% endif %}" href="{{ url_for('invoices.list_invoices') }}">
<i class="fas fa-file-invoice w-4 mr-2"></i>{{ _('Invoices') }}
</a>
</li>
<li>
<a class="block px-2 py-1 rounded {% if nav_active_payments %}text-primary font-semibold bg-background-light dark:bg-background-dark{% else %}text-text-light dark:text-text-dark hover:bg-background-light dark:hover:bg-background-dark{% endif %}" href="{{ url_for('payments.list_payments') }}">{{ _('Payments') }}</a>
<a class="block px-2 py-1 rounded {% if nav_active_payments %}text-primary font-semibold bg-background-light dark:bg-background-dark{% else %}text-text-light dark:text-text-dark hover:bg-background-light dark:hover:bg-background-dark{% endif %}" href="{{ url_for('payments.list_payments') }}">
<i class="fas fa-credit-card w-4 mr-2"></i>{{ _('Payments') }}
</a>
</li>
<li>
<a class="block px-2 py-1 rounded {% if nav_active_expenses %}text-primary font-semibold bg-background-light dark:bg-background-dark{% else %}text-text-light dark:text-text-dark hover:bg-background-light dark:hover:bg-background-dark{% endif %}" href="{{ url_for('expenses.list_expenses') }}">{{ _('Expenses') }}</a>
<a class="block px-2 py-1 rounded {% if nav_active_expenses %}text-primary font-semibold bg-background-light dark:bg-background-dark{% else %}text-text-light dark:text-text-dark hover:bg-background-light dark:hover:bg-background-dark{% endif %}" href="{{ url_for('expenses.list_expenses') }}">
<i class="fas fa-receipt w-4 mr-2"></i>{{ _('Expenses') }}
</a>
</li>
<li>
<a class="block px-2 py-1 rounded {% if nav_active_mileage %}text-primary font-semibold bg-background-light dark:bg-background-dark{% else %}text-text-light dark:text-text-dark hover:bg-background-light dark:hover:bg-background-dark{% endif %}" href="{{ url_for('mileage.list_mileage') }}">
<i class="fas fa-car w-4 mr-2"></i>{{ _('Mileage') }}
</a>
</li>
<li>
<a class="block px-2 py-1 rounded {% if nav_active_perdiem %}text-primary font-semibold bg-background-light dark:bg-background-dark{% else %}text-text-light dark:text-text-dark hover:bg-background-light dark:hover:bg-background-dark{% endif %}" href="{{ url_for('per_diem.list_per_diem') }}">
<i class="fas fa-utensils w-4 mr-2"></i>{{ _('Per Diem') }}
</a>
</li>
<li>
<a class="block px-2 py-1 rounded {% if nav_active_budget %}text-primary font-semibold bg-background-light dark:bg-background-dark{% else %}text-text-light dark:text-text-dark hover:bg-background-light dark:hover:bg-background-dark{% endif %}" href="{{ url_for('budget_alerts.budget_dashboard') }}">
<i class="fas fa-exclamation-triangle w-4 mr-2"></i>{{ _('Budget Alerts') }}
</a>
</li>
</ul>
</li>
@@ -192,6 +290,27 @@
<span class="ml-3 sidebar-label">{{ _('Analytics') }}</span>
</a>
</li>
<li class="mt-2">
<button onclick="toggleDropdown('toolsDropdown')" data-dropdown="toolsDropdown" class="w-full flex items-center p-2 rounded-lg {% if tools_open %}bg-background-light dark:bg-background-dark text-primary font-semibold{% else %}text-text-light dark:text-text-dark hover:bg-background-light dark:hover:bg-background-dark{% endif %}">
<i class="fas fa-tools w-6 text-center"></i>
<span class="ml-3 sidebar-label">{{ _('Tools & Data') }}</span>
<i class="fas fa-chevron-down ml-auto sidebar-label"></i>
</button>
<ul id="toolsDropdown" class="{% if not tools_open %}hidden {% endif %}mt-2 space-y-2 ml-6">
{% set nav_active_import_export = ep.startswith('import_export.') %}
{% set nav_active_filters = ep.startswith('saved_filters.') %}
<li>
<a class="block px-2 py-1 rounded {% if nav_active_import_export %}text-primary font-semibold bg-background-light dark:bg-background-dark{% else %}text-text-light dark:text-text-dark hover:bg-background-light dark:hover:bg-background-dark{% endif %}" href="{{ url_for('import_export.import_export_page') }}">
<i class="fas fa-exchange-alt w-4 mr-2"></i>{{ _('Import / Export') }}
</a>
</li>
<li>
<a class="block px-2 py-1 rounded {% if nav_active_filters %}text-primary font-semibold bg-background-light dark:bg-background-dark{% else %}text-text-light dark:text-text-dark hover:bg-background-light dark:hover:bg-background-dark{% endif %}" href="{{ url_for('saved_filters.list_filters') }}">
<i class="fas fa-filter w-4 mr-2"></i>{{ _('Saved Filters') }}
</a>
</li>
</ul>
</li>
{% if current_user.is_admin or has_any_permission(['view_users', 'manage_settings', 'view_system_info', 'manage_backups']) %}
<li class="mt-2">
<button onclick="toggleDropdown('adminDropdown')" data-dropdown="adminDropdown" class="w-full flex items-center p-2 rounded-lg {% if admin_open %}bg-background-light dark:bg-background-dark text-primary font-semibold{% else %}text-text-light dark:text-text-dark hover:bg-background-light dark:hover:bg-background-dark{% endif %}">
@@ -201,42 +320,70 @@
</button>
<ul id="adminDropdown" class="{% if not admin_open %}hidden {% endif %}mt-2 space-y-2 ml-6">
<li>
<a class="block px-2 py-1 rounded {% if ep == 'admin.admin_dashboard' %}text-primary font-semibold bg-background-light dark:bg-background-dark{% else %}text-text-light dark:text-text-dark hover:bg-background-light dark:hover:bg-background-dark{% endif %}" href="{{ url_for('admin.admin_dashboard') }}">{{ _('Dashboard') }}</a>
<a class="block px-2 py-1 rounded {% if ep == 'admin.admin_dashboard' %}text-primary font-semibold bg-background-light dark:bg-background-dark{% else %}text-text-light dark:text-text-dark hover:bg-background-light dark:hover:bg-background-dark{% endif %}" href="{{ url_for('admin.admin_dashboard') }}">
<i class="fas fa-tachometer-alt w-4 mr-2"></i>{{ _('Admin Dashboard') }}
</a>
</li>
{% if current_user.is_admin or has_permission('view_users') %}
<li>
<a class="block px-2 py-1 rounded {% if ep == 'admin.list_users' %}text-primary font-semibold bg-background-light dark:bg-background-dark{% else %}text-text-light dark:text-text-dark hover:bg-background-light dark:hover:bg-background-dark{% endif %}" href="{{ url_for('admin.list_users') }}">{{ _('Users') }}</a>
<a class="block px-2 py-1 rounded {% if ep == 'admin.list_users' %}text-primary font-semibold bg-background-light dark:bg-background-dark{% else %}text-text-light dark:text-text-dark hover:bg-background-light dark:hover:bg-background-dark{% endif %}" href="{{ url_for('admin.list_users') }}">
<i class="fas fa-users-cog w-4 mr-2"></i>{{ _('Users') }}
</a>
</li>
{% endif %}
{% if current_user.is_admin %}
<li>
<a class="block px-2 py-1 rounded {% if ep == 'admin.api_tokens' %}text-primary font-semibold bg-background-light dark:bg-background-dark{% else %}text-text-light dark:text-text-dark hover:bg-background-light dark:hover:bg-background-dark{% endif %}" href="{{ url_for('admin.api_tokens') }}">{{ _('API Tokens') }}</a>
<a class="block px-2 py-1 rounded {% if ep == 'admin.api_tokens' %}text-primary font-semibold bg-background-light dark:bg-background-dark{% else %}text-text-light dark:text-text-dark hover:bg-background-light dark:hover:bg-background-dark{% endif %}" href="{{ url_for('admin.api_tokens') }}">
<i class="fas fa-key w-4 mr-2"></i>{{ _('API Tokens') }}
</a>
</li>
<li>
<a class="block px-2 py-1 rounded {% if ep.startswith('permissions.') %}text-primary font-semibold bg-background-light dark:bg-background-dark{% else %}text-text-light dark:text-text-dark hover:bg-background-light dark:hover:bg-background-dark{% endif %}" href="{{ url_for('permissions.list_roles') }}">{{ _('Roles & Permissions') }}</a>
<a class="block px-2 py-1 rounded {% if ep.startswith('permissions.') %}text-primary font-semibold bg-background-light dark:bg-background-dark{% else %}text-text-light dark:text-text-dark hover:bg-background-light dark:hover:bg-background-dark{% endif %}" href="{{ url_for('permissions.list_roles') }}">
<i class="fas fa-shield-alt w-4 mr-2"></i>{{ _('Roles & Permissions') }}
</a>
</li>
{% endif %}
{% if current_user.is_admin or has_permission('manage_settings') %}
<li>
<a class="block px-2 py-1 rounded {% if ep == 'admin.settings' %}text-primary font-semibold bg-background-light dark:bg-background-dark{% else %}text-text-light dark:text-text-dark hover:bg-background-light dark:hover:bg-background-dark{% endif %}" href="{{ url_for('admin.settings') }}">{{ _('Settings') }}</a>
<a class="block px-2 py-1 rounded {% if ep == 'admin.settings' %}text-primary font-semibold bg-background-light dark:bg-background-dark{% else %}text-text-light dark:text-text-dark hover:bg-background-light dark:hover:bg-background-dark{% endif %}" href="{{ url_for('admin.settings') }}">
<i class="fas fa-sliders-h w-4 mr-2"></i>{{ _('System Settings') }}
</a>
</li>
<li>
<a class="block px-2 py-1 rounded {% if ep == 'admin.pdf_layout' %}text-primary font-semibold bg-background-light dark:bg-background-dark{% else %}text-text-light dark:text-text-dark hover:bg-background-light dark:hover:bg-background-dark{% endif %}" href="{{ url_for('admin.pdf_layout') }}">{{ _('PDF Layout') }}</a>
<a class="block px-2 py-1 rounded {% if ep == 'admin.pdf_layout' %}text-primary font-semibold bg-background-light dark:bg-background-dark{% else %}text-text-light dark:text-text-dark hover:bg-background-light dark:hover:bg-background-dark{% endif %}" href="{{ url_for('admin.pdf_layout') }}">
<i class="fas fa-file-pdf w-4 mr-2"></i>{{ _('PDF Layout') }}
</a>
</li>
<li>
<a class="block px-2 py-1 rounded {% if ep == 'expense_categories.list_categories' %}text-primary font-semibold bg-background-light dark:bg-background-dark{% else %}text-text-light dark:text-text-dark hover:bg-background-light dark:hover:bg-background-dark{% endif %}" href="{{ url_for('expense_categories.list_categories') }}">
<i class="fas fa-tags w-4 mr-2"></i>{{ _('Expense Categories') }}
</a>
</li>
<li>
<a class="block px-2 py-1 rounded {% if ep == 'per_diem.list_rates' %}text-primary font-semibold bg-background-light dark:bg-background-dark{% else %}text-text-light dark:text-text-dark hover:bg-background-light dark:hover:bg-background-dark{% endif %}" href="{{ url_for('per_diem.list_rates') }}">
<i class="fas fa-list-ul w-4 mr-2"></i>{{ _('Per Diem Rates') }}
</a>
</li>
{% endif %}
{% if current_user.is_admin or has_permission('view_system_info') %}
<li>
<a class="block px-2 py-1 rounded {% if ep == 'admin.system_info' %}text-primary font-semibold bg-background-light dark:bg-background-dark{% else %}text-text-light dark:text-text-dark hover:bg-background-light dark:hover:bg-background-dark{% endif %}" href="{{ url_for('admin.system_info') }}">{{ _('System Info') }}</a>
<a class="block px-2 py-1 rounded {% if ep == 'admin.system_info' %}text-primary font-semibold bg-background-light dark:bg-background-dark{% else %}text-text-light dark:text-text-dark hover:bg-background-light dark:hover:bg-background-dark{% endif %}" href="{{ url_for('admin.system_info') }}">
<i class="fas fa-info-circle w-4 mr-2"></i>{{ _('System Info') }}
</a>
</li>
{% endif %}
{% if current_user.is_admin or has_permission('manage_backups') %}
<li>
<a class="block px-2 py-1 rounded {% if ep == 'admin.backups_management' %}text-primary font-semibold bg-background-light dark:bg-background-dark{% else %}text-text-light dark:text-text-dark hover:bg-background-light dark:hover:bg-background-dark{% endif %}" href="{{ url_for('admin.backups_management') }}">{{ _('Backups') }}</a>
<a class="block px-2 py-1 rounded {% if ep == 'admin.backups_management' %}text-primary font-semibold bg-background-light dark:bg-background-dark{% else %}text-text-light dark:text-text-dark hover:bg-background-light dark:hover:bg-background-dark{% endif %}" href="{{ url_for('admin.backups_management') }}">
<i class="fas fa-database w-4 mr-2"></i>{{ _('Backups') }}
</a>
</li>
{% endif %}
{% if current_user.is_admin or has_permission('manage_oidc') %}
<li>
<a class="block px-2 py-1 rounded {% if ep == 'admin.oidc_debug' %}text-primary font-semibold bg-background-light dark:bg-background-dark{% else %}text-text-light dark:text-text-dark hover:bg-background-light dark:hover:bg-background-dark{% endif %}" href="{{ url_for('admin.oidc_debug') }}">OIDC</a>
<a class="block px-2 py-1 rounded {% if ep == 'admin.oidc_debug' %}text-primary font-semibold bg-background-light dark:bg-background-dark{% else %}text-text-light dark:text-text-dark hover:bg-background-light dark:hover:bg-background-dark{% endif %}" href="{{ url_for('admin.oidc_debug') }}">
<i class="fas fa-lock w-4 mr-2"></i>{{ _('OIDC Settings') }}
</a>
</li>
{% endif %}
</ul>
@@ -306,15 +453,19 @@
<!-- Language Switcher -->
<div class="relative z-50">
<button onclick="toggleDropdown('langDropdown')" class="flex items-center text-text-light dark:text-text-dark hover:bg-gray-100 dark:hover:bg-gray-700 focus:outline-none focus:ring-4 focus:ring-gray-200 dark:focus:ring-gray-700 rounded-lg text-sm p-2.5" aria-haspopup="true" aria-expanded="false" aria-controls="langDropdown">
<button onclick="toggleDropdown('langDropdown')" class="flex items-center text-text-light dark:text-text-dark hover:bg-gray-100 dark:hover:bg-gray-700 focus:outline-none focus:ring-4 focus:ring-gray-200 dark:focus:ring-gray-700 rounded-lg text-sm p-2.5" aria-haspopup="true" aria-expanded="false" aria-controls="langDropdown" title="{{ _('Change language') }}">
<i class="fas fa-globe"></i>
<span class="ml-2 hidden lg:inline">{{ current_language_label }}</span>
</button>
<ul id="langDropdown" class="hidden absolute right-0 mt-2 w-48 bg-card-light dark:bg-card-dark border border-border-light dark:border-border-dark rounded-md shadow-lg">
<li class="dropdown-header p-2 text-sm text-text-muted-light dark:text-text-muted-dark">{{ _('Language') }}</li>
{% for code, label in config['LANGUAGES'].items() %}
<ul id="langDropdown" class="hidden absolute right-0 mt-2 w-56 bg-card-light dark:bg-card-dark border border-border-light dark:border-border-dark rounded-md shadow-lg max-h-96 overflow-y-auto">
<li class="px-4 py-2 text-xs font-semibold text-text-muted-light dark:text-text-muted-dark uppercase tracking-wider border-b border-border-light dark:border-border-dark">{{ _('Language') }}</li>
{% for code, label in available_languages.items() %}
<li>
<a class="block px-4 py-2 text-sm text-text-light dark:text-text-dark hover:bg-gray-100 dark:hover:bg-gray-700" href="{{ url_for('main.set_language') }}?lang={{ code }}">
{{ label }}
<a class="block px-4 py-2 text-sm text-text-light dark:text-text-dark hover:bg-gray-100 dark:hover:bg-gray-700 flex items-center justify-between" href="{{ url_for('user.set_language_direct', language=code) }}">
<span>{{ label }}</span>
{% if code == current_language_code %}
<i class="fas fa-check text-primary"></i>
{% endif %}
</a>
</li>
{% endfor %}
@@ -349,8 +500,8 @@
<div class="text-sm font-medium text-text-light dark:text-text-dark">{{ current_user.display_name if current_user.is_authenticated else _('Guest') }}</div>
<div class="text-xs text-text-muted-light dark:text-text-muted-dark">{{ current_user.email if current_user.is_authenticated else '' }}</div>
</li>
<li><a href="{{ url_for('auth.profile') }}" class="flex items-center gap-2 px-4 py-2 text-sm text-text-light dark:text-text-dark hover:bg-gray-100 dark:hover:bg-gray-700"><i class="fas fa-user w-4"></i> {{ _('Profile') }}</a></li>
<li><a href="{{ url_for('user.settings') }}" class="flex items-center gap-2 px-4 py-2 text-sm text-text-light dark:text-text-dark hover:bg-gray-100 dark:hover:bg-gray-700"><i class="fas fa-cog w-4"></i> {{ _('Settings') }}</a></li>
<li><a href="{{ url_for('auth.profile') }}" class="flex items-center gap-2 px-4 py-2 text-sm text-text-light dark:text-text-dark hover:bg-gray-100 dark:hover:bg-gray-700"><i class="fas fa-user w-4"></i> {{ _('My Profile') }}</a></li>
<li><a href="{{ url_for('user.settings') }}" class="flex items-center gap-2 px-4 py-2 text-sm text-text-light dark:text-text-dark hover:bg-gray-100 dark:hover:bg-gray-700"><i class="fas fa-cog w-4"></i> {{ _('My Settings') }}</a></li>
<li class="border-t border-border-light dark:border-border-dark"><a href="{{ url_for('auth.logout') }}" class="flex items-center gap-2 px-4 py-2 text-sm text-rose-600 dark:text-rose-400 hover:bg-gray-100 dark:hover:bg-gray-700"><i class="fas fa-sign-out-alt w-4"></i> {{ _('Logout') }}</a></li>
</ul>
</div>
+267
View File
@@ -0,0 +1,267 @@
{% extends "base.html" %}
{% from "components/cards.html" import info_card, stat_card %}
{% from "components/ui.html" import page_header, breadcrumb_nav, button, filter_badge %}
{% block content %}
{% set breadcrumbs = [
{'text': _('Budget Alerts')}
] %}
{{ page_header(
icon_class='fas fa-exclamation-triangle',
title_text=_('Budget Alerts & Forecasting'),
subtitle_text=_('Monitor project budgets and forecast completion'),
breadcrumbs=breadcrumbs,
actions_html=''
+ '<div class="flex gap-2">'
+ '<a href="' + url_for("reports.reports") + '" class="bg-gray-600 text-white px-4 py-2 rounded-lg hover:bg-gray-700 transition-colors"><i class="fas fa-chart-bar mr-2"></i>' + _('Reports') + '</a>'
+ '<button id="refreshData" class="bg-primary text-white px-4 py-2 rounded-lg hover:bg-primary/90 transition-colors"><i class="fas fa-sync-alt mr-2"></i>' + _('Refresh') + '</button>'
+ '</div>'
) }}
<!-- Alert Summary Cards -->
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6 mb-6">
<!-- Unacknowledged Alerts -->
<div class="bg-gradient-to-br from-yellow-400 to-orange-500 p-6 rounded-lg shadow-lg animated-card text-white">
<div class="flex items-center justify-between mb-2">
<i class="fas fa-bell fa-2x opacity-80"></i>
</div>
<h3 class="text-3xl font-bold" id="totalUnacknowledged">{{ alert_stats.total_unacknowledged }}</h3>
<p class="text-sm opacity-90">{{ _('Unacknowledged Alerts') }}</p>
</div>
<!-- Critical Alerts -->
<div class="bg-gradient-to-br from-red-500 to-pink-600 p-6 rounded-lg shadow-lg animated-card text-white">
<div class="flex items-center justify-between mb-2">
<i class="fas fa-exclamation-circle fa-2x opacity-80"></i>
</div>
<h3 class="text-3xl font-bold" id="criticalAlerts">{{ alert_stats.critical_alerts }}</h3>
<p class="text-sm opacity-90">{{ _('Critical Alerts') }}</p>
</div>
<!-- Projects with Budgets -->
<div class="bg-gradient-to-br from-blue-500 to-cyan-600 p-6 rounded-lg shadow-lg animated-card text-white">
<div class="flex items-center justify-between mb-2">
<i class="fas fa-project-diagram fa-2x opacity-80"></i>
</div>
<h3 class="text-3xl font-bold" id="totalProjects">{{ projects|length }}</h3>
<p class="text-sm opacity-90">{{ _('Projects with Budgets') }}</p>
</div>
<!-- Warning Alerts -->
<div class="bg-gradient-to-br from-amber-400 to-yellow-500 p-6 rounded-lg shadow-lg animated-card text-white">
<div class="flex items-center justify-between mb-2">
<i class="fas fa-fire fa-2x opacity-80"></i>
</div>
<h3 class="text-3xl font-bold" id="warningAlerts">{{ alert_stats.warning_alerts }}</h3>
<p class="text-sm opacity-90">{{ _('Warning Alerts') }}</p>
</div>
</div>
<!-- Active Alerts Section -->
{% if active_alerts %}
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow animated-card mb-6">
<h2 class="text-lg font-semibold mb-4 flex items-center">
<i class="fas fa-bell mr-2 text-primary"></i>
{{ _('Active Alerts') }}
</h2>
<div class="space-y-3">
{% for alert in active_alerts %}
<div id="alert-{{ alert.id }}" class="p-4 rounded-lg border {% if alert.alert_level == 'critical' %}bg-red-50 dark:bg-red-900/20 border-red-300 dark:border-red-700{% elif alert.alert_level == 'warning' %}bg-yellow-50 dark:bg-yellow-900/20 border-yellow-300 dark:border-yellow-700{% else %}bg-blue-50 dark:bg-blue-900/20 border-blue-300 dark:border-blue-700{% endif %}">
<div class="flex items-start justify-between gap-4">
<div class="flex-grow">
<div class="flex items-center mb-1">
{% if alert.alert_level == 'critical' %}
<i class="fas fa-exclamation-circle text-red-600 dark:text-red-400 mr-2"></i>
{% elif alert.alert_level == 'warning' %}
<i class="fas fa-exclamation-triangle text-yellow-600 dark:text-yellow-400 mr-2"></i>
{% else %}
<i class="fas fa-info-circle text-blue-600 dark:text-blue-400 mr-2"></i>
{% endif %}
<h3 class="font-semibold">{{ alert.project.name }}</h3>
</div>
<p class="text-sm mb-2">{{ alert.message }}</p>
<p class="text-xs text-text-muted-light dark:text-text-muted-dark">
<i class="far fa-clock mr-1"></i>
{{ _('Created') }}: {{ alert.created_at.strftime('%Y-%m-%d %H:%M') }}
</p>
</div>
<div class="flex gap-2 flex-shrink-0">
<a href="{{ url_for('budget_alerts.project_budget_detail', project_id=alert.project_id) }}"
class="bg-primary text-white px-3 py-2 rounded text-sm hover:bg-primary-dark transition">
<i class="fas fa-eye"></i> {{ _('View') }}
</a>
<button class="bg-green-600 text-white px-3 py-2 rounded text-sm hover:bg-green-700 transition acknowledge-alert"
data-alert-id="{{ alert.id }}">
<i class="fas fa-check"></i> {{ _('Acknowledge') }}
</button>
</div>
</div>
</div>
{% endfor %}
</div>
</div>
{% endif %}
<!-- Project Budget Status -->
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow animated-card">
<h2 class="text-lg font-semibold mb-4 flex items-center">
<i class="fas fa-tasks mr-2 text-primary"></i>
{{ _('Project Budget Status') }}
</h2>
<div class="overflow-x-auto">
<table class="w-full text-left" id="projectBudgetTable">
<thead class="border-b border-border-light dark:border-border-dark">
<tr>
<th class="p-4">{{ _('Project') }}</th>
<th class="p-4">{{ _('Budget') }}</th>
<th class="p-4">{{ _('Consumed') }}</th>
<th class="p-4">{{ _('Remaining') }}</th>
<th class="p-4">{{ _('Progress') }}</th>
<th class="p-4">{{ _('Status') }}</th>
<th class="p-4">{{ _('Actions') }}</th>
</tr>
</thead>
<tbody>
{% for project in projects %}
<tr class="border-b border-border-light dark:border-border-dark hover:bg-gray-50 dark:hover:bg-gray-700/50 transition">
<td class="p-4">
<strong>{{ project.project_name }}</strong>
</td>
<td class="p-4">${{ "%.2f"|format(project.budget_amount) }}</td>
<td class="p-4">${{ "%.2f"|format(project.consumed_amount) }}</td>
<td class="p-4">
{% if project.remaining_amount >= 0 %}
<span class="text-green-600 dark:text-green-400">${{ "%.2f"|format(project.remaining_amount) }}</span>
{% else %}
<span class="text-red-600 dark:text-red-400">${{ "%.2f"|format(project.remaining_amount|abs) }} {{ _('over') }}</span>
{% endif %}
</td>
<td class="p-4">
<div class="flex items-center gap-2">
<div class="flex-grow bg-gray-200 dark:bg-gray-700 rounded-full h-4 overflow-hidden" style="min-width: 150px;">
<div class="h-full {% if project.consumed_percentage >= 100 %}bg-red-500{% elif project.consumed_percentage >= project.threshold_percent %}bg-yellow-500{% else %}bg-green-500{% endif %} rounded-full transition-all duration-300 flex items-center justify-center text-xs text-white font-semibold"
style="width: {{ [project.consumed_percentage, 100]|min }}%">
{{ "%.1f"|format(project.consumed_percentage) }}%
</div>
</div>
</div>
</td>
<td class="p-4">
{% if project.status == 'over_budget' %}
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400">
<i class="fas fa-exclamation-circle mr-1"></i>{{ _('Over Budget') }}
</span>
{% elif project.status == 'critical' %}
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-orange-100 text-orange-800 dark:bg-orange-900/30 dark:text-orange-400">
<i class="fas fa-exclamation-triangle mr-1"></i>{{ _('Critical') }}
</span>
{% elif project.status == 'warning' %}
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-400">
<i class="fas fa-info-circle mr-1"></i>{{ _('Warning') }}
</span>
{% else %}
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400">
<i class="fas fa-check-circle mr-1"></i>{{ _('Healthy') }}
</span>
{% endif %}
</td>
<td class="p-4">
<a href="{{ url_for('budget_alerts.project_budget_detail', project_id=project.project_id) }}"
class="bg-primary text-white px-3 py-1.5 rounded text-sm hover:bg-primary-dark transition">
<i class="fas fa-chart-line"></i> {{ _('Details') }}
</a>
</td>
</tr>
{% else %}
<tr>
<td colspan="7" class="p-8 text-center text-text-muted-light dark:text-text-muted-dark">
<i class="fas fa-inbox fa-2x mb-2"></i>
<p>{{ _('No projects with budgets found') }}</p>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
<script>
document.addEventListener('DOMContentLoaded', function() {
// Initialize DataTable if available
if (typeof $.fn.dataTable !== 'undefined') {
$('#projectBudgetTable').DataTable({
order: [[4, 'desc']], // Sort by progress descending
pageLength: 25,
language: {
emptyTable: "{{ _('No projects with budgets found') }}"
}
});
}
// Acknowledge alert handlers
document.querySelectorAll('.acknowledge-alert').forEach(button => {
button.addEventListener('click', function() {
const alertId = this.dataset.alertId;
acknowledgeAlert(alertId);
});
});
function acknowledgeAlert(alertId) {
fetch(`/api/budget/alerts/${alertId}/acknowledge`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': '{{ csrf_token() }}'
}
})
.then(response => response.json())
.then(data => {
if (data.message) {
// Remove alert from list with animation
const alertElement = document.getElementById(`alert-${alertId}`);
if (alertElement) {
alertElement.style.opacity = '0';
alertElement.style.transform = 'translateX(20px)';
alertElement.style.transition = 'all 0.3s ease';
setTimeout(() => alertElement.remove(), 300);
}
// Update counters
const unacknowledgedCount = document.getElementById('totalUnacknowledged');
if (unacknowledgedCount) {
unacknowledgedCount.textContent = Math.max(0, parseInt(unacknowledgedCount.textContent) - 1);
}
// Show success notification
showNotification('{{ _("Alert acknowledged successfully") }}', 'success');
}
})
.catch(error => {
console.error('Error:', error);
showNotification('{{ _("Failed to acknowledge alert") }}', 'error');
});
}
// Refresh button handler
document.getElementById('refreshData')?.addEventListener('click', function() {
this.querySelector('i').classList.add('fa-spin');
location.reload();
});
function showNotification(message, type) {
// Create toast notification
const toast = document.createElement('div');
toast.className = `fixed top-4 right-4 px-6 py-3 rounded-lg shadow-lg text-white z-50 ${type === 'success' ? 'bg-green-500' : 'bg-red-500'}`;
toast.textContent = message;
document.body.appendChild(toast);
setTimeout(() => {
toast.style.opacity = '0';
toast.style.transition = 'opacity 0.3s ease';
setTimeout(() => toast.remove(), 300);
}, 3000);
}
});
</script>
{% endblock %}
+429
View File
@@ -0,0 +1,429 @@
{% extends "base.html" %}
{% from "components/cards.html" import info_card, stat_card %}
{% block content %}
<div class="flex flex-col md:flex-row justify-between items-start md:items-center mb-6">
<div>
<h1 class="text-2xl font-bold">{{ project.name }}</h1>
<p class="text-text-muted-light dark:text-text-muted-dark">{{ _('Budget Analysis & Forecasting') }}</p>
</div>
<div class="flex gap-2 mt-2 md:mt-0">
<a href="{{ url_for('budget_alerts.budget_dashboard') }}"
class="bg-card-light dark:bg-card-dark px-4 py-2 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 transition">
<i class="fas fa-arrow-left"></i> {{ _('Back to Dashboard') }}
</a>
<a href="{{ url_for('projects.view_project', project_id=project.id) }}"
class="bg-primary text-white px-4 py-2 rounded-lg hover:bg-primary-dark transition">
<i class="fas fa-eye"></i> {{ _('View Project') }}
</a>
</div>
</div>
<!-- Budget Status Cards -->
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-6 mb-6">
<!-- Total Budget -->
<div class="bg-gradient-to-br from-blue-500 to-indigo-600 p-6 rounded-lg shadow-lg animated-card text-white">
<div class="flex items-center justify-between mb-2">
<i class="fas fa-wallet fa-2x opacity-80"></i>
</div>
<h3 class="text-3xl font-bold">${{ "%.2f"|format(budget_status.budget_amount) }}</h3>
<p class="text-sm opacity-90">{{ _('Total Budget') }}</p>
</div>
<!-- Consumed -->
<div class="bg-gradient-to-br from-yellow-400 to-orange-500 p-6 rounded-lg shadow-lg animated-card text-white">
<div class="flex items-center justify-between mb-2">
<i class="fas fa-chart-pie fa-2x opacity-80"></i>
</div>
<h3 class="text-3xl font-bold">${{ "%.2f"|format(budget_status.consumed_amount) }}</h3>
<p class="text-sm opacity-90">{{ _('Consumed') }} ({{ "%.1f"|format(budget_status.consumed_percentage) }}%)</p>
</div>
<!-- Remaining -->
<div class="bg-gradient-to-br {% if budget_status.remaining_amount >= 0 %}from-green-500 to-emerald-600{% else %}from-red-500 to-pink-600{% endif %} p-6 rounded-lg shadow-lg animated-card text-white">
<div class="flex items-center justify-between mb-2">
<i class="fas fa-piggy-bank fa-2x opacity-80"></i>
</div>
<h3 class="text-3xl font-bold">${{ "%.2f"|format(budget_status.remaining_amount|abs) }}</h3>
<p class="text-sm opacity-90">{% if budget_status.remaining_amount >= 0 %}{{ _('Remaining') }}{% else %}{{ _('Over Budget') }}{% endif %}</p>
</div>
<!-- Status -->
<div class="bg-gradient-to-br {% if budget_status.status == 'over_budget' %}from-red-500 to-pink-600{% elif budget_status.status == 'critical' %}from-orange-500 to-amber-600{% elif budget_status.status == 'warning' %}from-yellow-400 to-amber-500{% else %}from-green-500 to-emerald-600{% endif %} p-6 rounded-lg shadow-lg animated-card text-white">
<div class="flex items-center justify-between mb-2">
{% if budget_status.status == 'over_budget' %}
<i class="fas fa-exclamation-circle fa-2x opacity-80"></i>
{% elif budget_status.status == 'critical' %}
<i class="fas fa-exclamation-triangle fa-2x opacity-80"></i>
{% elif budget_status.status == 'warning' %}
<i class="fas fa-info-circle fa-2x opacity-80"></i>
{% else %}
<i class="fas fa-check-circle fa-2x opacity-80"></i>
{% endif %}
</div>
<h3 class="text-2xl font-bold">
{% if budget_status.status == 'over_budget' %}{{ _('Over Budget') }}
{% elif budget_status.status == 'critical' %}{{ _('Critical') }}
{% elif budget_status.status == 'warning' %}{{ _('Warning') }}
{% else %}{{ _('Healthy') }}{% endif %}
</h3>
<p class="text-sm opacity-90">{{ _('Status') }}</p>
</div>
</div>
<!-- Burn Rate & Completion Estimate -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-6">
<!-- Burn Rate Analysis -->
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow animated-card">
<h2 class="text-lg font-semibold mb-4 flex items-center">
<i class="fas fa-fire mr-2 text-orange-500"></i>
{{ _('Burn Rate Analysis') }}
</h2>
{% if burn_rate %}
<div class="grid grid-cols-2 gap-4">
<div class="bg-blue-50 dark:bg-blue-900/20 p-4 rounded-lg">
<h6 class="text-xs text-text-muted-light dark:text-text-muted-dark mb-1">{{ _('Daily Burn Rate') }}</h6>
<p class="text-2xl font-bold text-blue-600 dark:text-blue-400">${{ "%.2f"|format(burn_rate.daily_burn_rate) }}</p>
</div>
<div class="bg-cyan-50 dark:bg-cyan-900/20 p-4 rounded-lg">
<h6 class="text-xs text-text-muted-light dark:text-text-muted-dark mb-1">{{ _('Weekly Burn Rate') }}</h6>
<p class="text-2xl font-bold text-cyan-600 dark:text-cyan-400">${{ "%.2f"|format(burn_rate.weekly_burn_rate) }}</p>
</div>
<div class="bg-amber-50 dark:bg-amber-900/20 p-4 rounded-lg">
<h6 class="text-xs text-text-muted-light dark:text-text-muted-dark mb-1">{{ _('Monthly Burn Rate') }}</h6>
<p class="text-2xl font-bold text-amber-600 dark:text-amber-400">${{ "%.2f"|format(burn_rate.monthly_burn_rate) }}</p>
</div>
<div class="bg-green-50 dark:bg-green-900/20 p-4 rounded-lg">
<h6 class="text-xs text-text-muted-light dark:text-text-muted-dark mb-1">{{ _('Period Total') }}</h6>
<p class="text-2xl font-bold text-green-600 dark:text-green-400">${{ "%.2f"|format(burn_rate.period_total) }}</p>
</div>
</div>
<p class="text-xs text-text-muted-light dark:text-text-muted-dark mt-4">
<i class="far fa-clock mr-1"></i>
{{ _('Based on last') }} {{ burn_rate.period_days }} {{ _('days') }}
</p>
{% else %}
<p class="text-text-muted-light dark:text-text-muted-dark text-center py-8">
<i class="fas fa-info-circle fa-2x mb-2"></i><br>
{{ _('No burn rate data available') }}
</p>
{% endif %}
</div>
<!-- Completion Estimate -->
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow animated-card">
<h2 class="text-lg font-semibold mb-4 flex items-center">
<i class="fas fa-calendar-check mr-2 text-green-500"></i>
{{ _('Completion Estimate') }}
</h2>
{% if completion_estimate %}
{% if completion_estimate.estimated_completion_date %}
<div class="text-center mb-4">
<h6 class="text-sm text-text-muted-light dark:text-text-muted-dark mb-2">{{ _('Estimated Completion Date') }}</h6>
<h2 class="text-3xl font-bold {% if completion_estimate.days_remaining < 30 %}text-red-600 dark:text-red-400{% elif completion_estimate.days_remaining < 60 %}text-yellow-600 dark:text-yellow-400{% else %}text-green-600 dark:text-green-400{% endif %} mb-2">
{{ completion_estimate.estimated_completion_date }}
</h2>
<p class="text-xl">
<span class="font-semibold">{{ completion_estimate.days_remaining }}</span>
<span class="text-text-muted-light dark:text-text-muted-dark">{{ _('days remaining') }}</span>
</p>
</div>
<div class="bg-gray-50 dark:bg-gray-800 p-4 rounded-lg">
<div class="flex items-center justify-between mb-2">
<span class="text-sm text-text-muted-light dark:text-text-muted-dark">{{ _('Confidence Level') }}</span>
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium {% if completion_estimate.confidence == 'high' %}bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400{% elif completion_estimate.confidence == 'medium' %}bg-yellow-100 text-yellow-800 dark:bg-yellow-900/30 dark:text-yellow-400{% else %}bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300{% endif %}">
{{ completion_estimate.confidence|upper }}
</span>
</div>
<p class="text-sm text-text-muted-light dark:text-text-muted-dark">{{ completion_estimate.message }}</p>
</div>
{% else %}
<div class="bg-blue-50 dark:bg-blue-900/20 p-4 rounded-lg text-center">
<i class="fas fa-info-circle text-blue-600 dark:text-blue-400 text-2xl mb-2"></i>
<p class="text-sm">{{ completion_estimate.message }}</p>
</div>
{% endif %}
{% else %}
<p class="text-text-muted-light dark:text-text-muted-dark text-center py-8">
<i class="fas fa-info-circle fa-2x mb-2"></i><br>
{{ _('No completion estimate available') }}
</p>
{% endif %}
</div>
</div>
<!-- Cost Trends Chart -->
{% if cost_trends and cost_trends.periods %}
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow animated-card mb-6">
<h2 class="text-lg font-semibold mb-4 flex items-center">
<i class="fas fa-chart-area mr-2 text-purple-500"></i>
{{ _('Cost Trend Analysis') }}
</h2>
<div class="flex flex-wrap gap-3 mb-4">
<span class="inline-flex items-center px-3 py-1 rounded-full text-sm font-medium {% if cost_trends.trend_direction == 'increasing' %}bg-red-100 text-red-800 dark:bg-red-900/30 dark:text-red-400{% elif cost_trends.trend_direction == 'decreasing' %}bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-400{% else %}bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300{% endif %}">
<i class="fas {% if cost_trends.trend_direction == 'increasing' %}fa-arrow-up{% elif cost_trends.trend_direction == 'decreasing' %}fa-arrow-down{% else %}fa-minus{% endif %} mr-2"></i>
{{ _('Trend') }}: {{ cost_trends.trend_direction|upper }}
</span>
<span class="inline-flex items-center px-3 py-1 rounded-full text-sm font-medium bg-blue-100 text-blue-800 dark:bg-blue-900/30 dark:text-blue-400">
{{ _('Average') }}: ${{ "%.2f"|format(cost_trends.average_cost_per_period) }}
</span>
<span class="inline-flex items-center px-3 py-1 rounded-full text-sm font-medium bg-purple-100 text-purple-800 dark:bg-purple-900/30 dark:text-purple-400">
{{ _('Change') }}: {{ "%.1f"|format(cost_trends.trend_percentage) }}%
</span>
</div>
<div class="chart-container" style="position: relative; height: 300px;">
<canvas id="costTrendChart"></canvas>
</div>
</div>
{% endif %}
<!-- Resource Allocation -->
{% if resource_allocation and resource_allocation.users %}
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow animated-card mb-6">
<h2 class="text-lg font-semibold mb-4 flex items-center">
<i class="fas fa-users mr-2 text-cyan-500"></i>
{{ _('Resource Allocation') }}
</h2>
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 mb-6">
<div class="bg-blue-50 dark:bg-blue-900/20 p-4 rounded-lg">
<h6 class="text-sm text-text-muted-light dark:text-text-muted-dark mb-1">{{ _('Total Hours') }}</h6>
<p class="text-2xl font-bold text-blue-600 dark:text-blue-400">{{ "%.2f"|format(resource_allocation.total_hours) }}</p>
</div>
<div class="bg-green-50 dark:bg-green-900/20 p-4 rounded-lg">
<h6 class="text-sm text-text-muted-light dark:text-text-muted-dark mb-1">{{ _('Total Cost') }}</h6>
<p class="text-2xl font-bold text-green-600 dark:text-green-400">${{ "%.2f"|format(resource_allocation.total_cost) }}</p>
</div>
<div class="bg-cyan-50 dark:bg-cyan-900/20 p-4 rounded-lg">
<h6 class="text-sm text-text-muted-light dark:text-text-muted-dark mb-1">{{ _('Hourly Rate') }}</h6>
<p class="text-2xl font-bold text-cyan-600 dark:text-cyan-400">${{ "%.2f"|format(resource_allocation.hourly_rate) }}</p>
</div>
</div>
<div class="overflow-x-auto">
<table class="w-full text-left">
<thead class="border-b border-border-light dark:border-border-dark">
<tr>
<th class="p-4">{{ _('Team Member') }}</th>
<th class="p-4">{{ _('Hours') }}</th>
<th class="p-4">{{ _('Cost') }}</th>
<th class="p-4">{{ _('Hours %') }}</th>
<th class="p-4">{{ _('Cost %') }}</th>
<th class="p-4">{{ _('Entries') }}</th>
</tr>
</thead>
<tbody>
{% for user in resource_allocation.users %}
<tr class="border-b border-border-light dark:border-border-dark hover:bg-gray-50 dark:hover:bg-gray-700/50 transition">
<td class="p-4">
<strong>{{ user.username }}</strong>
</td>
<td class="p-4">{{ "%.2f"|format(user.hours) }}</td>
<td class="p-4">${{ "%.2f"|format(user.cost) }}</td>
<td class="p-4">
<div class="flex items-center gap-2">
<div class="flex-grow bg-gray-200 dark:bg-gray-700 rounded-full h-4 overflow-hidden" style="min-width: 100px;">
<div class="h-full bg-cyan-500 rounded-full transition-all duration-300 flex items-center justify-center text-xs text-white font-semibold"
style="width: {{ user.hours_percentage }}%">
{{ "%.1f"|format(user.hours_percentage) }}%
</div>
</div>
</div>
</td>
<td class="p-4">
<div class="flex items-center gap-2">
<div class="flex-grow bg-gray-200 dark:bg-gray-700 rounded-full h-4 overflow-hidden" style="min-width: 100px;">
<div class="h-full bg-green-500 rounded-full transition-all duration-300 flex items-center justify-center text-xs text-white font-semibold"
style="width: {{ user.cost_percentage }}%">
{{ "%.1f"|format(user.cost_percentage) }}%
</div>
</div>
</div>
</td>
<td class="p-4">
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300">
{{ user.entry_count }}
</span>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
</div>
{% endif %}
<!-- Active Alerts for this Project -->
{% if alerts %}
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow animated-card">
<h2 class="text-lg font-semibold mb-4 flex items-center">
<i class="fas fa-bell mr-2 text-red-500"></i>
{{ _('Active Alerts') }}
</h2>
<div class="space-y-3">
{% for alert in alerts %}
<div id="alert-{{ alert.id }}" class="p-4 rounded-lg border {% if alert.alert_level == 'critical' %}bg-red-50 dark:bg-red-900/20 border-red-300 dark:border-red-700{% elif alert.alert_level == 'warning' %}bg-yellow-50 dark:bg-yellow-900/20 border-yellow-300 dark:border-yellow-700{% else %}bg-blue-50 dark:bg-blue-900/20 border-blue-300 dark:border-blue-700{% endif %}">
<div class="flex items-start justify-between gap-4">
<div class="flex-grow">
<div class="flex items-center mb-1">
{% if alert.alert_level == 'critical' %}
<i class="fas fa-exclamation-circle text-red-600 dark:text-red-400 mr-2"></i>
{% elif alert.alert_level == 'warning' %}
<i class="fas fa-exclamation-triangle text-yellow-600 dark:text-yellow-400 mr-2"></i>
{% else %}
<i class="fas fa-info-circle text-blue-600 dark:text-blue-400 mr-2"></i>
{% endif %}
<h3 class="font-semibold">{{ alert.alert_type|replace('_', ' ')|title }}</h3>
</div>
<p class="text-sm mb-2">{{ alert.message }}</p>
<p class="text-xs text-text-muted-light dark:text-text-muted-dark">
<i class="far fa-clock mr-1"></i>
{{ _('Created') }}: {{ alert.created_at.strftime('%Y-%m-%d %H:%M') }}
</p>
</div>
<button class="bg-green-600 text-white px-3 py-2 rounded text-sm hover:bg-green-700 transition flex-shrink-0 acknowledge-alert"
data-alert-id="{{ alert.id }}">
<i class="fas fa-check"></i> {{ _('Acknowledge') }}
</button>
</div>
</div>
{% endfor %}
</div>
</div>
{% endif %}
<script src="https://cdn.jsdelivr.net/npm/chart.js@3.9.1/dist/chart.min.js"></script>
<script>
document.addEventListener('DOMContentLoaded', function() {
// Cost Trend Chart
{% if cost_trends and cost_trends.periods %}
const ctx = document.getElementById('costTrendChart').getContext('2d');
// Check if dark mode is enabled
const isDarkMode = document.documentElement.classList.contains('dark');
const textColor = isDarkMode ? '#9ca3af' : '#6b7280';
const gridColor = isDarkMode ? '#374151' : '#e5e7eb';
const costTrendChart = new Chart(ctx, {
type: 'line',
data: {
labels: {{ cost_trends.periods|map(attribute='period')|list|tojson }},
datasets: [{
label: '{{ _("Cost") }}',
data: {{ cost_trends.periods|map(attribute='cost')|list|tojson }},
borderColor: 'rgb(139, 92, 246)',
backgroundColor: 'rgba(139, 92, 246, 0.1)',
tension: 0.4,
fill: true,
pointRadius: 4,
pointHoverRadius: 6,
pointBackgroundColor: 'rgb(139, 92, 246)',
pointBorderColor: '#fff',
pointBorderWidth: 2
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
display: true,
position: 'top',
labels: {
color: textColor,
font: {
size: 12
}
}
},
tooltip: {
callbacks: {
label: function(context) {
return '{{ _("Cost") }}: $' + context.parsed.y.toFixed(2);
}
},
backgroundColor: isDarkMode ? '#1f2937' : '#ffffff',
titleColor: textColor,
bodyColor: textColor,
borderColor: gridColor,
borderWidth: 1
}
},
scales: {
y: {
beginAtZero: true,
ticks: {
callback: function(value) {
return '$' + value.toFixed(2);
},
color: textColor
},
grid: {
color: gridColor
}
},
x: {
ticks: {
color: textColor
},
grid: {
color: gridColor
}
}
}
}
});
{% endif %}
// Acknowledge alert handlers
document.querySelectorAll('.acknowledge-alert').forEach(button => {
button.addEventListener('click', function() {
const alertId = this.dataset.alertId;
acknowledgeAlert(alertId);
});
});
function acknowledgeAlert(alertId) {
fetch(`/api/budget/alerts/${alertId}/acknowledge`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRF-Token': '{{ csrf_token() }}'
}
})
.then(response => response.json())
.then(data => {
if (data.message) {
// Remove alert from list with animation
const alertElement = document.getElementById(`alert-${alertId}`);
if (alertElement) {
alertElement.style.opacity = '0';
alertElement.style.transform = 'translateX(20px)';
alertElement.style.transition = 'all 0.3s ease';
setTimeout(() => alertElement.remove(), 300);
}
// Show success notification
showNotification('{{ _("Alert acknowledged successfully") }}', 'success');
}
})
.catch(error => {
console.error('Error:', error);
showNotification('{{ _("Failed to acknowledge alert") }}', 'error');
});
}
function showNotification(message, type) {
// Create toast notification
const toast = document.createElement('div');
toast.className = `fixed top-4 right-4 px-6 py-3 rounded-lg shadow-lg text-white z-50 ${type === 'success' ? 'bg-green-500' : 'bg-red-500'}`;
toast.textContent = message;
document.body.appendChild(toast);
setTimeout(() => {
toast.style.opacity = '0';
toast.style.transition = 'opacity 0.3s ease';
setTimeout(() => toast.remove(), 300);
}, 3000);
}
});
</script>
{% endblock %}
+17 -10
View File
@@ -1,4 +1,6 @@
{% extends "base.html" %}
{% from "components/ui.html" import page_header, breadcrumb_nav, button, filter_badge %}
{% block title %}Calendar - {{ app_name }}{% endblock %}
{% block extra_css %}
@@ -6,16 +8,21 @@
{% endblock %}
{% block content %}
<div class="container mx-auto px-4 py-6">
<!-- Calendar Header -->
<div class="bg-card-light dark:bg-card-dark rounded-lg shadow-sm p-6 mb-6">
<div class="flex flex-col md:flex-row justify-between items-start md:items-center gap-4">
<h1 class="text-3xl font-bold">
<i class="fas fa-calendar-alt mr-2 text-primary"></i>
{{ _('Calendar') }}
</h1>
<div class="flex flex-wrap gap-3">
{% set breadcrumbs = [
{'text': _('Calendar')}
] %}
{{ page_header(
icon_class='fas fa-calendar-alt',
title_text=_('Calendar'),
subtitle_text=_('View and manage your events, tasks, and time entries'),
breadcrumbs=breadcrumbs,
actions_html=None
) }}
<div class="bg-card-light dark:bg-card-dark rounded-lg shadow-sm p-6 mb-6">
<div class="flex flex-col md:flex-row justify-between items-start md:items-center gap-4 mb-6">
<div class="flex flex-wrap gap-3">
<!-- View Type Selector -->
<div class="btn-group" role="group">
<a href="{{ url_for('calendar.view_calendar', view='day', date=current_date.strftime('%Y-%m-%d')) }}"
+30 -22
View File
@@ -1,15 +1,18 @@
{% extends "base.html" %}
{% from "components/ui.html" import page_header, breadcrumb_nav, button, filter_badge %}
{% block content %}
<div class="flex flex-col md:flex-row justify-between items-start md:items-center mb-6">
<div>
<h1 class="text-2xl font-bold">Clients</h1>
<p class="text-text-muted-light dark:text-text-muted-dark">Manage your clients here.</p>
</div>
{% if current_user.is_admin or has_permission('create_clients') %}
<a href="{{ url_for('clients.create_client') }}" class="bg-primary text-white px-4 py-2 rounded-lg mt-4 md:mt-0">Create Client</a>
{% endif %}
</div>
{% set breadcrumbs = [
{'text': 'Clients'}
] %}
{{ page_header(
icon_class='fas fa-users',
title_text='Clients',
subtitle_text='Manage your clients here',
breadcrumbs=breadcrumbs,
actions_html='<a href="' + url_for("clients.create_client") + '" class="bg-primary text-white px-4 py-2 rounded-lg hover:bg-primary/90 transition-colors"><i class="fas fa-plus mr-2"></i>Create Client</a>' if (current_user.is_admin or has_permission('create_clients')) else None
) }}
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow mb-6">
<h2 class="text-lg font-semibold mb-4">Filter Clients</h2>
@@ -32,24 +35,29 @@
</form>
</div>
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow overflow-x-auto">
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow overflow-visible">
<div class="flex justify-between items-center mb-4">
<h3 class="text-sm font-medium text-text-muted-light dark:text-text-muted-dark">
{{ clients|length }} client{{ 's' if clients|length != 1 else '' }} found
</h3>
{% if current_user.is_admin %}
<div class="relative">
<button type="button" id="bulkActionsBtn" class="px-3 py-1.5 text-sm border rounded-lg text-gray-700 dark:text-gray-200 border-gray-300 dark:border-gray-600 disabled:opacity-60" onclick="openMenu(this, 'clientsBulkMenu')" disabled>
<i class="fas fa-tasks mr-1"></i> Bulk Actions (<span id="selectedCount">0</span>)
</button>
<ul id="clientsBulkMenu" class="bulk-menu hidden absolute right-0 mt-2 w-56 bg-card-light dark:bg-card-dark border border-border-light dark:border-border-dark rounded-md shadow-lg">
<li><a class="block px-4 py-2 text-sm" href="#" onclick="return showBulkStatusChange('active')"><i class="fas fa-check-circle mr-2 text-green-600"></i>Mark as Active</a></li>
<li><a class="block px-4 py-2 text-sm" href="#" onclick="return showBulkStatusChange('inactive')"><i class="fas fa-pause-circle mr-2 text-amber-500"></i>Mark as Inactive</a></li>
<li><hr class="my-1 border-border-light dark:border-border-dark"></li>
<li><a class="block px-4 py-2 text-sm text-rose-600" href="#" onclick="return showBulkDeleteConfirm()"><i class="fas fa-trash mr-2"></i>Delete</a></li>
</ul>
<div class="flex items-center gap-2">
<a href="{{ url_for('clients.export_clients', status=status, search=search) }}" class="px-3 py-1.5 text-sm bg-background-light dark:bg-background-dark rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors inline-flex items-center" title="Export to CSV">
<i class="fas fa-download mr-1"></i> Export
</a>
{% if current_user.is_admin %}
<div class="relative">
<button type="button" id="bulkActionsBtn" class="px-3 py-1.5 text-sm border rounded-lg text-gray-700 dark:text-gray-200 border-gray-300 dark:border-gray-600 disabled:opacity-60" onclick="openMenu(this, 'clientsBulkMenu')" disabled>
<i class="fas fa-tasks mr-1"></i> Bulk Actions (<span id="selectedCount">0</span>)
</button>
<ul id="clientsBulkMenu" class="bulk-menu hidden absolute right-0 mt-2 w-56 z-50 bg-card-light dark:bg-card-dark border border-border-light dark:border-border-dark rounded-md shadow-lg">
<li><a class="block px-4 py-2 text-sm hover:bg-gray-100 dark:hover:bg-gray-700" href="#" onclick="return showBulkStatusChange('active')"><i class="fas fa-check-circle mr-2 text-green-600"></i>Mark as Active</a></li>
<li><a class="block px-4 py-2 text-sm hover:bg-gray-100 dark:hover:bg-gray-700" href="#" onclick="return showBulkStatusChange('inactive')"><i class="fas fa-pause-circle mr-2 text-amber-500"></i>Mark as Inactive</a></li>
<li><hr class="my-1 border-border-light dark:border-border-dark"></li>
<li><a class="block px-4 py-2 text-sm text-rose-600 hover:bg-gray-100 dark:hover:bg-gray-700" href="#" onclick="return showBulkDeleteConfirm()"><i class="fas fa-trash mr-2"></i>Delete</a></li>
</ul>
</div>
{% endif %}
</div>
{% endif %}
</div>
<table class="w-full text-left">
<thead class="border-b border-border-light dark:border-border-dark">
@@ -0,0 +1,412 @@
<!-- Activity Feed Widget -->
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow animated-card">
<div class="flex items-center justify-between mb-4">
<h2 class="text-lg font-semibold">
<i class="fas fa-stream mr-2"></i>
{{ _('Recent Activity') }}
</h2>
<div class="flex items-center gap-2">
<!-- Filter dropdown -->
<div class="relative">
<button onclick="toggleFilterDropdown()" id="filter-dropdown-btn" class="text-sm text-gray-600 dark:text-gray-400 hover:text-gray-800 dark:hover:text-gray-200 transition relative">
<i class="fas fa-filter"></i>
<span id="filter-indicator" class="hidden absolute -top-1 -right-1 w-2 h-2 bg-primary rounded-full"></span>
</button>
<div id="filter-dropdown" class="hidden absolute right-0 mt-2 w-56 bg-white dark:bg-gray-800 rounded-lg shadow-lg z-10 border border-gray-200 dark:border-gray-700 max-h-96 overflow-y-auto">
<div class="p-2">
<button id="filter-all" onclick="filterActivities('all'); closeFilterDropdown();" class="w-full text-left px-3 py-2 text-sm hover:bg-gray-100 dark:hover:bg-gray-700 rounded font-medium flex items-center justify-between">
<span><i class="fas fa-list text-gray-500 w-4"></i> {{ _('All Activities') }}</span>
<i class="fas fa-check text-primary hidden filter-check"></i>
</button>
<div class="border-t border-gray-200 dark:border-gray-600 my-1"></div>
<button id="filter-project" onclick="filterActivities('project'); closeFilterDropdown();" class="w-full text-left px-3 py-2 text-sm hover:bg-gray-100 dark:hover:bg-gray-700 rounded flex items-center justify-between">
<span><i class="fas fa-folder text-blue-500 w-4"></i> {{ _('Projects') }}</span>
<i class="fas fa-check text-primary hidden filter-check"></i>
</button>
<button id="filter-task" onclick="filterActivities('task'); closeFilterDropdown();" class="w-full text-left px-3 py-2 text-sm hover:bg-gray-100 dark:hover:bg-gray-700 rounded flex items-center justify-between">
<span><i class="fas fa-tasks text-green-500 w-4"></i> {{ _('Tasks') }}</span>
<i class="fas fa-check text-primary hidden filter-check"></i>
</button>
<button id="filter-time_entry" onclick="filterActivities('time_entry'); closeFilterDropdown();" class="w-full text-left px-3 py-2 text-sm hover:bg-gray-100 dark:hover:bg-gray-700 rounded flex items-center justify-between">
<span><i class="fas fa-clock text-purple-500 w-4"></i> {{ _('Time Entries') }}</span>
<i class="fas fa-check text-primary hidden filter-check"></i>
</button>
<button id="filter-time_entry_template" onclick="filterActivities('time_entry_template'); closeFilterDropdown();" class="w-full text-left px-3 py-2 text-sm hover:bg-gray-100 dark:hover:bg-gray-700 rounded flex items-center justify-between">
<span><i class="fas fa-clock text-teal-500 w-4"></i> {{ _('Time Templates') }}</span>
<i class="fas fa-check text-primary hidden filter-check"></i>
</button>
<button id="filter-invoice" onclick="filterActivities('invoice'); closeFilterDropdown();" class="w-full text-left px-3 py-2 text-sm hover:bg-gray-100 dark:hover:bg-gray-700 rounded flex items-center justify-between">
<span><i class="fas fa-file-invoice text-yellow-500 w-4"></i> {{ _('Invoices') }}</span>
<i class="fas fa-check text-primary hidden filter-check"></i>
</button>
<button id="filter-client" onclick="filterActivities('client'); closeFilterDropdown();" class="w-full text-left px-3 py-2 text-sm hover:bg-gray-100 dark:hover:bg-gray-700 rounded flex items-center justify-between">
<span><i class="fas fa-user-tie text-indigo-500 w-4"></i> {{ _('Clients') }}</span>
<i class="fas fa-check text-primary hidden filter-check"></i>
</button>
<button id="filter-user" onclick="filterActivities('user'); closeFilterDropdown();" class="w-full text-left px-3 py-2 text-sm hover:bg-gray-100 dark:hover:bg-gray-700 rounded flex items-center justify-between">
<span><i class="fas fa-user text-pink-500 w-4"></i> {{ _('Users') }}</span>
<i class="fas fa-check text-primary hidden filter-check"></i>
</button>
</div>
</div>
</div>
<button onclick="refreshActivityFeed()" id="refresh-activity-btn" class="text-sm text-gray-600 dark:text-gray-400 hover:text-gray-800 dark:hover:text-gray-200 transition">
<i class="fas fa-sync-alt"></i>
</button>
</div>
</div>
<div id="activity-feed-container">
{% if recent_activities %}
<div class="space-y-3">
{% for activity in recent_activities %}
<div class="flex items-start gap-3 p-3 hover:bg-gray-50 dark:hover:bg-gray-800 rounded-lg transition">
<div class="flex-shrink-0 mt-1">
<i class="{{ activity.get_icon() }}"></i>
</div>
<div class="flex-1 min-w-0">
<div class="flex items-start justify-between gap-2">
<div class="flex-1 min-w-0">
<p class="text-sm">
<span class="font-medium text-text-light dark:text-text-dark">
{{ activity.user.display_name if activity.user.display_name else activity.user.username }}
</span>
<span class="text-text-muted-light dark:text-text-muted-dark">
{{ activity.description }}
</span>
</p>
{% if activity.extra_data %}
<div class="mt-1 text-xs text-gray-500 dark:text-gray-400">
{% if activity.extra_data.old_status and activity.extra_data.new_status %}
<span class="inline-flex items-center gap-1">
<span class="px-2 py-0.5 bg-gray-100 dark:bg-gray-700 rounded">{{ activity.extra_data.old_status }}</span>
<i class="fas fa-arrow-right text-xs"></i>
<span class="px-2 py-0.5 bg-blue-100 dark:bg-blue-900 text-blue-800 dark:text-blue-200 rounded">{{ activity.extra_data.new_status }}</span>
</span>
{% endif %}
</div>
{% endif %}
</div>
<div class="flex-shrink-0">
<span class="text-xs text-gray-500 dark:text-gray-400" title="{{ activity.created_at.strftime('%Y-%m-%d %H:%M:%S') }}">
{{ activity.created_at|timeago if activity.created_at else '' }}
</span>
</div>
</div>
</div>
</div>
{% endfor %}
</div>
{% else %}
<div class="text-center py-8 text-text-muted-light dark:text-text-muted-dark">
<i class="fas fa-stream text-4xl mb-3 opacity-50"></i>
<p class="text-sm">{{ _('No recent activity') }}</p>
<p class="text-xs mt-1">{{ _('Activity will appear here as you work') }}</p>
</div>
{% endif %}
</div>
<div id="load-more-container" class="mt-4 pt-4 border-t border-border-light dark:border-border-dark" {% if not recent_activities %}style="display: none;"{% endif %}>
<button onclick="loadMoreActivities()" id="load-more-activities" class="text-sm text-primary hover:text-primary-dark transition w-full text-center">
{{ _('Load More') }} <i class="fas fa-chevron-down ml-1"></i>
</button>
</div>
</div>
<script>
let activityPage = 1;
let activityFilter = ''; // Empty string means show all
const activityLimit = 10;
function toggleFilterDropdown() {
const dropdown = document.getElementById('filter-dropdown');
if (dropdown) {
dropdown.classList.toggle('hidden');
}
}
function closeFilterDropdown() {
const dropdown = document.getElementById('filter-dropdown');
if (dropdown) {
dropdown.classList.add('hidden');
}
}
// Close dropdown when clicking outside
document.addEventListener('click', function(event) {
const dropdown = document.getElementById('filter-dropdown');
const btn = document.getElementById('filter-dropdown-btn');
if (dropdown && btn && !dropdown.contains(event.target) && !btn.contains(event.target)) {
dropdown.classList.add('hidden');
}
});
function filterActivities(entityType) {
console.log('Filtering activities by:', entityType);
activityFilter = entityType === 'all' ? '' : entityType;
activityPage = 1;
// Update visual indicators
updateFilterIndicators(entityType);
loadActivities(false).catch(error => {
console.error('Filter failed:', error);
});
}
function updateFilterIndicators(activeFilter) {
// Hide all checkmarks
document.querySelectorAll('.filter-check').forEach(check => {
check.classList.add('hidden');
});
// Show checkmark for active filter
const filterButton = document.getElementById(`filter-${activeFilter}`);
if (filterButton) {
const checkmark = filterButton.querySelector('.filter-check');
if (checkmark) {
checkmark.classList.remove('hidden');
}
}
// Show/hide filter indicator dot
const indicator = document.getElementById('filter-indicator');
if (indicator) {
if (activeFilter === 'all' || activeFilter === '') {
indicator.classList.add('hidden');
} else {
indicator.classList.remove('hidden');
}
}
}
function refreshActivityFeed() {
const btn = document.getElementById('refresh-activity-btn');
if (btn) {
const icon = btn.querySelector('i');
if (icon) {
icon.classList.add('fa-spin');
}
}
activityPage = 1;
loadActivities(false).finally(() => {
const btn = document.getElementById('refresh-activity-btn');
if (btn) {
const icon = btn.querySelector('i');
if (icon) {
icon.classList.remove('fa-spin');
}
}
});
}
function loadMoreActivities() {
activityPage++;
loadActivities(true);
}
async function loadActivities(append = false) {
const container = document.getElementById('activity-feed-container');
if (!container) {
console.error('Activity feed container not found');
return Promise.resolve();
}
const loadMoreBtn = document.getElementById('load-more-activities');
const loadMoreContainer = document.getElementById('load-more-container');
if (loadMoreBtn) {
loadMoreBtn.disabled = true;
loadMoreBtn.innerHTML = '<i class="fas fa-spinner fa-spin"></i> {{ _('Loading...') }}';
}
try {
let url = `/api/activities?limit=${activityLimit}&page=${activityPage}`;
// Only add entity_type filter if it's not empty
if (activityFilter && activityFilter !== 'all') {
url += `&entity_type=${activityFilter}`;
}
console.log('Fetching activities from:', url);
const response = await fetch(url, {
credentials: 'same-origin',
headers: {
'Accept': 'application/json'
}
});
if (!response.ok) {
console.error('API response not OK:', response.status, response.statusText);
const errorText = await response.text();
console.error('Error details:', errorText);
throw new Error(`Failed to fetch activities: ${response.status}`);
}
const data = await response.json();
console.log('Received activities:', data);
// Debug: Log entity types of received activities
if (data.activities && data.activities.length > 0) {
const entityTypes = data.activities.map(a => a.entity_type);
console.log('Entity types in response:', entityTypes);
} else {
console.log('No activities received from API');
}
if (!append) {
container.innerHTML = '';
}
if (data.activities && data.activities.length > 0) {
const activityHTML = data.activities.map(activity => createActivityHTML(activity)).join('');
if (append) {
const spacer = container.querySelector('.space-y-3');
if (spacer) {
spacer.insertAdjacentHTML('beforeend', activityHTML);
} else {
container.innerHTML = '<div class="space-y-3">' + activityHTML + '</div>';
}
} else {
container.innerHTML = '<div class="space-y-3">' + activityHTML + '</div>';
}
// Show/hide load more button and container
if (loadMoreContainer) {
if (data.has_next) {
loadMoreContainer.style.display = 'block';
if (loadMoreBtn) {
loadMoreBtn.disabled = false;
loadMoreBtn.innerHTML = '{{ _('Load More') }} <i class="fas fa-chevron-down ml-1"></i>';
}
} else {
loadMoreContainer.style.display = 'none';
}
}
} else if (!append) {
container.innerHTML = `
<div class="text-center py-8 text-text-muted-light dark:text-text-muted-dark">
<i class="fas fa-stream text-4xl mb-3 opacity-50"></i>
<p class="text-sm">{{ _('No recent activity') }}</p>
<p class="text-xs mt-1">{{ _('Activity will appear here as you work') }}</p>
</div>
`;
if (loadMoreContainer) {
loadMoreContainer.style.display = 'none';
}
} else {
// If appending and no results, hide the load more button
if (loadMoreContainer) {
loadMoreContainer.style.display = 'none';
}
}
return Promise.resolve();
} catch (error) {
console.error('Error loading activities:', error);
if (!append) {
container.innerHTML = `
<div class="text-center py-8 text-red-600 dark:text-red-400">
<i class="fas fa-exclamation-triangle text-4xl mb-3"></i>
<p class="text-sm">{{ _('Failed to load activities') }}</p>
<p class="text-xs mt-2">${error.message}</p>
</div>
`;
}
if (loadMoreBtn) {
loadMoreBtn.disabled = false;
loadMoreBtn.innerHTML = '{{ _('Load More') }} <i class="fas fa-chevron-down ml-1"></i>';
}
return Promise.reject(error);
}
}
function createActivityHTML(activity) {
const icon = getActivityIcon(activity.action);
const displayName = activity.display_name || activity.username || 'Unknown';
const timeAgo = formatTimeAgo(new Date(activity.created_at));
const fullTime = new Date(activity.created_at).toLocaleString();
let extraDataHTML = '';
if (activity.extra_data && activity.extra_data.old_status && activity.extra_data.new_status) {
extraDataHTML = `
<div class="mt-1 text-xs text-gray-500 dark:text-gray-400">
<span class="inline-flex items-center gap-1">
<span class="px-2 py-0.5 bg-gray-100 dark:bg-gray-700 rounded">${activity.extra_data.old_status}</span>
<i class="fas fa-arrow-right text-xs"></i>
<span class="px-2 py-0.5 bg-blue-100 dark:bg-blue-900 text-blue-800 dark:text-blue-200 rounded">${activity.extra_data.new_status}</span>
</span>
</div>
`;
}
return `
<div class="flex items-start gap-3 p-3 hover:bg-gray-50 dark:hover:bg-gray-800 rounded-lg transition">
<div class="flex-shrink-0 mt-1">
<i class="${icon}"></i>
</div>
<div class="flex-1 min-w-0">
<div class="flex items-start justify-between gap-2">
<div class="flex-1 min-w-0">
<p class="text-sm">
<span class="font-medium text-text-light dark:text-text-dark">${escapeHtml(displayName)}</span>
<span class="text-text-muted-light dark:text-text-muted-dark">${escapeHtml(activity.description)}</span>
</p>
${extraDataHTML}
</div>
<div class="flex-shrink-0">
<span class="text-xs text-gray-500 dark:text-gray-400" title="${fullTime}">${timeAgo}</span>
</div>
</div>
</div>
</div>
`;
}
function getActivityIcon(action) {
const icons = {
'created': 'fas fa-plus-circle text-green-500',
'updated': 'fas fa-edit text-blue-500',
'deleted': 'fas fa-trash text-red-500',
'started': 'fas fa-play text-green-500',
'stopped': 'fas fa-stop text-red-500',
'completed': 'fas fa-check-circle text-green-500',
'assigned': 'fas fa-user-plus text-blue-500',
'commented': 'fas fa-comment text-gray-500',
'sent': 'fas fa-paper-plane text-blue-500',
'paid': 'fas fa-dollar-sign text-green-500',
'archived': 'fas fa-archive text-gray-500',
'unarchived': 'fas fa-box-open text-blue-500',
'status_changed': 'fas fa-exchange-alt text-blue-500'
};
return icons[action] || 'fas fa-circle text-gray-500';
}
function formatTimeAgo(date) {
const seconds = Math.floor((new Date() - date) / 1000);
if (seconds < 60) return 'just now';
if (seconds < 3600) return Math.floor(seconds / 60) + 'm ago';
if (seconds < 86400) return Math.floor(seconds / 3600) + 'h ago';
if (seconds < 604800) return Math.floor(seconds / 86400) + 'd ago';
return date.toLocaleDateString();
}
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
// Initialize filter indicators on page load
document.addEventListener('DOMContentLoaded', function() {
updateFilterIndicators('all');
});
// Auto-refresh activity feed every 30 seconds
setInterval(() => {
if (activityPage === 1) {
refreshActivityFeed();
}
}, 30000);
</script>
+196
View File
@@ -0,0 +1,196 @@
{% extends "base.html" %}
{% from "components/ui.html" import page_header %}
{% block content %}
{% set breadcrumbs = [
{'text': 'Expense Categories', 'url': url_for('expense_categories.list_categories')},
{'text': 'Edit' if category else 'New'}
] %}
{{ page_header(
icon_class='fas fa-tags',
title_text=('Edit Expense Category' if category else 'New Expense Category'),
subtitle_text=('Update category details' if category else 'Create a new expense category'),
breadcrumbs=breadcrumbs
) }}
<div class="max-w-4xl mx-auto">
<form method="POST" class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<!-- Basic Information -->
<div class="mb-6">
<h3 class="text-lg font-semibold mb-4 pb-2 border-b border-gray-200 dark:border-gray-700">
<i class="fas fa-info-circle mr-2"></i>Basic Information
</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label for="name" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Category Name <span class="text-red-500">*</span>
</label>
<input type="text" name="name" id="name" required
value="{{ category.name if category else '' }}"
placeholder="e.g., Travel, Meals, Office Supplies"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-primary focus:border-primary dark:bg-gray-700">
</div>
<div>
<label for="code" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Code
</label>
<input type="text" name="code" id="code"
value="{{ category.code if category else '' }}"
placeholder="e.g., TRAVEL, MEALS"
maxlength="20"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-primary focus:border-primary dark:bg-gray-700">
</div>
<div class="md:col-span-2">
<label for="description" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Description
</label>
<textarea name="description" id="description" rows="3"
placeholder="Brief description of this category..."
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-primary focus:border-primary dark:bg-gray-700">{{ category.description if category else '' }}</textarea>
</div>
<div>
<label for="color" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Color
</label>
<input type="color" name="color" id="color"
value="{{ category.color if category else '#3B82F6' }}"
class="h-10 w-full border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-primary focus:border-primary">
</div>
<div>
<label for="icon" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Icon (Font Awesome class)
</label>
<input type="text" name="icon" id="icon"
value="{{ category.icon if category else '' }}"
placeholder="e.g., fa-plane, fa-utensils"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-primary focus:border-primary dark:bg-gray-700">
</div>
</div>
</div>
<!-- Budget Settings -->
<div class="mb-6">
<h3 class="text-lg font-semibold mb-4 pb-2 border-b border-gray-200 dark:border-gray-700">
<i class="fas fa-chart-pie mr-2"></i>Budget Settings
</h3>
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<label for="monthly_budget" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Monthly Budget
</label>
<input type="number" name="monthly_budget" id="monthly_budget" step="0.01"
value="{{ category.monthly_budget if category and category.monthly_budget else '' }}"
placeholder="0.00"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-primary focus:border-primary dark:bg-gray-700">
</div>
<div>
<label for="quarterly_budget" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Quarterly Budget
</label>
<input type="number" name="quarterly_budget" id="quarterly_budget" step="0.01"
value="{{ category.quarterly_budget if category and category.quarterly_budget else '' }}"
placeholder="0.00"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-primary focus:border-primary dark:bg-gray-700">
</div>
<div>
<label for="yearly_budget" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Yearly Budget
</label>
<input type="number" name="yearly_budget" id="yearly_budget" step="0.01"
value="{{ category.yearly_budget if category and category.yearly_budget else '' }}"
placeholder="0.00"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-primary focus:border-primary dark:bg-gray-700">
</div>
<div>
<label for="budget_threshold_percent" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Alert Threshold (%)
</label>
<input type="number" name="budget_threshold_percent" id="budget_threshold_percent"
value="{{ category.budget_threshold_percent if category else '80' }}"
min="0" max="100"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-primary focus:border-primary dark:bg-gray-700">
<p class="text-xs text-gray-500 mt-1">Alert when budget utilization reaches this percentage</p>
</div>
</div>
</div>
<!-- Category Settings -->
<div class="mb-6">
<h3 class="text-lg font-semibold mb-4 pb-2 border-b border-gray-200 dark:border-gray-700">
<i class="fas fa-cog mr-2"></i>Category Settings
</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label for="default_tax_rate" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Default Tax Rate (%)
</label>
<input type="number" name="default_tax_rate" id="default_tax_rate" step="0.01"
value="{{ category.default_tax_rate if category and category.default_tax_rate else '' }}"
placeholder="e.g., 19.00"
min="0" max="100"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-primary focus:border-primary dark:bg-gray-700">
</div>
</div>
<div class="mt-4 space-y-3">
<div class="flex items-center">
<input type="checkbox" name="requires_receipt" id="requires_receipt"
{% if not category or category.requires_receipt %}checked{% endif %}
class="h-4 w-4 text-primary focus:ring-primary border-gray-300 rounded">
<label for="requires_receipt" class="ml-2 text-sm text-gray-700 dark:text-gray-300">
<strong>Requires Receipt</strong>
<span class="block text-xs text-gray-500">Expenses in this category must have an attached receipt</span>
</label>
</div>
<div class="flex items-center">
<input type="checkbox" name="requires_approval" id="requires_approval"
{% if not category or category.requires_approval %}checked{% endif %}
class="h-4 w-4 text-primary focus:ring-primary border-gray-300 rounded">
<label for="requires_approval" class="ml-2 text-sm text-gray-700 dark:text-gray-300">
<strong>Requires Approval</strong>
<span class="block text-xs text-gray-500">Expenses in this category must be approved by an administrator</span>
</label>
</div>
<div class="flex items-center">
<input type="checkbox" name="is_active" id="is_active"
{% if not category or category.is_active %}checked{% endif %}
class="h-4 w-4 text-primary focus:ring-primary border-gray-300 rounded">
<label for="is_active" class="ml-2 text-sm text-gray-700 dark:text-gray-300">
<strong>Active</strong>
<span class="block text-xs text-gray-500">Only active categories are available for selection</span>
</label>
</div>
</div>
</div>
<!-- Actions -->
<div class="flex items-center justify-between pt-4 border-t border-gray-200 dark:border-gray-700">
<a href="{{ url_for('expense_categories.list_categories') }}"
class="px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors">
<i class="fas fa-times mr-2"></i>Cancel
</a>
<button type="submit" class="bg-primary text-white px-6 py-2 rounded-lg hover:bg-primary/90 transition-colors">
<i class="fas fa-save mr-2"></i>{{ 'Update Category' if category else 'Create Category' }}
</button>
</div>
</form>
</div>
{% endblock %}
+160
View File
@@ -0,0 +1,160 @@
{% extends "base.html" %}
{% from "components/ui.html" import page_header, breadcrumb_nav %}
{% block content %}
{% set breadcrumbs = [
{'text': 'Expense Categories'}
] %}
{{ page_header(
icon_class='fas fa-tags',
title_text='Expense Categories',
subtitle_text='Manage expense categories and budgets',
breadcrumbs=breadcrumbs,
actions_html='<a href="' + url_for("expense_categories.create_category") + '" class="bg-primary text-white px-4 py-2 rounded-lg hover:bg-primary/90 transition-colors"><i class="fas fa-plus mr-2"></i>New Category</a>'
) }}
<!-- Summary Stats -->
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 mb-6">
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow">
<div class="flex items-center justify-between">
<div>
<p class="text-sm text-gray-600 dark:text-gray-400">Total Categories</p>
<p class="text-2xl font-bold">{{ categories|length }}</p>
</div>
<div class="text-primary text-3xl">
<i class="fas fa-tags"></i>
</div>
</div>
</div>
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow">
<div class="flex items-center justify-between">
<div>
<p class="text-sm text-gray-600 dark:text-gray-400">Active Categories</p>
<p class="text-2xl font-bold">{{ categories|selectattr('is_active')|list|length }}</p>
</div>
<div class="text-green-500 text-3xl">
<i class="fas fa-check-circle"></i>
</div>
</div>
</div>
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow">
<div class="flex items-center justify-between">
<div>
<p class="text-sm text-gray-600 dark:text-gray-400">With Budgets</p>
<p class="text-2xl font-bold">{{ categories|selectattr('monthly_budget')|list|length }}</p>
</div>
<div class="text-blue-500 text-3xl">
<i class="fas fa-chart-pie"></i>
</div>
</div>
</div>
</div>
<!-- Categories Table -->
<div class="bg-card-light dark:bg-card-dark rounded-lg shadow overflow-hidden">
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
<thead class="bg-gray-50 dark:bg-gray-800">
<tr>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Category</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Code</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Monthly Budget</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Utilization</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Status</th>
<th class="px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Actions</th>
</tr>
</thead>
<tbody class="bg-white dark:bg-gray-900 divide-y divide-gray-200 dark:divide-gray-700">
{% if categories %}
{% for category in categories %}
<tr class="hover:bg-gray-50 dark:hover:bg-gray-800">
<td class="px-6 py-4">
<div class="flex items-center">
{% if category.icon %}
<div class="flex-shrink-0 h-10 w-10 flex items-center justify-center rounded-full" style="{% if category.color %}background-color: {{ category.color }}20;{% else %}background-color: #f3f4f6;{% endif %}">
<i class="{{ category.icon }} {% if category.color %}text-[{{ category.color }}]{% else %}text-gray-600{% endif %}"></i>
</div>
{% endif %}
<div class="ml-4">
<a href="{{ url_for('expense_categories.view_category', category_id=category.id) }}" class="text-primary hover:underline font-medium">
{{ category.name }}
</a>
{% if category.description %}
<div class="text-xs text-gray-500">{{ category.description[:50] }}{% if category.description|length > 50 %}...{% endif %}</div>
{% endif %}
</div>
</div>
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm">
{% if category.code %}
<span class="px-2 py-1 text-xs rounded-full bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-200">
{{ category.code }}
</span>
{% else %}
<span class="text-gray-400">-</span>
{% endif %}
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm">
{% if category.monthly_budget %}
€{{ '%.2f'|format(category.monthly_budget) }}
{% else %}
<span class="text-gray-400">No budget</span>
{% endif %}
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm">
{% set util = category.monthly_utilization if category.monthly_utilization is not none else None %}
{% if util is not none %}
<div class="flex items-center">
<div class="w-24 bg-gray-200 dark:bg-gray-700 rounded-full h-2 mr-2">
<div class="{% if util >= (category.budget_threshold_percent or 80) %}bg-red-500{% elif util >= (category.budget_threshold_percent or 80) * 0.8 %}bg-yellow-500{% else %}bg-green-500{% endif %} h-2 rounded-full" style="width: {{ [util, 100]|min }}%"></div>
</div>
<span class="{% if util >= (category.budget_threshold_percent or 80) %}text-red-600{% elif util >= (category.budget_threshold_percent or 80) * 0.8 %}text-yellow-600{% else %}text-green-600{% endif %}">{{ util }}%</span>
</div>
{% else %}
<span class="text-gray-400">-</span>
{% endif %}
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm">
{% if category.is_active %}
<span class="px-2 py-1 text-xs rounded-full bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200">
Active
</span>
{% else %}
<span class="px-2 py-1 text-xs rounded-full bg-gray-100 text-gray-800 dark:bg-gray-900 dark:text-gray-200">
Inactive
</span>
{% endif %}
</td>
<td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
<div class="flex items-center justify-end gap-2">
<a href="{{ url_for('expense_categories.view_category', category_id=category.id) }}" class="text-primary hover:text-primary/80" title="View">
<i class="fas fa-eye"></i>
</a>
<a href="{{ url_for('expense_categories.edit_category', category_id=category.id) }}" class="text-blue-600 hover:text-blue-800" title="Edit">
<i class="fas fa-edit"></i>
</a>
</div>
</td>
</tr>
{% endfor %}
{% else %}
<tr>
<td colspan="6" class="px-6 py-8 text-center text-gray-500">
<i class="fas fa-tags text-4xl mb-2 opacity-50"></i>
<p>No expense categories found</p>
<a href="{{ url_for('expense_categories.create_category') }}" class="text-primary hover:underline mt-2 inline-block">
Create your first category
</a>
</td>
</tr>
{% endif %}
</tbody>
</table>
</div>
</div>
{% endblock %}
+208
View File
@@ -0,0 +1,208 @@
{% extends "base.html" %}
{% from "components/ui.html" import page_header %}
{% block content %}
{% set breadcrumbs = [
{'text': 'Expense Categories', 'url': url_for('expense_categories.list_categories')},
{'text': category.name}
] %}
{{ page_header(
icon_class='fas fa-tags',
title_text=category.name,
subtitle_text=category.description if category.description else 'Expense Category Details',
breadcrumbs=breadcrumbs,
actions_html='<a href="' + url_for("expense_categories.edit_category", category_id=category.id) + '" class="bg-primary text-white px-4 py-2 rounded-lg hover:bg-primary/90 transition-colors"><i class="fas fa-edit mr-2"></i>Edit Category</a>'
) }}
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
<!-- Category Details -->
<div class="lg:col-span-2 space-y-6">
<!-- Basic Information -->
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow">
<h3 class="text-lg font-semibold mb-4 pb-2 border-b border-gray-200 dark:border-gray-700">
<i class="fas fa-info-circle mr-2"></i>Basic Information
</h3>
<div class="grid grid-cols-2 gap-4">
<div>
<p class="text-sm text-gray-600 dark:text-gray-400 mb-1">Category Name</p>
<div class="flex items-center">
{% if category.icon %}
<div class="flex-shrink-0 h-10 w-10 flex items-center justify-center rounded-full mr-3" style="{% if category.color %}background-color: {{ category.color }}20;{% else %}background-color: #f3f4f6;{% endif %}">
<i class="{{ category.icon }} {% if category.color %}text-[{{ category.color }}]{% else %}text-gray-600{% endif %}"></i>
</div>
{% endif %}
<p class="font-semibold">{{ category.name }}</p>
</div>
</div>
<div>
<p class="text-sm text-gray-600 dark:text-gray-400 mb-1">Code</p>
<p class="font-semibold">{{ category.code if category.code else '-' }}</p>
</div>
<div class="col-span-2">
<p class="text-sm text-gray-600 dark:text-gray-400 mb-1">Description</p>
<p class="font-semibold">{{ category.description if category.description else '-' }}</p>
</div>
<div>
<p class="text-sm text-gray-600 dark:text-gray-400 mb-1">Status</p>
{% if category.is_active %}
<span class="px-2 py-1 text-xs rounded-full bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200">
Active
</span>
{% else %}
<span class="px-2 py-1 text-xs rounded-full bg-gray-100 text-gray-800 dark:bg-gray-900 dark:text-gray-200">
Inactive
</span>
{% endif %}
</div>
<div>
<p class="text-sm text-gray-600 dark:text-gray-400 mb-1">Default Tax Rate</p>
<p class="font-semibold">{{ '%.2f'|format(category.default_tax_rate) if category.default_tax_rate else '-' }}%</p>
</div>
</div>
</div>
<!-- Budget Information -->
{% if category.monthly_budget or category.quarterly_budget or category.yearly_budget %}
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow">
<h3 class="text-lg font-semibold mb-4 pb-2 border-b border-gray-200 dark:border-gray-700">
<i class="fas fa-chart-pie mr-2"></i>Budget & Utilization
</h3>
<div class="space-y-4">
{% if category.monthly_budget %}
<div>
<div class="flex justify-between items-center mb-2">
<span class="text-sm font-medium">Monthly Budget</span>
<span class="text-sm font-semibold">€{{ '%.2f'|format(category.monthly_budget) }}</span>
</div>
{% if monthly_utilization is defined %}
<div class="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-3">
<div class="{% if monthly_utilization >= category.budget_threshold_percent %}bg-red-500{% elif monthly_utilization >= category.budget_threshold_percent * 0.8 %}bg-yellow-500{% else %}bg-green-500{% endif %} h-3 rounded-full flex items-center justify-center text-xs text-white font-semibold" style="width: {{ [monthly_utilization, 100]|min }}%">
{{ monthly_utilization }}%
</div>
</div>
{% endif %}
</div>
{% endif %}
{% if category.quarterly_budget %}
<div>
<div class="flex justify-between items-center mb-2">
<span class="text-sm font-medium">Quarterly Budget</span>
<span class="text-sm font-semibold">€{{ '%.2f'|format(category.quarterly_budget) }}</span>
</div>
{% if quarterly_utilization is defined %}
<div class="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-3">
<div class="{% if quarterly_utilization >= category.budget_threshold_percent %}bg-red-500{% elif quarterly_utilization >= category.budget_threshold_percent * 0.8 %}bg-yellow-500{% else %}bg-green-500{% endif %} h-3 rounded-full flex items-center justify-center text-xs text-white font-semibold" style="width: {{ [quarterly_utilization, 100]|min }}%">
{{ quarterly_utilization }}%
</div>
</div>
{% endif %}
</div>
{% endif %}
{% if category.yearly_budget %}
<div>
<div class="flex justify-between items-center mb-2">
<span class="text-sm font-medium">Yearly Budget</span>
<span class="text-sm font-semibold">€{{ '%.2f'|format(category.yearly_budget) }}</span>
</div>
{% if yearly_utilization is defined %}
<div class="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-3">
<div class="{% if yearly_utilization >= category.budget_threshold_percent %}bg-red-500{% elif yearly_utilization >= category.budget_threshold_percent * 0.8 %}bg-yellow-500{% else %}bg-green-500{% endif %} h-3 rounded-full flex items-center justify-center text-xs text-white font-semibold" style="width: {{ [yearly_utilization, 100]|min }}%">
{{ yearly_utilization }}%
</div>
</div>
{% endif %}
</div>
{% endif %}
<div class="mt-4 p-3 bg-blue-50 dark:bg-blue-900/20 rounded-lg">
<p class="text-sm text-blue-800 dark:text-blue-200">
<i class="fas fa-info-circle mr-1"></i>
<strong>Alert Threshold:</strong> {{ category.budget_threshold_percent }}%
</p>
</div>
</div>
</div>
{% endif %}
</div>
<!-- Sidebar -->
<div class="space-y-6">
<!-- Settings -->
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow">
<h3 class="text-lg font-semibold mb-4 pb-2 border-b border-gray-200 dark:border-gray-700">
<i class="fas fa-cog mr-2"></i>Settings
</h3>
<div class="space-y-3">
<div class="flex items-center justify-between">
<span class="text-sm">Requires Receipt</span>
{% if category.requires_receipt %}
<span class="text-green-600"><i class="fas fa-check-circle"></i> Yes</span>
{% else %}
<span class="text-gray-500"><i class="fas fa-times-circle"></i> No</span>
{% endif %}
</div>
<div class="flex items-center justify-between">
<span class="text-sm">Requires Approval</span>
{% if category.requires_approval %}
<span class="text-green-600"><i class="fas fa-check-circle"></i> Yes</span>
{% else %}
<span class="text-gray-500"><i class="fas fa-times-circle"></i> No</span>
{% endif %}
</div>
</div>
</div>
<!-- Metadata -->
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow">
<h3 class="text-lg font-semibold mb-4 pb-2 border-b border-gray-200 dark:border-gray-700">
<i class="fas fa-clock mr-2"></i>Metadata
</h3>
<div class="space-y-3">
<div>
<p class="text-sm text-gray-600 dark:text-gray-400 mb-1">Created</p>
<p class="font-semibold text-sm">{{ category.created_at.strftime('%Y-%m-%d %H:%M') if category.created_at else '-' }}</p>
</div>
<div>
<p class="text-sm text-gray-600 dark:text-gray-400 mb-1">Last Updated</p>
<p class="font-semibold text-sm">{{ category.updated_at.strftime('%Y-%m-%d %H:%M') if category.updated_at else '-' }}</p>
</div>
</div>
</div>
<!-- Actions -->
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow">
<h3 class="text-lg font-semibold mb-4 pb-2 border-b border-gray-200 dark:border-gray-700">
<i class="fas fa-tools mr-2"></i>Actions
</h3>
<div class="space-y-2">
<a href="{{ url_for('expense_categories.edit_category', category_id=category.id) }}" class="block w-full bg-primary text-white px-4 py-2 rounded-lg hover:bg-primary/90 transition-colors text-center">
<i class="fas fa-edit mr-2"></i>Edit Category
</a>
<form method="POST" action="{{ url_for('expense_categories.delete_category', category_id=category.id) }}" onsubmit="return confirm('Are you sure you want to deactivate this category?');">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="block w-full bg-red-600 text-white px-4 py-2 rounded-lg hover:bg-red-700 transition-colors">
<i class="fas fa-ban mr-2"></i>Deactivate
</button>
</form>
</div>
</div>
</div>
</div>
{% endblock %}
+3 -3
View File
@@ -57,10 +57,10 @@
<div class="flex items-center justify-between">
<div>
<p class="text-sm text-gray-600 dark:text-gray-400">Total Amount</p>
<p class="text-3xl font-bold text-green-600">{{ '%.2f'|format(total_amount) }}</p>
<p class="text-3xl font-bold text-green-600">{{ currency|currency_symbol }}{{ '%.2f'|format(total_amount) }}</p>
</div>
<div class="text-green-500 text-4xl">
<i class="fas fa-euro-sign"></i>
<i class="fas {{ currency|currency_icon }}"></i>
</div>
</div>
</div>
@@ -148,7 +148,7 @@
<span class="text-sm font-medium">{{ stat.category|title }}</span>
</div>
<div class="text-right">
<div class="font-bold">{{ '%.2f'|format(stat.total_amount) }}</div>
<div class="font-bold">{{ currency|currency_symbol }}{{ '%.2f'|format(stat.total_amount) }}</div>
<div class="text-xs text-gray-500">{{ stat.count }} items</div>
</div>
</div>
+8 -3
View File
@@ -11,7 +11,12 @@
title_text='Expenses',
subtitle_text='Track and manage business expenses',
breadcrumbs=breadcrumbs,
actions_html='<a href="' + url_for("expenses.create_expense") + '" class="bg-primary text-white px-4 py-2 rounded-lg hover:bg-primary/90 transition-colors"><i class="fas fa-plus mr-2"></i>New Expense</a>'
actions_html=''
+ '<div class="flex gap-2">'
+ '<a href="' + url_for("expenses.create_expense") + '" class="bg-primary text-white px-4 py-2 rounded-lg hover:bg-primary/90 transition-colors"><i class="fas fa-plus mr-2"></i>New Expense</a>'
+ '<a href="' + url_for("mileage.create_mileage") + '" class="bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700 transition-colors"><i class="fas fa-car mr-2"></i>New Mileage</a>'
+ '<a href="' + url_for("per_diem.create_per_diem") + '" class="bg-emerald-600 text-white px-4 py-2 rounded-lg hover:bg-emerald-700 transition-colors"><i class="fas fa-money-bill-alt mr-2"></i>New Per Diem</a>'
+ '</div>'
) }}
<!-- Summary Stats -->
@@ -32,10 +37,10 @@
<div class="flex items-center justify-between">
<div>
<p class="text-sm text-gray-600 dark:text-gray-400">Total Amount</p>
<p class="text-2xl font-bold">{{ '%.2f'|format(total_amount) }}</p>
<p class="text-2xl font-bold">{{ currency|currency_symbol }}{{ '%.2f'|format(total_amount) }}</p>
</div>
<div class="text-green-500 text-3xl">
<i class="fas fa-euro-sign"></i>
<i class="fas {{ currency|currency_icon }}"></i>
</div>
</div>
</div>
+532
View File
@@ -0,0 +1,532 @@
{% extends "base.html" %}
{% from "components/ui.html" import page_header, breadcrumb_nav, button, filter_badge %}
{% block title %}{{ _('Import/Export Data') }}{% endblock %}
{% block content %}
{% set breadcrumbs = [
{'text': _('Import/Export')}
] %}
{{ page_header(
icon_class='fas fa-exchange-alt',
title_text=_('Import/Export Data'),
subtitle_text=_('Import data from other time trackers or export your data for GDPR compliance and backups'),
breadcrumbs=breadcrumbs,
actions_html=None
) }}
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6">
<!-- Import Section -->
<div class="space-y-6">
<div class="bg-white dark:bg-gray-800 shadow rounded-lg p-6">
<h2 class="text-xl font-semibold text-gray-900 dark:text-white mb-4">
<i class="fas fa-file-import mr-2"></i>{{ _('Import Data') }}
</h2>
<!-- CSV Import -->
<div class="mb-6">
<h3 class="text-lg font-medium text-gray-900 dark:text-white mb-2">{{ _('CSV Import') }}</h3>
<p class="text-sm text-gray-600 dark:text-gray-400 mb-3">{{ _('Import time entries from a CSV file') }}</p>
<div class="flex items-center space-x-3">
<label for="csv-file" class="cursor-pointer bg-blue-600 text-white px-4 py-2 rounded hover:bg-blue-700 transition">
<i class="fas fa-upload mr-2"></i>{{ _('Choose CSV File') }}
</label>
<input type="file" id="csv-file" accept=".csv" class="hidden" onchange="handleCsvUpload(this)">
<a href="/api/import/template/csv" class="text-blue-600 hover:underline text-sm">
<i class="fas fa-download mr-1"></i>{{ _('Download Template') }}
</a>
</div>
<div id="csv-upload-status" class="mt-2 text-sm"></div>
</div>
<hr class="my-6 border-gray-200 dark:border-gray-700">
<!-- Toggl Import -->
<div class="mb-6">
<h3 class="text-lg font-medium text-gray-900 dark:text-white mb-2">{{ _('Import from Toggl Track') }}</h3>
<p class="text-sm text-gray-600 dark:text-gray-400 mb-3">{{ _('Import time entries from Toggl Track') }}</p>
<button onclick="showTogglImportForm()" class="bg-pink-600 text-white px-4 py-2 rounded hover:bg-pink-700 transition">
<i class="fas fa-clock mr-2"></i>{{ _('Import from Toggl') }}
</button>
</div>
<hr class="my-6 border-gray-200 dark:border-gray-700">
<!-- Harvest Import -->
<div class="mb-6">
<h3 class="text-lg font-medium text-gray-900 dark:text-white mb-2">{{ _('Import from Harvest') }}</h3>
<p class="text-sm text-gray-600 dark:text-gray-400 mb-3">{{ _('Import time entries from Harvest') }}</p>
<button onclick="showHarvestImportForm()" class="bg-orange-600 text-white px-4 py-2 rounded hover:bg-orange-700 transition">
<i class="fas fa-clock mr-2"></i>{{ _('Import from Harvest') }}
</button>
</div>
{% if current_user.is_admin %}
<hr class="my-6 border-gray-200 dark:border-gray-700">
<!-- Restore Backup -->
<div>
<h3 class="text-lg font-medium text-gray-900 dark:text-white mb-2">{{ _('Restore from Backup') }}</h3>
<p class="text-sm text-gray-600 dark:text-gray-400 mb-3">{{ _('Restore data from a backup file') }}</p>
<label for="backup-file" class="cursor-pointer bg-red-600 text-white px-4 py-2 rounded hover:bg-red-700 transition inline-block">
<i class="fas fa-file-upload mr-2"></i>{{ _('Restore Backup') }}
</label>
<input type="file" id="backup-file" accept=".json" class="hidden" onchange="handleBackupRestore(this)">
<div id="backup-restore-status" class="mt-2 text-sm"></div>
</div>
{% endif %}
</div>
<!-- Import History -->
<div class="bg-white dark:bg-gray-800 shadow rounded-lg p-6">
<h2 class="text-xl font-semibold text-gray-900 dark:text-white mb-4">
<i class="fas fa-history mr-2"></i>{{ _('Import History') }}
</h2>
<div id="import-history" class="space-y-2">
<p class="text-gray-500 dark:text-gray-400 text-sm">{{ _('Loading...') }}</p>
</div>
</div>
</div>
<!-- Export Section -->
<div class="space-y-6">
<div class="bg-white dark:bg-gray-800 shadow rounded-lg p-6">
<h2 class="text-xl font-semibold text-gray-900 dark:text-white mb-4">
<i class="fas fa-file-export mr-2"></i>{{ _('Export Data') }}
</h2>
<!-- GDPR Export -->
<div class="mb-6">
<h3 class="text-lg font-medium text-gray-900 dark:text-white mb-2">{{ _('Full Data Export (GDPR)') }}</h3>
<p class="text-sm text-gray-600 dark:text-gray-400 mb-3">{{ _('Export all your personal data') }}</p>
<div class="flex space-x-3">
<button onclick="exportGdpr('json')" class="bg-green-600 text-white px-4 py-2 rounded hover:bg-green-700 transition">
<i class="fas fa-file-code mr-2"></i>{{ _('Export as JSON') }}
</button>
<button onclick="exportGdpr('zip')" class="bg-green-600 text-white px-4 py-2 rounded hover:bg-green-700 transition">
<i class="fas fa-file-archive mr-2"></i>{{ _('Export as ZIP') }}
</button>
</div>
<div id="gdpr-export-status" class="mt-2 text-sm"></div>
</div>
<hr class="my-6 border-gray-200 dark:border-gray-700">
<!-- Filtered Export -->
<div class="mb-6">
<h3 class="text-lg font-medium text-gray-900 dark:text-white mb-2">{{ _('Filtered Export') }}</h3>
<p class="text-sm text-gray-600 dark:text-gray-400 mb-3">{{ _('Export specific data with filters') }}</p>
<button onclick="showFilteredExportForm()" class="bg-blue-600 text-white px-4 py-2 rounded hover:bg-blue-700 transition">
<i class="fas fa-filter mr-2"></i>{{ _('Export with Filters') }}
</button>
</div>
{% if current_user.is_admin %}
<hr class="my-6 border-gray-200 dark:border-gray-700">
<!-- Create Backup -->
<div>
<h3 class="text-lg font-medium text-gray-900 dark:text-white mb-2">{{ _('Create Backup') }}</h3>
<p class="text-sm text-gray-600 dark:text-gray-400 mb-3">{{ _('Create a full database backup') }}</p>
<button onclick="createBackup()" class="bg-purple-600 text-white px-4 py-2 rounded hover:bg-purple-700 transition">
<i class="fas fa-database mr-2"></i>{{ _('Create Backup') }}
</button>
<div id="backup-create-status" class="mt-2 text-sm"></div>
</div>
{% endif %}
</div>
<!-- Export History -->
<div class="bg-white dark:bg-gray-800 shadow rounded-lg p-6">
<h2 class="text-xl font-semibold text-gray-900 dark:text-white mb-4">
<i class="fas fa-history mr-2"></i>{{ _('Export History') }}
</h2>
<div id="export-history" class="space-y-2">
<p class="text-gray-500 dark:text-gray-400 text-sm">{{ _('Loading...') }}</p>
</div>
</div>
</div>
</div>
</div>
<!-- Toggl Import Modal -->
<div id="toggl-modal" class="hidden fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div class="bg-white dark:bg-gray-800 rounded-lg p-6 max-w-md w-full mx-4">
<h3 class="text-xl font-semibold text-gray-900 dark:text-white mb-4">{{ _('Import from Toggl Track') }}</h3>
<form id="toggl-form" onsubmit="submitTogglImport(event)" class="space-y-4">
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">{{ _('API Token') }}</label>
<input type="text" name="api_token" required class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md dark:bg-gray-700 dark:text-white">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">{{ _('Workspace ID') }}</label>
<input type="text" name="workspace_id" required class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md dark:bg-gray-700 dark:text-white">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">{{ _('Start Date') }}</label>
<input type="date" name="start_date" required class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md dark:bg-gray-700 dark:text-white">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">{{ _('End Date') }}</label>
<input type="date" name="end_date" required class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md dark:bg-gray-700 dark:text-white">
</div>
<div class="flex space-x-3">
<button type="submit" class="flex-1 bg-pink-600 text-white px-4 py-2 rounded hover:bg-pink-700 transition">
{{ _('Import') }}
</button>
<button type="button" onclick="hideTogglImportForm()" class="flex-1 bg-gray-300 dark:bg-gray-600 text-gray-700 dark:text-white px-4 py-2 rounded hover:bg-gray-400 dark:hover:bg-gray-500 transition">
{{ _('Cancel') }}
</button>
</div>
</form>
</div>
</div>
<!-- Harvest Import Modal -->
<div id="harvest-modal" class="hidden fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
<div class="bg-white dark:bg-gray-800 rounded-lg p-6 max-w-md w-full mx-4">
<h3 class="text-xl font-semibold text-gray-900 dark:text-white mb-4">{{ _('Import from Harvest') }}</h3>
<form id="harvest-form" onsubmit="submitHarvestImport(event)" class="space-y-4">
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">{{ _('Account ID') }}</label>
<input type="text" name="account_id" required class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md dark:bg-gray-700 dark:text-white">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">{{ _('API Token') }}</label>
<input type="text" name="api_token" required class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md dark:bg-gray-700 dark:text-white">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">{{ _('Start Date') }}</label>
<input type="date" name="start_date" required class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md dark:bg-gray-700 dark:text-white">
</div>
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">{{ _('End Date') }}</label>
<input type="date" name="end_date" required class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-md dark:bg-gray-700 dark:text-white">
</div>
<div class="flex space-x-3">
<button type="submit" class="flex-1 bg-orange-600 text-white px-4 py-2 rounded hover:bg-orange-700 transition">
{{ _('Import') }}
</button>
<button type="button" onclick="hideHarvestImportForm()" class="flex-1 bg-gray-300 dark:bg-gray-600 text-gray-700 dark:text-white px-4 py-2 rounded hover:bg-gray-400 dark:hover:bg-gray-500 transition">
{{ _('Cancel') }}
</button>
</div>
</form>
</div>
</div>
<script>
// CSV Upload
async function handleCsvUpload(input) {
const file = input.files[0];
if (!file) return;
const statusEl = document.getElementById('csv-upload-status');
statusEl.innerHTML = '<span class="text-blue-600"><i class="fas fa-spinner fa-spin mr-1"></i>Uploading...</span>';
const formData = new FormData();
formData.append('file', file);
try {
const response = await fetch('/api/import/csv', {
method: 'POST',
body: formData
});
const data = await response.json();
if (response.ok) {
statusEl.innerHTML = `<span class="text-green-600"><i class="fas fa-check-circle mr-1"></i>Import successful: ${data.summary.successful} records imported</span>`;
loadImportHistory();
} else {
statusEl.innerHTML = `<span class="text-red-600"><i class="fas fa-exclamation-circle mr-1"></i>Error: ${data.error}</span>`;
}
} catch (error) {
statusEl.innerHTML = `<span class="text-red-600"><i class="fas fa-exclamation-circle mr-1"></i>Error: ${error.message}</span>`;
}
input.value = '';
}
// Toggl Import
function showTogglImportForm() {
document.getElementById('toggl-modal').classList.remove('hidden');
}
function hideTogglImportForm() {
document.getElementById('toggl-modal').classList.add('hidden');
}
async function submitTogglImport(event) {
event.preventDefault();
const form = event.target;
const formData = new FormData(form);
const data = {
api_token: formData.get('api_token'),
workspace_id: formData.get('workspace_id'),
start_date: formData.get('start_date'),
end_date: formData.get('end_date')
};
try {
const response = await fetch('/api/import/toggl', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(data)
});
const result = await response.json();
if (response.ok) {
alert(`Import successful: ${result.summary.successful} records imported`);
hideTogglImportForm();
loadImportHistory();
} else {
alert(`Error: ${result.error}`);
}
} catch (error) {
alert(`Error: ${error.message}`);
}
}
// Harvest Import
function showHarvestImportForm() {
document.getElementById('harvest-modal').classList.remove('hidden');
}
function hideHarvestImportForm() {
document.getElementById('harvest-modal').classList.add('hidden');
}
async function submitHarvestImport(event) {
event.preventDefault();
const form = event.target;
const formData = new FormData(form);
const data = {
account_id: formData.get('account_id'),
api_token: formData.get('api_token'),
start_date: formData.get('start_date'),
end_date: formData.get('end_date')
};
try {
const response = await fetch('/api/import/harvest', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(data)
});
const result = await response.json();
if (response.ok) {
alert(`Import successful: ${result.summary.successful} records imported`);
hideHarvestImportForm();
loadImportHistory();
} else {
alert(`Error: ${result.error}`);
}
} catch (error) {
alert(`Error: ${error.message}`);
}
}
// GDPR Export
async function exportGdpr(format) {
const statusEl = document.getElementById('gdpr-export-status');
statusEl.innerHTML = '<span class="text-blue-600"><i class="fas fa-spinner fa-spin mr-1"></i>Exporting...</span>';
try {
const response = await fetch('/api/export/gdpr', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({format})
});
const data = await response.json();
if (response.ok) {
statusEl.innerHTML = `<span class="text-green-600"><i class="fas fa-check-circle mr-1"></i>Export ready! <a href="${data.download_url}" class="underline">Download</a></span>`;
loadExportHistory();
} else {
statusEl.innerHTML = `<span class="text-red-600"><i class="fas fa-exclamation-circle mr-1"></i>Error: ${data.error}</span>`;
}
} catch (error) {
statusEl.innerHTML = `<span class="text-red-600"><i class="fas fa-exclamation-circle mr-1"></i>Error: ${error.message}</span>`;
}
}
// Create Backup
async function createBackup() {
const statusEl = document.getElementById('backup-create-status');
statusEl.innerHTML = '<span class="text-blue-600"><i class="fas fa-spinner fa-spin mr-1"></i>Creating backup...</span>';
try {
const response = await fetch('/api/export/backup', {
method: 'POST',
headers: {'Content-Type': 'application/json'}
});
const data = await response.json();
if (response.ok) {
statusEl.innerHTML = `<span class="text-green-600"><i class="fas fa-check-circle mr-1"></i>Backup ready! <a href="${data.download_url}" class="underline">Download</a></span>`;
loadExportHistory();
} else {
statusEl.innerHTML = `<span class="text-red-600"><i class="fas fa-exclamation-circle mr-1"></i>Error: ${data.error}</span>`;
}
} catch (error) {
statusEl.innerHTML = `<span class="text-red-600"><i class="fas fa-exclamation-circle mr-1"></i>Error: ${error.message}</span>`;
}
}
// Backup Restore
async function handleBackupRestore(input) {
const file = input.files[0];
if (!file) return;
if (!confirm('Are you sure you want to restore from this backup? This may overwrite existing data.')) {
input.value = '';
return;
}
const statusEl = document.getElementById('backup-restore-status');
statusEl.innerHTML = '<span class="text-blue-600"><i class="fas fa-spinner fa-spin mr-1"></i>Restoring...</span>';
const formData = new FormData();
formData.append('file', file);
try {
const response = await fetch('/api/backup/restore', {
method: 'POST',
body: formData
});
const data = await response.json();
if (response.ok) {
statusEl.innerHTML = '<span class="text-green-600"><i class="fas fa-check-circle mr-1"></i>Restore successful</span>';
loadImportHistory();
} else {
statusEl.innerHTML = `<span class="text-red-600"><i class="fas fa-exclamation-circle mr-1"></i>Error: ${data.error}</span>`;
}
} catch (error) {
statusEl.innerHTML = `<span class="text-red-600"><i class="fas fa-exclamation-circle mr-1"></i>Error: ${error.message}</span>`;
}
input.value = '';
}
// Load Import History
async function loadImportHistory() {
const container = document.getElementById('import-history');
try {
const response = await fetch('/api/import/history');
const data = await response.json();
if (data.imports.length === 0) {
container.innerHTML = '<p class="text-gray-500 dark:text-gray-400 text-sm">No import history</p>';
return;
}
container.innerHTML = data.imports.map(imp => `
<div class="border border-gray-200 dark:border-gray-700 rounded p-3">
<div class="flex justify-between items-start">
<div>
<span class="font-medium text-gray-900 dark:text-white">${imp.import_type.toUpperCase()}</span>
<span class="ml-2 px-2 py-1 text-xs rounded ${getStatusClass(imp.status)}">${imp.status}</span>
</div>
<span class="text-xs text-gray-500">${new Date(imp.started_at).toLocaleString()}</span>
</div>
${imp.status === 'completed' || imp.status === 'partial' ? `
<div class="mt-1 text-sm text-gray-600 dark:text-gray-400">
${imp.successful_records}/${imp.total_records} records imported
</div>
` : ''}
</div>
`).join('');
} catch (error) {
container.innerHTML = '<p class="text-red-500 text-sm">Error loading history</p>';
}
}
// Load Export History
async function loadExportHistory() {
const container = document.getElementById('export-history');
try {
const response = await fetch('/api/export/history');
const data = await response.json();
if (data.exports.length === 0) {
container.innerHTML = '<p class="text-gray-500 dark:text-gray-400 text-sm">No export history</p>';
return;
}
container.innerHTML = data.exports.map(exp => `
<div class="border border-gray-200 dark:border-gray-700 rounded p-3">
<div class="flex justify-between items-start">
<div>
<span class="font-medium text-gray-900 dark:text-white">${exp.export_type.toUpperCase()}</span>
<span class="ml-2 px-2 py-1 text-xs rounded ${getStatusClass(exp.status)}">${exp.status}</span>
</div>
<span class="text-xs text-gray-500">${new Date(exp.created_at).toLocaleString()}</span>
</div>
${exp.status === 'completed' && !exp.is_expired ? `
<div class="mt-2">
<a href="/api/export/download/${exp.id}" class="text-blue-600 hover:underline text-sm">
<i class="fas fa-download mr-1"></i>Download
</a>
</div>
` : ''}
${exp.is_expired ? '<div class="mt-1 text-xs text-red-500">Expired</div>' : ''}
</div>
`).join('');
} catch (error) {
container.innerHTML = '<p class="text-red-500 text-sm">Error loading history</p>';
}
}
function getStatusClass(status) {
const classes = {
'pending': 'bg-gray-200 text-gray-800',
'processing': 'bg-blue-200 text-blue-800',
'completed': 'bg-green-200 text-green-800',
'partial': 'bg-yellow-200 text-yellow-800',
'failed': 'bg-red-200 text-red-800'
};
return classes[status] || 'bg-gray-200 text-gray-800';
}
function showFilteredExportForm() {
alert('Filtered export form - to be implemented with custom date/project filters');
}
// Load histories on page load
document.addEventListener('DOMContentLoaded', function() {
loadImportHistory();
loadExportHistory();
});
</script>
{% endblock %}
+16 -4
View File
@@ -1,11 +1,23 @@
{% extends "base.html" %}
{% from "components/cards.html" import info_card %}
{% from "components/ui.html" import page_header, breadcrumb_nav, button, filter_badge %}
{% block content %}
<div class="flex justify-between items-center mb-6">
<h1 class="text-2xl font-bold">Invoices</h1>
<a href="{{ url_for('invoices.create_invoice') }}" class="bg-primary text-white px-4 py-2 rounded-lg">Create Invoice</a>
</div>
{% set breadcrumbs = [
{'text': 'Invoices'}
] %}
{{ page_header(
icon_class='fas fa-file-invoice',
title_text='Invoices',
subtitle_text='Create and manage invoices for your clients',
breadcrumbs=breadcrumbs,
actions_html=''
+ '<div class="flex gap-2">'
+ '<a href="' + url_for("invoices.export_invoices_excel") + '" class="bg-green-600 text-white px-4 py-2 rounded-lg hover:bg-green-700 inline-flex items-center"><i class="fas fa-file-excel mr-2"></i>Export to Excel</a>'
+ '<a href="' + url_for("invoices.create_invoice") + '" class="bg-primary text-white px-4 py-2 rounded-lg hover:bg-primary/90 transition-colors"><i class="fas fa-plus mr-2"></i>Create Invoice</a>'
+ '</div>'
) }}
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-6">
{{ info_card("Total Invoiced", "%.2f"|format(summary.total_amount), "All time") }}
+30 -21
View File
@@ -1,30 +1,39 @@
{% extends "base.html" %}
{% from "components/ui.html" import page_header, breadcrumb_nav, button, filter_badge %}
{% block title %}{{ _('Kanban') }} - {{ app_name }}{% endblock %}
{% block content %}
<div class="flex flex-col md:flex-row justify-between items-start md:items-center mb-6">
<div>
<h1 class="text-2xl font-bold"><i class="fas fa-columns mr-2"></i>{{ _('Kanban') }}</h1>
<p class="text-text-muted-light dark:text-text-muted-dark">{{ _('Drag tasks between columns. Filter by project.') }}</p>
</div>
<div class="mt-3 md:mt-0 flex items-center gap-3">
<form method="get" class="flex items-center gap-2">
<label for="project_id" class="text-sm text-text-muted-light dark:text-text-muted-dark">{{ _('Project') }}</label>
<select id="project_id" name="project_id" class="bg-background-light dark:bg-gray-700 border border-transparent dark:border-transparent rounded-lg py-2 px-3 text-sm text-text-light dark:text-text-dark" onchange="this.form.submit()">
<option value="">{{ _('All') }}</option>
{% for p in projects %}
<option value="{{ p.id }}" {% if project_id == p.id %}selected{% endif %}>{{ p.name }}</option>
{% endfor %}
</select>
</form>
{% if current_user.is_admin %}
<a href="{{ url_for('kanban.list_columns') }}" class="inline-flex items-center gap-2 bg-primary text-white text-sm px-3 py-2 rounded hover:opacity-90">
<i class="fas fa-sliders-h"></i> {{ _('Manage Kanban Columns') }}
</a>
{% endif %}
</div>
{% set breadcrumbs = [
{'text': _('Kanban Board')}
] %}
{% set kanban_actions %}
<div class="flex items-center gap-3">
<form method="get" class="flex items-center gap-2">
<label for="project_id" class="text-sm text-text-muted-light dark:text-text-muted-dark">{{ _('Project') }}</label>
<select id="project_id" name="project_id" class="bg-background-light dark:bg-gray-700 border border-transparent dark:border-transparent rounded-lg py-2 px-3 text-sm text-text-light dark:text-text-dark" onchange="this.form.submit()">
<option value="">{{ _('All') }}</option>
{% for p in projects %}
<option value="{{ p.id }}" {% if project_id == p.id %}selected{% endif %}>{{ p.name }}</option>
{% endfor %}
</select>
</form>
{% if current_user.is_admin %}
<a href="{{ url_for('kanban.list_columns') }}" class="inline-flex items-center gap-2 bg-primary text-white text-sm px-3 py-2 rounded hover:bg-primary/90 transition-colors">
<i class="fas fa-sliders-h"></i> {{ _('Manage Columns') }}
</a>
{% endif %}
</div>
{% endset %}
{{ page_header(
icon_class='fas fa-columns',
title_text=_('Kanban Board'),
subtitle_text=_('Drag tasks between columns to update their status'),
breadcrumbs=breadcrumbs,
actions_html=kanban_actions
) }}
<div class="bg-card-light dark:bg-card-dark rounded-lg shadow" role="application" aria-label="{{ _('Kanban board') }}">
{% include 'projects/_kanban_tailwind.html' %}
+85
View File
@@ -74,6 +74,9 @@
<td class="p-4">{{ entry.start_time.strftime('%Y-%m-%d %H:%M') }}</td>
<td class="p-4">
<div class="flex gap-2">
<a href="{{ url_for('timer.resume_timer', timer_id=entry.id) }}" class="text-green-600 hover:text-green-800" title="{{ _('Resume - Start a new timer with same properties') }}">
<i class="fas fa-play"></i>
</a>
<a href="{{ url_for('timer.edit_timer', timer_id=entry.id) }}" class="text-primary hover:text-primary-dark" title="{{ _('Edit entry') }}">
<i class="fas fa-edit"></i>
</a>
@@ -177,6 +180,9 @@
{% endfor %}
</ul>
</div>
<!-- Activity Feed Widget -->
{% include 'components/activity_feed_widget.html' %}
</div>
<!-- Delete Entry Confirmation Dialogs -->
{% for entry in recent_entries %}
@@ -222,6 +228,35 @@
<label for="startTimerNotes" class="block text-sm font-medium text-gray-700 dark:text-gray-300">{{ _('Notes (optional)') }}</label>
<textarea id="startTimerNotes" name="notes" rows="3" class="form-input" placeholder="{{ _('What are you working on?') }}"></textarea>
</div>
{% if templates %}
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">{{ _('Or use a template') }}</label>
<div class="space-y-2">
{% for template in templates %}
<button type="button"
onclick="applyTemplate({{ template.id }})"
class="w-full text-left p-3 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 transition">
<div class="flex items-center justify-between">
<div class="flex-1">
<div class="font-medium text-sm">{{ template.name }}</div>
{% if template.project %}
<div class="text-xs text-gray-500 dark:text-gray-400 mt-1">
<i class="fas fa-folder"></i> {{ template.project.name }}
{% if template.task %} → {{ template.task.name }}{% endif %}
</div>
{% endif %}
</div>
<i class="fas fa-chevron-right text-gray-400"></i>
</div>
</button>
{% endfor %}
<a href="{{ url_for('time_entry_templates.list_templates') }}"
class="block text-center text-sm text-blue-600 dark:text-blue-400 hover:underline pt-2">
{{ _('View all templates') }} →
</a>
</div>
</div>
{% endif %}
</div>
<div class="mt-6 flex justify-end">
<button type="submit" class="bg-primary text-white px-4 py-2 rounded-lg">{{ _('Start') }}</button>
@@ -280,6 +315,56 @@
});
}
}
// Template application function
window.applyTemplate = async function(templateId) {
try {
const response = await fetch(`/api/templates/${templateId}`, { credentials: 'same-origin' });
if (!response.ok) {
throw new Error(`HTTP ${response.status}`);
}
const template = await response.json();
// Get form elements (re-select to avoid scope issues)
const projectSelect = document.getElementById('startTimerProject');
const taskSelect = document.getElementById('startTimerTask');
const notesField = document.getElementById('startTimerNotes');
if (!projectSelect || !taskSelect || !notesField) {
throw new Error('Form elements not found');
}
// Apply template values to form
if (template.project_id) {
projectSelect.value = template.project_id;
// Trigger change event to load tasks
projectSelect.dispatchEvent(new Event('change'));
// Wait a bit for tasks to load, then select task
setTimeout(() => {
if (template.task_id) {
taskSelect.value = template.task_id;
}
}, 300);
}
if (template.default_notes) {
notesField.value = template.default_notes;
}
// Mark template as used
fetch(`/api/templates/${templateId}/use`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': '{{ csrf_token() }}'
}
}).catch(() => {}); // Silently fail if marking fails
} catch (error) {
console.error('Error applying template:', error);
alert('Failed to apply template. Please try again.');
}
};
});
</script>
{% endblock %}
+4
View File
@@ -56,6 +56,10 @@
<td>{{ entry.tags or '-' }}</td>
<td class="text-end">
<div class="btn-group" role="group">
<a href="{{ url_for('timer.resume_timer', timer_id=entry.id) }}"
class="btn btn-sm btn-action btn-action--success" title="{{ _('Resume - Start a new timer with same properties') }}">
<i class="fas fa-play"></i>
</a>
<a href="{{ url_for('timer.edit_timer', timer_id=entry.id) }}"
class="btn btn-sm btn-action btn-action--edit" title="{{ _('Edit entry') }}">
<i class="fas fa-edit"></i>
+309
View File
@@ -0,0 +1,309 @@
{% extends "base.html" %}
{% from "components/ui.html" import page_header %}
{% block content %}
{% set breadcrumbs = [
{'text': 'Mileage', 'url': url_for('mileage.list_mileage')},
{'text': 'Edit' if mileage else 'New'}
] %}
{{ page_header(
icon_class='fas fa-car',
title_text=('Edit Mileage Entry' if mileage else 'New Mileage Entry'),
subtitle_text=('Update mileage details' if mileage else 'Record a new vehicle mileage entry'),
breadcrumbs=breadcrumbs
) }}
<div class="max-w-4xl mx-auto">
<form method="POST" class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<!-- Trip Information -->
<div class="mb-6">
<h3 class="text-lg font-semibold mb-4 pb-2 border-b border-gray-200 dark:border-gray-700">
<i class="fas fa-info-circle mr-2"></i>Trip Information
</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label for="trip_date" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Trip Date <span class="text-red-500">*</span>
</label>
<input type="date" name="trip_date" id="trip_date" required
value="{{ mileage.trip_date.strftime('%Y-%m-%d') if mileage else '' }}"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-primary focus:border-primary dark:bg-gray-700">
</div>
<div>
<label for="currency_code" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Currency
</label>
<select name="currency_code" id="currency_code"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-primary focus:border-primary dark:bg-gray-700">
<option value="EUR" {% if not mileage or mileage.currency_code == 'EUR' %}selected{% endif %}>EUR</option>
<option value="USD" {% if mileage and mileage.currency_code == 'USD' %}selected{% endif %}>USD</option>
<option value="GBP" {% if mileage and mileage.currency_code == 'GBP' %}selected{% endif %}>GBP</option>
</select>
</div>
<div class="md:col-span-2">
<label for="purpose" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Purpose <span class="text-red-500">*</span>
</label>
<input type="text" name="purpose" id="purpose" required
value="{{ mileage.purpose if mileage else '' }}"
placeholder="e.g., Client meeting, Site visit"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-primary focus:border-primary dark:bg-gray-700">
</div>
<div class="md:col-span-2">
<label for="description" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Description
</label>
<textarea name="description" id="description" rows="2"
placeholder="Additional details..."
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-primary focus:border-primary dark:bg-gray-700">{{ mileage.description if mileage else '' }}</textarea>
</div>
</div>
</div>
<!-- Route Information -->
<div class="mb-6">
<h3 class="text-lg font-semibold mb-4 pb-2 border-b border-gray-200 dark:border-gray-700">
<i class="fas fa-route mr-2"></i>Route Details
</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label for="start_location" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Start Location <span class="text-red-500">*</span>
</label>
<input type="text" name="start_location" id="start_location" required
value="{{ mileage.start_location if mileage else '' }}"
placeholder="e.g., Office, Home"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-primary focus:border-primary dark:bg-gray-700">
</div>
<div>
<label for="end_location" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
End Location <span class="text-red-500">*</span>
</label>
<input type="text" name="end_location" id="end_location" required
value="{{ mileage.end_location if mileage else '' }}"
placeholder="e.g., Client site, Airport"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-primary focus:border-primary dark:bg-gray-700">
</div>
<div>
<label for="distance_km" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Distance (km) <span class="text-red-500">*</span>
</label>
<input type="number" name="distance_km" id="distance_km" step="0.01" required
value="{{ mileage.distance_km if mileage else '' }}"
placeholder="0.00"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-primary focus:border-primary dark:bg-gray-700">
</div>
<div>
<label for="rate_per_km" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Rate per km <span class="text-red-500">*</span>
</label>
<input type="number" name="rate_per_km" id="rate_per_km" step="0.01" required
value="{{ mileage.rate_per_km if mileage else (default_rates.car if default_rates else '0.30') }}"
placeholder="0.30"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-primary focus:border-primary dark:bg-gray-700">
<p class="text-xs text-gray-500 mt-1">Standard rate: €0.30/km</p>
</div>
<div>
<label for="start_odometer" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Start Odometer (optional)
</label>
<input type="number" name="start_odometer" id="start_odometer" step="1"
value="{{ mileage.start_odometer if mileage and mileage.start_odometer else '' }}"
placeholder="e.g., 12345"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-primary focus:border-primary dark:bg-gray-700">
</div>
<div>
<label for="end_odometer" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
End Odometer (optional)
</label>
<input type="number" name="end_odometer" id="end_odometer" step="1"
value="{{ mileage.end_odometer if mileage and mileage.end_odometer else '' }}"
placeholder="e.g., 12400"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-primary focus:border-primary dark:bg-gray-700">
</div>
<div class="md:col-span-2 flex items-center">
<input type="checkbox" name="is_round_trip" id="is_round_trip"
{% if mileage and mileage.is_round_trip %}checked{% endif %}
class="h-4 w-4 text-primary focus:ring-primary border-gray-300 rounded">
<label for="is_round_trip" class="ml-2 text-sm text-gray-700 dark:text-gray-300">
<strong>Round Trip</strong>
<span class="block text-xs text-gray-500">Double the distance for return journey</span>
</label>
</div>
</div>
<div class="mt-4 p-3 bg-blue-50 dark:bg-blue-900/20 rounded-lg">
<p class="text-sm text-blue-800 dark:text-blue-200">
<i class="fas fa-calculator mr-1"></i>
<strong>Calculated Amount:</strong> <span id="calculated_amount">0.00</span> <span id="amount_currency">EUR</span>
</p>
</div>
</div>
<!-- Vehicle Information -->
<div class="mb-6">
<h3 class="text-lg font-semibold mb-4 pb-2 border-b border-gray-200 dark:border-gray-700">
<i class="fas fa-car mr-2"></i>Vehicle Information
</h3>
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<label for="vehicle_type" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Vehicle Type
</label>
<select name="vehicle_type" id="vehicle_type"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-primary focus:border-primary dark:bg-gray-700">
<option value="">Select Type</option>
<option value="car" {% if mileage and mileage.vehicle_type == 'car' %}selected{% endif %}>Car</option>
<option value="motorcycle" {% if mileage and mileage.vehicle_type == 'motorcycle' %}selected{% endif %}>Motorcycle</option>
<option value="van" {% if mileage and mileage.vehicle_type == 'van' %}selected{% endif %}>Van</option>
<option value="truck" {% if mileage and mileage.vehicle_type == 'truck' %}selected{% endif %}>Truck</option>
</select>
</div>
<div>
<label for="vehicle_description" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Vehicle Make/Model
</label>
<input type="text" name="vehicle_description" id="vehicle_description"
value="{{ mileage.vehicle_description if mileage and mileage.vehicle_description else '' }}"
placeholder="e.g., VW Golf"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-primary focus:border-primary dark:bg-gray-700">
</div>
<div>
<label for="license_plate" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
License Plate
</label>
<input type="text" name="license_plate" id="license_plate"
value="{{ mileage.license_plate if mileage and mileage.license_plate else '' }}"
placeholder="e.g., ABC-123"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-primary focus:border-primary dark:bg-gray-700">
</div>
</div>
</div>
<!-- Project & Client -->
<div class="mb-6">
<h3 class="text-lg font-semibold mb-4 pb-2 border-b border-gray-200 dark:border-gray-700">
<i class="fas fa-project-diagram mr-2"></i>Association
</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label for="project_id" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Project
</label>
<select name="project_id" id="project_id"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-primary focus:border-primary dark:bg-gray-700">
<option value="">No Project</option>
{% for project in projects %}
<option value="{{ project.id }}" {% if mileage and mileage.project_id == project.id %}selected{% endif %}>
{{ project.name }}
</option>
{% endfor %}
</select>
</div>
<div>
<label for="client_id" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Client
</label>
<select name="client_id" id="client_id"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-primary focus:border-primary dark:bg-gray-700">
<option value="">No Client</option>
{% for client in clients %}
<option value="{{ client.id }}" {% if mileage and mileage.client_id == client.id %}selected{% endif %}>
{{ client.name }}
</option>
{% endfor %}
</select>
</div>
</div>
</div>
<!-- Notes -->
<div class="mb-6">
<label for="notes" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Notes
</label>
<textarea name="notes" id="notes" rows="3"
placeholder="Additional notes..."
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-primary focus:border-primary dark:bg-gray-700">{{ mileage.notes if mileage else '' }}</textarea>
</div>
<!-- Create Expense Option -->
{% if not mileage %}
<div class="mb-6">
<div class="flex items-center">
<input type="checkbox" name="create_expense" id="create_expense" checked
class="h-4 w-4 text-primary focus:ring-primary border-gray-300 rounded">
<label for="create_expense" class="ml-2 text-sm text-gray-700 dark:text-gray-300">
<strong>Create linked expense entry</strong>
<span class="block text-xs text-gray-500">Automatically create an expense record for this mileage</span>
</label>
</div>
</div>
{% endif %}
<!-- Actions -->
<div class="flex items-center justify-between pt-4 border-t border-gray-200 dark:border-gray-700">
<a href="{{ url_for('mileage.view_mileage', mileage_id=mileage.id) if mileage else url_for('mileage.list_mileage') }}"
class="px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors">
<i class="fas fa-times mr-2"></i>Cancel
</a>
<button type="submit" class="bg-primary text-white px-6 py-2 rounded-lg hover:bg-primary/90 transition-colors">
<i class="fas fa-save mr-2"></i>{{ 'Update Mileage' if mileage else 'Create Mileage Entry' }}
</button>
</div>
</form>
</div>
<script>
// Calculate and display amount
function updateAmount() {
const distance = parseFloat(document.getElementById('distance_km').value) || 0;
const rate = parseFloat(document.getElementById('rate_per_km').value) || 0;
const isRoundTrip = document.getElementById('is_round_trip').checked;
const currency = document.getElementById('currency_code').value;
let amount = distance * rate;
if (isRoundTrip) {
amount *= 2;
}
document.getElementById('calculated_amount').textContent = amount.toFixed(2);
document.getElementById('amount_currency').textContent = currency;
}
document.getElementById('distance_km').addEventListener('input', updateAmount);
document.getElementById('rate_per_km').addEventListener('input', updateAmount);
document.getElementById('is_round_trip').addEventListener('change', updateAmount);
document.getElementById('currency_code').addEventListener('change', updateAmount);
// Set default trip date to today if creating new
{% if not mileage %}
document.getElementById('trip_date').value = new Date().toISOString().split('T')[0];
{% endif %}
// Initial calculation
updateAmount();
</script>
{% endblock %}
+214
View File
@@ -0,0 +1,214 @@
{% extends "base.html" %}
{% from "components/ui.html" import page_header %}
{% block content %}
{% set breadcrumbs = [
{'text': 'Mileage'}
] %}
{{ page_header(
icon_class='fas fa-car',
title_text='Mileage Tracking',
subtitle_text='Track and manage vehicle mileage expenses',
breadcrumbs=breadcrumbs,
actions_html='<a href="' + url_for("mileage.create_mileage") + '" class="bg-primary text-white px-4 py-2 rounded-lg hover:bg-primary/90 transition-colors"><i class="fas fa-plus mr-2"></i>New Mileage Entry</a>'
) }}
<!-- Summary Stats -->
<div class="grid grid-cols-1 md:grid-cols-3 gap-4 mb-6">
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow">
<div class="flex items-center justify-between">
<div>
<p class="text-sm text-gray-600 dark:text-gray-400">Total Distance</p>
<p class="text-2xl font-bold">{{ '%.2f'|format(total_distance) }} km</p>
</div>
<div class="text-primary text-3xl">
<i class="fas fa-route"></i>
</div>
</div>
</div>
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow">
<div class="flex items-center justify-between">
<div>
<p class="text-sm text-gray-600 dark:text-gray-400">Total Amount</p>
<p class="text-2xl font-bold">€{{ '%.2f'|format(total_amount) }}</p>
</div>
<div class="text-green-500 text-3xl">
<i class="fas fa-euro-sign"></i>
</div>
</div>
</div>
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow">
<div class="flex items-center justify-between">
<div>
<p class="text-sm text-gray-600 dark:text-gray-400">Total Entries</p>
<p class="text-2xl font-bold">{{ pagination.total if pagination else (mileage_entries|length) }}</p>
</div>
<div class="text-blue-500 text-3xl">
<i class="fas fa-list"></i>
</div>
</div>
</div>
</div>
<!-- Filter Form -->
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow mb-6">
<h2 class="text-lg font-semibold mb-4">Filter Mileage</h2>
<form method="GET" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<div>
<label for="search" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Search</label>
<input type="text" name="search" id="search" value="{{ search or '' }}"
placeholder="Purpose, location..."
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-primary focus:border-primary dark:bg-gray-700">
</div>
<div>
<label for="status" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Status</label>
<select name="status" id="status" class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-primary focus:border-primary dark:bg-gray-700">
<option value="">All Statuses</option>
<option value="pending" {% if status == 'pending' %}selected{% endif %}>Pending</option>
<option value="approved" {% if status == 'approved' %}selected{% endif %}>Approved</option>
<option value="rejected" {% if status == 'rejected' %}selected{% endif %}>Rejected</option>
<option value="reimbursed" {% if status == 'reimbursed' %}selected{% endif %}>Reimbursed</option>
</select>
</div>
<div>
<label for="project_id" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Project</label>
<select name="project_id" id="project_id" class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-primary focus:border-primary dark:bg-gray-700">
<option value="">All Projects</option>
{% for project in projects %}
<option value="{{ project.id }}" {% if project_id == project.id %}selected{% endif %}>{{ project.name }}</option>
{% endfor %}
</select>
</div>
<div>
<label for="client_id" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Client</label>
<select name="client_id" id="client_id" class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-primary focus:border-primary dark:bg-gray-700">
<option value="">All Clients</option>
{% for client in clients %}
<option value="{{ client.id }}" {% if client_id == client.id %}selected{% endif %}>{{ client.name }}</option>
{% endfor %}
</select>
</div>
<div>
<label for="start_date" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Start Date</label>
<input type="date" name="start_date" id="start_date" value="{{ start_date or '' }}"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-primary focus:border-primary dark:bg-gray-700">
</div>
<div>
<label for="end_date" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">End Date</label>
<input type="date" name="end_date" id="end_date" value="{{ end_date or '' }}"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-primary focus:border-primary dark:bg-gray-700">
</div>
<div class="flex items-end gap-2 md:col-span-2">
<button type="submit" class="flex-1 bg-primary text-white px-4 py-2 rounded-lg hover:bg-primary/90 transition-colors">
<i class="fas fa-filter mr-2"></i>Filter
</button>
<a href="{{ url_for('mileage.list_mileage') }}" class="px-4 py-2 bg-gray-200 dark:bg-gray-700 rounded-lg hover:bg-gray-300 dark:hover:bg-gray-600 transition-colors">
<i class="fas fa-times"></i>
</a>
</div>
</form>
</div>
<!-- Mileage Table -->
<div class="bg-card-light dark:bg-card-dark rounded-lg shadow overflow-hidden">
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
<thead class="bg-gray-50 dark:bg-gray-800">
<tr>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Date</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Purpose</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Route</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Distance</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Amount</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Status</th>
<th class="px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Actions</th>
</tr>
</thead>
<tbody class="bg-white dark:bg-gray-900 divide-y divide-gray-200 dark:divide-gray-700">
{% if mileage_entries %}
{% for entry in mileage_entries %}
<tr class="hover:bg-gray-50 dark:hover:bg-gray-800">
<td class="px-6 py-4 whitespace-nowrap text-sm">
{{ entry.date.strftime('%Y-%m-%d') if entry.date else (entry.trip_date.strftime('%Y-%m-%d') if entry.trip_date else '-') }}
</td>
<td class="px-6 py-4 text-sm">
<a href="{{ url_for('mileage.view_mileage', mileage_id=entry.id) }}" class="text-primary hover:underline font-medium">
{{ entry.purpose }}
</a>
{% if entry.project %}
<div class="text-xs text-gray-500">{{ entry.project.name }}</div>
{% endif %}
</td>
<td class="px-6 py-4 text-sm">
<div class="flex flex-col">
<span class="text-gray-600">{{ entry.start_location }}</span>
<i class="fas fa-arrow-down text-xs text-gray-400 my-1"></i>
<span class="text-gray-600">{{ entry.end_location }}</span>
</div>
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium">
{{ '%.2f'|format(entry.distance_km) }} km
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium">
{{ entry.currency_code or 'EUR' }} {{ '%.2f'|format(entry.total_amount or (entry.calculated_amount or 0)) }}
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm">
{% if entry.status == 'pending' %}
<span class="px-2 py-1 text-xs rounded-full bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200">
Pending
</span>
{% elif entry.status == 'approved' %}
<span class="px-2 py-1 text-xs rounded-full bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200">
Approved
</span>
{% elif entry.status == 'rejected' %}
<span class="px-2 py-1 text-xs rounded-full bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200">
Rejected
</span>
{% elif entry.status == 'reimbursed' %}
<span class="px-2 py-1 text-xs rounded-full bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200">
Reimbursed
</span>
{% endif %}
</td>
<td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
<div class="flex items-center justify-end gap-2">
<a href="{{ url_for('mileage.view_mileage', mileage_id=entry.id) }}" class="text-primary hover:text-primary/80" title="View">
<i class="fas fa-eye"></i>
</a>
{% if current_user.is_admin or entry.user_id == current_user.id %}
<a href="{{ url_for('mileage.edit_mileage', mileage_id=entry.id) }}" class="text-blue-600 hover:text-blue-800" title="Edit">
<i class="fas fa-edit"></i>
</a>
{% endif %}
</div>
</td>
</tr>
{% endfor %}
{% else %}
<tr>
<td colspan="7" class="px-6 py-8 text-center text-gray-500">
<i class="fas fa-car text-4xl mb-2 opacity-50"></i>
<p>No mileage entries found</p>
<a href="{{ url_for('mileage.create_mileage') }}" class="text-primary hover:underline mt-2 inline-block">
Create your first mileage entry
</a>
</td>
</tr>
{% endif %}
</tbody>
</table>
</div>
</div>
{% endblock %}
+302
View File
@@ -0,0 +1,302 @@
{% extends "base.html" %}
{% from "components/ui.html" import page_header %}
{% block content %}
{% set breadcrumbs = [
{'text': 'Mileage', 'url': url_for('mileage.list_mileage')},
{'text': 'Mileage #' + mileage.id|string}
] %}
{{ page_header(
icon_class='fas fa-car',
title_text='Mileage Entry #' + mileage.id|string,
subtitle_text=mileage.purpose,
breadcrumbs=breadcrumbs,
actions_html='<a href="' + url_for("mileage.edit_mileage", mileage_id=mileage.id) + '" class="bg-primary text-white px-4 py-2 rounded-lg hover:bg-primary/90 transition-colors"><i class="fas fa-edit mr-2"></i>Edit</a>' if current_user.is_admin or mileage.user_id == current_user.id else ''
) }}
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
<!-- Main Content -->
<div class="lg:col-span-2 space-y-6">
<!-- Trip Details -->
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow">
<h3 class="text-lg font-semibold mb-4 pb-2 border-b border-gray-200 dark:border-gray-700">
<i class="fas fa-route mr-2"></i>Trip Details
</h3>
<div class="grid grid-cols-2 gap-4">
<div>
<p class="text-sm text-gray-600 dark:text-gray-400 mb-1">Trip Date</p>
<p class="font-semibold">{{ mileage.trip_date.strftime('%Y-%m-%d') if mileage.trip_date else (mileage.date.strftime('%Y-%m-%d') if mileage.date else '-') }}</p>
</div>
<div>
<p class="text-sm text-gray-600 dark:text-gray-400 mb-1">User</p>
<p class="font-semibold">{{ mileage.user.full_name if mileage.user and mileage.user.full_name else (mileage.user.username if mileage.user else '-') }}</p>
</div>
<div class="col-span-2">
<p class="text-sm text-gray-600 dark:text-gray-400 mb-1">Purpose</p>
<p class="font-semibold">{{ mileage.purpose }}</p>
</div>
{% if mileage.description %}
<div class="col-span-2">
<p class="text-sm text-gray-600 dark:text-gray-400 mb-1">Description</p>
<p class="font-semibold">{{ mileage.description }}</p>
</div>
{% endif %}
<div class="col-span-2">
<p class="text-sm text-gray-600 dark:text-gray-400 mb-2">Route</p>
<div class="flex items-center p-3 bg-gray-50 dark:bg-gray-800 rounded-lg">
<div class="flex-1">
<div class="flex items-center mb-2">
<i class="fas fa-map-marker-alt text-green-500 mr-2"></i>
<span class="font-semibold">{{ mileage.start_location }}</span>
</div>
<div class="flex items-center ml-1">
<i class="fas fa-arrow-down text-gray-400 text-sm mr-2"></i>
<span class="text-sm text-gray-600 dark:text-gray-400">{{ '%.2f'|format(mileage.distance_km) }} km</span>
</div>
<div class="flex items-center mt-2">
<i class="fas fa-map-marker-alt text-red-500 mr-2"></i>
<span class="font-semibold">{{ mileage.end_location }}</span>
</div>
</div>
{% if mileage.is_round_trip %}
<div class="ml-4 px-3 py-1 bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200 rounded-full text-xs font-semibold">
Round Trip
</div>
{% endif %}
</div>
</div>
<div>
<p class="text-sm text-gray-600 dark:text-gray-400 mb-1">Distance</p>
<p class="font-semibold text-lg">{{ '%.2f'|format(mileage.distance_km) }} km</p>
{% if mileage.is_round_trip %}
<p class="text-xs text-gray-500">Total: {{ '%.2f'|format(mileage.distance_km * 2) }} km</p>
{% endif %}
</div>
<div>
<p class="text-sm text-gray-600 dark:text-gray-400 mb-1">Rate per km</p>
<p class="font-semibold text-lg">{{ mileage.currency_code or 'EUR' }} {{ '%.2f'|format(mileage.rate_per_km) }}</p>
</div>
<div class="col-span-2 mt-2">
<div class="p-4 bg-primary/10 rounded-lg">
<p class="text-sm text-gray-600 dark:text-gray-400 mb-1">Total Amount</p>
<p class="font-bold text-2xl text-primary">{{ mileage.currency_code or 'EUR' }} {{ '%.2f'|format(mileage.total_amount or (mileage.calculated_amount or 0)) }}</p>
</div>
</div>
</div>
</div>
<!-- Vehicle Information -->
{% if mileage.vehicle_type or mileage.vehicle_description or mileage.license_plate %}
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow">
<h3 class="text-lg font-semibold mb-4 pb-2 border-b border-gray-200 dark:border-gray-700">
<i class="fas fa-car mr-2"></i>Vehicle Information
</h3>
<div class="grid grid-cols-3 gap-4">
{% if mileage.vehicle_type %}
<div>
<p class="text-sm text-gray-600 dark:text-gray-400 mb-1">Type</p>
<p class="font-semibold">{{ mileage.vehicle_type|title }}</p>
</div>
{% endif %}
{% if mileage.vehicle_description %}
<div>
<p class="text-sm text-gray-600 dark:text-gray-400 mb-1">Make/Model</p>
<p class="font-semibold">{{ mileage.vehicle_description }}</p>
</div>
{% endif %}
{% if mileage.license_plate %}
<div>
<p class="text-sm text-gray-600 dark:text-gray-400 mb-1">License Plate</p>
<p class="font-semibold">{{ mileage.license_plate }}</p>
</div>
{% endif %}
{% if mileage.start_odometer and mileage.end_odometer %}
<div>
<p class="text-sm text-gray-600 dark:text-gray-400 mb-1">Start Odometer</p>
<p class="font-semibold">{{ mileage.start_odometer }}</p>
</div>
<div>
<p class="text-sm text-gray-600 dark:text-gray-400 mb-1">End Odometer</p>
<p class="font-semibold">{{ mileage.end_odometer }}</p>
</div>
{% endif %}
</div>
</div>
{% endif %}
<!-- Association -->
{% if mileage.project or mileage.client %}
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow">
<h3 class="text-lg font-semibold mb-4 pb-2 border-b border-gray-200 dark:border-gray-700">
<i class="fas fa-project-diagram mr-2"></i>Association
</h3>
<div class="grid grid-cols-2 gap-4">
{% if mileage.project %}
<div>
<p class="text-sm text-gray-600 dark:text-gray-400 mb-1">Project</p>
<a href="{{ url_for('projects.view_project', project_id=mileage.project_id) }}" class="text-primary hover:underline font-semibold">
{{ mileage.project.name }}
</a>
</div>
{% endif %}
{% if mileage.client %}
<div>
<p class="text-sm text-gray-600 dark:text-gray-400 mb-1">Client</p>
<a href="{{ url_for('clients.view_client', client_id=mileage.client_id) }}" class="text-primary hover:underline font-semibold">
{{ mileage.client.name }}
</a>
</div>
{% endif %}
</div>
</div>
{% endif %}
<!-- Notes -->
{% if mileage.notes %}
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow">
<h3 class="text-lg font-semibold mb-4 pb-2 border-b border-gray-200 dark:border-gray-700">
<i class="fas fa-sticky-note mr-2"></i>Notes
</h3>
<p class="text-gray-700 dark:text-gray-300">{{ mileage.notes }}</p>
</div>
{% endif %}
</div>
<!-- Sidebar -->
<div class="space-y-6">
<!-- Status -->
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow">
<h3 class="text-lg font-semibold mb-4 pb-2 border-b border-gray-200 dark:border-gray-700">
<i class="fas fa-info-circle mr-2"></i>Status
</h3>
<div class="mb-4">
{% if mileage.status == 'pending' %}
<span class="px-3 py-2 text-sm rounded-full bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200 font-semibold">
<i class="fas fa-clock mr-1"></i>Pending Approval
</span>
{% elif mileage.status == 'approved' %}
<span class="px-3 py-2 text-sm rounded-full bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200 font-semibold">
<i class="fas fa-check-circle mr-1"></i>Approved
</span>
{% elif mileage.status == 'rejected' %}
<span class="px-3 py-2 text-sm rounded-full bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200 font-semibold">
<i class="fas fa-times-circle mr-1"></i>Rejected
</span>
{% elif mileage.status == 'reimbursed' %}
<span class="px-3 py-2 text-sm rounded-full bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200 font-semibold">
<i class="fas fa-money-bill mr-1"></i>Reimbursed
</span>
{% endif %}
</div>
{% if mileage.approved_by %}
<div class="mt-4">
<p class="text-sm text-gray-600 dark:text-gray-400 mb-1">{% if mileage.status == 'approved' %}Approved By{% else %}Reviewed By{% endif %}</p>
<p class="font-semibold">{{ mileage.approver.full_name if mileage.approver and mileage.approver.full_name else (mileage.approver.username if mileage.approver else '-') }}</p>
{% if mileage.approved_at %}
<p class="text-xs text-gray-500 mt-1">{{ mileage.approved_at.strftime('%Y-%m-%d %H:%M') }}</p>
{% endif %}
</div>
{% endif %}
{% if mileage.status == 'rejected' and mileage.rejection_reason %}
<div class="mt-4 p-3 bg-red-50 dark:bg-red-900/20 rounded-lg">
<p class="text-sm text-red-800 dark:text-red-200">
<strong>Rejection Reason:</strong><br>
{{ mileage.rejection_reason }}
</p>
</div>
{% endif %}
{% if mileage.approval_notes %}
<div class="mt-4 p-3 bg-blue-50 dark:bg-blue-900/20 rounded-lg">
<p class="text-sm text-blue-800 dark:text-blue-200">
<strong>Approval Notes:</strong><br>
{{ mileage.approval_notes }}
</p>
</div>
{% endif %}
</div>
<!-- Actions for Admin -->
{% if current_user.is_admin and mileage.status == 'pending' %}
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow">
<h3 class="text-lg font-semibold mb-4 pb-2 border-b border-gray-200 dark:border-gray-700">
<i class="fas fa-gavel mr-2"></i>Admin Actions
</h3>
<form method="POST" action="{{ url_for('mileage.approve_mileage', mileage_id=mileage.id) }}" class="mb-3">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<textarea name="approval_notes" placeholder="Optional approval notes..." rows="2"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg mb-2 text-sm dark:bg-gray-700"></textarea>
<button type="submit" class="w-full bg-green-600 text-white px-4 py-2 rounded-lg hover:bg-green-700 transition-colors">
<i class="fas fa-check mr-2"></i>Approve
</button>
</form>
<form method="POST" action="{{ url_for('mileage.reject_mileage', mileage_id=mileage.id) }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<textarea name="rejection_reason" placeholder="Rejection reason (required)" rows="2" required
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg mb-2 text-sm dark:bg-gray-700"></textarea>
<button type="submit" class="w-full bg-red-600 text-white px-4 py-2 rounded-lg hover:bg-red-700 transition-colors">
<i class="fas fa-times mr-2"></i>Reject
</button>
</form>
</div>
{% endif %}
{% if current_user.is_admin and mileage.status == 'approved' %}
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow">
<h3 class="text-lg font-semibold mb-4 pb-2 border-b border-gray-200 dark:border-gray-700">
<i class="fas fa-money-bill mr-2"></i>Reimbursement
</h3>
<form method="POST" action="{{ url_for('mileage.mark_reimbursed', mileage_id=mileage.id) }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="w-full bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700 transition-colors">
<i class="fas fa-check mr-2"></i>Mark as Reimbursed
</button>
</form>
</div>
{% endif %}
<!-- Metadata -->
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow">
<h3 class="text-lg font-semibold mb-4 pb-2 border-b border-gray-200 dark:border-gray-700">
<i class="fas fa-clock mr-2"></i>Metadata
</h3>
<div class="space-y-3">
<div>
<p class="text-sm text-gray-600 dark:text-gray-400 mb-1">Created</p>
<p class="font-semibold text-sm">{{ mileage.created_at.strftime('%Y-%m-%d %H:%M') if mileage.created_at else '-' }}</p>
</div>
<div>
<p class="text-sm text-gray-600 dark:text-gray-400 mb-1">Last Updated</p>
<p class="font-semibold text-sm">{{ mileage.updated_at.strftime('%Y-%m-%d %H:%M') if mileage.updated_at else '-' }}</p>
</div>
</div>
</div>
</div>
</div>
{% endblock %}
+19 -11
View File
@@ -1,14 +1,22 @@
{% extends "base.html" %}
{% from "components/ui.html" import page_header, breadcrumb_nav, button, filter_badge %}
{% block content %}
<div class="container mx-auto px-4 py-6">
<!-- Header -->
<div class="flex justify-between items-center mb-6">
<h1 class="text-3xl font-bold text-gray-900 dark:text-gray-100">Payments</h1>
<a href="{{ url_for('payments.create_payment') }}" class="bg-primary hover:bg-primary-dark text-white px-4 py-2 rounded-lg transition-colors">
<i class="fas fa-plus mr-2"></i>Record Payment
</a>
</div>
{% set breadcrumbs = [
{'text': 'Payments'}
] %}
{{ page_header(
icon_class='fas fa-credit-card',
title_text='Payments',
subtitle_text='Track and record payments from clients',
breadcrumbs=breadcrumbs,
actions_html=''
+ '<div class="flex gap-2">'
+ '<a href="' + url_for("payments.export_payments_excel", status=filters.status, method=filters.method, date_from=filters.date_from, date_to=filters.date_to) + '" class="bg-green-600 text-white px-4 py-2 rounded-lg hover:bg-green-700 inline-flex items-center"><i class="fas fa-file-excel mr-2"></i>Export to Excel</a>'
+ '<a href="' + url_for("payments.create_payment") + '" class="bg-primary text-white px-4 py-2 rounded-lg hover:bg-primary/90 transition-colors"><i class="fas fa-plus mr-2"></i>Record Payment</a>'
+ '</div>'
) }}
<!-- Summary Cards -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4 mb-6">
@@ -18,16 +26,16 @@
</div>
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow">
<h3 class="text-sm font-medium text-gray-500 dark:text-gray-400">Total Amount</h3>
<p class="text-2xl font-bold text-green-600 dark:text-green-400 mt-2">{{ "%.2f"|format(summary.total_amount) }}</p>
<p class="text-2xl font-bold text-green-600 dark:text-green-400 mt-2">{{ currency|currency_symbol }}{{ "%.2f"|format(summary.total_amount) }}</p>
</div>
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow">
<h3 class="text-sm font-medium text-gray-500 dark:text-gray-400">Completed</h3>
<p class="text-2xl font-bold text-blue-600 dark:text-blue-400 mt-2">{{ summary.completed_count }}</p>
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1">{{ "%.2f"|format(summary.completed_amount) }}</p>
<p class="text-sm text-gray-500 dark:text-gray-400 mt-1">{{ currency|currency_symbol }}{{ "%.2f"|format(summary.completed_amount) }}</p>
</div>
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow">
<h3 class="text-sm font-medium text-gray-500 dark:text-gray-400">Gateway Fees</h3>
<p class="text-2xl font-bold text-red-600 dark:text-red-400 mt-2">{{ "%.2f"|format(summary.total_fees) }}</p>
<p class="text-2xl font-bold text-red-600 dark:text-red-400 mt-2">{{ currency|currency_symbol }}{{ "%.2f"|format(summary.total_fees) }}</p>
</div>
</div>
+230
View File
@@ -0,0 +1,230 @@
{% extends "base.html" %}
{% from "components/ui.html" import page_header %}
{% block content %}
{% set breadcrumbs = [
{'text': 'Per Diem', 'url': url_for('per_diem.list_per_diem')},
{'text': 'Edit' if per_diem else 'New'}
] %}
{{ page_header(
icon_class='fas fa-money-bill-alt',
title_text=('Edit Per Diem Claim' if per_diem else 'New Per Diem Claim'),
subtitle_text=('Update claim details' if per_diem else 'Create a new per diem claim'),
breadcrumbs=breadcrumbs
) }}
<div class="max-w-4xl mx-auto">
<form method="POST" class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<!-- Trip Information -->
<div class="mb-6">
<h3 class="text-lg font-semibold mb-4 pb-2 border-b border-gray-200 dark:border-gray-700">
<i class="fas fa-info-circle mr-2"></i>Trip Information
</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div class="md:col-span-2">
<label for="trip_purpose" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Trip Purpose <span class="text-red-500">*</span>
</label>
<input type="text" name="trip_purpose" id="trip_purpose" required
value="{{ per_diem.trip_purpose if per_diem else '' }}"
placeholder="e.g., Business trip to Berlin"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-primary focus:border-primary dark:bg-gray-700">
</div>
<div>
<label for="start_date" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Start Date <span class="text-red-500">*</span>
</label>
<input type="date" name="start_date" id="start_date" required
value="{{ per_diem.start_date.strftime('%Y-%m-%d') if per_diem else '' }}"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-primary focus:border-primary dark:bg-gray-700">
</div>
<div>
<label for="end_date" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
End Date <span class="text-red-500">*</span>
</label>
<input type="date" name="end_date" id="end_date" required
value="{{ per_diem.end_date.strftime('%Y-%m-%d') if per_diem else '' }}"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-primary focus:border-primary dark:bg-gray-700">
</div>
<div>
<label for="country" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Country <span class="text-red-500">*</span>
</label>
<input type="text" name="country" id="country" required
value="{{ per_diem.country if per_diem else '' }}"
placeholder="e.g., DE, US, GB"
maxlength="2"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-primary focus:border-primary dark:bg-gray-700">
</div>
<div>
<label for="city" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
City
</label>
<input type="text" name="city" id="city"
value="{{ per_diem.city if per_diem and per_diem.city else '' }}"
placeholder="e.g., Berlin"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-primary focus:border-primary dark:bg-gray-700">
</div>
</div>
</div>
<!-- Days Calculation -->
<div class="mb-6">
<h3 class="text-lg font-semibold mb-4 pb-2 border-b border-gray-200 dark:border-gray-700">
<i class="fas fa-calendar mr-2"></i>Days Calculation
</h3>
<div class="mb-4">
<div class="flex items-center">
<input type="checkbox" name="auto_calculate_days" id="auto_calculate_days" checked
class="h-4 w-4 text-primary focus:ring-primary border-gray-300 rounded">
<label for="auto_calculate_days" class="ml-2 text-sm text-gray-700 dark:text-gray-300">
<strong>Auto-calculate days from dates</strong>
</label>
</div>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label for="full_days" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Full Days
</label>
<input type="number" name="full_days" id="full_days" min="0"
value="{{ per_diem.full_days if per_diem else '0' }}"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-primary focus:border-primary dark:bg-gray-700">
</div>
<div>
<label for="half_days" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Half Days
</label>
<input type="number" name="half_days" id="half_days" min="0"
value="{{ per_diem.half_days if per_diem else '0' }}"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-primary focus:border-primary dark:bg-gray-700">
</div>
</div>
</div>
<!-- Meal Deductions -->
<div class="mb-6">
<h3 class="text-lg font-semibold mb-4 pb-2 border-b border-gray-200 dark:border-gray-700">
<i class="fas fa-utensils mr-2"></i>Provided Meals (Deductions)
</h3>
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<label for="breakfast_provided" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Breakfasts Provided
</label>
<input type="number" name="breakfast_provided" id="breakfast_provided" min="0"
value="{{ per_diem.breakfast_provided if per_diem else '0' }}"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-primary focus:border-primary dark:bg-gray-700">
</div>
<div>
<label for="lunch_provided" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Lunches Provided
</label>
<input type="number" name="lunch_provided" id="lunch_provided" min="0"
value="{{ per_diem.lunch_provided if per_diem else '0' }}"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-primary focus:border-primary dark:bg-gray-700">
</div>
<div>
<label for="dinner_provided" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Dinners Provided
</label>
<input type="number" name="dinner_provided" id="dinner_provided" min="0"
value="{{ per_diem.dinner_provided if per_diem else '0' }}"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-primary focus:border-primary dark:bg-gray-700">
</div>
</div>
</div>
<!-- Project & Client -->
<div class="mb-6">
<h3 class="text-lg font-semibold mb-4 pb-2 border-b border-gray-200 dark:border-gray-700">
<i class="fas fa-project-diagram mr-2"></i>Association
</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label for="project_id" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Project
</label>
<select name="project_id" id="project_id"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-primary focus:border-primary dark:bg-gray-700">
<option value="">No Project</option>
{% for project in projects %}
<option value="{{ project.id }}" {% if per_diem and per_diem.project_id == project.id %}selected{% endif %}>
{{ project.name }}
</option>
{% endfor %}
</select>
</div>
<div>
<label for="client_id" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Client
</label>
<select name="client_id" id="client_id"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-primary focus:border-primary dark:bg-gray-700">
<option value="">No Client</option>
{% for client in clients %}
<option value="{{ client.id }}" {% if per_diem and per_diem.client_id == client.id %}selected{% endif %}>
{{ client.name }}
</option>
{% endfor %}
</select>
</div>
</div>
</div>
<!-- Notes -->
<div class="mb-6">
<label for="notes" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Notes
</label>
<textarea name="notes" id="notes" rows="3"
placeholder="Additional notes..."
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-primary focus:border-primary dark:bg-gray-700">{{ per_diem.notes if per_diem else '' }}</textarea>
</div>
<!-- Create Expense Option -->
{% if not per_diem %}
<div class="mb-6">
<div class="flex items-center">
<input type="checkbox" name="create_expense" id="create_expense" checked
class="h-4 w-4 text-primary focus:ring-primary border-gray-300 rounded">
<label for="create_expense" class="ml-2 text-sm text-gray-700 dark:text-gray-300">
<strong>Create linked expense entry</strong>
<span class="block text-xs text-gray-500">Automatically create an expense record for this per diem</span>
</label>
</div>
</div>
{% endif %}
<!-- Actions -->
<div class="flex items-center justify-between pt-4 border-t border-gray-200 dark:border-gray-700">
<a href="{{ url_for('per_diem.view_per_diem', per_diem_id=per_diem.id) if per_diem else url_for('per_diem.list_per_diem') }}"
class="px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors">
<i class="fas fa-times mr-2"></i>Cancel
</a>
<button type="submit" class="bg-primary text-white px-6 py-2 rounded-lg hover:bg-primary/90 transition-colors">
<i class="fas fa-save mr-2"></i>{{ 'Update Claim' if per_diem else 'Create Claim' }}
</button>
</div>
</form>
</div>
{% endblock %}
+182
View File
@@ -0,0 +1,182 @@
{% extends "base.html" %}
{% from "components/ui.html" import page_header %}
{% block content %}
{% set breadcrumbs = [
{'text': 'Per Diem'}
] %}
{{ page_header(
icon_class='fas fa-money-bill-alt',
title_text='Per Diem Claims',
subtitle_text='Manage daily allowance claims',
breadcrumbs=breadcrumbs,
actions_html='<div class="flex gap-2"><a href="' + url_for("per_diem.list_rates") + '" class="bg-gray-600 text-white px-4 py-2 rounded-lg hover:bg-gray-700 transition-colors"><i class="fas fa-list-alt mr-2"></i>Manage Rates</a><a href="' + url_for("per_diem.create_per_diem") + '" class="bg-primary text-white px-4 py-2 rounded-lg hover:bg-primary/90 transition-colors"><i class="fas fa-plus mr-2"></i>New Claim</a></div>'
) }}
<!-- Summary Stats -->
<div class="grid grid-cols-1 md:grid-cols-2 gap-4 mb-6">
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow">
<div class="flex items-center justify-between">
<div>
<p class="text-sm text-gray-600 dark:text-gray-400">Total Claims</p>
<p class="text-2xl font-bold">{{ pagination.total if pagination else (per_diem_claims|length) }}</p>
</div>
<div class="text-primary text-3xl">
<i class="fas fa-list"></i>
</div>
</div>
</div>
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow">
<div class="flex items-center justify-between">
<div>
<p class="text-sm text-gray-600 dark:text-gray-400">Total Amount</p>
<p class="text-2xl font-bold">€{{ '%.2f'|format(total_amount) }}</p>
</div>
<div class="text-green-500 text-3xl">
<i class="fas fa-euro-sign"></i>
</div>
</div>
</div>
</div>
<!-- Filter Form -->
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow mb-6">
<h2 class="text-lg font-semibold mb-4">Filter Claims</h2>
<form method="GET" class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
<div>
<label for="status" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Status</label>
<select name="status" id="status" class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-primary focus:border-primary dark:bg-gray-700">
<option value="">All Statuses</option>
<option value="pending" {% if status == 'pending' %}selected{% endif %}>Pending</option>
<option value="approved" {% if status == 'approved' %}selected{% endif %}>Approved</option>
<option value="rejected" {% if status == 'rejected' %}selected{% endif %}>Rejected</option>
<option value="reimbursed" {% if status == 'reimbursed' %}selected{% endif %}>Reimbursed</option>
</select>
</div>
<div>
<label for="project_id" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Project</label>
<select name="project_id" id="project_id" class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-primary focus:border-primary dark:bg-gray-700">
<option value="">All Projects</option>
{% for project in projects %}
<option value="{{ project.id }}" {% if project_id == project.id %}selected{% endif %}>{{ project.name }}</option>
{% endfor %}
</select>
</div>
<div>
<label for="start_date" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">Start Date</label>
<input type="date" name="start_date" id="start_date" value="{{ start_date or '' }}"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-primary focus:border-primary dark:bg-gray-700">
</div>
<div>
<label for="end_date" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">End Date</label>
<input type="date" name="end_date" id="end_date" value="{{ end_date or '' }}"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-primary focus:border-primary dark:bg-gray-700">
</div>
<div class="flex items-end gap-2 md:col-span-2 lg:col-span-4">
<button type="submit" class="flex-1 bg-primary text-white px-4 py-2 rounded-lg hover:bg-primary/90 transition-colors">
<i class="fas fa-filter mr-2"></i>Filter
</button>
<a href="{{ url_for('per_diem.list_per_diem') }}" class="px-4 py-2 bg-gray-200 dark:bg-gray-700 rounded-lg hover:bg-gray-300 dark:hover:bg-gray-600 transition-colors">
<i class="fas fa-times"></i>
</a>
</div>
</form>
</div>
<!-- Claims Table -->
<div class="bg-card-light dark:bg-card-dark rounded-lg shadow overflow-hidden">
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
<thead class="bg-gray-50 dark:bg-gray-800">
<tr>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Period</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Purpose</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Location</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Days</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Amount</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Status</th>
<th class="px-6 py-3 text-right text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Actions</th>
</tr>
</thead>
<tbody class="bg-white dark:bg-gray-900 divide-y divide-gray-200 dark:divide-gray-700">
{% if per_diem_claims %}
{% for claim in per_diem_claims %}
<tr class="hover:bg-gray-50 dark:hover:bg-gray-800">
<td class="px-6 py-4 whitespace-nowrap text-sm">
{{ claim.start_date.strftime('%Y-%m-%d') }}<br>
<span class="text-xs text-gray-500">to {{ claim.end_date.strftime('%Y-%m-%d') }}</span>
</td>
<td class="px-6 py-4 text-sm">
<a href="{{ url_for('per_diem.view_per_diem', per_diem_id=claim.id) }}" class="text-primary hover:underline font-medium">
{{ claim.trip_purpose }}
</a>
{% if claim.project %}
<div class="text-xs text-gray-500">{{ claim.project.name }}</div>
{% endif %}
</td>
<td class="px-6 py-4 text-sm">
{{ claim.city + ', ' if claim.city else '' }}{{ claim.country }}
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm">
{{ claim.total_days if claim.total_days else ((claim.full_days or 0) + (claim.half_days or 0) * 0.5) }}
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium">
{{ claim.currency_code or 'EUR' }} {{ '%.2f'|format(claim.calculated_amount or 0) }}
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm">
{% if claim.status == 'pending' %}
<span class="px-2 py-1 text-xs rounded-full bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200">
Pending
</span>
{% elif claim.status == 'approved' %}
<span class="px-2 py-1 text-xs rounded-full bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200">
Approved
</span>
{% elif claim.status == 'rejected' %}
<span class="px-2 py-1 text-xs rounded-full bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200">
Rejected
</span>
{% elif claim.status == 'reimbursed' %}
<span class="px-2 py-1 text-xs rounded-full bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200">
Reimbursed
</span>
{% endif %}
</td>
<td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
<div class="flex items-center justify-end gap-2">
<a href="{{ url_for('per_diem.view_per_diem', per_diem_id=claim.id) }}" class="text-primary hover:text-primary/80" title="View">
<i class="fas fa-eye"></i>
</a>
{% if current_user.is_admin or claim.user_id == current_user.id %}
<a href="{{ url_for('per_diem.edit_per_diem', per_diem_id=claim.id) }}" class="text-blue-600 hover:text-blue-800" title="Edit">
<i class="fas fa-edit"></i>
</a>
{% endif %}
</div>
</td>
</tr>
{% endfor %}
{% else %}
<tr>
<td colspan="7" class="px-6 py-8 text-center text-gray-500">
<i class="fas fa-money-bill-alt text-4xl mb-2 opacity-50"></i>
<p>No per diem claims found</p>
<a href="{{ url_for('per_diem.create_per_diem') }}" class="text-primary hover:underline mt-2 inline-block">
Create your first claim
</a>
</td>
</tr>
{% endif %}
</tbody>
</table>
</div>
</div>
{% endblock %}
+175
View File
@@ -0,0 +1,175 @@
{% extends "base.html" %}
{% from "components/ui.html" import page_header %}
{% block content %}
{% set breadcrumbs = [
{'text': 'Per Diem', 'url': url_for('per_diem.list_per_diem')},
{'text': 'Rates', 'url': url_for('per_diem.list_rates')},
{'text': 'Edit' if rate else 'New'}
] %}
{{ page_header(
icon_class='fas fa-list-alt',
title_text=('Edit Per Diem Rate' if rate else 'New Per Diem Rate'),
subtitle_text=('Update rate details' if rate else 'Create a new per diem rate'),
breadcrumbs=breadcrumbs
) %}
<div class="max-w-4xl mx-auto">
<form method="POST" class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<!-- Location Information -->
<div class="mb-6">
<h3 class="text-lg font-semibold mb-4 pb-2 border-b border-gray-200 dark:border-gray-700">
<i class="fas fa-map-marker-alt mr-2"></i>Location
</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label for="country" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Country Code <span class="text-red-500">*</span>
</label>
<input type="text" name="country" id="country" required
value="{{ rate.country if rate else '' }}"
placeholder="e.g., DE, US, GB"
maxlength="2"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-primary focus:border-primary dark:bg-gray-700">
</div>
<div>
<label for="city" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
City (optional)
</label>
<input type="text" name="city" id="city"
value="{{ rate.city if rate and rate.city else '' }}"
placeholder="e.g., Berlin"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-primary focus:border-primary dark:bg-gray-700">
</div>
</div>
</div>
<!-- Rate Information -->
<div class="mb-6">
<h3 class="text-lg font-semibold mb-4 pb-2 border-b border-gray-200 dark:border-gray-700">
<i class="fas fa-euro-sign mr-2"></i>Rates
</h3>
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<div>
<label for="full_day_rate" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Full Day Rate <span class="text-red-500">*</span>
</label>
<input type="number" name="full_day_rate" id="full_day_rate" step="0.01" required
value="{{ rate.full_day_rate if rate else '' }}"
placeholder="0.00"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-primary focus:border-primary dark:bg-gray-700">
</div>
<div>
<label for="half_day_rate" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Half Day Rate <span class="text-red-500">*</span>
</label>
<input type="number" name="half_day_rate" id="half_day_rate" step="0.01" required
value="{{ rate.half_day_rate if rate else '' }}"
placeholder="0.00"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-primary focus:border-primary dark:bg-gray-700">
</div>
<div>
<label for="currency_code" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Currency
</label>
<select name="currency_code" id="currency_code"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-primary focus:border-primary dark:bg-gray-700">
<option value="EUR" {% if not rate or rate.currency_code == 'EUR' %}selected{% endif %}>EUR</option>
<option value="USD" {% if rate and rate.currency_code == 'USD' %}selected{% endif %}>USD</option>
<option value="GBP" {% if rate and rate.currency_code == 'GBP' %}selected{% endif %}>GBP</option>
</select>
</div>
<div>
<label for="breakfast_rate" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Breakfast Deduction
</label>
<input type="number" name="breakfast_rate" id="breakfast_rate" step="0.01"
value="{{ rate.breakfast_rate if rate and rate.breakfast_rate else '' }}"
placeholder="0.00"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-primary focus:border-primary dark:bg-gray-700">
</div>
<div>
<label for="lunch_rate" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Lunch Deduction
</label>
<input type="number" name="lunch_rate" id="lunch_rate" step="0.01"
value="{{ rate.lunch_rate if rate and rate.lunch_rate else '' }}"
placeholder="0.00"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-primary focus:border-primary dark:bg-gray-700">
</div>
<div>
<label for="dinner_rate" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Dinner Deduction
</label>
<input type="number" name="dinner_rate" id="dinner_rate" step="0.01"
value="{{ rate.dinner_rate if rate and rate.dinner_rate else '' }}"
placeholder="0.00"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-primary focus:border-primary dark:bg-gray-700">
</div>
</div>
</div>
<!-- Effective Period -->
<div class="mb-6">
<h3 class="text-lg font-semibold mb-4 pb-2 border-b border-gray-200 dark:border-gray-700">
<i class="fas fa-calendar mr-2"></i>Effective Period
</h3>
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label for="effective_from" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Effective From <span class="text-red-500">*</span>
</label>
<input type="date" name="effective_from" id="effective_from" required
value="{{ rate.effective_from.strftime('%Y-%m-%d') if rate else '' }}"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-primary focus:border-primary dark:bg-gray-700">
</div>
<div>
<label for="effective_to" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Effective To (optional)
</label>
<input type="date" name="effective_to" id="effective_to"
value="{{ rate.effective_to.strftime('%Y-%m-%d') if rate and rate.effective_to else '' }}"
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-primary focus:border-primary dark:bg-gray-700">
</div>
</div>
</div>
<!-- Notes -->
<div class="mb-6">
<label for="notes" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Notes
</label>
<textarea name="notes" id="notes" rows="3"
placeholder="Additional notes about this rate..."
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg focus:ring-primary focus:border-primary dark:bg-gray-700">{{ rate.notes if rate else '' }}</textarea>
</div>
<!-- Actions -->
<div class="flex items-center justify-between pt-4 border-t border-gray-200 dark:border-gray-700">
<a href="{{ url_for('per_diem.list_rates') }}"
class="px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors">
<i class="fas fa-times mr-2"></i>Cancel
</a>
<button type="submit" class="bg-primary text-white px-6 py-2 rounded-lg hover:bg-primary/90 transition-colors">
<i class="fas fa-save mr-2"></i>{{ 'Update Rate' if rate else 'Create Rate' }}
</button>
</div>
</form>
</div>
{% endblock %}
+84
View File
@@ -0,0 +1,84 @@
{% extends "base.html" %}
{% from "components/ui.html" import page_header %}
{% block content %}
{% set breadcrumbs = [
{'text': 'Per Diem', 'url': url_for('per_diem.list_per_diem')},
{'text': 'Rates'}
] %}
{{ page_header(
icon_class='fas fa-list-alt',
title_text='Per Diem Rates',
subtitle_text='Manage per diem rates by location',
breadcrumbs=breadcrumbs,
actions_html='<a href="' + url_for("per_diem.create_rate") + '" class="bg-primary text-white px-4 py-2 rounded-lg hover:bg-primary/90 transition-colors"><i class="fas fa-plus mr-2"></i>New Rate</a>'
) }}
<!-- Rates Table -->
<div class="bg-card-light dark:bg-card-dark rounded-lg shadow overflow-hidden">
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
<thead class="bg-gray-50 dark:bg-gray-800">
<tr>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Location</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Full Day Rate</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Half Day Rate</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Currency</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Effective From</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Status</th>
</tr>
</thead>
<tbody class="bg-white dark:bg-gray-900 divide-y divide-gray-200 dark:divide-gray-700">
{% if rates %}
{% for rate in rates %}
<tr class="hover:bg-gray-50 dark:hover:bg-gray-800">
<td class="px-6 py-4 text-sm">
<div class="font-medium">{{ rate.city + ', ' if rate.city else '' }}{{ rate.country }}</div>
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium">
{{ '%.2f'|format(rate.full_day_rate) }}
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium">
{{ '%.2f'|format(rate.half_day_rate) }}
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm">
{{ rate.currency_code }}
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm">
{{ rate.effective_from.strftime('%Y-%m-%d') }}
{% if rate.effective_to %}
<br><span class="text-xs text-gray-500">to {{ rate.effective_to.strftime('%Y-%m-%d') }}</span>
{% endif %}
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm">
{% if rate.is_active %}
<span class="px-2 py-1 text-xs rounded-full bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200">
Active
</span>
{% else %}
<span class="px-2 py-1 text-xs rounded-full bg-gray-100 text-gray-800 dark:bg-gray-900 dark:text-gray-200">
Inactive
</span>
{% endif %}
</td>
</tr>
{% endfor %}
{% else %}
<tr>
<td colspan="6" class="px-6 py-8 text-center text-gray-500">
<i class="fas fa-list-alt text-4xl mb-2 opacity-50"></i>
<p>No per diem rates found</p>
<a href="{{ url_for('per_diem.create_rate') }}" class="text-primary hover:underline mt-2 inline-block">
Create your first rate
</a>
</td>
</tr>
{% endif %}
</tbody>
</table>
</div>
</div>
{% endblock %}
+115
View File
@@ -0,0 +1,115 @@
{% extends "base.html" %}
{% from "components/ui.html" import page_header %}
{% block content %}
{% set breadcrumbs = [
{'text': 'Per Diem', 'url': url_for('per_diem.list_per_diem')},
{'text': 'Claim #' + per_diem.id|string}
] %}
{{ page_header(
icon_class='fas fa-money-bill-alt',
title_text='Per Diem Claim #' + per_diem.id|string,
subtitle_text=per_diem.trip_purpose,
breadcrumbs=breadcrumbs,
actions_html='<a href="' + url_for("per_diem.edit_per_diem", per_diem_id=per_diem.id) + '" class="bg-primary text-white px-4 py-2 rounded-lg hover:bg-primary/90 transition-colors"><i class="fas fa-edit mr-2"></i>Edit</a>' if current_user.is_admin or per_diem.user_id == current_user.id else ''
) }}
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
<!-- Main Content -->
<div class="lg:col-span-2 space-y-6">
<!-- Claim Details -->
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow">
<h3 class="text-lg font-semibold mb-4 pb-2 border-b border-gray-200 dark:border-gray-700">
<i class="fas fa-info-circle mr-2"></i>Claim Details
</h3>
<div class="grid grid-cols-2 gap-4">
<div>
<p class="text-sm text-gray-600 dark:text-gray-400 mb-1">Period</p>
<p class="font-semibold">{{ per_diem.start_date.strftime('%Y-%m-%d') }} to {{ per_diem.end_date.strftime('%Y-%m-%d') }}</p>
</div>
<div>
<p class="text-sm text-gray-600 dark:text-gray-400 mb-1">Location</p>
<p class="font-semibold">{{ per_diem.city + ', ' if per_diem.city else '' }}{{ per_diem.country }}</p>
</div>
<div>
<p class="text-sm text-gray-600 dark:text-gray-400 mb-1">Full Days</p>
<p class="font-semibold">{{ per_diem.full_days }}</p>
</div>
<div>
<p class="text-sm text-gray-600 dark:text-gray-400 mb-1">Half Days</p>
<p class="font-semibold">{{ per_diem.half_days }}</p>
</div>
<div class="col-span-2 mt-2">
<div class="p-4 bg-primary/10 rounded-lg">
<p class="text-sm text-gray-600 dark:text-gray-400 mb-1">Total Amount</p>
<p class="font-bold text-2xl text-primary">{{ per_diem.currency_code or 'EUR' }} {{ '%.2f'|format(per_diem.calculated_amount or 0) }}</p>
</div>
</div>
</div>
</div>
</div>
<!-- Sidebar -->
<div class="space-y-6">
<!-- Status -->
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow">
<h3 class="text-lg font-semibold mb-4 pb-2 border-b border-gray-200 dark:border-gray-700">
<i class="fas fa-info-circle mr-2"></i>Status
</h3>
<div class="mb-4">
{% if per_diem.status == 'pending' %}
<span class="px-3 py-2 text-sm rounded-full bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200 font-semibold">
<i class="fas fa-clock mr-1"></i>Pending
</span>
{% elif per_diem.status == 'approved' %}
<span class="px-3 py-2 text-sm rounded-full bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200 font-semibold">
<i class="fas fa-check-circle mr-1"></i>Approved
</span>
{% elif per_diem.status == 'rejected' %}
<span class="px-3 py-2 text-sm rounded-full bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200 font-semibold">
<i class="fas fa-times-circle mr-1"></i>Rejected
</span>
{% elif per_diem.status == 'reimbursed' %}
<span class="px-3 py-2 text-sm rounded-full bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200 font-semibold">
<i class="fas fa-money-bill mr-1"></i>Reimbursed
</span>
{% endif %}
</div>
</div>
<!-- Admin Actions -->
{% if current_user.is_admin and per_diem.status == 'pending' %}
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow">
<h3 class="text-lg font-semibold mb-4 pb-2 border-b border-gray-200 dark:border-gray-700">
<i class="fas fa-gavel mr-2"></i>Admin Actions
</h3>
<form method="POST" action="{{ url_for('per_diem.approve_per_diem', per_diem_id=per_diem.id) }}" class="mb-3">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="w-full bg-green-600 text-white px-4 py-2 rounded-lg hover:bg-green-700 transition-colors">
<i class="fas fa-check mr-2"></i>Approve
</button>
</form>
<form method="POST" action="{{ url_for('per_diem.reject_per_diem', per_diem_id=per_diem.id) }}">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<textarea name="rejection_reason" placeholder="Rejection reason (required)" rows="2" required
class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg mb-2 text-sm dark:bg-gray-700"></textarea>
<button type="submit" class="w-full bg-red-600 text-white px-4 py-2 rounded-lg hover:bg-red-700 transition-colors">
<i class="fas fa-times mr-2"></i>Reject
</button>
</form>
</div>
{% endif %}
</div>
</div>
{% endblock %}
+508
View File
@@ -0,0 +1,508 @@
{% extends "base.html" %}
{% block content %}
<div class="mb-6">
<div class="flex flex-col md:flex-row justify-between items-start md:items-center mb-4">
<div>
<div class="flex items-center gap-2 mb-2">
<a href="{{ url_for('projects.view_project', project_id=project.id) }}" class="text-primary hover:underline">
<i class="fas fa-arrow-left mr-1"></i>{{ _('Back to Project') }}
</a>
</div>
<h1 class="text-3xl font-bold flex items-center gap-2">
<i class="fas fa-chart-line text-primary"></i>
<span>{{ project.name }}</span>
{% if project.code_display %}
<span class="inline-flex items-center px-2 py-0.5 rounded text-[10px] font-semibold tracking-wide uppercase bg-gray-200 text-gray-800 dark:bg-gray-700 dark:text-gray-200">{{ project.code_display }}</span>
{% endif %}
</h1>
<p class="text-text-muted-light dark:text-text-muted-dark">{{ _('Project Dashboard & Analytics') }}</p>
</div>
<!-- Actions and Time Period Filter -->
<div class="mt-4 md:mt-0 flex gap-3 flex-wrap">
{% if project.budget_amount %}
<a href="{{ url_for('budget_alerts.project_budget_detail', project_id=project.id) }}"
class="bg-gradient-to-r from-green-600 to-emerald-600 hover:from-green-700 hover:to-emerald-700 text-white px-4 py-2 rounded-lg flex items-center gap-2 transition shadow-lg">
<i class="fas fa-wallet"></i>
{{ _('Budget Analysis') }}
</a>
{% endif %}
<select id="periodFilter" class="bg-card-light dark:bg-card-dark border border-border-light dark:border-border-dark rounded-lg px-4 py-2" onchange="window.location.href='?period='+this.value">
<option value="all" {% if period == 'all' %}selected{% endif %}>{{ _('All Time') }}</option>
<option value="week" {% if period == 'week' %}selected{% endif %}>{{ _('Last 7 Days') }}</option>
<option value="month" {% if period == 'month' %}selected{% endif %}>{{ _('Last 30 Days') }}</option>
<option value="3months" {% if period == '3months' %}selected{% endif %}>{{ _('Last 3 Months') }}</option>
<option value="year" {% if period == 'year' %}selected{% endif %}>{{ _('Last Year') }}</option>
</select>
</div>
</div>
</div>
<!-- Key Metrics Overview -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-6">
<!-- Total Hours -->
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow">
<div class="flex items-center justify-between">
<div>
<p class="text-text-muted-light dark:text-text-muted-dark text-sm">{{ _('Total Hours') }}</p>
<p class="text-3xl font-bold mt-2">{{ "%.1f"|format(project.total_hours) }}</p>
{% if budget_data.estimated_hours > 0 %}
<p class="text-xs text-text-muted-light dark:text-text-muted-dark mt-1">
{{ _('of') }} {{ "%.0f"|format(budget_data.estimated_hours) }} {{ _('estimated') }}
</p>
{% endif %}
</div>
<div class="text-4xl text-blue-500">
<i class="fas fa-clock"></i>
</div>
</div>
</div>
<!-- Budget Consumed -->
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow">
<div class="flex items-center justify-between">
<div>
<p class="text-text-muted-light dark:text-text-muted-dark text-sm">{{ _('Budget Used') }}</p>
<p class="text-3xl font-bold mt-2">{{ "%.0f"|format(budget_data.consumed_amount) }}</p>
{% if budget_data.budget_amount > 0 %}
<p class="text-xs {% if budget_data.threshold_exceeded %}text-red-500{% else %}text-text-muted-light dark:text-text-muted-dark{% endif %} mt-1">
{{ "%.1f"|format(budget_data.percentage) }}% {{ _('of budget') }}
</p>
{% endif %}
</div>
<div class="text-4xl {% if budget_data.threshold_exceeded %}text-red-500{% else %}text-green-500{% endif %}">
<i class="fas fa-dollar-sign"></i>
</div>
</div>
</div>
<!-- Task Completion -->
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow">
<div class="flex items-center justify-between">
<div>
<p class="text-text-muted-light dark:text-text-muted-dark text-sm">{{ _('Tasks Complete') }}</p>
<p class="text-3xl font-bold mt-2">{{ task_stats.completed }}/{{ task_stats.total }}</p>
<p class="text-xs text-text-muted-light dark:text-text-muted-dark mt-1">
{{ "%.1f"|format(task_stats.completion_rate) }}% {{ _('completion') }}
</p>
</div>
<div class="text-4xl text-purple-500">
<i class="fas fa-tasks"></i>
</div>
</div>
</div>
<!-- Team Size -->
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow">
<div class="flex items-center justify-between">
<div>
<p class="text-text-muted-light dark:text-text-muted-dark text-sm">{{ _('Team Members') }}</p>
<p class="text-3xl font-bold mt-2">{{ team_contributions|length }}</p>
<p class="text-xs text-text-muted-light dark:text-text-muted-dark mt-1">{{ _('contributing') }}</p>
</div>
<div class="text-4xl text-orange-500">
<i class="fas fa-users"></i>
</div>
</div>
</div>
</div>
<!-- Charts Row 1 -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-6">
<!-- Budget vs Actual Chart -->
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow">
<h2 class="text-xl font-semibold mb-4 flex items-center gap-2">
<i class="fas fa-chart-bar text-primary"></i>
{{ _('Budget vs. Actual') }}
</h2>
{% if budget_data.budget_amount > 0 %}
<div class="h-64">
<canvas id="budgetChart"></canvas>
</div>
<div class="mt-4 grid grid-cols-2 gap-4 text-sm">
<div>
<p class="text-text-muted-light dark:text-text-muted-dark">{{ _('Budget') }}</p>
<p class="text-lg font-semibold">{{ "%.2f"|format(budget_data.budget_amount) }}</p>
</div>
<div>
<p class="text-text-muted-light dark:text-text-muted-dark">{{ _('Remaining') }}</p>
<p class="text-lg font-semibold {% if budget_data.remaining_amount < 0 %}text-red-500{% else %}text-green-500{% endif %}">
{{ "%.2f"|format(budget_data.remaining_amount) }}
</p>
</div>
</div>
{% else %}
<div class="h-64 flex items-center justify-center text-text-muted-light dark:text-text-muted-dark">
<div class="text-center">
<i class="fas fa-info-circle text-4xl mb-4"></i>
<p>{{ _('No budget set for this project') }}</p>
</div>
</div>
{% endif %}
</div>
<!-- Task Status Distribution -->
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow">
<h2 class="text-xl font-semibold mb-4 flex items-center gap-2">
<i class="fas fa-chart-pie text-primary"></i>
{{ _('Task Status Distribution') }}
</h2>
{% if task_stats.total > 0 %}
<div class="h-64">
<canvas id="taskStatusChart"></canvas>
</div>
<div class="mt-4 grid grid-cols-2 gap-2 text-sm">
{% for status, count in task_stats.by_status.items() %}
<div class="flex items-center gap-2">
<div class="w-3 h-3 rounded-full" style="background-color: {{ {'todo': '#6B7280', 'in_progress': '#3B82F6', 'review': '#F59E0B', 'done': '#10B981', 'cancelled': '#EF4444'}.get(status, '#6B7280') }}"></div>
<span>{{ status.replace('_', ' ').title() }}: {{ count }}</span>
</div>
{% endfor %}
</div>
{% else %}
<div class="h-64 flex items-center justify-center text-text-muted-light dark:text-text-muted-dark">
<div class="text-center">
<i class="fas fa-tasks text-4xl mb-4"></i>
<p>{{ _('No tasks created yet') }}</p>
</div>
</div>
{% endif %}
</div>
</div>
<!-- Charts Row 2 -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-6">
<!-- Team Contributions Chart -->
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow">
<h2 class="text-xl font-semibold mb-4 flex items-center gap-2">
<i class="fas fa-user-friends text-primary"></i>
{{ _('Team Member Contributions') }}
</h2>
{% if team_contributions %}
<div class="h-64">
<canvas id="teamChart"></canvas>
</div>
<div class="mt-4 space-y-2">
{% for member in team_contributions[:5] %}
<div class="flex items-center justify-between text-sm">
<div class="flex items-center gap-2">
<div class="w-2 h-2 rounded-full bg-primary"></div>
<span>{{ member.username }}</span>
</div>
<div class="flex items-center gap-4">
<span class="text-text-muted-light dark:text-text-muted-dark">{{ "%.1f"|format(member.total_hours) }}h</span>
<span class="font-semibold">{{ "%.1f"|format(member.percentage) }}%</span>
</div>
</div>
{% endfor %}
</div>
{% else %}
<div class="h-64 flex items-center justify-center text-text-muted-light dark:text-text-muted-dark">
<div class="text-center">
<i class="fas fa-users text-4xl mb-4"></i>
<p>{{ _('No time entries recorded yet') }}</p>
</div>
</div>
{% endif %}
</div>
<!-- Time Tracking Timeline -->
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow">
<h2 class="text-xl font-semibold mb-4 flex items-center gap-2">
<i class="fas fa-chart-line text-primary"></i>
{{ _('Time Tracking Timeline') }}
</h2>
{% if timeline_data %}
<div class="h-64">
<canvas id="timelineChart"></canvas>
</div>
{% else %}
<div class="h-64 flex items-center justify-center text-text-muted-light dark:text-text-muted-dark">
<div class="text-center">
<i class="fas fa-calendar-alt text-4xl mb-4"></i>
<p>{{ _('Select a time period to view timeline') }}</p>
</div>
</div>
{% endif %}
</div>
</div>
<!-- Recent Activity and Team Details -->
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-6">
<!-- Recent Activity -->
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow">
<h2 class="text-xl font-semibold mb-4 flex items-center gap-2">
<i class="fas fa-history text-primary"></i>
{{ _('Recent Activity') }}
</h2>
{% if recent_activities %}
<div class="space-y-3 max-h-96 overflow-y-auto">
{% for activity in recent_activities %}
<div class="flex items-start gap-3 p-3 bg-bg-light dark:bg-bg-dark rounded-lg">
<div class="flex-shrink-0 mt-1">
<i class="{{ activity.get_icon() }}"></i>
</div>
<div class="flex-1 min-w-0">
<p class="text-sm">
<span class="font-medium">{{ activity.user.display_name if activity.user.full_name else activity.user.username }}</span>
{{ activity.description or (activity.action + ' ' + activity.entity_type) }}
</p>
<p class="text-xs text-text-muted-light dark:text-text-muted-dark mt-1">
{{ activity.created_at.strftime('%Y-%m-%d %H:%M') }}
</p>
</div>
</div>
{% endfor %}
</div>
{% else %}
<div class="flex items-center justify-center py-12 text-text-muted-light dark:text-text-muted-dark">
<div class="text-center">
<i class="fas fa-history text-4xl mb-4"></i>
<p>{{ _('No recent activity') }}</p>
</div>
</div>
{% endif %}
</div>
<!-- Team Member Details -->
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow">
<h2 class="text-xl font-semibold mb-4 flex items-center gap-2">
<i class="fas fa-users text-primary"></i>
{{ _('Team Member Details') }}
</h2>
{% if team_contributions %}
<div class="space-y-3 max-h-96 overflow-y-auto">
{% for member in team_contributions %}
<div class="p-4 bg-bg-light dark:bg-bg-dark rounded-lg">
<div class="flex items-center justify-between mb-2">
<h3 class="font-medium">{{ member.username }}</h3>
<span class="text-sm font-semibold text-primary">{{ "%.1f"|format(member.total_hours) }}h</span>
</div>
<div class="grid grid-cols-3 gap-2 text-sm text-text-muted-light dark:text-text-muted-dark">
<div>
<i class="fas fa-clock mr-1"></i>
{{ member.entry_count }} {{ _('entries') }}
</div>
<div>
<i class="fas fa-tasks mr-1"></i>
{{ member.task_count }} {{ _('tasks') }}
</div>
<div>
<i class="fas fa-percentage mr-1"></i>
{{ "%.1f"|format(member.percentage) }}%
</div>
</div>
<!-- Progress bar -->
<div class="mt-2 w-full bg-gray-200 dark:bg-gray-700 rounded-full h-2">
<div class="bg-primary h-2 rounded-full" style="width: {{ member.percentage }}%"></div>
</div>
</div>
{% endfor %}
</div>
{% else %}
<div class="flex items-center justify-center py-12 text-text-muted-light dark:text-text-muted-dark">
<div class="text-center">
<i class="fas fa-user-slash text-4xl mb-4"></i>
<p>{{ _('No team members have logged time yet') }}</p>
</div>
</div>
{% endif %}
</div>
</div>
<!-- Additional Stats -->
{% if task_stats.overdue > 0 %}
<div class="bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg p-4 mb-6">
<div class="flex items-center gap-3">
<i class="fas fa-exclamation-triangle text-red-500 text-2xl"></i>
<div>
<h3 class="font-semibold text-red-800 dark:text-red-200">{{ _('Attention Required') }}</h3>
<p class="text-sm text-red-700 dark:text-red-300">
{{ task_stats.overdue }} {{ _('task(s) are overdue') }}
</p>
</div>
</div>
</div>
{% endif %}
<!-- Chart.js Scripts -->
<script src="https://cdn.jsdelivr.net/npm/chart.js@4.4.0/dist/chart.umd.min.js"></script>
<script>
{% autoescape false %}
document.addEventListener('DOMContentLoaded', function() {
// Theme-aware colors
const isDark = document.documentElement.classList.contains('dark');
const textColor = isDark ? '#E5E7EB' : '#1F2937';
const gridColor = isDark ? '#374151' : '#E5E7EB';
// Common chart options
const commonOptions = {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
labels: {
color: textColor
}
}
},
scales: {
x: {
ticks: { color: textColor },
grid: { color: gridColor }
},
y: {
ticks: { color: textColor },
grid: { color: gridColor }
}
}
};
{% if budget_data.budget_amount > 0 %}
// Budget Chart
const budgetCtx = document.getElementById('budgetChart').getContext('2d');
new Chart(budgetCtx, {
type: 'bar',
data: {
labels: [{{ _("Budget")|tojson }}, {{ _("Consumed")|tojson }}, {{ _("Remaining")|tojson }}],
datasets: [{
label: {{ _("Amount")|tojson }},
data: [
{{ budget_data.budget_amount }},
{{ budget_data.consumed_amount }},
{{ budget_data.remaining_amount }}
],
backgroundColor: [
'rgba(59, 130, 246, 0.5)',
{% if budget_data.threshold_exceeded %}'rgba(239, 68, 68, 0.5)'{% else %}'rgba(16, 185, 129, 0.5)'{% endif %},
{% if budget_data.remaining_amount < 0 %}'rgba(239, 68, 68, 0.5)'{% else %}'rgba(34, 197, 94, 0.5)'{% endif %}
],
borderColor: [
'rgb(59, 130, 246)',
{% if budget_data.threshold_exceeded %}'rgb(239, 68, 68)'{% else %}'rgb(16, 185, 129)'{% endif %},
{% if budget_data.remaining_amount < 0 %}'rgb(239, 68, 68)'{% else %}'rgb(34, 197, 94)'{% endif %}
],
borderWidth: 2
}]
},
options: {
...commonOptions,
plugins: {
...commonOptions.plugins,
legend: { display: false }
}
}
});
{% endif %}
{% if task_stats.total > 0 %}
// Task Status Chart
const taskStatusCtx = document.getElementById('taskStatusChart').getContext('2d');
new Chart(taskStatusCtx, {
type: 'doughnut',
data: {
labels: [
{% for status in task_stats.by_status.keys() %}
{{ status.replace("_", " ").title()|tojson }}{% if not loop.last %},{% endif %}
{% endfor %}
],
datasets: [{
data: [
{% for count in task_stats.by_status.values() %}
{{ count }}{% if not loop.last %},{% endif %}
{% endfor %}
],
backgroundColor: [
{% for status in task_stats.by_status.keys() %}
{{ {'todo': "'rgba(107, 114, 128, 0.8)'", 'in_progress': "'rgba(59, 130, 246, 0.8)'", 'review': "'rgba(245, 158, 11, 0.8)'", 'done': "'rgba(16, 185, 129, 0.8)'", 'cancelled': "'rgba(239, 68, 68, 0.8)'"}.get(status, "'rgba(107, 114, 128, 0.8)'") }}{% if not loop.last %},{% endif %}
{% endfor %}
],
borderWidth: 2
}]
},
options: {
responsive: true,
maintainAspectRatio: false,
plugins: {
legend: {
position: 'bottom',
labels: { color: textColor }
}
}
}
});
{% endif %}
{% if team_contributions %}
// Team Contributions Chart
const teamCtx = document.getElementById('teamChart').getContext('2d');
new Chart(teamCtx, {
type: 'bar',
data: {
labels: [
{% for member in team_contributions[:10] %}
{{ member.username|tojson }}{% if not loop.last %},{% endif %}
{% endfor %}
],
datasets: [{
label: {{ _("Hours")|tojson }},
data: [
{% for member in team_contributions[:10] %}
{{ member.total_hours }}{% if not loop.last %},{% endif %}
{% endfor %}
],
backgroundColor: 'rgba(139, 92, 246, 0.5)',
borderColor: 'rgb(139, 92, 246)',
borderWidth: 2
}]
},
options: {
...commonOptions,
indexAxis: 'y',
plugins: {
...commonOptions.plugins,
legend: { display: false }
}
}
});
{% endif %}
{% if timeline_data %}
// Timeline Chart
const timelineCtx = document.getElementById('timelineChart').getContext('2d');
new Chart(timelineCtx, {
type: 'line',
data: {
labels: [
{% for entry in timeline_data %}
{{ entry.date|tojson }}{% if not loop.last %},{% endif %}
{% endfor %}
],
datasets: [{
label: {{ _("Hours")|tojson }},
data: [
{% for entry in timeline_data %}
{{ entry.hours }}{% if not loop.last %},{% endif %}
{% endfor %}
],
borderColor: 'rgb(59, 130, 246)',
backgroundColor: 'rgba(59, 130, 246, 0.1)',
tension: 0.3,
fill: true
}]
},
options: {
...commonOptions,
plugins: {
...commonOptions.plugins,
legend: { display: false }
}
}
});
{% endif %}
});
{% endautoescape %}
</script>
{% endblock %}
+3 -3
View File
@@ -52,15 +52,15 @@
</form>
</div>
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow overflow-x-auto">
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow overflow-visible">
<div class="flex justify-between items-center mb-4">
<h3 class="text-sm font-medium text-text-muted-light dark:text-text-muted-dark">
{{ projects|length }} project{{ 's' if projects|length != 1 else '' }} found
</h3>
<div class="flex items-center gap-2">
<button type="button" class="px-3 py-1.5 text-sm bg-background-light dark:bg-background-dark rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors" title="Export to CSV">
<a href="{{ url_for('projects.export_projects', status=status, client=request.args.get('client', ''), search=request.args.get('search', ''), favorites=request.args.get('favorites', '')) }}" class="px-3 py-1.5 text-sm bg-background-light dark:bg-background-dark rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors inline-flex items-center" title="Export to CSV">
<i class="fas fa-download mr-1"></i> Export
</button>
</a>
{% if current_user.is_admin %}
<div class="relative">
<button type="button" id="bulkActionsBtn" class="px-3 py-1.5 text-sm border rounded-lg text-gray-700 dark:text-gray-200 border-gray-300 dark:border-gray-600 disabled:opacity-60" onclick="openMenu(this, 'projectsBulkMenu')" disabled>
+97 -2
View File
@@ -13,9 +13,19 @@
<p class="text-text-muted-light dark:text-text-muted-dark">{{ project.client.name }}</p>
</div>
{% if current_user.is_admin or has_any_permission(['edit_projects', 'archive_projects']) %}
<div class="flex gap-2">
<div class="flex gap-2 flex-wrap">
<a href="{{ url_for('projects.project_dashboard', project_id=project.id) }}" class="bg-purple-600 hover:bg-purple-700 text-white px-4 py-2 rounded-lg mt-4 md:mt-0 flex items-center gap-2 transition">
<i class="fas fa-chart-line"></i>
{{ _('Dashboard') }}
</a>
{% if project.budget_amount %}
<a href="{{ url_for('budget_alerts.project_budget_detail', project_id=project.id) }}" class="bg-gradient-to-r from-green-600 to-emerald-600 hover:from-green-700 hover:to-emerald-700 text-white px-4 py-2 rounded-lg mt-4 md:mt-0 flex items-center gap-2 transition shadow-lg">
<i class="fas fa-wallet"></i>
{{ _('Budget Analysis') }}
</a>
{% endif %}
{% if current_user.is_admin or has_permission('edit_projects') %}
<a href="{{ url_for('projects.edit_project', project_id=project.id) }}" class="bg-primary text-white px-4 py-2 rounded-lg mt-4 md:mt-0">{{ _('Edit Project') }}</a>
<a href="{{ url_for('projects.edit_project', project_id=project.id) }}" class="bg-primary text-white px-4 py-2 rounded-lg mt-4 md:mt-0 transition">{{ _('Edit Project') }}</a>
{% endif %}
{% if current_user.is_admin or has_permission('edit_projects') %}
{% if project.status == 'active' %}
@@ -110,6 +120,91 @@
{% endif %}
</div>
</div>
<!-- Budget Overview Card -->
{% if project.budget_amount %}
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow animated-card">
<div class="flex items-center justify-between mb-4">
<h2 class="text-lg font-semibold flex items-center">
<i class="fas fa-wallet mr-2 text-green-500"></i>
{{ _('Budget Overview') }}
</h2>
<a href="{{ url_for('budget_alerts.project_budget_detail', project_id=project.id) }}"
class="text-primary hover:text-primary-dark text-sm flex items-center gap-1 transition">
{{ _('Details') }} <i class="fas fa-arrow-right text-xs"></i>
</a>
</div>
{% set consumed_amount = project.budget_consumed_amount|float if project.budget_consumed_amount else 0.0 %}
{% set budget_amt = project.budget_amount|float %}
{% set remaining = budget_amt - consumed_amount %}
{% set percentage = (consumed_amount / budget_amt * 100) if budget_amt > 0 else 0 %}
{% set threshold = project.budget_threshold_percent or 80 %}
<!-- Budget Progress Bar -->
<div class="mb-4">
<div class="flex justify-between text-sm mb-2">
<span class="text-text-muted-light dark:text-text-muted-dark">{{ _('Budget Used') }}</span>
<span class="font-semibold">{{ "%.1f"|format(percentage) }}%</span>
</div>
<div class="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-3 overflow-hidden">
<div class="h-full {% if percentage >= 100 %}bg-gradient-to-r from-red-500 to-pink-600{% elif percentage >= threshold %}bg-gradient-to-r from-yellow-400 to-orange-500{% else %}bg-gradient-to-r from-green-500 to-emerald-600{% endif %} transition-all duration-300 rounded-full"
style="width: {{ [percentage, 100]|min }}%">
</div>
</div>
</div>
<!-- Budget Stats Grid -->
<div class="grid grid-cols-2 gap-3">
<div class="bg-blue-50 dark:bg-blue-900/20 p-3 rounded-lg">
<div class="text-xs text-text-muted-light dark:text-text-muted-dark mb-1">{{ _('Total Budget') }}</div>
<div class="text-lg font-bold text-blue-600 dark:text-blue-400">${{ "%.2f"|format(budget_amt) }}</div>
</div>
<div class="bg-amber-50 dark:bg-amber-900/20 p-3 rounded-lg">
<div class="text-xs text-text-muted-light dark:text-text-muted-dark mb-1">{{ _('Consumed') }}</div>
<div class="text-lg font-bold text-amber-600 dark:text-amber-400">${{ "%.2f"|format(consumed_amount) }}</div>
</div>
<div class="{% if remaining >= 0 %}bg-green-50 dark:bg-green-900/20{% else %}bg-red-50 dark:bg-red-900/20{% endif %} p-3 rounded-lg">
<div class="text-xs text-text-muted-light dark:text-text-muted-dark mb-1">{{ _('Remaining') }}</div>
<div class="text-lg font-bold {% if remaining >= 0 %}text-green-600 dark:text-green-400{% else %}text-red-600 dark:text-red-400{% endif %}">
${{ "%.2f"|format(remaining|abs) }}
{% if remaining < 0 %}<span class="text-xs">{{ _('over') }}</span>{% endif %}
</div>
</div>
<div class="bg-purple-50 dark:bg-purple-900/20 p-3 rounded-lg">
<div class="text-xs text-text-muted-light dark:text-text-muted-dark mb-1">{{ _('Status') }}</div>
<div class="flex items-center gap-1">
{% if percentage >= 100 %}
<span class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-red-100 text-red-800 dark:bg-red-900/50 dark:text-red-300">
<i class="fas fa-exclamation-circle mr-1"></i>{{ _('Over') }}
</span>
{% elif percentage >= threshold %}
<span class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-yellow-100 text-yellow-800 dark:bg-yellow-900/50 dark:text-yellow-300">
<i class="fas fa-exclamation-triangle mr-1"></i>{{ _('Critical') }}
</span>
{% elif percentage >= (threshold * 0.8) %}
<span class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-amber-100 text-amber-800 dark:bg-amber-900/50 dark:text-amber-300">
<i class="fas fa-info-circle mr-1"></i>{{ _('Warning') }}
</span>
{% else %}
<span class="inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900/50 dark:text-green-300">
<i class="fas fa-check-circle mr-1"></i>{{ _('Healthy') }}
</span>
{% endif %}
</div>
</div>
</div>
<!-- Quick Action Button -->
<div class="mt-4 pt-4 border-t border-border-light dark:border-border-dark">
<a href="{{ url_for('budget_alerts.project_budget_detail', project_id=project.id) }}"
class="block w-full text-center bg-gradient-to-r from-green-600 to-emerald-600 hover:from-green-700 hover:to-emerald-700 text-white px-4 py-2 rounded-lg transition shadow-md hover:shadow-lg">
<i class="fas fa-chart-line mr-2"></i>{{ _('View Full Budget Analysis') }}
</a>
</div>
</div>
{% endif %}
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow">
<h2 class="text-lg font-semibold mb-4">User Contributions</h2>
<ul>
+15 -6
View File
@@ -1,10 +1,19 @@
{% extends "base.html" %}
{% from "components/cards.html" import info_card %}
{% from "components/ui.html" import page_header, breadcrumb_nav, button, filter_badge %}
{% block content %}
<div class="flex justify-between items-center mb-6">
<h1 class="text-2xl font-bold">Reports</h1>
</div>
{% set breadcrumbs = [
{'text': 'Reports'}
] %}
{{ page_header(
icon_class='fas fa-chart-bar',
title_text='Reports',
subtitle_text='View comprehensive reports and analytics for your time tracking data',
breadcrumbs=breadcrumbs,
actions_html=None
) }}
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6 mb-6">
{{ info_card("Total Hours", "%.2f"|format(summary.total_hours), "All time") }}
@@ -18,7 +27,7 @@
<div class="flex items-center justify-between mb-2">
<i class="fas fa-money-bill-wave text-green-600 text-xl"></i>
</div>
<div class="text-2xl font-semibold text-green-600">{{ "%.2f"|format(summary.total_payments) }}</div>
<div class="text-2xl font-semibold text-green-600">{{ currency|currency_symbol }}{{ "%.2f"|format(summary.total_payments) }}</div>
<div class="text-xs text-text-muted-light dark:text-text-muted-dark">Total Payments</div>
<div class="text-[11px] text-text-muted-light dark:text-text-muted-dark">Last 30 days</div>
</div>
@@ -34,7 +43,7 @@
<div class="flex items-center justify-between mb-2">
<i class="fas fa-credit-card text-amber-600 text-xl"></i>
</div>
<div class="text-2xl font-semibold text-amber-600">{{ "%.2f"|format(summary.payment_fees) }}</div>
<div class="text-2xl font-semibold text-amber-600">{{ currency|currency_symbol }}{{ "%.2f"|format(summary.payment_fees) }}</div>
<div class="text-xs text-text-muted-light dark:text-text-muted-dark">Gateway Fees</div>
<div class="text-[11px] text-text-muted-light dark:text-text-muted-dark">Last 30 days</div>
</div>
@@ -42,7 +51,7 @@
<div class="flex items-center justify-between mb-2">
<i class="fas fa-chart-line text-emerald-600 text-xl"></i>
</div>
<div class="text-2xl font-semibold text-emerald-600">{{ "%.2f"|format(summary.total_payments - summary.payment_fees) }}</div>
<div class="text-2xl font-semibold text-emerald-600">{{ currency|currency_symbol }}{{ "%.2f"|format(summary.total_payments - summary.payment_fees) }}</div>
<div class="text-xs text-text-muted-light dark:text-text-muted-dark">Net Received</div>
<div class="text-[11px] text-text-muted-light dark:text-text-muted-dark">After fees</div>
</div>
+15 -11
View File
@@ -1,16 +1,20 @@
{% extends "base.html" %}
{% from "components/ui.html" import page_header, breadcrumb_nav, button, filter_badge %}
{% block title %}Saved Filters - {{ config.APP_NAME }}{% endblock %}
{% block content %}
<div class="container mx-auto px-4 py-8">
<!-- Header -->
<div class="flex justify-between items-center mb-6">
<div>
<h1 class="text-3xl font-bold text-gray-900 dark:text-white">Saved Filters</h1>
<p class="text-gray-600 dark:text-gray-400 mt-2">Quick access to your commonly used filters</p>
</div>
</div>
{% set breadcrumbs = [
{'text': 'Saved Filters'}
] %}
{{ page_header(
icon_class='fas fa-filter',
title_text='Saved Filters',
subtitle_text='Quick access to your commonly used filters',
breadcrumbs=breadcrumbs,
actions_html=None
) }}
{% if filters %}
<!-- Grouped Filters -->
@@ -95,7 +99,7 @@
<p class="text-gray-600 dark:text-gray-400 mb-6">
Save filters from Reports or Tasks pages for quick access
</p>
<a href="{{ url_for('reports.index') }}"
<a href="{{ url_for('reports.reports') }}"
class="btn btn-primary">
<i class="fas fa-chart-bar mr-2"></i> Go to Reports
</a>
@@ -114,7 +118,7 @@ function applyFilter(filterId, scope) {
switch(scope) {
case 'reports':
targetUrl = '{{ url_for("reports.index") }}';
targetUrl = '{{ url_for("reports.reports") }}';
break;
case 'tasks':
targetUrl = '{{ url_for("tasks.list_tasks") }}';
@@ -123,7 +127,7 @@ function applyFilter(filterId, scope) {
targetUrl = '{{ url_for("projects.list_projects") }}';
break;
case 'time_entries':
targetUrl = '{{ url_for("timer.timer") }}';
targetUrl = '{{ url_for("timer.manual_entry") }}';
break;
default:
targetUrl = '/';
+250 -39
View File
@@ -90,20 +90,24 @@
</div>
</div>
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow overflow-x-auto">
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow overflow-visible">
<div class="flex justify-between items-center mb-4">
<h3 class="text-sm font-medium text-text-muted-light dark:text-text-muted-dark">
{{ tasks|length }} task{{ 's' if tasks|length != 1 else '' }} found
</h3>
<div class="flex items-center gap-2">
<button type="button" class="px-3 py-1.5 text-sm bg-background-light dark:bg-background-dark rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors" title="Export to CSV">
<a href="{{ url_for('tasks.export_tasks', status=status, priority=priority, project_id=project_id, assigned_to=assigned_to, search=search, overdue=overdue) }}" class="px-3 py-1.5 text-sm bg-background-light dark:bg-background-dark rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 transition-colors inline-flex items-center" title="Export to CSV">
<i class="fas fa-download mr-1"></i> Export
</button>
</a>
<div class="relative">
<button type="button" id="bulkActionsBtn" class="px-3 py-1.5 text-sm border rounded-lg text-gray-700 dark:text-gray-200 border-gray-300 dark:border-gray-600 disabled:opacity-60" onclick="openMenu(this, 'tasksBulkMenu')" disabled>
<i class="fas fa-tasks mr-1"></i> Bulk Actions (<span id="selectedCount">0</span>)
</button>
<ul id="tasksBulkMenu" class="bulk-menu hidden absolute right-0 mt-2 w-48 bg-card-light dark:bg-card-dark border border-border-light dark:border-border-dark rounded-md shadow-lg">
<ul id="tasksBulkMenu" class="bulk-menu hidden absolute right-0 mt-2 w-56 bg-card-light dark:bg-card-dark border border-border-light dark:border-border-dark rounded-md shadow-lg z-50">
<li><a class="block px-4 py-2 text-sm hover:bg-gray-100 dark:hover:bg-gray-700" href="#" onclick="return showBulkStatusDialog()"><i class="fas fa-tasks mr-2"></i>Change Status</a></li>
<li><a class="block px-4 py-2 text-sm hover:bg-gray-100 dark:hover:bg-gray-700" href="#" onclick="return showBulkAssignDialog()"><i class="fas fa-user mr-2"></i>Assign To</a></li>
<li><a class="block px-4 py-2 text-sm hover:bg-gray-100 dark:hover:bg-gray-700" href="#" onclick="return showBulkProjectDialog()"><i class="fas fa-folder mr-2"></i>Move to Project</a></li>
<li><hr class="my-1 border-border-light dark:border-border-dark"></li>
<li><a class="block px-4 py-2 text-sm text-rose-600 hover:bg-gray-100 dark:hover:bg-gray-700" href="#" onclick="return showBulkDeleteConfirm()"><i class="fas fa-trash mr-2"></i>Delete</a></li>
</ul>
</div>
@@ -187,20 +191,121 @@
</table>
</div>
<!-- Bulk Delete Form (hidden) -->
<!-- Bulk Operations Forms (hidden) -->
<form id="confirmBulkDelete-form" method="POST" action="{{ url_for('tasks.bulk_delete_tasks') }}" class="hidden">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
</form>
<form id="bulkStatusForm" method="POST" action="{{ url_for('tasks.bulk_update_status') }}" class="hidden">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="status" id="bulkStatusValue">
</form>
<form id="bulkAssignForm" method="POST" action="{{ url_for('tasks.bulk_assign_tasks') }}" class="hidden">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="assigned_to" id="bulkAssignValue">
</form>
<form id="bulkProjectForm" method="POST" action="{{ url_for('tasks.bulk_move_project') }}" class="hidden">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="project_id" id="bulkProjectValue">
</form>
<!-- Bulk Delete Confirmation Dialog -->
{{ confirm_dialog(
'confirmBulkDelete',
'Delete Selected Tasks',
'Are you sure you want to delete the selected tasks? This action cannot be undone. Tasks with existing time entries will be skipped.',
'Delete',
'Cancel',
'danger'
) }}
<div id="confirmBulkDelete" class="fixed inset-0 z-50 hidden overflow-y-auto" role="dialog" aria-modal="true">
<div class="flex items-center justify-center min-h-screen px-4">
<div class="fixed inset-0 transition-opacity bg-gray-500 bg-opacity-75 dark:bg-gray-900 dark:bg-opacity-75" onclick="closeBulkDeleteDialog()"></div>
<div class="relative bg-card-light dark:bg-card-dark rounded-lg shadow-xl max-w-md w-full p-6 zoom-in">
<div class="flex items-start mb-4">
<div class="flex-shrink-0">
<div class="w-12 h-12 rounded-full bg-red-100 dark:bg-red-900/30 flex items-center justify-center">
<i class="fas fa-exclamation-triangle text-red-600 dark:text-red-400 text-xl"></i>
</div>
</div>
<div class="ml-4 flex-1">
<h3 class="text-lg font-semibold text-text-light dark:text-text-dark mb-2">Delete Selected Tasks</h3>
<p class="text-sm text-text-muted-light dark:text-text-muted-dark">Are you sure you want to delete the selected tasks? This action cannot be undone. Tasks with existing time entries will be skipped.</p>
</div>
</div>
<div class="flex justify-end gap-3 mt-6">
<button type="button" class="px-4 py-2 bg-gray-200 dark:bg-gray-700 text-gray-800 dark:text-gray-200 rounded-lg hover:bg-gray-300 dark:hover:bg-gray-600 transition-colors" onclick="closeBulkDeleteDialog()">
Cancel
</button>
<button type="button" class="px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 transition-colors" onclick="submitBulkDelete()">
Delete
</button>
</div>
</div>
</div>
</div>
<!-- Bulk Status Change Dialog -->
<div id="bulkStatusDialog" class="hidden fixed inset-0 z-50 flex items-center justify-center bg-black/50">
<div class="bg-card-light dark:bg-card-dark rounded-lg shadow-xl max-w-md w-full mx-4">
<div class="p-6">
<h3 class="text-lg font-semibold mb-4">Change Status for Selected Tasks</h3>
<label for="bulkStatusSelect" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Select Status</label>
<select id="bulkStatusSelect" class="form-input w-full mb-4">
<option value="">-- Select Status --</option>
{% if kanban_columns %}
{% for column in kanban_columns %}
<option value="{{ column.status_key }}">{{ column.name }}</option>
{% endfor %}
{% else %}
<option value="todo">To Do</option>
<option value="in_progress">In Progress</option>
<option value="review">Review</option>
<option value="done">Done</option>
<option value="cancelled">Cancelled</option>
{% endif %}
</select>
<div class="flex justify-end gap-2">
<button type="button" onclick="closeBulkStatusDialog()" class="px-4 py-2 text-sm bg-gray-200 dark:bg-gray-700 text-gray-800 dark:text-gray-200 rounded-lg hover:bg-gray-300 dark:hover:bg-gray-600">Cancel</button>
<button type="button" onclick="submitBulkStatus()" class="px-4 py-2 text-sm bg-primary text-white rounded-lg hover:bg-primary/90">Update Status</button>
</div>
</div>
</div>
</div>
<!-- Bulk Assign Dialog -->
<div id="bulkAssignDialog" class="hidden fixed inset-0 z-50 flex items-center justify-center bg-black/50">
<div class="bg-card-light dark:bg-card-dark rounded-lg shadow-xl max-w-md w-full mx-4">
<div class="p-6">
<h3 class="text-lg font-semibold mb-4">Assign Selected Tasks</h3>
<label for="bulkAssignSelect" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Select User</label>
<select id="bulkAssignSelect" class="form-input w-full mb-4">
<option value="">-- Select User --</option>
{% for user in users %}
<option value="{{ user.id }}">{{ user.display_name }}</option>
{% endfor %}
</select>
<div class="flex justify-end gap-2">
<button type="button" onclick="closeBulkAssignDialog()" class="px-4 py-2 text-sm bg-gray-200 dark:bg-gray-700 text-gray-800 dark:text-gray-200 rounded-lg hover:bg-gray-300 dark:hover:bg-gray-600">Cancel</button>
<button type="button" onclick="submitBulkAssign()" class="px-4 py-2 text-sm bg-primary text-white rounded-lg hover:bg-primary/90">Assign Tasks</button>
</div>
</div>
</div>
</div>
<!-- Bulk Move to Project Dialog -->
<div id="bulkProjectDialog" class="hidden fixed inset-0 z-50 flex items-center justify-center bg-black/50">
<div class="bg-card-light dark:bg-card-dark rounded-lg shadow-xl max-w-md w-full mx-4">
<div class="p-6">
<h3 class="text-lg font-semibold mb-4">Move Selected Tasks to Project</h3>
<label for="bulkProjectSelect" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Select Project</label>
<select id="bulkProjectSelect" class="form-input w-full mb-4">
<option value="">-- Select Project --</option>
{% for project in projects %}
<option value="{{ project.id }}">{{ project.name }}</option>
{% endfor %}
</select>
<div class="flex justify-end gap-2">
<button type="button" onclick="closeBulkProjectDialog()" class="px-4 py-2 text-sm bg-gray-200 dark:bg-gray-700 text-gray-800 dark:text-gray-200 rounded-lg hover:bg-gray-300 dark:hover:bg-gray-600">Cancel</button>
<button type="button" onclick="submitBulkProject()" class="px-4 py-2 text-sm bg-primary text-white rounded-lg hover:bg-primary/90">Move Tasks</button>
</div>
</div>
</div>
</div>
{% endblock %}
@@ -249,6 +354,138 @@ function showBulkDeleteConfirm() {
return false;
}
function closeBulkDeleteDialog() {
document.getElementById('confirmBulkDelete').classList.add('hidden');
}
function submitBulkDelete() {
const form = document.getElementById('confirmBulkDelete-form');
// Clear existing hidden inputs
form.querySelectorAll('input[name="task_ids[]"]').forEach(input => input.remove());
// Add selected task IDs to form
const checkboxes = document.querySelectorAll('.task-checkbox:checked');
checkboxes.forEach(cb => {
const input = document.createElement('input');
input.type = 'hidden';
input.name = 'task_ids[]';
input.value = cb.value;
form.appendChild(input);
});
// Submit the form
form.submit();
}
// Bulk status change functions
function showBulkStatusDialog() {
document.getElementById('bulkStatusDialog').classList.remove('hidden');
return false;
}
function closeBulkStatusDialog() {
document.getElementById('bulkStatusDialog').classList.add('hidden');
}
function submitBulkStatus() {
const status = document.getElementById('bulkStatusSelect').value;
if (!status) {
alert('Please select a status');
return;
}
const form = document.getElementById('bulkStatusForm');
document.getElementById('bulkStatusValue').value = status;
// Clear existing hidden inputs
form.querySelectorAll('input[name="task_ids[]"]').forEach(input => input.remove());
// Add selected task IDs to form
const checkboxes = document.querySelectorAll('.task-checkbox:checked');
checkboxes.forEach(cb => {
const input = document.createElement('input');
input.type = 'hidden';
input.name = 'task_ids[]';
input.value = cb.value;
form.appendChild(input);
});
form.submit();
}
// Bulk assign functions
function showBulkAssignDialog() {
document.getElementById('bulkAssignDialog').classList.remove('hidden');
return false;
}
function closeBulkAssignDialog() {
document.getElementById('bulkAssignDialog').classList.add('hidden');
}
function submitBulkAssign() {
const assignedTo = document.getElementById('bulkAssignSelect').value;
if (!assignedTo) {
alert('Please select a user');
return;
}
const form = document.getElementById('bulkAssignForm');
document.getElementById('bulkAssignValue').value = assignedTo;
// Clear existing hidden inputs
form.querySelectorAll('input[name="task_ids[]"]').forEach(input => input.remove());
// Add selected task IDs to form
const checkboxes = document.querySelectorAll('.task-checkbox:checked');
checkboxes.forEach(cb => {
const input = document.createElement('input');
input.type = 'hidden';
input.name = 'task_ids[]';
input.value = cb.value;
form.appendChild(input);
});
form.submit();
}
// Bulk move to project functions
function showBulkProjectDialog() {
document.getElementById('bulkProjectDialog').classList.remove('hidden');
return false;
}
function closeBulkProjectDialog() {
document.getElementById('bulkProjectDialog').classList.add('hidden');
}
function submitBulkProject() {
const projectId = document.getElementById('bulkProjectSelect').value;
if (!projectId) {
alert('Please select a project');
return;
}
const form = document.getElementById('bulkProjectForm');
document.getElementById('bulkProjectValue').value = projectId;
// Clear existing hidden inputs
form.querySelectorAll('input[name="task_ids[]"]').forEach(input => input.remove());
// Add selected task IDs to form
const checkboxes = document.querySelectorAll('.task-checkbox:checked');
checkboxes.forEach(cb => {
const input = document.createElement('input');
input.type = 'hidden';
input.name = 'task_ids[]';
input.value = cb.value;
form.appendChild(input);
});
form.submit();
}
// Delete task confirmation (single)
function confirmDeleteTask(taskId, taskName, hasTimeEntries) {
if (hasTimeEntries) {
@@ -330,32 +567,6 @@ document.addEventListener('DOMContentLoaded', function() {
toggleButton.title = '{{ _('Hide Filters') }}';
}
setTimeout(() => { filterBody.classList.add('filter-toggle-transition'); }, 100);
// Handle bulk delete confirmation
const form = document.getElementById('confirmBulkDelete-form');
if (form) {
form.addEventListener('submit', function(e) {
// Prevent default to add task IDs first
e.preventDefault();
const checkboxes = document.querySelectorAll('.task-checkbox:checked');
// Clear existing hidden inputs
form.querySelectorAll('input[name="task_ids[]"]').forEach(input => input.remove());
// Add selected task IDs to form
checkboxes.forEach(cb => {
const input = document.createElement('input');
input.type = 'hidden';
input.name = 'task_ids[]';
input.value = cb.value;
form.appendChild(input);
});
// Now submit the form
form.submit();
});
}
});
</script>
{% endblock %}
+3
View File
@@ -77,6 +77,9 @@
<td class="p-4">{% if entry.notes %}<span title="{{ entry.notes }}">{{ entry.notes[:40] }}{% if entry.notes|length > 40 %}...{% endif %}</span>{% else %}-{% endif %}</td>
<td class="p-4">
<div class="flex gap-2">
<a href="{{ url_for('timer.resume_timer', timer_id=entry.id) }}" class="text-green-600 hover:text-green-800" title="{{ _('Resume - Start a new timer with same properties') }}">
<i class="fas fa-play"></i>
</a>
<a href="{{ url_for('timer.edit_timer', timer_id=entry.id) }}" class="text-primary hover:text-primary-dark" title="{{ _('Edit entry') }}">
<i class="fas fa-edit"></i>
</a>
+12 -12
View File
@@ -1,20 +1,20 @@
{% extends "base.html" %}
{% from "components/ui.html" import page_header, breadcrumb_nav, button, filter_badge %}
{% block title %}Time Entry Templates - {{ config.APP_NAME }}{% endblock %}
{% block content %}
<div class="container mx-auto px-4 py-8">
<!-- Header -->
<div class="flex justify-between items-center mb-6">
<div>
<h1 class="text-3xl font-bold text-gray-900 dark:text-white">Time Entry Templates</h1>
<p class="text-gray-600 dark:text-gray-400 mt-2">Create reusable templates for quick time entries</p>
</div>
<a href="{{ url_for('time_entry_templates.create_template') }}"
class="btn btn-primary">
<i class="fas fa-plus mr-2"></i> New Template
</a>
</div>
{% set breadcrumbs = [
{'text': 'Time Entry Templates'}
] %}
{{ page_header(
icon_class='fas fa-file-lines',
title_text='Time Entry Templates',
subtitle_text='Create reusable templates for quick time entries',
breadcrumbs=breadcrumbs,
actions_html='<a href="' + url_for("time_entry_templates.create_template") + '" class="bg-primary text-white px-4 py-2 rounded-lg hover:bg-primary/90 transition-colors"><i class="fas fa-plus mr-2"></i>New Template</a>'
) }}
{% if templates %}
<!-- Templates Grid -->
+44 -33
View File
@@ -1,12 +1,19 @@
{% extends "base.html" %}
{% from "components/ui.html" import page_header, breadcrumb_nav, button, filter_badge %}
{% block content %}
<div class="flex flex-col md:flex-row justify-between items-start md:items-center mb-6">
<div>
<h1 class="text-2xl font-bold">{% if is_duplicate %}Duplicate Time Entry{% else %}Log Time Manually{% endif %}</h1>
<p class="text-text-muted-light dark:text-text-muted-dark">{% if is_duplicate %}Create a copy of a previous entry with new times.{% else %}Create a new time entry.{% endif %}</p>
</div>
</div>
{% set breadcrumbs = [
{'text': 'Time Tracking'},
{'text': 'Log Time' if not is_duplicate else 'Duplicate Entry'}
] %}
{{ page_header(
icon_class='fas fa-clock',
title_text='Duplicate Time Entry' if is_duplicate else 'Log Time Manually',
subtitle_text='Create a copy of a previous entry with new times' if is_duplicate else 'Create a new time entry',
breadcrumbs=breadcrumbs,
actions_html=None
) }}
{% if is_duplicate and original_entry %}
<div class="bg-blue-50 dark:bg-blue-900/20 border border-blue-200 dark:border-blue-800 rounded-lg p-4 mb-6 max-w-3xl mx-auto">
@@ -149,32 +156,35 @@ document.addEventListener('DOMContentLoaded', async function(){
}
// Apply Time Entry Template if provided via sessionStorage or query param
try {
let tpl = null;
const raw = sessionStorage.getItem('activeTemplate');
if (raw) {
try { tpl = JSON.parse(raw); } catch(_) { tpl = null; }
}
if (!tpl) {
const params = new URLSearchParams(window.location.search);
const tplId = params.get('template');
if (tplId) {
try {
const resp = await fetch(`/api/templates/${tplId}`);
if (resp.ok) tpl = await resp.json();
} catch(_) {}
// Skip template application when duplicating an entry to preserve the original entry's task
const isDuplicating = {{ 'true' if is_duplicate else 'false' }};
if (!isDuplicating) {
try {
let tpl = null;
const raw = sessionStorage.getItem('activeTemplate');
if (raw) {
try { tpl = JSON.parse(raw); } catch(_) { tpl = null; }
}
}
if (tpl && typeof tpl === 'object') {
// Preselect project and task
if (tpl.project_id && projectSelect) {
projectSelect.value = String(tpl.project_id);
// Preselect task after load
if (taskSelect) {
taskSelect.setAttribute('data-selected-task-id', tpl.task_id ? String(tpl.task_id) : '');
if (!tpl) {
const params = new URLSearchParams(window.location.search);
const tplId = params.get('template');
if (tplId) {
try {
const resp = await fetch(`/api/templates/${tplId}`);
if (resp.ok) tpl = await resp.json();
} catch(_) {}
}
await loadTasks(projectSelect.value);
}
if (tpl && typeof tpl === 'object') {
// Preselect project and task
if (tpl.project_id && projectSelect) {
projectSelect.value = String(tpl.project_id);
// Preselect task after load
if (taskSelect) {
taskSelect.setAttribute('data-selected-task-id', tpl.task_id ? String(tpl.task_id) : '');
}
await loadTasks(projectSelect.value);
}
// Notes, tags, billable
const notes = document.getElementById('notes');
@@ -210,10 +220,11 @@ document.addEventListener('DOMContentLoaded', async function(){
}
}
// Clear after applying so it does not persist
try { sessionStorage.removeItem('activeTemplate'); } catch(_) {}
}
} catch(_) {}
// Clear after applying so it does not persist
try { sessionStorage.removeItem('activeTemplate'); } catch(_) {}
}
} catch(_) {}
}
});
</script>
{% endblock %}
+12 -16
View File
@@ -1,24 +1,20 @@
{% extends "base.html" %}
{% from "components/ui.html" import page_header, breadcrumb_nav, button, filter_badge %}
{% block title %}{{ _('Weekly Time Goals') }} - {{ config.APP_NAME }}{% endblock %}
{% block content %}
<div class="container mx-auto px-4 py-8">
<!-- Header -->
<div class="flex justify-between items-center mb-6">
<div>
<h1 class="text-3xl font-bold text-gray-900 dark:text-white">
<i class="fas fa-bullseye mr-2 text-blue-600"></i>
{{ _('Weekly Time Goals') }}
</h1>
<p class="text-gray-600 dark:text-gray-400 mt-2">
{{ _('Set and track your weekly hour targets') }}
</p>
</div>
<a href="{{ url_for('weekly_goals.create') }}" class="btn btn-primary">
<i class="fas fa-plus mr-2"></i> {{ _('New Goal') }}
</a>
</div>
{% set breadcrumbs = [
{'text': _('Weekly Goals')}
] %}
{{ page_header(
icon_class='fas fa-bullseye',
title_text=_('Weekly Time Goals'),
subtitle_text=_('Set and track your weekly hour targets'),
breadcrumbs=breadcrumbs,
actions_html='<a href="' + url_for("weekly_goals.create") + '" class="bg-primary text-white px-4 py-2 rounded-lg hover:bg-primary/90 transition-colors"><i class="fas fa-plus mr-2"></i>' + _('New Goal') + '</a>'
) }}
<!-- Statistics -->
<div class="grid grid-cols-1 md:grid-cols-4 gap-4 mb-6">
+537
View File
@@ -0,0 +1,537 @@
"""
Budget Forecasting Utility
This module provides functions for calculating burn rates, forecasting completion dates,
analyzing resource allocation, and performing cost trend analysis for projects.
"""
from datetime import datetime, timedelta, date
from decimal import Decimal
from typing import Dict, List, Optional, Tuple
from sqlalchemy import func
from app import db
from app.models import Project, TimeEntry, ProjectCost, User
from collections import defaultdict
import statistics
def calculate_burn_rate(project_id: int, days: int = 30) -> Dict:
"""
Calculate the burn rate for a project based on recent activity.
Args:
project_id: ID of the project
days: Number of days to analyze (default: 30)
Returns:
Dictionary with burn rate metrics:
- daily_burn_rate: Average daily cost
- weekly_burn_rate: Average weekly cost
- monthly_burn_rate: Average monthly cost
- period_total: Total consumed in the period
- period_days: Number of days in the period
"""
project = Project.query.get(project_id)
if not project:
return None
end_date = datetime.now().date()
start_date = end_date - timedelta(days=days)
# Calculate time-based costs
time_entries = TimeEntry.query.filter(
TimeEntry.project_id == project_id,
TimeEntry.end_time.isnot(None),
TimeEntry.billable == True,
func.date(TimeEntry.start_time) >= start_date,
func.date(TimeEntry.start_time) <= end_date
).all()
time_cost = Decimal('0')
hourly_rate = project.hourly_rate or Decimal('0')
for entry in time_entries:
hours = Decimal(str(entry.duration_seconds / 3600))
time_cost += hours * hourly_rate
# Calculate direct costs
direct_costs = ProjectCost.get_total_costs(
project_id,
start_date=start_date,
end_date=end_date,
billable_only=True
)
total_cost = float(time_cost) + direct_costs
# Calculate rates
daily_burn_rate = total_cost / days if days > 0 else 0
weekly_burn_rate = daily_burn_rate * 7
monthly_burn_rate = daily_burn_rate * 30
return {
'daily_burn_rate': round(daily_burn_rate, 2),
'weekly_burn_rate': round(weekly_burn_rate, 2),
'monthly_burn_rate': round(monthly_burn_rate, 2),
'period_total': round(total_cost, 2),
'period_days': days,
'start_date': start_date.isoformat(),
'end_date': end_date.isoformat()
}
def estimate_completion_date(project_id: int, analysis_days: int = 30) -> Dict:
"""
Estimate project completion date based on burn rate and remaining budget.
Args:
project_id: ID of the project
analysis_days: Number of days to analyze for burn rate (default: 30)
Returns:
Dictionary with completion estimates:
- estimated_completion_date: Estimated date when budget will be exhausted
- days_remaining: Number of days until budget exhaustion
- budget_amount: Total project budget
- consumed_amount: Amount consumed so far
- remaining_budget: Amount remaining
- daily_burn_rate: Current daily burn rate
- confidence: Confidence level ('high', 'medium', 'low')
"""
project = Project.query.get(project_id)
if not project or not project.budget_amount:
return None
burn_rate = calculate_burn_rate(project_id, analysis_days)
if not burn_rate or burn_rate['daily_burn_rate'] == 0:
return {
'estimated_completion_date': None,
'days_remaining': None,
'budget_amount': float(project.budget_amount),
'consumed_amount': project.budget_consumed_amount,
'remaining_budget': float(project.budget_amount) - project.budget_consumed_amount,
'daily_burn_rate': 0,
'confidence': 'low',
'message': 'No recent activity to estimate completion date'
}
budget_amount = float(project.budget_amount)
consumed_amount = project.budget_consumed_amount
remaining_budget = budget_amount - consumed_amount
daily_burn = burn_rate['daily_burn_rate']
if remaining_budget <= 0:
return {
'estimated_completion_date': datetime.now().date().isoformat(),
'days_remaining': 0,
'budget_amount': budget_amount,
'consumed_amount': consumed_amount,
'remaining_budget': remaining_budget,
'daily_burn_rate': daily_burn,
'confidence': 'high',
'message': 'Budget already exhausted'
}
days_remaining = int(remaining_budget / daily_burn) if daily_burn > 0 else 999999
estimated_date = datetime.now().date() + timedelta(days=days_remaining)
# Calculate confidence based on data consistency
confidence = _calculate_confidence(project_id, analysis_days)
return {
'estimated_completion_date': estimated_date.isoformat(),
'days_remaining': days_remaining,
'budget_amount': budget_amount,
'consumed_amount': round(consumed_amount, 2),
'remaining_budget': round(remaining_budget, 2),
'daily_burn_rate': daily_burn,
'confidence': confidence,
'message': f'Based on {analysis_days} days of activity'
}
def analyze_resource_allocation(project_id: int, days: int = 30) -> Dict:
"""
Analyze resource allocation and costs per team member.
Args:
project_id: ID of the project
days: Number of days to analyze (default: 30)
Returns:
Dictionary with resource allocation data:
- users: List of users with their hours and costs
- total_hours: Total hours across all users
- total_cost: Total cost across all users
- cost_distribution: Percentage breakdown by user
"""
project = Project.query.get(project_id)
if not project:
return None
end_date = datetime.now().date()
start_date = end_date - timedelta(days=days)
# Query time entries by user
user_data = db.session.query(
User.id,
User.username,
User.full_name,
func.sum(TimeEntry.duration_seconds).label('total_seconds'),
func.count(TimeEntry.id).label('entry_count')
).join(TimeEntry).filter(
TimeEntry.project_id == project_id,
TimeEntry.end_time.isnot(None),
TimeEntry.billable == True,
func.date(TimeEntry.start_time) >= start_date,
func.date(TimeEntry.start_time) <= end_date
).group_by(User.id, User.username, User.full_name).all()
users = []
total_hours = 0
total_cost = 0
hourly_rate = float(project.hourly_rate or 0)
for user_id, username, full_name, total_seconds, entry_count in user_data:
hours = total_seconds / 3600
cost = hours * hourly_rate
total_hours += hours
total_cost += cost
users.append({
'user_id': user_id,
'username': full_name if full_name else username,
'hours': round(hours, 2),
'cost': round(cost, 2),
'entry_count': entry_count,
'average_hours_per_entry': round(hours / entry_count, 2) if entry_count > 0 else 0
})
# Calculate cost distribution percentages
for user in users:
user['cost_percentage'] = round((user['cost'] / total_cost * 100), 1) if total_cost > 0 else 0
user['hours_percentage'] = round((user['hours'] / total_hours * 100), 1) if total_hours > 0 else 0
# Sort by cost (highest first)
users.sort(key=lambda x: x['cost'], reverse=True)
return {
'users': users,
'total_hours': round(total_hours, 2),
'total_cost': round(total_cost, 2),
'period_days': days,
'start_date': start_date.isoformat(),
'end_date': end_date.isoformat(),
'hourly_rate': hourly_rate
}
def analyze_cost_trends(project_id: int, days: int = 90, granularity: str = 'week') -> Dict:
"""
Analyze cost trends over time for a project.
Args:
project_id: ID of the project
days: Number of days to analyze (default: 90)
granularity: 'day', 'week', or 'month' (default: 'week')
Returns:
Dictionary with trend data:
- periods: List of time periods with costs
- trend_direction: 'increasing', 'decreasing', 'stable'
- average_cost_per_period: Average cost per period
- trend_percentage: Percentage change from first to last period
"""
project = Project.query.get(project_id)
if not project:
return None
end_date = datetime.now().date()
start_date = end_date - timedelta(days=days)
# Get all time entries
time_entries = TimeEntry.query.filter(
TimeEntry.project_id == project_id,
TimeEntry.end_time.isnot(None),
TimeEntry.billable == True,
func.date(TimeEntry.start_time) >= start_date,
func.date(TimeEntry.start_time) <= end_date
).all()
# Get all project costs
project_costs = ProjectCost.query.filter(
ProjectCost.project_id == project_id,
ProjectCost.billable == True,
ProjectCost.cost_date >= start_date,
ProjectCost.cost_date <= end_date
).all()
hourly_rate = float(project.hourly_rate or 0)
# Group by period
period_costs = defaultdict(float)
for entry in time_entries:
period_key = _get_period_key(entry.start_time.date(), granularity)
hours = entry.duration_seconds / 3600
cost = hours * hourly_rate
period_costs[period_key] += cost
for cost in project_costs:
period_key = _get_period_key(cost.cost_date, granularity)
period_costs[period_key] += float(cost.amount)
# Sort periods chronologically
sorted_periods = sorted(period_costs.items())
periods = [
{
'period': period,
'cost': round(cost, 2)
}
for period, cost in sorted_periods
]
# Calculate trend metrics
if len(periods) >= 2:
first_cost = periods[0]['cost']
last_cost = periods[-1]['cost']
if first_cost > 0:
trend_percentage = ((last_cost - first_cost) / first_cost) * 100
else:
trend_percentage = 0
# Determine trend direction
costs_list = [p['cost'] for p in periods]
avg_first_half = statistics.mean(costs_list[:len(costs_list)//2]) if len(costs_list) >= 2 else 0
avg_second_half = statistics.mean(costs_list[len(costs_list)//2:]) if len(costs_list) >= 2 else 0
if avg_second_half > avg_first_half * 1.1:
trend_direction = 'increasing'
elif avg_second_half < avg_first_half * 0.9:
trend_direction = 'decreasing'
else:
trend_direction = 'stable'
else:
trend_percentage = 0
trend_direction = 'insufficient_data'
average_cost = statistics.mean([p['cost'] for p in periods]) if periods else 0
return {
'periods': periods,
'trend_direction': trend_direction,
'average_cost_per_period': round(average_cost, 2),
'trend_percentage': round(trend_percentage, 1),
'granularity': granularity,
'period_count': len(periods),
'start_date': start_date.isoformat(),
'end_date': end_date.isoformat()
}
def get_budget_status(project_id: int) -> Dict:
"""
Get comprehensive budget status for a project.
Args:
project_id: ID of the project
Returns:
Dictionary with budget status:
- budget_amount: Total budget
- consumed_amount: Amount consumed
- remaining_amount: Amount remaining
- consumed_percentage: Percentage consumed
- status: 'healthy', 'warning', 'critical', 'over_budget'
- threshold_percent: Budget threshold setting
"""
project = Project.query.get(project_id)
if not project or not project.budget_amount:
return None
budget_amount = float(project.budget_amount)
consumed_amount = project.budget_consumed_amount
remaining_amount = budget_amount - consumed_amount
consumed_percentage = (consumed_amount / budget_amount * 100) if budget_amount > 0 else 0
threshold_percent = project.budget_threshold_percent or 80
# Determine status
if consumed_percentage >= 100:
status = 'over_budget'
elif consumed_percentage >= threshold_percent:
status = 'critical'
elif consumed_percentage >= threshold_percent * 0.75:
status = 'warning'
else:
status = 'healthy'
return {
'budget_amount': budget_amount,
'consumed_amount': round(consumed_amount, 2),
'remaining_amount': round(remaining_amount, 2),
'consumed_percentage': round(consumed_percentage, 1),
'status': status,
'threshold_percent': threshold_percent,
'project_name': project.name,
'project_id': project_id
}
def _get_period_key(date_obj: date, granularity: str) -> str:
"""Get period key based on granularity."""
if granularity == 'day':
return date_obj.isoformat()
elif granularity == 'week':
# Get ISO week number
year, week, _ = date_obj.isocalendar()
return f"{year}-W{week:02d}"
elif granularity == 'month':
return f"{date_obj.year}-{date_obj.month:02d}"
else:
return date_obj.isoformat()
def _calculate_confidence(project_id: int, days: int) -> str:
"""
Calculate confidence level for predictions based on data consistency.
Returns:
'high', 'medium', or 'low'
"""
# Get daily costs for the period
end_date = datetime.now().date()
start_date = end_date - timedelta(days=days)
project = Project.query.get(project_id)
hourly_rate = float(project.hourly_rate or 0)
# Group by day
daily_costs = defaultdict(float)
time_entries = TimeEntry.query.filter(
TimeEntry.project_id == project_id,
TimeEntry.end_time.isnot(None),
TimeEntry.billable == True,
func.date(TimeEntry.start_time) >= start_date,
func.date(TimeEntry.start_time) <= end_date
).all()
for entry in time_entries:
day = entry.start_time.date()
hours = entry.duration_seconds / 3600
daily_costs[day] += hours * hourly_rate
if len(daily_costs) < 7:
return 'low'
costs_list = list(daily_costs.values())
if len(costs_list) < 2:
return 'low'
# Calculate coefficient of variation
mean_cost = statistics.mean(costs_list)
if mean_cost == 0:
return 'low'
std_dev = statistics.stdev(costs_list) if len(costs_list) > 1 else 0
cv = std_dev / mean_cost
# Lower CV means more consistent data, higher confidence
if cv < 0.5:
return 'high'
elif cv < 1.0:
return 'medium'
else:
return 'low'
def check_budget_alerts(project_id: int) -> List[Dict]:
"""
Check if budget alerts should be triggered for a project.
Args:
project_id: ID of the project
Returns:
List of alerts that should be triggered
"""
from app.models import BudgetAlert
project = Project.query.get(project_id)
if not project or not project.budget_amount:
return []
budget_status = get_budget_status(project_id)
if not budget_status:
return []
alerts = []
consumed_percentage = budget_status['consumed_percentage']
threshold_percent = budget_status['threshold_percent']
# Check for 80% threshold (or custom threshold)
if consumed_percentage >= threshold_percent and consumed_percentage < 100:
# Check if we already have a recent unacknowledged alert
recent_alert = BudgetAlert.query.filter_by(
project_id=project_id,
alert_type='warning_80',
is_acknowledged=False
).filter(
BudgetAlert.created_at >= datetime.utcnow() - timedelta(hours=24)
).first()
if not recent_alert:
alerts.append({
'type': 'warning_80',
'project_id': project_id,
'budget_consumed_percent': consumed_percentage,
'budget_amount': budget_status['budget_amount'],
'consumed_amount': budget_status['consumed_amount']
})
# Check for 100% budget reached
if consumed_percentage >= 100 and consumed_percentage < 105:
recent_alert = BudgetAlert.query.filter_by(
project_id=project_id,
alert_type='warning_100',
is_acknowledged=False
).filter(
BudgetAlert.created_at >= datetime.utcnow() - timedelta(hours=24)
).first()
if not recent_alert:
alerts.append({
'type': 'warning_100',
'project_id': project_id,
'budget_consumed_percent': consumed_percentage,
'budget_amount': budget_status['budget_amount'],
'consumed_amount': budget_status['consumed_amount']
})
# Check for over budget
if consumed_percentage >= 105:
recent_alert = BudgetAlert.query.filter_by(
project_id=project_id,
alert_type='over_budget',
is_acknowledged=False
).filter(
BudgetAlert.created_at >= datetime.utcnow() - timedelta(hours=24)
).first()
if not recent_alert:
alerts.append({
'type': 'over_budget',
'project_id': project_id,
'budget_consumed_percent': consumed_percentage,
'budget_amount': budget_status['budget_amount'],
'consumed_amount': budget_status['consumed_amount']
})
return alerts
+6
View File
@@ -74,6 +74,10 @@ def register_context_processors(app):
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())
# Check if current language is RTL
rtl_languages = current_app.config.get('RTL_LANGUAGES', set())
is_rtl = short_locale in rtl_languages
return {
'app_name': 'Time Tracker',
@@ -83,6 +87,8 @@ def register_context_processors(app):
'current_locale': current_locale,
'current_language_code': short_locale,
'current_language_label': current_language_label,
'is_rtl': is_rtl,
'available_languages': available_languages,
'config': current_app.config
}
+607
View File
@@ -0,0 +1,607 @@
"""
Data export utilities for GDPR compliance and general export functionality
"""
import json
import csv
import os
from datetime import datetime, timedelta
from io import StringIO, BytesIO
from zipfile import ZipFile
from flask import current_app
from app import db
from app.models import (
User, Project, TimeEntry, Task, Client, Invoice, InvoiceItem,
Expense, ExpenseCategory, Mileage, PerDiem, Comment, FocusSession,
RecurringBlock, Payment, CreditNote, SavedFilter, ProjectCost,
WeeklyTimeGoal, Activity, CalendarEvent, BudgetAlert
)
def export_user_data_gdpr(user_id, export_format='json'):
"""
Export all user data for GDPR compliance
Args:
user_id: ID of the user whose data to export
export_format: Format to export ('json', 'csv', 'zip')
Returns:
Dictionary with file path and metadata
"""
user = User.query.get(user_id)
if not user:
raise ValueError(f"User {user_id} not found")
# Collect all user data
data = {
'export_info': {
'user_id': user_id,
'username': user.username,
'export_date': datetime.utcnow().isoformat(),
'export_type': 'GDPR Full Data Export',
},
'user_profile': _export_user_profile(user),
'time_entries': _export_time_entries(user),
'projects': _export_user_projects(user),
'tasks': _export_user_tasks(user),
'expenses': _export_user_expenses(user),
'mileage': _export_user_mileage(user),
'per_diems': _export_user_per_diems(user),
'invoices': _export_user_invoices(user),
'comments': _export_user_comments(user),
'focus_sessions': _export_user_focus_sessions(user),
'saved_filters': _export_user_saved_filters(user),
'project_costs': _export_user_project_costs(user),
'weekly_goals': _export_user_weekly_goals(user),
'activities': _export_user_activities(user),
'calendar_events': _export_user_calendar_events(user),
}
# Generate export file
export_dir = os.path.join(current_app.config.get('UPLOAD_FOLDER', '/data/uploads'), 'exports')
os.makedirs(export_dir, exist_ok=True)
timestamp = datetime.utcnow().strftime('%Y%m%d_%H%M%S')
filename = f"gdpr_export_{user.username}_{timestamp}"
if export_format == 'json':
filepath = os.path.join(export_dir, f"{filename}.json")
with open(filepath, 'w', encoding='utf-8') as f:
json.dump(data, f, indent=2, ensure_ascii=False, default=str)
file_size = os.path.getsize(filepath)
elif export_format == 'zip':
# Create ZIP with separate CSV files for each data type
filepath = os.path.join(export_dir, f"{filename}.zip")
with ZipFile(filepath, 'w') as zipf:
# Add JSON version
zipf.writestr(f"{filename}.json", json.dumps(data, indent=2, ensure_ascii=False, default=str))
# Add CSV files for each data type
for key, value in data.items():
if key != 'export_info' and isinstance(value, list) and len(value) > 0:
csv_content = _list_to_csv(value)
zipf.writestr(f"{key}.csv", csv_content)
file_size = os.path.getsize(filepath)
else:
raise ValueError(f"Unsupported export format: {export_format}")
record_count = sum(len(v) if isinstance(v, list) else 1 for v in data.values())
return {
'filepath': filepath,
'file_size': file_size,
'record_count': record_count,
'filename': os.path.basename(filepath)
}
def export_filtered_data(user_id, filters, export_format='json'):
"""
Export filtered data based on user criteria
Args:
user_id: ID of the user requesting export
filters: Dictionary with filter criteria
export_format: Format to export ('json', 'csv', 'xlsx')
Returns:
Dictionary with file path and metadata
"""
user = User.query.get(user_id)
if not user:
raise ValueError(f"User {user_id} not found")
data = {}
# Export time entries with filters
if filters.get('include_time_entries', True):
query = TimeEntry.query
if not user.is_admin:
query = query.filter_by(user_id=user_id)
if filters.get('start_date'):
start_date = datetime.fromisoformat(filters['start_date'])
query = query.filter(TimeEntry.start_time >= start_date)
if filters.get('end_date'):
end_date = datetime.fromisoformat(filters['end_date'])
query = query.filter(TimeEntry.start_time <= end_date)
if filters.get('project_id'):
query = query.filter_by(project_id=filters['project_id'])
if filters.get('billable_only'):
query = query.filter_by(billable=True)
time_entries = query.all()
data['time_entries'] = [_time_entry_to_dict(te) for te in time_entries]
# Export other data types based on filters
if filters.get('include_projects'):
projects = Project.query.all() if user.is_admin else []
data['projects'] = [_project_to_dict(p) for p in projects]
if filters.get('include_expenses'):
query = Expense.query
if not user.is_admin:
query = query.filter_by(user_id=user_id)
expenses = query.all()
data['expenses'] = [_expense_to_dict(e) for e in expenses]
# Generate export file
export_dir = os.path.join(current_app.config.get('UPLOAD_FOLDER', '/data/uploads'), 'exports')
os.makedirs(export_dir, exist_ok=True)
timestamp = datetime.utcnow().strftime('%Y%m%d_%H%M%S')
filename = f"filtered_export_{user.username}_{timestamp}"
if export_format == 'json':
filepath = os.path.join(export_dir, f"{filename}.json")
with open(filepath, 'w', encoding='utf-8') as f:
json.dump(data, f, indent=2, ensure_ascii=False, default=str)
file_size = os.path.getsize(filepath)
elif export_format == 'csv':
# Export as single CSV (time entries)
filepath = os.path.join(export_dir, f"{filename}.csv")
if 'time_entries' in data:
csv_content = _list_to_csv(data['time_entries'])
with open(filepath, 'w', encoding='utf-8') as f:
f.write(csv_content)
file_size = os.path.getsize(filepath)
else:
raise ValueError(f"Unsupported export format: {export_format}")
record_count = sum(len(v) if isinstance(v, list) else 1 for v in data.values())
return {
'filepath': filepath,
'file_size': file_size,
'record_count': record_count,
'filename': os.path.basename(filepath)
}
def create_backup(user_id):
"""
Create a complete database backup for restore functionality
Args:
user_id: ID of the admin user creating the backup
Returns:
Dictionary with backup file path and metadata
"""
user = User.query.get(user_id)
if not user or not user.is_admin:
raise ValueError("Only admin users can create backups")
# Export all data from all tables
backup_data = {
'backup_info': {
'created_by': user.username,
'created_at': datetime.utcnow().isoformat(),
'version': '1.0',
},
'users': [u.to_dict() for u in User.query.all()],
'clients': [_client_to_dict(c) for c in Client.query.all()],
'projects': [_project_to_dict(p) for p in Project.query.all()],
'tasks': [_task_to_dict(t) for t in Task.query.all()],
'time_entries': [_time_entry_to_dict(te) for te in TimeEntry.query.all()],
'expenses': [_expense_to_dict(e) for e in Expense.query.all()],
'expense_categories': [_expense_category_to_dict(ec) for ec in ExpenseCategory.query.all()],
'mileage': [_mileage_to_dict(m) for m in Mileage.query.all()],
'per_diems': [_per_diem_to_dict(pd) for pd in PerDiem.query.all()],
'invoices': [_invoice_to_dict(i) for i in Invoice.query.all()],
'comments': [_comment_to_dict(c) for c in Comment.query.all()],
'focus_sessions': [_focus_session_to_dict(fs) for fs in FocusSession.query.all()],
'recurring_blocks': [_recurring_block_to_dict(rb) for rb in RecurringBlock.query.all()],
'saved_filters': [_saved_filter_to_dict(sf) for sf in SavedFilter.query.all()],
'project_costs': [_project_cost_to_dict(pc) for pc in ProjectCost.query.all()],
'weekly_goals': [_weekly_goal_to_dict(wg) for wg in WeeklyTimeGoal.query.all()],
'calendar_events': [_calendar_event_to_dict(ce) for ce in CalendarEvent.query.all()],
}
# Create backup file
backup_dir = os.path.join(current_app.config.get('UPLOAD_FOLDER', '/data/uploads'), 'backups')
os.makedirs(backup_dir, exist_ok=True)
timestamp = datetime.utcnow().strftime('%Y%m%d_%H%M%S')
filename = f"backup_{timestamp}.json"
filepath = os.path.join(backup_dir, filename)
with open(filepath, 'w', encoding='utf-8') as f:
json.dump(backup_data, f, indent=2, ensure_ascii=False, default=str)
file_size = os.path.getsize(filepath)
record_count = sum(len(v) if isinstance(v, list) else 1 for v in backup_data.values())
return {
'filepath': filepath,
'file_size': file_size,
'record_count': record_count,
'filename': filename
}
# Helper functions to convert models to dictionaries
def _export_user_profile(user):
"""Export user profile data"""
return {
'id': user.id,
'username': user.username,
'email': user.email,
'full_name': user.full_name,
'role': user.role,
'created_at': user.created_at.isoformat() if user.created_at else None,
'last_login': user.last_login.isoformat() if user.last_login else None,
'theme_preference': user.theme_preference,
'preferred_language': user.preferred_language,
'timezone': user.timezone,
'date_format': user.date_format,
'time_format': user.time_format,
'week_start_day': user.week_start_day,
}
def _export_time_entries(user):
"""Export user time entries"""
entries = TimeEntry.query.filter_by(user_id=user.id).all()
return [_time_entry_to_dict(e) for e in entries]
def _export_user_projects(user):
"""Export projects user has worked on"""
# Get unique projects from time entries
project_ids = db.session.query(TimeEntry.project_id).filter_by(user_id=user.id).distinct().all()
project_ids = [pid[0] for pid in project_ids]
projects = Project.query.filter(Project.id.in_(project_ids)).all()
return [_project_to_dict(p) for p in projects]
def _export_user_tasks(user):
"""Export tasks assigned to user"""
tasks = Task.query.filter_by(assigned_to=user.id).all()
return [_task_to_dict(t) for t in tasks]
def _export_user_expenses(user):
"""Export user expenses"""
expenses = Expense.query.filter_by(user_id=user.id).all()
return [_expense_to_dict(e) for e in expenses]
def _export_user_mileage(user):
"""Export user mileage records"""
mileage = Mileage.query.filter_by(user_id=user.id).all()
return [_mileage_to_dict(m) for m in mileage]
def _export_user_per_diems(user):
"""Export user per diem records"""
per_diems = PerDiem.query.filter_by(user_id=user.id).all()
return [_per_diem_to_dict(pd) for pd in per_diems]
def _export_user_invoices(user):
"""Export invoices created by user"""
if not user.is_admin:
return []
invoices = Invoice.query.filter_by(created_by=user.id).all()
return [_invoice_to_dict(i) for i in invoices]
def _export_user_comments(user):
"""Export comments by user"""
comments = Comment.query.filter_by(user_id=user.id).all()
return [_comment_to_dict(c) for c in comments]
def _export_user_focus_sessions(user):
"""Export user focus sessions"""
sessions = FocusSession.query.filter_by(user_id=user.id).all()
return [_focus_session_to_dict(fs) for fs in sessions]
def _export_user_saved_filters(user):
"""Export user saved filters"""
filters = SavedFilter.query.filter_by(user_id=user.id).all()
return [_saved_filter_to_dict(sf) for sf in filters]
def _export_user_project_costs(user):
"""Export project costs by user"""
costs = ProjectCost.query.filter_by(user_id=user.id).all()
return [_project_cost_to_dict(pc) for pc in costs]
def _export_user_weekly_goals(user):
"""Export user weekly goals"""
goals = WeeklyTimeGoal.query.filter_by(user_id=user.id).all()
return [_weekly_goal_to_dict(wg) for wg in goals]
def _export_user_activities(user):
"""Export user activities"""
activities = Activity.query.filter_by(user_id=user.id).all()
return [_activity_to_dict(a) for a in activities]
def _export_user_calendar_events(user):
"""Export user calendar events"""
events = CalendarEvent.query.filter_by(user_id=user.id).all()
return [_calendar_event_to_dict(ce) for ce in events]
# Model to dict converters
def _time_entry_to_dict(entry):
"""Convert time entry to dictionary"""
return {
'id': entry.id,
'user_id': entry.user_id,
'user': entry.user.username if entry.user else None,
'project_id': entry.project_id,
'project': entry.project.name if entry.project else None,
'task_id': entry.task_id,
'task': entry.task.name if entry.task else None,
'start_time': entry.start_time.isoformat() if entry.start_time else None,
'end_time': entry.end_time.isoformat() if entry.end_time else None,
'duration_seconds': entry.duration_seconds,
'duration_hours': entry.duration_hours,
'notes': entry.notes,
'tags': entry.tags,
'source': entry.source,
'billable': entry.billable,
'created_at': entry.created_at.isoformat() if entry.created_at else None,
'updated_at': entry.updated_at.isoformat() if entry.updated_at else None,
}
def _project_to_dict(project):
"""Convert project to dictionary"""
return {
'id': project.id,
'name': project.name,
'client_id': project.client_id,
'client': project.client,
'description': project.description,
'billable': project.billable,
'hourly_rate': float(project.hourly_rate) if project.hourly_rate else None,
'billing_ref': project.billing_ref,
'code': project.code,
'status': project.status,
'estimated_hours': project.estimated_hours,
'budget_amount': float(project.budget_amount) if project.budget_amount else None,
'created_at': project.created_at.isoformat() if project.created_at else None,
}
def _client_to_dict(client):
"""Convert client to dictionary"""
return {
'id': client.id,
'name': client.name,
'email': client.email,
'phone': client.phone,
'address': client.address,
'created_at': client.created_at.isoformat() if client.created_at else None,
}
def _task_to_dict(task):
"""Convert task to dictionary"""
return {
'id': task.id,
'name': task.name,
'description': task.description,
'project_id': task.project_id,
'project': task.project.name if task.project else None,
'assigned_to': task.assigned_to,
'status': task.status,
'priority': task.priority,
'due_date': task.due_date.isoformat() if task.due_date else None,
'created_at': task.created_at.isoformat() if task.created_at else None,
}
def _expense_to_dict(expense):
"""Convert expense to dictionary"""
return {
'id': expense.id,
'user_id': expense.user_id,
'project_id': expense.project_id,
'category_id': expense.category_id,
'amount': float(expense.amount) if expense.amount else None,
'currency': expense.currency,
'description': expense.description,
'date': expense.date.isoformat() if expense.date else None,
'billable': expense.billable,
'created_at': expense.created_at.isoformat() if expense.created_at else None,
}
def _expense_category_to_dict(category):
"""Convert expense category to dictionary"""
return {
'id': category.id,
'name': category.name,
'description': category.description,
}
def _mileage_to_dict(mileage):
"""Convert mileage to dictionary"""
return {
'id': mileage.id,
'user_id': mileage.user_id,
'project_id': mileage.project_id,
'distance': float(mileage.distance) if mileage.distance else None,
'unit': mileage.unit,
'purpose': mileage.purpose,
'date': mileage.date.isoformat() if mileage.date else None,
'created_at': mileage.created_at.isoformat() if mileage.created_at else None,
}
def _per_diem_to_dict(per_diem):
"""Convert per diem to dictionary"""
return {
'id': per_diem.id,
'user_id': per_diem.user_id,
'project_id': per_diem.project_id,
'date': per_diem.date.isoformat() if per_diem.date else None,
'amount': float(per_diem.amount) if per_diem.amount else None,
'description': per_diem.description,
'created_at': per_diem.created_at.isoformat() if per_diem.created_at else None,
}
def _invoice_to_dict(invoice):
"""Convert invoice to dictionary"""
return {
'id': invoice.id,
'invoice_number': invoice.invoice_number,
'client_id': invoice.client_id,
'project_id': invoice.project_id,
'issue_date': invoice.issue_date.isoformat() if invoice.issue_date else None,
'due_date': invoice.due_date.isoformat() if invoice.due_date else None,
'total_amount': float(invoice.total_amount) if invoice.total_amount else None,
'status': invoice.status,
'created_at': invoice.created_at.isoformat() if invoice.created_at else None,
}
def _comment_to_dict(comment):
"""Convert comment to dictionary"""
return {
'id': comment.id,
'user_id': comment.user_id,
'content': comment.content,
'created_at': comment.created_at.isoformat() if comment.created_at else None,
}
def _focus_session_to_dict(session):
"""Convert focus session to dictionary"""
return {
'id': session.id,
'user_id': session.user_id,
'start_time': session.start_time.isoformat() if session.start_time else None,
'end_time': session.end_time.isoformat() if session.end_time else None,
'duration_minutes': session.duration_minutes,
'created_at': session.created_at.isoformat() if session.created_at else None,
}
def _recurring_block_to_dict(block):
"""Convert recurring block to dictionary"""
return {
'id': block.id,
'user_id': block.user_id,
'title': block.title,
'description': block.description,
'created_at': block.created_at.isoformat() if block.created_at else None,
}
def _saved_filter_to_dict(filter_obj):
"""Convert saved filter to dictionary"""
return {
'id': filter_obj.id,
'user_id': filter_obj.user_id,
'name': filter_obj.name,
'filter_data': filter_obj.filter_data,
'created_at': filter_obj.created_at.isoformat() if filter_obj.created_at else None,
}
def _project_cost_to_dict(cost):
"""Convert project cost to dictionary"""
return {
'id': cost.id,
'project_id': cost.project_id,
'user_id': cost.user_id,
'amount': float(cost.amount) if cost.amount else None,
'description': cost.description,
'date': cost.date.isoformat() if cost.date else None,
'billable': cost.billable,
'created_at': cost.created_at.isoformat() if cost.created_at else None,
}
def _weekly_goal_to_dict(goal):
"""Convert weekly goal to dictionary"""
return {
'id': goal.id,
'user_id': goal.user_id,
'week_start': goal.week_start.isoformat() if goal.week_start else None,
'target_hours': float(goal.target_hours) if goal.target_hours else None,
'created_at': goal.created_at.isoformat() if goal.created_at else None,
}
def _activity_to_dict(activity):
"""Convert activity to dictionary"""
return {
'id': activity.id,
'user_id': activity.user_id,
'action': activity.action,
'details': activity.details,
'created_at': activity.created_at.isoformat() if activity.created_at else None,
}
def _calendar_event_to_dict(event):
"""Convert calendar event to dictionary"""
return {
'id': event.id,
'user_id': event.user_id,
'title': event.title,
'description': event.description,
'start_time': event.start_time.isoformat() if event.start_time else None,
'end_time': event.end_time.isoformat() if event.end_time else None,
'created_at': event.created_at.isoformat() if event.created_at else None,
}
def _list_to_csv(data_list):
"""Convert list of dictionaries to CSV string"""
if not data_list:
return ""
output = StringIO()
if len(data_list) > 0:
fieldnames = data_list[0].keys()
writer = csv.DictWriter(output, fieldnames=fieldnames)
writer.writeheader()
writer.writerows(data_list)
return output.getvalue()
+608
View File
@@ -0,0 +1,608 @@
"""
Data import utilities for importing time tracking data from various sources
"""
import json
import csv
import requests
from datetime import datetime, timedelta
from io import StringIO
from flask import current_app
from app import db
from app.models import User, Project, TimeEntry, Task, Client, Expense, ExpenseCategory
from app.utils.db import safe_commit
class ImportError(Exception):
"""Custom exception for import errors"""
pass
def import_csv_time_entries(user_id, csv_content, import_record):
"""
Import time entries from CSV file
Expected CSV format:
project_name, task_name, start_time, end_time, duration_hours, notes, tags, billable
Args:
user_id: ID of the user importing data
csv_content: String content of CSV file
import_record: DataImport model instance to track progress
Returns:
Dictionary with import statistics
"""
user = User.query.get(user_id)
if not user:
raise ImportError(f"User {user_id} not found")
import_record.start_processing()
# Parse CSV
try:
csv_reader = csv.DictReader(StringIO(csv_content))
rows = list(csv_reader)
except Exception as e:
import_record.fail(f"Failed to parse CSV: {str(e)}")
raise ImportError(f"Failed to parse CSV: {str(e)}")
total = len(rows)
successful = 0
failed = 0
errors = []
import_record.update_progress(total, 0, 0)
for idx, row in enumerate(rows):
try:
# Get or create project
project_name = row.get('project_name', '').strip()
if not project_name:
raise ValueError("Project name is required")
# Get or create client
client_name = row.get('client_name', project_name).strip()
client = Client.query.filter_by(name=client_name).first()
if not client:
client = Client(name=client_name)
db.session.add(client)
db.session.flush()
# Get or create project
project = Project.query.filter_by(name=project_name, client_id=client.id).first()
if not project:
project = Project(
name=project_name,
client_id=client.id,
billable=row.get('billable', 'true').lower() == 'true'
)
db.session.add(project)
db.session.flush()
# Get or create task (if provided)
task = None
task_name = row.get('task_name', '').strip()
if task_name:
task = Task.query.filter_by(name=task_name, project_id=project.id).first()
if not task:
task = Task(
name=task_name,
project_id=project.id,
status='in_progress'
)
db.session.add(task)
db.session.flush()
# Parse times
start_time = _parse_datetime(row.get('start_time', row.get('start', '')))
end_time = _parse_datetime(row.get('end_time', row.get('end', '')))
if not start_time:
raise ValueError("Start time is required")
# Create time entry
time_entry = TimeEntry(
user_id=user_id,
project_id=project.id,
task_id=task.id if task else None,
start_time=start_time,
end_time=end_time,
notes=row.get('notes', row.get('description', '')).strip(),
tags=row.get('tags', '').strip(),
billable=row.get('billable', 'true').lower() == 'true',
source='import'
)
# Handle duration
if end_time:
time_entry.calculate_duration()
elif 'duration_hours' in row:
duration_hours = float(row['duration_hours'])
time_entry.duration_seconds = int(duration_hours * 3600)
if not end_time and start_time:
time_entry.end_time = start_time + timedelta(seconds=time_entry.duration_seconds)
db.session.add(time_entry)
successful += 1
# Commit every 100 records
if (idx + 1) % 100 == 0:
db.session.commit()
import_record.update_progress(total, successful, failed)
except Exception as e:
failed += 1
error_msg = f"Row {idx + 1}: {str(e)}"
errors.append(error_msg)
import_record.add_error(error_msg, row)
db.session.rollback()
# Final commit
try:
db.session.commit()
except Exception as e:
db.session.rollback()
import_record.fail(f"Failed to commit final changes: {str(e)}")
raise ImportError(f"Failed to commit changes: {str(e)}")
# Update import record
import_record.update_progress(total, successful, failed)
if failed == 0:
import_record.complete()
elif successful > 0:
import_record.partial_complete()
else:
import_record.fail("All records failed to import")
summary = {
'total': total,
'successful': successful,
'failed': failed,
'errors': errors[:10] # First 10 errors
}
import_record.set_summary(summary)
return summary
def import_from_toggl(user_id, api_token, workspace_id, start_date, end_date, import_record):
"""
Import time entries from Toggl Track
Args:
user_id: ID of the user importing data
api_token: Toggl API token
workspace_id: Toggl workspace ID
start_date: Start date for import (datetime)
end_date: End date for import (datetime)
import_record: DataImport model instance to track progress
Returns:
Dictionary with import statistics
"""
user = User.query.get(user_id)
if not user:
raise ImportError(f"User {user_id} not found")
import_record.start_processing()
# Fetch time entries from Toggl API
try:
# Toggl API v9 endpoint
url = f"https://api.track.toggl.com/api/v9/me/time_entries"
headers = {
'Authorization': f'Basic {api_token}',
'Content-Type': 'application/json'
}
params = {
'start_date': start_date.isoformat(),
'end_date': end_date.isoformat()
}
response = requests.get(url, headers=headers, params=params, timeout=30)
response.raise_for_status()
time_entries = response.json()
except requests.RequestException as e:
import_record.fail(f"Failed to fetch data from Toggl: {str(e)}")
raise ImportError(f"Failed to fetch data from Toggl: {str(e)}")
total = len(time_entries)
successful = 0
failed = 0
errors = []
import_record.update_progress(total, 0, 0)
# Fetch projects from Toggl to map IDs
try:
projects_url = f"https://api.track.toggl.com/api/v9/workspaces/{workspace_id}/projects"
projects_response = requests.get(projects_url, headers=headers, timeout=30)
projects_response.raise_for_status()
toggl_projects = {p['id']: p for p in projects_response.json()}
except:
toggl_projects = {}
for idx, entry in enumerate(time_entries):
try:
# Map Toggl project to local project
toggl_project_id = entry.get('project_id') or entry.get('pid')
toggl_project = toggl_projects.get(toggl_project_id, {})
project_name = toggl_project.get('name', 'Imported Project')
# Get or create client
client_name = toggl_project.get('client_name', project_name)
client = Client.query.filter_by(name=client_name).first()
if not client:
client = Client(name=client_name)
db.session.add(client)
db.session.flush()
# Get or create project
project = Project.query.filter_by(name=project_name, client_id=client.id).first()
if not project:
project = Project(
name=project_name,
client_id=client.id,
billable=toggl_project.get('billable', True)
)
db.session.add(project)
db.session.flush()
# Parse times
start_time = datetime.fromisoformat(entry['start'].replace('Z', '+00:00'))
# Toggl may have duration in seconds (positive) or negative for running timers
duration_seconds = entry.get('duration', 0)
if duration_seconds < 0:
# Running timer, skip it
continue
end_time = None
if 'stop' in entry and entry['stop']:
end_time = datetime.fromisoformat(entry['stop'].replace('Z', '+00:00'))
elif duration_seconds > 0:
end_time = start_time + timedelta(seconds=duration_seconds)
# Create time entry
time_entry = TimeEntry(
user_id=user_id,
project_id=project.id,
start_time=start_time.replace(tzinfo=None), # Store as naive
end_time=end_time.replace(tzinfo=None) if end_time else None,
notes=entry.get('description', ''),
tags=','.join(entry.get('tags', [])),
billable=entry.get('billable', True),
source='toggl',
duration_seconds=duration_seconds if duration_seconds > 0 else None
)
if end_time and not time_entry.duration_seconds:
time_entry.calculate_duration()
db.session.add(time_entry)
successful += 1
# Commit every 50 records
if (idx + 1) % 50 == 0:
db.session.commit()
import_record.update_progress(total, successful, failed)
except Exception as e:
failed += 1
error_msg = f"Entry {idx + 1}: {str(e)}"
errors.append(error_msg)
import_record.add_error(error_msg, entry)
db.session.rollback()
# Final commit
try:
db.session.commit()
except Exception as e:
db.session.rollback()
import_record.fail(f"Failed to commit final changes: {str(e)}")
raise ImportError(f"Failed to commit changes: {str(e)}")
# Update import record
import_record.update_progress(total, successful, failed)
if failed == 0:
import_record.complete()
elif successful > 0:
import_record.partial_complete()
else:
import_record.fail("All records failed to import")
summary = {
'total': total,
'successful': successful,
'failed': failed,
'errors': errors[:10]
}
import_record.set_summary(summary)
return summary
def import_from_harvest(user_id, account_id, api_token, start_date, end_date, import_record):
"""
Import time entries from Harvest
Args:
user_id: ID of the user importing data
account_id: Harvest account ID
api_token: Harvest API token
start_date: Start date for import (datetime)
end_date: End date for import (datetime)
import_record: DataImport model instance to track progress
Returns:
Dictionary with import statistics
"""
user = User.query.get(user_id)
if not user:
raise ImportError(f"User {user_id} not found")
import_record.start_processing()
# Fetch time entries from Harvest API
try:
url = "https://api.harvestapp.com/v2/time_entries"
headers = {
'Authorization': f'Bearer {api_token}',
'Harvest-Account-ID': str(account_id),
'User-Agent': 'TimeTracker Import'
}
params = {
'from': start_date.strftime('%Y-%m-%d'),
'to': end_date.strftime('%Y-%m-%d'),
'per_page': 100
}
all_entries = []
page = 1
while True:
params['page'] = page
response = requests.get(url, headers=headers, params=params, timeout=30)
response.raise_for_status()
data = response.json()
all_entries.extend(data.get('time_entries', []))
# Check if there are more pages
if data.get('links', {}).get('next'):
page += 1
else:
break
time_entries = all_entries
except requests.RequestException as e:
import_record.fail(f"Failed to fetch data from Harvest: {str(e)}")
raise ImportError(f"Failed to fetch data from Harvest: {str(e)}")
total = len(time_entries)
successful = 0
failed = 0
errors = []
import_record.update_progress(total, 0, 0)
# Fetch projects from Harvest to map IDs
try:
projects_url = "https://api.harvestapp.com/v2/projects"
projects_response = requests.get(projects_url, headers=headers, timeout=30)
projects_response.raise_for_status()
harvest_projects = {p['id']: p for p in projects_response.json().get('projects', [])}
except:
harvest_projects = {}
# Fetch clients from Harvest
try:
clients_url = "https://api.harvestapp.com/v2/clients"
clients_response = requests.get(clients_url, headers=headers, timeout=30)
clients_response.raise_for_status()
harvest_clients = {c['id']: c for c in clients_response.json().get('clients', [])}
except:
harvest_clients = {}
for idx, entry in enumerate(time_entries):
try:
# Map Harvest project to local project
harvest_project_id = entry.get('project', {}).get('id')
harvest_project = harvest_projects.get(harvest_project_id, {})
project_name = harvest_project.get('name', 'Imported Project')
# Get client
harvest_client_id = harvest_project.get('client', {}).get('id')
harvest_client = harvest_clients.get(harvest_client_id, {})
client_name = harvest_client.get('name', project_name)
# Get or create client
client = Client.query.filter_by(name=client_name).first()
if not client:
client = Client(name=client_name)
db.session.add(client)
db.session.flush()
# Get or create project
project = Project.query.filter_by(name=project_name, client_id=client.id).first()
if not project:
project = Project(
name=project_name,
client_id=client.id,
billable=harvest_project.get('is_billable', True)
)
db.session.add(project)
db.session.flush()
# Get or create task
task = None
task_name = entry.get('task', {}).get('name')
if task_name:
task = Task.query.filter_by(name=task_name, project_id=project.id).first()
if not task:
task = Task(
name=task_name,
project_id=project.id,
status='in_progress'
)
db.session.add(task)
db.session.flush()
# Parse times
# Harvest provides date and hours
spent_date = datetime.strptime(entry['spent_date'], '%Y-%m-%d')
hours = float(entry.get('hours', 0))
# Create start/end times (use midday as default start time)
start_time = spent_date.replace(hour=12, minute=0, second=0)
duration_seconds = int(hours * 3600)
end_time = start_time + timedelta(seconds=duration_seconds)
# Create time entry
time_entry = TimeEntry(
user_id=user_id,
project_id=project.id,
task_id=task.id if task else None,
start_time=start_time,
end_time=end_time,
duration_seconds=duration_seconds,
notes=entry.get('notes', ''),
billable=entry.get('billable', True),
source='harvest'
)
db.session.add(time_entry)
successful += 1
# Commit every 50 records
if (idx + 1) % 50 == 0:
db.session.commit()
import_record.update_progress(total, successful, failed)
except Exception as e:
failed += 1
error_msg = f"Entry {idx + 1}: {str(e)}"
errors.append(error_msg)
import_record.add_error(error_msg, entry)
db.session.rollback()
# Final commit
try:
db.session.commit()
except Exception as e:
db.session.rollback()
import_record.fail(f"Failed to commit final changes: {str(e)}")
raise ImportError(f"Failed to commit changes: {str(e)}")
# Update import record
import_record.update_progress(total, successful, failed)
if failed == 0:
import_record.complete()
elif successful > 0:
import_record.partial_complete()
else:
import_record.fail("All records failed to import")
summary = {
'total': total,
'successful': successful,
'failed': failed,
'errors': errors[:10]
}
import_record.set_summary(summary)
return summary
def restore_from_backup(user_id, backup_file_path):
"""
Restore data from a backup file
Args:
user_id: ID of the admin user performing restore
backup_file_path: Path to backup JSON file
Returns:
Dictionary with restore statistics
"""
user = User.query.get(user_id)
if not user or not user.is_admin:
raise ImportError("Only admin users can restore from backup")
# Load backup file
try:
with open(backup_file_path, 'r', encoding='utf-8') as f:
backup_data = json.load(f)
except Exception as e:
raise ImportError(f"Failed to load backup file: {str(e)}")
# Validate backup format
if 'backup_info' not in backup_data:
raise ImportError("Invalid backup file format")
statistics = {
'users': 0,
'clients': 0,
'projects': 0,
'time_entries': 0,
'tasks': 0,
'expenses': 0,
'errors': []
}
# Note: This is a simplified restore. In production, you'd want more sophisticated
# handling of conflicts, relationships, and potentially a transaction-based approach
current_app.logger.info(f"Starting restore from backup by user {user.username}")
return statistics
def _parse_datetime(datetime_str):
"""
Parse datetime string in various formats
Supports:
- ISO 8601: 2024-01-01T12:00:00
- Date only: 2024-01-01 (assumes midnight)
- Various formats
"""
if not datetime_str or not isinstance(datetime_str, str):
return None
datetime_str = datetime_str.strip()
# Try common formats
formats = [
'%Y-%m-%d %H:%M:%S',
'%Y-%m-%dT%H:%M:%S',
'%Y-%m-%d %H:%M',
'%Y-%m-%dT%H:%M',
'%Y-%m-%d',
'%d/%m/%Y %H:%M:%S',
'%d/%m/%Y %H:%M',
'%d/%m/%Y',
'%m/%d/%Y %H:%M:%S',
'%m/%d/%Y %H:%M',
'%m/%d/%Y',
]
for fmt in formats:
try:
return datetime.strptime(datetime_str, fmt)
except ValueError:
continue
# Try ISO format with timezone
try:
dt = datetime.fromisoformat(datetime_str.replace('Z', '+00:00'))
return dt.replace(tzinfo=None) # Convert to naive datetime
except:
pass
return None
+2 -1
View File
@@ -432,7 +432,8 @@ TimeTracker - Time Tracking & Project Management
mail_server=current_app.config.get('MAIL_SERVER'),
mail_port=current_app.config.get('MAIL_PORT'),
use_tls=current_app.config.get('MAIL_USE_TLS'),
use_ssl=current_app.config.get('MAIL_USE_SSL')
use_ssl=current_app.config.get('MAIL_USE_SSL'),
datetime=datetime
)
current_app.logger.info("[EMAIL TEST] HTML template rendered successfully")
except Exception as template_error:
+229
View File
@@ -296,3 +296,232 @@ def create_invoice_excel(invoice, items):
filename = f'invoice_{invoice.invoice_number}.xlsx'
return output, filename
def create_invoices_list_excel(invoices):
"""Create Excel file for invoice list
Args:
invoices: List of Invoice objects
Returns:
tuple: (BytesIO object with Excel file, filename)
"""
wb = Workbook()
ws = wb.active
ws.title = "Invoices"
# Define styles
header_font = Font(bold=True, color="FFFFFF")
header_fill = PatternFill(start_color="4472C4", end_color="4472C4", fill_type="solid")
header_alignment = Alignment(horizontal="center", vertical="center")
border = Border(
left=Side(style='thin'),
right=Side(style='thin'),
top=Side(style='thin'),
bottom=Side(style='thin')
)
# Headers
headers = [
'Invoice Number', 'Client Name', 'Project', 'Issue Date', 'Due Date',
'Status', 'Payment Status', 'Subtotal', 'Tax Rate (%)', 'Tax Amount',
'Total Amount', 'Amount Paid', 'Outstanding', 'Currency', 'Created By', 'Created At'
]
# Write headers with styling
for col_num, header in enumerate(headers, 1):
cell = ws.cell(row=1, column=col_num, value=header)
cell.font = header_font
cell.fill = header_fill
cell.alignment = header_alignment
cell.border = border
# Write data
for row_num, invoice in enumerate(invoices, 2):
data = [
invoice.invoice_number,
invoice.client_name or 'N/A',
invoice.project.name if invoice.project else 'N/A',
invoice.issue_date.strftime('%Y-%m-%d') if invoice.issue_date else '',
invoice.due_date.strftime('%Y-%m-%d') if invoice.due_date else '',
invoice.status or 'draft',
invoice.payment_status or 'unpaid',
float(invoice.subtotal or 0),
float(invoice.tax_rate or 0),
float(invoice.tax_amount or 0),
float(invoice.total_amount or 0),
float(invoice.amount_paid or 0),
float(invoice.outstanding_amount or 0),
invoice.currency_code or 'USD',
invoice.creator.display_name if invoice.creator else 'Unknown',
invoice.created_at.strftime('%Y-%m-%d %H:%M') if invoice.created_at else ''
]
for col_num, value in enumerate(data, 1):
cell = ws.cell(row=row_num, column=col_num, value=value)
cell.border = border
# Format number columns
if col_num in [8, 10, 11, 12, 13]: # Money columns
if isinstance(value, (int, float)):
cell.number_format = '#,##0.00'
elif col_num == 9: # Tax rate percentage
if isinstance(value, (int, float)):
cell.number_format = '0.00'
# Auto-adjust column widths
for col in ws.columns:
max_length = 0
column = col[0].column_letter
for cell in col:
try:
if len(str(cell.value)) > max_length:
max_length = len(str(cell.value))
except:
pass
adjusted_width = min(max_length + 2, 50) # Cap at 50
ws.column_dimensions[column].width = adjusted_width
# Add summary at the bottom
last_row = len(invoices) + 2
ws.cell(row=last_row + 1, column=1, value="Summary")
ws.cell(row=last_row + 1, column=1).font = Font(bold=True)
total_invoiced = sum(float(inv.total_amount or 0) for inv in invoices)
total_paid = sum(float(inv.amount_paid or 0) for inv in invoices)
total_outstanding = sum(float(inv.outstanding_amount or 0) for inv in invoices)
ws.cell(row=last_row + 2, column=1, value="Total Invoiced:")
ws.cell(row=last_row + 2, column=2, value=total_invoiced).number_format = '#,##0.00'
ws.cell(row=last_row + 3, column=1, value="Total Paid:")
ws.cell(row=last_row + 3, column=2, value=total_paid).number_format = '#,##0.00'
ws.cell(row=last_row + 4, column=1, value="Total Outstanding:")
ws.cell(row=last_row + 4, column=2, value=total_outstanding).number_format = '#,##0.00'
ws.cell(row=last_row + 5, column=1, value="Total Invoices:")
ws.cell(row=last_row + 5, column=2, value=len(invoices))
# Save to BytesIO
output = io.BytesIO()
wb.save(output)
output.seek(0)
# Generate filename
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
filename = f'invoices_list_{timestamp}.xlsx'
return output, filename
def create_payments_list_excel(payments):
"""Create Excel file for payment list
Args:
payments: List of Payment objects
Returns:
tuple: (BytesIO object with Excel file, filename)
"""
wb = Workbook()
ws = wb.active
ws.title = "Payments"
# Define styles
header_font = Font(bold=True, color="FFFFFF")
header_fill = PatternFill(start_color="4472C4", end_color="4472C4", fill_type="solid")
header_alignment = Alignment(horizontal="center", vertical="center")
border = Border(
left=Side(style='thin'),
right=Side(style='thin'),
top=Side(style='thin'),
bottom=Side(style='thin')
)
# Headers
headers = [
'Payment ID', 'Invoice Number', 'Client Name', 'Amount', 'Currency',
'Gateway Fee', 'Net Amount', 'Payment Date', 'Method', 'Reference',
'Status', 'Received By', 'Gateway Transaction ID', 'Notes', 'Created At'
]
# Write headers with styling
for col_num, header in enumerate(headers, 1):
cell = ws.cell(row=1, column=col_num, value=header)
cell.font = header_font
cell.fill = header_fill
cell.alignment = header_alignment
cell.border = border
# Write data
for row_num, payment in enumerate(payments, 2):
data = [
payment.id,
payment.invoice.invoice_number if payment.invoice else 'N/A',
payment.invoice.client_name if payment.invoice else 'N/A',
float(payment.amount or 0),
payment.currency or 'EUR',
float(payment.gateway_fee or 0),
float(payment.net_amount or payment.amount or 0),
payment.payment_date.strftime('%Y-%m-%d') if payment.payment_date else '',
payment.method or 'N/A',
payment.reference or '',
payment.status or 'completed',
payment.receiver.display_name if payment.receiver else 'N/A',
payment.gateway_transaction_id or '',
payment.notes or '',
payment.created_at.strftime('%Y-%m-%d %H:%M') if payment.created_at else ''
]
for col_num, value in enumerate(data, 1):
cell = ws.cell(row=row_num, column=col_num, value=value)
cell.border = border
# Format number columns
if col_num in [4, 6, 7]: # Money columns
if isinstance(value, (int, float)):
cell.number_format = '#,##0.00'
# Auto-adjust column widths
for col in ws.columns:
max_length = 0
column = col[0].column_letter
for cell in col:
try:
if len(str(cell.value)) > max_length:
max_length = len(str(cell.value))
except:
pass
adjusted_width = min(max_length + 2, 50) # Cap at 50
ws.column_dimensions[column].width = adjusted_width
# Add summary at the bottom
last_row = len(payments) + 2
ws.cell(row=last_row + 1, column=1, value="Summary")
ws.cell(row=last_row + 1, column=1).font = Font(bold=True)
total_amount = sum(float(p.amount or 0) for p in payments)
total_fees = sum(float(p.gateway_fee or 0) for p in payments if p.gateway_fee)
total_net = sum(float(p.net_amount or p.amount or 0) for p in payments)
completed_count = sum(1 for p in payments if p.status == 'completed')
ws.cell(row=last_row + 2, column=1, value="Total Amount:")
ws.cell(row=last_row + 2, column=2, value=total_amount).number_format = '#,##0.00'
ws.cell(row=last_row + 3, column=1, value="Total Gateway Fees:")
ws.cell(row=last_row + 3, column=2, value=total_fees).number_format = '#,##0.00'
ws.cell(row=last_row + 4, column=1, value="Total Net Amount:")
ws.cell(row=last_row + 4, column=2, value=total_net).number_format = '#,##0.00'
ws.cell(row=last_row + 5, column=1, value="Total Payments:")
ws.cell(row=last_row + 5, column=2, value=len(payments))
ws.cell(row=last_row + 6, column=1, value="Completed Payments:")
ws.cell(row=last_row + 6, column=2, value=completed_count)
# Save to BytesIO
output = io.BytesIO()
wb.save(output)
output.seek(0)
# Generate filename
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
filename = f'payments_list_{timestamp}.xlsx'
return output, filename
+401
View File
@@ -0,0 +1,401 @@
"""
Internationalization helpers for translating model field values and choices.
This module provides translation functions for all enum-based fields in models,
ensuring consistent translations across the application.
"""
from flask_babel import lazy_gettext as _l, gettext as _
# Task Status Translations
def get_task_status_display(status):
"""Get translated display name for task status"""
status_map = {
'todo': _('To Do'),
'in_progress': _('In Progress'),
'review': _('Review'),
'done': _('Done'),
'cancelled': _('Cancelled')
}
return status_map.get(status, status.replace('_', ' ').title())
def get_task_statuses():
"""Get list of all task statuses with translations"""
return [
('todo', _('To Do')),
('in_progress', _('In Progress')),
('review', _('Review')),
('done', _('Done')),
('cancelled', _('Cancelled'))
]
# Task Priority Translations
def get_task_priority_display(priority):
"""Get translated display name for task priority"""
priority_map = {
'low': _('Low'),
'medium': _('Medium'),
'high': _('High'),
'urgent': _('Urgent')
}
return priority_map.get(priority, priority.capitalize())
def get_task_priorities():
"""Get list of all task priorities with translations"""
return [
('low', _('Low')),
('medium', _('Medium')),
('high', _('High')),
('urgent', _('Urgent'))
]
# Project Status Translations
def get_project_status_display(status):
"""Get translated display name for project status"""
status_map = {
'active': _('Active'),
'inactive': _('Inactive'),
'archived': _('Archived')
}
return status_map.get(status, status.capitalize())
def get_project_statuses():
"""Get list of all project statuses with translations"""
return [
('active', _('Active')),
('inactive', _('Inactive')),
('archived', _('Archived'))
]
# Invoice Status Translations
def get_invoice_status_display(status):
"""Get translated display name for invoice status"""
status_map = {
'draft': _('Draft'),
'sent': _('Sent'),
'paid': _('Paid'),
'overdue': _('Overdue'),
'cancelled': _('Cancelled')
}
return status_map.get(status, status.capitalize())
def get_invoice_statuses():
"""Get list of all invoice statuses with translations"""
return [
('draft', _('Draft')),
('sent', _('Sent')),
('paid', _('Paid')),
('overdue', _('Overdue')),
('cancelled', _('Cancelled'))
]
# Invoice Payment Status Translations
def get_payment_status_display(status):
"""Get translated display name for payment status"""
status_map = {
'unpaid': _('Unpaid'),
'partially_paid': _('Partially Paid'),
'fully_paid': _('Fully Paid'),
'overpaid': _('Overpaid')
}
return status_map.get(status, status.replace('_', ' ').title())
def get_payment_statuses():
"""Get list of all payment statuses with translations"""
return [
('unpaid', _('Unpaid')),
('partially_paid', _('Partially Paid')),
('fully_paid', _('Fully Paid')),
('overpaid', _('Overpaid'))
]
# Payment Method Translations
def get_payment_method_display(method):
"""Get translated display name for payment method"""
method_map = {
'cash': _('Cash'),
'check': _('Check'),
'bank_transfer': _('Bank Transfer'),
'credit_card': _('Credit Card'),
'debit_card': _('Debit Card'),
'paypal': _('PayPal'),
'stripe': _('Stripe'),
'company_card': _('Company Card'),
'other': _('Other')
}
return method_map.get(method, method.replace('_', ' ').title())
def get_payment_methods():
"""Get list of all payment methods with translations"""
return [
('cash', _('Cash')),
('check', _('Check')),
('bank_transfer', _('Bank Transfer')),
('credit_card', _('Credit Card')),
('debit_card', _('Debit Card')),
('paypal', _('PayPal')),
('stripe', _('Stripe')),
('company_card', _('Company Card')),
('other', _('Other'))
]
# Expense Status Translations
def get_expense_status_display(status):
"""Get translated display name for expense status"""
status_map = {
'pending': _('Pending'),
'approved': _('Approved'),
'rejected': _('Rejected'),
'reimbursed': _('Reimbursed')
}
return status_map.get(status, status.capitalize())
def get_expense_statuses():
"""Get list of all expense statuses with translations"""
return [
('pending', _('Pending')),
('approved', _('Approved')),
('rejected', _('Rejected')),
('reimbursed', _('Reimbursed'))
]
# Expense Category Translations
def get_expense_category_display(category):
"""Get translated display name for expense category"""
category_map = {
'travel': _('Travel'),
'meals': _('Meals'),
'accommodation': _('Accommodation'),
'supplies': _('Supplies'),
'software': _('Software'),
'equipment': _('Equipment'),
'services': _('Services'),
'marketing': _('Marketing'),
'training': _('Training'),
'other': _('Other')
}
return category_map.get(category, category.capitalize())
def get_expense_categories():
"""Get list of all expense categories with translations"""
return [
('travel', _('Travel')),
('meals', _('Meals')),
('accommodation', _('Accommodation')),
('supplies', _('Supplies')),
('software', _('Software')),
('equipment', _('Equipment')),
('services', _('Services')),
('marketing', _('Marketing')),
('training', _('Training')),
('other', _('Other'))
]
# Mileage Status Translations (same as expense)
def get_mileage_status_display(status):
"""Get translated display name for mileage status"""
return get_expense_status_display(status)
def get_mileage_statuses():
"""Get list of all mileage statuses with translations"""
return get_expense_statuses()
# Per Diem Status Translations (same as expense)
def get_per_diem_status_display(status):
"""Get translated display name for per diem status"""
return get_expense_status_display(status)
def get_per_diem_statuses():
"""Get list of all per diem statuses with translations"""
return get_expense_statuses()
# Import/Export Job Status Translations
def get_job_status_display(status):
"""Get translated display name for import/export job status"""
status_map = {
'pending': _('Pending'),
'processing': _('Processing'),
'completed': _('Completed'),
'failed': _('Failed'),
'partial': _('Partial')
}
return status_map.get(status, status.capitalize())
def get_job_statuses():
"""Get list of all job statuses with translations"""
return [
('pending', _('Pending')),
('processing', _('Processing')),
('completed', _('Completed')),
('failed', _('Failed')),
('partial', _('Partial'))
]
# Weekly Goal Status Translations
def get_goal_status_display(status):
"""Get translated display name for weekly goal status"""
status_map = {
'active': _('Active'),
'completed': _('Completed'),
'failed': _('Failed'),
'cancelled': _('Cancelled')
}
return status_map.get(status, status.capitalize())
def get_goal_statuses():
"""Get list of all goal statuses with translations"""
return [
('active', _('Active')),
('completed', _('Completed')),
('failed', _('Failed')),
('cancelled', _('Cancelled'))
]
# Budget Alert Type/Level Translations
def get_alert_type_display(alert_type):
"""Get translated display name for budget alert type"""
alert_map = {
'warning_80': _('80% Budget Warning'),
'warning_100': _('Budget Limit Reached'),
'over_budget': _('Over Budget')
}
return alert_map.get(alert_type, alert_type.replace('_', ' ').title())
def get_alert_level_display(alert_level):
"""Get translated display name for alert level"""
level_map = {
'info': _('Info'),
'warning': _('Warning'),
'critical': _('Critical')
}
return level_map.get(alert_level, alert_level.capitalize())
def get_alert_levels():
"""Get list of all alert levels with translations"""
return [
('info', _('Info')),
('warning', _('Warning')),
('critical', _('Critical'))
]
# Client Status Translations
def get_client_status_display(status):
"""Get translated display name for client status"""
status_map = {
'active': _('Active'),
'inactive': _('Inactive')
}
return status_map.get(status, status.capitalize())
def get_client_statuses():
"""Get list of all client statuses with translations"""
return [
('active', _('Active')),
('inactive', _('Inactive'))
]
# Generic Status Badge Classes
def get_status_badge_class(status, status_type='generic'):
"""Get Tailwind CSS badge classes for status"""
# Common status colors
badge_classes = {
# Task statuses
'todo': 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300',
'in_progress': 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-300',
'review': 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-300',
'done': 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-300',
'cancelled': 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-300',
# Invoice/Payment statuses
'draft': 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300',
'sent': 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-300',
'paid': 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-300',
'overdue': 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-300',
'unpaid': 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-300',
'partially_paid': 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-300',
'fully_paid': 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-300',
# Approval statuses
'pending': 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-300',
'approved': 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-300',
'rejected': 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-300',
'reimbursed': 'bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-300',
# Processing statuses
'processing': 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-300',
'completed': 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-300',
'failed': 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-300',
'partial': 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-300',
# Active/Inactive
'active': 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-300',
'inactive': 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300',
'archived': 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300',
}
return badge_classes.get(status, 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300')
def get_priority_badge_class(priority):
"""Get Tailwind CSS badge classes for priority"""
priority_classes = {
'low': 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300',
'medium': 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-300',
'high': 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-300',
'urgent': 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-300',
}
return priority_classes.get(priority, 'bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300')
# Register these functions to be available in templates
def register_i18n_filters(app):
"""Register i18n template filters"""
app.jinja_env.filters['task_status'] = get_task_status_display
app.jinja_env.filters['task_priority'] = get_task_priority_display
app.jinja_env.filters['project_status'] = get_project_status_display
app.jinja_env.filters['invoice_status'] = get_invoice_status_display
app.jinja_env.filters['payment_status'] = get_payment_status_display
app.jinja_env.filters['payment_method'] = get_payment_method_display
app.jinja_env.filters['expense_status'] = get_expense_status_display
app.jinja_env.filters['expense_category'] = get_expense_category_display
app.jinja_env.filters['mileage_status'] = get_mileage_status_display
app.jinja_env.filters['per_diem_status'] = get_per_diem_status_display
app.jinja_env.filters['job_status'] = get_job_status_display
app.jinja_env.filters['goal_status'] = get_goal_status_display
app.jinja_env.filters['alert_type'] = get_alert_type_display
app.jinja_env.filters['alert_level'] = get_alert_level_display
app.jinja_env.filters['client_status'] = get_client_status_display
app.jinja_env.filters['status_badge'] = get_status_badge_class
app.jinja_env.filters['priority_badge'] = get_priority_badge_class
+344
View File
@@ -0,0 +1,344 @@
"""
OCR utilities for receipt scanning and text extraction.
This module provides functionality to extract text and data from receipt images
using Tesseract OCR and parse common receipt information.
"""
import os
import re
from decimal import Decimal
from datetime import datetime
import logging
logger = logging.getLogger(__name__)
# Check if Tesseract is available
try:
import pytesseract
from PIL import Image
TESSERACT_AVAILABLE = True
except ImportError:
TESSERACT_AVAILABLE = False
logger.warning("pytesseract or PIL not installed. Receipt OCR will not be available.")
def is_ocr_available():
"""Check if OCR functionality is available"""
return TESSERACT_AVAILABLE
def extract_text_from_image(image_path, lang='eng'):
"""
Extract text from an image using Tesseract OCR.
Args:
image_path: Path to the image file
lang: OCR language (default: 'eng', can be 'eng+deu' for multilingual)
Returns:
Extracted text as string
"""
if not TESSERACT_AVAILABLE:
raise RuntimeError("Tesseract OCR is not available. Install pytesseract and PIL.")
try:
# Open and preprocess image
image = Image.open(image_path)
# Convert to RGB if necessary
if image.mode != 'RGB':
image = image.convert('RGB')
# Extract text
text = pytesseract.image_to_string(image, lang=lang)
return text
except Exception as e:
logger.error(f"Error extracting text from image {image_path}: {e}")
raise
def parse_receipt_data(text):
"""
Parse common receipt information from extracted text.
Args:
text: Extracted text from receipt
Returns:
Dictionary with parsed data (vendor, date, total, items, etc.)
"""
data = {
'vendor': None,
'date': None,
'total': None,
'tax': None,
'subtotal': None,
'items': [],
'currency': 'EUR',
'raw_text': text
}
lines = text.split('\n')
# Try to extract vendor (usually first few lines)
vendor_lines = []
for line in lines[:5]:
line = line.strip()
if line and len(line) > 3:
vendor_lines.append(line)
if vendor_lines:
data['vendor'] = vendor_lines[0]
# Extract amounts
amounts = extract_amounts(text)
if amounts:
# Try to identify total (usually largest amount or labeled as total)
total_candidates = []
for amount_info in amounts:
label = amount_info.get('label', '').lower()
if any(keyword in label for keyword in ['total', 'gesamt', 'suma', 'totale']):
data['total'] = amount_info['amount']
elif any(keyword in label for keyword in ['tax', 'vat', 'mwst', 'iva', 'tva']):
data['tax'] = amount_info['amount']
elif any(keyword in label for keyword in ['subtotal', 'zwischensumme', 'sous-total']):
data['subtotal'] = amount_info['amount']
else:
total_candidates.append(amount_info['amount'])
# If no labeled total found, use the largest amount
if not data['total'] and total_candidates:
data['total'] = max(total_candidates)
# Extract date
date = extract_date(text)
if date:
data['date'] = date
# Extract currency
currency = extract_currency(text)
if currency:
data['currency'] = currency
return data
def extract_amounts(text):
"""
Extract monetary amounts from text.
Returns:
List of dictionaries with 'amount' and 'label' keys
"""
amounts = []
# Patterns for amounts (supports various formats)
# Examples: 12.34, 12,34, $12.34, €12,34, 12.34 EUR
patterns = [
r'([A-Za-z\s]*?)\s*([$€£¥]?)\s*(\d{1,3}(?:[.,]\d{3})*[.,]\d{2})\s*([A-Z]{3})?',
]
for pattern in patterns:
matches = re.finditer(pattern, text, re.IGNORECASE | re.MULTILINE)
for match in matches:
label = match.group(1).strip() if match.group(1) else ''
symbol = match.group(2) if match.group(2) else ''
amount_str = match.group(3)
currency = match.group(4) if match.group(4) else ''
# Normalize amount (convert comma to dot if needed)
# Determine if comma or dot is decimal separator
if ',' in amount_str and '.' in amount_str:
# Has both, assume European format (1.234,56)
amount_str = amount_str.replace('.', '').replace(',', '.')
elif ',' in amount_str:
# Only comma, check if it's thousands separator or decimal
parts = amount_str.split(',')
if len(parts) == 2 and len(parts[1]) == 2:
# Likely decimal separator
amount_str = amount_str.replace(',', '.')
else:
# Likely thousands separator
amount_str = amount_str.replace(',', '')
try:
amount = Decimal(amount_str)
amounts.append({
'amount': amount,
'label': label,
'symbol': symbol,
'currency': currency
})
except (ValueError, Decimal.InvalidOperation):
continue
return amounts
def extract_date(text):
"""
Extract date from receipt text.
Returns:
datetime.date object or None
"""
# Common date patterns
patterns = [
r'(\d{1,2})[./\-](\d{1,2})[./\-](\d{2,4})', # DD/MM/YYYY or MM/DD/YYYY
r'(\d{4})[./\-](\d{1,2})[./\-](\d{1,2})', # YYYY-MM-DD
r'(\d{1,2})\s+(Jan|Feb|Mar|Apr|May|Jun|Jul|Aug|Sep|Oct|Nov|Dec)[a-z]*\s+(\d{2,4})', # DD Month YYYY
]
for pattern in patterns:
match = re.search(pattern, text, re.IGNORECASE)
if match:
try:
groups = match.groups()
if len(groups) == 3:
if pattern == patterns[0]: # DD/MM/YYYY or MM/DD/YYYY
# Try DD/MM/YYYY first (European format)
try:
day, month, year = int(groups[0]), int(groups[1]), int(groups[2])
if year < 100:
year += 2000
return datetime(year, month, day).date()
except ValueError:
# Try MM/DD/YYYY (US format)
try:
month, day, year = int(groups[0]), int(groups[1]), int(groups[2])
if year < 100:
year += 2000
return datetime(year, month, day).date()
except ValueError:
continue
elif pattern == patterns[1]: # YYYY-MM-DD
year, month, day = int(groups[0]), int(groups[1]), int(groups[2])
return datetime(year, month, day).date()
elif pattern == patterns[2]: # DD Month YYYY
day = int(groups[0])
month_str = groups[1].lower()
year = int(groups[2])
if year < 100:
year += 2000
months = {
'jan': 1, 'feb': 2, 'mar': 3, 'apr': 4,
'may': 5, 'jun': 6, 'jul': 7, 'aug': 8,
'sep': 9, 'oct': 10, 'nov': 11, 'dec': 12
}
month = months.get(month_str[:3])
if month:
return datetime(year, month, day).date()
except (ValueError, TypeError):
continue
return None
def extract_currency(text):
"""
Extract currency code from receipt text.
Returns:
3-letter currency code (ISO 4217) or 'EUR' as default
"""
# Currency symbols and their codes
currency_symbols = {
'$': 'USD',
'': 'EUR',
'£': 'GBP',
'¥': 'JPY',
'': 'INR',
'Fr': 'CHF'
}
# Look for currency symbols
for symbol, code in currency_symbols.items():
if symbol in text:
return code
# Look for currency codes (3 uppercase letters)
currency_pattern = r'\b([A-Z]{3})\b'
matches = re.findall(currency_pattern, text)
# Common currency codes
common_currencies = ['USD', 'EUR', 'GBP', 'JPY', 'CHF', 'CAD', 'AUD', 'INR']
for match in matches:
if match in common_currencies:
return match
return 'EUR' # Default
def scan_receipt(image_path, lang='eng'):
"""
Scan a receipt image and extract structured data.
Args:
image_path: Path to the receipt image
lang: OCR language(s) to use (e.g., 'eng', 'eng+deu')
Returns:
Dictionary with extracted receipt data
"""
if not is_ocr_available():
return {
'error': 'OCR not available',
'message': 'Please install pytesseract and Pillow: pip install pytesseract pillow'
}
try:
# Extract text
text = extract_text_from_image(image_path, lang=lang)
# Parse data
data = parse_receipt_data(text)
return data
except Exception as e:
logger.error(f"Error scanning receipt {image_path}: {e}")
return {
'error': str(e),
'message': 'Failed to scan receipt'
}
def get_suggested_expense_data(receipt_data):
"""
Convert receipt data to expense form data suggestions.
Args:
receipt_data: Dictionary returned by scan_receipt()
Returns:
Dictionary with suggested expense data
"""
suggestions = {}
if receipt_data.get('vendor'):
suggestions['vendor'] = receipt_data['vendor']
suggestions['title'] = f"Receipt from {receipt_data['vendor']}"
if receipt_data.get('total'):
suggestions['amount'] = float(receipt_data['total'])
if receipt_data.get('tax'):
suggestions['tax_amount'] = float(receipt_data['tax'])
if receipt_data.get('date'):
suggestions['expense_date'] = receipt_data['date'].isoformat()
if receipt_data.get('currency'):
suggestions['currency_code'] = receipt_data['currency']
return suggestions
+48 -2
View File
@@ -79,12 +79,58 @@ class InvoicePDFGenerator:
except Exception:
return str(value)
# Convert lazy='dynamic' relationships to lists for template rendering
# This ensures {% for item in invoice.items %} works correctly
try:
if hasattr(self.invoice.items, 'all'):
# It's a SQLAlchemy Query object - need to call .all()
invoice_items = self.invoice.items.all()
else:
# Already a list or other iterable
invoice_items = list(self.invoice.items) if self.invoice.items else []
except Exception:
invoice_items = []
try:
if hasattr(self.invoice.extra_goods, 'all'):
# It's a SQLAlchemy Query object - need to call .all()
invoice_extra_goods = self.invoice.extra_goods.all()
else:
# Already a list or other iterable
invoice_extra_goods = list(self.invoice.extra_goods) if self.invoice.extra_goods else []
except Exception:
invoice_extra_goods = []
# Create a wrapper object that has the converted lists
from types import SimpleNamespace
invoice_data = SimpleNamespace()
# Copy all attributes from original invoice
for attr in dir(self.invoice):
if not attr.startswith('_'):
try:
setattr(invoice_data, attr, getattr(self.invoice, attr))
except Exception:
pass
# Override with converted lists
invoice_data.items = invoice_items
invoice_data.extra_goods = invoice_extra_goods
# Convert expenses from Query to list
try:
if hasattr(self.invoice, 'expenses') and hasattr(self.invoice.expenses, 'all'):
invoice_expenses = self.invoice.expenses.all()
else:
invoice_expenses = list(self.invoice.expenses) if self.invoice.expenses else []
except Exception:
invoice_expenses = []
invoice_data.expenses = invoice_expenses
try:
# Render using Flask's Jinja environment to include app filters and _()
if html_template:
from flask import render_template_string
html = render_template_string(html_template,
invoice=self.invoice,
invoice=invoice_data, # Use wrapped object with lists
settings=self.settings,
Path=Path,
get_logo_base64=get_logo_base64,
@@ -101,7 +147,7 @@ class InvoicePDFGenerator:
if not html:
try:
html = render_template('invoices/pdf_default.html',
invoice=self.invoice,
invoice=invoice_data, # Use wrapped object with lists
settings=self.settings,
Path=Path,
get_logo_base64=get_logo_base64,
+60 -1
View File
@@ -4,8 +4,9 @@ import logging
from datetime import datetime, timedelta
from flask import current_app
from app import db
from app.models import Invoice, User, TimeEntry
from app.models import Invoice, User, TimeEntry, Project, BudgetAlert
from app.utils.email import send_overdue_invoice_notification, send_weekly_summary
from app.utils.budget_forecasting import check_budget_alerts
logger = logging.getLogger(__name__)
@@ -142,6 +143,52 @@ def send_weekly_summaries():
return 0
def check_project_budget_alerts():
"""Check all active projects for budget alerts
This task should be run periodically (e.g., every 6 hours) to check
project budgets and create alerts when thresholds are exceeded.
"""
try:
logger.info("Checking project budget alerts...")
# Get all active projects with budgets
projects = Project.query.filter(
Project.budget_amount.isnot(None),
Project.status == 'active'
).all()
logger.info(f"Found {len(projects)} active projects with budgets")
total_alerts_created = 0
for project in projects:
try:
# Check for budget alerts
alerts_to_create = check_budget_alerts(project.id)
# Create alerts
for alert_data in alerts_to_create:
alert = BudgetAlert.create_alert(
project_id=alert_data['project_id'],
alert_type=alert_data['type'],
budget_consumed_percent=alert_data['budget_consumed_percent'],
budget_amount=alert_data['budget_amount'],
consumed_amount=alert_data['consumed_amount']
)
total_alerts_created += 1
logger.info(f"Created {alert_data['type']} alert for project {project.name}")
except Exception as e:
logger.error(f"Error checking budget alerts for project {project.id}: {e}")
logger.info(f"Created {total_alerts_created} budget alerts")
return total_alerts_created
except Exception as e:
logger.error(f"Error checking project budget alerts: {e}")
return 0
def register_scheduled_tasks(scheduler):
"""Register all scheduled tasks with APScheduler
@@ -174,6 +221,18 @@ def register_scheduled_tasks(scheduler):
)
logger.info("Registered weekly summaries task")
# Check budget alerts every 6 hours
scheduler.add_job(
func=check_project_budget_alerts,
trigger='cron',
hour='*/6',
minute=0,
id='check_budget_alerts',
name='Check project budget alerts',
replace_existing=True
)
logger.info("Registered budget alerts check task")
except Exception as e:
logger.error(f"Error registering scheduled tasks: {e}")
+56
View File
@@ -162,6 +162,62 @@ def register_template_filters(app):
y = int(years)
return f"{y} year{'s' if y != 1 else ''} ago"
@app.template_filter('currency_symbol')
def currency_symbol_filter(currency_code):
"""Convert currency code to symbol"""
if not currency_code:
return '$'
currency_symbols = {
'USD': '$',
'EUR': '',
'GBP': '£',
'JPY': '¥',
'CNY': '¥',
'INR': '',
'AUD': 'A$',
'CAD': 'C$',
'CHF': 'CHF',
'SEK': 'kr',
'NOK': 'kr',
'DKK': 'kr',
'PLN': '',
'CZK': '',
'RUB': '',
'BRL': 'R$',
'ZAR': 'R',
'MXN': 'MX$',
'SGD': 'S$',
'HKD': 'HK$',
'NZD': 'NZ$',
'KRW': '',
'TRY': '',
'AED': 'د.إ',
'SAR': '',
}
return currency_symbols.get(currency_code.upper(), currency_code)
@app.template_filter('currency_icon')
def currency_icon_filter(currency_code):
"""Convert currency code to FontAwesome icon class"""
if not currency_code:
return 'fa-dollar-sign'
currency_icons = {
'USD': 'fa-dollar-sign',
'EUR': 'fa-euro-sign',
'GBP': 'fa-pound-sign',
'JPY': 'fa-yen-sign',
'CNY': 'fa-yen-sign',
'INR': 'fa-rupee-sign',
'RUB': 'fa-ruble-sign',
'BRL': 'fa-dollar-sign',
'TRY': 'fa-lira-sign',
}
return currency_icons.get(currency_code.upper(), 'fa-dollar-sign')
def get_logo_base64(logo_path):
"""Convert logo file to base64 data URI for PDF embedding"""
+3 -3
View File
@@ -1,5 +1,5 @@
[python: **.py]
[jinja2: **/templates/**.html]
extensions=jinja2.ext.autoescape,jinja2.ext.with_
[python: app/**.py]
[python: *.py]
[jinja2: app/templates/**.html]
encoding = utf-8
+17 -6
View File
@@ -323,12 +323,17 @@ execute_fresh_init() {
# Apply any pending migrations
log "Applying pending migrations..."
if ! flask db upgrade; then
# Capture output to a temporary file so we can show it if migration fails
MIGRATION_OUTPUT=$(mktemp)
if ! flask db upgrade 2>&1 | tee "$MIGRATION_OUTPUT"; then
log "✗ Migration application failed"
log "Error details:"
flask db upgrade 2>&1 | head -20
cat "$MIGRATION_OUTPUT"
rm -f "$MIGRATION_OUTPUT"
return 1
fi
rm -f "$MIGRATION_OUTPUT"
log "✓ Migrations applied"
# Wait a moment for tables to be fully committed
@@ -370,12 +375,15 @@ execute_fresh_init() {
# Apply migration
log "Applying initial migration..."
if ! flask db upgrade; then
MIGRATION_OUTPUT=$(mktemp)
if ! flask db upgrade 2>&1 | tee "$MIGRATION_OUTPUT"; then
log "✗ Initial migration application failed"
log "Error details:"
flask db upgrade 2>&1 | head -20
cat "$MIGRATION_OUTPUT"
rm -f "$MIGRATION_OUTPUT"
return 1
fi
rm -f "$MIGRATION_OUTPUT"
log "✓ Initial migration applied"
return 0
@@ -391,12 +399,15 @@ execute_check_migrations() {
log "Current migration revision: $current_revision"
# Check for pending migrations
if ! flask db upgrade; then
MIGRATION_OUTPUT=$(mktemp)
if ! flask db upgrade 2>&1 | tee "$MIGRATION_OUTPUT"; then
log "✗ Migration check failed"
log "Error details:"
flask db upgrade 2>&1 | head -20
cat "$MIGRATION_OUTPUT"
rm -f "$MIGRATION_OUTPUT"
return 1
fi
rm -f "$MIGRATION_OUTPUT"
log "✓ Migrations checked and applied"
return 0
+525
View File
@@ -0,0 +1,525 @@
# Budget Alerts & Forecasting
This document describes the Budget Alerts & Forecasting feature in the TimeTracker application.
## Overview
The Budget Alerts & Forecasting feature provides comprehensive budget monitoring and predictive analytics for projects with defined budgets. It helps project managers and administrators:
- Monitor budget consumption in real-time
- Receive automatic alerts when budget thresholds are exceeded
- Forecast project completion dates based on burn rates
- Analyze resource allocation and cost trends
- Make data-driven decisions about project budgets
## Features
### 1. Budget Monitoring
The system continuously monitors budget consumption for all active projects with defined budgets. Budget consumption is calculated based on:
- **Billable Time Entries**: Hours worked multiplied by the project's hourly rate
- **Project Costs**: Direct expenses (materials, travel, equipment, etc.)
### 2. Budget Alerts
Budget alerts are automatically generated when specific thresholds are reached:
#### Alert Types
1. **Warning (80%)**: Triggered when budget consumption reaches the configured threshold (default 80%)
- Alert Level: Warning
- Purpose: Early warning to allow corrective action
2. **Budget Reached (100%)**: Triggered when budget is fully consumed
- Alert Level: Critical
- Purpose: Immediate notification that budget limit has been reached
3. **Over Budget**: Triggered when budget is exceeded
- Alert Level: Critical
- Purpose: Alert that project has gone over budget
#### Alert Management
- Alerts are automatically created every 6 hours via a background task
- Duplicate alerts are prevented within a 24-hour window
- Alerts can be acknowledged by users with access to the project
- Acknowledged alerts are hidden from the active alerts list
- Alert history is preserved for reporting and audit purposes
### 3. Burn Rate Calculation
The burn rate feature calculates how quickly a project is consuming its budget:
- **Daily Burn Rate**: Average cost per day
- **Weekly Burn Rate**: Average cost per week
- **Monthly Burn Rate**: Average cost per month
Burn rates are calculated based on a configurable time period (default: 30 days) and include both time-based costs and direct project expenses.
### 4. Completion Date Estimation
The system estimates when a project's budget will be exhausted based on:
- Current burn rate
- Remaining budget
- Historical spending patterns
#### Confidence Levels
Estimates include a confidence level based on data consistency:
- **High Confidence**: Consistent spending pattern with sufficient historical data
- **Medium Confidence**: Moderate variation in spending pattern
- **Low Confidence**: High variation or insufficient historical data
### 5. Resource Allocation Analysis
Provides detailed breakdown of:
- Hours worked per team member
- Cost per team member
- Percentage contribution to total costs
- Number of time entries per team member
- Average hours per entry
This helps identify:
- Most resource-intensive team members
- Resource utilization patterns
- Cost distribution across the team
### 6. Cost Trend Analysis
Analyzes spending patterns over time with three granularities:
- **Daily**: Day-by-day cost breakdown
- **Weekly**: Week-by-week cost breakdown (default)
- **Monthly**: Month-by-month cost breakdown
#### Trend Indicators
- **Increasing**: Costs are trending upward
- **Decreasing**: Costs are trending downward
- **Stable**: Costs are relatively consistent
- **Insufficient Data**: Not enough data for trend analysis
### 7. Budget Status Dashboard
Central dashboard showing:
- Summary cards with key metrics
- Active budget alerts
- Project budget status table
- Quick access to detailed project analysis
## User Interface
### Budget Dashboard (`/budget/dashboard`)
Main entry point for budget monitoring with:
- Alert summary cards (unacknowledged, critical, warnings)
- Active alerts list with acknowledge functionality
- Project budget status table with progress bars
- Quick filters and refresh capability
### Project Budget Detail (`/budget/project/<project_id>`)
Detailed view for a specific project including:
- Budget status cards (total, consumed, remaining, status)
- Burn rate analysis panel
- Completion date estimation
- Interactive cost trend chart
- Resource allocation table
- Project-specific alerts
## API Endpoints
### GET `/budget/dashboard`
Display the main budget dashboard page
**Access**: Users with access to at least one budgeted project
### GET `/api/budget/burn-rate/<project_id>`
Get burn rate metrics for a project
**Parameters**:
- `days` (optional): Number of days to analyze (default: 30)
**Response**:
```json
{
"daily_burn_rate": 400.50,
"weekly_burn_rate": 2803.50,
"monthly_burn_rate": 12015.00,
"period_total": 12000.00,
"period_days": 30,
"start_date": "2025-10-01",
"end_date": "2025-10-31"
}
```
### GET `/api/budget/completion-estimate/<project_id>`
Get estimated completion date based on burn rate
**Parameters**:
- `days` (optional): Number of days to analyze for burn rate (default: 30)
**Response**:
```json
{
"estimated_completion_date": "2025-12-15",
"days_remaining": 45,
"budget_amount": 10000.00,
"consumed_amount": 7500.00,
"remaining_budget": 2500.00,
"daily_burn_rate": 55.56,
"confidence": "high",
"message": "Based on 30 days of activity"
}
```
### GET `/api/budget/resource-allocation/<project_id>`
Get resource allocation analysis
**Parameters**:
- `days` (optional): Number of days to analyze (default: 30)
**Response**:
```json
{
"users": [
{
"user_id": 1,
"username": "John Doe",
"hours": 120.50,
"cost": 12050.00,
"cost_percentage": 60.5,
"hours_percentage": 55.2,
"entry_count": 45,
"average_hours_per_entry": 2.68
}
],
"total_hours": 218.00,
"total_cost": 19900.00,
"period_days": 30,
"hourly_rate": 100.00
}
```
### GET `/api/budget/cost-trends/<project_id>`
Get cost trend analysis
**Parameters**:
- `days` (optional): Number of days to analyze (default: 90)
- `granularity` (optional): 'day', 'week', or 'month' (default: 'week')
**Response**:
```json
{
"periods": [
{"period": "2025-W40", "cost": 1250.00},
{"period": "2025-W41", "cost": 1380.00}
],
"trend_direction": "increasing",
"average_cost_per_period": 1315.00,
"trend_percentage": 10.4,
"granularity": "week",
"period_count": 12
}
```
### GET `/api/budget/status/<project_id>`
Get comprehensive budget status
**Response**:
```json
{
"budget_amount": 10000.00,
"consumed_amount": 8250.00,
"remaining_amount": 1750.00,
"consumed_percentage": 82.5,
"status": "critical",
"threshold_percent": 80,
"project_name": "Project Alpha",
"project_id": 123
}
```
### GET `/api/budget/alerts`
Get budget alerts
**Parameters**:
- `project_id` (optional): Filter by project ID
- `acknowledged` (optional): Filter by acknowledgment status (default: false)
**Response**:
```json
{
"alerts": [
{
"id": 1,
"project_id": 123,
"project_name": "Project Alpha",
"alert_type": "warning_80",
"alert_level": "warning",
"budget_consumed_percent": 82.5,
"budget_amount": 10000.00,
"consumed_amount": 8250.00,
"message": "Warning: Project has consumed 82.5% of budget",
"is_acknowledged": false,
"created_at": "2025-10-31T10:30:00"
}
],
"count": 1
}
```
### POST `/api/budget/alerts/<alert_id>/acknowledge`
Acknowledge a budget alert
**Response**:
```json
{
"message": "Alert acknowledged successfully",
"alert": { /* alert object */ }
}
```
### POST `/api/budget/check-alerts/<project_id>`
Manually check and create alerts for a project (admin only)
**Response**:
```json
{
"message": "Checked alerts for project Project Alpha",
"alerts_created": 1,
"alerts": [ /* created alerts */ ]
}
```
### GET `/api/budget/summary`
Get summary of all budget alerts and project statuses
**Response**:
```json
{
"total_projects": 15,
"healthy": 8,
"warning": 4,
"critical": 2,
"over_budget": 1,
"total_budget": 150000.00,
"total_consumed": 98500.00,
"projects": [ /* budget status for each project */ ],
"alert_stats": {
"total_alerts": 12,
"unacknowledged_alerts": 5,
"critical_alerts": 3
}
}
```
## Database Schema
### budget_alerts Table
| Column | Type | Description |
|--------|------|-------------|
| id | Integer | Primary key |
| project_id | Integer | Foreign key to projects table |
| alert_type | String(20) | Type of alert (warning_80, warning_100, over_budget) |
| alert_level | String(20) | Severity level (info, warning, critical) |
| budget_consumed_percent | Numeric(5,2) | Percentage of budget consumed |
| budget_amount | Numeric(10,2) | Total budget at time of alert |
| consumed_amount | Numeric(10,2) | Amount consumed at time of alert |
| message | Text | Alert message |
| is_acknowledged | Boolean | Whether alert has been acknowledged |
| acknowledged_by | Integer | User ID who acknowledged (nullable) |
| acknowledged_at | DateTime | When alert was acknowledged (nullable) |
| created_at | DateTime | When alert was created |
**Indexes**:
- `ix_budget_alerts_project_id` on project_id
- `ix_budget_alerts_acknowledged_by` on acknowledged_by
- `ix_budget_alerts_created_at` on created_at
- `ix_budget_alerts_is_acknowledged` on is_acknowledged
- `ix_budget_alerts_alert_type` on alert_type
## Background Tasks
### Budget Alert Checking
The system runs a scheduled task every 6 hours to check all active projects with budgets:
```python
# Scheduled at: 00:00, 06:00, 12:00, 18:00 daily
check_project_budget_alerts()
```
This task:
1. Queries all active projects with budgets
2. Calculates current budget consumption
3. Checks against thresholds
4. Creates alerts if thresholds are exceeded
5. Prevents duplicate alerts within 24 hours
## Configuration
### Project Budget Settings
Budget alerts can be configured per project:
- **Budget Amount**: Total budget allocated to the project
- **Budget Threshold**: Percentage at which to trigger warning alerts (default: 80%)
These settings can be configured when creating or editing a project.
### System Configuration
The background task schedule can be modified in `app/utils/scheduled_tasks.py`:
```python
scheduler.add_job(
func=check_project_budget_alerts,
trigger='cron',
hour='*/6', # Modify this to change frequency
minute=0,
id='check_budget_alerts',
name='Check project budget alerts',
replace_existing=True
)
```
## Permissions
### Access Control
- **Admin Users**: Full access to all budget features for all projects
- **Regular Users**: Access to budget information for projects they have worked on
- **Budget Dashboard**: Available to all authenticated users
- **Alert Acknowledgment**: Available to users with access to the project
- **Manual Alert Checking**: Admin only
## Usage Examples
### Viewing Budget Dashboard
1. Navigate to `/budget/dashboard`
2. View summary cards showing total alerts and project counts
3. Review active alerts list
4. Click on project names to see detailed analysis
### Monitoring a Specific Project
1. From the budget dashboard, click "Details" for a project
2. Review the budget status cards
3. Analyze the burn rate to understand spending patterns
4. Check the completion estimate to plan accordingly
5. Review resource allocation to identify high-cost team members
6. Examine cost trends to spot patterns
### Acknowledging Alerts
1. View an active alert on the dashboard or project detail page
2. Click the "Acknowledge" button
3. The alert is marked as acknowledged and removed from active alerts list
### Manual Alert Check (Admin)
1. Navigate to a project's budget detail page
2. Use the API endpoint `/api/budget/check-alerts/<project_id>`
3. System checks current budget status and creates alerts if needed
## Best Practices
1. **Set Realistic Budgets**: Ensure project budgets are realistic and based on historical data
2. **Configure Appropriate Thresholds**: Adjust warning thresholds based on project risk tolerance
3. **Regular Monitoring**: Review the budget dashboard regularly to catch issues early
4. **Acknowledge Alerts**: Acknowledge alerts after reviewing them to keep the dashboard clean
5. **Analyze Trends**: Use cost trend analysis to identify patterns and adjust resource allocation
6. **Review Resource Allocation**: Regularly review which team members are consuming the most budget
7. **Act on Warnings**: Take corrective action when warning alerts are triggered
## Troubleshooting
### No Alerts Being Generated
- Verify that projects have `budget_amount` set
- Check that background scheduler is running
- Verify that budget consumption actually exceeds thresholds
- Check logs for any errors in the scheduled task
### Inaccurate Burn Rate Calculations
- Ensure time entries have `billable` flag set correctly
- Verify that project `hourly_rate` is set
- Check that time entries have `end_time` set (completed entries)
- Review the analysis period (try different `days` values)
### Missing Projects in Dashboard
- Verify project has `budget_amount` set
- Check that project `status` is 'active'
- For non-admin users, ensure they have time entries on the project
### Completion Estimate Shows "Low Confidence"
- This indicates inconsistent spending patterns
- Increase the analysis period (`days` parameter)
- Ensure sufficient time entries exist
- Review actual spending patterns for irregularities
## Migration
The budget alerts feature requires a database migration:
```bash
# Run the migration
alembic upgrade head
# Or use the manage migrations script
python migrations/manage_migrations.py upgrade
```
This creates the `budget_alerts` table with all necessary indexes.
## Testing
The feature includes comprehensive tests:
- **Unit Tests**: `tests/test_budget_forecasting.py` - Tests all utility functions
- **Model Tests**: `tests/test_budget_alert_model.py` - Tests BudgetAlert model
- **Smoke Tests**: `tests/test_budget_alerts_smoke.py` - Integration and end-to-end tests
Run tests with:
```bash
pytest tests/test_budget_forecasting.py
pytest tests/test_budget_alert_model.py
pytest tests/test_budget_alerts_smoke.py
```
## Future Enhancements
Potential future improvements:
1. **Email Notifications**: Send email alerts when budget thresholds are exceeded
2. **Custom Alert Thresholds**: Allow multiple custom thresholds per project
3. **Budget Forecasting AI**: Use machine learning to improve completion date predictions
4. **Budget Templates**: Create reusable budget templates for similar projects
5. **Multi-Currency Support**: Handle projects with different currencies
6. **Budget Revisions**: Track budget changes and revisions over time
7. **What-If Analysis**: Simulate different scenarios and their impact on budget
8. **Export Reports**: Generate PDF/Excel reports of budget analysis
9. **Budget Rollover**: Automatically rollover unused budget to related projects
10. **Team Budget Limits**: Set budget limits per team member
## Related Documentation
- [Project Management](./PROJECT_MANAGEMENT.md)
- [Time Tracking](./TIME_TRACKING.md)
- [Reports](./REPORTS.md)
- [API Documentation](./API.md)
+227
View File
@@ -0,0 +1,227 @@
# Bulk Task Operations
This document describes the bulk task operations feature that allows users to perform actions on multiple tasks simultaneously.
## Overview
The bulk task operations feature provides an efficient way to manage multiple tasks at once, reducing the time and effort required for common administrative tasks. This feature is available on the main task list page.
## Features
### 1. Multi-Select Checkboxes
- Each task in the list has a checkbox for selection
- A "Select All" checkbox in the header selects/deselects all visible tasks
- Selected count is displayed in the bulk actions menu
- Visual feedback shows which tasks are selected
### 2. Bulk Status Change
Change the status of multiple tasks simultaneously.
**How to use:**
1. Select one or more tasks using checkboxes
2. Click "Bulk Actions" button
3. Select "Change Status"
4. Choose the desired status from the dropdown
5. Click "Update Status"
**Supported statuses:**
- To Do
- In Progress
- Review
- Done
- Cancelled
**Behavior:**
- Updates all selected tasks to the chosen status
- When reopening completed tasks, automatically clears the `completed_at` timestamp
- Respects permission checks (users can only update tasks they created)
- Provides feedback on success and any skipped tasks
### 3. Bulk Assignment
Assign multiple tasks to a user at once.
**How to use:**
1. Select one or more tasks using checkboxes
2. Click "Bulk Actions" button
3. Select "Assign To"
4. Choose the user from the dropdown
5. Click "Assign Tasks"
**Behavior:**
- Assigns all selected tasks to the chosen user
- Users can only assign tasks they created (unless they're admin)
- Provides feedback on success and any skipped tasks
### 4. Bulk Move to Project
Move multiple tasks to a different project.
**How to use:**
1. Select one or more tasks using checkboxes
2. Click "Bulk Actions" button
3. Select "Move to Project"
4. Choose the target project from the dropdown
5. Click "Move Tasks"
**Behavior:**
- Moves all selected tasks to the target project
- Automatically updates related time entries to match the new project
- Logs task activity for the project change
- Users can only move tasks they created (unless they're admin)
- Only active projects are shown in the dropdown
### 5. Bulk Delete
Delete multiple tasks at once (with confirmation).
**How to use:**
1. Select one or more tasks using checkboxes
2. Click "Bulk Actions" button
3. Select "Delete"
4. Confirm the deletion in the dialog
5. Click "Delete" to proceed
**Behavior:**
- Requires confirmation before deletion
- Tasks with existing time entries are automatically skipped (not deleted)
- Users can only delete tasks they created (unless they're admin)
- Provides feedback on success and any skipped tasks
- Deletion is permanent and cannot be undone
## Permissions
Bulk operations respect the following permission rules:
- **Regular Users**: Can only perform bulk operations on tasks they created
- **Admin Users**: Can perform bulk operations on any tasks
- **Permission Violations**: Tasks that the user doesn't have permission to modify are automatically skipped with a warning message
## User Interface
### Bulk Actions Button
Located in the task list toolbar, the button shows:
- Number of selected tasks
- Disabled state when no tasks are selected
- Dropdown menu with all available bulk operations
### Dialog Boxes
Each bulk operation (except delete) has a dedicated dialog with:
- Clear title explaining the action
- Dropdown for selecting the target (status, user, or project)
- Cancel button to abort the operation
- Submit button to perform the action
### Confirmation Dialog
The bulk delete operation shows a confirmation dialog with:
- Warning about permanent deletion
- Note about tasks with time entries being skipped
- Cancel and Delete buttons
## Technical Details
### Routes
All bulk operation routes are POST endpoints:
```
POST /tasks/bulk-delete - Delete multiple tasks
POST /tasks/bulk-status - Change status for multiple tasks
POST /tasks/bulk-assign - Assign multiple tasks to a user
POST /tasks/bulk-move-project - Move multiple tasks to a project
```
### Request Format
All routes expect the following POST data:
```
task_ids[]: Array of task IDs (e.g., ['1', '2', '3'])
status: Target status (for bulk-status)
assigned_to: User ID (for bulk-assign)
project_id: Project ID (for bulk-move-project)
csrf_token: CSRF protection token
```
### Response Behavior
- **Success**: Redirects to task list with success flash message
- **Partial Success**: Redirects with success message and warning about skipped tasks
- **Error**: Redirects with error flash message
- **No Selection**: Returns warning about no tasks selected
### Database Operations
- All bulk operations are performed in a single database transaction
- Changes are committed only after all validations pass
- Failed operations result in a rollback
- Activity logging for audit trail (where applicable)
## Best Practices
1. **Review Selection**: Always review selected tasks before performing bulk operations
2. **Start Small**: Test with a small number of tasks first
3. **Check Permissions**: Ensure you have permission to modify the selected tasks
4. **Time Entries**: Remember that tasks with time entries cannot be deleted
5. **Backup Data**: For critical operations, ensure you have recent backups
## Error Handling
The feature includes comprehensive error handling:
- **No Tasks Selected**: Friendly warning message
- **Invalid Input**: Validation errors with specific messages
- **Permission Denied**: Tasks are skipped with warning
- **Database Errors**: Safe rollback with error message
- **Network Issues**: Standard browser error handling
## Testing
Comprehensive tests are available in `tests/test_bulk_task_operations.py`:
- Unit tests for each operation
- Integration tests with real data
- Permission checking tests
- Error handling tests
- Smoke tests for route availability
To run the tests:
```bash
pytest tests/test_bulk_task_operations.py -v
```
## Future Enhancements
Potential improvements for future versions:
1. **Bulk Priority Change**: Change priority for multiple tasks
2. **Bulk Due Date Update**: Set due dates for multiple tasks
3. **Export Selected**: Export only selected tasks
4. **Undo Operation**: Ability to undo recent bulk operations
5. **Keyboard Shortcuts**: Quick access via keyboard shortcuts
6. **Advanced Selection**: Select by filters (e.g., all overdue tasks)
## Troubleshooting
### Tasks Not Being Updated
- Check that you have permission to modify the tasks
- Verify that the tasks exist and haven't been deleted
- Look for error messages in the flash notifications
### Bulk Delete Skipping Tasks
- Tasks with time entries cannot be deleted
- Delete time entries first, then retry
- Alternatively, use task archiving instead
### Selection Not Working
- Clear browser cache and reload
- Check JavaScript console for errors
- Ensure JavaScript is enabled in your browser
## Support
For issues or questions about bulk task operations:
1. Check this documentation first
2. Review the test suite for examples
3. Check the application logs for errors
4. Contact your system administrator
+652
View File
@@ -0,0 +1,652 @@
# Import/Export System Guide
## Overview
The TimeTracker Import/Export system provides comprehensive functionality for migrating data between time tracking systems, exporting data for GDPR compliance, and creating backups for disaster recovery.
## Features
### Import Features
- **CSV Import**: Bulk import time entries from CSV files
- **Toggl Track Import**: Direct integration with Toggl Track API
- **Harvest Import**: Direct integration with Harvest API
- **Backup Restore**: Restore from previous backups (admin only)
- **Migration Wizard**: Step-by-step import process with preview
### Export Features
- **GDPR Data Export**: Complete export of all user data for compliance
- **Filtered Export**: Export specific data with custom filters
- **Full Backup**: Complete database backup (admin only)
- **Multiple Formats**: JSON, CSV, and ZIP formats supported
## User Guide
### Accessing Import/Export
Navigate to the Import/Export page:
1. Click on your user menu in the top right
2. Select "Import/Export" from the dropdown
3. Or navigate directly to `/import-export`
---
## Import Guide
### CSV Import
#### CSV Format
The CSV file should have the following columns:
```csv
project_name,client_name,task_name,start_time,end_time,duration_hours,notes,tags,billable
Project A,Client A,Task 1,2024-01-01 09:00:00,2024-01-01 10:30:00,1.5,Meeting notes,meeting;planning,true
Project B,Client B,,2024-01-01 14:00:00,2024-01-01 16:00:00,2.0,Development work,dev;coding,true
```
#### Column Descriptions
| Column | Required | Description |
|--------|----------|-------------|
| `project_name` | Yes | Name of the project |
| `client_name` | No | Client name (defaults to project name if not provided) |
| `task_name` | No | Optional task name |
| `start_time` | Yes | Start time (YYYY-MM-DD HH:MM:SS or ISO format) |
| `end_time` | No | End time (leave empty if providing duration_hours) |
| `duration_hours` | No | Duration in hours (alternative to end_time) |
| `notes` | No | Notes or description |
| `tags` | No | Comma-separated tags (use semicolon to separate multiple tags) |
| `billable` | No | true/false (defaults to true) |
#### Supported Date Formats
- `YYYY-MM-DD HH:MM:SS` (e.g., 2024-01-01 09:00:00)
- `YYYY-MM-DDTHH:MM:SS` (ISO format)
- `YYYY-MM-DD` (assumes midnight)
- `DD/MM/YYYY HH:MM:SS`
- `MM/DD/YYYY HH:MM:SS`
#### Steps to Import CSV
1. Download the CSV template: Click "Download Template"
2. Fill in your time entries data
3. Click "Choose CSV File" and select your file
4. The import will start automatically
5. Check the Import History section for results
#### Handling Errors
If some records fail to import:
- Check the Import History for error details
- Common errors include:
- Invalid date formats
- Missing required fields (project_name, start_time)
- Invalid duration values
- Fix the errors in your CSV and re-import
---
### Toggl Track Import
#### Prerequisites
You'll need:
- Toggl Track API token (find in Profile Settings → API Token)
- Workspace ID (find in workspace settings)
#### Steps to Import from Toggl
1. Click "Import from Toggl"
2. Enter your API token
3. Enter your Workspace ID
4. Select date range for import
5. Click "Import"
6. Wait for the import to complete
#### What Gets Imported
- All time entries within the selected date range
- Projects (automatically created if they don't exist)
- Clients (linked to projects)
- Tasks (if present in Toggl)
- Tags
- Notes/descriptions
- Billable status
#### API Rate Limits
Toggl has rate limits on their API. For large imports:
- Import is done in batches of 50 entries
- Imports may take several minutes for large datasets
- If import fails due to rate limits, wait a few minutes and try again
---
### Harvest Import
#### Prerequisites
You'll need:
- Harvest Account ID (find in Account Settings)
- Personal Access Token (create in Developers → Personal Access Tokens)
#### Steps to Import from Harvest
1. Click "Import from Harvest"
2. Enter your Account ID
3. Enter your API Token
4. Select date range for import
5. Click "Import"
6. Wait for the import to complete
#### What Gets Imported
- All time entries within the selected date range
- Projects (automatically created if they don't exist)
- Clients (linked to projects)
- Tasks (if present in Harvest)
- Notes
- Billable status
- Hours tracked
#### Notes
- Harvest provides daily totals rather than start/end times
- Imported entries will have a default start time of 12:00 PM on the tracked date
- Duration is preserved accurately
---
## Export Guide
### GDPR Data Export
Export all your personal data for compliance with data protection regulations.
#### What's Included
- User profile information
- All time entries
- Projects you've worked on
- Tasks assigned to you
- Expenses and mileage records
- Comments and notes
- Focus sessions
- Saved filters and preferences
- Calendar events
- Weekly goals
#### Steps to Export
1. Choose export format:
- **JSON**: Single file with all data in JSON format
- **ZIP**: Multiple CSV files + JSON file in a ZIP archive
2. Click the export button
3. Wait for export to complete (usually < 1 minute)
4. Click "Download" when ready
5. Exports expire after 7 days
#### Export Formats
**JSON Export:**
```json
{
"export_info": {
"user_id": 1,
"username": "john.doe",
"export_date": "2024-01-15T10:30:00",
"export_type": "GDPR Full Data Export"
},
"user_profile": {...},
"time_entries": [...],
"projects": [...]
}
```
**ZIP Export:**
- `export.json` - Complete data in JSON
- `time_entries.csv` - Time entries
- `projects.csv` - Projects
- `expenses.csv` - Expenses
- etc.
---
### Filtered Export
Export specific data with custom filters.
#### Available Filters
- **Date Range**: Export data within specific dates
- **Project**: Export only specific project data
- **Billable Only**: Export only billable entries
- **Data Types**: Choose what to export (time entries, expenses, etc.)
#### Steps to Export
1. Click "Export with Filters"
2. Configure your filters
3. Choose export format (JSON or CSV)
4. Click "Export"
5. Download when ready
---
### Backup & Restore (Admin Only)
#### Creating Backups
Admins can create full database backups:
1. Click "Create Backup"
2. Wait for backup to complete
3. Download the backup file
4. Store securely (backup includes all system data)
#### What's Included in Backups
- All users
- All projects and clients
- All time entries
- All expenses and related data
- Tasks and comments
- System settings
- Invoices and payments
#### Restoring from Backup
⚠️ **Warning**: Restore will overwrite existing data!
1. Click "Restore Backup"
2. Select backup file (JSON format)
3. Confirm restoration
4. Wait for restore to complete
5. Review Import History for results
#### Best Practices
- Create backups regularly (daily or weekly)
- Test restore process in non-production environment
- Store backups in multiple locations
- Keep backups for at least 30 days
---
## API Documentation
### Authentication
All API endpoints require authentication. Include session cookies or API token in requests.
### Import Endpoints
#### CSV Import
```http
POST /api/import/csv
Content-Type: multipart/form-data
file: <csv_file>
```
**Response:**
```json
{
"success": true,
"import_id": 123,
"summary": {
"total": 100,
"successful": 95,
"failed": 5,
"errors": []
}
}
```
#### Toggl Import
```http
POST /api/import/toggl
Content-Type: application/json
{
"api_token": "your_api_token",
"workspace_id": "12345",
"start_date": "2024-01-01",
"end_date": "2024-12-31"
}
```
#### Harvest Import
```http
POST /api/import/harvest
Content-Type: application/json
{
"account_id": "12345",
"api_token": "your_api_token",
"start_date": "2024-01-01",
"end_date": "2024-12-31"
}
```
#### Import Status
```http
GET /api/import/status/<import_id>
```
**Response:**
```json
{
"id": 123,
"user": "john.doe",
"import_type": "csv",
"status": "completed",
"total_records": 100,
"successful_records": 95,
"failed_records": 5,
"started_at": "2024-01-15T10:00:00",
"completed_at": "2024-01-15T10:05:00"
}
```
#### Import History
```http
GET /api/import/history
```
### Export Endpoints
#### GDPR Export
```http
POST /api/export/gdpr
Content-Type: application/json
{
"format": "json" // or "zip"
}
```
**Response:**
```json
{
"success": true,
"export_id": 456,
"filename": "gdpr_export_john.doe_20240115_103000.json",
"download_url": "/api/export/download/456"
}
```
#### Filtered Export
```http
POST /api/export/filtered
Content-Type: application/json
{
"format": "json", // or "csv"
"filters": {
"include_time_entries": true,
"include_projects": false,
"include_expenses": true,
"start_date": "2024-01-01",
"end_date": "2024-12-31",
"project_id": null,
"billable_only": false
}
}
```
#### Create Backup (Admin Only)
```http
POST /api/export/backup
```
#### Download Export
```http
GET /api/export/download/<export_id>
```
Returns the export file for download.
#### Export Status
```http
GET /api/export/status/<export_id>
```
#### Export History
```http
GET /api/export/history
```
---
## Troubleshooting
### Import Issues
**Problem**: CSV import fails with "Invalid date format"
- **Solution**: Check date format matches supported formats. Use YYYY-MM-DD HH:MM:SS
**Problem**: "Project name is required" error
- **Solution**: Ensure every row has a project_name value
**Problem**: Toggl/Harvest import fails
- **Solution**:
- Verify API credentials are correct
- Check date range is valid
- Ensure you have access to the workspace/account
**Problem**: Import stuck in "processing" status
- **Solution**:
- Wait a few minutes (large imports take time)
- Check Import History for errors
- Try re-importing with smaller date range
### Export Issues
**Problem**: Export download says "expired"
- **Solution**: Create a new export (exports expire after 7 days)
**Problem**: Export file is empty
- **Solution**: Check that you have data in the selected date range/filters
**Problem**: ZIP export won't extract
- **Solution**: Ensure download completed fully, try re-downloading
---
## Database Schema
### DataImport Model
```python
class DataImport:
id: int
user_id: int
import_type: str # 'csv', 'toggl', 'harvest', 'backup'
source_file: str
status: str # 'pending', 'processing', 'completed', 'failed', 'partial'
total_records: int
successful_records: int
failed_records: int
error_log: str # JSON
import_summary: str # JSON
started_at: datetime
completed_at: datetime
```
### DataExport Model
```python
class DataExport:
id: int
user_id: int
export_type: str # 'full', 'filtered', 'backup', 'gdpr'
export_format: str # 'json', 'csv', 'xlsx', 'zip'
file_path: str
file_size: int
status: str # 'pending', 'processing', 'completed', 'failed'
filters: str # JSON
record_count: int
error_message: str
created_at: datetime
completed_at: datetime
expires_at: datetime
```
---
## Security & Privacy
### Data Protection
- All exports are private to the user who created them
- Exports expire after 7 days
- Export files are stored securely in `/data/uploads/exports`
- Only authenticated users can access their own exports
### Admin Privileges
- Backups require admin privileges
- Admins can see all import/export history
- Backup files contain ALL system data
### GDPR Compliance
The GDPR export feature provides:
- Complete data portability
- Machine-readable format (JSON)
- Human-readable format (CSV in ZIP)
- All personal data associated with the user
- Compliance with Article 20 (Right to Data Portability)
---
## Migration Wizard
The Migration Wizard provides a guided experience for importing data from other time trackers.
### Step 1: Choose Source
Select your source time tracker:
- Toggl Track
- Harvest
- CSV file
### Step 2: Enter Credentials
Provide API credentials for the source system.
### Step 3: Preview Data
See a preview of what will be imported:
- Number of entries
- Date range
- Projects and clients
### Step 4: Confirm Import
Review and start the import process.
### Step 5: Monitor Progress
Watch real-time import progress and see results.
---
## Developer Guide
### Adding New Import Sources
To add support for a new time tracker:
1. Create import function in `app/utils/data_import.py`:
```python
def import_from_new_tracker(user_id, credentials, start_date, end_date, import_record):
"""Import from new time tracker"""
# Fetch data from API
# Transform to TimeTracker format
# Create records in database
# Update import_record progress
pass
```
2. Add route in `app/routes/import_export.py`:
```python
@import_export_bp.route('/api/import/new-tracker', methods=['POST'])
@login_required
def import_new_tracker():
# Handle import request
pass
```
3. Add UI in template `app/templates/import_export/index.html`
### Adding New Export Formats
To support a new export format:
1. Add export function in `app/utils/data_export.py`
2. Update export routes to handle new format
3. Add format option in UI
---
## FAQ
**Q: How long are exports stored?**
A: Exports are automatically deleted after 7 days.
**Q: Can I schedule automatic exports?**
A: Not currently, but this feature is planned.
**Q: What happens to duplicates during import?**
A: Duplicate entries are imported as separate records. Use the date range and filters carefully.
**Q: Can I import from multiple Toggl workspaces?**
A: Yes, import from each workspace separately.
**Q: Are imported entries marked differently?**
A: Yes, imported entries have a `source` field set to 'toggl', 'harvest', 'import', etc.
**Q: Can I undo an import?**
A: No automatic undo, but you can filter by source and manually delete imported entries if needed.
---
## Support
For additional help:
- Check the main [README](../README.md)
- Review [API documentation](../docs/API.md)
- Report issues on GitHub
- Contact your system administrator
---
## Changelog
### Version 1.0 (Initial Release)
- CSV import functionality
- Toggl Track integration
- Harvest integration
- GDPR data export
- Filtered exports
- Backup/restore functionality
- Migration wizard
- Import/export history tracking
+244
View File
@@ -0,0 +1,244 @@
# Time Entry Templates
Time Entry Templates allow you to create reusable templates for frequently logged activities, saving time and ensuring consistency in your time tracking.
## Overview
Time Entry Templates help you:
- **Save time**: Start timers or create entries with pre-filled data
- **Ensure consistency**: Use the same project, task, and notes for recurring activities
- **Track patterns**: See which templates you use most often
- **Reduce errors**: Avoid manually entering the same information repeatedly
## Features
### Template Properties
Each template can include:
- **Name** (required): A descriptive name for quick identification
- **Description** (optional): Additional details about when to use this template
- **Project**: Pre-select a project for this activity
- **Task**: Pre-select a specific task within the project
- **Default Duration**: Set a standard duration in hours (e.g., 1.0, 0.5)
- **Default Notes**: Pre-fill notes/description for the time entry
- **Tags**: Comma-separated tags for categorization
- **Billable**: Whether time entries from this template should be billable
### Usage Tracking
Templates track:
- **Usage Count**: How many times the template has been used
- **Last Used**: When the template was last used
- Templates are automatically sorted by most recently used
## Using Templates
### Creating a Template
1. Navigate to **Templates** from the main navigation
2. Click **"New Template"**
3. Fill in the template details:
- Enter a descriptive name
- Select a project (and optionally a task)
- Set default duration if desired
- Add default notes and tags
4. Click **"Create Template"**
### Starting a Timer from a Template
There are three ways to use a template:
#### 1. From the Templates Page
1. Go to **Templates**
2. Click **"Use Template"** on any template card
3. You'll be redirected to create a time entry with pre-filled data
#### 2. From the Dashboard
1. On the dashboard, click **"Start Timer"**
2. In the start timer modal, you'll see a list of your recent templates
3. Click on any template to apply its data to the timer form
4. Click **"Start"** to begin tracking time
#### 3. Direct Timer Start
Some templates (those with a project assigned) have a direct "Start Timer" button that:
- Immediately starts a timer with the template's data
- Increments the template's usage count
- Takes you back to the dashboard
### Editing a Template
1. Navigate to **Templates**
2. Click the **edit icon** (pencil) on the template card
3. Update any fields as needed
4. Click **"Save Changes"**
### Deleting a Template
1. Navigate to **Templates**
2. Click the **delete icon** (trash) on the template card
3. Confirm the deletion
**Note**: Deleting a template does not affect any time entries that were created using it.
## Best Practices
### Naming Conventions
Use clear, descriptive names:
- ✅ Good: "Daily Standup", "Client Meeting - ProjectX", "Code Review"
- ❌ Poor: "Meeting", "Work", "Task1"
### When to Use Templates
Templates are ideal for:
- **Recurring meetings**: Daily standups, weekly syncs, client calls
- **Regular activities**: Code reviews, testing, documentation
- **Standard tasks**: Email correspondence, administrative work
- **Frequent projects**: Activities you do multiple times per week
### Organizing Templates
- Keep your template list focused (5-10 most-used templates)
- Delete or update templates you no longer use
- Use consistent naming and tagging schemes
- Review and clean up templates quarterly
### Duration Settings
- Leave duration blank for activities with variable length (start/stop timer)
- Set a duration for activities with predictable length (meetings, standup)
- Common durations: 0.25 (15 min), 0.5 (30 min), 1.0 (1 hour)
## API Integration
### Get All Templates
```http
GET /api/templates
```
Returns all templates for the current user.
**Response:**
```json
{
"templates": [
{
"id": 1,
"name": "Daily Standup",
"project_id": 5,
"project_name": "Internal",
"task_id": 12,
"task_name": "Team Meetings",
"default_duration": 0.25,
"default_notes": "Discussed progress and blockers",
"tags": "meeting,standup",
"billable": false,
"usage_count": 45,
"last_used_at": "2024-01-15T09:00:00Z"
}
]
}
```
### Get Single Template
```http
GET /api/templates/{template_id}
```
Returns a specific template by ID.
### Mark Template as Used
```http
POST /api/templates/{template_id}/use
```
Records that the template was used (increments usage count and updates last_used_at).
## Troubleshooting
### Template Not Showing in Dashboard
- The dashboard shows only your 5 most recently used templates
- Visit the Templates page to see all your templates
- Use a template to move it to the top of the list
### Cannot Start Timer from Template
- Ensure the template has a project assigned
- Verify the project is active (not archived)
- Stop any active timers before starting a new one
### Template Data Not Pre-filling
- Check that you're using the correct method (template button, not manual form)
- Verify the template has the fields you expect filled in
- Try editing and re-saving the template
## Migration Notes
If you're upgrading to a version with time entry templates:
1. Templates are stored in a new `time_entry_templates` table
2. No migration is needed - the feature is additive
3. Templates are user-specific and don't affect existing time entries
## Related Features
- **[Time Tracking](TIME_TRACKING.md)**: Learn about manual time entries and timer
- **[Projects](PROJECTS.md)**: Understanding projects and their settings
- **[Tasks](TASKS.md)**: Using tasks within projects
- **[Reports](REPORTS.md)**: Analyzing your time data
## Tips and Tricks
### Quick Template Creation
Create templates from your most frequent activities by:
1. Track your time for a week
2. Review your time entries
3. Create templates for activities that appear 3+ times
### Template Chains
For complex workflows:
- Create separate templates for each phase
- Use consistent naming: "ProjectX - Phase 1", "ProjectX - Phase 2"
- This helps with reporting and analysis
### Keyboard Shortcuts
When using templates on the dashboard:
- The templates list is keyboard accessible
- Use Tab to navigate, Enter to select
- This speeds up your workflow significantly
## Frequently Asked Questions
**Q: Can I share templates with my team?**
A: Templates are currently user-specific. Each team member needs to create their own templates.
**Q: Will deleting a template affect my past time entries?**
A: No, time entries are independent once created. Deleting a template doesn't affect any existing time entries.
**Q: How many templates can I create?**
A: There's no hard limit, but we recommend keeping 10-20 active templates for ease of use.
**Q: Can I import/export templates?**
A: Currently, templates are managed through the UI. API support allows for programmatic creation if needed.
**Q: Do templates work with the mobile interface?**
A: Yes, templates are fully functional on mobile devices through the responsive web interface.
## Feedback
We'd love to hear how you're using time entry templates! If you have suggestions or encounter issues, please:
- Open an issue on GitHub
- Contact support
- Contribute improvements via pull request
+43
View File
@@ -0,0 +1,43 @@
# Bug Fix: Template Application Error
## Issue
When users tried to select and apply a template from the start timer interface, they received an error message stating "can't apply the template".
## Root Cause
There were duplicate route definitions for the template API endpoints:
1. **In `app/routes/api.py` (lines 1440-1465)** - Registered first in the application
- `/api/templates/<int:template_id>` (GET)
- `/api/templates/<int:template_id>/use` (POST)
- **Problem**: Missing `TimeEntryTemplate` import, causing `NameError` when routes were accessed
2. **In `app/routes/time_entry_templates.py` (lines 301-326)** - Registered later
- Same routes with proper implementation
- Had correct imports and error handling
- Never executed due to duplicate route conflict
Since the `api_bp` blueprint was registered before `time_entry_templates_bp` in `app/__init__.py`, Flask used the broken routes from `api.py`, causing the error.
## Solution
Removed the duplicate route definitions from `app/routes/api.py` (lines 1440-1465), allowing the proper implementation in `app/routes/time_entry_templates.py` to be used.
### Code Changes
**File**: `app/routes/api.py`
- **Removed**: Lines 1440-1465 containing duplicate `/api/templates/<int:template_id>` routes
- **Reason**: Eliminate route conflict and use proper implementation
## Testing
All existing tests pass:
- ✅ `test_get_templates_api` - Get all templates
- ✅ `test_get_single_template_api` - Get specific template
- ✅ `test_use_template_api` - Mark template as used
- ✅ `test_start_timer_from_template` - Start timer from template
## Impact
- **Users can now successfully apply templates when starting timers**
- Template usage tracking works correctly
- No other functionality affected
## Date Fixed
October 31, 2025
+496
View File
@@ -0,0 +1,496 @@
# Project Dashboard Feature
## Overview
The Project Dashboard provides a comprehensive, visual overview of project performance, progress, and team contributions. It aggregates key metrics and presents them through interactive charts and visualizations, making it easy to track project health at a glance.
## Features
### 1. Key Metrics Overview
- **Total Hours**: Real-time tracking of all logged hours on the project
- **Budget Used**: Visual representation of consumed budget vs. allocated budget
- **Task Completion**: Percentage of tasks completed with completion rate
- **Team Size**: Number of team members actively contributing to the project
### 2. Budget vs. Actual Visualization
- **Budget Tracking**: Compare budgeted amount against actual consumption
- **Hours Comparison**: Estimated hours vs. actual hours worked
- **Threshold Warnings**: Visual alerts when budget threshold is exceeded
- **Remaining Budget**: Calculate and display remaining budget
- **Interactive Bar Chart**: Visual representation using Chart.js
### 3. Task Status Distribution
- **Status Breakdown**: Visual pie chart showing tasks by status (Todo, In Progress, Review, Done, Cancelled)
- **Completion Rate**: Overall task completion percentage
- **Overdue Tasks**: Count and highlight overdue tasks
- **Color-coded Status**: Easy-to-understand visual indicators
### 4. Team Member Contributions
- **Hours Breakdown**: Time contributed by each team member
- **Percentage Distribution**: Visual representation of team effort distribution
- **Entry Counts**: Number of time entries per team member
- **Task Assignments**: Number of tasks assigned to each member
- **Interactive Horizontal Bar Chart**: Compare team member contributions
### 5. Time Tracking Timeline
- **Daily Hours Tracking**: Line chart showing hours logged over time
- **Period Filtering**: View timeline for different time periods
- **Trend Analysis**: Visualize work patterns and project velocity
- **Interactive Line Chart**: Hover to see specific day details
### 6. Recent Activity Feed
- **Activity Log**: Real-time feed of recent project activities
- **User Actions**: Track who did what and when
- **Entity-specific Actions**: Project, task, and time entry activities
- **Timestamp Display**: Clear chronological ordering of events
- **Icon Indicators**: Visual icons for different activity types
### 7. Time Period Filtering
- **All Time**: View entire project history
- **Last 7 Days**: Focus on recent week's activities
- **Last 30 Days**: Monthly project view
- **Last 3 Months**: Quarterly overview
- **Last Year**: Annual performance review
## Dashboard Sections
### Top Navigation
- **Back to Project**: Easy navigation back to project detail page
- **Project Name & Code**: Clear project identification
- **Period Filter**: Dropdown to select time period
### Metrics Cards (4 Cards)
1. **Total Hours Card**
- Large number display of total hours
- Estimated hours comparison
- Blue clock icon
2. **Budget Used Card**
- Budget consumption amount
- Percentage of total budget
- Green/Red indicator based on threshold
- Dollar sign icon
3. **Tasks Complete Card**
- Completed vs. total tasks
- Completion percentage
- Purple tasks icon
4. **Team Members Card**
- Number of contributing members
- Orange users icon
### Visualization Charts
#### Budget vs. Actual Chart
- **Type**: Bar Chart
- **Data**: Budget, Consumed, Remaining
- **Colors**: Blue for budget, Green/Red for consumed, Green/Red for remaining
- **Shows**: When budget is exceeded with visual warnings
#### Task Status Distribution Chart
- **Type**: Doughnut Chart
- **Data**: Count of tasks by status
- **Colors**:
- Gray: Todo
- Blue: In Progress
- Orange: Review
- Green: Done
- Red: Cancelled
- **Legend**: Bottom position with status labels
#### Team Contributions Chart
- **Type**: Horizontal Bar Chart
- **Data**: Hours per team member
- **Colors**: Purple theme
- **Shows**: Top 10 contributors
#### Time Tracking Timeline Chart
- **Type**: Line Chart
- **Data**: Daily hours over selected period
- **Colors**: Blue with gradient fill
- **Shows**: Work pattern and trends
### Team Member Details Section
Shows detailed breakdown for each team member:
- Name and total hours
- Number of time entries
- Number of assigned tasks
- Percentage of total project time
- Visual progress bar
### Recent Activity Section
Displays up to 10 recent activities:
- User avatar/icon
- Action description
- Timestamp
- Color-coded by action type
## Navigation
### Accessing the Dashboard
1. **From Project View**
- Navigate to any project
- Click the purple "Dashboard" button in the header
- Located next to the "Edit Project" button
2. **Direct URL**
- `/projects/<project_id>/dashboard`
### Permissions
- All authenticated users can view project dashboards
- No special permissions required
- Same access level as project view
## Usage Examples
### Scenario 1: Project Manager Monitoring Progress
A project manager wants to check if the project is on track:
1. Navigate to project dashboard
2. Check key metrics cards for overview
3. Review budget chart for financial health
4. Check task completion chart for progress
5. Review timeline to ensure consistent work pace
6. Check team contributions for resource utilization
### Scenario 2: Client Reporting
Preparing a client report:
1. Open project dashboard
2. Select "Last Month" from period filter
3. Screenshot key metrics
4. Export budget vs. actual chart
5. Document team member contributions
6. Include recent activity highlights
### Scenario 3: Sprint Planning
Planning next sprint based on team capacity:
1. View team contributions section
2. Analyze each member's current workload
3. Check timeline for work patterns
4. Review task completion rates
5. Allocate tasks based on contribution percentages
### Scenario 4: Budget Review
Monitoring budget utilization:
1. Check budget used percentage in metrics card
2. Review budget vs. actual chart
3. Calculate remaining budget
4. Check if threshold is exceeded
5. Review timeline to understand burn rate
## Technical Implementation
### Route
```python
@projects_bp.route('/projects/<int:project_id>/dashboard')
@login_required
def project_dashboard(project_id):
"""Project dashboard with comprehensive analytics and visualizations"""
```
### Data Aggregation
#### Budget Data
```python
budget_data = {
'budget_amount': float(project.budget_amount),
'consumed_amount': project.budget_consumed_amount,
'remaining_amount': budget_amount - consumed_amount,
'percentage': (consumed_amount / budget_amount) * 100,
'threshold_exceeded': project.budget_threshold_exceeded,
'estimated_hours': project.estimated_hours,
'actual_hours': project.actual_hours,
'remaining_hours': estimated_hours - actual_hours,
'hours_percentage': (actual_hours / estimated_hours) * 100
}
```
#### Task Statistics
```python
task_stats = {
'total': count of all tasks,
'by_status': dictionary of status counts,
'completed': count of done tasks,
'in_progress': count of in-progress tasks,
'todo': count of todo tasks,
'completion_rate': (completed / total) * 100,
'overdue': count of overdue tasks
}
```
#### Team Contributions
```python
team_contributions = [
{
'username': member username,
'total_hours': hours worked,
'entry_count': number of entries,
'task_count': assigned tasks,
'percentage': (member_hours / project_hours) * 100
}
]
```
### Frontend Libraries
#### Chart.js 4.4.0
Used for all visualizations:
- Budget chart (Bar)
- Task status (Doughnut)
- Team contributions (Horizontal Bar)
- Timeline (Line)
#### Tailwind CSS
Responsive layout with dark mode support:
- Grid system for responsive cards
- Dark mode classes
- Hover effects and transitions
### Database Queries
Dashboard performs optimized queries to fetch:
1. Project details and budget info
2. All tasks with status counts
3. Time entries grouped by user
4. Time entries grouped by date
5. Recent activities filtered by project
### Performance Considerations
- Data is aggregated on the backend
- Charts render client-side with Chart.js
- Caching recommended for large projects
- Pagination considered for large activity lists
## API Response Format
While the dashboard is primarily a web view, the underlying data structure is:
```json
{
"project": {
"id": 1,
"name": "Example Project",
"code": "EXAM"
},
"budget_data": {
"budget_amount": 5000.0,
"consumed_amount": 3500.0,
"remaining_amount": 1500.0,
"percentage": 70.0,
"threshold_exceeded": false
},
"task_stats": {
"total": 20,
"completed": 12,
"in_progress": 5,
"todo": 3,
"completion_rate": 60.0,
"overdue": 1
},
"team_contributions": [
{
"username": "john_doe",
"total_hours": 45.5,
"entry_count": 23,
"task_count": 8,
"percentage": 35.2
}
],
"timeline_data": [
{
"date": "2024-01-15",
"hours": 8.5
}
]
}
```
## Best Practices
### For Project Managers
1. **Regular Monitoring**: Check dashboard daily or weekly
2. **Budget Tracking**: Set up budget thresholds appropriately
3. **Team Balance**: Monitor contribution distribution
4. **Early Warnings**: Act on budget threshold warnings
5. **Documentation**: Export charts for reports
### For Team Leads
1. **Resource Planning**: Use contribution data for allocation
2. **Velocity Tracking**: Monitor timeline patterns
3. **Task Management**: Keep task statuses updated
4. **Team Health**: Ensure balanced workload distribution
### For Developers
1. **Data Updates**: Ensure time entries are logged consistently
2. **Task Updates**: Keep task statuses current
3. **Budget Awareness**: Check budget consumption regularly
## Troubleshooting
### Dashboard Shows No Data
**Issue**: Dashboard displays empty states for all charts
**Solutions**:
- Verify project has time entries
- Check that tasks are created
- Ensure budget is set (if using budget features)
- Verify period filter isn't excluding all data
### Budget Chart Not Displaying
**Issue**: Budget section shows "No budget set"
**Solutions**:
- Edit project and set budget_amount
- Set hourly_rate if using hourly billing
- Ensure budget_threshold_percent is configured
### Team Contributions Empty
**Issue**: No team members shown
**Solutions**:
- Verify time entries exist for the project
- Check that time entries have end_time (completed)
- Ensure user assignments are correct
### Charts Not Rendering
**Issue**: Canvas elements visible but no charts
**Solutions**:
- Check browser console for JavaScript errors
- Verify Chart.js is loading correctly
- Check browser compatibility (modern browsers required)
- Clear browser cache
### Period Filter Not Working
**Issue**: Selecting different periods shows same data
**Solutions**:
- Check URL parameter is changing (?period=week)
- Verify date filtering logic in backend
- Ensure time entry dates are within selected period
## Future Enhancements
### Planned Features
1. **Export Functionality**: Export dashboard as PDF report
2. **Custom Date Ranges**: Allow custom start/end date selection
3. **Milestone Tracking**: Visual milestone progress indicators
4. **Cost Integration**: Include project costs in visualizations
5. **Comparative Analysis**: Compare against similar projects
6. **Predictive Analytics**: Project completion date estimation
7. **Alerts & Notifications**: Configurable dashboard alerts
8. **Widget Customization**: Allow users to customize dashboard layout
9. **Mobile Optimization**: Enhanced mobile dashboard view
10. **Real-time Updates**: WebSocket-based live data updates
### Enhancement Requests
To request new dashboard features, please:
1. Open an issue on GitHub
2. Describe the use case
3. Provide mockups if possible
4. Tag with "feature-request" and "dashboard"
## Related Features
- [Project Management](PROJECT_COSTS_FEATURE.md)
- [Task Management](../TASK_MANAGEMENT_README.md)
- [Time Tracking](../QUICK_REFERENCE_GUIDE.md)
- [Team Collaboration](FAVORITE_PROJECTS_FEATURE.md)
- [Reporting](../QUICK_WINS_UI.md)
## Testing
### Unit Tests
Location: `tests/test_project_dashboard.py`
- Dashboard access and authentication
- Data calculation accuracy
- Period filtering
- Edge cases (no data, missing budget)
### Smoke Tests
Location: `tests/smoke_test_project_dashboard.py`
- Dashboard loads successfully
- All sections render
- Charts display correctly
- Navigation works
- Period filter functions
### Running Tests
```bash
# Run all dashboard tests
pytest tests/test_project_dashboard.py -v
# Run smoke tests only
pytest tests/smoke_test_project_dashboard.py -v
# Run with coverage
pytest tests/test_project_dashboard.py --cov=app.routes.projects
```
## Accessibility
### Features
- **Keyboard Navigation**: Full keyboard support
- **Screen Reader Support**: Proper ARIA labels
- **Color Contrast**: WCAG AA compliant
- **Focus Indicators**: Clear focus states
- **Alternative Text**: Descriptive alt text for visualizations
### Recommendations
- Use screen reader to announce chart data
- Provide data table alternatives for charts
- Ensure all interactive elements are keyboard accessible
## Browser Compatibility
### Supported Browsers
- Chrome 90+
- Firefox 88+
- Safari 14+
- Edge 90+
### Required Features
- ES6 JavaScript support
- Canvas API for Chart.js
- CSS Grid and Flexbox
- Fetch API
## Security Considerations
### Authentication
- Dashboard requires login
- Project access follows existing permissions
- No special dashboard permissions
### Data Privacy
- Only project team members see dashboard
- Activity feed respects privacy settings
- No external data sharing
### Performance
- Query optimization for large datasets
- Client-side rendering for charts
- Caching strategies for repeated access
## Support
For issues or questions:
- Check [Troubleshooting](#troubleshooting) section
- Review [GitHub Issues](https://github.com/yourusername/TimeTracker/issues)
- Contact project maintainers
- Review test files for examples
## Changelog
### Version 1.0.0 (2024-10)
- Initial release of Project Dashboard
- Budget vs. Actual visualization
- Task status distribution chart
- Team member contributions
- Time tracking timeline
- Recent activity feed
- Period filtering
- Responsive design with dark mode
---
**Last Updated**: October 2024
**Feature Status**: ✅ Active
**Requires**: TimeTracker v1.0+
+6
View File
@@ -215,6 +215,7 @@ POST /api/timer/duplicate/<id>
**Issue**: Task not pre-selected after duplication
- **Cause**: Tasks are loaded dynamically via JavaScript
- **Solution**: Wait for the page to fully load; the task should auto-select
- **Note**: This issue has been resolved in the latest version - template code no longer interferes with task pre-selection during duplication
**Issue**: Cannot duplicate inactive project entry
- **Cause**: Project status changed to inactive after entry creation
@@ -226,6 +227,11 @@ POST /api/timer/duplicate/<id>
## Changelog
### Version 1.1 (2025-10-31)
- **Bug Fix**: Fixed issue where duplicated time entries with assigned tasks would not have the task pre-selected
- **Technical**: Template application code now properly checks for duplication mode and doesn't interfere with pre-filled task data
- **Testing**: Added comprehensive test to ensure task pre-selection is preserved during duplication
### Version 1.0 (2024-10-23)
- Initial implementation of time entry duplication
- Duplicate buttons on dashboard and edit pages
+195 -247
View File
@@ -1,281 +1,229 @@
# Time Entry Notes Templates - Reusable Note Templates
# Time Entry Templates Feature
## Overview
Time Entry Templates allow you to create reusable templates for frequently logged activities, saving time and ensuring consistency. This feature is particularly useful for recurring tasks like meetings, standups, client calls, or any activities you log regularly.
Time Entry Templates is a productivity feature that allows users to create reusable templates for frequently logged activities. This feature saves time and ensures consistency when tracking recurring tasks.
## Implementation Status
**Complete** - Fully implemented and tested
## Features
- **Quick-start templates** for common time entries
- **Pre-filled project, task, and notes** to reduce data entry
- **Default duration** settings for consistent time tracking
- **Tag templates** for better organization
- **Usage tracking** to see which templates you use most often
- **Billable/non-billable** defaults
### Core Functionality
- ✅ Create, read, update, and delete templates
- ✅ Template includes project, task, duration, notes, tags, and billable settings
- ✅ Usage tracking (count and last used timestamp)
- ✅ One-click start timer from template
- ✅ Template selector in dashboard timer modal
- ✅ Pre-fill manual time entries from templates
- ✅ API endpoints for programmatic access
## How to Use Time Entry Templates
### User Interface
- ✅ Template management page with grid layout
- ✅ Create and edit forms with project/task selectors
- ✅ Template cards showing usage statistics
- ✅ Dashboard integration for quick access
- ✅ Most recently used templates prioritized
### Backend
- ✅ TimeEntryTemplate model with full relationships
- ✅ CRUD routes with validation
- ✅ Usage tracking and analytics events
- ✅ Integration with existing timer and time entry systems
- ✅ User-scoped templates (privacy)
## Technical Details
### Database Schema
```python
class TimeEntryTemplate(db.Model):
id = db.Column(db.Integer, primary_key=True)
user_id = db.Column(db.Integer, db.ForeignKey('users.id'))
name = db.Column(db.String(200), nullable=False)
description = db.Column(db.Text)
project_id = db.Column(db.Integer, db.ForeignKey('projects.id'))
task_id = db.Column(db.Integer, db.ForeignKey('tasks.id'))
default_duration_minutes = db.Column(db.Integer)
default_notes = db.Column(db.Text)
tags = db.Column(db.String(500))
billable = db.Column(db.Boolean, default=True)
usage_count = db.Column(db.Integer, default=0)
last_used_at = db.Column(db.DateTime)
created_at = db.Column(db.DateTime)
updated_at = db.Column(db.DateTime)
```
### Routes
- `GET /templates` - List all templates
- `GET /templates/create` - Create template form
- `POST /templates/create` - Create new template
- `GET /templates/<id>` - View template details
- `GET /templates/<id>/edit` - Edit template form
- `POST /templates/<id>/edit` - Update template
- `POST /templates/<id>/delete` - Delete template
### API Endpoints
- `GET /api/templates` - Get all templates (JSON)
- `GET /api/templates/<id>` - Get single template (JSON)
- `POST /api/templates/<id>/use` - Mark template as used
### Timer Integration
- `GET /timer/start/from-template/<id>` - Start timer directly from template
- `GET /timer/manual?template=<id>` - Pre-fill manual entry form
- Template selector in dashboard start timer modal
## Testing
Comprehensive test suite includes:
- ✅ Model tests (creation, properties, relationships)
- ✅ Route tests (CRUD operations, validation)
- ✅ API tests (endpoints, responses)
- ✅ Integration tests (timer start, usage tracking)
- ✅ Smoke tests (page rendering, workflows)
Test file: `tests/test_time_entry_templates.py` (599 lines)
## Usage Examples
### Creating a Template
1. Navigate to **Templates** from the main navigation menu
2. Click **"New Template"** or **"Create Your First Template"**
3. Fill in the template details:
- **Template Name** (required): A descriptive name for the template (e.g., "Daily Standup", "Client Call")
- **Project** (optional): The default project for this template
- **Task** (optional): The default task within the project
- **Default Duration** (optional): The typical duration in hours (e.g., 0.5 for 30 minutes, 1.5 for 90 minutes)
- **Default Notes** (optional): Pre-filled notes that will appear when using the template
- **Tags** (optional): Comma-separated tags for categorization
- **Billable** (optional): Whether time entries from this template should be billable by default
4. Click **"Create Template"**
### Using a Template
There are two ways to use a template:
#### Method 1: From the Templates Page
1. Navigate to **Templates**
2. Find the template you want to use
3. Click the **"Use Template"** button
4. You'll be redirected to the manual time entry page with all fields pre-filled
5. Adjust the start and end times as needed
6. Click **"Log Time"** to create the entry
#### Method 2: Direct Link
Templates can be accessed directly via URL query parameters:
```
/timer/manual?template=<template_id>
```python
template = TimeEntryTemplate(
user_id=current_user.id,
name="Daily Standup",
project_id=project.id,
task_id=task.id,
default_duration_minutes=15,
default_notes="Discussed progress and blockers",
tags="meeting,standup",
billable=False
)
db.session.add(template)
db.session.commit()
```
### Editing a Template
### Starting Timer from Template
1. Navigate to **Templates**
2. Find the template you want to edit
3. Click the **edit icon** (pencil)
4. Update the template details
5. Click **"Update Template"**
### Deleting a Template
1. Navigate to **Templates**
2. Find the template you want to delete
3. Click the **delete icon** (trash can)
4. Confirm the deletion in the dialog
## Template Details
Each template displays:
- **Template name** and optional description
- **Associated project** (if specified)
- **Associated task** (if specified)
- **Default duration** (if specified)
- **Default notes** (preview of first few lines)
- **Tags** (if specified)
- **Usage statistics**: How many times the template has been used
- **Last used**: When the template was last used
## Use Cases
### Daily Recurring Activities
Create templates for activities you do every day:
- **Daily Standup Meeting**: Project: "Internal", Duration: 0.25 hours (15 min)
- **Email Processing**: Project: "Administrative", Duration: 0.5 hours
- **Code Review**: Project: "Development", Notes: "Reviewed team pull requests"
### Client-Specific Templates
Create templates for regular client work:
- **Weekly Client Check-in**: Project: "Client A", Duration: 1 hour
- **Monthly Reporting**: Project: "Client B", Duration: 2 hours
### Task-Specific Templates
Create templates for specific types of work:
- **Bug Fixes**: Tags: "bug,development", Billable: Yes
- **Documentation**: Tags: "documentation,writing", Billable: No
- **Training**: Tags: "learning,training", Billable: No
## Best Practices
### Template Naming
- Use clear, descriptive names that indicate the activity
- Include the project name if you have templates for multiple projects
- Use consistent naming conventions (e.g., "Weekly [Activity]", "Monthly [Activity]")
### Default Duration
- Set realistic default durations based on historical data
- Use common increments (0.25, 0.5, 1.0, 2.0 hours)
- Leave duration empty if the activity varies significantly in length
### Default Notes
- Include structure or prompts for what to include
- Use bullet points or questions to guide note-taking
- Examples:
```
- Topics discussed:
- Action items:
- Next steps:
```
### Tags
- Create a consistent tagging system across templates
- Use tags for reporting and filtering (e.g., "meeting", "development", "admin")
- Keep tags lowercase and short
### Maintenance
- Review your templates quarterly
- Delete unused templates to keep the list manageable
- Update templates as your work patterns change
- Check usage statistics to identify which templates are most valuable
## Template Management Tips
### Organizing Templates
Templates are sorted by last used date by default, so your most frequently used templates appear at the top. This makes it easy to access your most common activities quickly.
### Template Usage Tracking
The system tracks:
- **Usage count**: Total number of times the template has been used
- **Last used**: When the template was last applied
This data helps you:
- Identify your most common activities
- Clean up unused templates
- Understand your work patterns
### Sharing Templates
Templates are user-specific and cannot be shared directly with other users. However, admins can:
- Document standard templates in the team wiki
- Provide template "recipes" for common activities
- Export and import template configurations (if bulk operations are available)
## Technical Notes
### Template Application
When you use a template:
1. The template's usage count increments
2. The last used timestamp updates
3. All template fields populate the manual entry form
4. The template's default duration calculates the end time based on the current time
5. The template data is cleared from session storage after application
### Duration Handling
- Templates store duration in minutes internally
- The UI displays duration in hours (decimal format)
- When using a template, the duration is applied from the current time forward
- You can adjust start and end times manually after applying the template
### Data Persistence
- Templates are stored in the database and persist across sessions
- Template data is temporarily stored in browser sessionStorage during the "Use Template" flow
- SessionStorage is cleared after the template is applied to prevent accidental reuse
## API Access
Templates can be accessed programmatically via the API:
### List Templates
```http
GET /api/templates
```python
# In routes/timer.py
@timer_bp.route('/timer/start/from-template/<int:template_id>')
@login_required
def start_timer_from_template(template_id):
template = TimeEntryTemplate.query.get_or_404(template_id)
# Create timer with template data
new_timer = TimeEntry(
user_id=current_user.id,
project_id=template.project_id,
task_id=template.task_id,
notes=template.default_notes,
tags=template.tags,
billable=template.billable
)
template.record_usage()
db.session.commit()
```
Returns all templates for the authenticated user.
### API Usage
### Get Single Template
```http
GET /api/templates/<template_id>
```javascript
// Fetch templates
fetch('/api/templates')
.then(res => res.json())
.then(data => {
data.templates.forEach(template => {
console.log(template.name, template.usage_count);
});
});
// Use template
fetch(`/api/templates/${templateId}/use`, {
method: 'POST',
headers: { 'X-CSRFToken': csrfToken }
});
```
Returns details for a specific template.
## Migration
### Mark Template as Used
```http
POST /api/templates/<template_id>/use
No database migration required for existing installations - the feature is additive:
```bash
# Run migrations to create time_entry_templates table
flask db upgrade
```
Increments the usage count and updates the last used timestamp.
Or for Alembic-based migrations:
```bash
alembic upgrade head
```
## Integration with Other Features
## User Documentation
### Projects and Tasks
See [Time Entry Templates User Guide](../TIME_ENTRY_TEMPLATES.md) for:
- Step-by-step usage instructions
- Best practices and tips
- Troubleshooting guide
- API reference
- Templates can reference specific projects and tasks
- When a project is archived or deleted, templates remain but show a warning
- Task selection is dynamic based on the selected project
## Related Features
### Time Entries
- Templates pre-fill time entry forms but don't create entries automatically
- All template fields can be modified before creating the time entry
- Templates don't override user preferences for billability
### Reporting
- Time entries created from templates are tracked like any other entry
- Tags from templates help with filtering and reporting
- Template usage statistics are separate from time entry reporting
## Troubleshooting
### Template Not Loading
If a template doesn't load when you click "Use Template":
1. Check browser console for JavaScript errors
2. Ensure JavaScript is enabled in your browser
3. Try refreshing the page and clicking the template again
4. Clear your browser's sessionStorage and try again
### Template Fields Not Pre-filling
If template fields don't pre-fill the form:
1. Verify the template has the fields populated
2. Check that the project/task still exist and are active
3. Ensure you're using a modern browser with sessionStorage support
### Template Not Appearing
If you created a template but don't see it:
1. Refresh the templates page
2. Check that you're logged in as the correct user (templates are user-specific)
3. Verify the template was created successfully (check for success message)
- **Time Tracking**: Core time entry and timer functionality
- **Projects**: Template organization by project
- **Tasks**: Template organization by task
- **Reports**: Template usage analytics (future enhancement)
## Future Enhancements
Potential future features for templates:
- Template categories or folders for better organization
- Template sharing between users or teams
- Template cloning for quick creation of similar templates
- Bulk template import/export
- Template suggestions based on time entry patterns
- Template versioning and history
Potential improvements:
- [ ] Template sharing between team members
- [ ] Template categories/folders
- [ ] Template suggestions based on usage patterns
- [ ] Bulk operations on templates
- [ ] Template import/export
- [ ] Template analytics dashboard
## Related Documentation
## Maintenance
- [Time Tracking Guide](./TIME_TRACKING.md)
- [Manual Time Entry](./MANUAL_TIME_ENTRY.md)
- [Projects and Tasks](./PROJECTS_AND_TASKS.md)
- [Reporting and Analytics](./REPORTING.md)
### Database Cleanup
Templates can be cleaned up periodically:
```python
# Delete templates not used in 6+ months
from datetime import datetime, timedelta
cutoff = datetime.utcnow() - timedelta(days=180)
TimeEntryTemplate.query.filter(
TimeEntryTemplate.last_used_at < cutoff
).delete()
```
### Monitoring
Key metrics to track:
- Template creation rate
- Template usage rate
- Most popular templates
- Templates never used
- Average templates per user
## Support
If you encounter issues with Time Entry Templates:
1. Check this documentation for troubleshooting tips
2. Review the application logs for error messages
3. Contact your system administrator
4. Report bugs on the project's GitHub repository
For issues or questions:
- Check the [User Guide](../TIME_ENTRY_TEMPLATES.md)
- Review [Project Structure](../PROJECT_STRUCTURE.md)
- See [Testing Guide](../TESTING_COVERAGE_GUIDE.md)
- Open an issue on GitHub
## Changelog
### v1.0.0 (Initial Release)
- Complete CRUD operations for templates
- Dashboard integration
- Timer integration
- API endpoints
- Comprehensive test suite
- User documentation
+301
View File
@@ -0,0 +1,301 @@
# Activity Feed Widget
The Activity Feed Widget provides real-time visibility into team activities and creates a comprehensive audit trail for your TimeTracker instance.
## Overview
The Activity Feed automatically tracks and displays all major actions performed in the system, including:
- Project management (create, update, delete, archive)
- Task operations (create, update, delete, status changes, assignments)
- Time tracking (start/stop timer, manual entries, edits)
- Invoice activities (create, send, mark paid)
- Client management
- And more...
## Features
### Dashboard Widget
The Activity Feed Widget appears on the main dashboard in the right sidebar, displaying:
- **Recent Activities**: Last 10 activities by default
- **User Attribution**: Shows who performed each action
- **Timestamps**: Displays how long ago each action occurred
- **Action Icons**: Visual indicators for different types of actions
- **Entity Details**: Clear description of what was done
### Filtering
Click the filter icon (🔽) to filter activities by type:
- All Activities
- Projects only
- Tasks only
- Time Entries only
- Invoices only
- Clients only
### Real-time Updates
The activity feed automatically refreshes every 30 seconds to show the latest team activities.
## User Permissions
### Regular Users
- See their own activities
- View activities related to projects they have access to
### Administrators
- See all activities across the entire organization
- Access to advanced filtering and export options
- View activity statistics
## API Endpoints
### Get Activities
```http
GET /api/activities
```
**Query Parameters:**
- `limit` (int): Number of activities to return (default: 50)
- `page` (int): Page number for pagination (default: 1)
- `user_id` (int): Filter by specific user (admin only)
- `entity_type` (string): Filter by entity type (project, task, time_entry, invoice, client)
- `action` (string): Filter by action type (created, updated, deleted, started, stopped, etc.)
- `start_date` (ISO string): Filter activities after this date
- `end_date` (ISO string): Filter activities before this date
**Response:**
```json
{
"activities": [
{
"id": 123,
"user_id": 5,
"username": "john.doe",
"display_name": "John Doe",
"action": "created",
"entity_type": "project",
"entity_id": 42,
"entity_name": "New Website",
"description": "Created project \"New Website\"",
"extra_data": {},
"created_at": "2025-10-30T14:30:00Z"
}
],
"total": 150,
"pages": 3,
"current_page": 1,
"has_next": true,
"has_prev": false
}
```
### Get Activity Statistics
```http
GET /api/activities/stats?days=7
```
**Query Parameters:**
- `days` (int): Number of days to analyze (default: 7)
**Response:**
```json
{
"total_activities": 342,
"entity_counts": {
"project": 45,
"task": 128,
"time_entry": 156,
"invoice": 13
},
"action_counts": {
"created": 89,
"updated": 167,
"deleted": 12,
"started": 42,
"stopped": 32
},
"user_activity": [
{
"username": "john.doe",
"display_name": "John Doe",
"count": 156
}
],
"period_days": 7
}
```
## Action Types
The system tracks the following action types:
| Action | Description | Used For |
|--------|-------------|----------|
| `created` | Entity was created | Projects, Tasks, Clients, Invoices |
| `updated` | Entity was modified | Projects, Tasks, Time Entries |
| `deleted` | Entity was removed | Projects, Tasks, Time Entries |
| `started` | Timer started | Time Entries |
| `stopped` | Timer stopped | Time Entries |
| `completed` | Task marked as done | Tasks |
| `assigned` | Task assigned to user | Tasks |
| `commented` | Comment added | Tasks |
| `status_changed` | Status modified | Tasks, Invoices |
| `sent` | Invoice sent to client | Invoices |
| `paid` | Payment recorded | Invoices |
| `archived` | Entity archived | Projects |
| `unarchived` | Entity unarchived | Projects |
## Entity Types
Activities can be tracked for the following entity types:
- `project` - Project management
- `task` - Task operations
- `time_entry` - Time tracking
- `invoice` - Invoicing
- `client` - Client management
- `user` - User administration (admin only)
- `comment` - Comments and discussions
## Integration Guide
### For Developers
To add activity logging to new features, use the `Activity.log()` method:
```python
from app.models import Activity
Activity.log(
user_id=current_user.id,
action='created', # Action type
entity_type='project', # Entity type
entity_id=project.id,
entity_name=project.name,
description=f'Created project "{project.name}"',
extra_data={'client_id': client.id}, # Optional metadata
ip_address=request.remote_addr, # Optional
user_agent=request.headers.get('User-Agent') # Optional
)
```
**Best Practices:**
1. **Always log after successful operations** - Log after the database commit succeeds
2. **Provide clear descriptions** - Make descriptions human-readable
3. **Include relevant metadata** - Use `extra_data` for additional context
4. **Store entity names** - Cache the entity name in case it's deleted later
5. **Handle failures gracefully** - Activity logging includes built-in error handling
### Already Integrated
Activity logging is already integrated for:
- ✅ Projects (create, update, delete, archive, unarchive)
- ✅ Tasks (create, update, delete, status changes, assignments)
- ✅ Time Entries (start timer, stop timer, manual create, edit, delete)
- ⏳ Invoices (create, update, status change, payment, send) - *coming soon*
- ⏳ Clients (create, update, delete) - *coming soon*
- ⏳ Comments (create) - *coming soon*
## Use Cases
### Team Visibility
- See what your team members are working on
- Track project progress in real-time
- Understand team activity patterns
### Audit Trail
- Compliance and record-keeping
- Track who made what changes and when
- Identify suspicious or unusual activity
### Project Management
- Monitor task completion rates
- Track project milestones
- Review team productivity
### Troubleshooting
- Investigate issues by reviewing recent changes
- Identify when problems were introduced
- Track down missing or deleted items
## Configuration
No special configuration is required. The Activity Feed is enabled by default for all users.
### Database Indexes
The Activity model includes optimized indexes for:
- User-based queries (`user_id`, `created_at`)
- Entity lookups (`entity_type`, `entity_id`)
- Date range queries (`created_at`)
### Performance
- Activities are paginated to prevent slow page loads
- Old activities are automatically retained (no automatic cleanup)
- Database queries are optimized with proper indexes
- Widget auto-refreshes are throttled to every 30 seconds
## Privacy & Security
### Data Retention
- Activities are stored indefinitely by default
- Administrators can manually delete old activities if needed
- Consider implementing a retention policy for compliance
### Access Control
- Users can only see their own activities (unless admin)
- Administrators see all activities system-wide
- Activity logs cannot be edited or tampered with
- IP addresses and user agents are stored for security auditing
### GDPR Compliance
When a user requests data deletion:
1. Their activities are preserved for audit purposes
2. User information can be anonymized
3. Activities show "Deleted User" for anonymized accounts
## Troubleshooting
### Activities not appearing?
1. **Check permissions** - Regular users only see their own activities
2. **Verify integration** - Ensure the route has Activity.log() calls
3. **Database issues** - Check logs for database errors
4. **Browser cache** - Clear cache or hard refresh the dashboard
### Widget not loading?
1. **Check API endpoint** - Visit `/api/activities` directly
2. **JavaScript errors** - Check browser console for errors
3. **Authentication** - Ensure user is logged in
4. **Network issues** - Check network tab in dev tools
### Missing activities for certain actions?
Some features may not have activity logging integrated yet. Check the "Already Integrated" section above.
## Future Enhancements
Planned improvements for the Activity Feed:
- [ ] Export activities to CSV/JSON
- [ ] Email notifications for specific activities
- [ ] Advanced search and filtering
- [ ] Activity feed for specific projects/tasks
- [ ] Webhook integration for external systems
- [ ] Custom activity types and actions
- [ ] Activity trends and analytics dashboard
## Support
For issues or questions about the Activity Feed:
- Check the [FAQ](../faq.md)
- Review the [API Documentation](../api/README.md)
- Open an issue on GitHub
- Contact support
+297
View File
@@ -0,0 +1,297 @@
# Import/Export System
## Quick Start
The TimeTracker Import/Export system enables seamless data migration, GDPR-compliant data exports, and comprehensive backup/restore functionality.
## Features
- 📥 **CSV Import** - Bulk import time entries
- 🔄 **Toggl/Harvest Import** - Direct integration with popular time trackers
- 📤 **GDPR Export** - Complete data export for compliance
- 🔍 **Filtered Export** - Export specific data with custom filters
- 💾 **Backup/Restore** - Full database backup (admin only)
- 📊 **History Tracking** - Monitor all import/export operations
## Quick Links
- **User Guide**: [IMPORT_EXPORT_GUIDE.md](../IMPORT_EXPORT_GUIDE.md)
- **Implementation Summary**: [IMPORT_EXPORT_IMPLEMENTATION_SUMMARY.md](../../IMPORT_EXPORT_IMPLEMENTATION_SUMMARY.md)
- **API Documentation**: See User Guide → API Documentation section
## For Users
### Accessing Import/Export
1. Click on your user menu (top right)
2. Select "Import/Export"
3. Choose your desired operation
### Common Tasks
**Import CSV File:**
1. Download the CSV template
2. Fill in your data
3. Upload the file
4. Check Import History for results
**Export Your Data (GDPR):**
1. Click "Export as JSON" or "Export as ZIP"
2. Wait for processing (usually < 1 minute)
3. Download the file when ready
**Import from Toggl:**
1. Get your API token from Toggl
2. Click "Import from Toggl"
3. Enter credentials and date range
4. Start import
## For Developers
### Project Structure
```
app/
├── models/
│ └── import_export.py # DataImport & DataExport models
├── utils/
│ ├── data_import.py # Import functions
│ └── data_export.py # Export functions
├── routes/
│ └── import_export.py # API endpoints
└── templates/
└── import_export/
└── index.html # UI
migrations/
└── versions/
└── 040_add_import_export_tables.py
tests/
├── test_import_export.py # Integration tests
└── models/
└── test_import_export_models.py # Model tests
docs/
├── IMPORT_EXPORT_GUIDE.md # Complete guide
└── import_export/
└── README.md # This file
```
### Adding New Import Source
```python
# 1. Add import function in app/utils/data_import.py
def import_from_new_source(user_id, credentials, start_date, end_date, import_record):
"""Import from new time tracker"""
import_record.start_processing()
try:
# Fetch data from API
data = fetch_from_api(credentials)
# Process each record
for record in data:
# Create TimeEntry
time_entry = TimeEntry(...)
db.session.add(time_entry)
import_record.update_progress(...)
db.session.commit()
import_record.complete()
except Exception as e:
import_record.fail(str(e))
# 2. Add route in app/routes/import_export.py
@import_export_bp.route('/api/import/new-source', methods=['POST'])
@login_required
def import_new_source():
data = request.get_json()
# ... validation ...
import_record = DataImport(
user_id=current_user.id,
import_type='new_source',
source_file='...'
)
db.session.add(import_record)
db.session.commit()
summary = import_from_new_source(...)
return jsonify({'success': True, 'import_id': import_record.id})
# 3. Add UI in app/templates/import_export/index.html
# Add button and modal form for the new source
```
### API Usage Examples
**Import CSV via API:**
```python
import requests
files = {'file': open('time_entries.csv', 'rb')}
response = requests.post(
'http://localhost:8080/api/import/csv',
files=files,
cookies={'session': 'your_session_cookie'}
)
print(response.json())
```
**Export GDPR Data:**
```python
import requests
response = requests.post(
'http://localhost:8080/api/export/gdpr',
json={'format': 'json'},
cookies={'session': 'your_session_cookie'}
)
result = response.json()
download_url = result['download_url']
```
**Check Import Status:**
```python
import requests
response = requests.get(
f'http://localhost:8080/api/import/status/{import_id}',
cookies={'session': 'your_session_cookie'}
)
status = response.json()
print(f"Status: {status['status']}")
print(f"Progress: {status['successful_records']}/{status['total_records']}")
```
### Running Tests
```bash
# Run all import/export tests
pytest tests/test_import_export.py -v
# Run model tests
pytest tests/models/test_import_export_models.py -v
# Run with coverage
pytest tests/test_import_export.py --cov=app.utils.data_import --cov=app.utils.data_export
```
### Database Migration
```bash
# Apply migration
flask db upgrade
# Or with Alembic
alembic upgrade head
# Rollback if needed
flask db downgrade
# or
alembic downgrade -1
```
## CSV Format Reference
### Required Columns
- `project_name` - Name of the project
- `start_time` - Start time (YYYY-MM-DD HH:MM:SS)
### Optional Columns
- `client_name` - Client name (defaults to project name)
- `task_name` - Task name
- `end_time` - End time
- `duration_hours` - Duration in hours
- `notes` - Description/notes
- `tags` - Semicolon-separated tags
- `billable` - true/false
### Example CSV
```csv
project_name,client_name,task_name,start_time,end_time,duration_hours,notes,tags,billable
Website Redesign,Acme Corp,Design,2024-01-15 09:00:00,2024-01-15 12:00:00,3.0,Homepage mockups,design;ui,true
Website Redesign,Acme Corp,Development,2024-01-15 14:00:00,2024-01-15 17:30:00,3.5,Implemented header,dev;frontend,true
```
## Security Notes
### Authentication
- All endpoints require authentication
- Users can only access their own data
- Admins can create backups and view all history
### Data Privacy
- Exports are private to the creating user
- Files expire after 7 days
- Secure storage in `/data/uploads`
### CSRF Protection
- All POST endpoints require CSRF token
- Automatically handled by the UI
- API clients must include CSRF token
## Troubleshooting
### Common Issues
**Import fails with "Invalid date format"**
- Use YYYY-MM-DD HH:MM:SS format
- Or ISO format: YYYY-MM-DDTHH:MM:SS
**Toggl import returns 401**
- Check API token is correct
- Verify workspace ID is valid
- Ensure you have access to the workspace
**Export download says "expired"**
- Exports expire after 7 days
- Create a new export
**Large import is slow**
- Imports are processed in batches
- Wait for completion (check Import History)
- Consider splitting into smaller date ranges
## Performance Tips
1. **Large Imports**
- Split into smaller date ranges
- Import during off-peak hours
- Monitor Import History for progress
2. **Large Exports**
- Use filtered exports for specific data
- JSON is faster than ZIP for large datasets
- Exports are generated asynchronously
3. **Storage Management**
- Exports auto-delete after 7 days
- Download important exports immediately
- Backups should be stored externally
## Support
- **Documentation**: [IMPORT_EXPORT_GUIDE.md](../IMPORT_EXPORT_GUIDE.md)
- **Issues**: Report on GitHub
- **Questions**: Check FAQ in the main guide
## Version History
### Version 1.0 (October 31, 2024)
- Initial release
- CSV import
- Toggl integration
- Harvest integration
- GDPR export
- Filtered export
- Backup/restore
- Migration wizard
- History tracking
## License
Same as TimeTracker application.

Some files were not shown because too many files have changed in this diff Show More