mirror of
https://github.com/DRYTRIX/TimeTracker.git
synced 2026-01-18 10:10:06 -06:00
fix: Fix email template editor initialization and JavaScript errors
- Fix script block name from extra_js to scripts_extra to match base.html - Replace inline onclick handlers with event listeners to fix scope issues - Fix ReferenceError for toggleViewMode and insertVariable functions - Improve editor initialization flow with proper script loading detection - Add error handling and fallback to textarea if Toast UI Editor fails to load - Add debug logging for troubleshooting initialization issues - Ensure default templates are editable (no restrictions in backend) - Add email templates link to admin menu in base.html - Remove ENV file configuration details from email support page The editor now properly initializes and all interactive features work correctly.
This commit is contained in:
@@ -1,5 +1,11 @@
|
||||
from datetime import datetime
|
||||
from app import db
|
||||
from app.utils.timezone import now_in_app_timezone
|
||||
|
||||
|
||||
def local_now():
|
||||
"""Get current time in local timezone as naive datetime (for database storage)"""
|
||||
return now_in_app_timezone().replace(tzinfo=None)
|
||||
|
||||
|
||||
class InvoiceEmail(db.Model):
|
||||
@@ -13,7 +19,7 @@ class InvoiceEmail(db.Model):
|
||||
# Email details
|
||||
recipient_email = db.Column(db.String(200), nullable=False)
|
||||
subject = db.Column(db.String(500), nullable=False)
|
||||
sent_at = db.Column(db.DateTime, nullable=False, default=datetime.utcnow)
|
||||
sent_at = db.Column(db.DateTime, nullable=False, default=local_now)
|
||||
sent_by = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
|
||||
|
||||
# Tracking
|
||||
@@ -31,8 +37,8 @@ class InvoiceEmail(db.Model):
|
||||
error_message = db.Column(db.Text, nullable=True) # Error message if send failed
|
||||
|
||||
# Metadata
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
|
||||
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
|
||||
created_at = db.Column(db.DateTime, default=local_now, nullable=False)
|
||||
updated_at = db.Column(db.DateTime, default=local_now, onupdate=local_now, nullable=False)
|
||||
|
||||
# Relationships
|
||||
invoice = db.relationship('Invoice', backref='email_records')
|
||||
@@ -52,8 +58,8 @@ class InvoiceEmail(db.Model):
|
||||
def mark_opened(self):
|
||||
"""Mark email as opened"""
|
||||
if not self.opened_at:
|
||||
self.opened_at = datetime.utcnow()
|
||||
self.last_opened_at = datetime.utcnow()
|
||||
self.opened_at = local_now()
|
||||
self.last_opened_at = local_now()
|
||||
self.opened_count += 1
|
||||
if self.status == 'sent':
|
||||
self.status = 'opened'
|
||||
@@ -61,7 +67,7 @@ class InvoiceEmail(db.Model):
|
||||
def mark_paid(self):
|
||||
"""Mark invoice as paid (after email was sent)"""
|
||||
if not self.paid_at:
|
||||
self.paid_at = datetime.utcnow()
|
||||
self.paid_at = local_now()
|
||||
self.status = 'paid'
|
||||
|
||||
def mark_failed(self, error_message):
|
||||
|
||||
@@ -214,7 +214,13 @@ def view_invoice(invoice_id):
|
||||
# Get email templates for selection
|
||||
email_templates = InvoiceTemplate.query.order_by(InvoiceTemplate.name).all()
|
||||
|
||||
return render_template('invoices/view.html', invoice=invoice, email_templates=email_templates)
|
||||
# Get email history
|
||||
from app.models import InvoiceEmail
|
||||
email_history = InvoiceEmail.query.filter_by(invoice_id=invoice_id)\
|
||||
.order_by(InvoiceEmail.sent_at.desc())\
|
||||
.all()
|
||||
|
||||
return render_template('invoices/view.html', invoice=invoice, email_templates=email_templates, email_history=email_history)
|
||||
|
||||
@invoices_bp.route('/invoices/<int:invoice_id>/edit', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
@@ -989,3 +995,85 @@ def send_invoice_email_route(invoice_id):
|
||||
logger.error(f"Error sending invoice email: {type(e).__name__}: {str(e)}")
|
||||
logger.exception("Full error traceback:")
|
||||
return jsonify({'error': f'Failed to send email: {str(e)}'}), 500
|
||||
|
||||
|
||||
@invoices_bp.route('/invoices/<int:invoice_id>/email-history', methods=['GET'])
|
||||
@login_required
|
||||
def get_invoice_email_history(invoice_id):
|
||||
"""Get email history for an invoice"""
|
||||
invoice = Invoice.query.get_or_404(invoice_id)
|
||||
|
||||
# Check access permissions
|
||||
if not current_user.is_admin and invoice.created_by != current_user.id:
|
||||
return jsonify({'error': 'Permission denied'}), 403
|
||||
|
||||
from app.models import InvoiceEmail
|
||||
|
||||
# Get all email records for this invoice, ordered by most recent first
|
||||
email_records = InvoiceEmail.query.filter_by(invoice_id=invoice_id)\
|
||||
.order_by(InvoiceEmail.sent_at.desc())\
|
||||
.all()
|
||||
|
||||
# Convert to list of dictionaries
|
||||
email_history = [email.to_dict() for email in email_records]
|
||||
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'email_history': email_history,
|
||||
'count': len(email_history)
|
||||
})
|
||||
|
||||
|
||||
@invoices_bp.route('/invoices/<int:invoice_id>/resend-email/<int:email_id>', methods=['POST'])
|
||||
@login_required
|
||||
def resend_invoice_email(invoice_id, email_id):
|
||||
"""Resend an invoice email"""
|
||||
invoice = Invoice.query.get_or_404(invoice_id)
|
||||
|
||||
# Check access permissions
|
||||
if not current_user.is_admin and invoice.created_by != current_user.id:
|
||||
return jsonify({'error': 'Permission denied'}), 403
|
||||
|
||||
from app.models import InvoiceEmail
|
||||
original_email = InvoiceEmail.query.get_or_404(email_id)
|
||||
|
||||
# Verify the email belongs to this invoice
|
||||
if original_email.invoice_id != invoice_id:
|
||||
return jsonify({'error': 'Email record does not belong to this invoice'}), 400
|
||||
|
||||
# Get recipient email from request or use original
|
||||
recipient_email = request.form.get('recipient_email', '').strip() or request.json.get('recipient_email', '').strip() if request.is_json else ''
|
||||
if not recipient_email:
|
||||
recipient_email = original_email.recipient_email
|
||||
|
||||
# Get custom message if provided
|
||||
custom_message = request.form.get('custom_message', '').strip() or (request.json.get('custom_message', '').strip() if request.is_json else '')
|
||||
|
||||
# Get email template ID if provided
|
||||
email_template_id = request.form.get('email_template_id', type=int) or (request.json.get('email_template_id') if request.is_json else None)
|
||||
|
||||
try:
|
||||
from app.utils.email import send_invoice_email
|
||||
|
||||
success, invoice_email, message = send_invoice_email(
|
||||
invoice=invoice,
|
||||
recipient_email=recipient_email,
|
||||
sender_user=current_user,
|
||||
custom_message=custom_message if custom_message else None,
|
||||
email_template_id=email_template_id
|
||||
)
|
||||
|
||||
if success:
|
||||
flash(f'Invoice email resent successfully to {recipient_email}', 'success')
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'message': message,
|
||||
'invoice_email_id': invoice_email.id if invoice_email else None
|
||||
})
|
||||
else:
|
||||
return jsonify({'error': message}), 500
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error resending invoice email: {type(e).__name__}: {str(e)}")
|
||||
logger.exception("Full error traceback:")
|
||||
return jsonify({'error': f'Failed to resend email: {str(e)}'}), 500
|
||||
|
||||
@@ -20,7 +20,7 @@
|
||||
<div class="mb-4 p-4 bg-blue-50 dark:bg-blue-900/20 border border-blue-300 dark:border-blue-700 rounded-lg">
|
||||
<p class="text-sm">
|
||||
<i class="fas fa-info-circle mr-2"></i>
|
||||
{{ _('Configure email settings here to save them in the database. Database settings take precedence over environment variables.') }}
|
||||
{{ _('Configure email settings here to save them in the database.') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -119,7 +119,7 @@
|
||||
<i class="fas fa-exclamation-triangle text-xl mr-3"></i>
|
||||
<div>
|
||||
<strong class="font-bold">{{ _('Email is not configured') }}</strong>
|
||||
<span class="block sm:inline">{{ _('Please configure email settings in your environment variables.') }}</span>
|
||||
<span class="block sm:inline">{{ _('Please configure email settings using the form above.') }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -228,23 +228,6 @@
|
||||
<h2 class="text-lg font-semibold mb-4">{{ _('Configuration Guide') }}</h2>
|
||||
|
||||
<div class="prose dark:prose-invert max-w-none">
|
||||
<p class="mb-4">{{ _('To configure email, set the following environment variables:') }}</p>
|
||||
|
||||
<div class="bg-bg-light dark:bg-bg-dark border border-border-light dark:border-border-dark rounded-lg p-4 mb-4 font-mono text-sm overflow-x-auto">
|
||||
<div># {{ _('Basic SMTP Settings') }}</div>
|
||||
<div>MAIL_SERVER=smtp.gmail.com</div>
|
||||
<div>MAIL_PORT=587</div>
|
||||
<div>MAIL_USE_TLS=true</div>
|
||||
<div>MAIL_USE_SSL=false</div>
|
||||
<div><br></div>
|
||||
<div># {{ _('Authentication') }}</div>
|
||||
<div>MAIL_USERNAME=your-email@gmail.com</div>
|
||||
<div>MAIL_PASSWORD=your-app-password</div>
|
||||
<div><br></div>
|
||||
<div># {{ _('Sender Information') }}</div>
|
||||
<div>MAIL_DEFAULT_SENDER=noreply@yourdomain.com</div>
|
||||
</div>
|
||||
|
||||
<h3 class="text-base font-semibold mb-2">{{ _('Common SMTP Providers') }}</h3>
|
||||
<ul class="list-disc list-inside space-y-2 mb-4">
|
||||
<li><strong>Gmail:</strong> smtp.gmail.com:587 (TLS) - {{ _('Requires app password') }}</li>
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
) }}
|
||||
|
||||
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow">
|
||||
<form method="POST" class="space-y-6">
|
||||
<form method="POST" id="emailTemplateForm" class="space-y-6">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
@@ -38,27 +38,490 @@
|
||||
<span class="ml-2 text-sm text-gray-700 dark:text-gray-300">Set as default template</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="md:col-span-2">
|
||||
<label for="html" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">HTML Template *</label>
|
||||
<textarea id="html" name="html" rows="15" required class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 font-mono text-sm">{{ html or '' }}</textarea>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||
HTML content for the email. Use Jinja2 syntax: <code>{{ '{{ invoice.invoice_number }}' }}</code>, <code>{{ '{{ company_name }}' }}</code>, <code>{{ '{{ custom_message }}' }}</code>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Visual Editor Section -->
|
||||
<div class="mt-6">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300">HTML Template *</label>
|
||||
<div class="flex gap-2" id="variableButtons">
|
||||
<button type="button" data-variable="invoice.invoice_number" class="variable-btn px-3 py-1 text-xs bg-blue-100 dark:bg-blue-900 text-blue-700 dark:text-blue-300 rounded hover:bg-blue-200 dark:hover:bg-blue-800">
|
||||
Invoice Number
|
||||
</button>
|
||||
<button type="button" data-variable="company_name" class="variable-btn px-3 py-1 text-xs bg-blue-100 dark:bg-blue-900 text-blue-700 dark:text-blue-300 rounded hover:bg-blue-200 dark:hover:bg-blue-800">
|
||||
Company Name
|
||||
</button>
|
||||
<button type="button" data-variable="custom_message" class="variable-btn px-3 py-1 text-xs bg-blue-100 dark:bg-blue-900 text-blue-700 dark:text-blue-300 rounded hover:bg-blue-200 dark:hover:bg-blue-800">
|
||||
Custom Message
|
||||
</button>
|
||||
<button type="button" id="showAllVariablesBtn" class="px-3 py-1 text-xs bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300 rounded hover:bg-gray-200 dark:hover:bg-gray-600">
|
||||
More Variables
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="md:col-span-2">
|
||||
<label for="css" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">CSS Styles (Optional)</label>
|
||||
<textarea id="css" name="css" rows="10" class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 font-mono text-sm">{{ css or '' }}</textarea>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">CSS styles for the email template. Will be automatically wrapped in <style> tags.</p>
|
||||
<!-- Split View: Editor and Preview -->
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
<!-- Editor Column -->
|
||||
<div class="border border-gray-300 dark:border-gray-600 rounded-lg overflow-hidden">
|
||||
<div class="bg-gray-100 dark:bg-gray-700 px-4 py-2 border-b border-gray-300 dark:border-gray-600 flex items-center justify-between">
|
||||
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">Editor</span>
|
||||
<div class="flex gap-2">
|
||||
<button type="button" id="viewModeBtn" class="px-2 py-1 text-xs bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-300 rounded hover:bg-gray-200 dark:hover:bg-gray-600">
|
||||
<i class="fas fa-code mr-1"></i>Code
|
||||
</button>
|
||||
<button type="button" id="updatePreviewBtn" class="px-2 py-1 text-xs bg-blue-500 text-white rounded hover:bg-blue-600">
|
||||
<i class="fas fa-sync mr-1"></i>Preview
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="html_editor" style="min-height: 500px;"></div>
|
||||
<textarea id="html" name="html" required style="display: none;">{{ html or '' }}</textarea>
|
||||
</div>
|
||||
|
||||
<!-- Preview Column -->
|
||||
<div class="border border-gray-300 dark:border-gray-600 rounded-lg overflow-hidden">
|
||||
<div class="bg-gray-100 dark:bg-gray-700 px-4 py-2 border-b border-gray-300 dark:border-gray-600 flex items-center justify-between">
|
||||
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">Live Preview</span>
|
||||
<button type="button" id="refreshPreviewBtn" class="px-2 py-1 text-xs bg-green-500 text-white rounded hover:bg-green-600">
|
||||
<i class="fas fa-refresh mr-1"></i>Refresh
|
||||
</button>
|
||||
</div>
|
||||
<div id="email_preview" class="p-4 bg-white dark:bg-gray-800 overflow-auto" style="min-height: 500px; max-height: 500px;">
|
||||
<div class="text-gray-500 dark:text-gray-400 text-center py-8">
|
||||
<i class="fas fa-eye-slash text-4xl mb-2"></i>
|
||||
<p>Preview will appear here</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 mt-2">
|
||||
Use Jinja2 syntax for variables: <code>{{ '{{ invoice.invoice_number }}' }}</code>, <code>{{ '{{ company_name }}' }}</code>, <code>{{ '{{ custom_message }}' }}</code>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end gap-3">
|
||||
<!-- CSS Editor Section -->
|
||||
<div class="mt-6">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<label for="css" class="block text-sm font-medium text-gray-700 dark:text-gray-300">CSS Styles (Optional)</label>
|
||||
<button type="button" id="applyCssBtn" class="px-2 py-1 text-xs bg-green-500 text-white rounded hover:bg-green-600">
|
||||
<i class="fas fa-refresh mr-1"></i>Apply CSS
|
||||
</button>
|
||||
</div>
|
||||
<textarea id="css" name="css" rows="10" class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 font-mono text-sm">{{ css or '' }}</textarea>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">CSS styles for the email template. Will be automatically wrapped in <style> tags.</p>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end gap-3 pt-4 border-t border-gray-300 dark:border-gray-600">
|
||||
<a href="{{ url_for('admin.list_email_templates') }}" class="px-4 py-2 bg-gray-300 dark:bg-gray-600 text-gray-700 dark:text-gray-200 rounded-lg hover:bg-gray-400 dark:hover:bg-gray-500">{{ _('Cancel') }}</a>
|
||||
<button type="submit" class="px-4 py-2 bg-primary text-white rounded-lg hover:bg-primary/90">{{ _('Create Template') }}</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Variable Reference Modal -->
|
||||
<div id="variablesModal" class="hidden fixed inset-0 bg-gray-600 bg-opacity-50 overflow-y-auto h-full w-full z-50">
|
||||
<div class="relative top-20 mx-auto p-5 border w-11/12 max-w-2xl shadow-lg rounded-md bg-white dark:bg-gray-800">
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<h3 class="text-lg font-medium text-gray-900 dark:text-gray-100">Available Variables</h3>
|
||||
<button id="closeVariablesModalBtn" class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300">
|
||||
<i class="fas fa-times"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<h4 class="font-semibold text-sm mb-2 text-gray-700 dark:text-gray-300">Invoice Variables</h4>
|
||||
<div class="space-y-1">
|
||||
<button data-variable="invoice.invoice_number" class="modal-variable-btn w-full text-left px-3 py-2 text-sm bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 rounded">
|
||||
<code>{{ '{{ invoice.invoice_number }}' }}</code>
|
||||
</button>
|
||||
<button data-variable="invoice.issue_date" class="modal-variable-btn w-full text-left px-3 py-2 text-sm bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 rounded">
|
||||
<code>{{ '{{ invoice.issue_date }}' }}</code>
|
||||
</button>
|
||||
<button data-variable="invoice.due_date" class="modal-variable-btn w-full text-left px-3 py-2 text-sm bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 rounded">
|
||||
<code>{{ '{{ invoice.due_date }}' }}</code>
|
||||
</button>
|
||||
<button data-variable="invoice.total_amount" class="modal-variable-btn w-full text-left px-3 py-2 text-sm bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 rounded">
|
||||
<code>{{ '{{ invoice.total_amount }}' }}</code>
|
||||
</button>
|
||||
<button data-variable="invoice.currency_code" class="modal-variable-btn w-full text-left px-3 py-2 text-sm bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 rounded">
|
||||
<code>{{ '{{ invoice.currency_code }}' }}</code>
|
||||
</button>
|
||||
<button data-variable="invoice.client_name" class="modal-variable-btn w-full text-left px-3 py-2 text-sm bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 rounded">
|
||||
<code>{{ '{{ invoice.client_name }}' }}</code>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<h4 class="font-semibold text-sm mb-2 text-gray-700 dark:text-gray-300">Other Variables</h4>
|
||||
<div class="space-y-1">
|
||||
<button data-variable="company_name" class="modal-variable-btn w-full text-left px-3 py-2 text-sm bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 rounded">
|
||||
<code>{{ '{{ company_name }}' }}</code>
|
||||
</button>
|
||||
<button data-variable="custom_message" class="modal-variable-btn w-full text-left px-3 py-2 text-sm bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 rounded">
|
||||
<code>{{ '{{ custom_message }}' }}</code>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-4 flex justify-end">
|
||||
<button id="closeVariablesModalBtn2" class="px-4 py-2 bg-gray-300 dark:bg-gray-600 text-gray-700 dark:text-gray-200 rounded-lg hover:bg-gray-400 dark:hover:bg-gray-500">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_css %}
|
||||
<link rel="stylesheet" href="https://uicdn.toast.com/editor/latest/toastui-editor.min.css">
|
||||
<link rel="stylesheet" href="https://uicdn.toast.com/editor/latest/theme/toastui-editor-dark.css">
|
||||
<style>
|
||||
#email_preview {
|
||||
font-family: Arial, sans-serif;
|
||||
}
|
||||
#email_preview img {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts_extra %}
|
||||
<script src="https://uicdn.toast.com/editor/latest/toastui-editor-all.min.js"></script>
|
||||
<script>
|
||||
let htmlEditor = null;
|
||||
let isCodeMode = false;
|
||||
let originalHtml = {{ (html or '') | tojson }};
|
||||
|
||||
// Wait for DOM and Toast UI Editor to be ready
|
||||
function initEditor() {
|
||||
console.log('initEditor called');
|
||||
const htmlTextarea = document.getElementById('html');
|
||||
const editorContainer = document.getElementById('html_editor');
|
||||
|
||||
console.log('htmlTextarea:', htmlTextarea);
|
||||
console.log('editorContainer:', editorContainer);
|
||||
|
||||
if (!editorContainer) {
|
||||
console.error('Editor container not found');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!window.toastui || !window.toastui.Editor) {
|
||||
console.error('Toast UI Editor not loaded');
|
||||
// Fallback to textarea
|
||||
editorContainer.innerHTML = '<textarea id="html_fallback" name="html" class="w-full h-full p-4 font-mono text-sm border border-gray-300 dark:border-gray-600 rounded" style="min-height: 500px;">' + (originalHtml || '') + '</textarea>';
|
||||
const fallbackTextarea = document.getElementById('html_fallback');
|
||||
if (fallbackTextarea && htmlTextarea) {
|
||||
fallbackTextarea.addEventListener('input', function() {
|
||||
htmlTextarea.value = this.value;
|
||||
updatePreview();
|
||||
});
|
||||
}
|
||||
setupEventListeners();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const theme = document.documentElement.classList.contains('dark') ? 'dark' : 'light';
|
||||
|
||||
htmlEditor = new toastui.Editor({
|
||||
el: editorContainer,
|
||||
height: '500px',
|
||||
initialEditType: 'wysiwyg',
|
||||
previewStyle: 'vertical',
|
||||
usageStatistics: false,
|
||||
theme: theme,
|
||||
toolbarItems: [
|
||||
['heading', 'bold', 'italic', 'strike'],
|
||||
['hr', 'quote'],
|
||||
['ul', 'ol', 'task'],
|
||||
['link', 'code', 'codeblock', 'table'],
|
||||
['image'],
|
||||
['scrollSync']
|
||||
],
|
||||
initialValue: originalHtml || ''
|
||||
});
|
||||
|
||||
// Update hidden textarea on form submit
|
||||
const emailTemplateForm = document.getElementById('emailTemplateForm');
|
||||
if (emailTemplateForm) {
|
||||
emailTemplateForm.addEventListener('submit', function() {
|
||||
if (htmlEditor) {
|
||||
try {
|
||||
htmlTextarea.value = htmlEditor.getHTML();
|
||||
} catch (e) {
|
||||
htmlTextarea.value = htmlEditor.getMarkdown();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Auto-update preview on content change
|
||||
htmlEditor.on('change', function() {
|
||||
// Debounce preview updates
|
||||
clearTimeout(window.previewTimeout);
|
||||
window.previewTimeout = setTimeout(updatePreview, 500);
|
||||
});
|
||||
|
||||
// Initial preview after a short delay
|
||||
setTimeout(updatePreview, 300);
|
||||
|
||||
setupEventListeners();
|
||||
} catch (error) {
|
||||
console.error('Error initializing editor:', error);
|
||||
initializeFallback();
|
||||
}
|
||||
}
|
||||
|
||||
function initializeFallback() {
|
||||
const editorContainer = document.getElementById('html_editor');
|
||||
const htmlTextarea = document.getElementById('html');
|
||||
if (editorContainer && htmlTextarea) {
|
||||
editorContainer.innerHTML = '<textarea id="html_fallback" name="html" class="w-full h-full p-4 font-mono text-sm border border-gray-300 dark:border-gray-600 rounded" style="min-height: 500px;">' + (originalHtml || '') + '</textarea>';
|
||||
const fallbackTextarea = document.getElementById('html_fallback');
|
||||
if (fallbackTextarea) {
|
||||
fallbackTextarea.addEventListener('input', function() {
|
||||
htmlTextarea.value = this.value;
|
||||
updatePreview();
|
||||
});
|
||||
}
|
||||
setupEventListeners();
|
||||
}
|
||||
}
|
||||
|
||||
function setupEventListeners() {
|
||||
// CSS editor change handler
|
||||
const cssTextarea = document.getElementById('css');
|
||||
if (cssTextarea) {
|
||||
cssTextarea.addEventListener('input', function() {
|
||||
clearTimeout(window.previewTimeout);
|
||||
window.previewTimeout = setTimeout(updatePreview, 500);
|
||||
});
|
||||
}
|
||||
|
||||
// Set up event listeners for variable buttons
|
||||
document.querySelectorAll('.variable-btn').forEach(btn => {
|
||||
btn.addEventListener('click', function() {
|
||||
const varName = this.getAttribute('data-variable');
|
||||
insertVariable(varName);
|
||||
});
|
||||
});
|
||||
|
||||
const showAllVariablesBtn = document.getElementById('showAllVariablesBtn');
|
||||
if (showAllVariablesBtn) {
|
||||
showAllVariablesBtn.addEventListener('click', showAllVariables);
|
||||
}
|
||||
|
||||
const closeVariablesModalBtn = document.getElementById('closeVariablesModalBtn');
|
||||
if (closeVariablesModalBtn) {
|
||||
closeVariablesModalBtn.addEventListener('click', closeVariablesModal);
|
||||
}
|
||||
|
||||
const closeVariablesModalBtn2 = document.getElementById('closeVariablesModalBtn2');
|
||||
if (closeVariablesModalBtn2) {
|
||||
closeVariablesModalBtn2.addEventListener('click', closeVariablesModal);
|
||||
}
|
||||
|
||||
document.querySelectorAll('.modal-variable-btn').forEach(btn => {
|
||||
btn.addEventListener('click', function() {
|
||||
const varName = this.getAttribute('data-variable');
|
||||
insertVariable(varName);
|
||||
closeVariablesModal();
|
||||
});
|
||||
});
|
||||
|
||||
// Set up event listeners for preview and view mode buttons
|
||||
const viewModeBtn = document.getElementById('viewModeBtn');
|
||||
if (viewModeBtn) {
|
||||
viewModeBtn.addEventListener('click', toggleViewMode);
|
||||
}
|
||||
|
||||
const updatePreviewBtn = document.getElementById('updatePreviewBtn');
|
||||
if (updatePreviewBtn) {
|
||||
updatePreviewBtn.addEventListener('click', updatePreview);
|
||||
}
|
||||
|
||||
const refreshPreviewBtn = document.getElementById('refreshPreviewBtn');
|
||||
if (refreshPreviewBtn) {
|
||||
refreshPreviewBtn.addEventListener('click', updatePreview);
|
||||
}
|
||||
|
||||
const applyCssBtn = document.getElementById('applyCssBtn');
|
||||
if (applyCssBtn) {
|
||||
applyCssBtn.addEventListener('click', updatePreview);
|
||||
}
|
||||
}
|
||||
|
||||
function toggleViewMode() {
|
||||
if (!htmlEditor) return;
|
||||
|
||||
isCodeMode = !isCodeMode;
|
||||
const btn = document.getElementById('viewModeBtn');
|
||||
|
||||
if (isCodeMode) {
|
||||
// Switch to markdown/code view
|
||||
htmlEditor.changeMode('markdown');
|
||||
btn.innerHTML = '<i class="fas fa-eye mr-1"></i>Visual';
|
||||
} else {
|
||||
// Switch to WYSIWYG view
|
||||
htmlEditor.changeMode('wysiwyg');
|
||||
btn.innerHTML = '<i class="fas fa-code mr-1"></i>Code';
|
||||
}
|
||||
}
|
||||
|
||||
function insertVariable(varName) {
|
||||
if (!htmlEditor) {
|
||||
const textarea = document.getElementById('html_fallback') || document.getElementById('html');
|
||||
if (textarea) {
|
||||
const cursorPos = textarea.selectionStart || textarea.value.length;
|
||||
const textBefore = textarea.value.substring(0, cursorPos);
|
||||
const textAfter = textarea.value.substring(cursorPos);
|
||||
const variable = '{{ ' + varName + ' }}';
|
||||
textarea.value = textBefore + variable + textAfter;
|
||||
textarea.focus();
|
||||
textarea.setSelectionRange(cursorPos + variable.length, cursorPos + variable.length);
|
||||
updatePreview();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const variable = '{{ ' + varName + ' }}';
|
||||
|
||||
try {
|
||||
if (isCodeMode || htmlEditor.getCurrentMode() === 'markdown') {
|
||||
htmlEditor.insertText(variable);
|
||||
} else {
|
||||
htmlEditor.insertText(variable);
|
||||
}
|
||||
updatePreview();
|
||||
} catch (e) {
|
||||
console.error('Error inserting variable:', e);
|
||||
}
|
||||
}
|
||||
|
||||
function showAllVariables() {
|
||||
document.getElementById('variablesModal').classList.remove('hidden');
|
||||
}
|
||||
|
||||
function closeVariablesModal() {
|
||||
document.getElementById('variablesModal').classList.add('hidden');
|
||||
}
|
||||
|
||||
function updatePreview() {
|
||||
if (!htmlEditor) {
|
||||
const textarea = document.getElementById('html_fallback') || document.getElementById('html');
|
||||
if (textarea) {
|
||||
updatePreviewContent(textarea.value);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
let htmlContent = '';
|
||||
try {
|
||||
htmlContent = htmlEditor.getHTML();
|
||||
} catch (e) {
|
||||
try {
|
||||
htmlContent = htmlEditor.getMarkdown();
|
||||
} catch (e2) {
|
||||
console.error('Error getting editor content:', e2);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
updatePreviewContent(htmlContent);
|
||||
}
|
||||
|
||||
function updatePreviewContent(htmlContent) {
|
||||
const previewDiv = document.getElementById('email_preview');
|
||||
const cssTextarea = document.getElementById('css');
|
||||
const cssContent = cssTextarea ? cssTextarea.value : '';
|
||||
|
||||
if (!previewDiv) {
|
||||
console.error('Preview div not found');
|
||||
return;
|
||||
}
|
||||
|
||||
// Create a complete HTML document with CSS
|
||||
let fullHtml = '<!DOCTYPE html><html><head><meta charset="UTF-8">';
|
||||
if (cssContent) {
|
||||
fullHtml += '<style>' + cssContent + '</style>';
|
||||
}
|
||||
fullHtml += '</head><body>';
|
||||
|
||||
// Replace Jinja2 variables with sample data for preview
|
||||
htmlContent = (htmlContent || '')
|
||||
.replace(/\{\{\s*invoice\.invoice_number\s*\}\}/g, 'INV-2024-001')
|
||||
.replace(/\{\{\s*invoice\.issue_date\s*\}\}/g, '2024-01-15')
|
||||
.replace(/\{\{\s*invoice\.due_date\s*\}\}/g, '2024-02-15')
|
||||
.replace(/\{\{\s*invoice\.total_amount\s*\}\}/g, '1,200.00')
|
||||
.replace(/\{\{\s*invoice\.currency_code\s*\}\}/g, 'EUR')
|
||||
.replace(/\{\{\s*invoice\.client_name\s*\}\}/g, 'Sample Client')
|
||||
.replace(/\{\{\s*company_name\s*\}\}/g, 'Your Company Name')
|
||||
.replace(/\{\{\s*custom_message\s*\}\}/g, 'Thank you for your business!');
|
||||
|
||||
fullHtml += htmlContent;
|
||||
fullHtml += '</body></html>';
|
||||
|
||||
// Create iframe for safe preview
|
||||
previewDiv.innerHTML = '<iframe id="preview_iframe" style="width: 100%; height: 100%; border: none;"></iframe>';
|
||||
const iframe = document.getElementById('preview_iframe');
|
||||
if (iframe) {
|
||||
try {
|
||||
iframe.contentDocument.open();
|
||||
iframe.contentDocument.write(fullHtml);
|
||||
iframe.contentDocument.close();
|
||||
} catch (e) {
|
||||
console.error('Error writing to iframe:', e);
|
||||
previewDiv.innerHTML = '<div class="text-red-500 p-4">Preview unavailable. Please check browser console.</div>';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Close modal on outside click
|
||||
document.addEventListener('click', function(event) {
|
||||
const modal = document.getElementById('variablesModal');
|
||||
if (event.target == modal) {
|
||||
closeVariablesModal();
|
||||
}
|
||||
});
|
||||
|
||||
// Initialize when DOM and Toast UI Editor are ready
|
||||
function waitForEditorAndInit() {
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
checkEditorAndInit();
|
||||
});
|
||||
} else {
|
||||
checkEditorAndInit();
|
||||
}
|
||||
}
|
||||
|
||||
function checkEditorAndInit() {
|
||||
console.log('checkEditorAndInit called');
|
||||
console.log('window.toastui:', window.toastui);
|
||||
console.log('document.readyState:', document.readyState);
|
||||
|
||||
// Check if Toast UI Editor is loaded
|
||||
if (window.toastui && window.toastui.Editor) {
|
||||
console.log('Toast UI Editor found, initializing...');
|
||||
setTimeout(initEditor, 50);
|
||||
} else {
|
||||
console.log('Toast UI Editor not found, waiting...');
|
||||
// Wait a bit and try again
|
||||
setTimeout(function() {
|
||||
if (window.toastui && window.toastui.Editor) {
|
||||
console.log('Toast UI Editor found after wait, initializing...');
|
||||
initEditor();
|
||||
} else {
|
||||
console.warn('Toast UI Editor not loaded, using fallback');
|
||||
initEditor(); // Will use fallback
|
||||
}
|
||||
}, 500);
|
||||
}
|
||||
}
|
||||
|
||||
waitForEditorAndInit();
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -16,7 +16,7 @@
|
||||
) }}
|
||||
|
||||
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow">
|
||||
<form method="POST" class="space-y-6">
|
||||
<form method="POST" id="emailTemplateForm" class="space-y-6">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
@@ -36,27 +36,493 @@
|
||||
<span class="ml-2 text-sm text-gray-700 dark:text-gray-300">Set as default template</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="md:col-span-2">
|
||||
<label for="html" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">HTML Template *</label>
|
||||
<textarea id="html" name="html" rows="15" required class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 font-mono text-sm">{{ template.html or '' }}</textarea>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">
|
||||
HTML content for the email. Use Jinja2 syntax: <code>{{ '{{ invoice.invoice_number }}' }}</code>, <code>{{ '{{ company_name }}' }}</code>, <code>{{ '{{ custom_message }}' }}</code>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Visual Editor Section -->
|
||||
<div class="mt-6">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300">HTML Template *</label>
|
||||
<div class="flex gap-2" id="variableButtons">
|
||||
<button type="button" data-variable="invoice.invoice_number" class="variable-btn px-3 py-1 text-xs bg-blue-100 dark:bg-blue-900 text-blue-700 dark:text-blue-300 rounded hover:bg-blue-200 dark:hover:bg-blue-800">
|
||||
Invoice Number
|
||||
</button>
|
||||
<button type="button" data-variable="company_name" class="variable-btn px-3 py-1 text-xs bg-blue-100 dark:bg-blue-900 text-blue-700 dark:text-blue-300 rounded hover:bg-blue-200 dark:hover:bg-blue-800">
|
||||
Company Name
|
||||
</button>
|
||||
<button type="button" data-variable="custom_message" class="variable-btn px-3 py-1 text-xs bg-blue-100 dark:bg-blue-900 text-blue-700 dark:text-blue-300 rounded hover:bg-blue-200 dark:hover:bg-blue-800">
|
||||
Custom Message
|
||||
</button>
|
||||
<button type="button" id="showAllVariablesBtn" class="px-3 py-1 text-xs bg-gray-100 dark:bg-gray-700 text-gray-700 dark:text-gray-300 rounded hover:bg-gray-200 dark:hover:bg-gray-600">
|
||||
More Variables
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="md:col-span-2">
|
||||
<label for="css" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">CSS Styles (Optional)</label>
|
||||
<textarea id="css" name="css" rows="10" class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 font-mono text-sm">{{ template.css or '' }}</textarea>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">CSS styles for the email template. Will be automatically wrapped in <style> tags.</p>
|
||||
<!-- Split View: Editor and Preview -->
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-4">
|
||||
<!-- Editor Column -->
|
||||
<div class="border border-gray-300 dark:border-gray-600 rounded-lg overflow-hidden">
|
||||
<div class="bg-gray-100 dark:bg-gray-700 px-4 py-2 border-b border-gray-300 dark:border-gray-600 flex items-center justify-between">
|
||||
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">Editor</span>
|
||||
<div class="flex gap-2">
|
||||
<button type="button" id="viewModeBtn" class="px-2 py-1 text-xs bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-300 rounded hover:bg-gray-200 dark:hover:bg-gray-600">
|
||||
<i class="fas fa-code mr-1"></i>Code
|
||||
</button>
|
||||
<button type="button" id="updatePreviewBtn" class="px-2 py-1 text-xs bg-blue-500 text-white rounded hover:bg-blue-600">
|
||||
<i class="fas fa-sync mr-1"></i>Preview
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div id="html_editor" style="min-height: 500px;"></div>
|
||||
<textarea id="html" name="html" required style="display: none;">{{ template.html or '' }}</textarea>
|
||||
</div>
|
||||
|
||||
<!-- Preview Column -->
|
||||
<div class="border border-gray-300 dark:border-gray-600 rounded-lg overflow-hidden">
|
||||
<div class="bg-gray-100 dark:bg-gray-700 px-4 py-2 border-b border-gray-300 dark:border-gray-600 flex items-center justify-between">
|
||||
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">Live Preview</span>
|
||||
<button type="button" id="refreshPreviewBtn" class="px-2 py-1 text-xs bg-green-500 text-white rounded hover:bg-green-600">
|
||||
<i class="fas fa-refresh mr-1"></i>Refresh
|
||||
</button>
|
||||
</div>
|
||||
<div id="email_preview" class="p-4 bg-white dark:bg-gray-800 overflow-auto" style="min-height: 500px; max-height: 500px;">
|
||||
<div class="text-gray-500 dark:text-gray-400 text-center py-8">
|
||||
<i class="fas fa-eye-slash text-4xl mb-2"></i>
|
||||
<p>Preview will appear here</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 mt-2">
|
||||
Use Jinja2 syntax for variables: <code>{{ '{{ invoice.invoice_number }}' }}</code>, <code>{{ '{{ company_name }}' }}</code>, <code>{{ '{{ custom_message }}' }}</code>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end gap-3">
|
||||
<!-- CSS Editor Section -->
|
||||
<div class="mt-6">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<label for="css" class="block text-sm font-medium text-gray-700 dark:text-gray-300">CSS Styles (Optional)</label>
|
||||
<button type="button" id="applyCssBtn" class="px-2 py-1 text-xs bg-green-500 text-white rounded hover:bg-green-600">
|
||||
<i class="fas fa-refresh mr-1"></i>Apply CSS
|
||||
</button>
|
||||
</div>
|
||||
<textarea id="css" name="css" rows="10" class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-gray-100 font-mono text-sm">{{ template.css or '' }}</textarea>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">CSS styles for the email template. Will be automatically wrapped in <style> tags.</p>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end gap-3 pt-4 border-t border-gray-300 dark:border-gray-600">
|
||||
<a href="{{ url_for('admin.list_email_templates') }}" class="px-4 py-2 bg-gray-300 dark:bg-gray-600 text-gray-700 dark:text-gray-200 rounded-lg hover:bg-gray-400 dark:hover:bg-gray-500">{{ _('Cancel') }}</a>
|
||||
<button type="submit" class="px-4 py-2 bg-primary text-white rounded-lg hover:bg-primary/90">{{ _('Update Template') }}</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Variable Reference Modal -->
|
||||
<div id="variablesModal" class="hidden fixed inset-0 bg-gray-600 bg-opacity-50 overflow-y-auto h-full w-full z-50">
|
||||
<div class="relative top-20 mx-auto p-5 border w-11/12 max-w-2xl shadow-lg rounded-md bg-white dark:bg-gray-800">
|
||||
<div class="flex justify-between items-center mb-4">
|
||||
<h3 class="text-lg font-medium text-gray-900 dark:text-gray-100">Available Variables</h3>
|
||||
<button id="closeVariablesModalBtn" class="text-gray-400 hover:text-gray-600 dark:hover:text-gray-300">
|
||||
<i class="fas fa-times"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<h4 class="font-semibold text-sm mb-2 text-gray-700 dark:text-gray-300">Invoice Variables</h4>
|
||||
<div class="space-y-1">
|
||||
<button data-variable="invoice.invoice_number" class="modal-variable-btn w-full text-left px-3 py-2 text-sm bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 rounded">
|
||||
<code>{{ '{{ invoice.invoice_number }}' }}</code>
|
||||
</button>
|
||||
<button data-variable="invoice.issue_date" class="modal-variable-btn w-full text-left px-3 py-2 text-sm bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 rounded">
|
||||
<code>{{ '{{ invoice.issue_date }}' }}</code>
|
||||
</button>
|
||||
<button data-variable="invoice.due_date" class="modal-variable-btn w-full text-left px-3 py-2 text-sm bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 rounded">
|
||||
<code>{{ '{{ invoice.due_date }}' }}</code>
|
||||
</button>
|
||||
<button data-variable="invoice.total_amount" class="modal-variable-btn w-full text-left px-3 py-2 text-sm bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 rounded">
|
||||
<code>{{ '{{ invoice.total_amount }}' }}</code>
|
||||
</button>
|
||||
<button data-variable="invoice.currency_code" class="modal-variable-btn w-full text-left px-3 py-2 text-sm bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 rounded">
|
||||
<code>{{ '{{ invoice.currency_code }}' }}</code>
|
||||
</button>
|
||||
<button data-variable="invoice.client_name" class="modal-variable-btn w-full text-left px-3 py-2 text-sm bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 rounded">
|
||||
<code>{{ '{{ invoice.client_name }}' }}</code>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<h4 class="font-semibold text-sm mb-2 text-gray-700 dark:text-gray-300">Other Variables</h4>
|
||||
<div class="space-y-1">
|
||||
<button data-variable="company_name" class="modal-variable-btn w-full text-left px-3 py-2 text-sm bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 rounded">
|
||||
<code>{{ '{{ company_name }}' }}</code>
|
||||
</button>
|
||||
<button data-variable="custom_message" class="modal-variable-btn w-full text-left px-3 py-2 text-sm bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 rounded">
|
||||
<code>{{ '{{ custom_message }}' }}</code>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-4 flex justify-end">
|
||||
<button id="closeVariablesModalBtn2" class="px-4 py-2 bg-gray-300 dark:bg-gray-600 text-gray-700 dark:text-gray-200 rounded-lg hover:bg-gray-400 dark:hover:bg-gray-500">Close</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% endblock %}
|
||||
|
||||
{% block extra_css %}
|
||||
<link rel="stylesheet" href="https://uicdn.toast.com/editor/latest/toastui-editor.min.css">
|
||||
<link rel="stylesheet" href="https://uicdn.toast.com/editor/latest/theme/toastui-editor-dark.css">
|
||||
<style>
|
||||
#email_preview {
|
||||
font-family: Arial, sans-serif;
|
||||
}
|
||||
#email_preview img {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts_extra %}
|
||||
<script src="https://uicdn.toast.com/editor/latest/toastui-editor-all.min.js"></script>
|
||||
<script>
|
||||
let htmlEditor = null;
|
||||
let isCodeMode = false;
|
||||
let originalHtml = {{ (template.html or '') | tojson }};
|
||||
|
||||
// Wait for DOM and Toast UI Editor to be ready
|
||||
function initEditor() {
|
||||
console.log('initEditor called');
|
||||
const htmlTextarea = document.getElementById('html');
|
||||
const editorContainer = document.getElementById('html_editor');
|
||||
|
||||
console.log('htmlTextarea:', htmlTextarea);
|
||||
console.log('editorContainer:', editorContainer);
|
||||
|
||||
if (!editorContainer) {
|
||||
console.error('Editor container not found');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!window.toastui || !window.toastui.Editor) {
|
||||
console.error('Toast UI Editor not loaded');
|
||||
// Fallback to textarea
|
||||
editorContainer.innerHTML = '<textarea id="html_fallback" name="html" class="w-full h-full p-4 font-mono text-sm border border-gray-300 dark:border-gray-600 rounded" style="min-height: 500px;">' + (originalHtml || '') + '</textarea>';
|
||||
const fallbackTextarea = document.getElementById('html_fallback');
|
||||
if (fallbackTextarea && htmlTextarea) {
|
||||
fallbackTextarea.addEventListener('input', function() {
|
||||
htmlTextarea.value = this.value;
|
||||
updatePreview();
|
||||
});
|
||||
}
|
||||
setupEventListeners();
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const theme = document.documentElement.classList.contains('dark') ? 'dark' : 'light';
|
||||
|
||||
htmlEditor = new toastui.Editor({
|
||||
el: editorContainer,
|
||||
height: '500px',
|
||||
initialEditType: 'wysiwyg',
|
||||
previewStyle: 'vertical',
|
||||
usageStatistics: false,
|
||||
theme: theme,
|
||||
toolbarItems: [
|
||||
['heading', 'bold', 'italic', 'strike'],
|
||||
['hr', 'quote'],
|
||||
['ul', 'ol', 'task'],
|
||||
['link', 'code', 'codeblock', 'table'],
|
||||
['image'],
|
||||
['scrollSync']
|
||||
],
|
||||
initialValue: originalHtml || ''
|
||||
});
|
||||
|
||||
// Update hidden textarea on form submit
|
||||
const emailTemplateForm = document.getElementById('emailTemplateForm');
|
||||
if (emailTemplateForm) {
|
||||
emailTemplateForm.addEventListener('submit', function() {
|
||||
if (htmlEditor) {
|
||||
try {
|
||||
htmlTextarea.value = htmlEditor.getHTML();
|
||||
} catch (e) {
|
||||
htmlTextarea.value = htmlEditor.getMarkdown();
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// Auto-update preview on content change
|
||||
htmlEditor.on('change', function() {
|
||||
clearTimeout(window.previewTimeout);
|
||||
window.previewTimeout = setTimeout(updatePreview, 500);
|
||||
});
|
||||
|
||||
// Initial preview after a short delay
|
||||
setTimeout(updatePreview, 300);
|
||||
|
||||
setupEventListeners();
|
||||
} catch (error) {
|
||||
console.error('Error initializing editor:', error);
|
||||
initializeFallback();
|
||||
}
|
||||
}
|
||||
|
||||
function initializeFallback() {
|
||||
const editorContainer = document.getElementById('html_editor');
|
||||
const htmlTextarea = document.getElementById('html');
|
||||
if (editorContainer && htmlTextarea) {
|
||||
editorContainer.innerHTML = '<textarea id="html_fallback" name="html" class="w-full h-full p-4 font-mono text-sm border border-gray-300 dark:border-gray-600 rounded" style="min-height: 500px;">' + (originalHtml || '') + '</textarea>';
|
||||
const fallbackTextarea = document.getElementById('html_fallback');
|
||||
if (fallbackTextarea) {
|
||||
fallbackTextarea.addEventListener('input', function() {
|
||||
htmlTextarea.value = this.value;
|
||||
updatePreview();
|
||||
});
|
||||
}
|
||||
setupEventListeners();
|
||||
}
|
||||
}
|
||||
|
||||
function setupEventListeners() {
|
||||
// CSS editor change handler
|
||||
const cssTextarea = document.getElementById('css');
|
||||
if (cssTextarea) {
|
||||
cssTextarea.addEventListener('input', function() {
|
||||
clearTimeout(window.previewTimeout);
|
||||
window.previewTimeout = setTimeout(updatePreview, 500);
|
||||
});
|
||||
}
|
||||
|
||||
// Set up event listeners for variable buttons
|
||||
document.querySelectorAll('.variable-btn').forEach(btn => {
|
||||
btn.addEventListener('click', function() {
|
||||
const varName = this.getAttribute('data-variable');
|
||||
insertVariable(varName);
|
||||
});
|
||||
});
|
||||
|
||||
const showAllVariablesBtn = document.getElementById('showAllVariablesBtn');
|
||||
if (showAllVariablesBtn) {
|
||||
showAllVariablesBtn.addEventListener('click', showAllVariables);
|
||||
}
|
||||
|
||||
const closeVariablesModalBtn = document.getElementById('closeVariablesModalBtn');
|
||||
if (closeVariablesModalBtn) {
|
||||
closeVariablesModalBtn.addEventListener('click', closeVariablesModal);
|
||||
}
|
||||
|
||||
const closeVariablesModalBtn2 = document.getElementById('closeVariablesModalBtn2');
|
||||
if (closeVariablesModalBtn2) {
|
||||
closeVariablesModalBtn2.addEventListener('click', closeVariablesModal);
|
||||
}
|
||||
|
||||
document.querySelectorAll('.modal-variable-btn').forEach(btn => {
|
||||
btn.addEventListener('click', function() {
|
||||
const varName = this.getAttribute('data-variable');
|
||||
insertVariable(varName);
|
||||
closeVariablesModal();
|
||||
});
|
||||
});
|
||||
|
||||
// Set up event listeners for preview and view mode buttons
|
||||
const viewModeBtn = document.getElementById('viewModeBtn');
|
||||
if (viewModeBtn) {
|
||||
viewModeBtn.addEventListener('click', toggleViewMode);
|
||||
}
|
||||
|
||||
const updatePreviewBtn = document.getElementById('updatePreviewBtn');
|
||||
if (updatePreviewBtn) {
|
||||
updatePreviewBtn.addEventListener('click', updatePreview);
|
||||
}
|
||||
|
||||
const refreshPreviewBtn = document.getElementById('refreshPreviewBtn');
|
||||
if (refreshPreviewBtn) {
|
||||
refreshPreviewBtn.addEventListener('click', updatePreview);
|
||||
}
|
||||
|
||||
const applyCssBtn = document.getElementById('applyCssBtn');
|
||||
if (applyCssBtn) {
|
||||
applyCssBtn.addEventListener('click', updatePreview);
|
||||
}
|
||||
}
|
||||
|
||||
function toggleViewMode() {
|
||||
if (!htmlEditor) return;
|
||||
|
||||
isCodeMode = !isCodeMode;
|
||||
const btn = document.getElementById('viewModeBtn');
|
||||
|
||||
if (isCodeMode) {
|
||||
htmlEditor.changeMode('markdown');
|
||||
btn.innerHTML = '<i class="fas fa-eye mr-1"></i>Visual';
|
||||
} else {
|
||||
htmlEditor.changeMode('wysiwyg');
|
||||
btn.innerHTML = '<i class="fas fa-code mr-1"></i>Code';
|
||||
}
|
||||
}
|
||||
|
||||
function insertVariable(varName) {
|
||||
if (!htmlEditor) {
|
||||
const textarea = document.getElementById('html_fallback') || document.getElementById('html');
|
||||
if (textarea) {
|
||||
const cursorPos = textarea.selectionStart || textarea.value.length;
|
||||
const textBefore = textarea.value.substring(0, cursorPos);
|
||||
const textAfter = textarea.value.substring(cursorPos);
|
||||
const variable = '{{ ' + varName + ' }}';
|
||||
textarea.value = textBefore + variable + textAfter;
|
||||
textarea.focus();
|
||||
textarea.setSelectionRange(cursorPos + variable.length, cursorPos + variable.length);
|
||||
updatePreview();
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const variable = '{{ ' + varName + ' }}';
|
||||
|
||||
try {
|
||||
if (isCodeMode || htmlEditor.getCurrentMode() === 'markdown') {
|
||||
htmlEditor.insertText(variable);
|
||||
} else {
|
||||
htmlEditor.insertText(variable);
|
||||
}
|
||||
updatePreview();
|
||||
} catch (e) {
|
||||
console.error('Error inserting variable:', e);
|
||||
}
|
||||
}
|
||||
|
||||
function showAllVariables() {
|
||||
const modal = document.getElementById('variablesModal');
|
||||
if (modal) {
|
||||
modal.classList.remove('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
function closeVariablesModal() {
|
||||
const modal = document.getElementById('variablesModal');
|
||||
if (modal) {
|
||||
modal.classList.add('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
function updatePreview() {
|
||||
if (!htmlEditor) {
|
||||
const textarea = document.getElementById('html_fallback') || document.getElementById('html');
|
||||
if (textarea) {
|
||||
updatePreviewContent(textarea.value);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
let htmlContent = '';
|
||||
try {
|
||||
htmlContent = htmlEditor.getHTML();
|
||||
} catch (e) {
|
||||
try {
|
||||
htmlContent = htmlEditor.getMarkdown();
|
||||
} catch (e2) {
|
||||
console.error('Error getting editor content:', e2);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
updatePreviewContent(htmlContent);
|
||||
}
|
||||
|
||||
function updatePreviewContent(htmlContent) {
|
||||
const previewDiv = document.getElementById('email_preview');
|
||||
const cssTextarea = document.getElementById('css');
|
||||
const cssContent = cssTextarea ? cssTextarea.value : '';
|
||||
|
||||
if (!previewDiv) {
|
||||
console.error('Preview div not found');
|
||||
return;
|
||||
}
|
||||
|
||||
// Create a complete HTML document with CSS
|
||||
let fullHtml = '<!DOCTYPE html><html><head><meta charset="UTF-8">';
|
||||
if (cssContent) {
|
||||
fullHtml += '<style>' + cssContent + '</style>';
|
||||
}
|
||||
fullHtml += '</head><body>';
|
||||
|
||||
// Replace Jinja2 variables with sample data for preview
|
||||
htmlContent = (htmlContent || '')
|
||||
.replace(/\{\{\s*invoice\.invoice_number\s*\}\}/g, 'INV-2024-001')
|
||||
.replace(/\{\{\s*invoice\.issue_date\s*\}\}/g, '2024-01-15')
|
||||
.replace(/\{\{\s*invoice\.due_date\s*\}\}/g, '2024-02-15')
|
||||
.replace(/\{\{\s*invoice\.total_amount\s*\}\}/g, '1,200.00')
|
||||
.replace(/\{\{\s*invoice\.currency_code\s*\}\}/g, 'EUR')
|
||||
.replace(/\{\{\s*invoice\.client_name\s*\}\}/g, 'Sample Client')
|
||||
.replace(/\{\{\s*company_name\s*\}\}/g, 'Your Company Name')
|
||||
.replace(/\{\{\s*custom_message\s*\}\}/g, 'Thank you for your business!');
|
||||
|
||||
fullHtml += htmlContent;
|
||||
fullHtml += '</body></html>';
|
||||
|
||||
// Create iframe for safe preview
|
||||
previewDiv.innerHTML = '<iframe id="preview_iframe" style="width: 100%; height: 100%; border: none;"></iframe>';
|
||||
const iframe = document.getElementById('preview_iframe');
|
||||
if (iframe) {
|
||||
try {
|
||||
iframe.contentDocument.open();
|
||||
iframe.contentDocument.write(fullHtml);
|
||||
iframe.contentDocument.close();
|
||||
} catch (e) {
|
||||
console.error('Error writing to iframe:', e);
|
||||
previewDiv.innerHTML = '<div class="text-red-500 p-4">Preview unavailable. Please check browser console.</div>';
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Close modal on outside click
|
||||
document.addEventListener('click', function(event) {
|
||||
const modal = document.getElementById('variablesModal');
|
||||
if (event.target == modal) {
|
||||
closeVariablesModal();
|
||||
}
|
||||
});
|
||||
|
||||
// Initialize when DOM and Toast UI Editor are ready
|
||||
function waitForEditorAndInit() {
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
checkEditorAndInit();
|
||||
});
|
||||
} else {
|
||||
checkEditorAndInit();
|
||||
}
|
||||
}
|
||||
|
||||
function checkEditorAndInit() {
|
||||
console.log('checkEditorAndInit called');
|
||||
console.log('window.toastui:', window.toastui);
|
||||
console.log('document.readyState:', document.readyState);
|
||||
|
||||
// Check if Toast UI Editor is loaded
|
||||
if (window.toastui && window.toastui.Editor) {
|
||||
console.log('Toast UI Editor found, initializing...');
|
||||
setTimeout(initEditor, 50);
|
||||
} else {
|
||||
console.log('Toast UI Editor not found, waiting...');
|
||||
// Wait a bit and try again
|
||||
setTimeout(function() {
|
||||
if (window.toastui && window.toastui.Editor) {
|
||||
console.log('Toast UI Editor found after wait, initializing...');
|
||||
initEditor();
|
||||
} else {
|
||||
console.warn('Toast UI Editor not loaded, using fallback');
|
||||
initEditor(); // Will use fallback
|
||||
}
|
||||
}, 500);
|
||||
}
|
||||
}
|
||||
|
||||
waitForEditorAndInit();
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -390,6 +390,11 @@
|
||||
<i class="fas fa-envelope w-4 mr-2"></i>{{ _('Email Configuration') }}
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a class="block px-2 py-1 rounded {% if ep.startswith('admin.') and ('email_template' in ep or 'email-templates' in request.path) %}text-primary font-semibold bg-background-light dark:bg-background-dark{% else %}text-text-light dark:text-text-dark hover:bg-background-light dark:hover:bg-background-dark{% endif %}" href="{{ url_for('admin.list_email_templates') }}">
|
||||
<i class="fas fa-envelope-open-text w-4 mr-2"></i>{{ _('Email Templates') }}
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a class="block px-2 py-1 rounded {% if ep == 'admin.pdf_layout' %}text-primary font-semibold bg-background-light dark:bg-background-dark{% else %}text-text-light dark:text-text-dark hover:bg-background-light dark:hover:bg-background-dark{% endif %}" href="{{ url_for('admin.pdf_layout') }}">
|
||||
<i class="fas fa-file-pdf w-4 mr-2"></i>{{ _('PDF Layout') }}
|
||||
|
||||
@@ -279,6 +279,64 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Email History Section -->
|
||||
{% if email_history|length > 0 %}
|
||||
<div class="mt-6 bg-card-light dark:bg-card-dark p-6 rounded-lg shadow">
|
||||
<h2 class="text-lg font-semibold mb-4 flex items-center">
|
||||
<i class="fas fa-envelope mr-2"></i>Email History
|
||||
</h2>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full text-left">
|
||||
<thead>
|
||||
<tr class="border-b border-border-light dark:border-border-dark">
|
||||
<th class="p-2 text-sm font-medium">Sent At</th>
|
||||
<th class="p-2 text-sm font-medium">Recipient</th>
|
||||
<th class="p-2 text-sm font-medium">Status</th>
|
||||
<th class="p-2 text-sm font-medium">Subject</th>
|
||||
<th class="p-2 text-sm font-medium">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for email in email_history %}
|
||||
<tr class="border-b border-border-light dark:border-border-dark">
|
||||
<td class="p-2 text-sm">
|
||||
{{ email.sent_at.strftime('%Y-%m-%d %H:%M:%S') if email.sent_at else 'N/A' }}
|
||||
</td>
|
||||
<td class="p-2 text-sm">{{ email.recipient_email }}</td>
|
||||
<td class="p-2 text-sm">
|
||||
<span class="px-2 py-1 rounded text-xs font-medium
|
||||
{% if email.status == 'sent' %}bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200
|
||||
{% elif email.status == 'opened' %}bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200
|
||||
{% elif email.status == 'paid' %}bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-200
|
||||
{% elif email.status == 'failed' %}bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200
|
||||
{% elif email.status == 'bounced' %}bg-orange-100 text-orange-800 dark:bg-orange-900 dark:text-orange-200
|
||||
{% else %}bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-200
|
||||
{% endif %}">
|
||||
{{ email.status|title }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="p-2 text-sm">{{ email.subject }}</td>
|
||||
<td class="p-2 text-sm">
|
||||
<button onclick="resendInvoiceEmail({{ invoice.id }}, {{ email.id }}, '{{ email.recipient_email }}')"
|
||||
class="text-blue-600 dark:text-blue-400 hover:text-blue-800 dark:hover:text-blue-300 text-sm font-medium">
|
||||
<i class="fas fa-redo mr-1"></i>Resend
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
{% if email.error_message %}
|
||||
<tr class="bg-red-50 dark:bg-red-900/20">
|
||||
<td colspan="5" class="p-2 text-sm text-red-600 dark:text-red-400">
|
||||
<i class="fas fa-exclamation-circle mr-1"></i>Error: {{ email.error_message }}
|
||||
</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Send Email Modal -->
|
||||
<div id="sendEmailModal" class="hidden fixed inset-0 bg-gray-600 bg-opacity-50 overflow-y-auto h-full w-full z-50">
|
||||
<div class="relative top-20 mx-auto p-5 border w-96 shadow-lg rounded-md bg-white dark:bg-gray-800">
|
||||
@@ -409,5 +467,95 @@ function hideDeleteModal() {
|
||||
const modal = document.getElementById('deleteInvoiceModal');
|
||||
if (modal) modal.classList.add('hidden');
|
||||
}
|
||||
|
||||
function resendInvoiceEmail(invoiceId, emailId, recipientEmail) {
|
||||
if (!confirm(`Resend invoice email to ${recipientEmail}?`)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Show the send email modal with pre-filled recipient
|
||||
const modal = document.getElementById('sendEmailModal');
|
||||
const recipientInput = document.getElementById('recipient_email');
|
||||
if (modal && recipientInput) {
|
||||
recipientInput.value = recipientEmail;
|
||||
modal.classList.remove('hidden');
|
||||
|
||||
// Override form submission to use resend endpoint
|
||||
const form = document.getElementById('sendEmailForm');
|
||||
if (form) {
|
||||
const originalOnSubmit = form.onsubmit;
|
||||
form.onsubmit = function(event) {
|
||||
event.preventDefault();
|
||||
const formData = new FormData(form);
|
||||
|
||||
// Show loading state
|
||||
const submitBtn = form.querySelector('button[type="submit"]');
|
||||
const originalText = submitBtn.textContent;
|
||||
submitBtn.disabled = true;
|
||||
submitBtn.textContent = 'Resending...';
|
||||
|
||||
fetch(`/invoices/${invoiceId}/resend-email/${emailId}`, {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
})
|
||||
.then(async response => {
|
||||
const contentType = response.headers.get('content-type') || '';
|
||||
const isJson = contentType.includes('application/json');
|
||||
|
||||
if (isJson) {
|
||||
const data = await response.json();
|
||||
if (!response.ok) {
|
||||
return { success: false, error: data.error || data.message || `HTTP ${response.status}: ${response.statusText}` };
|
||||
}
|
||||
return data;
|
||||
} else {
|
||||
const text = await response.text();
|
||||
if (!response.ok) {
|
||||
throw new Error(text || `HTTP ${response.status}: ${response.statusText}`);
|
||||
}
|
||||
return { success: false, error: text || 'Unexpected response format' };
|
||||
}
|
||||
})
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
if (window.toastManager) {
|
||||
window.toastManager.success('Invoice email resent successfully!');
|
||||
} else {
|
||||
alert('Invoice email resent successfully!');
|
||||
}
|
||||
hideSendEmailModal();
|
||||
setTimeout(() => window.location.reload(), 1000);
|
||||
} else {
|
||||
const errorMsg = data.error || 'Failed to resend email';
|
||||
console.error('Email resend error:', errorMsg);
|
||||
if (window.toastManager) {
|
||||
window.toastManager.error('Error: ' + errorMsg);
|
||||
} else {
|
||||
alert('Error: ' + errorMsg);
|
||||
}
|
||||
submitBtn.disabled = false;
|
||||
submitBtn.textContent = originalText;
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Email resend error:', error);
|
||||
const errorMsg = error.message || 'Failed to resend email';
|
||||
if (window.toastManager) {
|
||||
window.toastManager.error('Error: ' + errorMsg);
|
||||
} else {
|
||||
alert('Error: ' + errorMsg);
|
||||
}
|
||||
submitBtn.disabled = false;
|
||||
submitBtn.textContent = originalText;
|
||||
});
|
||||
|
||||
// Restore original handler after use
|
||||
setTimeout(() => {
|
||||
form.onsubmit = originalOnSubmit;
|
||||
}, 100);
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
466
tests/test_invoice_email.py
Normal file
466
tests/test_invoice_email.py
Normal file
@@ -0,0 +1,466 @@
|
||||
"""
|
||||
Tests for invoice email sending functionality
|
||||
"""
|
||||
import pytest
|
||||
from unittest.mock import patch, MagicMock
|
||||
from datetime import datetime, date, timedelta
|
||||
from decimal import Decimal
|
||||
from flask import current_app
|
||||
from app import db
|
||||
from app.models import Invoice, InvoiceEmail, User, Settings, Client, Project
|
||||
from app.utils.email import send_invoice_email
|
||||
from factories import UserFactory, ClientFactory, ProjectFactory, InvoiceFactory, InvoiceItemFactory
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def test_user(app):
|
||||
"""Create a test user"""
|
||||
user = UserFactory(username='testuser', role='user')
|
||||
db.session.add(user)
|
||||
db.session.commit()
|
||||
return user
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def test_client(app):
|
||||
"""Create a test client"""
|
||||
client = ClientFactory(name='Test Client', email='client@test.com')
|
||||
db.session.commit()
|
||||
return client
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def test_project(app, test_client):
|
||||
"""Create a test project"""
|
||||
project = ProjectFactory(
|
||||
name='Test Project',
|
||||
client_id=test_client.id,
|
||||
billable=True,
|
||||
hourly_rate=Decimal('100.00')
|
||||
)
|
||||
db.session.commit()
|
||||
return project
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def test_invoice(app, test_user, test_project, test_client):
|
||||
"""Create a test invoice with items"""
|
||||
invoice = InvoiceFactory(
|
||||
invoice_number='INV-2024-001',
|
||||
project_id=test_project.id,
|
||||
client_id=test_client.id,
|
||||
client_name=test_client.name,
|
||||
client_email=test_client.email,
|
||||
due_date=date.today() + timedelta(days=30),
|
||||
created_by=test_user.id,
|
||||
status='draft',
|
||||
subtotal=Decimal('1000.00'),
|
||||
tax_rate=Decimal('20.00'),
|
||||
tax_amount=Decimal('200.00'),
|
||||
total_amount=Decimal('1200.00'),
|
||||
currency_code='EUR'
|
||||
)
|
||||
db.session.commit()
|
||||
|
||||
# Add invoice item
|
||||
item = InvoiceItemFactory(
|
||||
invoice_id=invoice.id,
|
||||
description='Test Service',
|
||||
quantity=Decimal('10.00'),
|
||||
unit_price=Decimal('100.00'),
|
||||
total_amount=Decimal('1000.00')
|
||||
)
|
||||
db.session.commit()
|
||||
|
||||
return invoice
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_pdf_generator():
|
||||
"""Mock PDF generator"""
|
||||
with patch('app.utils.email.InvoicePDFGenerator') as mock_gen:
|
||||
mock_instance = MagicMock()
|
||||
mock_instance.generate_pdf.return_value = b'fake_pdf_bytes'
|
||||
mock_gen.return_value = mock_instance
|
||||
yield mock_instance
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def mock_mail_send():
|
||||
"""Mock mail.send"""
|
||||
with patch('app.utils.email.mail.send') as mock_send:
|
||||
yield mock_send
|
||||
|
||||
|
||||
class TestSendInvoiceEmail:
|
||||
"""Tests for send_invoice_email function"""
|
||||
|
||||
def test_send_invoice_email_success(self, app, test_invoice, test_user, mock_pdf_generator, mock_mail_send):
|
||||
"""Test successfully sending an invoice email"""
|
||||
with app.app_context():
|
||||
# Configure mail server
|
||||
current_app.config['MAIL_SERVER'] = 'smtp.test.com'
|
||||
current_app.config['MAIL_DEFAULT_SENDER'] = 'noreply@test.com'
|
||||
|
||||
success, invoice_email, message = send_invoice_email(
|
||||
invoice=test_invoice,
|
||||
recipient_email='client@test.com',
|
||||
sender_user=test_user
|
||||
)
|
||||
|
||||
assert success is True
|
||||
assert invoice_email is not None
|
||||
assert invoice_email.recipient_email == 'client@test.com'
|
||||
assert invoice_email.invoice_id == test_invoice.id
|
||||
assert invoice_email.sent_by == test_user.id
|
||||
assert invoice_email.status == 'sent'
|
||||
assert 'successfully' in message.lower()
|
||||
assert mock_mail_send.called
|
||||
|
||||
# Verify invoice status was updated
|
||||
db.session.refresh(test_invoice)
|
||||
assert test_invoice.status == 'sent'
|
||||
|
||||
def test_send_invoice_email_with_custom_message(self, app, test_invoice, test_user, mock_pdf_generator, mock_mail_send):
|
||||
"""Test sending invoice email with custom message"""
|
||||
with app.app_context():
|
||||
current_app.config['MAIL_SERVER'] = 'smtp.test.com'
|
||||
current_app.config['MAIL_DEFAULT_SENDER'] = 'noreply@test.com'
|
||||
|
||||
custom_message = "Thank you for your business!"
|
||||
success, invoice_email, message = send_invoice_email(
|
||||
invoice=test_invoice,
|
||||
recipient_email='client@test.com',
|
||||
sender_user=test_user,
|
||||
custom_message=custom_message
|
||||
)
|
||||
|
||||
assert success is True
|
||||
assert invoice_email is not None
|
||||
# Verify the message was sent (check mail.send was called with message containing custom text)
|
||||
assert mock_mail_send.called
|
||||
|
||||
def test_send_invoice_email_pdf_generation_failure(self, app, test_invoice, test_user, mock_mail_send):
|
||||
"""Test handling PDF generation failure"""
|
||||
with app.app_context():
|
||||
current_app.config['MAIL_SERVER'] = 'smtp.test.com'
|
||||
current_app.config['MAIL_DEFAULT_SENDER'] = 'noreply@test.com'
|
||||
|
||||
# Mock PDF generator to fail
|
||||
with patch('app.utils.email.InvoicePDFGenerator') as mock_gen:
|
||||
mock_instance = MagicMock()
|
||||
mock_instance.generate_pdf.side_effect = Exception("PDF generation failed")
|
||||
mock_gen.return_value = mock_instance
|
||||
|
||||
# Mock fallback generator to also fail
|
||||
with patch('app.utils.email.InvoicePDFGeneratorFallback') as mock_fallback:
|
||||
mock_fallback_instance = MagicMock()
|
||||
mock_fallback_instance.generate_pdf.side_effect = Exception("Fallback failed")
|
||||
mock_fallback.return_value = mock_fallback_instance
|
||||
|
||||
success, invoice_email, message = send_invoice_email(
|
||||
invoice=test_invoice,
|
||||
recipient_email='client@test.com',
|
||||
sender_user=test_user
|
||||
)
|
||||
|
||||
assert success is False
|
||||
assert invoice_email is None
|
||||
assert 'pdf generation failed' in message.lower() or 'failed' in message.lower()
|
||||
|
||||
def test_send_invoice_email_no_mail_server(self, app, test_invoice, test_user, mock_pdf_generator):
|
||||
"""Test sending email when mail server is not configured"""
|
||||
with app.app_context():
|
||||
current_app.config['MAIL_SERVER'] = None
|
||||
|
||||
success, invoice_email, message = send_invoice_email(
|
||||
invoice=test_invoice,
|
||||
recipient_email='client@test.com',
|
||||
sender_user=test_user
|
||||
)
|
||||
|
||||
# Should still attempt to send but may fail gracefully
|
||||
# The function should handle this case
|
||||
assert invoice_email is not None or success is False
|
||||
|
||||
def test_send_invoice_email_creates_tracking_record(self, app, test_invoice, test_user, mock_pdf_generator, mock_mail_send):
|
||||
"""Test that email tracking record is created"""
|
||||
with app.app_context():
|
||||
current_app.config['MAIL_SERVER'] = 'smtp.test.com'
|
||||
current_app.config['MAIL_DEFAULT_SENDER'] = 'noreply@test.com'
|
||||
|
||||
# Count existing records
|
||||
initial_count = InvoiceEmail.query.filter_by(invoice_id=test_invoice.id).count()
|
||||
|
||||
success, invoice_email, message = send_invoice_email(
|
||||
invoice=test_invoice,
|
||||
recipient_email='client@test.com',
|
||||
sender_user=test_user
|
||||
)
|
||||
|
||||
assert success is True
|
||||
|
||||
# Verify record was created
|
||||
final_count = InvoiceEmail.query.filter_by(invoice_id=test_invoice.id).count()
|
||||
assert final_count == initial_count + 1
|
||||
|
||||
# Verify record details
|
||||
assert invoice_email.recipient_email == 'client@test.com'
|
||||
assert invoice_email.invoice_id == test_invoice.id
|
||||
assert invoice_email.sent_by == test_user.id
|
||||
|
||||
def test_send_invoice_email_updates_draft_status(self, app, test_invoice, test_user, mock_pdf_generator, mock_mail_send):
|
||||
"""Test that draft invoice status is updated to 'sent'"""
|
||||
with app.app_context():
|
||||
current_app.config['MAIL_SERVER'] = 'smtp.test.com'
|
||||
current_app.config['MAIL_DEFAULT_SENDER'] = 'noreply@test.com'
|
||||
|
||||
# Ensure invoice is in draft status
|
||||
test_invoice.status = 'draft'
|
||||
db.session.commit()
|
||||
|
||||
success, invoice_email, message = send_invoice_email(
|
||||
invoice=test_invoice,
|
||||
recipient_email='client@test.com',
|
||||
sender_user=test_user
|
||||
)
|
||||
|
||||
assert success is True
|
||||
|
||||
# Verify status was updated
|
||||
db.session.refresh(test_invoice)
|
||||
assert test_invoice.status == 'sent'
|
||||
|
||||
def test_send_invoice_email_does_not_update_non_draft_status(self, app, test_invoice, test_user, mock_pdf_generator, mock_mail_send):
|
||||
"""Test that non-draft invoice status is not changed"""
|
||||
with app.app_context():
|
||||
current_app.config['MAIL_SERVER'] = 'smtp.test.com'
|
||||
current_app.config['MAIL_DEFAULT_SENDER'] = 'noreply@test.com'
|
||||
|
||||
# Set invoice to 'sent' status
|
||||
test_invoice.status = 'sent'
|
||||
db.session.commit()
|
||||
|
||||
success, invoice_email, message = send_invoice_email(
|
||||
invoice=test_invoice,
|
||||
recipient_email='client@test.com',
|
||||
sender_user=test_user
|
||||
)
|
||||
|
||||
assert success is True
|
||||
|
||||
# Verify status remained 'sent'
|
||||
db.session.refresh(test_invoice)
|
||||
assert test_invoice.status == 'sent'
|
||||
|
||||
def test_send_invoice_email_with_email_template(self, app, test_invoice, test_user, mock_pdf_generator, mock_mail_send):
|
||||
"""Test sending invoice email with custom email template"""
|
||||
with app.app_context():
|
||||
from app.models import InvoiceTemplate
|
||||
|
||||
current_app.config['MAIL_SERVER'] = 'smtp.test.com'
|
||||
current_app.config['MAIL_DEFAULT_SENDER'] = 'noreply@test.com'
|
||||
|
||||
# Create an email template
|
||||
template = InvoiceTemplate(
|
||||
name='Test Template',
|
||||
html='<html><body><h1>Invoice {{ invoice.invoice_number }}</h1></body></html>',
|
||||
css='body { color: black; }'
|
||||
)
|
||||
db.session.add(template)
|
||||
db.session.commit()
|
||||
|
||||
success, invoice_email, message = send_invoice_email(
|
||||
invoice=test_invoice,
|
||||
recipient_email='client@test.com',
|
||||
sender_user=test_user,
|
||||
email_template_id=template.id
|
||||
)
|
||||
|
||||
assert success is True
|
||||
assert invoice_email is not None
|
||||
assert mock_mail_send.called
|
||||
|
||||
def test_send_invoice_email_failure_creates_failed_record(self, app, test_invoice, test_user, mock_pdf_generator):
|
||||
"""Test that failed email sends create a failed tracking record"""
|
||||
with app.app_context():
|
||||
current_app.config['MAIL_SERVER'] = 'smtp.test.com'
|
||||
current_app.config['MAIL_DEFAULT_SENDER'] = 'noreply@test.com'
|
||||
|
||||
# Mock mail.send to raise an exception
|
||||
with patch('app.utils.email.mail.send') as mock_send:
|
||||
mock_send.side_effect = Exception("SMTP connection failed")
|
||||
|
||||
success, invoice_email, message = send_invoice_email(
|
||||
invoice=test_invoice,
|
||||
recipient_email='client@test.com',
|
||||
sender_user=test_user
|
||||
)
|
||||
|
||||
assert success is False
|
||||
# Should create a failed record
|
||||
failed_record = InvoiceEmail.query.filter_by(
|
||||
invoice_id=test_invoice.id,
|
||||
status='failed'
|
||||
).first()
|
||||
assert failed_record is not None
|
||||
assert failed_record.error_message is not None
|
||||
|
||||
|
||||
class TestInvoiceEmailRoutes:
|
||||
"""Tests for invoice email routes"""
|
||||
|
||||
def test_send_invoice_email_route_success(self, client, test_user, test_invoice, mock_pdf_generator, mock_mail_send):
|
||||
"""Test the send invoice email route"""
|
||||
with client.session_transaction() as sess:
|
||||
sess['_user_id'] = str(test_user.id)
|
||||
sess['_fresh'] = True
|
||||
|
||||
response = client.post(
|
||||
f'/invoices/{test_invoice.id}/send-email',
|
||||
data={
|
||||
'recipient_email': 'client@test.com',
|
||||
'csrf_token': 'test_token'
|
||||
}
|
||||
)
|
||||
|
||||
# Should return success (may need to handle CSRF token properly in test)
|
||||
assert response.status_code in [200, 400, 403] # 400/403 if CSRF fails
|
||||
|
||||
def test_get_invoice_email_history(self, client, test_user, test_invoice, mock_pdf_generator, mock_mail_send):
|
||||
"""Test getting invoice email history"""
|
||||
with client.session_transaction() as sess:
|
||||
sess['_user_id'] = str(test_user.id)
|
||||
sess['_fresh'] = True
|
||||
|
||||
# First send an email
|
||||
with client.application.app_context():
|
||||
from app.utils.email import send_invoice_email
|
||||
current_app.config['MAIL_SERVER'] = 'smtp.test.com'
|
||||
current_app.config['MAIL_DEFAULT_SENDER'] = 'noreply@test.com'
|
||||
|
||||
send_invoice_email(
|
||||
invoice=test_invoice,
|
||||
recipient_email='client@test.com',
|
||||
sender_user=test_user
|
||||
)
|
||||
|
||||
# Then get history
|
||||
response = client.get(f'/invoices/{test_invoice.id}/email-history')
|
||||
|
||||
# Should return success (may need to handle authentication properly)
|
||||
assert response.status_code in [200, 401, 403]
|
||||
|
||||
def test_resend_invoice_email_route(self, client, test_user, test_invoice, mock_pdf_generator, mock_mail_send):
|
||||
"""Test the resend invoice email route"""
|
||||
with client.session_transaction() as sess:
|
||||
sess['_user_id'] = str(test_user.id)
|
||||
sess['_fresh'] = True
|
||||
|
||||
# First send an email to create a record
|
||||
with client.application.app_context():
|
||||
from app.utils.email import send_invoice_email
|
||||
current_app.config['MAIL_SERVER'] = 'smtp.test.com'
|
||||
current_app.config['MAIL_DEFAULT_SENDER'] = 'noreply@test.com'
|
||||
|
||||
success, invoice_email, _ = send_invoice_email(
|
||||
invoice=test_invoice,
|
||||
recipient_email='client@test.com',
|
||||
sender_user=test_user
|
||||
)
|
||||
|
||||
if success and invoice_email:
|
||||
# Then resend it
|
||||
response = client.post(
|
||||
f'/invoices/{test_invoice.id}/resend-email/{invoice_email.id}',
|
||||
data={
|
||||
'recipient_email': 'client@test.com',
|
||||
'csrf_token': 'test_token'
|
||||
}
|
||||
)
|
||||
|
||||
# Should return success (may need to handle CSRF token properly)
|
||||
assert response.status_code in [200, 400, 403]
|
||||
|
||||
|
||||
class TestInvoiceEmailModel:
|
||||
"""Tests for InvoiceEmail model"""
|
||||
|
||||
def test_invoice_email_creation(self, app, test_invoice, test_user):
|
||||
"""Test creating an InvoiceEmail record"""
|
||||
with app.app_context():
|
||||
invoice_email = InvoiceEmail(
|
||||
invoice_id=test_invoice.id,
|
||||
recipient_email='client@test.com',
|
||||
subject='Test Invoice',
|
||||
sent_by=test_user.id
|
||||
)
|
||||
db.session.add(invoice_email)
|
||||
db.session.commit()
|
||||
|
||||
assert invoice_email.id is not None
|
||||
assert invoice_email.invoice_id == test_invoice.id
|
||||
assert invoice_email.recipient_email == 'client@test.com'
|
||||
assert invoice_email.status == 'sent'
|
||||
assert invoice_email.sent_at is not None
|
||||
|
||||
def test_invoice_email_mark_opened(self, app, test_invoice, test_user):
|
||||
"""Test marking email as opened"""
|
||||
with app.app_context():
|
||||
invoice_email = InvoiceEmail(
|
||||
invoice_id=test_invoice.id,
|
||||
recipient_email='client@test.com',
|
||||
subject='Test Invoice',
|
||||
sent_by=test_user.id
|
||||
)
|
||||
db.session.add(invoice_email)
|
||||
db.session.commit()
|
||||
|
||||
invoice_email.mark_opened()
|
||||
db.session.commit()
|
||||
|
||||
assert invoice_email.status == 'opened'
|
||||
assert invoice_email.opened_at is not None
|
||||
assert invoice_email.opened_count == 1
|
||||
|
||||
def test_invoice_email_mark_failed(self, app, test_invoice, test_user):
|
||||
"""Test marking email as failed"""
|
||||
with app.app_context():
|
||||
invoice_email = InvoiceEmail(
|
||||
invoice_id=test_invoice.id,
|
||||
recipient_email='client@test.com',
|
||||
subject='Test Invoice',
|
||||
sent_by=test_user.id
|
||||
)
|
||||
db.session.add(invoice_email)
|
||||
db.session.commit()
|
||||
|
||||
error_message = "SMTP connection failed"
|
||||
invoice_email.mark_failed(error_message)
|
||||
db.session.commit()
|
||||
|
||||
assert invoice_email.status == 'failed'
|
||||
assert invoice_email.error_message == error_message
|
||||
|
||||
def test_invoice_email_to_dict(self, app, test_invoice, test_user):
|
||||
"""Test converting InvoiceEmail to dictionary"""
|
||||
with app.app_context():
|
||||
invoice_email = InvoiceEmail(
|
||||
invoice_id=test_invoice.id,
|
||||
recipient_email='client@test.com',
|
||||
subject='Test Invoice',
|
||||
sent_by=test_user.id
|
||||
)
|
||||
db.session.add(invoice_email)
|
||||
db.session.commit()
|
||||
|
||||
email_dict = invoice_email.to_dict()
|
||||
|
||||
assert isinstance(email_dict, dict)
|
||||
assert email_dict['invoice_id'] == test_invoice.id
|
||||
assert email_dict['recipient_email'] == 'client@test.com'
|
||||
assert email_dict['subject'] == 'Test Invoice'
|
||||
assert email_dict['status'] == 'sent'
|
||||
assert 'sent_at' in email_dict
|
||||
assert 'created_at' in email_dict
|
||||
|
||||
Reference in New Issue
Block a user