mirror of
https://github.com/DRYTRIX/TimeTracker.git
synced 2026-01-27 23:19:42 -06:00
Merge pull request #251 from DRYTRIX/Feat-InvoiceUpdate
feat: Add recurring invoices and email integration with template mana…
This commit is contained in:
@@ -759,6 +759,7 @@ def create_app(config=None):
|
||||
from app.routes.analytics import analytics_bp
|
||||
from app.routes.tasks import tasks_bp
|
||||
from app.routes.invoices import invoices_bp
|
||||
from app.routes.recurring_invoices import recurring_invoices_bp
|
||||
from app.routes.payments import payments_bp
|
||||
from app.routes.clients import clients_bp
|
||||
from app.routes.client_notes import client_notes_bp
|
||||
@@ -801,6 +802,7 @@ def create_app(config=None):
|
||||
app.register_blueprint(analytics_bp)
|
||||
app.register_blueprint(tasks_bp)
|
||||
app.register_blueprint(invoices_bp)
|
||||
app.register_blueprint(recurring_invoices_bp)
|
||||
app.register_blueprint(payments_bp)
|
||||
app.register_blueprint(clients_bp)
|
||||
app.register_blueprint(client_notes_bp)
|
||||
|
||||
@@ -36,6 +36,8 @@ from .budget_alert import BudgetAlert
|
||||
from .import_export import DataImport, DataExport
|
||||
from .invoice_pdf_template import InvoicePDFTemplate
|
||||
from .audit_log import AuditLog
|
||||
from .recurring_invoice import RecurringInvoice
|
||||
from .invoice_email import InvoiceEmail
|
||||
|
||||
__all__ = [
|
||||
"User",
|
||||
@@ -79,4 +81,6 @@ __all__ = [
|
||||
"InvoicePDFTemplate",
|
||||
"ClientPrepaidConsumption",
|
||||
"AuditLog",
|
||||
"RecurringInvoice",
|
||||
"InvoiceEmail",
|
||||
]
|
||||
|
||||
@@ -28,6 +28,7 @@ class Invoice(db.Model):
|
||||
total_amount = db.Column(db.Numeric(10, 2), nullable=False, default=0)
|
||||
currency_code = db.Column(db.String(3), nullable=False, default='EUR')
|
||||
template_id = db.Column(db.Integer, db.ForeignKey('invoice_templates.id'), nullable=True, index=True)
|
||||
recurring_invoice_id = db.Column(db.Integer, db.ForeignKey('recurring_invoices.id'), nullable=True, index=True)
|
||||
|
||||
# Notes and terms
|
||||
notes = db.Column(db.Text, nullable=True)
|
||||
|
||||
94
app/models/invoice_email.py
Normal file
94
app/models/invoice_email.py
Normal file
@@ -0,0 +1,94 @@
|
||||
from datetime import datetime
|
||||
from app import db
|
||||
|
||||
|
||||
class InvoiceEmail(db.Model):
|
||||
"""Model for tracking invoice emails sent to clients"""
|
||||
|
||||
__tablename__ = 'invoice_emails'
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
invoice_id = db.Column(db.Integer, db.ForeignKey('invoices.id'), nullable=False, index=True)
|
||||
|
||||
# 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_by = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
|
||||
|
||||
# Tracking
|
||||
opened_at = db.Column(db.DateTime, nullable=True) # When email was opened (if tracked)
|
||||
opened_count = db.Column(db.Integer, nullable=False, default=0) # Number of times opened
|
||||
last_opened_at = db.Column(db.DateTime, nullable=True) # Last time email was opened
|
||||
|
||||
# Payment tracking
|
||||
paid_at = db.Column(db.DateTime, nullable=True) # When invoice was marked as paid (if after email)
|
||||
|
||||
# Status
|
||||
status = db.Column(db.String(20), nullable=False, default='sent') # 'sent', 'opened', 'paid', 'bounced', 'failed'
|
||||
|
||||
# Error tracking
|
||||
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)
|
||||
|
||||
# Relationships
|
||||
invoice = db.relationship('Invoice', backref='email_records')
|
||||
sender = db.relationship('User', backref='sent_invoice_emails')
|
||||
|
||||
def __init__(self, invoice_id, recipient_email, subject, sent_by, **kwargs):
|
||||
self.invoice_id = invoice_id
|
||||
self.recipient_email = recipient_email
|
||||
self.subject = subject
|
||||
self.sent_by = sent_by
|
||||
self.status = kwargs.get('status', 'sent')
|
||||
self.error_message = kwargs.get('error_message')
|
||||
|
||||
def __repr__(self):
|
||||
return f'<InvoiceEmail {self.invoice_id} -> {self.recipient_email} ({self.status})>'
|
||||
|
||||
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_count += 1
|
||||
if self.status == 'sent':
|
||||
self.status = 'opened'
|
||||
|
||||
def mark_paid(self):
|
||||
"""Mark invoice as paid (after email was sent)"""
|
||||
if not self.paid_at:
|
||||
self.paid_at = datetime.utcnow()
|
||||
self.status = 'paid'
|
||||
|
||||
def mark_failed(self, error_message):
|
||||
"""Mark email send as failed"""
|
||||
self.status = 'failed'
|
||||
self.error_message = error_message
|
||||
|
||||
def mark_bounced(self):
|
||||
"""Mark email as bounced"""
|
||||
self.status = 'bounced'
|
||||
|
||||
def to_dict(self):
|
||||
"""Convert invoice email to dictionary"""
|
||||
return {
|
||||
'id': self.id,
|
||||
'invoice_id': self.invoice_id,
|
||||
'recipient_email': self.recipient_email,
|
||||
'subject': self.subject,
|
||||
'sent_at': self.sent_at.isoformat() if self.sent_at else None,
|
||||
'sent_by': self.sent_by,
|
||||
'opened_at': self.opened_at.isoformat() if self.opened_at else None,
|
||||
'opened_count': self.opened_count,
|
||||
'last_opened_at': self.last_opened_at.isoformat() if self.last_opened_at else None,
|
||||
'paid_at': self.paid_at.isoformat() if self.paid_at else None,
|
||||
'status': self.status,
|
||||
'error_message': self.error_message,
|
||||
'created_at': self.created_at.isoformat() if self.created_at else None,
|
||||
'updated_at': self.updated_at.isoformat() if self.updated_at else None
|
||||
}
|
||||
|
||||
245
app/models/recurring_invoice.py
Normal file
245
app/models/recurring_invoice.py
Normal file
@@ -0,0 +1,245 @@
|
||||
from datetime import datetime, timedelta
|
||||
from decimal import Decimal
|
||||
from dateutil.relativedelta import relativedelta
|
||||
from app import db
|
||||
|
||||
|
||||
class RecurringInvoice(db.Model):
|
||||
"""Recurring invoice template model for automated billing"""
|
||||
|
||||
__tablename__ = 'recurring_invoices'
|
||||
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
name = db.Column(db.String(200), nullable=False) # Template name/description
|
||||
project_id = db.Column(db.Integer, db.ForeignKey('projects.id'), nullable=False, index=True)
|
||||
client_id = db.Column(db.Integer, db.ForeignKey('clients.id'), nullable=False, index=True)
|
||||
|
||||
# Recurrence settings
|
||||
frequency = db.Column(db.String(20), nullable=False) # 'daily', 'weekly', 'monthly', 'yearly'
|
||||
interval = db.Column(db.Integer, nullable=False, default=1) # Every N periods (e.g., every 2 weeks)
|
||||
next_run_date = db.Column(db.Date, nullable=False) # Next date to generate invoice
|
||||
end_date = db.Column(db.Date, nullable=True) # Optional end date for recurrence
|
||||
|
||||
# Invoice template settings (copied to generated invoices)
|
||||
client_name = db.Column(db.String(200), nullable=False)
|
||||
client_email = db.Column(db.String(200), nullable=True)
|
||||
client_address = db.Column(db.Text, nullable=True)
|
||||
due_date_days = db.Column(db.Integer, nullable=False, default=30) # Days from issue date to due date
|
||||
tax_rate = db.Column(db.Numeric(5, 2), nullable=False, default=0)
|
||||
currency_code = db.Column(db.String(3), nullable=False, default='EUR')
|
||||
notes = db.Column(db.Text, nullable=True)
|
||||
terms = db.Column(db.Text, nullable=True)
|
||||
template_id = db.Column(db.Integer, db.ForeignKey('invoice_templates.id'), nullable=True, index=True)
|
||||
|
||||
# Auto-send settings
|
||||
auto_send = db.Column(db.Boolean, nullable=False, default=False) # Automatically send via email when generated
|
||||
auto_include_time_entries = db.Column(db.Boolean, nullable=False, default=True) # Include unbilled time entries
|
||||
|
||||
# Status
|
||||
is_active = db.Column(db.Boolean, nullable=False, default=True)
|
||||
|
||||
# Metadata
|
||||
created_by = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False)
|
||||
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
|
||||
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
|
||||
last_generated_at = db.Column(db.DateTime, nullable=True) # Last time an invoice was generated
|
||||
|
||||
# Relationships
|
||||
project = db.relationship('Project', backref='recurring_invoices')
|
||||
client = db.relationship('Client', backref='recurring_invoices')
|
||||
creator = db.relationship('User', backref='created_recurring_invoices')
|
||||
template = db.relationship('InvoiceTemplate', backref='recurring_invoices')
|
||||
generated_invoices = db.relationship('Invoice', backref='recurring_invoice_template', lazy='dynamic', foreign_keys='[Invoice.recurring_invoice_id]')
|
||||
|
||||
def __init__(self, name, project_id, client_id, frequency, next_run_date, created_by, **kwargs):
|
||||
self.name = name
|
||||
self.project_id = project_id
|
||||
self.client_id = client_id
|
||||
self.frequency = frequency
|
||||
self.next_run_date = next_run_date
|
||||
self.created_by = created_by
|
||||
|
||||
# Set optional fields
|
||||
self.interval = kwargs.get('interval', 1)
|
||||
self.end_date = kwargs.get('end_date')
|
||||
self.client_name = kwargs.get('client_name', '')
|
||||
self.client_email = kwargs.get('client_email')
|
||||
self.client_address = kwargs.get('client_address')
|
||||
self.due_date_days = kwargs.get('due_date_days', 30)
|
||||
self.tax_rate = Decimal(str(kwargs.get('tax_rate', 0)))
|
||||
self.currency_code = kwargs.get('currency_code', 'EUR')
|
||||
self.notes = kwargs.get('notes')
|
||||
self.terms = kwargs.get('terms')
|
||||
self.template_id = kwargs.get('template_id')
|
||||
self.auto_send = kwargs.get('auto_send', False)
|
||||
self.auto_include_time_entries = kwargs.get('auto_include_time_entries', True)
|
||||
self.is_active = kwargs.get('is_active', True)
|
||||
|
||||
def __repr__(self):
|
||||
return f'<RecurringInvoice {self.name} ({self.frequency})>'
|
||||
|
||||
def calculate_next_run_date(self, from_date=None):
|
||||
"""Calculate the next run date based on frequency and interval"""
|
||||
if from_date is None:
|
||||
from_date = datetime.utcnow().date()
|
||||
|
||||
if self.frequency == 'daily':
|
||||
return from_date + timedelta(days=self.interval)
|
||||
elif self.frequency == 'weekly':
|
||||
return from_date + timedelta(weeks=self.interval)
|
||||
elif self.frequency == 'monthly':
|
||||
return from_date + relativedelta(months=self.interval)
|
||||
elif self.frequency == 'yearly':
|
||||
return from_date + relativedelta(years=self.interval)
|
||||
else:
|
||||
raise ValueError(f"Invalid frequency: {self.frequency}")
|
||||
|
||||
def should_generate_today(self):
|
||||
"""Check if invoice should be generated today"""
|
||||
if not self.is_active:
|
||||
return False
|
||||
|
||||
today = datetime.utcnow().date()
|
||||
|
||||
# Check if we've reached the end date
|
||||
if self.end_date and today > self.end_date:
|
||||
return False
|
||||
|
||||
# Check if it's time to generate
|
||||
return today >= self.next_run_date
|
||||
|
||||
def generate_invoice(self):
|
||||
"""Generate an invoice from this recurring template"""
|
||||
from app.models import Invoice, InvoiceItem, TimeEntry, Settings
|
||||
|
||||
if not self.should_generate_today():
|
||||
return None
|
||||
|
||||
# Get settings for currency
|
||||
settings = Settings.get_settings()
|
||||
currency_code = self.currency_code or (settings.currency if settings else 'EUR')
|
||||
|
||||
# Calculate dates
|
||||
issue_date = datetime.utcnow().date()
|
||||
due_date = issue_date + timedelta(days=self.due_date_days)
|
||||
|
||||
# Generate invoice number
|
||||
invoice_number = Invoice.generate_invoice_number()
|
||||
|
||||
# Create invoice
|
||||
invoice = Invoice(
|
||||
invoice_number=invoice_number,
|
||||
project_id=self.project_id,
|
||||
client_name=self.client_name,
|
||||
due_date=due_date,
|
||||
created_by=self.created_by,
|
||||
client_id=self.client_id,
|
||||
client_email=self.client_email,
|
||||
client_address=self.client_address,
|
||||
tax_rate=self.tax_rate,
|
||||
notes=self.notes,
|
||||
terms=self.terms,
|
||||
currency_code=currency_code,
|
||||
template_id=self.template_id,
|
||||
issue_date=issue_date
|
||||
)
|
||||
|
||||
# Link to recurring invoice template
|
||||
invoice.recurring_invoice_id = self.id
|
||||
|
||||
db.session.add(invoice)
|
||||
|
||||
# Auto-include time entries if enabled
|
||||
if self.auto_include_time_entries:
|
||||
# Get unbilled time entries for this project
|
||||
time_entries = TimeEntry.query.filter(
|
||||
TimeEntry.project_id == self.project_id,
|
||||
TimeEntry.end_time.isnot(None),
|
||||
TimeEntry.billable == True
|
||||
).order_by(TimeEntry.start_time.desc()).all()
|
||||
|
||||
# Filter out entries already billed
|
||||
unbilled_entries = []
|
||||
for entry in time_entries:
|
||||
already_billed = False
|
||||
for other_invoice in self.project.invoices:
|
||||
if other_invoice.id != invoice.id:
|
||||
for item in other_invoice.items:
|
||||
if item.time_entry_ids and str(entry.id) in item.time_entry_ids.split(','):
|
||||
already_billed = True
|
||||
break
|
||||
if already_billed:
|
||||
break
|
||||
|
||||
if not already_billed:
|
||||
unbilled_entries.append(entry)
|
||||
|
||||
# Group and create invoice items
|
||||
if unbilled_entries:
|
||||
from app.models.rate_override import RateOverride
|
||||
grouped_entries = {}
|
||||
for entry in unbilled_entries:
|
||||
if entry.task_id:
|
||||
key = f"task_{entry.task_id}"
|
||||
description = f"Task: {entry.task.name if entry.task else 'Unknown Task'}"
|
||||
else:
|
||||
key = f"project_{entry.project_id}"
|
||||
description = f"Project: {entry.project.name}"
|
||||
|
||||
if key not in grouped_entries:
|
||||
grouped_entries[key] = {
|
||||
'description': description,
|
||||
'entries': [],
|
||||
'total_hours': Decimal('0'),
|
||||
}
|
||||
|
||||
grouped_entries[key]['entries'].append(entry)
|
||||
grouped_entries[key]['total_hours'] += entry.duration_hours
|
||||
|
||||
# Create invoice items
|
||||
hourly_rate = RateOverride.resolve_rate(self.project)
|
||||
for group in grouped_entries.values():
|
||||
if group['total_hours'] > 0:
|
||||
item = InvoiceItem(
|
||||
invoice_id=invoice.id,
|
||||
description=group['description'],
|
||||
quantity=group['total_hours'],
|
||||
unit_price=hourly_rate,
|
||||
time_entry_ids=','.join(str(e.id) for e in group['entries'])
|
||||
)
|
||||
db.session.add(item)
|
||||
|
||||
# Calculate totals
|
||||
invoice.calculate_totals()
|
||||
|
||||
# Update recurring invoice
|
||||
self.last_generated_at = datetime.utcnow()
|
||||
self.next_run_date = self.calculate_next_run_date(issue_date)
|
||||
|
||||
return invoice
|
||||
|
||||
def to_dict(self):
|
||||
"""Convert recurring invoice to dictionary"""
|
||||
return {
|
||||
'id': self.id,
|
||||
'name': self.name,
|
||||
'project_id': self.project_id,
|
||||
'client_id': self.client_id,
|
||||
'frequency': self.frequency,
|
||||
'interval': self.interval,
|
||||
'next_run_date': self.next_run_date.isoformat() if self.next_run_date else None,
|
||||
'end_date': self.end_date.isoformat() if self.end_date else None,
|
||||
'client_name': self.client_name,
|
||||
'client_email': self.client_email,
|
||||
'due_date_days': self.due_date_days,
|
||||
'tax_rate': float(self.tax_rate),
|
||||
'currency_code': self.currency_code,
|
||||
'auto_send': self.auto_send,
|
||||
'auto_include_time_entries': self.auto_include_time_entries,
|
||||
'is_active': self.is_active,
|
||||
'created_by': self.created_by,
|
||||
'created_at': self.created_at.isoformat() if self.created_at else None,
|
||||
'updated_at': self.updated_at.isoformat() if self.updated_at else None,
|
||||
'last_generated_at': self.last_generated_at.isoformat() if self.last_generated_at else None
|
||||
}
|
||||
|
||||
@@ -1560,3 +1560,155 @@ def get_email_config():
|
||||
'password_set': bool(settings.mail_password),
|
||||
'default_sender': settings.mail_default_sender or ''
|
||||
}), 200
|
||||
|
||||
|
||||
# ==================== Email Template Management ====================
|
||||
|
||||
@admin_bp.route('/admin/email-templates')
|
||||
@login_required
|
||||
@admin_or_permission_required('manage_settings')
|
||||
def list_email_templates():
|
||||
"""List all email templates"""
|
||||
from app.models import InvoiceTemplate
|
||||
|
||||
templates = InvoiceTemplate.query.order_by(InvoiceTemplate.name).all()
|
||||
|
||||
return render_template('admin/email_templates/list.html', templates=templates)
|
||||
|
||||
|
||||
@admin_bp.route('/admin/email-templates/create', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
@admin_or_permission_required('manage_settings')
|
||||
def create_email_template():
|
||||
"""Create a new email template"""
|
||||
from app.models import InvoiceTemplate
|
||||
|
||||
if request.method == 'POST':
|
||||
name = request.form.get('name', '').strip()
|
||||
description = request.form.get('description', '').strip()
|
||||
html = request.form.get('html', '').strip()
|
||||
css = request.form.get('css', '').strip()
|
||||
is_default = request.form.get('is_default') == 'on'
|
||||
|
||||
# Validate
|
||||
if not name:
|
||||
flash(_('Template name is required'), 'error')
|
||||
return render_template('admin/email_templates/create.html')
|
||||
|
||||
# Check for duplicate name
|
||||
existing = InvoiceTemplate.query.filter_by(name=name).first()
|
||||
if existing:
|
||||
flash(_('A template with this name already exists'), 'error')
|
||||
return render_template('admin/email_templates/create.html',
|
||||
name=name, description=description, html=html, css=css)
|
||||
|
||||
# If setting as default, unset other defaults
|
||||
if is_default:
|
||||
InvoiceTemplate.query.update({InvoiceTemplate.is_default: False})
|
||||
|
||||
# Create template
|
||||
template = InvoiceTemplate(
|
||||
name=name,
|
||||
description=description if description else None,
|
||||
html=html if html else None,
|
||||
css=css if css else None,
|
||||
is_default=is_default
|
||||
)
|
||||
|
||||
db.session.add(template)
|
||||
if not safe_commit('create_email_template', {'name': name}):
|
||||
flash(_('Could not create email template due to a database error.'), 'error')
|
||||
return render_template('admin/email_templates/create.html')
|
||||
|
||||
flash(_('Email template created successfully'), 'success')
|
||||
return redirect(url_for('admin.list_email_templates'))
|
||||
|
||||
return render_template('admin/email_templates/create.html')
|
||||
|
||||
|
||||
@admin_bp.route('/admin/email-templates/<int:template_id>')
|
||||
@login_required
|
||||
@admin_or_permission_required('manage_settings')
|
||||
def view_email_template(template_id):
|
||||
"""View email template details"""
|
||||
from app.models import InvoiceTemplate
|
||||
|
||||
template = InvoiceTemplate.query.get_or_404(template_id)
|
||||
|
||||
return render_template('admin/email_templates/view.html', template=template)
|
||||
|
||||
|
||||
@admin_bp.route('/admin/email-templates/<int:template_id>/edit', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
@admin_or_permission_required('manage_settings')
|
||||
def edit_email_template(template_id):
|
||||
"""Edit email template"""
|
||||
from app.models import InvoiceTemplate
|
||||
|
||||
template = InvoiceTemplate.query.get_or_404(template_id)
|
||||
|
||||
if request.method == 'POST':
|
||||
name = request.form.get('name', '').strip()
|
||||
description = request.form.get('description', '').strip()
|
||||
html = request.form.get('html', '').strip()
|
||||
css = request.form.get('css', '').strip()
|
||||
is_default = request.form.get('is_default') == 'on'
|
||||
|
||||
# Validate
|
||||
if not name:
|
||||
flash(_('Template name is required'), 'error')
|
||||
return render_template('admin/email_templates/edit.html', template=template)
|
||||
|
||||
# Check for duplicate name (excluding current template)
|
||||
existing = InvoiceTemplate.query.filter(
|
||||
InvoiceTemplate.name == name,
|
||||
InvoiceTemplate.id != template_id
|
||||
).first()
|
||||
if existing:
|
||||
flash(_('A template with this name already exists'), 'error')
|
||||
return render_template('admin/email_templates/edit.html', template=template)
|
||||
|
||||
# If setting as default, unset other defaults
|
||||
if is_default:
|
||||
InvoiceTemplate.query.filter(InvoiceTemplate.id != template_id).update({InvoiceTemplate.is_default: False})
|
||||
|
||||
# Update template
|
||||
template.name = name
|
||||
template.description = description if description else None
|
||||
template.html = html if html else None
|
||||
template.css = css if css else None
|
||||
template.is_default = is_default
|
||||
template.updated_at = datetime.utcnow()
|
||||
|
||||
if not safe_commit('edit_email_template', {'template_id': template_id}):
|
||||
flash(_('Could not update email template due to a database error.'), 'error')
|
||||
return render_template('admin/email_templates/edit.html', template=template)
|
||||
|
||||
flash(_('Email template updated successfully'), 'success')
|
||||
return redirect(url_for('admin.view_email_template', template_id=template_id))
|
||||
|
||||
return render_template('admin/email_templates/edit.html', template=template)
|
||||
|
||||
|
||||
@admin_bp.route('/admin/email-templates/<int:template_id>/delete', methods=['POST'])
|
||||
@login_required
|
||||
@admin_or_permission_required('manage_settings')
|
||||
def delete_email_template(template_id):
|
||||
"""Delete email template"""
|
||||
from app.models import InvoiceTemplate
|
||||
|
||||
template = InvoiceTemplate.query.get_or_404(template_id)
|
||||
template_name = template.name
|
||||
|
||||
# Check if template is in use
|
||||
if template.invoices.count() > 0 or template.recurring_invoices.count() > 0:
|
||||
flash(_('Cannot delete template that is in use by invoices or recurring invoices'), 'error')
|
||||
return redirect(url_for('admin.list_email_templates'))
|
||||
|
||||
db.session.delete(template)
|
||||
if not safe_commit('delete_email_template', {'template_id': template_id}):
|
||||
flash(_('Could not delete email template due to a database error.'), 'error')
|
||||
else:
|
||||
flash(_('Email template "%(name)s" deleted successfully', name=template_name), 'success')
|
||||
|
||||
return redirect(url_for('admin.list_email_templates'))
|
||||
|
||||
@@ -197,6 +197,7 @@ def create_invoice():
|
||||
@login_required
|
||||
def view_invoice(invoice_id):
|
||||
"""View invoice details"""
|
||||
from app.models import InvoiceTemplate
|
||||
invoice = Invoice.query.get_or_404(invoice_id)
|
||||
|
||||
# Check access permissions
|
||||
@@ -210,7 +211,10 @@ def view_invoice(invoice_id):
|
||||
"invoice_number": invoice.invoice_number
|
||||
})
|
||||
|
||||
return render_template('invoices/view.html', invoice=invoice)
|
||||
# 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)
|
||||
|
||||
@invoices_bp.route('/invoices/<int:invoice_id>/edit', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
@@ -323,8 +327,10 @@ def edit_invoice(invoice_id):
|
||||
return redirect(url_for('invoices.view_invoice', invoice_id=invoice.id))
|
||||
|
||||
# GET request - show edit form
|
||||
from app.models import InvoiceTemplate
|
||||
projects = Project.query.filter_by(status='active').order_by(Project.name).all()
|
||||
return render_template('invoices/edit.html', invoice=invoice, projects=projects)
|
||||
email_templates = InvoiceTemplate.query.order_by(InvoiceTemplate.name).all()
|
||||
return render_template('invoices/edit.html', invoice=invoice, projects=projects, email_templates=email_templates)
|
||||
|
||||
@invoices_bp.route('/invoices/<int:invoice_id>/status', methods=['POST'])
|
||||
@login_required
|
||||
@@ -931,3 +937,55 @@ def export_invoices_excel():
|
||||
as_attachment=True,
|
||||
download_name=filename
|
||||
)
|
||||
|
||||
@invoices_bp.route('/invoices/<int:invoice_id>/send-email', methods=['POST'])
|
||||
@login_required
|
||||
def send_invoice_email_route(invoice_id):
|
||||
"""Send invoice via 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
|
||||
|
||||
# Get recipient email from request
|
||||
recipient_email = request.form.get('recipient_email', '').strip() or request.json.get('recipient_email', '').strip() if request.is_json else ''
|
||||
|
||||
if not recipient_email:
|
||||
# Try to use invoice client email
|
||||
recipient_email = invoice.client_email
|
||||
|
||||
if not recipient_email:
|
||||
return jsonify({'error': 'Recipient email address is required'}), 400
|
||||
|
||||
# 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 sent 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 sending invoice email: {type(e).__name__}: {str(e)}")
|
||||
logger.exception("Full error traceback:")
|
||||
return jsonify({'error': f'Failed to send email: {str(e)}'}), 500
|
||||
|
||||
264
app/routes/recurring_invoices.py
Normal file
264
app/routes/recurring_invoices.py
Normal file
@@ -0,0 +1,264 @@
|
||||
from flask import Blueprint, render_template, request, redirect, url_for, flash, jsonify
|
||||
from flask_babel import gettext as _
|
||||
from flask_login import login_required, current_user
|
||||
from app import db, log_event
|
||||
from app.models import RecurringInvoice, Project, Client, Settings, Invoice
|
||||
from datetime import datetime, timedelta
|
||||
from decimal import Decimal
|
||||
import logging
|
||||
from app.utils.db import safe_commit
|
||||
|
||||
recurring_invoices_bp = Blueprint('recurring_invoices', __name__)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@recurring_invoices_bp.route('/recurring-invoices')
|
||||
@login_required
|
||||
def list_recurring_invoices():
|
||||
"""List all recurring invoices"""
|
||||
# Get filter parameters
|
||||
is_active = request.args.get('is_active', '').strip()
|
||||
|
||||
# Build query
|
||||
if current_user.is_admin:
|
||||
query = RecurringInvoice.query
|
||||
else:
|
||||
query = RecurringInvoice.query.filter_by(created_by=current_user.id)
|
||||
|
||||
# Apply active filter
|
||||
if is_active == 'true':
|
||||
query = query.filter_by(is_active=True)
|
||||
elif is_active == 'false':
|
||||
query = query.filter_by(is_active=False)
|
||||
|
||||
# Get recurring invoices
|
||||
recurring_invoices = query.order_by(RecurringInvoice.next_run_date.asc()).all()
|
||||
|
||||
return render_template('recurring_invoices/list.html', recurring_invoices=recurring_invoices)
|
||||
|
||||
|
||||
@recurring_invoices_bp.route('/recurring-invoices/create', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
def create_recurring_invoice():
|
||||
"""Create a new recurring invoice"""
|
||||
if request.method == 'POST':
|
||||
# Get form data
|
||||
name = request.form.get('name', '').strip()
|
||||
project_id = request.form.get('project_id', type=int)
|
||||
client_id = request.form.get('client_id', type=int)
|
||||
frequency = request.form.get('frequency', '').strip()
|
||||
interval = request.form.get('interval', type=int, default=1)
|
||||
next_run_date_str = request.form.get('next_run_date', '').strip()
|
||||
end_date_str = request.form.get('end_date', '').strip()
|
||||
client_name = request.form.get('client_name', '').strip()
|
||||
client_email = request.form.get('client_email', '').strip()
|
||||
client_address = request.form.get('client_address', '').strip()
|
||||
due_date_days = request.form.get('due_date_days', type=int, default=30)
|
||||
tax_rate = request.form.get('tax_rate', '0').strip()
|
||||
notes = request.form.get('notes', '').strip()
|
||||
terms = request.form.get('terms', '').strip()
|
||||
auto_send = request.form.get('auto_send') == 'on'
|
||||
auto_include_time_entries = request.form.get('auto_include_time_entries') != 'off'
|
||||
|
||||
# Validate required fields
|
||||
if not name or not project_id or not client_id or not frequency or not next_run_date_str:
|
||||
flash('Name, project, client, frequency, and next run date are required', 'error')
|
||||
return render_template('recurring_invoices/create.html')
|
||||
|
||||
try:
|
||||
next_run_date = datetime.strptime(next_run_date_str, '%Y-%m-%d').date()
|
||||
except ValueError:
|
||||
flash('Invalid next run date format', 'error')
|
||||
return render_template('recurring_invoices/create.html')
|
||||
|
||||
end_date = None
|
||||
if end_date_str:
|
||||
try:
|
||||
end_date = datetime.strptime(end_date_str, '%Y-%m-%d').date()
|
||||
except ValueError:
|
||||
flash('Invalid end date format', 'error')
|
||||
return render_template('recurring_invoices/create.html')
|
||||
|
||||
try:
|
||||
tax_rate = Decimal(tax_rate)
|
||||
except ValueError:
|
||||
flash('Invalid tax rate format', 'error')
|
||||
return render_template('recurring_invoices/create.html')
|
||||
|
||||
# Get project and client
|
||||
project = Project.query.get(project_id)
|
||||
client = Client.query.get(client_id)
|
||||
if not project or not client:
|
||||
flash('Selected project or client not found', 'error')
|
||||
return render_template('recurring_invoices/create.html')
|
||||
|
||||
# Get currency from settings
|
||||
settings = Settings.get_settings()
|
||||
currency_code = settings.currency if settings else 'EUR'
|
||||
|
||||
# Use client info if not provided
|
||||
if not client_name:
|
||||
client_name = client.name
|
||||
if not client_email:
|
||||
client_email = client.email
|
||||
|
||||
# Create recurring invoice
|
||||
recurring = RecurringInvoice(
|
||||
name=name,
|
||||
project_id=project_id,
|
||||
client_id=client_id,
|
||||
frequency=frequency,
|
||||
next_run_date=next_run_date,
|
||||
created_by=current_user.id,
|
||||
interval=interval,
|
||||
end_date=end_date,
|
||||
client_name=client_name,
|
||||
client_email=client_email,
|
||||
client_address=client_address,
|
||||
due_date_days=due_date_days,
|
||||
tax_rate=tax_rate,
|
||||
notes=notes,
|
||||
terms=terms,
|
||||
currency_code=currency_code,
|
||||
auto_send=auto_send,
|
||||
auto_include_time_entries=auto_include_time_entries
|
||||
)
|
||||
|
||||
db.session.add(recurring)
|
||||
if not safe_commit('create_recurring_invoice', {'name': name, 'project_id': project_id}):
|
||||
flash('Could not create recurring invoice due to a database error. Please check server logs.', 'error')
|
||||
return render_template('recurring_invoices/create.html')
|
||||
|
||||
flash(f'Recurring invoice "{name}" created successfully', 'success')
|
||||
return redirect(url_for('recurring_invoices.list_recurring_invoices'))
|
||||
|
||||
# GET request - show form
|
||||
projects = Project.query.filter_by(status='active', billable=True).order_by(Project.name).all()
|
||||
clients = Client.query.filter_by(status='active').order_by(Client.name).all()
|
||||
settings = Settings.get_settings()
|
||||
|
||||
# Set default next run date to tomorrow
|
||||
default_next_run_date = (datetime.utcnow() + timedelta(days=1)).strftime('%Y-%m-%d')
|
||||
|
||||
return render_template('recurring_invoices/create.html',
|
||||
projects=projects,
|
||||
clients=clients,
|
||||
settings=settings,
|
||||
default_next_run_date=default_next_run_date)
|
||||
|
||||
|
||||
@recurring_invoices_bp.route('/recurring-invoices/<int:recurring_id>')
|
||||
@login_required
|
||||
def view_recurring_invoice(recurring_id):
|
||||
"""View recurring invoice details"""
|
||||
recurring = RecurringInvoice.query.get_or_404(recurring_id)
|
||||
|
||||
# Check access permissions
|
||||
if not current_user.is_admin and recurring.created_by != current_user.id:
|
||||
flash('You do not have permission to view this recurring invoice', 'error')
|
||||
return redirect(url_for('recurring_invoices.list_recurring_invoices'))
|
||||
|
||||
# Get generated invoices
|
||||
generated_invoices = recurring.generated_invoices.order_by(Invoice.created_at.desc()).limit(10).all()
|
||||
|
||||
return render_template('recurring_invoices/view.html', recurring=recurring, generated_invoices=generated_invoices)
|
||||
|
||||
|
||||
@recurring_invoices_bp.route('/recurring-invoices/<int:recurring_id>/edit', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
def edit_recurring_invoice(recurring_id):
|
||||
"""Edit recurring invoice"""
|
||||
recurring = RecurringInvoice.query.get_or_404(recurring_id)
|
||||
|
||||
# Check access permissions
|
||||
if not current_user.is_admin and recurring.created_by != current_user.id:
|
||||
flash('You do not have permission to edit this recurring invoice', 'error')
|
||||
return redirect(url_for('recurring_invoices.list_recurring_invoices'))
|
||||
|
||||
if request.method == 'POST':
|
||||
# Update recurring invoice
|
||||
recurring.name = request.form.get('name', '').strip()
|
||||
recurring.frequency = request.form.get('frequency', '').strip()
|
||||
recurring.interval = request.form.get('interval', type=int, default=1)
|
||||
recurring.next_run_date = datetime.strptime(request.form.get('next_run_date'), '%Y-%m-%d').date()
|
||||
|
||||
end_date_str = request.form.get('end_date', '').strip()
|
||||
recurring.end_date = datetime.strptime(end_date_str, '%Y-%m-%d').date() if end_date_str else None
|
||||
|
||||
recurring.client_name = request.form.get('client_name', '').strip()
|
||||
recurring.client_email = request.form.get('client_email', '').strip()
|
||||
recurring.client_address = request.form.get('client_address', '').strip()
|
||||
recurring.due_date_days = request.form.get('due_date_days', type=int, default=30)
|
||||
recurring.tax_rate = Decimal(request.form.get('tax_rate', '0'))
|
||||
recurring.notes = request.form.get('notes', '').strip()
|
||||
recurring.terms = request.form.get('terms', '').strip()
|
||||
recurring.auto_send = request.form.get('auto_send') == 'on'
|
||||
recurring.auto_include_time_entries = request.form.get('auto_include_time_entries') != 'off'
|
||||
recurring.is_active = request.form.get('is_active') == 'on'
|
||||
|
||||
if not safe_commit('edit_recurring_invoice', {'recurring_id': recurring.id}):
|
||||
flash('Could not update recurring invoice due to a database error. Please check server logs.', 'error')
|
||||
return render_template('recurring_invoices/edit.html', recurring=recurring, projects=Project.query.filter_by(status='active').order_by(Project.name).all(), clients=Client.query.filter_by(status='active').order_by(Client.name).all())
|
||||
|
||||
flash('Recurring invoice updated successfully', 'success')
|
||||
return redirect(url_for('recurring_invoices.view_recurring_invoice', recurring_id=recurring.id))
|
||||
|
||||
# GET request - show edit form
|
||||
projects = Project.query.filter_by(status='active').order_by(Project.name).all()
|
||||
clients = Client.query.filter_by(status='active').order_by(Client.name).all()
|
||||
return render_template('recurring_invoices/edit.html', recurring=recurring, projects=projects, clients=clients)
|
||||
|
||||
|
||||
@recurring_invoices_bp.route('/recurring-invoices/<int:recurring_id>/delete', methods=['POST'])
|
||||
@login_required
|
||||
def delete_recurring_invoice(recurring_id):
|
||||
"""Delete recurring invoice"""
|
||||
recurring = RecurringInvoice.query.get_or_404(recurring_id)
|
||||
|
||||
# Check access permissions
|
||||
if not current_user.is_admin and recurring.created_by != current_user.id:
|
||||
flash('You do not have permission to delete this recurring invoice', 'error')
|
||||
return redirect(url_for('recurring_invoices.list_recurring_invoices'))
|
||||
|
||||
name = recurring.name
|
||||
db.session.delete(recurring)
|
||||
if not safe_commit('delete_recurring_invoice', {'recurring_id': recurring.id}):
|
||||
flash('Could not delete recurring invoice due to a database error. Please check server logs.', 'error')
|
||||
return redirect(url_for('recurring_invoices.list_recurring_invoices'))
|
||||
|
||||
flash(f'Recurring invoice "{name}" deleted successfully', 'success')
|
||||
return redirect(url_for('recurring_invoices.list_recurring_invoices'))
|
||||
|
||||
|
||||
@recurring_invoices_bp.route('/recurring-invoices/<int:recurring_id>/generate', methods=['POST'])
|
||||
@login_required
|
||||
def generate_invoice_now(recurring_id):
|
||||
"""Manually generate an invoice from a recurring template"""
|
||||
recurring = RecurringInvoice.query.get_or_404(recurring_id)
|
||||
|
||||
# Check access permissions
|
||||
if not current_user.is_admin and recurring.created_by != current_user.id:
|
||||
return jsonify({'error': 'Permission denied'}), 403
|
||||
|
||||
try:
|
||||
# Temporarily set next_run_date to today to allow generation
|
||||
original_next_run_date = recurring.next_run_date
|
||||
recurring.next_run_date = datetime.utcnow().date()
|
||||
|
||||
invoice = recurring.generate_invoice()
|
||||
if invoice:
|
||||
db.session.commit()
|
||||
flash(f'Invoice {invoice.invoice_number} generated successfully', 'success')
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'invoice_id': invoice.id,
|
||||
'invoice_number': invoice.invoice_number
|
||||
})
|
||||
else:
|
||||
recurring.next_run_date = original_next_run_date
|
||||
return jsonify({'error': 'Failed to generate invoice'}), 400
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error generating invoice from recurring template: {e}")
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
@@ -367,12 +367,12 @@ class EnhancedErrorHandler {
|
||||
if (window.toastManager) {
|
||||
const toastId = window.toastManager.error(message, 'Error', 0);
|
||||
|
||||
// Find toast element by ID or by checking the container
|
||||
// Find toast element by ID
|
||||
const toastElement = window.toastManager.container.querySelector(
|
||||
`[data-toast-id="${toastId}"]`
|
||||
) || Array.from(window.toastManager.container.children).find(
|
||||
el => el.getAttribute('data-toast-id') === String(toastId)
|
||||
) || document.querySelector(`#toast-${toastId}`);
|
||||
);
|
||||
|
||||
if (toastElement) {
|
||||
const retryContainer = document.createElement('div');
|
||||
|
||||
@@ -48,8 +48,8 @@ class ToastNotificationManager {
|
||||
dismissible: options.dismissible !== false
|
||||
};
|
||||
|
||||
const toast = this.createToast(config);
|
||||
const toastId = Date.now() + Math.random();
|
||||
const toast = this.createToast(config, toastId);
|
||||
|
||||
this.toasts.set(toastId, {
|
||||
element: toast,
|
||||
@@ -80,12 +80,15 @@ class ToastNotificationManager {
|
||||
return toastId;
|
||||
}
|
||||
|
||||
createToast(config) {
|
||||
createToast(config, toastId) {
|
||||
const toast = document.createElement('div');
|
||||
toast.className = `toast-notification toast-${config.type}`;
|
||||
toast.setAttribute('role', 'alert');
|
||||
toast.setAttribute('aria-live', config.type === 'error' ? 'assertive' : 'polite');
|
||||
toast.setAttribute('aria-atomic', 'true');
|
||||
if (toastId) {
|
||||
toast.setAttribute('data-toast-id', String(toastId));
|
||||
}
|
||||
|
||||
// Icon
|
||||
const icon = this.getIcon(config.type);
|
||||
|
||||
@@ -43,6 +43,11 @@
|
||||
<div>Email Configuration</div>
|
||||
<div class="text-xs mt-1 opacity-90">Test & Configure</div>
|
||||
</a>
|
||||
<a href="{{ url_for('admin.list_email_templates') }}" class="bg-purple-600 text-white p-4 rounded-lg text-center hover:bg-purple-700">
|
||||
<i class="fas fa-file-alt mb-2"></i>
|
||||
<div>Email Templates</div>
|
||||
<div class="text-xs mt-1 opacity-90">Invoice Email Templates</div>
|
||||
</a>
|
||||
<a href="{{ url_for('admin.settings') }}" class="bg-primary text-white p-4 rounded-lg text-center hover:bg-blue-600">
|
||||
<i class="fas fa-cog mb-2"></i>
|
||||
<div>Settings</div>
|
||||
|
||||
64
app/templates/admin/email_templates/create.html
Normal file
64
app/templates/admin/email_templates/create.html
Normal file
@@ -0,0 +1,64 @@
|
||||
{% extends "base.html" %}
|
||||
{% from "components/ui.html" import page_header %}
|
||||
|
||||
{% block content %}
|
||||
{% set breadcrumbs = [
|
||||
{'text': 'Admin', 'url': url_for('admin.admin_dashboard')},
|
||||
{'text': 'Email Templates', 'url': url_for('admin.list_email_templates')},
|
||||
{'text': 'Create Template'}
|
||||
] %}
|
||||
|
||||
{{ page_header(
|
||||
icon_class='fas fa-envelope',
|
||||
title_text='Create Email Template',
|
||||
subtitle_text='Create a new email template for invoice emails',
|
||||
breadcrumbs=breadcrumbs
|
||||
) }}
|
||||
|
||||
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow">
|
||||
<form method="POST" 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">
|
||||
<div class="md:col-span-2">
|
||||
<label for="name" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Template Name *</label>
|
||||
<input type="text" id="name" name="name" required value="{{ name or '' }}" 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">
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">A unique name to identify this template</p>
|
||||
</div>
|
||||
|
||||
<div class="md:col-span-2">
|
||||
<label for="description" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Description</label>
|
||||
<input type="text" id="description" name="description" value="{{ description or '' }}" 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">
|
||||
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">Optional description of this template</p>
|
||||
</div>
|
||||
|
||||
<div class="md:col-span-2">
|
||||
<label class="flex items-center">
|
||||
<input type="checkbox" name="is_default" class="rounded border-gray-300 text-primary focus:ring-primary">
|
||||
<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>
|
||||
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end gap-3">
|
||||
<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>
|
||||
{% endblock %}
|
||||
|
||||
62
app/templates/admin/email_templates/edit.html
Normal file
62
app/templates/admin/email_templates/edit.html
Normal file
@@ -0,0 +1,62 @@
|
||||
{% extends "base.html" %}
|
||||
{% from "components/ui.html" import page_header %}
|
||||
|
||||
{% block content %}
|
||||
{% set breadcrumbs = [
|
||||
{'text': 'Admin', 'url': url_for('admin.admin_dashboard')},
|
||||
{'text': 'Email Templates', 'url': url_for('admin.list_email_templates')},
|
||||
{'text': template.name}
|
||||
] %}
|
||||
|
||||
{{ page_header(
|
||||
icon_class='fas fa-envelope',
|
||||
title_text='Edit Email Template',
|
||||
subtitle_text=template.name,
|
||||
breadcrumbs=breadcrumbs
|
||||
) }}
|
||||
|
||||
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow">
|
||||
<form method="POST" 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">
|
||||
<div class="md:col-span-2">
|
||||
<label for="name" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Template Name *</label>
|
||||
<input type="text" id="name" name="name" required value="{{ template.name }}" 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">
|
||||
</div>
|
||||
|
||||
<div class="md:col-span-2">
|
||||
<label for="description" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Description</label>
|
||||
<input type="text" id="description" name="description" value="{{ template.description or '' }}" 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">
|
||||
</div>
|
||||
|
||||
<div class="md:col-span-2">
|
||||
<label class="flex items-center">
|
||||
<input type="checkbox" name="is_default" {% if template.is_default %}checked{% endif %} class="rounded border-gray-300 text-primary focus:ring-primary">
|
||||
<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>
|
||||
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end gap-3">
|
||||
<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>
|
||||
{% endblock %}
|
||||
|
||||
102
app/templates/admin/email_templates/list.html
Normal file
102
app/templates/admin/email_templates/list.html
Normal file
@@ -0,0 +1,102 @@
|
||||
{% extends "base.html" %}
|
||||
{% from "components/ui.html" import page_header %}
|
||||
|
||||
{% block content %}
|
||||
{% set breadcrumbs = [
|
||||
{'text': 'Admin', 'url': url_for('admin.admin_dashboard')},
|
||||
{'text': 'Email Templates'}
|
||||
] %}
|
||||
|
||||
{{ page_header(
|
||||
icon_class='fas fa-envelope',
|
||||
title_text='Email Templates',
|
||||
subtitle_text='Manage email templates for invoice emails',
|
||||
breadcrumbs=breadcrumbs,
|
||||
actions_html='<a href="' + url_for("admin.create_email_template") + '" class="bg-primary text-white px-4 py-2 rounded-lg hover:bg-primary/90 transition-colors"><i class="fas fa-plus mr-2"></i>Create Template</a>'
|
||||
) }}
|
||||
|
||||
{% if templates %}
|
||||
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow">
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-full divide-y divide-border-light dark:divide-border-dark">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-text-muted-light dark:text-text-muted-dark uppercase tracking-wider">Name</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-text-muted-light dark:text-text-muted-dark uppercase tracking-wider">Description</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-text-muted-light dark:text-text-muted-dark uppercase tracking-wider">Status</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-text-muted-light dark:text-text-muted-dark uppercase tracking-wider">Created</th>
|
||||
<th class="px-6 py-3 text-right text-xs font-medium text-text-muted-light dark:text-text-muted-dark uppercase tracking-wider">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="bg-card-light dark:bg-card-dark divide-y divide-border-light dark:divide-border-dark">
|
||||
{% for template in templates %}
|
||||
<tr>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium text-text-light dark:text-text-dark">
|
||||
<a href="{{ url_for('admin.view_email_template', template_id=template.id) }}" class="text-primary hover:underline">{{ template.name }}</a>
|
||||
</td>
|
||||
<td class="px-6 py-4 text-sm text-text-muted-light dark:text-text-muted-dark">
|
||||
{{ template.description or '-' }}
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm">
|
||||
{% if template.is_default %}
|
||||
<span class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300">Default</span>
|
||||
{% else %}
|
||||
<span class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300">Active</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-text-muted-light dark:text-text-muted-dark">
|
||||
{{ template.created_at.strftime('%Y-%m-%d') if template.created_at else '-' }}
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
|
||||
<a href="{{ url_for('admin.view_email_template', template_id=template.id) }}" class="text-indigo-600 hover:text-indigo-900 dark:text-indigo-400 dark:hover:text-indigo-300 mr-3">View</a>
|
||||
<a href="{{ url_for('admin.edit_email_template', template_id=template.id) }}" class="text-blue-600 hover:text-blue-900 dark:text-blue-400 dark:hover:text-blue-300 mr-3">Edit</a>
|
||||
<button type="button" onclick="showDeleteModal('{{ template.id }}', '{{ template.name }}')" class="text-red-600 hover:text-red-900 dark:text-red-400 dark:hover:text-red-300">Delete</button>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow text-center text-text-muted-light dark:text-text-muted-dark">
|
||||
<p class="text-lg">{{ _('No email templates found.') }}</p>
|
||||
<p class="mt-2">{{ _('Create your first email template to customize invoice emails.') }}</p>
|
||||
<a href="{{ url_for('admin.create_email_template') }}" class="mt-4 inline-flex items-center px-4 py-2 border border-transparent text-sm font-medium rounded-md shadow-sm text-white bg-primary hover:bg-primary/90">
|
||||
<i class="fas fa-plus mr-2"></i>{{ _('Create Email Template') }}
|
||||
</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Delete Modal -->
|
||||
<div id="deleteTemplateModal" 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">
|
||||
<div class="mt-3 text-center">
|
||||
<h3 class="text-lg leading-6 font-medium text-gray-900 dark:text-gray-100">{{ _('Delete Email Template') }}</h3>
|
||||
<div class="mt-2 px-7 py-3">
|
||||
<p class="text-sm text-gray-500 dark:text-gray-400">{{ _('Are you sure you want to delete') }} "<strong id="deleteTemplateName"></strong>"? {{ _('This action cannot be undone.') }}</p>
|
||||
</div>
|
||||
<div class="items-center px-4 py-3">
|
||||
<button id="cancelDelete" onclick="hideDeleteModal()" 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 mr-2">{{ _('Cancel') }}</button>
|
||||
<form id="deleteTemplateForm" method="POST" class="inline-block">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<button type="submit" class="px-4 py-2 bg-red-600 text-white rounded-lg hover:bg-red-700">{{ _('Delete') }}</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function showDeleteModal(templateId, name) {
|
||||
document.getElementById('deleteTemplateName').textContent = name;
|
||||
document.getElementById('deleteTemplateForm').action = "{{ url_for('admin.delete_email_template', template_id=0) }}".replace('0', templateId);
|
||||
document.getElementById('deleteTemplateModal').classList.remove('hidden');
|
||||
}
|
||||
|
||||
function hideDeleteModal() {
|
||||
document.getElementById('deleteTemplateModal').classList.add('hidden');
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
68
app/templates/admin/email_templates/view.html
Normal file
68
app/templates/admin/email_templates/view.html
Normal file
@@ -0,0 +1,68 @@
|
||||
{% extends "base.html" %}
|
||||
{% from "components/ui.html" import page_header %}
|
||||
|
||||
{% block content %}
|
||||
{% set breadcrumbs = [
|
||||
{'text': 'Admin', 'url': url_for('admin.admin_dashboard')},
|
||||
{'text': 'Email Templates', 'url': url_for('admin.list_email_templates')},
|
||||
{'text': template.name}
|
||||
] %}
|
||||
|
||||
{{ page_header(
|
||||
icon_class='fas fa-envelope',
|
||||
title_text=template.name,
|
||||
subtitle_text='Email template details',
|
||||
breadcrumbs=breadcrumbs,
|
||||
actions_html='' +
|
||||
'<a href="' + url_for("admin.edit_email_template", template_id=template.id) + '" class="bg-primary text-white px-4 py-2 rounded-lg hover:bg-primary/90 transition-colors"><i class="fas fa-edit mr-2"></i>Edit</a>'
|
||||
) }}
|
||||
|
||||
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow mb-6">
|
||||
<h2 class="text-lg font-semibold mb-4">Template Information</h2>
|
||||
<dl class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div>
|
||||
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400">Name</dt>
|
||||
<dd class="text-sm text-gray-900 dark:text-gray-100">{{ template.name }}</dd>
|
||||
</div>
|
||||
{% if template.description %}
|
||||
<div>
|
||||
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400">Description</dt>
|
||||
<dd class="text-sm text-gray-900 dark:text-gray-100">{{ template.description }}</dd>
|
||||
</div>
|
||||
{% endif %}
|
||||
<div>
|
||||
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400">Status</dt>
|
||||
<dd class="text-sm text-gray-900 dark:text-gray-100">
|
||||
{% if template.is_default %}
|
||||
<span class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-green-100 text-green-800 dark:bg-green-900/30 dark:text-green-300">Default</span>
|
||||
{% else %}
|
||||
<span class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-gray-100 text-gray-800 dark:bg-gray-700 dark:text-gray-300">Active</span>
|
||||
{% endif %}
|
||||
</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400">Created</dt>
|
||||
<dd class="text-sm text-gray-900 dark:text-gray-100">{{ template.created_at.strftime('%Y-%m-%d %H:%M') if template.created_at else '-' }}</dd>
|
||||
</div>
|
||||
<div>
|
||||
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400">Last Updated</dt>
|
||||
<dd class="text-sm text-gray-900 dark:text-gray-100">{{ template.updated_at.strftime('%Y-%m-%d %H:%M') if template.updated_at else '-' }}</dd>
|
||||
</div>
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
{% if template.html %}
|
||||
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow mb-6">
|
||||
<h2 class="text-lg font-semibold mb-4">HTML Template</h2>
|
||||
<pre class="bg-gray-100 dark:bg-gray-800 p-4 rounded-lg overflow-x-auto text-sm"><code>{{ template.html }}</code></pre>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if template.css %}
|
||||
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow">
|
||||
<h2 class="text-lg font-semibold mb-4">CSS Styles</h2>
|
||||
<pre class="bg-gray-100 dark:bg-gray-800 p-4 rounded-lg overflow-x-auto text-sm"><code>{{ template.css }}</code></pre>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
|
||||
@@ -162,7 +162,7 @@
|
||||
<nav class="flex-1">
|
||||
{% set ep = request.endpoint or '' %}
|
||||
{% set work_open = ep.startswith('projects.') or ep.startswith('clients.') or ep.startswith('tasks.') or ep.startswith('timer.') or ep.startswith('kanban.') or ep.startswith('weekly_goals.') %}
|
||||
{% set finance_open = ep.startswith('reports.') or ep.startswith('invoices.') or ep.startswith('payments.') or ep.startswith('expenses.') or ep.startswith('budget_alerts.') or ep.startswith('mileage.') or (ep.startswith('per_diem.') and not ep.startswith('per_diem.list_rates')) %}
|
||||
{% set finance_open = ep.startswith('reports.') or ep.startswith('invoices.') or ep.startswith('recurring_invoices.') or ep.startswith('payments.') or ep.startswith('expenses.') or ep.startswith('budget_alerts.') or ep.startswith('mileage.') or (ep.startswith('per_diem.') and not ep.startswith('per_diem.list_rates')) %}
|
||||
{% set analytics_open = ep.startswith('analytics.') %}
|
||||
{% set tools_open = ep.startswith('import_export.') or ep.startswith('saved_filters.') %}
|
||||
{% set admin_open = ep.startswith('admin.') or ep.startswith('permissions.') or (ep.startswith('expense_categories.') and current_user.is_admin) or (ep.startswith('per_diem.list_rates') and current_user.is_admin) or ep.startswith('time_entry_templates.') or ep.startswith('audit_logs.') %}
|
||||
@@ -240,6 +240,7 @@
|
||||
<ul id="financeDropdown" class="{% if not finance_open %}hidden {% endif %}mt-2 space-y-2 ml-6">
|
||||
{% set nav_active_reports = ep.startswith('reports.') %}
|
||||
{% set nav_active_invoices = ep.startswith('invoices.') %}
|
||||
{% set nav_active_recurring_invoices = ep.startswith('recurring_invoices.') %}
|
||||
{% set nav_active_payments = ep.startswith('payments.') %}
|
||||
{% set nav_active_expenses = ep.startswith('expenses.') %}
|
||||
{% set nav_active_mileage = ep.startswith('mileage.') %}
|
||||
@@ -255,6 +256,11 @@
|
||||
<i class="fas fa-file-invoice w-4 mr-2"></i>{{ _('Invoices') }}
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a class="block px-2 py-1 rounded {% if nav_active_recurring_invoices %}text-primary font-semibold bg-background-light dark:bg-background-dark{% else %}text-text-light dark:text-text-dark hover:bg-background-light dark:hover:bg-background-dark{% endif %}" href="{{ url_for('recurring_invoices.list_recurring_invoices') }}">
|
||||
<i class="fas fa-sync-alt w-4 mr-2"></i>{{ _('Recurring Invoices') }}
|
||||
</a>
|
||||
</li>
|
||||
<li>
|
||||
<a class="block px-2 py-1 rounded {% if nav_active_payments %}text-primary font-semibold bg-background-light dark:bg-background-dark{% else %}text-text-light dark:text-text-dark hover:bg-background-light dark:hover:bg-background-dark{% endif %}" href="{{ url_for('payments.list_payments') }}">
|
||||
<i class="fas fa-credit-card w-4 mr-2"></i>{{ _('Payments') }}
|
||||
|
||||
111
app/templates/email/invoice.html
Normal file
111
app/templates/email/invoice.html
Normal file
@@ -0,0 +1,111 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<style>
|
||||
body {
|
||||
font-family: Arial, sans-serif;
|
||||
line-height: 1.6;
|
||||
color: #333;
|
||||
max-width: 600px;
|
||||
margin: 0 auto;
|
||||
padding: 20px;
|
||||
}
|
||||
.header {
|
||||
background-color: #3b82f6;
|
||||
color: white;
|
||||
padding: 20px;
|
||||
text-align: center;
|
||||
border-radius: 5px;
|
||||
}
|
||||
.content {
|
||||
background-color: #f9fafb;
|
||||
padding: 20px;
|
||||
margin-top: 20px;
|
||||
border-radius: 5px;
|
||||
}
|
||||
.invoice-details {
|
||||
background-color: white;
|
||||
padding: 15px;
|
||||
margin: 15px 0;
|
||||
border-left: 4px solid #3b82f6;
|
||||
}
|
||||
.invoice-details table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
.invoice-details td {
|
||||
padding: 8px 0;
|
||||
}
|
||||
.invoice-details td:first-child {
|
||||
font-weight: bold;
|
||||
width: 40%;
|
||||
}
|
||||
.custom-message {
|
||||
background-color: #fef3c7;
|
||||
padding: 15px;
|
||||
margin: 15px 0;
|
||||
border-left: 4px solid #f59e0b;
|
||||
border-radius: 5px;
|
||||
}
|
||||
.footer {
|
||||
text-align: center;
|
||||
color: #6b7280;
|
||||
font-size: 12px;
|
||||
margin-top: 30px;
|
||||
padding-top: 20px;
|
||||
border-top: 1px solid #e5e7eb;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="header">
|
||||
<h1>Invoice {{ invoice.invoice_number }}</h1>
|
||||
</div>
|
||||
|
||||
<div class="content">
|
||||
<p>Hello,</p>
|
||||
|
||||
<p>Please find attached invoice <strong>{{ invoice.invoice_number }}</strong> for your records.</p>
|
||||
|
||||
<div class="invoice-details">
|
||||
<table>
|
||||
<tr>
|
||||
<td>Invoice Number:</td>
|
||||
<td>{{ invoice.invoice_number }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Issue Date:</td>
|
||||
<td>{{ invoice.issue_date.strftime('%Y-%m-%d') if invoice.issue_date else 'N/A' }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Due Date:</td>
|
||||
<td>{{ invoice.due_date.strftime('%Y-%m-%d') if invoice.due_date else 'N/A' }}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td>Amount:</td>
|
||||
<td><strong>{{ invoice.currency_code }} {{ invoice.total_amount }}</strong></td>
|
||||
</tr>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{% if custom_message %}
|
||||
<div class="custom-message">
|
||||
<p><strong>Message:</strong></p>
|
||||
<p>{{ custom_message }}</p>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<p>Please remit payment by the due date.</p>
|
||||
|
||||
<p>Thank you for your business!</p>
|
||||
</div>
|
||||
|
||||
<div class="footer">
|
||||
<p><strong>{{ company_name }}</strong></p>
|
||||
<p>TimeTracker - Time Tracking & Project Management</p>
|
||||
<p>This invoice was sent automatically. Please contact us if you have any questions.</p>
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -10,6 +10,9 @@
|
||||
<a href="{{ url_for('invoices.view_invoice', invoice_id=invoice.id) }}" class="bg-gray-200 dark:bg-gray-700 px-4 py-2 rounded-lg hover:bg-gray-300 dark:hover:bg-gray-600 transition">
|
||||
<i class="fas fa-arrow-left mr-2"></i>{{ _('Back') }}
|
||||
</a>
|
||||
<button type="button" onclick="showSendEmailModal()" class="bg-blue-500 text-white px-4 py-2 rounded-lg hover:bg-blue-600">
|
||||
<i class="fas fa-envelope mr-1"></i>{{ _('Send Email') }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -522,4 +525,122 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
});
|
||||
});
|
||||
</script>
|
||||
|
||||
<!-- 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">
|
||||
<div class="mt-3">
|
||||
<h3 class="text-lg font-medium text-gray-900 dark:text-gray-100 mb-4">Send Invoice via Email</h3>
|
||||
<form id="sendEmailForm" onsubmit="sendInvoiceEmail(event)">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<div class="mb-4">
|
||||
<label for="recipient_email" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Recipient Email *</label>
|
||||
<input type="email" id="recipient_email" name="recipient_email" value="{{ invoice.client_email }}" required class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100">
|
||||
</div>
|
||||
<div class="mb-4">
|
||||
<label for="email_template_id" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Email Template (Optional)</label>
|
||||
<select id="email_template_id" name="email_template_id" class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100">
|
||||
<option value="">Default Template</option>
|
||||
{% for template in email_templates %}
|
||||
<option value="{{ template.id }}">{{ template.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="mb-4">
|
||||
<label for="custom_message" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Custom Message (Optional)</label>
|
||||
<textarea id="custom_message" name="custom_message" rows="4" class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100"></textarea>
|
||||
</div>
|
||||
<div class="flex justify-end space-x-3">
|
||||
<button type="button" onclick="hideSendEmailModal()" 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</button>
|
||||
<button type="submit" class="px-4 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600">Send Email</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function showSendEmailModal() {
|
||||
const modal = document.getElementById('sendEmailModal');
|
||||
if (modal) modal.classList.remove('hidden');
|
||||
}
|
||||
|
||||
function hideSendEmailModal() {
|
||||
const modal = document.getElementById('sendEmailModal');
|
||||
if (modal) modal.classList.add('hidden');
|
||||
}
|
||||
|
||||
function sendInvoiceEmail(event) {
|
||||
event.preventDefault();
|
||||
const form = event.target;
|
||||
const formData = new FormData(form);
|
||||
|
||||
// Show loading state
|
||||
const submitBtn = form.querySelector('button[type="submit"]');
|
||||
const originalText = submitBtn.textContent;
|
||||
submitBtn.disabled = true;
|
||||
submitBtn.textContent = 'Sending...';
|
||||
|
||||
fetch("{{ url_for('invoices.send_invoice_email_route', invoice_id=invoice.id) }}", {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
})
|
||||
.then(async response => {
|
||||
// Check content type to determine how to parse
|
||||
const contentType = response.headers.get('content-type') || '';
|
||||
const isJson = contentType.includes('application/json');
|
||||
|
||||
// Always try JSON first if content-type suggests it, otherwise read as text
|
||||
if (isJson) {
|
||||
const data = await response.json();
|
||||
// Handle both success and error responses
|
||||
if (!response.ok) {
|
||||
return { success: false, error: data.error || data.message || `HTTP ${response.status}: ${response.statusText}` };
|
||||
}
|
||||
return data;
|
||||
} else {
|
||||
// Not JSON, read as text (only once)
|
||||
const text = await response.text();
|
||||
if (!response.ok) {
|
||||
throw new Error(text || `HTTP ${response.status}: ${response.statusText}`);
|
||||
}
|
||||
// If successful but not JSON, return error format
|
||||
return { success: false, error: text || 'Unexpected response format' };
|
||||
}
|
||||
})
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
if (window.toastManager) {
|
||||
window.toastManager.success('Invoice email sent successfully!');
|
||||
} else {
|
||||
alert('Invoice email sent successfully!');
|
||||
}
|
||||
hideSendEmailModal();
|
||||
// Optionally reload the page to show updated status
|
||||
setTimeout(() => window.location.reload(), 1000);
|
||||
} else {
|
||||
const errorMsg = data.error || 'Failed to send email';
|
||||
console.error('Email send 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 send error:', error);
|
||||
const errorMsg = error.message || 'Failed to send email. Please check server logs for details.';
|
||||
if (window.toastManager) {
|
||||
window.toastManager.error(errorMsg);
|
||||
} else {
|
||||
alert(errorMsg);
|
||||
}
|
||||
submitBtn.disabled = false;
|
||||
submitBtn.textContent = originalText;
|
||||
});
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -14,6 +14,7 @@
|
||||
breadcrumbs=breadcrumbs,
|
||||
actions_html=''
|
||||
+ '<div class="flex gap-2">'
|
||||
+ '<a href="' + url_for("recurring_invoices.list_recurring_invoices") + '" class="bg-purple-600 text-white px-4 py-2 rounded-lg hover:bg-purple-700 inline-flex items-center"><i class="fas fa-sync-alt mr-2"></i>Recurring Invoices</a>'
|
||||
+ '<a href="' + url_for("invoices.export_invoices_excel") + '" class="bg-green-600 text-white px-4 py-2 rounded-lg hover:bg-green-700 inline-flex items-center"><i class="fas fa-file-excel mr-2"></i>Export to Excel</a>'
|
||||
+ '<a href="' + url_for("invoices.create_invoice") + '" class="bg-primary text-white px-4 py-2 rounded-lg hover:bg-primary/90 transition-colors"><i class="fas fa-plus mr-2"></i>Create Invoice</a>'
|
||||
+ '</div>'
|
||||
|
||||
@@ -16,6 +16,9 @@
|
||||
</select>
|
||||
<a id="export-pdf-link" href="{{ url_for('invoices.export_invoice_pdf', invoice_id=invoice.id) }}" class="bg-secondary text-white px-4 py-2 rounded-lg hover:bg-secondary-dark">Export PDF</a>
|
||||
</div>
|
||||
<button type="button" onclick="showSendEmailModal()" class="bg-blue-500 text-white px-4 py-2 rounded-lg hover:bg-blue-600">
|
||||
<i class="fas fa-envelope mr-1"></i>Send Email
|
||||
</button>
|
||||
<a href="{{ url_for('payments.create_payment', invoice_id=invoice.id) }}" class="bg-green-500 text-white px-4 py-2 rounded-lg hover:bg-green-600">Record Payment</a>
|
||||
<button type="button" onclick="showDeleteModal('{{ invoice.id }}', '{{ invoice.invoice_number }}')" class="bg-red-500 text-white px-4 py-2 rounded-lg hover:bg-red-600">
|
||||
<i class="fas fa-trash mr-1"></i>Delete
|
||||
@@ -276,7 +279,123 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 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">
|
||||
<div class="mt-3">
|
||||
<h3 class="text-lg font-medium text-gray-900 dark:text-gray-100 mb-4">Send Invoice via Email</h3>
|
||||
<form id="sendEmailForm" onsubmit="sendInvoiceEmail(event)">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<div class="mb-4">
|
||||
<label for="recipient_email" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Recipient Email *</label>
|
||||
<input type="email" id="recipient_email" name="recipient_email" value="{{ invoice.client_email }}" required class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100">
|
||||
</div>
|
||||
<div class="mb-4">
|
||||
<label for="email_template_id" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Email Template (Optional)</label>
|
||||
<select id="email_template_id" name="email_template_id" class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100">
|
||||
<option value="">Default Template</option>
|
||||
{% for template in email_templates %}
|
||||
<option value="{{ template.id }}">{{ template.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="mb-4">
|
||||
<label for="custom_message" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Custom Message (Optional)</label>
|
||||
<textarea id="custom_message" name="custom_message" rows="4" class="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100"></textarea>
|
||||
</div>
|
||||
<div class="flex justify-end space-x-3">
|
||||
<button type="button" onclick="hideSendEmailModal()" 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</button>
|
||||
<button type="submit" class="px-4 py-2 bg-blue-500 text-white rounded-lg hover:bg-blue-600">Send Email</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function showSendEmailModal() {
|
||||
const modal = document.getElementById('sendEmailModal');
|
||||
if (modal) modal.classList.remove('hidden');
|
||||
}
|
||||
|
||||
function hideSendEmailModal() {
|
||||
const modal = document.getElementById('sendEmailModal');
|
||||
if (modal) modal.classList.add('hidden');
|
||||
}
|
||||
|
||||
function sendInvoiceEmail(event) {
|
||||
event.preventDefault();
|
||||
const form = event.target;
|
||||
const formData = new FormData(form);
|
||||
|
||||
// Show loading state
|
||||
const submitBtn = form.querySelector('button[type="submit"]');
|
||||
const originalText = submitBtn.textContent;
|
||||
submitBtn.disabled = true;
|
||||
submitBtn.textContent = 'Sending...';
|
||||
|
||||
fetch("{{ url_for('invoices.send_invoice_email_route', invoice_id=invoice.id) }}", {
|
||||
method: 'POST',
|
||||
body: formData
|
||||
})
|
||||
.then(async response => {
|
||||
// Check content type to determine how to parse
|
||||
const contentType = response.headers.get('content-type') || '';
|
||||
const isJson = contentType.includes('application/json');
|
||||
|
||||
// Always try JSON first if content-type suggests it, otherwise read as text
|
||||
if (isJson) {
|
||||
const data = await response.json();
|
||||
// Handle both success and error responses
|
||||
if (!response.ok) {
|
||||
return { success: false, error: data.error || data.message || `HTTP ${response.status}: ${response.statusText}` };
|
||||
}
|
||||
return data;
|
||||
} else {
|
||||
// Not JSON, read as text (only once)
|
||||
const text = await response.text();
|
||||
if (!response.ok) {
|
||||
throw new Error(text || `HTTP ${response.status}: ${response.statusText}`);
|
||||
}
|
||||
// If successful but not JSON, return error format
|
||||
return { success: false, error: text || 'Unexpected response format' };
|
||||
}
|
||||
})
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
if (window.toastManager) {
|
||||
window.toastManager.success('Invoice email sent successfully!');
|
||||
} else {
|
||||
alert('Invoice email sent successfully!');
|
||||
}
|
||||
hideSendEmailModal();
|
||||
// Optionally reload the page to show updated status
|
||||
setTimeout(() => window.location.reload(), 1000);
|
||||
} else {
|
||||
const errorMsg = data.error || 'Failed to send email';
|
||||
console.error('Email send 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 send error:', error);
|
||||
const errorMsg = error.message || 'Failed to send email. Please check server logs for details.';
|
||||
if (window.toastManager) {
|
||||
window.toastManager.error(errorMsg);
|
||||
} else {
|
||||
alert(errorMsg);
|
||||
}
|
||||
submitBtn.disabled = false;
|
||||
submitBtn.textContent = originalText;
|
||||
});
|
||||
}
|
||||
|
||||
function showDeleteModal(invoiceId, invoiceNumber) {
|
||||
const numberEl = document.getElementById('deleteInvoiceNumber');
|
||||
const formEl = document.getElementById('deleteInvoiceForm');
|
||||
|
||||
167
app/templates/recurring_invoices/create.html
Normal file
167
app/templates/recurring_invoices/create.html
Normal file
@@ -0,0 +1,167 @@
|
||||
{% extends "base.html" %}
|
||||
{% from "components/ui.html" import page_header %}
|
||||
|
||||
{% block content %}
|
||||
{% set breadcrumbs = [
|
||||
{'text': 'Recurring Invoices', 'url': url_for('recurring_invoices.list_recurring_invoices')},
|
||||
{'text': 'Create'}
|
||||
] %}
|
||||
|
||||
{{ page_header(
|
||||
icon_class='fas fa-sync-alt',
|
||||
title_text='Create Recurring Invoice',
|
||||
subtitle_text='Set up automated invoice generation',
|
||||
breadcrumbs=breadcrumbs
|
||||
) }}
|
||||
|
||||
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow">
|
||||
<form method="POST" 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">
|
||||
<div>
|
||||
<label for="name" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Template Name *</label>
|
||||
<input type="text" id="name" name="name" 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">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="project_id" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Project *</label>
|
||||
<select id="project_id" name="project_id" 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">
|
||||
<option value="">Select Project</option>
|
||||
{% for project in projects %}
|
||||
<option value="{{ project.id }}" data-client-id="{{ project.client_id if project.client_id else '' }}" data-client-name="{{ project.client_obj.name if project.client_obj else '' }}" data-client-email="{{ project.client_obj.email if project.client_obj else '' }}" data-client-address="{{ project.client_obj.address if project.client_obj else '' }}">{{ project.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="client_id" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Client *</label>
|
||||
<select id="client_id" name="client_id" 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">
|
||||
<option value="">Select Client</option>
|
||||
{% for client in clients %}
|
||||
<option value="{{ client.id }}">{{ client.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="frequency" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Frequency *</label>
|
||||
<select id="frequency" name="frequency" 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">
|
||||
<option value="daily">Daily</option>
|
||||
<option value="weekly">Weekly</option>
|
||||
<option value="monthly" selected>Monthly</option>
|
||||
<option value="yearly">Yearly</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="interval" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Interval</label>
|
||||
<input type="number" id="interval" name="interval" value="1" min="1" 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">
|
||||
<p class="text-xs text-gray-500 mt-1">Every N periods (e.g., every 2 weeks)</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="next_run_date" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Next Run Date *</label>
|
||||
<input type="date" id="next_run_date" name="next_run_date" value="{{ default_next_run_date }}" 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">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="end_date" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">End Date (Optional)</label>
|
||||
<input type="date" id="end_date" name="end_date" 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">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="due_date_days" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Due Date (Days from Issue)</label>
|
||||
<input type="number" id="due_date_days" name="due_date_days" value="30" min="1" 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">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="tax_rate" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Tax Rate (%)</label>
|
||||
<input type="number" id="tax_rate" name="tax_rate" value="0" step="0.01" min="0" 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">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="client_name" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Client Name</label>
|
||||
<input type="text" id="client_name" name="client_name" 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">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="client_email" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Client Email</label>
|
||||
<input type="email" id="client_email" name="client_email" 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">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="client_address" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Client Address</label>
|
||||
<textarea id="client_address" name="client_address" rows="3" 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"></textarea>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="notes" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Notes</label>
|
||||
<textarea id="notes" name="notes" rows="3" 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"></textarea>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="terms" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Terms</label>
|
||||
<textarea id="terms" name="terms" rows="3" 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"></textarea>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center space-x-4">
|
||||
<label class="flex items-center">
|
||||
<input type="checkbox" name="auto_send" class="mr-2">
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300">Automatically send invoices via email when generated</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center space-x-4">
|
||||
<label class="flex items-center">
|
||||
<input type="checkbox" name="auto_include_time_entries" checked class="mr-2">
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300">Automatically include unbilled time entries</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end space-x-4">
|
||||
<a href="{{ url_for('recurring_invoices.list_recurring_invoices') }}" class="px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-800">Cancel</a>
|
||||
<button type="submit" class="px-4 py-2 bg-primary text-white rounded-lg hover:bg-primary/90">Create Recurring Invoice</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
var projectSelect = document.getElementById('project_id');
|
||||
var clientSelect = document.getElementById('client_id');
|
||||
var clientName = document.getElementById('client_name');
|
||||
var clientEmail = document.getElementById('client_email');
|
||||
var clientAddress = document.getElementById('client_address');
|
||||
|
||||
if (projectSelect && clientSelect) {
|
||||
projectSelect.addEventListener('change', function() {
|
||||
var opt = projectSelect.options[projectSelect.selectedIndex];
|
||||
if (!opt || !opt.value) return;
|
||||
|
||||
var clientId = opt.getAttribute('data-client-id');
|
||||
var name = opt.getAttribute('data-client-name') || '';
|
||||
var email = opt.getAttribute('data-client-email') || '';
|
||||
var address = opt.getAttribute('data-client-address') || '';
|
||||
|
||||
// Set client dropdown
|
||||
if (clientId) {
|
||||
clientSelect.value = clientId;
|
||||
}
|
||||
|
||||
// Set client details fields
|
||||
if (name && clientName) {
|
||||
clientName.value = name;
|
||||
}
|
||||
if (email && clientEmail) {
|
||||
clientEmail.value = email;
|
||||
}
|
||||
if (address && clientAddress) {
|
||||
clientAddress.value = address;
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
116
app/templates/recurring_invoices/edit.html
Normal file
116
app/templates/recurring_invoices/edit.html
Normal file
@@ -0,0 +1,116 @@
|
||||
{% extends "base.html" %}
|
||||
{% from "components/ui.html" import page_header %}
|
||||
|
||||
{% block content %}
|
||||
{% set breadcrumbs = [
|
||||
{'text': 'Recurring Invoices', 'url': url_for('recurring_invoices.list_recurring_invoices')},
|
||||
{'text': recurring.name, 'url': url_for('recurring_invoices.view_recurring_invoice', recurring_id=recurring.id)},
|
||||
{'text': 'Edit'}
|
||||
] %}
|
||||
|
||||
{{ page_header(
|
||||
icon_class='fas fa-sync-alt',
|
||||
title_text='Edit Recurring Invoice',
|
||||
subtitle_text=recurring.name,
|
||||
breadcrumbs=breadcrumbs
|
||||
) }}
|
||||
|
||||
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow">
|
||||
<form method="POST" 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">
|
||||
<div>
|
||||
<label for="name" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Template Name *</label>
|
||||
<input type="text" id="name" name="name" value="{{ recurring.name }}" 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">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="frequency" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Frequency *</label>
|
||||
<select id="frequency" name="frequency" 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">
|
||||
<option value="daily" {% if recurring.frequency == 'daily' %}selected{% endif %}>Daily</option>
|
||||
<option value="weekly" {% if recurring.frequency == 'weekly' %}selected{% endif %}>Weekly</option>
|
||||
<option value="monthly" {% if recurring.frequency == 'monthly' %}selected{% endif %}>Monthly</option>
|
||||
<option value="yearly" {% if recurring.frequency == 'yearly' %}selected{% endif %}>Yearly</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="interval" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Interval</label>
|
||||
<input type="number" id="interval" name="interval" value="{{ recurring.interval }}" min="1" 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">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="next_run_date" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Next Run Date *</label>
|
||||
<input type="date" id="next_run_date" name="next_run_date" value="{{ recurring.next_run_date.strftime('%Y-%m-%d') if recurring.next_run_date else '' }}" 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">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="end_date" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">End Date (Optional)</label>
|
||||
<input type="date" id="end_date" name="end_date" value="{{ recurring.end_date.strftime('%Y-%m-%d') if recurring.end_date else '' }}" 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">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="due_date_days" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Due Date (Days from Issue)</label>
|
||||
<input type="number" id="due_date_days" name="due_date_days" value="{{ recurring.due_date_days }}" min="1" 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">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="tax_rate" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Tax Rate (%)</label>
|
||||
<input type="number" id="tax_rate" name="tax_rate" value="{{ recurring.tax_rate }}" step="0.01" min="0" 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">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="client_name" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Client Name</label>
|
||||
<input type="text" id="client_name" name="client_name" value="{{ recurring.client_name }}" 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">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="client_email" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Client Email</label>
|
||||
<input type="email" id="client_email" name="client_email" value="{{ recurring.client_email or '' }}" 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">
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="client_address" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Client Address</label>
|
||||
<textarea id="client_address" name="client_address" rows="3" 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">{{ recurring.client_address or '' }}</textarea>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="notes" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Notes</label>
|
||||
<textarea id="notes" name="notes" rows="3" 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">{{ recurring.notes or '' }}</textarea>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label for="terms" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Terms</label>
|
||||
<textarea id="terms" name="terms" rows="3" 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">{{ recurring.terms or '' }}</textarea>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center space-x-4">
|
||||
<label class="flex items-center">
|
||||
<input type="checkbox" name="auto_send" {% if recurring.auto_send %}checked{% endif %} class="mr-2">
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300">Automatically send invoices via email when generated</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center space-x-4">
|
||||
<label class="flex items-center">
|
||||
<input type="checkbox" name="auto_include_time_entries" {% if recurring.auto_include_time_entries %}checked{% endif %} class="mr-2">
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300">Automatically include unbilled time entries</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center space-x-4">
|
||||
<label class="flex items-center">
|
||||
<input type="checkbox" name="is_active" {% if recurring.is_active %}checked{% endif %} class="mr-2">
|
||||
<span class="text-sm text-gray-700 dark:text-gray-300">Active</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="flex justify-end space-x-4">
|
||||
<a href="{{ url_for('recurring_invoices.view_recurring_invoice', recurring_id=recurring.id) }}" class="px-4 py-2 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-800">Cancel</a>
|
||||
<button type="submit" class="px-4 py-2 bg-primary text-white rounded-lg hover:bg-primary/90">Update Recurring Invoice</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
114
app/templates/recurring_invoices/list.html
Normal file
114
app/templates/recurring_invoices/list.html
Normal file
@@ -0,0 +1,114 @@
|
||||
{% extends "base.html" %}
|
||||
{% from "components/ui.html" import page_header, button %}
|
||||
|
||||
{% block content %}
|
||||
{% set breadcrumbs = [
|
||||
{'text': 'Recurring Invoices'}
|
||||
] %}
|
||||
|
||||
{{ page_header(
|
||||
icon_class='fas fa-sync-alt',
|
||||
title_text='Recurring Invoices',
|
||||
subtitle_text='Automate invoice generation for subscription-based billing',
|
||||
breadcrumbs=breadcrumbs,
|
||||
actions_html=''
|
||||
+ '<a href="' + url_for("recurring_invoices.create_recurring_invoice") + '" class="bg-primary text-white px-4 py-2 rounded-lg hover:bg-primary/90 transition-colors"><i class="fas fa-plus mr-2"></i>Create Recurring Invoice</a>'
|
||||
) }}
|
||||
|
||||
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow">
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
|
||||
<thead class="bg-gray-50 dark:bg-gray-800">
|
||||
<tr>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Name</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Project</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Client</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Frequency</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Next Run</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Status</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="bg-white dark:bg-gray-900 divide-y divide-gray-200 dark:divide-gray-700">
|
||||
{% for recurring in recurring_invoices %}
|
||||
<tr>
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<div class="text-sm font-medium text-gray-900 dark:text-gray-100">{{ recurring.name }}</div>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<div class="text-sm text-gray-500 dark:text-gray-400">{{ recurring.project.name if recurring.project else 'N/A' }}</div>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<div class="text-sm text-gray-500 dark:text-gray-400">{{ recurring.client_name }}</div>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<div class="text-sm text-gray-500 dark:text-gray-400">
|
||||
Every {{ recurring.interval }} {{ recurring.frequency }}{{ 's' if recurring.interval > 1 else '' }}
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
<div class="text-sm text-gray-500 dark:text-gray-400">{{ recurring.next_run_date.strftime('%Y-%m-%d') if recurring.next_run_date else 'N/A' }}</div>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap">
|
||||
{% if recurring.is_active %}
|
||||
<span class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200">Active</span>
|
||||
{% else %}
|
||||
<span class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-gray-100 text-gray-800 dark:bg-gray-900 dark:text-gray-200">Inactive</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium">
|
||||
<a href="{{ url_for('recurring_invoices.view_recurring_invoice', recurring_id=recurring.id) }}" class="text-primary hover:text-primary/80 mr-3">View</a>
|
||||
<a href="{{ url_for('recurring_invoices.edit_recurring_invoice', recurring_id=recurring.id) }}" class="text-blue-600 hover:text-blue-900 mr-3">Edit</a>
|
||||
<button onclick="generateInvoice({{ recurring.id }})" class="text-green-600 hover:text-green-900 mr-3">Generate Now</button>
|
||||
<form method="POST" action="{{ url_for('recurring_invoices.delete_recurring_invoice', recurring_id=recurring.id) }}" class="inline" onsubmit="return confirm('Are you sure you want to delete this recurring invoice?');">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<button type="submit" class="text-red-600 hover:text-red-900">Delete</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
<tr>
|
||||
<td colspan="7" class="px-6 py-4 text-center text-gray-500 dark:text-gray-400">No recurring invoices found. <a href="{{ url_for('recurring_invoices.create_recurring_invoice') }}" class="text-primary">Create one</a> to get started.</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function generateInvoice(recurringId) {
|
||||
if (!confirm('Generate an invoice from this recurring template now?')) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Get CSRF token from meta tag
|
||||
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content || '';
|
||||
|
||||
fetch(`/recurring-invoices/${recurringId}/generate`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRFToken': csrfToken
|
||||
},
|
||||
body: JSON.stringify({
|
||||
csrf_token: csrfToken
|
||||
})
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
alert(`Invoice ${data.invoice_number} generated successfully!`);
|
||||
window.location.href = `/invoices/${data.invoice_id}/edit`;
|
||||
} else {
|
||||
alert('Error: ' + (data.error || 'Failed to generate invoice'));
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error:', error);
|
||||
alert('Failed to generate invoice');
|
||||
});
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
159
app/templates/recurring_invoices/view.html
Normal file
159
app/templates/recurring_invoices/view.html
Normal file
@@ -0,0 +1,159 @@
|
||||
{% extends "base.html" %}
|
||||
{% from "components/ui.html" import page_header %}
|
||||
|
||||
{% block content %}
|
||||
{% set breadcrumbs = [
|
||||
{'text': 'Recurring Invoices', 'url': url_for('recurring_invoices.list_recurring_invoices')},
|
||||
{'text': recurring.name}
|
||||
] %}
|
||||
|
||||
{{ page_header(
|
||||
icon_class='fas fa-sync-alt',
|
||||
title_text=recurring.name,
|
||||
subtitle_text='Recurring Invoice Template',
|
||||
breadcrumbs=breadcrumbs,
|
||||
actions_html=''
|
||||
+ '<a href="' + url_for("recurring_invoices.edit_recurring_invoice", recurring_id=recurring.id) + '" class="bg-blue-600 text-white px-4 py-2 rounded-lg hover:bg-blue-700 mr-2">Edit</a>'
|
||||
+ '<button onclick="generateInvoice(' + recurring.id|string + ')" class="bg-green-600 text-white px-4 py-2 rounded-lg hover:bg-green-700">Generate Now</button>'
|
||||
) }}
|
||||
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6 mb-6">
|
||||
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow">
|
||||
<h3 class="text-lg font-semibold mb-4">Template Details</h3>
|
||||
<dl class="space-y-2">
|
||||
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400">Project</dt>
|
||||
<dd class="text-sm text-gray-900 dark:text-gray-100">{{ recurring.project.name if recurring.project else 'N/A' }}</dd>
|
||||
|
||||
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400">Client</dt>
|
||||
<dd class="text-sm text-gray-900 dark:text-gray-100">{{ recurring.client_name }}</dd>
|
||||
|
||||
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400">Frequency</dt>
|
||||
<dd class="text-sm text-gray-900 dark:text-gray-100">Every {{ recurring.interval }} {{ recurring.frequency }}{{ 's' if recurring.interval > 1 else '' }}</dd>
|
||||
|
||||
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400">Next Run Date</dt>
|
||||
<dd class="text-sm text-gray-900 dark:text-gray-100">{{ recurring.next_run_date.strftime('%Y-%m-%d') if recurring.next_run_date else 'N/A' }}</dd>
|
||||
|
||||
{% if recurring.end_date %}
|
||||
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400">End Date</dt>
|
||||
<dd class="text-sm text-gray-900 dark:text-gray-100">{{ recurring.end_date.strftime('%Y-%m-%d') }}</dd>
|
||||
{% endif %}
|
||||
|
||||
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400">Status</dt>
|
||||
<dd class="text-sm">
|
||||
{% if recurring.is_active %}
|
||||
<span class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200">Active</span>
|
||||
{% else %}
|
||||
<span class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-gray-100 text-gray-800 dark:bg-gray-900 dark:text-gray-200">Inactive</span>
|
||||
{% endif %}
|
||||
</dd>
|
||||
</dl>
|
||||
</div>
|
||||
|
||||
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow">
|
||||
<h3 class="text-lg font-semibold mb-4">Invoice Settings</h3>
|
||||
<dl class="space-y-2">
|
||||
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400">Due Date (Days)</dt>
|
||||
<dd class="text-sm text-gray-900 dark:text-gray-100">{{ recurring.due_date_days }} days from issue date</dd>
|
||||
|
||||
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400">Tax Rate</dt>
|
||||
<dd class="text-sm text-gray-900 dark:text-gray-100">{{ recurring.tax_rate }}%</dd>
|
||||
|
||||
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400">Currency</dt>
|
||||
<dd class="text-sm text-gray-900 dark:text-gray-100">{{ recurring.currency_code }}</dd>
|
||||
|
||||
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400">Auto Send</dt>
|
||||
<dd class="text-sm text-gray-900 dark:text-gray-100">
|
||||
{% if recurring.auto_send %}
|
||||
<span class="text-green-600">Yes</span>
|
||||
{% else %}
|
||||
<span class="text-gray-500">No</span>
|
||||
{% endif %}
|
||||
</dd>
|
||||
|
||||
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400">Auto Include Time Entries</dt>
|
||||
<dd class="text-sm text-gray-900 dark:text-gray-100">
|
||||
{% if recurring.auto_include_time_entries %}
|
||||
<span class="text-green-600">Yes</span>
|
||||
{% else %}
|
||||
<span class="text-gray-500">No</span>
|
||||
{% endif %}
|
||||
</dd>
|
||||
|
||||
{% if recurring.last_generated_at %}
|
||||
<dt class="text-sm font-medium text-gray-500 dark:text-gray-400">Last Generated</dt>
|
||||
<dd class="text-sm text-gray-900 dark:text-gray-100">{{ recurring.last_generated_at.strftime('%Y-%m-%d %H:%M') }}</dd>
|
||||
{% endif %}
|
||||
</dl>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{% if generated_invoices %}
|
||||
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow">
|
||||
<h3 class="text-lg font-semibold mb-4">Recently Generated Invoices</h3>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="min-w-full divide-y divide-gray-200 dark:divide-gray-700">
|
||||
<thead class="bg-gray-50 dark:bg-gray-800">
|
||||
<tr>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">Invoice Number</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">Issue Date</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">Amount</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">Status</th>
|
||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody class="bg-white dark:bg-gray-900 divide-y divide-gray-200 dark:divide-gray-700">
|
||||
{% for invoice in generated_invoices %}
|
||||
<tr>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100">{{ invoice.invoice_number }}</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400">{{ invoice.issue_date.strftime('%Y-%m-%d') if invoice.issue_date else 'N/A' }}</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm text-gray-500 dark:text-gray-400">{{ invoice.currency_code }} {{ invoice.total_amount }}</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm">
|
||||
<span class="px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200">{{ invoice.status }}</span>
|
||||
</td>
|
||||
<td class="px-6 py-4 whitespace-nowrap text-sm font-medium">
|
||||
<a href="{{ url_for('invoices.view_invoice', invoice_id=invoice.id) }}" class="text-primary hover:text-primary/80">View</a>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<script>
|
||||
function generateInvoice(recurringId) {
|
||||
if (!confirm('Generate an invoice from this recurring template now?')) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Get CSRF token from meta tag
|
||||
const csrfToken = document.querySelector('meta[name="csrf-token"]')?.content || '';
|
||||
|
||||
fetch(`/recurring-invoices/${recurringId}/generate`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'X-CSRFToken': csrfToken
|
||||
},
|
||||
body: JSON.stringify({
|
||||
csrf_token: csrfToken
|
||||
})
|
||||
})
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
if (data.success) {
|
||||
alert(`Invoice ${data.invoice_number} generated successfully!`);
|
||||
window.location.href = `/invoices/${data.invoice_id}/edit`;
|
||||
} else {
|
||||
alert('Error: ' + (data.error || 'Failed to generate invoice'));
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error:', error);
|
||||
alert('Failed to generate invoice');
|
||||
});
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -2,9 +2,11 @@
|
||||
|
||||
import os
|
||||
from flask import current_app, render_template, url_for
|
||||
from jinja2 import Template as JinjaTemplate
|
||||
from flask_mail import Mail, Message
|
||||
from threading import Thread
|
||||
from datetime import datetime, timedelta
|
||||
from app import db
|
||||
|
||||
|
||||
mail = Mail()
|
||||
@@ -463,3 +465,228 @@ TimeTracker - Time Tracking & Project Management
|
||||
current_app.logger.exception("[EMAIL TEST] Full exception trace:")
|
||||
return False, f'Failed to send test email: {str(e)}'
|
||||
|
||||
|
||||
def send_invoice_email(invoice, recipient_email, sender_user=None, custom_message=None, email_template_id=None):
|
||||
"""Send an invoice via email with PDF attachment
|
||||
|
||||
Args:
|
||||
invoice: Invoice object
|
||||
recipient_email: Email address to send to
|
||||
sender_user: User object who is sending (for tracking)
|
||||
custom_message: Optional custom message to include in email
|
||||
email_template_id: Optional email template ID to use
|
||||
|
||||
Returns:
|
||||
tuple: (success: bool, invoice_email: InvoiceEmail or None, message: str)
|
||||
"""
|
||||
try:
|
||||
from app.models import InvoiceEmail, Settings
|
||||
|
||||
current_app.logger.info(f"[INVOICE EMAIL] Sending invoice {invoice.invoice_number} to {recipient_email}")
|
||||
|
||||
# Generate PDF
|
||||
pdf_bytes = None
|
||||
try:
|
||||
from app.utils.pdf_generator import InvoicePDFGenerator
|
||||
settings = Settings.get_settings()
|
||||
pdf_generator = InvoicePDFGenerator(invoice, settings=settings, page_size='A4')
|
||||
pdf_bytes = pdf_generator.generate_pdf()
|
||||
if not pdf_bytes:
|
||||
raise ValueError("PDF generator returned None")
|
||||
current_app.logger.info(f"[INVOICE EMAIL] PDF generated successfully - size: {len(pdf_bytes)} bytes")
|
||||
except Exception as pdf_error:
|
||||
current_app.logger.warning(f"[INVOICE EMAIL] PDF generation failed, trying fallback: {pdf_error}")
|
||||
current_app.logger.exception("[INVOICE EMAIL] PDF generation error details:")
|
||||
try:
|
||||
from app.utils.pdf_generator_fallback import InvoicePDFGeneratorFallback
|
||||
settings = Settings.get_settings()
|
||||
pdf_generator = InvoicePDFGeneratorFallback(invoice, settings=settings)
|
||||
pdf_bytes = pdf_generator.generate_pdf()
|
||||
if not pdf_bytes:
|
||||
raise ValueError("PDF fallback generator returned None")
|
||||
current_app.logger.info(f"[INVOICE EMAIL] PDF generated via fallback - size: {len(pdf_bytes)} bytes")
|
||||
except Exception as fallback_error:
|
||||
current_app.logger.error(f"[INVOICE EMAIL] Both PDF generators failed: {fallback_error}")
|
||||
current_app.logger.exception("[INVOICE EMAIL] Fallback PDF generation error details:")
|
||||
return False, None, f'PDF generation failed: {str(fallback_error)}'
|
||||
|
||||
if not pdf_bytes:
|
||||
current_app.logger.error("[INVOICE EMAIL] PDF bytes is None after generation")
|
||||
return False, None, 'PDF generation returned empty result'
|
||||
|
||||
# Get settings for email subject/body
|
||||
settings = Settings.get_settings()
|
||||
company_name = settings.company_name if settings else 'Your Company'
|
||||
|
||||
# Create email subject
|
||||
subject = f"Invoice {invoice.invoice_number} from {company_name}"
|
||||
|
||||
# Get email template if specified
|
||||
html_body = None
|
||||
text_body = None
|
||||
|
||||
if email_template_id:
|
||||
try:
|
||||
from app.models import InvoiceTemplate
|
||||
email_template = InvoiceTemplate.query.get(email_template_id)
|
||||
if email_template and email_template.html:
|
||||
# Use custom template
|
||||
# Ensure the HTML is properly formatted for email
|
||||
template_html = email_template.html.strip()
|
||||
|
||||
# If CSS is provided separately, wrap it in <style> tags
|
||||
if email_template.css and email_template.css.strip():
|
||||
css_content = email_template.css.strip()
|
||||
# Check if CSS is already wrapped in <style> tags
|
||||
if not css_content.startswith('<style'):
|
||||
css_content = f'<style>\n{css_content}\n</style>'
|
||||
# Insert CSS into HTML if not already present
|
||||
if '<style>' not in template_html and '</style>' not in template_html:
|
||||
# Try to insert before </head> or at the beginning if no head tag
|
||||
if '</head>' in template_html:
|
||||
template_html = template_html.replace('</head>', f'{css_content}\n</head>')
|
||||
elif '<body>' in template_html:
|
||||
template_html = template_html.replace('<body>', f'{css_content}\n<body>')
|
||||
else:
|
||||
template_html = f'{css_content}\n{template_html}'
|
||||
|
||||
# Ensure HTML has proper structure
|
||||
if not template_html.strip().startswith('<!DOCTYPE') and not template_html.strip().startswith('<html'):
|
||||
# Wrap in minimal HTML structure if needed
|
||||
if '<html' not in template_html:
|
||||
template_html = f'''<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
</head>
|
||||
<body>
|
||||
{template_html}
|
||||
</body>
|
||||
</html>'''
|
||||
|
||||
template = JinjaTemplate(template_html)
|
||||
html_body = template.render(
|
||||
invoice=invoice,
|
||||
company_name=company_name,
|
||||
custom_message=custom_message
|
||||
)
|
||||
# Generate text version from HTML if needed
|
||||
text_body = f"Invoice {invoice.invoice_number} - Please see attached PDF for details."
|
||||
except Exception as template_error:
|
||||
current_app.logger.warning(f"[INVOICE EMAIL] Custom template failed: {template_error}")
|
||||
current_app.logger.exception("[INVOICE EMAIL] Template error details:")
|
||||
|
||||
# Fallback to default template
|
||||
if not html_body:
|
||||
# Create email body
|
||||
text_body = f"""
|
||||
Hello,
|
||||
|
||||
Please find attached invoice {invoice.invoice_number} for your records.
|
||||
|
||||
Invoice Details:
|
||||
- Invoice Number: {invoice.invoice_number}
|
||||
- Issue Date: {invoice.issue_date.strftime('%Y-%m-%d') if invoice.issue_date else 'N/A'}
|
||||
- Due Date: {invoice.due_date.strftime('%Y-%m-%d') if invoice.due_date else 'N/A'}
|
||||
- Amount: {invoice.currency_code} {invoice.total_amount}
|
||||
|
||||
"""
|
||||
|
||||
if custom_message:
|
||||
text_body += f"\n{custom_message}\n\n"
|
||||
|
||||
text_body += f"""
|
||||
Please remit payment by the due date.
|
||||
|
||||
Thank you for your business!
|
||||
|
||||
---
|
||||
{company_name}
|
||||
"""
|
||||
|
||||
# Render HTML template
|
||||
try:
|
||||
html_body = render_template(
|
||||
'email/invoice.html',
|
||||
invoice=invoice,
|
||||
company_name=company_name,
|
||||
custom_message=custom_message
|
||||
)
|
||||
except Exception as template_error:
|
||||
current_app.logger.warning(f"[INVOICE EMAIL] HTML template not available: {template_error}")
|
||||
html_body = None
|
||||
|
||||
# Get sender user ID
|
||||
sender_id = sender_user.id if sender_user else None
|
||||
if not sender_id:
|
||||
# Try to get from invoice creator
|
||||
sender_id = invoice.created_by
|
||||
|
||||
# Send email synchronously to catch errors
|
||||
attachments = [
|
||||
(f'invoice_{invoice.invoice_number}.pdf', 'application/pdf', pdf_bytes)
|
||||
]
|
||||
|
||||
# Create message
|
||||
msg = Message(
|
||||
subject=subject,
|
||||
recipients=[recipient_email],
|
||||
body=text_body,
|
||||
html=html_body,
|
||||
sender=current_app.config['MAIL_DEFAULT_SENDER']
|
||||
)
|
||||
|
||||
# Add attachments
|
||||
for filename, content_type, data in attachments:
|
||||
msg.attach(filename, content_type, data)
|
||||
|
||||
# Send synchronously to catch errors
|
||||
try:
|
||||
current_app.logger.info(f"[INVOICE EMAIL] Attempting to send email to {recipient_email}")
|
||||
current_app.logger.debug(f"[INVOICE EMAIL] Email config - Server: {current_app.config.get('MAIL_SERVER')}, Port: {current_app.config.get('MAIL_PORT')}")
|
||||
mail.send(msg)
|
||||
current_app.logger.info(f"[INVOICE EMAIL] ✓ Email sent successfully to {recipient_email}")
|
||||
except Exception as send_error:
|
||||
current_app.logger.error(f"[INVOICE EMAIL] ✗ Failed to send email: {type(send_error).__name__}: {str(send_error)}")
|
||||
current_app.logger.exception("[INVOICE EMAIL] Email send error details:")
|
||||
raise send_error
|
||||
|
||||
# Create email tracking record
|
||||
invoice_email = InvoiceEmail(
|
||||
invoice_id=invoice.id,
|
||||
recipient_email=recipient_email,
|
||||
subject=subject,
|
||||
sent_by=sender_id
|
||||
)
|
||||
db.session.add(invoice_email)
|
||||
|
||||
# Update invoice status to 'sent' if it's still 'draft'
|
||||
if invoice.status == 'draft':
|
||||
invoice.status = 'sent'
|
||||
|
||||
db.session.commit()
|
||||
|
||||
return True, invoice_email, f'Invoice email sent successfully to {recipient_email}'
|
||||
|
||||
except Exception as e:
|
||||
current_app.logger.error(f"[INVOICE EMAIL] ✗ Failed to send invoice email: {type(e).__name__}: {str(e)}")
|
||||
current_app.logger.exception("[INVOICE EMAIL] Full exception trace:")
|
||||
|
||||
# Try to create failed tracking record
|
||||
try:
|
||||
from app.models import InvoiceEmail
|
||||
sender_id = sender_user.id if sender_user else invoice.created_by
|
||||
invoice_email = InvoiceEmail(
|
||||
invoice_id=invoice.id,
|
||||
recipient_email=recipient_email,
|
||||
subject=f"Invoice {invoice.invoice_number}",
|
||||
sent_by=sender_id
|
||||
)
|
||||
invoice_email.mark_failed(str(e))
|
||||
db.session.add(invoice_email)
|
||||
db.session.commit()
|
||||
except Exception:
|
||||
db.session.rollback()
|
||||
|
||||
return False, None, f'Failed to send invoice email: {str(e)}'
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ import logging
|
||||
from datetime import datetime, timedelta
|
||||
from flask import current_app
|
||||
from app import db
|
||||
from app.models import Invoice, User, TimeEntry, Project, BudgetAlert
|
||||
from app.models import Invoice, User, TimeEntry, Project, BudgetAlert, RecurringInvoice
|
||||
from app.utils.email import send_overdue_invoice_notification, send_weekly_summary
|
||||
from app.utils.budget_forecasting import check_budget_alerts
|
||||
|
||||
@@ -189,6 +189,66 @@ def check_project_budget_alerts():
|
||||
return 0
|
||||
|
||||
|
||||
def generate_recurring_invoices():
|
||||
"""Generate invoices from active recurring invoice templates
|
||||
|
||||
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}")
|
||||
|
||||
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
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error generating recurring invoices: {e}")
|
||||
return 0
|
||||
|
||||
|
||||
def register_scheduled_tasks(scheduler):
|
||||
"""Register all scheduled tasks with APScheduler
|
||||
|
||||
@@ -233,6 +293,18 @@ def register_scheduled_tasks(scheduler):
|
||||
)
|
||||
logger.info("Registered budget alerts check task")
|
||||
|
||||
# Generate recurring invoices daily at 8 AM
|
||||
scheduler.add_job(
|
||||
func=generate_recurring_invoices,
|
||||
trigger='cron',
|
||||
hour=8,
|
||||
minute=0,
|
||||
id='generate_recurring_invoices',
|
||||
name='Generate recurring invoices',
|
||||
replace_existing=True
|
||||
)
|
||||
logger.info("Registered recurring invoices generation task")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error registering scheduled tasks: {e}")
|
||||
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
"""Add recurring invoices and email tracking
|
||||
|
||||
Revision ID: 045
|
||||
Revises: 044
|
||||
Create Date: 2025-01-22
|
||||
|
||||
"""
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
from sqlalchemy.dialects import postgresql
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = '045'
|
||||
down_revision = '044'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade():
|
||||
"""Create recurring_invoices and invoice_emails tables, add recurring_invoice_id to invoices"""
|
||||
|
||||
# Create recurring_invoices table
|
||||
op.create_table('recurring_invoices',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('name', sa.String(length=200), nullable=False),
|
||||
sa.Column('project_id', sa.Integer(), nullable=False),
|
||||
sa.Column('client_id', sa.Integer(), nullable=False),
|
||||
sa.Column('frequency', sa.String(length=20), nullable=False),
|
||||
sa.Column('interval', sa.Integer(), nullable=False, server_default='1'),
|
||||
sa.Column('next_run_date', sa.Date(), nullable=False),
|
||||
sa.Column('end_date', sa.Date(), nullable=True),
|
||||
sa.Column('client_name', sa.String(length=200), nullable=False),
|
||||
sa.Column('client_email', sa.String(length=200), nullable=True),
|
||||
sa.Column('client_address', sa.Text(), nullable=True),
|
||||
sa.Column('due_date_days', sa.Integer(), nullable=False, server_default='30'),
|
||||
sa.Column('tax_rate', sa.Numeric(precision=5, scale=2), nullable=False, server_default='0'),
|
||||
sa.Column('currency_code', sa.String(length=3), nullable=False, server_default='EUR'),
|
||||
sa.Column('notes', sa.Text(), nullable=True),
|
||||
sa.Column('terms', sa.Text(), nullable=True),
|
||||
sa.Column('template_id', sa.Integer(), nullable=True),
|
||||
sa.Column('auto_send', sa.Boolean(), nullable=False, server_default='false'),
|
||||
sa.Column('auto_include_time_entries', sa.Boolean(), nullable=False, server_default='true'),
|
||||
sa.Column('is_active', sa.Boolean(), nullable=False, server_default='true'),
|
||||
sa.Column('created_by', sa.Integer(), nullable=False),
|
||||
sa.Column('created_at', sa.DateTime(), nullable=False, server_default=sa.text('CURRENT_TIMESTAMP')),
|
||||
sa.Column('updated_at', sa.DateTime(), nullable=False, server_default=sa.text('CURRENT_TIMESTAMP')),
|
||||
sa.Column('last_generated_at', sa.DateTime(), nullable=True),
|
||||
sa.ForeignKeyConstraint(['project_id'], ['projects.id'], ondelete='CASCADE'),
|
||||
sa.ForeignKeyConstraint(['client_id'], ['clients.id'], ondelete='CASCADE'),
|
||||
sa.ForeignKeyConstraint(['created_by'], ['users.id'], ondelete='CASCADE'),
|
||||
sa.ForeignKeyConstraint(['template_id'], ['invoice_templates.id'], ondelete='SET NULL'),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
|
||||
# Create indexes for recurring_invoices
|
||||
op.create_index('ix_recurring_invoices_project_id', 'recurring_invoices', ['project_id'])
|
||||
op.create_index('ix_recurring_invoices_client_id', 'recurring_invoices', ['client_id'])
|
||||
op.create_index('ix_recurring_invoices_next_run_date', 'recurring_invoices', ['next_run_date'])
|
||||
op.create_index('ix_recurring_invoices_is_active', 'recurring_invoices', ['is_active'])
|
||||
|
||||
# Create invoice_emails table
|
||||
op.create_table('invoice_emails',
|
||||
sa.Column('id', sa.Integer(), nullable=False),
|
||||
sa.Column('invoice_id', sa.Integer(), nullable=False),
|
||||
sa.Column('recipient_email', sa.String(length=200), nullable=False),
|
||||
sa.Column('subject', sa.String(length=500), nullable=False),
|
||||
sa.Column('sent_at', sa.DateTime(), nullable=False, server_default=sa.text('CURRENT_TIMESTAMP')),
|
||||
sa.Column('sent_by', sa.Integer(), nullable=False),
|
||||
sa.Column('opened_at', sa.DateTime(), nullable=True),
|
||||
sa.Column('opened_count', sa.Integer(), nullable=False, server_default='0'),
|
||||
sa.Column('last_opened_at', sa.DateTime(), nullable=True),
|
||||
sa.Column('paid_at', sa.DateTime(), nullable=True),
|
||||
sa.Column('status', sa.String(length=20), nullable=False, server_default='sent'),
|
||||
sa.Column('error_message', sa.Text(), nullable=True),
|
||||
sa.Column('created_at', sa.DateTime(), nullable=False, server_default=sa.text('CURRENT_TIMESTAMP')),
|
||||
sa.Column('updated_at', sa.DateTime(), nullable=False, server_default=sa.text('CURRENT_TIMESTAMP')),
|
||||
sa.ForeignKeyConstraint(['invoice_id'], ['invoices.id'], ondelete='CASCADE'),
|
||||
sa.ForeignKeyConstraint(['sent_by'], ['users.id'], ondelete='CASCADE'),
|
||||
sa.PrimaryKeyConstraint('id')
|
||||
)
|
||||
|
||||
# Create indexes for invoice_emails
|
||||
op.create_index('ix_invoice_emails_invoice_id', 'invoice_emails', ['invoice_id'])
|
||||
op.create_index('ix_invoice_emails_recipient_email', 'invoice_emails', ['recipient_email'])
|
||||
op.create_index('ix_invoice_emails_status', 'invoice_emails', ['status'])
|
||||
op.create_index('ix_invoice_emails_sent_at', 'invoice_emails', ['sent_at'])
|
||||
|
||||
# Add recurring_invoice_id to invoices table
|
||||
op.add_column('invoices', sa.Column('recurring_invoice_id', sa.Integer(), nullable=True))
|
||||
op.create_index('ix_invoices_recurring_invoice_id', 'invoices', ['recurring_invoice_id'])
|
||||
op.create_foreign_key('fk_invoices_recurring_invoice_id', 'invoices', 'recurring_invoices', ['recurring_invoice_id'], ['id'], ondelete='SET NULL')
|
||||
|
||||
|
||||
def downgrade():
|
||||
"""Remove recurring invoices and email tracking tables"""
|
||||
|
||||
# Remove recurring_invoice_id from invoices
|
||||
op.drop_constraint('fk_invoices_recurring_invoice_id', 'invoices', type_='foreignkey')
|
||||
op.drop_index('ix_invoices_recurring_invoice_id', table_name='invoices')
|
||||
op.drop_column('invoices', 'recurring_invoice_id')
|
||||
|
||||
# Drop invoice_emails table
|
||||
op.drop_index('ix_invoice_emails_sent_at', table_name='invoice_emails')
|
||||
op.drop_index('ix_invoice_emails_status', table_name='invoice_emails')
|
||||
op.drop_index('ix_invoice_emails_recipient_email', table_name='invoice_emails')
|
||||
op.drop_index('ix_invoice_emails_invoice_id', table_name='invoice_emails')
|
||||
op.drop_table('invoice_emails')
|
||||
|
||||
# Drop recurring_invoices table
|
||||
op.drop_index('ix_recurring_invoices_is_active', table_name='recurring_invoices')
|
||||
op.drop_index('ix_recurring_invoices_next_run_date', table_name='recurring_invoices')
|
||||
op.drop_index('ix_recurring_invoices_client_id', table_name='recurring_invoices')
|
||||
op.drop_index('ix_recurring_invoices_project_id', table_name='recurring_invoices')
|
||||
op.drop_table('recurring_invoices')
|
||||
|
||||
Reference in New Issue
Block a user