mirror of
https://github.com/DRYTRIX/TimeTracker.git
synced 2026-02-16 10:38:45 -06:00
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:
@@ -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
|
||||
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
@@ -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)
|
||||
|
||||
80
app/models/salesman_email_mapping.py
Normal file
80
app/models/salesman_email_mapping.py
Normal 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()
|
||||
|
||||
@@ -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":
|
||||
|
||||
339
app/routes/salesman_reports.py
Normal file
339
app/routes/salesman_reports.py
Normal 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
|
||||
|
||||
@@ -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)}"}
|
||||
|
||||
283
app/services/unpaid_hours_service.py
Normal file
283
app/services/unpaid_hours_service.py
Normal 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
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
403
app/templates/admin/salesman_email_mappings.html
Normal file
403
app/templates/admin/salesman_email_mappings.html
Normal 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 %}
|
||||
|
||||
189
app/templates/email/unpaid_hours_report.html
Normal file
189
app/templates/email/unpaid_hours_report.html
Normal 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>
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
44
migrations/versions/087_add_salesman_email_mapping.py
Normal file
44
migrations/versions/087_add_salesman_email_mapping.py
Normal 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')
|
||||
|
||||
36
migrations/versions/088_add_salesman_splitting_to_reports.py
Normal file
36
migrations/versions/088_add_salesman_splitting_to_reports.py
Normal 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')
|
||||
|
||||
Reference in New Issue
Block a user