mirror of
https://github.com/DRYTRIX/TimeTracker.git
synced 2026-05-20 05:10:26 -05:00
+2
-2
@@ -278,9 +278,9 @@ def create_app(config=None):
|
||||
if (not app.config.get("TESTING")) and (not scheduler.running):
|
||||
from app.utils.scheduled_tasks import register_scheduled_tasks
|
||||
scheduler.start()
|
||||
# Register tasks after app context is available
|
||||
# Register tasks after app context is available, passing app instance
|
||||
with app.app_context():
|
||||
register_scheduled_tasks(scheduler)
|
||||
register_scheduled_tasks(scheduler, app=app)
|
||||
|
||||
# Only initialize CSRF protection if enabled
|
||||
if app.config.get('WTF_CSRF_ENABLED'):
|
||||
|
||||
@@ -662,8 +662,27 @@
|
||||
.prose p, .prose-sm p { margin: 0.5rem 0; }
|
||||
.prose a, .prose-sm a { color: #3B82F6; text-decoration: underline; }
|
||||
.dark .prose a, .dark .prose-sm a { color: #60A5FA; }
|
||||
.prose ul, .prose ol, .prose-sm ul, .prose-sm ol { padding-left: 1.25rem; margin: 0.5rem 0; }
|
||||
.prose li, .prose-sm li { margin: 0.25rem 0; }
|
||||
.prose ul, .prose ol, .prose-sm ul, .prose-sm ol {
|
||||
padding-left: 1.5rem;
|
||||
margin: 0.75rem 0;
|
||||
display: block;
|
||||
list-style-position: outside;
|
||||
}
|
||||
.prose ul, .prose-sm ul {
|
||||
list-style-type: disc;
|
||||
}
|
||||
.prose ol, .prose-sm ol {
|
||||
list-style-type: decimal;
|
||||
}
|
||||
.prose li, .prose-sm li {
|
||||
margin: 0.25rem 0;
|
||||
display: list-item;
|
||||
}
|
||||
.prose ul ul, .prose ol ol, .prose ul ol, .prose ol ul,
|
||||
.prose-sm ul ul, .prose-sm ol ol, .prose-sm ul ol, .prose-sm ol ul {
|
||||
margin-top: 0.25rem;
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
.prose code, .prose-sm code {
|
||||
background: #F7F9FB;
|
||||
color: #1F2937;
|
||||
|
||||
@@ -10,6 +10,9 @@ class EnhancedErrorHandler {
|
||||
this.isOnline = navigator.onLine;
|
||||
this.retryAttempts = new Map();
|
||||
this.maxRetries = 3;
|
||||
// Track recent errors to prevent duplicates
|
||||
this.recentErrors = new Map(); // message -> timestamp
|
||||
this.errorDeduplicationWindow = 60000; // 1 minute - don't show same error twice within this window
|
||||
this.init();
|
||||
}
|
||||
|
||||
@@ -427,12 +430,42 @@ class EnhancedErrorHandler {
|
||||
}
|
||||
|
||||
showError(message, title = 'Error') {
|
||||
// Check for duplicates before showing
|
||||
if (this.isDuplicateError(message)) {
|
||||
console.warn('Duplicate error suppressed:', message);
|
||||
return;
|
||||
}
|
||||
|
||||
if (window.toastManager) {
|
||||
window.toastManager.error(message, title);
|
||||
} else {
|
||||
console.error(title + ':', message);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if an error message was recently shown (deduplication)
|
||||
*/
|
||||
isDuplicateError(message) {
|
||||
const now = Date.now();
|
||||
const lastShown = this.recentErrors.get(message);
|
||||
|
||||
if (lastShown && (now - lastShown) < this.errorDeduplicationWindow) {
|
||||
return true; // This error was shown recently
|
||||
}
|
||||
|
||||
// Update the timestamp for this error
|
||||
this.recentErrors.set(message, now);
|
||||
|
||||
// Clean up old entries (older than deduplication window)
|
||||
for (const [msg, timestamp] of this.recentErrors.entries()) {
|
||||
if (now - timestamp >= this.errorDeduplicationWindow) {
|
||||
this.recentErrors.delete(msg);
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Recovery Options
|
||||
@@ -593,6 +626,18 @@ class EnhancedErrorHandler {
|
||||
|
||||
const userFriendlyMessage = 'An unexpected error occurred. Please refresh the page or contact support if the problem persists.';
|
||||
|
||||
// Check if we've shown this error recently
|
||||
if (this.isDuplicateError(userFriendlyMessage)) {
|
||||
// Log to console but don't show duplicate toast
|
||||
console.error('JavaScript Error (duplicate suppressed):', {
|
||||
error,
|
||||
message,
|
||||
filename,
|
||||
lineno
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
this.showError(userFriendlyMessage, 'Application Error');
|
||||
|
||||
// Log to console for debugging
|
||||
@@ -611,6 +656,13 @@ class EnhancedErrorHandler {
|
||||
|
||||
const userFriendlyMessage = 'An operation failed unexpectedly. Please try again or contact support if the problem persists.';
|
||||
|
||||
// Check if we've shown this error recently
|
||||
if (this.isDuplicateError(userFriendlyMessage)) {
|
||||
// Log to console but don't show duplicate toast
|
||||
console.error('Unhandled Rejection (duplicate suppressed):', reason);
|
||||
return;
|
||||
}
|
||||
|
||||
this.showError(userFriendlyMessage, 'Operation Failed');
|
||||
|
||||
// Log to console for debugging
|
||||
|
||||
+20
-2
@@ -958,14 +958,32 @@
|
||||
|
||||
// Save to database if user is logged in
|
||||
{% if current_user.is_authenticated %}
|
||||
fetch('/api/preferences', {
|
||||
fetch('/api/theme', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRFToken': '{{ csrf_token() }}'
|
||||
},
|
||||
body: JSON.stringify({ theme: newTheme })
|
||||
}).catch(err => console.error('Failed to save theme preference:', err));
|
||||
})
|
||||
.then(response => {
|
||||
if (!response.ok) {
|
||||
return response.json().then(data => {
|
||||
throw new Error(data.error || 'Failed to save theme preference');
|
||||
});
|
||||
}
|
||||
return response.json();
|
||||
})
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
console.log('Theme preference saved:', data.theme);
|
||||
}
|
||||
})
|
||||
.catch(err => {
|
||||
console.error('Failed to save theme preference:', err);
|
||||
// Don't show error toast for theme changes - it's not critical
|
||||
// The theme change already worked locally, just didn't persist
|
||||
});
|
||||
{% endif %}
|
||||
});
|
||||
|
||||
|
||||
+222
-193
@@ -18,52 +18,53 @@ def check_overdue_invoices():
|
||||
This task should be run daily to check for invoices that are past their due date
|
||||
and send notifications to users who have overdue invoice notifications enabled.
|
||||
"""
|
||||
try:
|
||||
logger.info("Checking for overdue invoices...")
|
||||
|
||||
# Get all invoices that are overdue and not paid/cancelled
|
||||
today = datetime.utcnow().date()
|
||||
overdue_invoices = Invoice.query.filter(
|
||||
Invoice.due_date < today,
|
||||
Invoice.status.in_(['draft', 'sent'])
|
||||
).all()
|
||||
|
||||
logger.info(f"Found {len(overdue_invoices)} overdue invoices")
|
||||
|
||||
notifications_sent = 0
|
||||
for invoice in overdue_invoices:
|
||||
# Update invoice status to overdue if it's not already
|
||||
if invoice.status != 'overdue':
|
||||
invoice.status = 'overdue'
|
||||
db.session.commit()
|
||||
with current_app.app_context():
|
||||
try:
|
||||
logger.info("Checking for overdue invoices...")
|
||||
|
||||
# Get users to notify (creator and admins)
|
||||
users_to_notify = set()
|
||||
# Get all invoices that are overdue and not paid/cancelled
|
||||
today = datetime.utcnow().date()
|
||||
overdue_invoices = Invoice.query.filter(
|
||||
Invoice.due_date < today,
|
||||
Invoice.status.in_(['draft', 'sent'])
|
||||
).all()
|
||||
|
||||
# Add the invoice creator
|
||||
if invoice.creator:
|
||||
users_to_notify.add(invoice.creator)
|
||||
logger.info(f"Found {len(overdue_invoices)} overdue invoices")
|
||||
|
||||
# Add all admins
|
||||
admins = User.query.filter_by(role='admin', is_active=True).all()
|
||||
users_to_notify.update(admins)
|
||||
notifications_sent = 0
|
||||
for invoice in overdue_invoices:
|
||||
# Update invoice status to overdue if it's not already
|
||||
if invoice.status != 'overdue':
|
||||
invoice.status = 'overdue'
|
||||
db.session.commit()
|
||||
|
||||
# Get users to notify (creator and admins)
|
||||
users_to_notify = set()
|
||||
|
||||
# Add the invoice creator
|
||||
if invoice.creator:
|
||||
users_to_notify.add(invoice.creator)
|
||||
|
||||
# Add all admins
|
||||
admins = User.query.filter_by(role='admin', is_active=True).all()
|
||||
users_to_notify.update(admins)
|
||||
|
||||
# Send notifications
|
||||
for user in users_to_notify:
|
||||
if user.email and user.email_notifications and user.notification_overdue_invoices:
|
||||
try:
|
||||
send_overdue_invoice_notification(invoice, user)
|
||||
notifications_sent += 1
|
||||
logger.info(f"Sent overdue notification for invoice {invoice.invoice_number} to {user.username}")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to send notification to {user.username}: {e}")
|
||||
|
||||
# Send notifications
|
||||
for user in users_to_notify:
|
||||
if user.email and user.email_notifications and user.notification_overdue_invoices:
|
||||
try:
|
||||
send_overdue_invoice_notification(invoice, user)
|
||||
notifications_sent += 1
|
||||
logger.info(f"Sent overdue notification for invoice {invoice.invoice_number} to {user.username}")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to send notification to {user.username}: {e}")
|
||||
logger.info(f"Sent {notifications_sent} overdue invoice notifications")
|
||||
return notifications_sent
|
||||
|
||||
logger.info(f"Sent {notifications_sent} overdue invoice notifications")
|
||||
return notifications_sent
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error checking overdue invoices: {e}")
|
||||
return 0
|
||||
except Exception as e:
|
||||
logger.error(f"Error checking overdue invoices: {e}")
|
||||
return 0
|
||||
|
||||
|
||||
def send_weekly_summaries():
|
||||
@@ -72,75 +73,76 @@ def send_weekly_summaries():
|
||||
This task should be run weekly (e.g., Sunday evening or Monday morning)
|
||||
to send time tracking summaries to users who have opted in.
|
||||
"""
|
||||
try:
|
||||
logger.info("Sending weekly summaries...")
|
||||
|
||||
# Get users who want weekly summaries
|
||||
users = User.query.filter_by(
|
||||
is_active=True,
|
||||
email_notifications=True,
|
||||
notification_weekly_summary=True
|
||||
).all()
|
||||
|
||||
logger.info(f"Found {len(users)} users with weekly summaries enabled")
|
||||
|
||||
# Calculate date range (last 7 days)
|
||||
end_date = datetime.utcnow().date()
|
||||
start_date = end_date - timedelta(days=7)
|
||||
|
||||
summaries_sent = 0
|
||||
for user in users:
|
||||
if not user.email:
|
||||
continue
|
||||
with current_app.app_context():
|
||||
try:
|
||||
logger.info("Sending weekly summaries...")
|
||||
|
||||
try:
|
||||
# Get time entries for this user in the past week
|
||||
entries = TimeEntry.query.filter(
|
||||
TimeEntry.user_id == user.id,
|
||||
TimeEntry.start_time >= datetime.combine(start_date, datetime.min.time()),
|
||||
TimeEntry.start_time < datetime.combine(end_date + timedelta(days=1), datetime.min.time()),
|
||||
TimeEntry.end_time.isnot(None)
|
||||
).all()
|
||||
|
||||
if not entries:
|
||||
logger.info(f"No entries for {user.username}, skipping")
|
||||
# Get users who want weekly summaries
|
||||
users = User.query.filter_by(
|
||||
is_active=True,
|
||||
email_notifications=True,
|
||||
notification_weekly_summary=True
|
||||
).all()
|
||||
|
||||
logger.info(f"Found {len(users)} users with weekly summaries enabled")
|
||||
|
||||
# Calculate date range (last 7 days)
|
||||
end_date = datetime.utcnow().date()
|
||||
start_date = end_date - timedelta(days=7)
|
||||
|
||||
summaries_sent = 0
|
||||
for user in users:
|
||||
if not user.email:
|
||||
continue
|
||||
|
||||
# Calculate hours worked
|
||||
hours_worked = sum(e.duration_hours for e in entries)
|
||||
try:
|
||||
# Get time entries for this user in the past week
|
||||
entries = TimeEntry.query.filter(
|
||||
TimeEntry.user_id == user.id,
|
||||
TimeEntry.start_time >= datetime.combine(start_date, datetime.min.time()),
|
||||
TimeEntry.start_time < datetime.combine(end_date + timedelta(days=1), datetime.min.time()),
|
||||
TimeEntry.end_time.isnot(None)
|
||||
).all()
|
||||
|
||||
if not entries:
|
||||
logger.info(f"No entries for {user.username}, skipping")
|
||||
continue
|
||||
|
||||
# Calculate hours worked
|
||||
hours_worked = sum(e.duration_hours for e in entries)
|
||||
|
||||
# Group by project
|
||||
projects_map = {}
|
||||
for entry in entries:
|
||||
if entry.project:
|
||||
project_name = entry.project.name
|
||||
if project_name not in projects_map:
|
||||
projects_map[project_name] = {'name': project_name, 'hours': 0}
|
||||
projects_map[project_name]['hours'] += entry.duration_hours
|
||||
|
||||
projects_data = sorted(projects_map.values(), key=lambda x: x['hours'], reverse=True)
|
||||
|
||||
# Send email
|
||||
send_weekly_summary(
|
||||
user=user,
|
||||
start_date=start_date.strftime('%Y-%m-%d'),
|
||||
end_date=end_date.strftime('%Y-%m-%d'),
|
||||
hours_worked=hours_worked,
|
||||
projects_data=projects_data
|
||||
)
|
||||
|
||||
summaries_sent += 1
|
||||
logger.info(f"Sent weekly summary to {user.username}")
|
||||
|
||||
# Group by project
|
||||
projects_map = {}
|
||||
for entry in entries:
|
||||
if entry.project:
|
||||
project_name = entry.project.name
|
||||
if project_name not in projects_map:
|
||||
projects_map[project_name] = {'name': project_name, 'hours': 0}
|
||||
projects_map[project_name]['hours'] += entry.duration_hours
|
||||
|
||||
projects_data = sorted(projects_map.values(), key=lambda x: x['hours'], reverse=True)
|
||||
|
||||
# Send email
|
||||
send_weekly_summary(
|
||||
user=user,
|
||||
start_date=start_date.strftime('%Y-%m-%d'),
|
||||
end_date=end_date.strftime('%Y-%m-%d'),
|
||||
hours_worked=hours_worked,
|
||||
projects_data=projects_data
|
||||
)
|
||||
|
||||
summaries_sent += 1
|
||||
logger.info(f"Sent weekly summary to {user.username}")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to send weekly summary to {user.username}: {e}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to send weekly summary to {user.username}: {e}")
|
||||
logger.info(f"Sent {summaries_sent} weekly summaries")
|
||||
return summaries_sent
|
||||
|
||||
logger.info(f"Sent {summaries_sent} weekly summaries")
|
||||
return summaries_sent
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error sending weekly summaries: {e}")
|
||||
return 0
|
||||
except Exception as e:
|
||||
logger.error(f"Error sending weekly summaries: {e}")
|
||||
return 0
|
||||
|
||||
|
||||
def check_project_budget_alerts():
|
||||
@@ -149,44 +151,45 @@ def check_project_budget_alerts():
|
||||
This task should be run periodically (e.g., every 6 hours) to check
|
||||
project budgets and create alerts when thresholds are exceeded.
|
||||
"""
|
||||
try:
|
||||
logger.info("Checking project budget alerts...")
|
||||
|
||||
# Get all active projects with budgets
|
||||
projects = Project.query.filter(
|
||||
Project.budget_amount.isnot(None),
|
||||
Project.status == 'active'
|
||||
).all()
|
||||
|
||||
logger.info(f"Found {len(projects)} active projects with budgets")
|
||||
|
||||
total_alerts_created = 0
|
||||
for project in projects:
|
||||
try:
|
||||
# Check for budget alerts
|
||||
alerts_to_create = check_budget_alerts(project.id)
|
||||
|
||||
# Create alerts
|
||||
for alert_data in alerts_to_create:
|
||||
alert = BudgetAlert.create_alert(
|
||||
project_id=alert_data['project_id'],
|
||||
alert_type=alert_data['type'],
|
||||
budget_consumed_percent=alert_data['budget_consumed_percent'],
|
||||
budget_amount=alert_data['budget_amount'],
|
||||
consumed_amount=alert_data['consumed_amount']
|
||||
)
|
||||
total_alerts_created += 1
|
||||
logger.info(f"Created {alert_data['type']} alert for project {project.name}")
|
||||
with current_app.app_context():
|
||||
try:
|
||||
logger.info("Checking project budget alerts...")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error checking budget alerts for project {project.id}: {e}")
|
||||
# Get all active projects with budgets
|
||||
projects = Project.query.filter(
|
||||
Project.budget_amount.isnot(None),
|
||||
Project.status == 'active'
|
||||
).all()
|
||||
|
||||
logger.info(f"Found {len(projects)} active projects with budgets")
|
||||
|
||||
total_alerts_created = 0
|
||||
for project in projects:
|
||||
try:
|
||||
# Check for budget alerts
|
||||
alerts_to_create = check_budget_alerts(project.id)
|
||||
|
||||
# Create alerts
|
||||
for alert_data in alerts_to_create:
|
||||
alert = BudgetAlert.create_alert(
|
||||
project_id=alert_data['project_id'],
|
||||
alert_type=alert_data['type'],
|
||||
budget_consumed_percent=alert_data['budget_consumed_percent'],
|
||||
budget_amount=alert_data['budget_amount'],
|
||||
consumed_amount=alert_data['consumed_amount']
|
||||
)
|
||||
total_alerts_created += 1
|
||||
logger.info(f"Created {alert_data['type']} alert for project {project.name}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error checking budget alerts for project {project.id}: {e}")
|
||||
|
||||
logger.info(f"Created {total_alerts_created} budget alerts")
|
||||
return total_alerts_created
|
||||
|
||||
logger.info(f"Created {total_alerts_created} budget alerts")
|
||||
return total_alerts_created
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error checking project budget alerts: {e}")
|
||||
return 0
|
||||
except Exception as e:
|
||||
logger.error(f"Error checking project budget alerts: {e}")
|
||||
return 0
|
||||
|
||||
|
||||
def generate_recurring_invoices():
|
||||
@@ -194,66 +197,68 @@ def generate_recurring_invoices():
|
||||
|
||||
This task should be run daily to check for recurring invoices that need to be generated.
|
||||
"""
|
||||
try:
|
||||
logger.info("Generating recurring invoices...")
|
||||
|
||||
# Get all active recurring invoices that should generate today
|
||||
today = datetime.utcnow().date()
|
||||
recurring_invoices = RecurringInvoice.query.filter(
|
||||
RecurringInvoice.is_active == True,
|
||||
RecurringInvoice.next_run_date <= today
|
||||
).all()
|
||||
|
||||
logger.info(f"Found {len(recurring_invoices)} recurring invoices to process")
|
||||
|
||||
invoices_generated = 0
|
||||
emails_sent = 0
|
||||
|
||||
for recurring in recurring_invoices:
|
||||
try:
|
||||
# Check if we've reached the end date
|
||||
if recurring.end_date and today > recurring.end_date:
|
||||
logger.info(f"Recurring invoice {recurring.id} has reached end date, deactivating")
|
||||
recurring.is_active = False
|
||||
db.session.commit()
|
||||
continue
|
||||
|
||||
# Generate invoice
|
||||
invoice = recurring.generate_invoice()
|
||||
if invoice:
|
||||
db.session.commit()
|
||||
invoices_generated += 1
|
||||
logger.info(f"Generated invoice {invoice.invoice_number} from recurring template {recurring.name}")
|
||||
|
||||
# Auto-send if enabled
|
||||
if recurring.auto_send and invoice.client_email:
|
||||
try:
|
||||
from app.utils.email import send_invoice_email
|
||||
send_invoice_email(invoice, invoice.client_email, sender_user=recurring.creator)
|
||||
emails_sent += 1
|
||||
logger.info(f"Auto-sent invoice {invoice.invoice_number} to {invoice.client_email}")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to auto-send invoice {invoice.invoice_number}: {e}")
|
||||
else:
|
||||
logger.warning(f"Failed to generate invoice from recurring template {recurring.id}")
|
||||
with current_app.app_context():
|
||||
try:
|
||||
logger.info("Generating recurring invoices...")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error processing recurring invoice {recurring.id}: {e}")
|
||||
db.session.rollback()
|
||||
# Get all active recurring invoices that should generate today
|
||||
today = datetime.utcnow().date()
|
||||
recurring_invoices = RecurringInvoice.query.filter(
|
||||
RecurringInvoice.is_active == True,
|
||||
RecurringInvoice.next_run_date <= today
|
||||
).all()
|
||||
|
||||
logger.info(f"Found {len(recurring_invoices)} recurring invoices to process")
|
||||
|
||||
invoices_generated = 0
|
||||
emails_sent = 0
|
||||
|
||||
for recurring in recurring_invoices:
|
||||
try:
|
||||
# Check if we've reached the end date
|
||||
if recurring.end_date and today > recurring.end_date:
|
||||
logger.info(f"Recurring invoice {recurring.id} has reached end date, deactivating")
|
||||
recurring.is_active = False
|
||||
db.session.commit()
|
||||
continue
|
||||
|
||||
# Generate invoice
|
||||
invoice = recurring.generate_invoice()
|
||||
if invoice:
|
||||
db.session.commit()
|
||||
invoices_generated += 1
|
||||
logger.info(f"Generated invoice {invoice.invoice_number} from recurring template {recurring.name}")
|
||||
|
||||
# Auto-send if enabled
|
||||
if recurring.auto_send and invoice.client_email:
|
||||
try:
|
||||
from app.utils.email import send_invoice_email
|
||||
send_invoice_email(invoice, invoice.client_email, sender_user=recurring.creator)
|
||||
emails_sent += 1
|
||||
logger.info(f"Auto-sent invoice {invoice.invoice_number} to {invoice.client_email}")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to auto-send invoice {invoice.invoice_number}: {e}")
|
||||
else:
|
||||
logger.warning(f"Failed to generate invoice from recurring template {recurring.id}")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error processing recurring invoice {recurring.id}: {e}")
|
||||
db.session.rollback()
|
||||
|
||||
logger.info(f"Generated {invoices_generated} invoices, sent {emails_sent} emails")
|
||||
return invoices_generated
|
||||
|
||||
logger.info(f"Generated {invoices_generated} invoices, sent {emails_sent} emails")
|
||||
return invoices_generated
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error generating recurring invoices: {e}")
|
||||
return 0
|
||||
except Exception as e:
|
||||
logger.error(f"Error generating recurring invoices: {e}")
|
||||
return 0
|
||||
|
||||
|
||||
def register_scheduled_tasks(scheduler):
|
||||
def register_scheduled_tasks(scheduler, app=None):
|
||||
"""Register all scheduled tasks with APScheduler
|
||||
|
||||
Args:
|
||||
scheduler: APScheduler instance
|
||||
app: Flask app instance (optional, will use current_app if not provided)
|
||||
"""
|
||||
try:
|
||||
# Check overdue invoices daily at 9 AM
|
||||
@@ -306,8 +311,29 @@ def register_scheduled_tasks(scheduler):
|
||||
logger.info("Registered recurring invoices generation task")
|
||||
|
||||
# Retry failed webhook deliveries every 5 minutes
|
||||
# Create a closure that captures the app instance
|
||||
if app is None:
|
||||
try:
|
||||
app = current_app._get_current_object()
|
||||
except RuntimeError:
|
||||
logger.warning("Could not get app instance for webhook retry task")
|
||||
app = None
|
||||
|
||||
def retry_failed_webhooks_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 webhook retry")
|
||||
return
|
||||
|
||||
with app_instance.app_context():
|
||||
retry_failed_webhooks()
|
||||
|
||||
scheduler.add_job(
|
||||
func=retry_failed_webhooks,
|
||||
func=retry_failed_webhooks_with_app,
|
||||
trigger='cron',
|
||||
minute='*/5',
|
||||
id='retry_failed_webhooks',
|
||||
@@ -325,6 +351,9 @@ def retry_failed_webhooks():
|
||||
|
||||
This task should be run periodically to retry webhook deliveries
|
||||
that have failed and are scheduled for retry.
|
||||
|
||||
Note: This function should be called within an app context.
|
||||
Use retry_failed_webhooks_with_app() wrapper for scheduled tasks.
|
||||
"""
|
||||
try:
|
||||
from app.utils.webhook_service import WebhookService
|
||||
|
||||
@@ -75,9 +75,67 @@ def register_template_filters(app):
|
||||
|
||||
@app.template_filter('markdown')
|
||||
def markdown_filter(text):
|
||||
"""Render markdown to safe HTML using bleach sanitation."""
|
||||
"""Render markdown to safe HTML using bleach sanitation, preserving rich text styling."""
|
||||
if not text:
|
||||
return ""
|
||||
|
||||
# Check if text appears to be pure HTML (starts with < and looks like HTML document)
|
||||
# Only treat as HTML if it starts with a tag and doesn't look like markdown
|
||||
import re
|
||||
# More specific check: HTML should start with a tag and not be markdown list/bullet syntax
|
||||
is_html = (re.match(r'^\s*<[a-z]', text, re.IGNORECASE) and
|
||||
not re.match(r'^\s*[-*+]\s+', text) and # Not markdown list
|
||||
not re.match(r'^\s*\d+\.\s+', text)) # Not numbered list
|
||||
|
||||
if is_html:
|
||||
if bleach is None:
|
||||
try:
|
||||
from markupsafe import escape
|
||||
return escape(text)
|
||||
except Exception:
|
||||
return text
|
||||
# Allow style attributes for rich text preservation
|
||||
allowed_tags = bleach.sanitizer.ALLOWED_TAGS.union({
|
||||
'p', 'pre', 'code', 'img', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
|
||||
'table', 'thead', 'tbody', 'tr', 'th', 'td', 'hr', 'br', 'ul', 'ol', 'li',
|
||||
'strong', 'em', 'b', 'i', 'u', 's', 'strike', 'blockquote', 'a', 'div', 'span',
|
||||
'sub', 'sup', 'del', 'ins', 'mark', 'small', 'big'
|
||||
})
|
||||
# Build allowed_attrs with style support for common rich text elements
|
||||
allowed_attrs = {
|
||||
**bleach.sanitizer.ALLOWED_ATTRIBUTES,
|
||||
'a': ['href', 'title', 'rel', 'target', 'style'],
|
||||
'img': ['src', 'alt', 'title', 'style', 'width', 'height'],
|
||||
'p': ['style', 'class', 'id'],
|
||||
'div': ['style', 'class', 'id'],
|
||||
'span': ['style', 'class', 'id'],
|
||||
'h1': ['style', 'class', 'id'],
|
||||
'h2': ['style', 'class', 'id'],
|
||||
'h3': ['style', 'class', 'id'],
|
||||
'h4': ['style', 'class', 'id'],
|
||||
'h5': ['style', 'class', 'id'],
|
||||
'h6': ['style', 'class', 'id'],
|
||||
'strong': ['style', 'class', 'id'],
|
||||
'em': ['style', 'class', 'id'],
|
||||
'b': ['style', 'class', 'id'],
|
||||
'i': ['style', 'class', 'id'],
|
||||
'u': ['style', 'class', 'id'],
|
||||
's': ['style', 'class', 'id'],
|
||||
'strike': ['style', 'class', 'id'],
|
||||
'blockquote': ['style', 'class', 'id'],
|
||||
'ul': ['style', 'class', 'id', 'type'],
|
||||
'ol': ['style', 'class', 'id', 'type', 'start'],
|
||||
'li': ['style', 'class', 'id'],
|
||||
'table': ['style', 'class', 'id'],
|
||||
'thead': ['style', 'class', 'id'],
|
||||
'tbody': ['style', 'class', 'id'],
|
||||
'tr': ['style', 'class', 'id'],
|
||||
'th': ['style', 'class', 'id'],
|
||||
'td': ['style', 'class', 'id'],
|
||||
}
|
||||
return bleach.clean(text, tags=allowed_tags, attributes=allowed_attrs, strip=True)
|
||||
|
||||
# Process as markdown
|
||||
if _md is None:
|
||||
# Fallback: escape and basic nl2br
|
||||
try:
|
||||
@@ -86,14 +144,49 @@ def register_template_filters(app):
|
||||
return text
|
||||
return escape(text).replace('\n', '<br>')
|
||||
|
||||
html = _md.markdown(text, extensions=['extra', 'sane_lists', 'smarty'])
|
||||
# Convert markdown to HTML
|
||||
html = _md.markdown(text, extensions=['extra', 'sane_lists', 'smarty', 'codehilite'])
|
||||
if bleach is None:
|
||||
return html
|
||||
allowed_tags = bleach.sanitizer.ALLOWED_TAGS.union({'p','pre','code','img','h1','h2','h3','h4','h5','h6','table','thead','tbody','tr','th','td','hr','br','ul','ol','li','strong','em','blockquote','a'})
|
||||
|
||||
# Sanitize the HTML output from markdown
|
||||
allowed_tags = bleach.sanitizer.ALLOWED_TAGS.union({
|
||||
'p', 'pre', 'code', 'img', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
|
||||
'table', 'thead', 'tbody', 'tr', 'th', 'td', 'hr', 'br', 'ul', 'ol', 'li',
|
||||
'strong', 'em', 'b', 'i', 'u', 's', 'strike', 'blockquote', 'a', 'div', 'span',
|
||||
'sub', 'sup', 'del', 'ins', 'mark', 'small', 'big'
|
||||
})
|
||||
# Build allowed_attrs with style support for common rich text elements
|
||||
allowed_attrs = {
|
||||
**bleach.sanitizer.ALLOWED_ATTRIBUTES,
|
||||
'a': ['href', 'title', 'rel', 'target'],
|
||||
'img': ['src', 'alt', 'title'],
|
||||
'a': ['href', 'title', 'rel', 'target', 'style'],
|
||||
'img': ['src', 'alt', 'title', 'style', 'width', 'height'],
|
||||
'p': ['style', 'class', 'id'],
|
||||
'div': ['style', 'class', 'id'],
|
||||
'span': ['style', 'class', 'id'],
|
||||
'h1': ['style', 'class', 'id'],
|
||||
'h2': ['style', 'class', 'id'],
|
||||
'h3': ['style', 'class', 'id'],
|
||||
'h4': ['style', 'class', 'id'],
|
||||
'h5': ['style', 'class', 'id'],
|
||||
'h6': ['style', 'class', 'id'],
|
||||
'strong': ['style', 'class', 'id'],
|
||||
'em': ['style', 'class', 'id'],
|
||||
'b': ['style', 'class', 'id'],
|
||||
'i': ['style', 'class', 'id'],
|
||||
'u': ['style', 'class', 'id'],
|
||||
's': ['style', 'class', 'id'],
|
||||
'strike': ['style', 'class', 'id'],
|
||||
'blockquote': ['style', 'class', 'id'],
|
||||
'ul': ['style', 'class', 'id', 'type'],
|
||||
'ol': ['style', 'class', 'id', 'type', 'start'],
|
||||
'li': ['style', 'class', 'id'],
|
||||
'table': ['style', 'class', 'id'],
|
||||
'thead': ['style', 'class', 'id'],
|
||||
'tbody': ['style', 'class', 'id'],
|
||||
'tr': ['style', 'class', 'id'],
|
||||
'th': ['style', 'class', 'id'],
|
||||
'td': ['style', 'class', 'id'],
|
||||
}
|
||||
return bleach.clean(html, tags=allowed_tags, attributes=allowed_attrs, strip=True)
|
||||
|
||||
|
||||
+19
-8
@@ -30,19 +30,30 @@ def _get_authenticated_user(user=None):
|
||||
def get_app_timezone():
|
||||
"""Get the application's configured timezone from database settings or environment."""
|
||||
try:
|
||||
# Check if we have an application context before accessing database
|
||||
from flask import has_app_context
|
||||
if not has_app_context():
|
||||
# No app context, skip database lookup
|
||||
return os.getenv('TZ', 'Europe/Rome')
|
||||
|
||||
# Try to get timezone from database settings first
|
||||
from app.models import Settings
|
||||
from app import db
|
||||
|
||||
# Check if we have a database connection
|
||||
if db.session.is_active and not getattr(db.session, "_flushing", False):
|
||||
try:
|
||||
settings = Settings.get_settings()
|
||||
if settings and settings.timezone:
|
||||
return settings.timezone
|
||||
except Exception as e:
|
||||
# Log the error but continue with fallback
|
||||
print(f"Warning: Could not get timezone from database: {e}")
|
||||
try:
|
||||
if db.session.is_active and not getattr(db.session, "_flushing", False):
|
||||
try:
|
||||
settings = Settings.get_settings()
|
||||
if settings and settings.timezone:
|
||||
return settings.timezone
|
||||
except Exception as e:
|
||||
# Log the error but continue with fallback
|
||||
print(f"Warning: Could not get timezone from database: {e}")
|
||||
except RuntimeError as e:
|
||||
# RuntimeError typically means "Working outside of application context"
|
||||
# Fall back to environment variable
|
||||
pass
|
||||
except Exception as e:
|
||||
# If database is not available or settings don't exist, fall back to environment
|
||||
print(f"Warning: Database not available for timezone: {e}")
|
||||
|
||||
@@ -7,7 +7,7 @@ from setuptools import setup, find_packages
|
||||
|
||||
setup(
|
||||
name='timetracker',
|
||||
version='3.10.2',
|
||||
version='3.10.3',
|
||||
packages=find_packages(),
|
||||
include_package_data=True,
|
||||
install_requires=[
|
||||
|
||||
@@ -2482,8 +2482,23 @@ function initializePDFEditor() {
|
||||
fd.append('css', css);
|
||||
fd.append('csrf_token', CSRF_TOKEN);
|
||||
|
||||
fetch(PREVIEW_URL, { method: 'POST', body: fd })
|
||||
.then(r => r.text())
|
||||
// Add cache-busting parameter to ensure fresh preview
|
||||
const previewUrl = PREVIEW_URL + '?t=' + Date.now();
|
||||
|
||||
fetch(previewUrl, {
|
||||
method: 'POST',
|
||||
body: fd,
|
||||
cache: 'no-cache',
|
||||
headers: {
|
||||
'Cache-Control': 'no-cache'
|
||||
}
|
||||
})
|
||||
.then(r => {
|
||||
if (!r.ok) {
|
||||
throw new Error(`HTTP error! status: ${r.status}`);
|
||||
}
|
||||
return r.text();
|
||||
})
|
||||
.then(html => {
|
||||
const doc = frame.contentDocument || frame.contentWindow.document;
|
||||
doc.open();
|
||||
@@ -2494,6 +2509,7 @@ function initializePDFEditor() {
|
||||
})
|
||||
.catch(err => {
|
||||
loading.classList.remove('active');
|
||||
console.error('Preview error:', err);
|
||||
alert('Preview error: ' + err.message);
|
||||
closePreviewModal();
|
||||
});
|
||||
@@ -2519,8 +2535,10 @@ function initializePDFEditor() {
|
||||
const fontStyleCss = fontStyle === 'italic' ? 'italic' : 'normal';
|
||||
const color = attrs.fill || 'black';
|
||||
const text = (attrs.text || '').replace(/</g, '<').replace(/>/g, '>');
|
||||
// Preserve text alignment (Konva Text uses 'align' attribute: 'left', 'center', 'right')
|
||||
const textAlign = attrs.align || 'left';
|
||||
|
||||
bodyContent += ` <div class="element text-element" style="position:absolute;left:${x}px;top:${y}px;font-size:${fontSize}px;font-family:'${fontFamily}';font-weight:${fontWeight};font-style:${fontStyleCss};color:${color};opacity:${opacity};width:${attrs.width || 400}px">${text}</div>\n`;
|
||||
bodyContent += ` <div class="element text-element" style="position:absolute;left:${x}px;top:${y}px;font-size:${fontSize}px;font-family:'${fontFamily}';font-weight:${fontWeight};font-style:${fontStyleCss};color:${color};opacity:${opacity};width:${attrs.width || 400}px;text-align:${textAlign}">${text}</div>\n`;
|
||||
} else if (child.className === 'Image') {
|
||||
const w = Math.round(attrs.width || 100);
|
||||
const h = Math.round(attrs.height || 50);
|
||||
@@ -2577,16 +2595,32 @@ function initializePDFEditor() {
|
||||
}
|
||||
|
||||
if (isItemsTable) {
|
||||
// Extract actual header text from the table group's first Text child
|
||||
const children = child.getChildren ? child.getChildren() : (child.children || []);
|
||||
const textElements = children.filter(c => c.className === 'Text');
|
||||
const headerText = textElements[0] ? (textElements[0].attrs.text || '') : '';
|
||||
|
||||
// Parse header text (format: "Description | Qty | Price | Total" or German equivalent)
|
||||
// Default to English if header text is empty or doesn't contain |
|
||||
let headerParts = ['Description', 'Qty', 'Unit Price', 'Total'];
|
||||
if (headerText && headerText.includes('|')) {
|
||||
headerParts = headerText.split('|').map(part => part.trim()).filter(part => part.length > 0);
|
||||
// Ensure we have at least 4 parts, pad with defaults if needed
|
||||
while (headerParts.length < 4) {
|
||||
headerParts.push(['Description', 'Qty', 'Unit Price', 'Total'][headerParts.length]);
|
||||
}
|
||||
}
|
||||
|
||||
// Generate proper HTML table for invoice items
|
||||
bodyContent += ` <!-- Items Table Start -->\n`;
|
||||
bodyContent += ` <div style="position:absolute;left:${x}px;top:${y}px;opacity:${opacity};width:515px;">\n`;
|
||||
bodyContent += ` <table style="width:100%;border-collapse:collapse;font-size:11px;background:white;">\n`;
|
||||
bodyContent += ` <thead>\n`;
|
||||
bodyContent += ` <tr style="background-color:#f8f9fa;border-bottom:2px solid #333;">\n`;
|
||||
bodyContent += ` <th style="text-align:left;padding:10px;font-weight:bold;font-size:12px;">Description</th>\n`;
|
||||
bodyContent += ` <th style="text-align:center;padding:10px;font-weight:bold;font-size:12px;width:70px;">Qty</th>\n`;
|
||||
bodyContent += ` <th style="text-align:right;padding:10px;font-weight:bold;font-size:12px;width:110px;">Unit Price</th>\n`;
|
||||
bodyContent += ` <th style="text-align:right;padding:10px;font-weight:bold;font-size:12px;width:110px;">Total</th>\n`;
|
||||
bodyContent += ` <th style="text-align:left;padding:10px;font-weight:bold;font-size:12px;">${(headerParts[0] || 'Description').replace(/</g, '<').replace(/>/g, '>')}</th>\n`;
|
||||
bodyContent += ` <th style="text-align:center;padding:10px;font-weight:bold;font-size:12px;width:70px;">${(headerParts[1] || 'Qty').replace(/</g, '<').replace(/>/g, '>')}</th>\n`;
|
||||
bodyContent += ` <th style="text-align:right;padding:10px;font-weight:bold;font-size:12px;width:110px;">${(headerParts[2] || 'Unit Price').replace(/</g, '<').replace(/>/g, '>')}</th>\n`;
|
||||
bodyContent += ` <th style="text-align:right;padding:10px;font-weight:bold;font-size:12px;width:110px;">${(headerParts[3] || 'Total').replace(/</g, '<').replace(/>/g, '>')}</th>\n`;
|
||||
bodyContent += ` </tr>\n`;
|
||||
bodyContent += ` </thead>\n`;
|
||||
bodyContent += ` <tbody>\n`;
|
||||
@@ -2611,16 +2645,32 @@ function initializePDFEditor() {
|
||||
bodyContent += ` </div>\n`;
|
||||
bodyContent += ` <!-- Items Table End -->\n`;
|
||||
} else if (isExpensesTable) {
|
||||
// Extract actual header text from the table group's first Text child
|
||||
const children = child.getChildren ? child.getChildren() : (child.children || []);
|
||||
const textElements = children.filter(c => c.className === 'Text');
|
||||
const headerText = textElements[0] ? (textElements[0].attrs.text || '') : '';
|
||||
|
||||
// Parse header text (format: "Expense | Date | Category | Amount" or localized)
|
||||
// Default to English if header text is empty or doesn't contain |
|
||||
let headerParts = ['Expense', 'Date', 'Category', 'Amount'];
|
||||
if (headerText && headerText.includes('|')) {
|
||||
headerParts = headerText.split('|').map(part => part.trim()).filter(part => part.length > 0);
|
||||
// Ensure we have at least 4 parts, pad with defaults if needed
|
||||
while (headerParts.length < 4) {
|
||||
headerParts.push(['Expense', 'Date', 'Category', 'Amount'][headerParts.length]);
|
||||
}
|
||||
}
|
||||
|
||||
// Generate proper HTML table for project expenses
|
||||
bodyContent += ` <!-- Expenses Table Start -->\n`;
|
||||
bodyContent += ` <div style="position:absolute;left:${x}px;top:${y}px;opacity:${opacity};width:515px;">\n`;
|
||||
bodyContent += ` <table style="width:100%;border-collapse:collapse;font-size:11px;background:#fffbf0;">\n`;
|
||||
bodyContent += ` <thead>\n`;
|
||||
bodyContent += ` <tr style="background-color:#fff3cd;border-bottom:2px solid #856404;">\n`;
|
||||
bodyContent += ` <th style="text-align:left;padding:10px;font-weight:bold;font-size:12px;color:#856404;">Expense</th>\n`;
|
||||
bodyContent += ` <th style="text-align:center;padding:10px;font-weight:bold;font-size:12px;width:100px;color:#856404;">Date</th>\n`;
|
||||
bodyContent += ` <th style="text-align:left;padding:10px;font-weight:bold;font-size:12px;width:110px;color:#856404;">Category</th>\n`;
|
||||
bodyContent += ` <th style="text-align:right;padding:10px;font-weight:bold;font-size:12px;width:110px;color:#856404;">Amount</th>\n`;
|
||||
bodyContent += ` <th style="text-align:left;padding:10px;font-weight:bold;font-size:12px;color:#856404;">${(headerParts[0] || 'Expense').replace(/</g, '<').replace(/>/g, '>')}</th>\n`;
|
||||
bodyContent += ` <th style="text-align:center;padding:10px;font-weight:bold;font-size:12px;width:100px;color:#856404;">${(headerParts[1] || 'Date').replace(/</g, '<').replace(/>/g, '>')}</th>\n`;
|
||||
bodyContent += ` <th style="text-align:left;padding:10px;font-weight:bold;font-size:12px;width:110px;color:#856404;">${(headerParts[2] || 'Category').replace(/</g, '<').replace(/>/g, '>')}</th>\n`;
|
||||
bodyContent += ` <th style="text-align:right;padding:10px;font-weight:bold;font-size:12px;width:110px;color:#856404;">${(headerParts[3] || 'Amount').replace(/</g, '<').replace(/>/g, '>')}</th>\n`;
|
||||
bodyContent += ` </tr>\n`;
|
||||
bodyContent += ` </thead>\n`;
|
||||
bodyContent += ` <tbody>\n`;
|
||||
@@ -2650,7 +2700,9 @@ function initializePDFEditor() {
|
||||
child.children.forEach(c => {
|
||||
if (c.className === 'Text') {
|
||||
const text = (c.attrs.text || '').replace(/</g, '<').replace(/>/g, '>');
|
||||
bodyContent += ` <div style="font-size:${c.attrs.fontSize || 12}px;font-weight:${c.attrs.fontStyle === 'bold' ? 'bold' : 'normal'}">${text}</div>\n`;
|
||||
// Preserve text alignment for text in groups
|
||||
const textAlign = c.attrs.align || 'left';
|
||||
bodyContent += ` <div style="font-size:${c.attrs.fontSize || 12}px;font-weight:${c.attrs.fontStyle === 'bold' ? 'bold' : 'normal'};text-align:${textAlign}">${text}</div>\n`;
|
||||
} else if (c.className === 'Line') {
|
||||
bodyContent += ` <hr style="border-top:${c.attrs.strokeWidth || 1}px solid ${c.attrs.stroke || 'black'};margin:${c.attrs.y || 0}px 0">\n`;
|
||||
}
|
||||
@@ -2660,78 +2712,17 @@ function initializePDFEditor() {
|
||||
}
|
||||
});
|
||||
|
||||
// Get dimensions for current page size
|
||||
const currentSizeHtml = CURRENT_PAGE_SIZE || 'A4';
|
||||
const dimensionsHtml = PAGE_SIZE_DIMENSIONS[currentSizeHtml] || PAGE_SIZE_DIMENSIONS['A4'];
|
||||
const widthPxHtml = dimensionsHtml.width;
|
||||
const heightPxHtml = dimensionsHtml.height;
|
||||
|
||||
// Wrap in complete HTML document for proper PDF rendering
|
||||
const html = `<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<title>Invoice</title>
|
||||
<style>
|
||||
@page {
|
||||
size: ${currentSizeHtml};
|
||||
margin: 0;
|
||||
}
|
||||
body {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
font-family: Arial, sans-serif;
|
||||
}
|
||||
.invoice-wrapper {
|
||||
position: relative;
|
||||
width: ${widthPxHtml}px;
|
||||
min-height: ${heightPxHtml}px;
|
||||
background: white;
|
||||
padding: 20px;
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.element, .text-element {
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
.rectangle-element, .circle-element {
|
||||
box-sizing: border-box;
|
||||
}
|
||||
.line-element {
|
||||
padding: 0;
|
||||
}
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin: 10px 0;
|
||||
}
|
||||
table th {
|
||||
background-color: #f8f9fa;
|
||||
font-weight: bold;
|
||||
text-align: left;
|
||||
padding: 10px;
|
||||
border-bottom: 2px solid #333;
|
||||
}
|
||||
table td {
|
||||
padding: 10px;
|
||||
border-bottom: 1px solid #ddd;
|
||||
}
|
||||
table tr:last-child td {
|
||||
border-bottom: 2px solid #333;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="invoice-wrapper">
|
||||
${bodyContent}</div>
|
||||
</body>
|
||||
</html>`;
|
||||
|
||||
// Get dimensions for current page size
|
||||
const currentSize = CURRENT_PAGE_SIZE || 'A4';
|
||||
const dimensions = PAGE_SIZE_DIMENSIONS[currentSize] || PAGE_SIZE_DIMENSIONS['A4'];
|
||||
const widthPx = dimensions.width;
|
||||
const heightPx = dimensions.height;
|
||||
|
||||
// For preview, return just the body content (template HTML) wrapped in invoice-wrapper
|
||||
// The preview endpoint will wrap it with its own HTML structure
|
||||
const html = `<div class="invoice-wrapper">
|
||||
${bodyContent}</div>`;
|
||||
|
||||
const css = `@page {
|
||||
size: ${currentSize};
|
||||
margin: 0;
|
||||
|
||||
Reference in New Issue
Block a user