Add salesman-based report splitting and email distribution

- Add SalesmanEmailMapping model to map salesman initials to email addresses
  - Support for direct email addresses, email patterns, and domain-based patterns
  - Admin interface for managing email mappings

- Add UnpaidHoursService for querying and grouping unpaid time entries
  - Filter by client custom fields (e.g., salesman)
  - Group unpaid hours by salesman for report generation

- Add salesman report routes and API endpoints
  - CRUD operations for email mappings
  - Generate and send reports split by salesman
  - Preview email addresses for salesman initials

- Enhance scheduled reports with salesman splitting
  - Add split_by_salesman and salesman_field_name to report schedules
  - Automatically split reports by salesman and send to mapped emails

- Add UI components for salesman report management
  - Admin dashboard integration
  - Report builder with salesman splitting options
  - Email mapping management interface

- Add email template for unpaid hours reports

- Add database migrations:
  - 087: Create salesman_email_mappings table
  - 088: Add salesman splitting fields to report_email_schedules
This commit is contained in:
Dries Peeters
2025-12-03 08:59:48 +01:00
parent f3a3a40480
commit 4e57f08c03
15 changed files with 1842 additions and 43 deletions

View File

@@ -987,6 +987,8 @@ def create_app(config=None):
from app.routes.kiosk import kiosk_bp
from app.routes.link_templates import link_templates_bp
from app.routes.custom_field_definitions import custom_field_definitions_bp
from app.routes.custom_reports import custom_reports_bp
from app.routes.salesman_reports import salesman_reports_bp
try:
from app.routes.audit_logs import audit_logs_bp
@@ -1040,6 +1042,8 @@ def create_app(config=None):
app.register_blueprint(leads_bp)
app.register_blueprint(link_templates_bp)
app.register_blueprint(custom_field_definitions_bp)
app.register_blueprint(custom_reports_bp)
app.register_blueprint(salesman_reports_bp)
# audit_logs_bp is registered above with error handling
# Register integration connectors
@@ -1094,12 +1098,7 @@ def create_app(config=None):
except Exception as e:
logger.warning(f"Could not register push_notifications blueprint: {e}")
try:
from app.routes.custom_reports import custom_reports_bp
app.register_blueprint(custom_reports_bp)
except Exception as e:
logger.warning(f"Could not register custom_reports blueprint: {e}")
# custom_reports_bp is already registered above (line 1045)
try:
from app.routes.gantt import gantt_bp

View File

@@ -76,6 +76,7 @@ from .gamification import Badge, UserBadge, Leaderboard, LeaderboardEntry
from .expense_gps import MileageTrack
from .link_template import LinkTemplate
from .custom_field_definition import CustomFieldDefinition
from .salesman_email_mapping import SalesmanEmailMapping
__all__ = [
"User",
@@ -178,4 +179,5 @@ __all__ = [
"MileageTrack",
"LinkTemplate",
"CustomFieldDefinition",
"SalesmanEmailMapping",
]

View File

@@ -33,6 +33,8 @@ class ReportEmailSchedule(db.Model):
next_run_at = db.Column(db.DateTime, nullable=True)
last_run_at = db.Column(db.DateTime, nullable=True)
active = db.Column(db.Boolean, default=True, nullable=False)
split_by_salesman = db.Column(db.Boolean, default=False, nullable=False) # Split report by salesman
salesman_field_name = db.Column(db.String(50), nullable=True) # Custom field name for salesman (default: 'salesman')
created_by = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=False)
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)

View File

@@ -0,0 +1,80 @@
"""
Salesman Email Mapping Model
Maps salesman initials (from client custom fields) to email addresses
for automated report distribution.
"""
from datetime import datetime
from app import db
class SalesmanEmailMapping(db.Model):
"""Maps salesman initials to email addresses for report distribution"""
__tablename__ = "salesman_email_mappings"
id = db.Column(db.Integer, primary_key=True)
salesman_initial = db.Column(db.String(20), nullable=False, unique=True, index=True)
email_address = db.Column(db.String(255), nullable=True) # Direct email address
email_pattern = db.Column(db.String(255), nullable=True) # Pattern like '{value}@test.de'
domain = db.Column(db.String(255), nullable=True) # Domain for pattern-based emails
is_active = db.Column(db.Boolean, default=True, nullable=False, index=True)
notes = db.Column(db.Text, nullable=True)
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, salesman_initial, email_address=None, email_pattern=None, domain=None, notes=None):
"""Create a salesman email mapping"""
self.salesman_initial = salesman_initial.strip().upper()
self.email_address = email_address.strip() if email_address else None
self.email_pattern = email_pattern.strip() if email_pattern else None
self.domain = domain.strip() if domain else None
self.notes = notes.strip() if notes else None
def __repr__(self):
return f"<SalesmanEmailMapping {self.salesman_initial} -> {self.get_email()}>"
def get_email(self):
"""Get the email address for this salesman initial"""
if self.email_address:
return self.email_address
elif self.email_pattern and self.salesman_initial:
# Replace {value} with the salesman initial
return self.email_pattern.replace("{value}", self.salesman_initial)
elif self.domain and self.salesman_initial:
# Default pattern: {initial}@domain
return f"{self.salesman_initial}@{self.domain}"
return None
def to_dict(self):
"""Convert to dictionary"""
return {
"id": self.id,
"salesman_initial": self.salesman_initial,
"email_address": self.email_address,
"email_pattern": self.email_pattern,
"domain": self.domain,
"is_active": self.is_active,
"notes": self.notes,
"resolved_email": self.get_email(),
"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_email_for_initial(cls, initial):
"""Get email address for a salesman initial"""
if not initial:
return None
initial = initial.strip().upper()
mapping = cls.query.filter_by(salesman_initial=initial, is_active=True).first()
if mapping:
return mapping.get_email()
return None
@classmethod
def get_all_active(cls):
"""Get all active mappings"""
return cls.query.filter_by(is_active=True).order_by(cls.salesman_initial).all()

View File

@@ -6,8 +6,9 @@ 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
from app.models import SavedReportView, TimeEntry, Project, Task, User
from app.models import SavedReportView, TimeEntry, Project, Task, User, Client
from app.utils.db import safe_commit
from app.services.unpaid_hours_service import UnpaidHoursService
import json
from datetime import datetime, timedelta
@@ -29,7 +30,21 @@ def report_builder():
{"id": "expenses", "name": "Expenses", "icon": "receipt"},
]
return render_template("reports/builder.html", saved_views=saved_views, data_sources=data_sources)
# Get available clients for custom field filtering
clients = Client.query.filter_by(status="active").order_by(Client.name).all()
# Extract unique custom field keys from clients
custom_field_keys = set()
for client in clients:
if client.custom_fields:
custom_field_keys.update(client.custom_fields.keys())
return render_template(
"reports/builder.html",
saved_views=saved_views,
data_sources=data_sources,
custom_field_keys=sorted(list(custom_field_keys)),
)
@custom_reports_bp.route("/reports/builder/save", methods=["POST"])
@@ -177,43 +192,115 @@ def generate_report_data(config, user_id=None):
# Generate data based on source
if data_source == "time_entries":
query = TimeEntry.query.filter(
TimeEntry.end_time.isnot(None), TimeEntry.start_time >= start_dt, TimeEntry.start_time <= end_dt
)
# Check if unpaid hours filter is enabled
unpaid_only = filters.get("unpaid_only", False)
custom_field_filter = filters.get("custom_field_filter") # e.g., {"salesman": "MM"}
# Filter by user if not admin or if user_id is specified
if user_id:
user = User.query.get(user_id)
if not user or not user.is_admin:
query = query.filter(TimeEntry.user_id == user_id)
if unpaid_only:
# Use unpaid hours service
unpaid_service = UnpaidHoursService()
entries = unpaid_service.get_unpaid_time_entries(
start_date=start_dt,
end_date=end_dt,
project_id=filters.get("project_id"),
client_id=filters.get("client_id"),
user_id=filters.get("user_id") or user_id,
custom_field_filter=custom_field_filter,
)
else:
# Standard query
query = TimeEntry.query.filter(
TimeEntry.end_time.isnot(None), TimeEntry.start_time >= start_dt, TimeEntry.start_time <= end_dt
)
project_id = filters.get("project_id")
if project_id:
# Convert to int if it's a string
try:
project_id = int(project_id) if isinstance(project_id, str) else project_id
query = query.filter(TimeEntry.project_id == project_id)
except (ValueError, TypeError):
from flask import current_app
current_app.logger.warning(f"Invalid project_id: {project_id}, ignoring filter")
if filters.get("user_id"):
query = query.filter(TimeEntry.user_id == filters["user_id"])
# Filter by user if not admin or if user_id is specified
if user_id:
user = User.query.get(user_id)
if not user or not user.is_admin:
query = query.filter(TimeEntry.user_id == user_id)
entries = query.all()
project_id = filters.get("project_id")
if project_id:
# Convert to int if it's a string
try:
project_id = int(project_id) if isinstance(project_id, str) else project_id
query = query.filter(TimeEntry.project_id == project_id)
except (ValueError, TypeError):
from flask import current_app
current_app.logger.warning(f"Invalid project_id: {project_id}, ignoring filter")
if filters.get("user_id"):
query = query.filter(TimeEntry.user_id == filters["user_id"])
# Apply custom field filter if provided
if custom_field_filter:
# Get all entries first, then filter by custom fields
all_entries = query.all()
entries = []
for entry in all_entries:
client = None
if entry.project and entry.project.client:
client = entry.project.client
elif entry.client:
client = entry.client
if client and client.custom_fields:
matches = True
for field_name, field_value in custom_field_filter.items():
client_value = client.custom_fields.get(field_name)
if str(client_value).upper().strip() != str(field_value).upper().strip():
matches = False
break
if matches:
entries.append(entry)
else:
entries = query.all()
# Build response data
client_data = {}
data_list = []
for e in entries:
client = None
if e.project and e.project.client:
client = e.project.client
elif e.client:
client = e.client
client_name = client.name if client else "Unknown"
salesman = None
if client and client.custom_fields:
salesman = client.custom_fields.get("salesman")
entry_data = {
"id": e.id,
"date": e.start_time.strftime("%Y-%m-%d") if e.start_time else "",
"project": e.project.name if e.project else "",
"client": client_name,
"salesman": salesman or "",
"user": e.user.username if e.user else "",
"duration": e.duration_hours,
"notes": e.notes or "",
"billable": e.billable,
"paid": e.paid,
}
data_list.append(entry_data)
# Group by client for summary
if client_name not in client_data:
client_data[client_name] = {"hours": 0, "entries": []}
client_data[client_name]["hours"] += e.duration_hours or 0
client_data[client_name]["entries"].append(entry_data)
return {
"data": [
{
"id": e.id,
"date": e.start_time.strftime("%Y-%m-%d") if e.start_time else "",
"project": e.project.name if e.project else "",
"user": e.user.username if e.user else "",
"duration": e.duration_hours,
"notes": e.notes or "",
}
for e in entries
],
"summary": {"total_entries": len(entries), "total_hours": sum(e.duration_hours or 0 for e in entries)},
"data": data_list,
"summary": {
"total_entries": len(entries),
"total_hours": round(sum(e.duration_hours or 0 for e in entries), 2),
"unpaid_only": unpaid_only,
"by_client": client_data,
},
}
elif data_source == "projects":

View File

@@ -0,0 +1,339 @@
"""
Routes for salesman-based report generation and email mapping management.
"""
from flask import Blueprint, render_template, request, jsonify, flash, redirect, url_for
from flask_babel import gettext as _
from flask_login import login_required, current_user
from app import db
from app.models import SalesmanEmailMapping, Client
from app.services.unpaid_hours_service import UnpaidHoursService
from app.services.scheduled_report_service import ScheduledReportService
from app.utils.db import safe_commit
from app.utils.email import send_email
from datetime import datetime, timedelta
import json
salesman_reports_bp = Blueprint("salesman_reports", __name__)
@salesman_reports_bp.route("/admin/salesman-email-mappings")
@login_required
def list_email_mappings():
"""List all salesman email mappings (Admin only)"""
if not current_user.is_admin:
flash(_("You do not have permission to access this page."), "error")
return redirect(url_for("reports.reports"))
mappings = SalesmanEmailMapping.query.order_by(SalesmanEmailMapping.salesman_initial).all()
return render_template("admin/salesman_email_mappings.html", mappings=mappings)
@salesman_reports_bp.route("/api/salesman-email-mappings", methods=["GET"])
@login_required
def get_email_mappings_api():
"""Get all salesman email mappings (API)"""
if not current_user.is_admin:
return jsonify({"error": "Permission denied"}), 403
mappings = SalesmanEmailMapping.query.order_by(SalesmanEmailMapping.salesman_initial).all()
return jsonify({"success": True, "mappings": [m.to_dict() for m in mappings]})
@salesman_reports_bp.route("/api/salesman-email-mappings", methods=["POST"])
@login_required
def create_email_mapping():
"""Create a new salesman email mapping"""
if not current_user.is_admin:
return jsonify({"success": False, "message": "Permission denied"}), 403
try:
data = request.json
salesman_initial = data.get("salesman_initial")
email_address = data.get("email_address")
email_pattern = data.get("email_pattern")
domain = data.get("domain")
notes = data.get("notes")
if not salesman_initial:
return jsonify({"success": False, "message": "Salesman initial is required"}), 400
# Check if mapping already exists
existing = SalesmanEmailMapping.query.filter_by(salesman_initial=salesman_initial.upper().strip()).first()
if existing:
return jsonify({"success": False, "message": "Mapping for this initial already exists"}), 400
mapping = SalesmanEmailMapping(
salesman_initial=salesman_initial,
email_address=email_address,
email_pattern=email_pattern,
domain=domain,
notes=notes,
)
db.session.add(mapping)
if safe_commit("create_salesman_email_mapping", {"user_id": current_user.id}):
return jsonify({"success": True, "message": "Mapping created successfully", "mapping": mapping.to_dict()})
else:
return jsonify({"success": False, "message": "Failed to create mapping"}), 500
except Exception as e:
return jsonify({"success": False, "message": str(e)}), 500
@salesman_reports_bp.route("/api/salesman-email-mappings/<int:mapping_id>", methods=["PUT"])
@login_required
def update_email_mapping(mapping_id):
"""Update a salesman email mapping"""
if not current_user.is_admin:
return jsonify({"success": False, "message": "Permission denied"}), 403
try:
mapping = SalesmanEmailMapping.query.get_or_404(mapping_id)
data = request.json
if "email_address" in data:
mapping.email_address = data["email_address"]
if "email_pattern" in data:
mapping.email_pattern = data["email_pattern"]
if "domain" in data:
mapping.domain = data["domain"]
if "notes" in data:
mapping.notes = data["notes"]
if "is_active" in data:
mapping.is_active = bool(data["is_active"])
mapping.updated_at = datetime.utcnow()
if safe_commit("update_salesman_email_mapping", {"user_id": current_user.id, "mapping_id": mapping_id}):
return jsonify({"success": True, "message": "Mapping updated successfully", "mapping": mapping.to_dict()})
else:
return jsonify({"success": False, "message": "Failed to update mapping"}), 500
except Exception as e:
return jsonify({"success": False, "message": str(e)}), 500
@salesman_reports_bp.route("/api/salesman-email-mappings/<int:mapping_id>", methods=["DELETE"])
@login_required
def delete_email_mapping(mapping_id):
"""Delete a salesman email mapping"""
if not current_user.is_admin:
return jsonify({"success": False, "message": "Permission denied"}), 403
try:
mapping = SalesmanEmailMapping.query.get_or_404(mapping_id)
db.session.delete(mapping)
if safe_commit("delete_salesman_email_mapping", {"user_id": current_user.id, "mapping_id": mapping_id}):
return jsonify({"success": True, "message": "Mapping deleted successfully"})
else:
return jsonify({"success": False, "message": "Failed to delete mapping"}), 500
except Exception as e:
return jsonify({"success": False, "message": str(e)}), 500
@salesman_reports_bp.route("/api/unpaid-hours/by-salesman", methods=["GET"])
@login_required
def get_unpaid_hours_by_salesman():
"""Get unpaid hours grouped by salesman"""
if not current_user.is_admin:
return jsonify({"error": "Permission denied"}), 403
try:
start_date = request.args.get("start_date")
end_date = request.args.get("end_date")
salesman_field_name = request.args.get("salesman_field_name", "salesman")
# Parse dates
start_dt = None
end_dt = None
if start_date:
try:
start_dt = datetime.strptime(start_date, "%Y-%m-%d")
except ValueError:
return jsonify({"error": "Invalid start_date format. Use YYYY-MM-DD"}), 400
if end_date:
try:
end_dt = datetime.strptime(end_date, "%Y-%m-%d") + timedelta(days=1) - timedelta(seconds=1)
except ValueError:
return jsonify({"error": "Invalid end_date format. Use YYYY-MM-DD"}), 400
unpaid_service = UnpaidHoursService()
result = unpaid_service.get_unpaid_hours_by_salesman(
start_date=start_dt,
end_date=end_dt,
salesman_field_name=salesman_field_name,
)
# Convert entries to dict format for JSON serialization
formatted_result = {}
for salesman_initial, data in result.items():
formatted_result[salesman_initial] = {
"total_hours": data["total_hours"],
"total_entries": data["total_entries"],
"clients": data["clients"],
"projects": data["projects"],
"entries": [
{
"id": e.id,
"date": e.start_time.strftime("%Y-%m-%d") if e.start_time else "",
"project": e.project.name if e.project else "",
"client": (e.project.client.name if e.project and e.project.client else (e.client.name if e.client else "Unknown")),
"user": e.user.username if e.user else "",
"duration": e.duration_hours,
"notes": e.notes or "",
}
for e in data["entries"]
],
}
return jsonify({"success": True, "data": formatted_result})
except Exception as e:
return jsonify({"success": False, "error": str(e)}), 500
@salesman_reports_bp.route("/api/salesman-reports/preview-email", methods=["POST"])
@login_required
def preview_salesman_email():
"""Preview email address for a salesman initial"""
if not current_user.is_admin:
return jsonify({"error": "Permission denied"}), 403
try:
data = request.json
salesman_initial = data.get("salesman_initial")
email_pattern = data.get("email_pattern")
domain = data.get("domain")
if not salesman_initial:
return jsonify({"error": "Salesman initial is required"}), 400
salesman_initial = salesman_initial.strip().upper()
# Try to get existing mapping
mapping = SalesmanEmailMapping.query.filter_by(salesman_initial=salesman_initial).first()
if mapping:
email = mapping.get_email()
else:
# Preview with provided pattern/domain
if email_pattern:
email = email_pattern.replace("{value}", salesman_initial)
elif domain:
email = f"{salesman_initial}@{domain}"
else:
email = None
return jsonify({"success": True, "email": email, "salesman_initial": salesman_initial})
except Exception as e:
return jsonify({"success": False, "error": str(e)}), 500
@salesman_reports_bp.route("/api/salesman-reports/generate", methods=["POST"])
@login_required
def generate_salesman_reports():
"""Generate and optionally send reports for each salesman"""
if not current_user.is_admin:
return jsonify({"error": "Permission denied"}), 403
try:
data = request.json
start_date = data.get("start_date")
end_date = data.get("end_date")
salesman_field_name = data.get("salesman_field_name", "salesman")
send_emails = data.get("send_emails", False)
# Parse dates
start_dt = None
end_dt = None
if start_date:
start_dt = datetime.strptime(start_date, "%Y-%m-%d")
if end_date:
end_dt = datetime.strptime(end_date, "%Y-%m-%d") + timedelta(days=1) - timedelta(seconds=1)
unpaid_service = UnpaidHoursService()
result = unpaid_service.get_unpaid_hours_by_salesman(
start_date=start_dt,
end_date=end_dt,
salesman_field_name=salesman_field_name,
)
reports = {}
emails_sent = []
for salesman_initial, data in result.items():
if salesman_initial == "_UNASSIGNED_":
continue
# Get email for this salesman
email = SalesmanEmailMapping.get_email_for_initial(salesman_initial)
if not email and send_emails:
continue # Skip if no email mapping and we're sending emails
# Format report data
report_data = {
"salesman_initial": salesman_initial,
"total_hours": data["total_hours"],
"total_entries": data["total_entries"],
"clients": data["clients"],
"projects": data["projects"],
"entries": [
{
"id": e.id,
"date": e.start_time.strftime("%Y-%m-%d") if e.start_time else "",
"project": e.project.name if e.project else "",
"client": (e.project.client.name if e.project and e.project.client else (e.client.name if e.client else "Unknown")),
"user": e.user.username if e.user else "",
"duration": e.duration_hours,
"notes": e.notes or "",
}
for e in data["entries"]
],
}
reports[salesman_initial] = report_data
# Send email if requested
if send_emails and email:
try:
subject = f"Unpaid Hours Report - {salesman_initial}"
text_body = f"""
Unpaid Hours Report for {salesman_initial}
Total Hours: {data['total_hours']}
Total Entries: {data['total_entries']}
Clients: {', '.join(data['clients'])}
Projects: {', '.join(data['projects'])}
Please review the attached report for details.
"""
send_email(
to=email,
subject=subject,
text_body=text_body,
template="email/unpaid_hours_report.html",
salesman_initial=salesman_initial,
report_data=report_data,
start_date=start_date,
end_date=end_date,
)
emails_sent.append({"salesman": salesman_initial, "email": email, "status": "sent"})
except Exception as e:
emails_sent.append({"salesman": salesman_initial, "email": email, "status": "error", "error": str(e)})
return jsonify({
"success": True,
"reports": reports,
"emails_sent": emails_sent,
"total_salesmen": len(reports),
})
except Exception as e:
return jsonify({"success": False, "error": str(e)}), 500

View File

@@ -6,8 +6,9 @@ from typing import Optional, List, Dict, Any
from datetime import datetime, timedelta
from flask import current_app
from app import db
from app.models import ReportEmailSchedule, SavedReportView, User
from app.models import ReportEmailSchedule, SavedReportView, User, SalesmanEmailMapping
from app.services.reporting_service import ReportingService
from app.services.unpaid_hours_service import UnpaidHoursService
from app.utils.email import send_email
from app.utils.timezone import now_in_app_timezone
import logging
@@ -106,6 +107,10 @@ class ScheduledReportService:
except:
config = {}
# Check if we should split by salesman
if schedule.split_by_salesman:
return self._generate_and_send_salesman_reports(schedule, saved_view, config)
# Generate report data based on config
report_data = self._generate_report_data(saved_view, config)
@@ -275,3 +280,118 @@ class ScheduledReportService:
db.session.rollback()
logger.error(f"Error deleting schedule: {e}")
return {"success": False, "message": f"Error deleting schedule: {str(e)}"}
def _generate_and_send_salesman_reports(
self, schedule: ReportEmailSchedule, saved_view: SavedReportView, config: Dict[str, Any]
) -> Dict[str, Any]:
"""
Generate and send reports split by salesman.
This generates individual reports for each salesman and sends them
to their configured email addresses.
Returns:
dict with 'success', 'message', and 'sent_count' keys
"""
try:
from datetime import timedelta
# Get date range from config or use defaults
end_date = now_in_app_timezone()
if config.get("end_date"):
if isinstance(config["end_date"], str):
end_date = datetime.fromisoformat(config["end_date"])
else:
end_date = config["end_date"]
# Default to last month if no start date
start_date = end_date - timedelta(days=30)
if config.get("start_date"):
if isinstance(config["start_date"], str):
start_date = datetime.fromisoformat(config["start_date"])
else:
start_date = config["start_date"]
# Get salesman field name (default: 'salesman')
salesman_field_name = schedule.salesman_field_name or "salesman"
# Get unpaid hours grouped by salesman
unpaid_service = UnpaidHoursService()
salesman_reports = unpaid_service.get_unpaid_hours_by_salesman(
start_date=start_date,
end_date=end_date,
salesman_field_name=salesman_field_name,
)
sent_count = 0
errors = []
for salesman_initial, report_data in salesman_reports.items():
# Skip unassigned entries
if salesman_initial == "_UNASSIGNED_":
continue
# Get email for this salesman
email = SalesmanEmailMapping.get_email_for_initial(salesman_initial)
if not email:
logger.warning(f"No email mapping found for salesman {salesman_initial}, skipping")
errors.append(f"No email for {salesman_initial}")
continue
# Format report data for email
formatted_data = {
"salesman_initial": salesman_initial,
"total_hours": report_data["total_hours"],
"total_entries": report_data["total_entries"],
"clients": report_data["clients"],
"projects": report_data["projects"],
"entries": [
{
"id": e.id,
"date": e.start_time.strftime("%Y-%m-%d") if e.start_time else "",
"project": e.project.name if e.project else "",
"client": (e.project.client.name if e.project and e.project.client else (e.client.name if e.client else "Unknown")),
"user": e.user.username if e.user else "",
"duration": e.duration_hours,
"notes": e.notes or "",
}
for e in report_data["entries"]
],
}
try:
send_email(
to=email,
subject=f"Unpaid Hours Report - {salesman_initial} ({start_date.strftime('%Y-%m-%d')} to {end_date.strftime('%Y-%m-%d')})",
template="email/unpaid_hours_report.html",
salesman_initial=salesman_initial,
report_data=formatted_data,
start_date=start_date.strftime("%Y-%m-%d"),
end_date=end_date.strftime("%Y-%m-%d"),
)
sent_count += 1
logger.info(f"Sent unpaid hours report to {email} for salesman {salesman_initial}")
except Exception as e:
error_msg = f"Error sending to {email} ({salesman_initial}): {str(e)}"
logger.error(error_msg)
errors.append(error_msg)
# Update schedule
schedule.last_run_at = now_in_app_timezone()
schedule.next_run_at = self._calculate_next_run(schedule.cadence, schedule.cron, schedule.timezone)
db.session.commit()
message = f"Reports sent to {sent_count} salesmen."
if errors:
message += f" Errors: {len(errors)}"
return {
"success": True,
"message": message,
"sent_count": sent_count,
"errors": errors,
}
except Exception as e:
db.session.rollback()
logger.error(f"Error generating and sending salesman reports: {e}")
return {"success": False, "message": f"Error generating reports: {str(e)}"}

View File

@@ -0,0 +1,283 @@
"""
Service for querying unpaid hours with custom field filtering.
This service provides methods to:
- Query unpaid (unbilled) time entries
- Filter by client custom fields (e.g., salesman)
- Group by salesman for report generation
"""
from typing import Optional, Dict, Any, List
from datetime import datetime
from decimal import Decimal
from sqlalchemy import func, or_, and_
from sqlalchemy.orm import joinedload
from app import db
from app.models import TimeEntry, InvoiceItem, Client, Project
class UnpaidHoursService:
"""Service for unpaid hours queries and reporting"""
def get_unpaid_time_entries(
self,
start_date: Optional[datetime] = None,
end_date: Optional[datetime] = None,
project_id: Optional[int] = None,
client_id: Optional[int] = None,
user_id: Optional[int] = None,
custom_field_filter: Optional[Dict[str, Any]] = None,
) -> List[TimeEntry]:
"""
Get unpaid (unbilled) time entries.
Unpaid means:
- billable = True
- paid = False
- Not referenced in any InvoiceItem.time_entry_ids
Args:
start_date: Filter entries from this date
end_date: Filter entries until this date
project_id: Filter by project
client_id: Filter by client
user_id: Filter by user
custom_field_filter: Dict with field name and value to filter by client custom fields
e.g., {"salesman": "MM"} or {"field_name": "value"}
Returns:
List of TimeEntry objects that are unpaid
"""
# Start with base query for billable, unpaid entries
query = TimeEntry.query.filter(
TimeEntry.billable == True,
TimeEntry.paid == False,
TimeEntry.end_time.isnot(None),
)
# Date filters
if start_date:
query = query.filter(TimeEntry.start_time >= start_date)
if end_date:
query = query.filter(TimeEntry.start_time <= end_date)
# Project/Client/User filters
if project_id:
query = query.filter(TimeEntry.project_id == project_id)
if client_id:
query = query.filter(TimeEntry.client_id == client_id)
if user_id:
query = query.filter(TimeEntry.user_id == user_id)
# Get all entries first
all_entries = query.options(joinedload(TimeEntry.project), joinedload(TimeEntry.client)).all()
# Get all billed time entry IDs from invoice items
billed_entry_ids = set()
invoice_items = InvoiceItem.query.filter(InvoiceItem.time_entry_ids.isnot(None)).all()
for item in invoice_items:
if item.time_entry_ids:
try:
entry_ids = [int(id_str.strip()) for id_str in item.time_entry_ids.split(",") if id_str.strip()]
billed_entry_ids.update(entry_ids)
except (ValueError, AttributeError):
continue
# Filter out billed entries
unpaid_entries = [entry for entry in all_entries if entry.id not in billed_entry_ids]
# Apply custom field filter if provided
if custom_field_filter:
unpaid_entries = self._filter_by_custom_fields(unpaid_entries, custom_field_filter)
return unpaid_entries
def _filter_by_custom_fields(self, entries: List[TimeEntry], custom_field_filter: Dict[str, Any]) -> List[TimeEntry]:
"""
Filter entries by client custom fields.
Args:
entries: List of TimeEntry objects
custom_field_filter: Dict with field name and value
e.g., {"salesman": "MM"}
Returns:
Filtered list of TimeEntry objects
"""
if not custom_field_filter:
return entries
filtered = []
for entry in entries:
# Get client from entry (via project or direct)
client = None
if entry.project and entry.project.client:
client = entry.project.client
elif entry.client:
client = entry.client
if not client or not client.custom_fields:
continue
# Check if any custom field matches
matches = True
for field_name, field_value in custom_field_filter.items():
client_value = client.custom_fields.get(field_name)
# Case-insensitive comparison
if str(client_value).upper().strip() != str(field_value).upper().strip():
matches = False
break
if matches:
filtered.append(entry)
return filtered
def get_unpaid_hours_summary(
self,
start_date: Optional[datetime] = None,
end_date: Optional[datetime] = None,
custom_field_filter: Optional[Dict[str, Any]] = None,
) -> Dict[str, Any]:
"""
Get summary of unpaid hours.
Returns:
Dict with total_hours, total_entries, and breakdown by client/project
"""
entries = self.get_unpaid_time_entries(
start_date=start_date,
end_date=end_date,
custom_field_filter=custom_field_filter,
)
total_hours = sum(entry.duration_hours or 0 for entry in entries)
# Group by client
by_client = {}
by_project = {}
for entry in entries:
client = None
if entry.project and entry.project.client:
client = entry.project.client
elif entry.client:
client = entry.client
if client:
client_name = client.name
if client_name not in by_client:
by_client[client_name] = {"hours": 0, "entries": []}
by_client[client_name]["hours"] += entry.duration_hours or 0
by_client[client_name]["entries"].append(entry)
if entry.project:
project_name = entry.project.name
if project_name not in by_project:
by_project[project_name] = {"hours": 0, "entries": []}
by_project[project_name]["hours"] += entry.duration_hours or 0
by_project[project_name]["entries"].append(entry)
return {
"total_hours": round(total_hours, 2),
"total_entries": len(entries),
"by_client": by_client,
"by_project": by_project,
}
def group_by_salesman(
self,
entries: List[TimeEntry],
salesman_field_name: str = "salesman",
) -> Dict[str, List[TimeEntry]]:
"""
Group unpaid hours by salesman initial from client custom fields.
Args:
entries: List of TimeEntry objects
salesman_field_name: Name of the custom field containing salesman info
Returns:
Dict mapping salesman initial to list of TimeEntry objects
"""
grouped = {}
unassigned = []
for entry in entries:
# Get client from entry
client = None
if entry.project and entry.project.client:
client = entry.project.client
elif entry.client:
client = entry.client
if not client or not client.custom_fields:
unassigned.append(entry)
continue
salesman_value = client.custom_fields.get(salesman_field_name)
if not salesman_value:
unassigned.append(entry)
continue
# Normalize salesman initial (uppercase, strip)
salesman_initial = str(salesman_value).upper().strip()
if salesman_initial not in grouped:
grouped[salesman_initial] = []
grouped[salesman_initial].append(entry)
# Add unassigned entries to a special key
if unassigned:
grouped["_UNASSIGNED_"] = unassigned
return grouped
def get_unpaid_hours_by_salesman(
self,
start_date: Optional[datetime] = None,
end_date: Optional[datetime] = None,
salesman_field_name: str = "salesman",
) -> Dict[str, Dict[str, Any]]:
"""
Get unpaid hours grouped by salesman.
Returns:
Dict mapping salesman initial to summary dict with:
- entries: List of TimeEntry objects
- total_hours: Total hours for this salesman
- clients: List of unique clients
- projects: List of unique projects
"""
entries = self.get_unpaid_time_entries(
start_date=start_date,
end_date=end_date,
)
grouped_entries = self.group_by_salesman(entries, salesman_field_name)
result = {}
for salesman_initial, salesman_entries in grouped_entries.items():
total_hours = sum(entry.duration_hours or 0 for entry in salesman_entries)
# Get unique clients and projects
clients = set()
projects = set()
for entry in salesman_entries:
if entry.project and entry.project.client:
clients.add(entry.project.client.name)
elif entry.client:
clients.add(entry.client.name)
if entry.project:
projects.add(entry.project.name)
result[salesman_initial] = {
"entries": salesman_entries,
"total_hours": round(total_hours, 2),
"total_entries": len(salesman_entries),
"clients": sorted(list(clients)),
"projects": sorted(list(projects)),
}
return result

View File

@@ -76,6 +76,11 @@
<div>Link Templates</div>
<div class="text-xs mt-1 opacity-90">URL Templates for Fields</div>
</a>
<a href="{{ url_for('salesman_reports.list_email_mappings') }}" class="bg-amber-600 text-white p-4 rounded-lg text-center hover:bg-amber-700">
<i class="fas fa-envelope mb-2"></i>
<div>Salesman Email Mappings</div>
<div class="text-xs mt-1 opacity-90">Report Distribution</div>
</a>
</div>
</div>

View File

@@ -0,0 +1,403 @@
{% extends "base.html" %}
{% from "components/ui.html" import page_header %}
{% block title %}{{ _('Salesman Email Mappings') }} - {{ _('Admin') }} - {{ app_name }}{% endblock %}
{% block content %}
{% set breadcrumbs = [
{'text': 'Admin', 'url': url_for('admin.admin_dashboard')},
{'text': 'Salesman Email Mappings'}
] %}
{{ page_header(
icon_class='fas fa-envelope',
title_text='Salesman Email Mappings',
subtitle_text='Configure email addresses for salesman-based report distribution',
breadcrumbs=breadcrumbs
) }}
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow mb-6">
<div class="flex justify-between items-center mb-4">
<h3 class="text-lg font-semibold">{{ _('Email Mappings') }}</h3>
<button onclick="openCreateModal()" class="bg-primary text-white px-4 py-2 rounded-lg hover:bg-primary/90">
<i class="fas fa-plus mr-2"></i>{{ _('Add Mapping') }}
</button>
</div>
<div class="overflow-x-auto">
<table class="min-w-full divide-y divide-border-light dark:divide-border-dark">
<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-300 uppercase tracking-wider">
{{ _('Salesman Initial') }}
</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
{{ _('Email Address') }}
</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
{{ _('Pattern/Domain') }}
</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
{{ _('Status') }}
</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-300 uppercase tracking-wider">
{{ _('Actions') }}
</th>
</tr>
</thead>
<tbody id="mappingsTableBody" class="bg-white dark:bg-gray-900 divide-y divide-border-light dark:divide-border-dark">
<tr>
<td colspan="5" class="px-6 py-4 text-center text-gray-500 dark:text-gray-400">
<i class="fas fa-spinner fa-spin mr-2"></i>{{ _('Loading...') }}
</td>
</tr>
</tbody>
</table>
</div>
</div>
<!-- Create/Edit Modal -->
<div id="mappingModal" class="hidden fixed inset-0 bg-black/60 z-50 flex items-center justify-center p-4">
<div class="bg-card-light dark:bg-card-dark rounded-lg shadow-xl p-6 max-w-md w-full">
<div class="flex justify-between items-center mb-4">
<h3 id="modalTitle" class="text-lg font-semibold">{{ _('Add Email Mapping') }}</h3>
<button onclick="closeModal()" class="text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200">
<i class="fas fa-times text-xl"></i>
</button>
</div>
<form id="mappingForm" onsubmit="saveMapping(event)">
<input type="hidden" id="mappingId">
<div class="mb-4">
<label for="salesmanInitial" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
{{ _('Salesman Initial') }} <span class="text-red-500">*</span>
</label>
<input type="text" id="salesmanInitial" required
class="form-input" placeholder="MM, PB, etc."
pattern="[A-Za-z]{1,20}" title="{{ _('1-20 letters only') }}">
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">
{{ _('The salesman initial from client custom fields (e.g., MM for Max Mustermann)') }}
</p>
</div>
<div class="mb-4">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
{{ _('Email Configuration') }}
</label>
<div class="space-y-3">
<div>
<label class="flex items-center cursor-pointer">
<input type="radio" name="emailType" value="direct" checked class="form-radio mr-2" onchange="updateEmailType()">
<span class="text-sm">{{ _('Direct Email Address') }}</span>
</label>
<input type="email" id="emailAddress"
class="form-input mt-2"
placeholder="salesman@example.com">
</div>
<div>
<label class="flex items-center cursor-pointer">
<input type="radio" name="emailType" value="pattern" class="form-radio mr-2" onchange="updateEmailType()">
<span class="text-sm">{{ _('Pattern (e.g., {value}@test.de)') }}</span>
</label>
<input type="text" id="emailPattern"
class="form-input mt-2 hidden"
placeholder="{value}@test.de">
</div>
<div>
<label class="flex items-center cursor-pointer">
<input type="radio" name="emailType" value="domain" class="form-radio mr-2" onchange="updateEmailType()">
<span class="text-sm">{{ _('Domain (e.g., test.de)') }}</span>
</label>
<input type="text" id="emailDomain"
class="form-input mt-2 hidden"
placeholder="test.de">
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">
{{ _('Will generate: {initial}@domain') }}
</p>
</div>
</div>
</div>
<div class="mb-4">
<label for="notes" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
{{ _('Notes') }} ({{ _('optional') }})
</label>
<textarea id="notes" rows="2" class="form-input"
placeholder="{{ _('Additional notes about this mapping') }}"></textarea>
</div>
<div class="mb-4">
<label class="flex items-center cursor-pointer">
<input type="checkbox" id="isActive" checked class="form-checkbox mr-2">
<span class="text-sm text-gray-700 dark:text-gray-300">{{ _('Active') }}</span>
</label>
</div>
<div id="emailPreview" class="mb-4 p-3 bg-blue-50 dark:bg-blue-900/20 rounded-lg hidden">
<p class="text-xs text-gray-600 dark:text-gray-400 mb-1">{{ _('Preview:') }}</p>
<p class="font-mono text-sm" id="previewEmail"></p>
</div>
<div class="flex justify-end gap-4">
<button type="button" onclick="closeModal()"
class="px-4 py-2 border border-border-light dark:border-border-dark rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700">
{{ _('Cancel') }}
</button>
<button type="submit" class="bg-primary text-white px-4 py-2 rounded-lg hover:bg-primary/90">
{{ _('Save') }}
</button>
</div>
</form>
</div>
</div>
<script>
let mappings = [];
// Load mappings on page load
document.addEventListener('DOMContentLoaded', function() {
loadMappings();
// Add preview on input change
document.getElementById('salesmanInitial')?.addEventListener('input', updatePreview);
document.getElementById('emailAddress')?.addEventListener('input', updatePreview);
document.getElementById('emailPattern')?.addEventListener('input', updatePreview);
document.getElementById('emailDomain')?.addEventListener('input', updatePreview);
});
function loadMappings() {
fetch('{{ url_for("salesman_reports.get_email_mappings_api") }}')
.then(response => response.json())
.then(data => {
if (data.success) {
mappings = data.mappings;
renderMappings();
}
})
.catch(error => {
console.error('Error loading mappings:', error);
document.getElementById('mappingsTableBody').innerHTML =
'<tr><td colspan="5" class="px-6 py-4 text-center text-red-500">Error loading mappings</td></tr>';
});
}
function renderMappings() {
const tbody = document.getElementById('mappingsTableBody');
if (mappings.length === 0) {
tbody.innerHTML = `
<tr>
<td colspan="5" class="px-6 py-4 text-center text-gray-500 dark:text-gray-400">
{{ _('No mappings configured. Click "Add Mapping" to create one.') }}
</td>
</tr>
`;
return;
}
tbody.innerHTML = mappings.map(m => `
<tr>
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium">
<span class="inline-flex items-center px-2.5 py-0.5 rounded-full text-xs font-medium bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200">
${m.salesman_initial}
</span>
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm">
${m.resolved_email || '<span class="text-gray-400">-</span>'}
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400">
${m.email_pattern || m.domain || m.email_address || '-'}
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm">
${m.is_active
? '<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 dark:text-green-200">Active</span>'
: '<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-900 dark:text-gray-200">Inactive</span>'}
</td>
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium">
<button onclick="editMapping(${m.id})" class="text-blue-600 hover:text-blue-900 dark:text-blue-400 mr-3">
<i class="fas fa-edit"></i>
</button>
<button onclick="deleteMapping(${m.id})" class="text-red-600 hover:text-red-900 dark:text-red-400">
<i class="fas fa-trash"></i>
</button>
</td>
</tr>
`).join('');
}
function openCreateModal() {
document.getElementById('modalTitle').textContent = '{{ _("Add Email Mapping") }}';
document.getElementById('mappingForm').reset();
document.getElementById('mappingId').value = '';
document.getElementById('isActive').checked = true;
updateEmailType();
document.getElementById('mappingModal').classList.remove('hidden');
}
function editMapping(id) {
const mapping = mappings.find(m => m.id === id);
if (!mapping) return;
document.getElementById('modalTitle').textContent = '{{ _("Edit Email Mapping") }}';
document.getElementById('mappingId').value = mapping.id;
document.getElementById('salesmanInitial').value = mapping.salesman_initial;
document.getElementById('notes').value = mapping.notes || '';
document.getElementById('isActive').checked = mapping.is_active;
// Set email type
if (mapping.email_address) {
document.querySelector('input[name="emailType"][value="direct"]').checked = true;
document.getElementById('emailAddress').value = mapping.email_address;
} else if (mapping.email_pattern) {
document.querySelector('input[name="emailType"][value="pattern"]').checked = true;
document.getElementById('emailPattern').value = mapping.email_pattern;
} else if (mapping.domain) {
document.querySelector('input[name="emailType"][value="domain"]').checked = true;
document.getElementById('emailDomain').value = mapping.domain;
}
updateEmailType();
updatePreview();
document.getElementById('mappingModal').classList.remove('hidden');
}
function closeModal() {
document.getElementById('mappingModal').classList.add('hidden');
document.getElementById('mappingForm').reset();
}
function updateEmailType() {
const emailType = document.querySelector('input[name="emailType"]:checked').value;
document.getElementById('emailAddress').classList.add('hidden');
document.getElementById('emailPattern').classList.add('hidden');
document.getElementById('emailDomain').classList.add('hidden');
if (emailType === 'direct') {
document.getElementById('emailAddress').classList.remove('hidden');
document.getElementById('emailAddress').required = true;
} else if (emailType === 'pattern') {
document.getElementById('emailPattern').classList.remove('hidden');
document.getElementById('emailPattern').required = true;
} else if (emailType === 'domain') {
document.getElementById('emailDomain').classList.remove('hidden');
document.getElementById('emailDomain').required = true;
}
updatePreview();
}
function updatePreview() {
const initial = document.getElementById('salesmanInitial').value.toUpperCase();
const emailType = document.querySelector('input[name="emailType"]:checked')?.value;
const previewDiv = document.getElementById('emailPreview');
const previewEmail = document.getElementById('previewEmail');
if (!initial) {
previewDiv.classList.add('hidden');
return;
}
let email = '';
if (emailType === 'direct') {
email = document.getElementById('emailAddress').value;
} else if (emailType === 'pattern') {
const pattern = document.getElementById('emailPattern').value;
email = pattern.replace('{value}', initial);
} else if (emailType === 'domain') {
const domain = document.getElementById('emailDomain').value;
email = `${initial}@${domain}`;
}
if (email) {
previewEmail.textContent = email;
previewDiv.classList.remove('hidden');
} else {
previewDiv.classList.add('hidden');
}
}
function saveMapping(event) {
event.preventDefault();
const id = document.getElementById('mappingId').value;
const salesmanInitial = document.getElementById('salesmanInitial').value.toUpperCase().trim();
const emailType = document.querySelector('input[name="emailType"]:checked').value;
const isActive = document.getElementById('isActive').checked;
const notes = document.getElementById('notes').value;
const data = {
salesman_initial: salesmanInitial,
is_active: isActive,
notes: notes
};
if (emailType === 'direct') {
data.email_address = document.getElementById('emailAddress').value;
} else if (emailType === 'pattern') {
data.email_pattern = document.getElementById('emailPattern').value;
} else if (emailType === 'domain') {
data.domain = document.getElementById('emailDomain').value;
}
const url = id
? `{{ url_for('salesman_reports.update_email_mapping', mapping_id=0) }}`.replace('0', id)
: '{{ url_for("salesman_reports.create_email_mapping") }}';
const method = id ? 'PUT' : 'POST';
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content || '';
fetch(url, {
method: method,
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': csrfToken
},
credentials: 'same-origin',
body: JSON.stringify(data)
})
.then(response => response.json())
.then(result => {
if (result.success) {
closeModal();
loadMappings();
} else {
alert(result.message || '{{ _("Error saving mapping") }}');
}
})
.catch(error => {
alert('{{ _("Error saving mapping") }}: ' + error.message);
});
}
function deleteMapping(id) {
if (!confirm('{{ _("Are you sure you want to delete this mapping?") }}')) {
return;
}
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content || '';
const url = `{{ url_for('salesman_reports.delete_email_mapping', mapping_id=0) }}`.replace('0', id);
fetch(url, {
method: 'DELETE',
headers: {
'X-CSRFToken': csrfToken
},
credentials: 'same-origin'
})
.then(response => response.json())
.then(result => {
if (result.success) {
loadMappings();
} else {
alert(result.message || '{{ _("Error deleting mapping") }}');
}
})
.catch(error => {
alert('{{ _("Error deleting mapping") }}: ' + error.message);
});
}
</script>
{% endblock %}

View File

@@ -0,0 +1,189 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<style>
body {
font-family: Arial, sans-serif;
line-height: 1.6;
color: #333;
max-width: 800px;
margin: 0 auto;
padding: 20px;
}
.header {
background-color: #f59e0b;
color: white;
padding: 20px;
text-align: center;
border-radius: 5px;
}
.content {
background-color: #f9fafb;
padding: 20px;
margin-top: 20px;
border-radius: 5px;
}
.summary-box {
background-color: white;
padding: 20px;
margin: 15px 0;
text-align: center;
border-radius: 5px;
border: 2px solid #f59e0b;
}
.summary-box h2 {
margin: 0;
color: #f59e0b;
font-size: 36px;
}
.summary-box p {
margin: 5px 0;
color: #6b7280;
}
.warning-box {
background-color: #fef3c7;
border-left: 4px solid #f59e0b;
padding: 15px;
margin: 15px 0;
border-radius: 5px;
}
.clients-section {
margin: 20px 0;
}
.client-group {
background-color: white;
padding: 15px;
margin: 15px 0;
border-radius: 5px;
border-left: 4px solid #3b82f6;
}
.client-group h3 {
margin-top: 0;
color: #3b82f6;
}
.entries-table {
width: 100%;
border-collapse: collapse;
margin-top: 10px;
}
.entries-table th {
background-color: #e0f2fe;
padding: 10px;
text-align: left;
font-weight: bold;
font-size: 12px;
}
.entries-table td {
padding: 8px;
border-bottom: 1px solid #e5e7eb;
font-size: 12px;
}
.entries-table tr:last-child td {
border-bottom: none;
}
.hours-badge {
background-color: #f59e0b;
color: white;
padding: 4px 8px;
border-radius: 4px;
font-weight: bold;
}
.button {
display: inline-block;
padding: 12px 24px;
background-color: #3b82f6;
color: white;
text-decoration: none;
border-radius: 5px;
margin-top: 15px;
}
.footer {
text-align: center;
color: #6b7280;
font-size: 12px;
margin-top: 30px;
padding-top: 20px;
border-top: 1px solid #e5e7eb;
}
</style>
</head>
<body>
<div class="header">
<h1>⚠️ Unpaid Hours Report</h1>
<p>Salesman: {{ salesman_initial }}</p>
<p>{{ start_date }} to {{ end_date }}</p>
</div>
<div class="content">
<p>Hello,</p>
<p>This report contains all unpaid (unbilled) hours for clients assigned to you ({{ salesman_initial }}).</p>
<div class="summary-box">
<p>Total Unpaid Hours</p>
<h2>{{ "%.2f"|format(report_data.total_hours) }}</h2>
<p>{{ report_data.total_entries }} time entries</p>
</div>
<div class="warning-box">
<strong>⚠️ Action Required:</strong> These hours need to be reviewed and billed to clients.
</div>
<div class="clients-section">
<h3>Clients with Unpaid Hours:</h3>
<p><strong>{{ report_data.clients|join(', ') }}</strong></p>
</div>
<div class="clients-section">
<h3>Projects with Unpaid Hours:</h3>
<p><strong>{{ report_data.projects|join(', ') }}</strong></p>
</div>
{% if report_data.entries %}
<h3 style="margin-top: 30px;">Time Entry Details</h3>
<table class="entries-table">
<thead>
<tr>
<th>Date</th>
<th>Client</th>
<th>Project</th>
<th>User</th>
<th style="text-align: right;">Hours</th>
<th>Notes</th>
</tr>
</thead>
<tbody>
{% for entry in report_data.entries %}
<tr>
<td>{{ entry.date }}</td>
<td>{{ entry.client }}</td>
<td>{{ entry.project }}</td>
<td>{{ entry.user }}</td>
<td style="text-align: right;">
<span class="hours-badge">{{ "%.2f"|format(entry.duration) }}</span>
</td>
<td>{{ entry.notes[:50] }}{% if entry.notes|length > 50 %}...{% endif %}</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endif %}
<p style="margin-top: 20px;">Please review these entries and create invoices as needed.</p>
<center>
<a href="{{ url_for('reports.reports', _external=True) }}" class="button">
View Reports in TimeTracker
</a>
</center>
</div>
<div class="footer">
<p>TimeTracker - Time Tracking & Project Management</p>
<p>This is an automated report. For questions, please contact your administrator.</p>
</div>
</body>
</html>

View File

@@ -85,6 +85,52 @@
</select>
</div>
</div>
<!-- Advanced Filters (for Time Entries) -->
<div id="timeEntriesFilters" class="mt-4 pt-4 border-t border-border-light dark:border-border-dark">
<h4 class="text-sm font-semibold mb-3 text-gray-700 dark:text-gray-300">{{ _('Advanced Filters') }}</h4>
<!-- Unpaid Hours Filter -->
<div class="mb-4">
<label class="flex items-center cursor-pointer">
<input type="checkbox" id="filterUnpaidOnly" class="form-checkbox mr-2">
<span class="text-sm text-gray-700 dark:text-gray-300">
<i class="fas fa-exclamation-triangle text-orange-500 mr-1"></i>
{{ _('Unpaid Hours Only') }}
</span>
</label>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1 ml-6">
{{ _('Show only billable hours that have not been invoiced') }}
</p>
</div>
<!-- Custom Field Filter -->
{% if custom_field_keys %}
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
{{ _('Custom Field') }}
</label>
<select id="filterCustomFieldName" class="form-input">
<option value="">{{ _('No Filter') }}</option>
{% for field_key in custom_field_keys %}
<option value="{{ field_key }}">{{ field_key|replace('_', ' ')|title }}</option>
{% endfor %}
</select>
</div>
<div id="filterCustomFieldValueContainer" class="hidden">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
{{ _('Field Value') }}
</label>
<input type="text" id="filterCustomFieldValue" class="form-input"
placeholder="{{ _('e.g., MM, PB') }}">
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">
{{ _('Enter the value to filter by (e.g., salesman initial)') }}
</p>
</div>
</div>
{% endif %}
</div>
</div>
</div>
</div>
@@ -243,12 +289,23 @@ function previewReport() {
// Collect current filters
const projectSelect = document.getElementById('filterProject');
const projectValue = projectSelect ? projectSelect.value : '';
reportConfig.filters = {
start_date: document.getElementById('filterStartDate').value || null,
end_date: document.getElementById('filterEndDate').value || null,
project_id: projectValue || null
project_id: projectValue || null,
unpaid_only: document.getElementById('filterUnpaidOnly')?.checked || false
};
// Add custom field filter if set
const customFieldName = document.getElementById('filterCustomFieldName')?.value;
const customFieldValue = document.getElementById('filterCustomFieldValue')?.value;
if (customFieldName && customFieldValue) {
reportConfig.filters.custom_field_filter = {
[customFieldName]: customFieldValue
};
}
// Show preview modal
const modal = document.getElementById('previewModal');
const loading = document.getElementById('previewLoading');
@@ -424,9 +481,19 @@ document.getElementById('saveForm').addEventListener('submit', async (e) => {
reportConfig.filters = {
start_date: document.getElementById('filterStartDate').value,
end_date: document.getElementById('filterEndDate').value,
project_id: document.getElementById('filterProject').value || null
project_id: document.getElementById('filterProject').value || null,
unpaid_only: document.getElementById('filterUnpaidOnly')?.checked || false
};
// Add custom field filter if set
const customFieldName = document.getElementById('filterCustomFieldName')?.value;
const customFieldValue = document.getElementById('filterCustomFieldValue')?.value;
if (customFieldName && customFieldValue) {
reportConfig.filters.custom_field_filter = {
[customFieldName]: customFieldValue
};
}
try {
// Get CSRF token from meta tag
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content || '';
@@ -458,6 +525,37 @@ document.getElementById('saveForm').addEventListener('submit', async (e) => {
alert('{{ _("Error saving report") }}: ' + error.message);
}
});
// Show/hide custom field value input based on field selection
document.getElementById('filterCustomFieldName')?.addEventListener('change', function() {
const valueContainer = document.getElementById('filterCustomFieldValueContainer');
const valueInput = document.getElementById('filterCustomFieldValue');
if (this.value) {
valueContainer.classList.remove('hidden');
valueInput.required = true;
} else {
valueContainer.classList.add('hidden');
valueInput.required = false;
valueInput.value = '';
}
});
// Show time entries filters only when time_entries is selected
function updateFiltersVisibility() {
const timeEntriesFilters = document.getElementById('timeEntriesFilters');
if (reportConfig.data_source === 'time_entries') {
timeEntriesFilters?.classList.remove('hidden');
} else {
timeEntriesFilters?.classList.add('hidden');
}
}
// Update filter visibility when data source changes
const originalAddDataSource = addDataSourceToCanvas;
addDataSourceToCanvas = function(sourceId) {
originalAddDataSource(sourceId);
updateFiltersVisibility();
};
</script>
{% endblock %}

View File

@@ -263,6 +263,93 @@ def generate_recurring_invoices():
return 0
def send_monthly_unpaid_hours_reports():
"""Send monthly unpaid hours reports split by salesman
This task runs on the first day of each month and generates
unpaid hours reports for each salesman based on their client assignments.
"""
with current_app.app_context():
try:
logger.info("Sending monthly unpaid hours reports by salesman...")
from app.services.unpaid_hours_service import UnpaidHoursService
from app.models import SalesmanEmailMapping
from app.utils.email import send_email
from datetime import datetime, timedelta
# Get last month's date range
now = datetime.now()
if now.month == 1:
last_month_start = datetime(now.year - 1, 12, 1)
last_month_end = datetime(now.year, 1, 1) - timedelta(seconds=1)
else:
last_month_start = datetime(now.year, now.month - 1, 1)
last_month_end = datetime(now.year, now.month, 1) - timedelta(seconds=1)
# Get unpaid hours grouped by salesman
unpaid_service = UnpaidHoursService()
salesman_reports = unpaid_service.get_unpaid_hours_by_salesman(
start_date=last_month_start,
end_date=last_month_end,
salesman_field_name="salesman",
)
sent_count = 0
for salesman_initial, report_data in salesman_reports.items():
if salesman_initial == "_UNASSIGNED_":
continue
# Get email for this salesman
email = SalesmanEmailMapping.get_email_for_initial(salesman_initial)
if not email:
logger.warning(f"No email mapping for salesman {salesman_initial}, skipping")
continue
# Format report data
formatted_data = {
"salesman_initial": salesman_initial,
"total_hours": report_data["total_hours"],
"total_entries": report_data["total_entries"],
"clients": report_data["clients"],
"projects": report_data["projects"],
"entries": [
{
"id": e.id,
"date": e.start_time.strftime("%Y-%m-%d") if e.start_time else "",
"project": e.project.name if e.project else "",
"client": (e.project.client.name if e.project and e.project.client else (e.client.name if e.client else "Unknown")),
"user": e.user.username if e.user else "",
"duration": e.duration_hours,
"notes": e.notes or "",
}
for e in report_data["entries"]
],
}
try:
send_email(
to=email,
subject=f"Monthly Unpaid Hours Report - {salesman_initial} ({last_month_start.strftime('%Y-%m-%d')} to {last_month_end.strftime('%Y-%m-%d')})",
template="email/unpaid_hours_report.html",
salesman_initial=salesman_initial,
report_data=formatted_data,
start_date=last_month_start.strftime("%Y-%m-%d"),
end_date=last_month_end.strftime("%Y-%m-%d"),
)
sent_count += 1
logger.info(f"Sent monthly unpaid hours report to {email} for {salesman_initial}")
except Exception as e:
logger.error(f"Error sending report to {email} ({salesman_initial}): {e}")
logger.info(f"Sent {sent_count} monthly unpaid hours reports")
return sent_count
except Exception as e:
logger.error(f"Error sending monthly unpaid hours reports: {e}")
return 0
def register_scheduled_tasks(scheduler, app=None):
"""Register all scheduled tasks with APScheduler
@@ -320,6 +407,31 @@ def register_scheduled_tasks(scheduler, app=None):
)
logger.info("Registered recurring invoices generation task")
# Send monthly unpaid hours reports by salesman (first day of month at 9 AM)
def send_monthly_unpaid_hours_reports_with_app():
"""Wrapper that uses the captured app instance"""
app_instance = app
if app_instance is None:
try:
app_instance = current_app._get_current_object()
except RuntimeError:
logger.error("No app instance available for monthly unpaid hours reports")
return
with app_instance.app_context():
send_monthly_unpaid_hours_reports()
scheduler.add_job(
func=send_monthly_unpaid_hours_reports_with_app,
trigger="cron",
day=1,
hour=9,
minute=0,
id="send_monthly_unpaid_hours_reports",
name="Send monthly unpaid hours reports by salesman",
replace_existing=True,
)
logger.info("Registered monthly unpaid hours reports task")
# Retry failed webhook deliveries every 5 minutes
# Create a closure that captures the app instance
if app is None:

View File

@@ -0,0 +1,44 @@
"""Add salesman email mapping table
Revision ID: 087_salesman_email_mapping
Revises: 086_project_client_attachments
Create Date: 2025-01-29
This migration adds:
- salesman_email_mappings table for mapping salesman initials to email addresses
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '087_salesman_email_mapping'
down_revision = '086_project_client_attachments'
branch_labels = None
depends_on = None
def upgrade():
"""Create salesman_email_mappings table"""
op.create_table('salesman_email_mappings',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('salesman_initial', sa.String(length=20), nullable=False),
sa.Column('email_address', sa.String(length=255), nullable=True),
sa.Column('email_pattern', sa.String(length=255), nullable=True), # e.g., '{value}@test.de'
sa.Column('domain', sa.String(length=255), nullable=True), # e.g., 'test.de' for pattern-based emails
sa.Column('is_active', sa.Boolean(), nullable=False, server_default='true'),
sa.Column('notes', sa.Text(), nullable=True),
sa.Column('created_at', sa.DateTime(), nullable=False),
sa.Column('updated_at', sa.DateTime(), nullable=False),
sa.PrimaryKeyConstraint('id'),
sa.UniqueConstraint('salesman_initial', name='uq_salesman_email_mapping_initial')
)
op.create_index('ix_salesman_email_mappings_initial', 'salesman_email_mappings', ['salesman_initial'], unique=False)
op.create_index('ix_salesman_email_mappings_active', 'salesman_email_mappings', ['is_active'], unique=False)
def downgrade():
"""Drop salesman_email_mappings table"""
op.drop_index('ix_salesman_email_mappings_active', table_name='salesman_email_mappings')
op.drop_index('ix_salesman_email_mappings_initial', table_name='salesman_email_mappings')
op.drop_table('salesman_email_mappings')

View File

@@ -0,0 +1,36 @@
"""Add salesman splitting to report email schedules
Revision ID: 088_salesman_splitting_reports
Revises: 087_salesman_email_mapping
Create Date: 2025-01-29
This migration adds:
- split_by_salesman field to report_email_schedules table
- salesman_field_name field to specify which custom field to use
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '088_salesman_splitting_reports'
down_revision = '087_salesman_email_mapping'
branch_labels = None
depends_on = None
def upgrade():
"""Add salesman splitting fields to report_email_schedules"""
# Add split_by_salesman field
op.add_column('report_email_schedules',
sa.Column('split_by_salesman', sa.Boolean(), nullable=False, server_default='false'))
# Add salesman_field_name field (defaults to 'salesman')
op.add_column('report_email_schedules',
sa.Column('salesman_field_name', sa.String(length=50), nullable=True))
def downgrade():
"""Remove salesman splitting fields"""
op.drop_column('report_email_schedules', 'salesman_field_name')
op.drop_column('report_email_schedules', 'split_by_salesman')