mirror of
https://github.com/DRYTRIX/TimeTracker.git
synced 2026-05-19 04:40:32 -05:00
@@ -1,30 +0,0 @@
|
||||
feat: enhance CSRF protection with double-submit cookie pattern
|
||||
|
||||
Implement comprehensive CSRF token management with cookie-based
|
||||
double-submit pattern to improve security and SPA compatibility.
|
||||
|
||||
Changes:
|
||||
- Add CSRF cookie configuration in app/config.py
|
||||
* WTF_CSRF_SSL_STRICT for strict SSL validation in production
|
||||
* CSRF_COOKIE_NAME (default: XSRF-TOKEN) for framework compatibility
|
||||
* CSRF_COOKIE_SECURE inherits from SESSION_COOKIE_SECURE by default
|
||||
* CSRF_COOKIE_HTTPONLY, CSRF_COOKIE_SAMESITE, and CSRF_COOKIE_DOMAIN settings
|
||||
|
||||
- Implement CSRF cookie handler in app/__init__.py
|
||||
* Set CSRF token in cookie after each request
|
||||
* Configure cookie with secure flags based on environment settings
|
||||
* Support for double-submit pattern and SPA frameworks
|
||||
|
||||
- Add client-side CSRF token management in base.html
|
||||
* JavaScript utilities for token retrieval and validation
|
||||
* Cookie synchronization for frameworks that read XSRF-TOKEN
|
||||
* Auto-refresh mechanism for stale tokens (>15 minutes)
|
||||
* Pre-submit token validation and refresh
|
||||
* User notification for missing cookies/tokens
|
||||
|
||||
- Clean up docker-compose.yml environment variables
|
||||
* Remove redundant SECRET_KEY, WTF_CSRF_*, and cookie security settings
|
||||
* These are now managed through .env files and config.py
|
||||
|
||||
This enhancement provides better CSRF protection while maintaining
|
||||
compatibility with modern JavaScript frameworks and SPA architectures.
|
||||
+28
-3
@@ -121,6 +121,12 @@ class Invoice(db.Model):
|
||||
return 0
|
||||
return float((self.amount_paid or 0) / self.total_amount * 100)
|
||||
|
||||
@property
|
||||
def sorted_payments(self):
|
||||
"""Get payments sorted by payment_date and created_at (newest first)"""
|
||||
from app.models.payments import Payment
|
||||
return self.payments.order_by(Payment.payment_date.desc(), Payment.created_at.desc()).all()
|
||||
|
||||
def update_payment_status(self):
|
||||
"""Update payment status based on amount paid"""
|
||||
if not self.amount_paid or self.amount_paid == 0:
|
||||
@@ -134,7 +140,25 @@ class Invoice(db.Model):
|
||||
self.payment_status = 'partially_paid'
|
||||
|
||||
def record_payment(self, amount, payment_date=None, payment_method=None, payment_reference=None, payment_notes=None):
|
||||
"""Record a payment for this invoice"""
|
||||
"""
|
||||
DEPRECATED: Record a payment for this invoice.
|
||||
|
||||
This method is deprecated. Please use the Payment model (app.models.Payment)
|
||||
to record payments instead. The Payment model provides:
|
||||
- Multiple payment tracking per invoice
|
||||
- Payment status management (completed, pending, failed, refunded)
|
||||
- Gateway fee tracking
|
||||
- Better audit trail
|
||||
|
||||
This method is kept for backwards compatibility only and may be removed in a future version.
|
||||
"""
|
||||
import warnings
|
||||
warnings.warn(
|
||||
"Invoice.record_payment() is deprecated. Use the Payment model instead.",
|
||||
DeprecationWarning,
|
||||
stacklevel=2
|
||||
)
|
||||
|
||||
self.amount_paid = (self.amount_paid or 0) + Decimal(str(amount))
|
||||
self.payment_date = payment_date or datetime.utcnow().date()
|
||||
if payment_method:
|
||||
@@ -155,7 +179,7 @@ class Invoice(db.Model):
|
||||
self.status = 'sent'
|
||||
|
||||
def calculate_totals(self):
|
||||
"""Calculate invoice totals from items and extra goods"""
|
||||
"""Calculate invoice totals from items, extra goods, and expenses"""
|
||||
# Optionally apply tax rules before totals
|
||||
try:
|
||||
self._apply_tax_rules_if_any()
|
||||
@@ -163,7 +187,8 @@ class Invoice(db.Model):
|
||||
pass
|
||||
items_total = sum(item.total_amount for item in self.items)
|
||||
goods_total = sum(good.total_amount for good in self.extra_goods)
|
||||
subtotal = items_total + goods_total
|
||||
expenses_total = sum(expense.total_amount for expense in self.expenses)
|
||||
subtotal = items_total + goods_total + expenses_total
|
||||
self.subtotal = subtotal
|
||||
self.tax_amount = subtotal * (self.tax_rate / 100)
|
||||
self.total_amount = subtotal + self.tax_amount
|
||||
|
||||
@@ -32,6 +32,7 @@ class Settings(db.Model):
|
||||
# PDF template customization
|
||||
invoice_pdf_template_html = db.Column(db.Text, default='', nullable=True)
|
||||
invoice_pdf_template_css = db.Column(db.Text, default='', nullable=True)
|
||||
invoice_pdf_design_json = db.Column(db.Text, default='', nullable=True) # Konva.js design state
|
||||
|
||||
# Invoice defaults
|
||||
invoice_prefix = db.Column(db.String(10), default='INV', nullable=False)
|
||||
@@ -80,6 +81,7 @@ class Settings(db.Model):
|
||||
# PDF template customization
|
||||
self.invoice_pdf_template_html = kwargs.get('invoice_pdf_template_html', '')
|
||||
self.invoice_pdf_template_css = kwargs.get('invoice_pdf_template_css', '')
|
||||
self.invoice_pdf_design_json = kwargs.get('invoice_pdf_design_json', '')
|
||||
|
||||
# Set invoice defaults
|
||||
self.invoice_prefix = kwargs.get('invoice_prefix', 'INV')
|
||||
@@ -171,6 +173,7 @@ class Settings(db.Model):
|
||||
'invoice_notes': self.invoice_notes,
|
||||
'invoice_pdf_template_html': self.invoice_pdf_template_html,
|
||||
'invoice_pdf_template_css': self.invoice_pdf_template_css,
|
||||
'invoice_pdf_design_json': self.invoice_pdf_design_json,
|
||||
'allow_analytics': self.allow_analytics,
|
||||
'mail_enabled': self.mail_enabled,
|
||||
'mail_server': self.mail_server,
|
||||
|
||||
+77
-17
@@ -50,7 +50,12 @@ def allowed_logo_file(filename):
|
||||
def get_upload_folder():
|
||||
"""Get the upload folder path for logos"""
|
||||
upload_folder = os.path.join(current_app.root_path, 'static', 'uploads', 'logos')
|
||||
os.makedirs(upload_folder, exist_ok=True)
|
||||
try:
|
||||
os.makedirs(upload_folder, exist_ok=True)
|
||||
current_app.logger.info(f'Logo upload folder ensured: {upload_folder}')
|
||||
except Exception as e:
|
||||
current_app.logger.error(f'Error creating upload folder {upload_folder}: {str(e)}')
|
||||
raise
|
||||
return upload_folder
|
||||
|
||||
@admin_bp.route('/admin')
|
||||
@@ -300,6 +305,13 @@ def toggle_telemetry():
|
||||
return redirect(url_for('admin.telemetry_dashboard'))
|
||||
|
||||
|
||||
@admin_bp.route('/admin/clear-cache')
|
||||
@login_required
|
||||
@admin_or_permission_required('manage_settings')
|
||||
def clear_cache():
|
||||
"""Cache clearing utility page"""
|
||||
return render_template('admin/clear_cache.html')
|
||||
|
||||
@admin_bp.route('/admin/settings', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
@admin_or_permission_required('manage_settings')
|
||||
@@ -383,8 +395,10 @@ def pdf_layout():
|
||||
if request.method == 'POST':
|
||||
html_template = request.form.get('invoice_pdf_template_html', '')
|
||||
css_template = request.form.get('invoice_pdf_template_css', '')
|
||||
design_json = request.form.get('design_json', '')
|
||||
settings_obj.invoice_pdf_template_html = html_template
|
||||
settings_obj.invoice_pdf_template_css = css_template
|
||||
settings_obj.invoice_pdf_design_json = design_json
|
||||
if not safe_commit('admin_update_pdf_layout'):
|
||||
from flask_babel import gettext as _
|
||||
flash(_('Could not update PDF layout due to a database error.'), 'error')
|
||||
@@ -395,6 +409,7 @@ def pdf_layout():
|
||||
# Provide initial defaults to the template if no custom HTML/CSS saved
|
||||
initial_html = settings_obj.invoice_pdf_template_html or ''
|
||||
initial_css = settings_obj.invoice_pdf_template_css or ''
|
||||
design_json = settings_obj.invoice_pdf_design_json or ''
|
||||
try:
|
||||
if not initial_html:
|
||||
env = current_app.jinja_env
|
||||
@@ -412,7 +427,7 @@ def pdf_layout():
|
||||
initial_css = css_src
|
||||
except Exception:
|
||||
pass
|
||||
return render_template('admin/pdf_layout.html', settings=settings_obj, initial_html=initial_html, initial_css=initial_css)
|
||||
return render_template('admin/pdf_layout.html', settings=settings_obj, initial_html=initial_html, initial_css=initial_css, design_json=design_json)
|
||||
|
||||
|
||||
@admin_bp.route('/admin/pdf-layout/reset', methods=['POST'])
|
||||
@@ -424,6 +439,7 @@ def pdf_layout_reset():
|
||||
settings_obj = Settings.get_settings()
|
||||
settings_obj.invoice_pdf_template_html = ''
|
||||
settings_obj.invoice_pdf_template_css = ''
|
||||
settings_obj.invoice_pdf_design_json = ''
|
||||
if not safe_commit('admin_reset_pdf_layout'):
|
||||
flash(_('Could not reset PDF layout due to a database error.'), 'error')
|
||||
else:
|
||||
@@ -490,6 +506,7 @@ def pdf_layout_preview():
|
||||
client_address='',
|
||||
project=SimpleNamespace(name='Sample Project', description=''),
|
||||
items=[],
|
||||
extra_goods=[],
|
||||
subtotal=0.0,
|
||||
tax_rate=0.0,
|
||||
tax_amount=0.0,
|
||||
@@ -507,6 +524,13 @@ def pdf_layout_preview():
|
||||
invoice.items = [sample_item]
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# Ensure extra_goods attribute exists
|
||||
try:
|
||||
if not hasattr(invoice, 'extra_goods'):
|
||||
invoice.extra_goods = []
|
||||
except Exception:
|
||||
pass
|
||||
# Helper: sanitize Jinja blocks to fix entities/smart quotes inserted by editor
|
||||
def _sanitize_jinja_blocks(raw: str) -> str:
|
||||
try:
|
||||
@@ -571,6 +595,24 @@ def pdf_layout_preview():
|
||||
return f"{float(value):,.2f} {settings_obj.currency}"
|
||||
except Exception:
|
||||
return f"{value} {settings_obj.currency}"
|
||||
|
||||
# Helper function for logo - converts to base64 data URI
|
||||
def _get_logo_base64(logo_path):
|
||||
try:
|
||||
if not logo_path or not os.path.exists(logo_path):
|
||||
return None
|
||||
import base64
|
||||
import mimetypes
|
||||
with open(logo_path, 'rb') as f:
|
||||
data = base64.b64encode(f.read()).decode('utf-8')
|
||||
mime_type, _ = mimetypes.guess_type(logo_path)
|
||||
if not mime_type:
|
||||
mime_type = 'image/png'
|
||||
return f'data:{mime_type};base64,{data}'
|
||||
except Exception as e:
|
||||
print(f"Error loading logo: {e}")
|
||||
return None
|
||||
|
||||
body_html = render_template_string(
|
||||
sanitized,
|
||||
invoice=invoice,
|
||||
@@ -578,21 +620,24 @@ def pdf_layout_preview():
|
||||
Path=_Path,
|
||||
format_date=_format_date,
|
||||
format_money=_format_money,
|
||||
get_logo_base64=_get_logo_base64,
|
||||
item=sample_item,
|
||||
)
|
||||
except Exception as e:
|
||||
body_html = f"<div style='color:red'>Template error: {str(e)}</div>" + sanitized
|
||||
page_html = f"""
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset='UTF-8'>
|
||||
<title>PDF Preview</title>
|
||||
<style>{css}</style>
|
||||
</head>
|
||||
<body>{body_html}</body>
|
||||
</html>
|
||||
"""
|
||||
# Build complete HTML page with embedded styles
|
||||
page_html = f"""<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Invoice Preview</title>
|
||||
<style>{css}</style>
|
||||
</head>
|
||||
<body>
|
||||
{body_html}
|
||||
</body>
|
||||
</html>"""
|
||||
return page_html
|
||||
|
||||
@admin_bp.route('/admin/upload-logo', methods=['POST'])
|
||||
@@ -631,6 +676,11 @@ def upload_logo():
|
||||
file_path = os.path.join(upload_folder, unique_filename)
|
||||
file.save(file_path)
|
||||
|
||||
# Log successful save
|
||||
current_app.logger.info(f'Logo saved successfully: {file_path}')
|
||||
current_app.logger.info(f'File exists check: {os.path.exists(file_path)}')
|
||||
current_app.logger.info(f'File size: {os.path.getsize(file_path) if os.path.exists(file_path) else "N/A"} bytes')
|
||||
|
||||
# Update settings
|
||||
settings_obj = Settings.get_settings()
|
||||
|
||||
@@ -648,7 +698,7 @@ def upload_logo():
|
||||
flash('Could not save logo due to a database error. Please check server logs.', 'error')
|
||||
return redirect(url_for('admin.settings'))
|
||||
|
||||
flash('Company logo uploaded successfully', 'success')
|
||||
flash('Company logo uploaded successfully! You can see it in the "Current Company Logo" section above. It will appear on invoices and PDF documents.', 'success')
|
||||
else:
|
||||
flash('Invalid file type. Allowed types: PNG, JPG, JPEG, GIF, SVG, WEBP', 'error')
|
||||
|
||||
@@ -675,7 +725,7 @@ def remove_logo():
|
||||
if not safe_commit('admin_remove_logo'):
|
||||
flash('Could not remove logo due to a database error. Please check server logs.', 'error')
|
||||
return redirect(url_for('admin.settings'))
|
||||
flash('Company logo removed successfully', 'success')
|
||||
flash('Company logo removed successfully. Upload a new logo in the section below if needed.', 'success')
|
||||
else:
|
||||
flash('No logo to remove', 'info')
|
||||
|
||||
@@ -688,8 +738,18 @@ def serve_uploaded_logo(filename):
|
||||
This route is intentionally public so logos render on unauthenticated pages
|
||||
like the login screen and in favicons.
|
||||
"""
|
||||
upload_folder = get_upload_folder()
|
||||
return send_from_directory(upload_folder, filename)
|
||||
try:
|
||||
upload_folder = get_upload_folder()
|
||||
file_path = os.path.join(upload_folder, filename)
|
||||
|
||||
if not os.path.exists(file_path):
|
||||
current_app.logger.error(f'Logo file not found: {file_path}')
|
||||
return 'Logo file not found', 404
|
||||
|
||||
return send_from_directory(upload_folder, filename)
|
||||
except Exception as e:
|
||||
current_app.logger.error(f'Error serving logo {filename}: {str(e)}')
|
||||
return 'Error serving logo', 500
|
||||
|
||||
@admin_bp.route('/admin/backups')
|
||||
@login_required
|
||||
|
||||
+55
-61
@@ -2,7 +2,7 @@ from flask import Blueprint, render_template, request, redirect, url_for, flash,
|
||||
from flask_babel import gettext as _
|
||||
from flask_login import login_required, current_user
|
||||
from app import db, log_event, track_event
|
||||
from app.models import User, Project, TimeEntry, Invoice, InvoiceItem, Settings, RateOverride, ProjectCost, ExtraGood
|
||||
from app.models import User, Project, TimeEntry, Invoice, InvoiceItem, Settings, RateOverride, ProjectCost, ExtraGood, Expense
|
||||
from datetime import datetime, timedelta, date
|
||||
from decimal import Decimal, InvalidOperation
|
||||
import io
|
||||
@@ -216,6 +216,24 @@ def edit_invoice(invoice_id):
|
||||
flash(f'Invalid quantity or price for item {i+1}', 'error')
|
||||
continue
|
||||
|
||||
# Update expenses
|
||||
expense_ids = request.form.getlist('expense_id[]')
|
||||
|
||||
# Unlink expenses not in the list
|
||||
for expense in invoice.expenses.all():
|
||||
if str(expense.id) not in expense_ids:
|
||||
expense.unmark_as_invoiced()
|
||||
|
||||
# Link expenses in the list
|
||||
if expense_ids:
|
||||
for expense_id in expense_ids:
|
||||
try:
|
||||
expense = Expense.query.get(int(expense_id))
|
||||
if expense and not expense.invoiced:
|
||||
expense.mark_as_invoiced(invoice.id)
|
||||
except (ValueError, AttributeError):
|
||||
continue
|
||||
|
||||
# Update extra goods
|
||||
good_ids = request.form.getlist('good_id[]')
|
||||
good_names = request.form.getlist('good_name[]')
|
||||
@@ -292,63 +310,6 @@ def update_invoice_status(invoice_id):
|
||||
|
||||
return jsonify({'success': True, 'status': new_status})
|
||||
|
||||
@invoices_bp.route('/invoices/<int:invoice_id>/payment', methods=['GET', 'POST'])
|
||||
@login_required
|
||||
def record_payment(invoice_id):
|
||||
"""Record payment for invoice"""
|
||||
invoice = Invoice.query.get_or_404(invoice_id)
|
||||
|
||||
# Check access permissions
|
||||
if not current_user.is_admin and invoice.created_by != current_user.id:
|
||||
flash('You do not have permission to record payment for this invoice', 'error')
|
||||
return redirect(url_for('invoices.list_invoices'))
|
||||
|
||||
if request.method == 'POST':
|
||||
# Get form data
|
||||
amount = request.form.get('amount', '0').strip()
|
||||
payment_date_str = request.form.get('payment_date', '').strip()
|
||||
payment_method = request.form.get('payment_method', '').strip()
|
||||
payment_reference = request.form.get('payment_reference', '').strip()
|
||||
payment_notes = request.form.get('payment_notes', '').strip()
|
||||
|
||||
# Validate amount
|
||||
try:
|
||||
amount = Decimal(amount)
|
||||
if amount <= 0:
|
||||
flash('Payment amount must be greater than zero', 'error')
|
||||
return render_template('invoices/record_payment.html', invoice=invoice)
|
||||
except (ValueError, InvalidOperation):
|
||||
flash('Invalid payment amount', 'error')
|
||||
return render_template('invoices/record_payment.html', invoice=invoice)
|
||||
|
||||
# Validate payment date
|
||||
payment_date = None
|
||||
if payment_date_str:
|
||||
try:
|
||||
payment_date = datetime.strptime(payment_date_str, '%Y-%m-%d').date()
|
||||
except ValueError:
|
||||
flash('Invalid payment date format', 'error')
|
||||
return render_template('invoices/record_payment.html', invoice=invoice)
|
||||
|
||||
# Record the payment
|
||||
invoice.record_payment(
|
||||
amount=amount,
|
||||
payment_date=payment_date,
|
||||
payment_method=payment_method if payment_method else None,
|
||||
payment_reference=payment_reference if payment_reference else None,
|
||||
payment_notes=payment_notes if payment_notes else None
|
||||
)
|
||||
|
||||
if not safe_commit('record_payment', {'invoice_id': invoice.id, 'amount': float(amount)}):
|
||||
flash('Could not record payment due to a database error. Please check server logs.', 'error')
|
||||
return render_template('invoices/record_payment.html', invoice=invoice)
|
||||
|
||||
flash(f'Payment of {amount} recorded successfully', 'success')
|
||||
return redirect(url_for('invoices.view_invoice', invoice_id=invoice.id))
|
||||
|
||||
# GET request - show payment form
|
||||
today = datetime.now().strftime('%Y-%m-%d')
|
||||
return render_template('invoices/record_payment.html', invoice=invoice, today=today)
|
||||
|
||||
@invoices_bp.route('/invoices/<int:invoice_id>/delete', methods=['POST'])
|
||||
@login_required
|
||||
@@ -382,13 +343,14 @@ def generate_from_time(invoice_id):
|
||||
return redirect(url_for('invoices.list_invoices'))
|
||||
|
||||
if request.method == 'POST':
|
||||
# Get selected time entries, costs, and extra goods
|
||||
# Get selected time entries, costs, expenses, and extra goods
|
||||
selected_entries = request.form.getlist('time_entries[]')
|
||||
selected_costs = request.form.getlist('project_costs[]')
|
||||
selected_expenses = request.form.getlist('expenses[]')
|
||||
selected_goods = request.form.getlist('extra_goods[]')
|
||||
|
||||
if not selected_entries and not selected_costs and not selected_goods:
|
||||
flash('No time entries, costs, or extra goods selected', 'error')
|
||||
if not selected_entries and not selected_costs and not selected_expenses and not selected_goods:
|
||||
flash('No time entries, costs, expenses, or extra goods selected', 'error')
|
||||
return redirect(url_for('invoices.generate_from_time', invoice_id=invoice.id))
|
||||
|
||||
# Clear existing items
|
||||
@@ -453,6 +415,14 @@ def generate_from_time(invoice_id):
|
||||
# Mark cost as invoiced
|
||||
cost.mark_as_invoiced(invoice.id)
|
||||
|
||||
# Process expenses
|
||||
if selected_expenses:
|
||||
expenses = Expense.query.filter(Expense.id.in_(selected_expenses)).all()
|
||||
|
||||
for expense in expenses:
|
||||
# Mark expense as invoiced (this links it to the invoice)
|
||||
expense.mark_as_invoiced(invoice.id)
|
||||
|
||||
# Process extra goods from project
|
||||
if selected_goods:
|
||||
goods = ExtraGood.query.filter(ExtraGood.id.in_(selected_goods)).all()
|
||||
@@ -509,6 +479,9 @@ def generate_from_time(invoice_id):
|
||||
# Get uninvoiced billable costs for this project
|
||||
unbilled_costs = ProjectCost.get_uninvoiced_costs(invoice.project_id)
|
||||
|
||||
# Get uninvoiced billable expenses for this project
|
||||
unbilled_expenses = Expense.get_uninvoiced_expenses(project_id=invoice.project_id)
|
||||
|
||||
# Get billable extra goods for this project (not yet on an invoice)
|
||||
project_goods = ExtraGood.query.filter(
|
||||
ExtraGood.project_id == invoice.project_id,
|
||||
@@ -519,6 +492,7 @@ def generate_from_time(invoice_id):
|
||||
# Calculate totals
|
||||
total_available_hours = sum(entry.duration_hours for entry in unbilled_entries)
|
||||
total_available_costs = sum(float(cost.amount) for cost in unbilled_costs)
|
||||
total_available_expenses = sum(float(expense.total_amount) for expense in unbilled_expenses)
|
||||
total_available_goods = sum(float(good.total_amount) for good in project_goods)
|
||||
|
||||
# Get currency from settings
|
||||
@@ -529,9 +503,11 @@ def generate_from_time(invoice_id):
|
||||
invoice=invoice,
|
||||
time_entries=unbilled_entries,
|
||||
project_costs=unbilled_costs,
|
||||
expenses=unbilled_expenses,
|
||||
extra_goods=project_goods,
|
||||
total_available_hours=total_available_hours,
|
||||
total_available_costs=total_available_costs,
|
||||
total_available_expenses=total_available_expenses,
|
||||
total_available_goods=total_available_goods,
|
||||
currency=currency)
|
||||
|
||||
@@ -568,6 +544,24 @@ def export_invoice_csv(invoice_id):
|
||||
float(item.total_amount)
|
||||
])
|
||||
|
||||
# Write expenses
|
||||
for expense in invoice.expenses:
|
||||
writer.writerow([
|
||||
f"{expense.title} ({expense.category})",
|
||||
1,
|
||||
float(expense.total_amount),
|
||||
float(expense.total_amount)
|
||||
])
|
||||
|
||||
# Write goods
|
||||
for good in invoice.extra_goods:
|
||||
writer.writerow([
|
||||
good.name,
|
||||
float(good.quantity),
|
||||
float(good.unit_price),
|
||||
float(good.total_amount)
|
||||
])
|
||||
|
||||
writer.writerow([])
|
||||
writer.writerow(['Subtotal', '', '', float(invoice.subtotal)])
|
||||
writer.writerow(['Tax Rate', '', '', f'{float(invoice.tax_rate)}%'])
|
||||
|
||||
+42
-3
@@ -158,8 +158,8 @@
|
||||
}
|
||||
|
||||
function onKeyDown(ev){
|
||||
// Check if typing in input field
|
||||
if (['input','textarea'].includes(ev.target.tagName.toLowerCase())) return;
|
||||
// Check if typing in input field or editor
|
||||
if (isTypingInField(ev)) return;
|
||||
|
||||
// Note: ? key (Shift+/) is now handled by keyboard-shortcuts-advanced.js for shortcuts panel
|
||||
// Command palette is opened with Ctrl+K
|
||||
@@ -172,11 +172,49 @@
|
||||
let seq = [];
|
||||
let seqTimer = null;
|
||||
function resetSeq(){ seq = []; if (seqTimer) { clearTimeout(seqTimer); seqTimer = null; } }
|
||||
|
||||
// Check if user is typing in input field or rich text editor
|
||||
function isTypingInField(ev){
|
||||
const target = ev.target;
|
||||
const tag = (target.tagName || '').toLowerCase();
|
||||
|
||||
// Check standard inputs
|
||||
if (['input','textarea','select'].includes(tag) || target.isContentEditable) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check for rich text editors (Toast UI Editor, etc.)
|
||||
const editorSelectors = [
|
||||
'.toastui-editor', '.toastui-editor-contents', '.ProseMirror',
|
||||
'.CodeMirror', '.ql-editor', '.tox-edit-area', '.note-editable',
|
||||
'[contenteditable="true"]', '.toastui-editor-ww-container',
|
||||
'.toastui-editor-md-container'
|
||||
];
|
||||
|
||||
for (let i = 0; i < editorSelectors.length; i++) {
|
||||
if (target.closest && target.closest(editorSelectors[i])) {
|
||||
console.log('[Commands.js] Blocked - inside editor:', editorSelectors[i]);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function sequenceHandler(ev){
|
||||
if (ev.repeat) return;
|
||||
const key = ev.key.toLowerCase();
|
||||
if (['input','textarea'].includes(ev.target.tagName.toLowerCase())) return; // ignore typing fields
|
||||
|
||||
// Check if typing in any input field or editor
|
||||
if (isTypingInField(ev)) {
|
||||
console.log('[Commands.js] Blocked - user is typing');
|
||||
resetSeq(); // Clear any partial sequence
|
||||
return;
|
||||
}
|
||||
|
||||
if (ev.ctrlKey || ev.metaKey || ev.altKey) return; // only plain keys
|
||||
|
||||
console.log('[Commands.js] Processing key in sequence:', key, 'current seq:', seq);
|
||||
seq.push(key);
|
||||
if (seq.length > 2) seq.shift();
|
||||
if (seq.length === 1 && seq[0] === 'g'){
|
||||
@@ -185,6 +223,7 @@
|
||||
}
|
||||
if (seq.length === 2 && seq[0] === 'g'){
|
||||
const second = seq[1];
|
||||
console.log('[Commands.js] Executing navigation for g +', second);
|
||||
resetSeq();
|
||||
if (second === 'd') return nav('/');
|
||||
if (second === 'p') return nav('/projects');
|
||||
|
||||
@@ -975,8 +975,17 @@ function easeOutQuad(t) {
|
||||
}
|
||||
|
||||
// Global functions for inline event handlers
|
||||
function bulkDelete() {
|
||||
if (confirm('Are you sure you want to delete the selected items?')) {
|
||||
async function bulkDelete() {
|
||||
const confirmed = await showConfirm(
|
||||
'Are you sure you want to delete the selected items?',
|
||||
{
|
||||
title: 'Delete Items',
|
||||
confirmText: 'Delete',
|
||||
cancelText: 'Cancel',
|
||||
variant: 'danger'
|
||||
}
|
||||
);
|
||||
if (confirmed) {
|
||||
window.toastManager?.success('Items deleted successfully');
|
||||
clearSelection();
|
||||
}
|
||||
|
||||
@@ -201,16 +201,32 @@ class KeyboardShortcutManager {
|
||||
* Handle key press
|
||||
*/
|
||||
handleKeyPress(e) {
|
||||
// AGGRESSIVE DEBUG LOGGING
|
||||
const debugInfo = {
|
||||
key: e.key,
|
||||
target: e.target,
|
||||
tagName: e.target.tagName,
|
||||
classList: e.target.classList ? Array.from(e.target.classList) : [],
|
||||
isContentEditable: e.target.isContentEditable
|
||||
};
|
||||
console.log('[KS-Advanced] Key pressed:', debugInfo);
|
||||
|
||||
// When palette is open, do not trigger a second open; let commands.js handle focus
|
||||
const palette = document.getElementById('commandPaletteModal');
|
||||
const paletteOpen = palette && !palette.classList.contains('hidden');
|
||||
|
||||
// Ignore if typing in input/textarea except for allowed global combos
|
||||
if (this.isTyping(e)) {
|
||||
// Check if typing in input field
|
||||
const isTypingInInput = this.isTyping(e);
|
||||
console.log('[KS-Advanced] isTyping result:', isTypingInInput);
|
||||
|
||||
// If typing in input/textarea, ONLY allow specific global combos
|
||||
if (isTypingInInput) {
|
||||
console.log('[KS-Advanced] BLOCKED - User is typing in input field');
|
||||
// Allow Ctrl+/ to focus search even when typing
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === '/') {
|
||||
e.preventDefault();
|
||||
this.toggleSearch();
|
||||
return;
|
||||
}
|
||||
// Allow Ctrl+K to open/focus palette even when typing
|
||||
else if ((e.ctrlKey || e.metaKey) && (e.key === 'k' || e.key === 'K')) {
|
||||
@@ -222,9 +238,20 @@ class KeyboardShortcutManager {
|
||||
} else {
|
||||
this.openCommandPalette();
|
||||
}
|
||||
return;
|
||||
}
|
||||
// Allow Shift+? for shortcuts panel
|
||||
else if (e.key === '?' && e.shiftKey) {
|
||||
e.preventDefault();
|
||||
this.showShortcutsPanel();
|
||||
return;
|
||||
}
|
||||
// Block ALL other shortcuts when typing
|
||||
console.log('[KS-Advanced] Blocking shortcut - in input field');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('[KS-Advanced] NOT blocked - processing shortcut');
|
||||
|
||||
const key = this.getKeyCombo(e);
|
||||
const normalizedKey = this.normalizeKey(key);
|
||||
@@ -310,40 +337,55 @@ class KeyboardShortcutManager {
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if user is typing
|
||||
* Check if user is typing in an input field
|
||||
*/
|
||||
isTyping(e) {
|
||||
const target = e.target;
|
||||
const tagName = target.tagName.toLowerCase();
|
||||
const isInput = tagName === 'input' || tagName === 'textarea' || target.isContentEditable;
|
||||
|
||||
// Don't block anything if not in an input
|
||||
if (!isInput) {
|
||||
return false;
|
||||
console.log('[KS-Advanced isTyping] Checking:', {
|
||||
tagName: tagName,
|
||||
isContentEditable: target.isContentEditable,
|
||||
classList: target.classList ? Array.from(target.classList) : []
|
||||
});
|
||||
|
||||
// Check standard input elements
|
||||
if (tagName === 'input' ||
|
||||
tagName === 'textarea' ||
|
||||
tagName === 'select' ||
|
||||
target.isContentEditable) {
|
||||
console.log('[KS-Advanced isTyping] TRUE - standard input');
|
||||
return true;
|
||||
}
|
||||
|
||||
// Allow Escape in search inputs to close/clear
|
||||
if (target.type === 'search' && e.key === 'Escape') {
|
||||
return false;
|
||||
// Check for rich text editors (Toast UI Editor, TinyMCE, CodeMirror, etc.)
|
||||
const richEditorSelectors = [
|
||||
'.toastui-editor',
|
||||
'.toastui-editor-contents',
|
||||
'.ProseMirror',
|
||||
'.CodeMirror',
|
||||
'.ql-editor', // Quill
|
||||
'.tox-edit-area', // TinyMCE
|
||||
'.note-editable', // Summernote
|
||||
'[contenteditable="true"]',
|
||||
// Additional Toast UI Editor specific selectors
|
||||
'.toastui-editor-ww-container',
|
||||
'.toastui-editor-md-container',
|
||||
'.te-editor',
|
||||
'.te-ww-container',
|
||||
'.te-md-container'
|
||||
];
|
||||
|
||||
// Check if target is within any rich text editor
|
||||
for (const selector of richEditorSelectors) {
|
||||
if (target.closest && target.closest(selector)) {
|
||||
console.log('[KS-Advanced isTyping] TRUE - inside editor:', selector);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
// Allow Ctrl+/ and Cmd+/ even in inputs for search
|
||||
if (e.key === '/' && (e.ctrlKey || e.metaKey)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Allow Ctrl+K and Cmd+K even in inputs for command palette
|
||||
if (e.key === 'k' && (e.ctrlKey || e.metaKey)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Allow Shift+? for shortcuts panel
|
||||
if (e.key === '?' && e.shiftKey) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Block all other keys when typing
|
||||
return true;
|
||||
console.log('[KS-Advanced isTyping] FALSE - not in input');
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -395,6 +395,8 @@
|
||||
* Handle key down event
|
||||
*/
|
||||
handleKeyDown(e) {
|
||||
console.log('[KS-Enhanced] Key pressed:', e.key);
|
||||
|
||||
// Track pressed keys
|
||||
this.pressedKeys.add(e.key.toLowerCase());
|
||||
|
||||
@@ -404,16 +406,23 @@
|
||||
return;
|
||||
}
|
||||
|
||||
// Skip if typing in input (except for allowed combos)
|
||||
if (this.isTypingContext(e)) {
|
||||
// Allow specific combos even in inputs
|
||||
const combo = this.getKeyCombo(e);
|
||||
// Check if typing in input first
|
||||
const isTyping = this.isTypingContext(e);
|
||||
const combo = this.getKeyCombo(e);
|
||||
|
||||
console.log('[KS-Enhanced] isTyping:', isTyping, 'combo:', combo);
|
||||
|
||||
// If typing in input, ONLY allow specific combos
|
||||
if (isTyping) {
|
||||
if (!this.isAllowedInInput(combo)) {
|
||||
console.log('[KS-Enhanced] BLOCKED - typing in input, not allowed combo');
|
||||
// Clear any key sequence when user is typing
|
||||
this.resetSequence();
|
||||
return;
|
||||
}
|
||||
console.log('[KS-Enhanced] Allowed combo in input:', combo);
|
||||
}
|
||||
|
||||
const combo = this.getKeyCombo(e);
|
||||
const normalizedCombo = this.normalizeKeys(combo);
|
||||
|
||||
// Check for custom shortcut override
|
||||
@@ -450,9 +459,12 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Handle key sequences (like 'g d')
|
||||
if (!e.ctrlKey && !e.metaKey && !e.altKey && e.key.length === 1) {
|
||||
// Handle key sequences (like 'g d') - but NOT if typing in input
|
||||
if (!e.ctrlKey && !e.metaKey && !e.altKey && e.key.length === 1 && !isTyping) {
|
||||
console.log('[KS-Enhanced] Processing sequence for key:', e.key);
|
||||
this.handleSequence(e);
|
||||
} else {
|
||||
console.log('[KS-Enhanced] NOT processing sequence - modifiers or typing');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -467,7 +479,11 @@
|
||||
* Handle key sequences like 'g d' or 'g p'
|
||||
*/
|
||||
handleSequence(e) {
|
||||
if (this.isTypingContext(e)) return;
|
||||
// Double-check: should never be called if typing, but just in case
|
||||
if (this.isTypingContext(e)) {
|
||||
this.resetSequence();
|
||||
return;
|
||||
}
|
||||
|
||||
clearTimeout(this.sequenceTimeout);
|
||||
|
||||
@@ -540,10 +556,41 @@
|
||||
isTypingContext(e) {
|
||||
const target = e.target;
|
||||
const tagName = target.tagName.toLowerCase();
|
||||
return tagName === 'input' ||
|
||||
tagName === 'textarea' ||
|
||||
tagName === 'select' ||
|
||||
target.isContentEditable;
|
||||
|
||||
// Check standard input elements
|
||||
if (tagName === 'input' ||
|
||||
tagName === 'textarea' ||
|
||||
tagName === 'select' ||
|
||||
target.isContentEditable) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check for rich text editors (Toast UI Editor, TinyMCE, CodeMirror, etc.)
|
||||
const richEditorSelectors = [
|
||||
'.toastui-editor',
|
||||
'.toastui-editor-contents',
|
||||
'.ProseMirror',
|
||||
'.CodeMirror',
|
||||
'.ql-editor', // Quill
|
||||
'.tox-edit-area', // TinyMCE
|
||||
'.note-editable', // Summernote
|
||||
'[contenteditable="true"]',
|
||||
// Additional Toast UI Editor specific selectors
|
||||
'.toastui-editor-ww-container',
|
||||
'.toastui-editor-md-container',
|
||||
'.te-editor',
|
||||
'.te-ww-container',
|
||||
'.te-md-container'
|
||||
];
|
||||
|
||||
// Check if target is within any rich text editor
|
||||
for (const selector of richEditorSelectors) {
|
||||
if (target.closest && target.closest(selector)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -194,32 +194,35 @@
|
||||
let sequenceTimer = null;
|
||||
|
||||
document.addEventListener('keydown', (e) => {
|
||||
// Ignore if typing in input
|
||||
if (this.isTyping(e)) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Focus search with Ctrl+K or Cmd+K
|
||||
// Focus search with Ctrl+K or Cmd+K (allowed even in inputs)
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === 'k') {
|
||||
e.preventDefault();
|
||||
this.focusSearch();
|
||||
return;
|
||||
}
|
||||
|
||||
// Open command palette with ? (main entry point)
|
||||
if (e.key === '?' && !e.ctrlKey && !e.metaKey && !e.altKey) {
|
||||
// Open command palette with ? (allowed even in inputs, but only if Shift is pressed)
|
||||
if (e.key === '?' && e.shiftKey && !e.ctrlKey && !e.metaKey && !e.altKey) {
|
||||
e.preventDefault();
|
||||
this.openCommandPalette();
|
||||
return;
|
||||
}
|
||||
|
||||
// Help with Shift+? (or Ctrl/Cmd+?)
|
||||
// Help with Shift+? (or Ctrl/Cmd+?) (allowed even in inputs)
|
||||
if ((e.key === '?' && e.shiftKey) || (e.key === '/' && e.ctrlKey)) {
|
||||
e.preventDefault();
|
||||
this.showHelp();
|
||||
return;
|
||||
}
|
||||
|
||||
// Ignore ALL other shortcuts if typing in input
|
||||
if (this.isTyping(e)) {
|
||||
// Clear any existing key sequence when user starts typing
|
||||
keySequence = [];
|
||||
clearTimeout(sequenceTimer);
|
||||
return;
|
||||
}
|
||||
|
||||
// Handle key sequences (like 'g' then 'd')
|
||||
clearTimeout(sequenceTimer);
|
||||
keySequence.push(e.key.toLowerCase());
|
||||
@@ -571,12 +574,53 @@
|
||||
isTyping(event) {
|
||||
const target = event.target;
|
||||
const tagName = target.tagName.toLowerCase();
|
||||
return (
|
||||
tagName === 'input' ||
|
||||
|
||||
// Debug logging (can be removed after testing)
|
||||
if (window.location.pathname.includes('create') || window.location.pathname.includes('edit')) {
|
||||
console.log('[Keyboard Shortcuts Debug]', {
|
||||
target: target,
|
||||
tagName: tagName,
|
||||
classList: target.classList ? Array.from(target.classList) : [],
|
||||
isContentEditable: target.isContentEditable,
|
||||
parentClasses: target.parentElement ? Array.from(target.parentElement.classList || []) : []
|
||||
});
|
||||
}
|
||||
|
||||
// Check standard input elements
|
||||
if (tagName === 'input' ||
|
||||
tagName === 'textarea' ||
|
||||
tagName === 'select' ||
|
||||
target.isContentEditable
|
||||
);
|
||||
target.isContentEditable) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check for rich text editors (Toast UI Editor, TinyMCE, CodeMirror, etc.)
|
||||
const richEditorSelectors = [
|
||||
'.toastui-editor',
|
||||
'.toastui-editor-contents',
|
||||
'.ProseMirror',
|
||||
'.CodeMirror',
|
||||
'.ql-editor', // Quill
|
||||
'.tox-edit-area', // TinyMCE
|
||||
'.note-editable', // Summernote
|
||||
'[contenteditable="true"]',
|
||||
// Additional Toast UI Editor specific selectors
|
||||
'.toastui-editor-ww-container',
|
||||
'.toastui-editor-md-container',
|
||||
'.te-editor',
|
||||
'.te-ww-container',
|
||||
'.te-md-container'
|
||||
];
|
||||
|
||||
// Check if target is within any rich text editor
|
||||
for (const selector of richEditorSelectors) {
|
||||
if (target.closest && target.closest(selector)) {
|
||||
console.log('[Keyboard Shortcuts] Blocked - inside editor:', selector);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
detectKeyboardNavigation() {
|
||||
|
||||
@@ -353,8 +353,17 @@ class OnboardingManager {
|
||||
/**
|
||||
* Skip the tour
|
||||
*/
|
||||
skip() {
|
||||
if (confirm('Are you sure you want to skip the tour? You can restart it later from the Help menu.')) {
|
||||
async skip() {
|
||||
const confirmed = await showConfirm(
|
||||
'Are you sure you want to skip the tour? You can restart it later from the Help menu.',
|
||||
{
|
||||
title: 'Skip Tour',
|
||||
confirmText: 'Skip',
|
||||
cancelText: 'Continue Tour',
|
||||
variant: 'warning'
|
||||
}
|
||||
);
|
||||
if (confirmed) {
|
||||
this.complete();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
* Provides offline support and background sync
|
||||
*/
|
||||
|
||||
const CACHE_VERSION = 'v1.0.0';
|
||||
const CACHE_VERSION = 'v1.0.1';
|
||||
const CACHE_NAME = `timetracker-${CACHE_VERSION}`;
|
||||
|
||||
// Resources to cache immediately
|
||||
@@ -70,6 +70,12 @@ self.addEventListener('fetch', event => {
|
||||
return;
|
||||
}
|
||||
|
||||
// Skip caching for uploads directory (user-uploaded content that changes)
|
||||
if (url.pathname.startsWith('/uploads/')) {
|
||||
event.respondWith(fetch(request)); // Always fetch fresh
|
||||
return;
|
||||
}
|
||||
|
||||
// API requests - network first, cache fallback
|
||||
if (url.pathname.startsWith('/api/')) {
|
||||
event.respondWith(networkFirst(request));
|
||||
|
||||
@@ -340,7 +340,16 @@ document.getElementById('createTokenForm').addEventListener('submit', async (e)
|
||||
});
|
||||
|
||||
async function toggleToken(tokenId, isActive) {
|
||||
if (!confirm(`Are you sure you want to ${isActive ? 'deactivate' : 'activate'} this token?`)) {
|
||||
const confirmed = await showConfirm(
|
||||
`{{ _("Are you sure you want to") }} ${isActive ? '{{ _("deactivate") }}' : '{{ _("activate") }}'} {{ _("this token?") }}`,
|
||||
{
|
||||
title: isActive ? '{{ _("Deactivate Token") }}' : '{{ _("Activate Token") }}',
|
||||
confirmText: isActive ? '{{ _("Deactivate") }}' : '{{ _("Activate") }}',
|
||||
cancelText: '{{ _("Cancel") }}',
|
||||
variant: isActive ? 'warning' : 'primary'
|
||||
}
|
||||
);
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -364,7 +373,16 @@ async function toggleToken(tokenId, isActive) {
|
||||
}
|
||||
|
||||
async function deleteToken(tokenId) {
|
||||
if (!confirm('Are you sure you want to delete this token? This action cannot be undone.')) {
|
||||
const confirmed = await showConfirm(
|
||||
'{{ _("Are you sure you want to delete this token? This action cannot be undone.") }}',
|
||||
{
|
||||
title: '{{ _("Delete Token") }}',
|
||||
confirmText: '{{ _("Delete") }}',
|
||||
cancelText: '{{ _("Cancel") }}',
|
||||
variant: 'danger'
|
||||
}
|
||||
);
|
||||
if (!confirmed) {
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block content %}
|
||||
<div class="flex flex-col md:flex-row justify-between items-start md:items-center mb-6">
|
||||
<div>
|
||||
<h1 class="text-2xl font-bold">Clear Cache</h1>
|
||||
<p class="text-text-muted-light dark:text-text-muted-dark">Force clear browser and ServiceWorker cache</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow">
|
||||
<div class="space-y-6">
|
||||
<!-- ServiceWorker Status -->
|
||||
<div class="border-b border-border-light dark:border-border-dark pb-6">
|
||||
<h2 class="text-lg font-semibold mb-4">ServiceWorker Status</h2>
|
||||
<div id="sw-status" class="p-4 bg-gray-50 dark:bg-gray-800 rounded">
|
||||
<p class="text-sm">Checking ServiceWorker...</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Cache Actions -->
|
||||
<div class="border-b border-border-light dark:border-border-dark pb-6">
|
||||
<h2 class="text-lg font-semibold mb-4">Clear All Caches</h2>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400 mb-4">
|
||||
This will clear all cached resources including CSS, JavaScript, and images. Use this if you're experiencing issues with outdated content.
|
||||
</p>
|
||||
<button onclick="clearAllCaches()" class="bg-red-600 hover:bg-red-700 text-white px-6 py-2 rounded-lg font-medium transition-colors">
|
||||
Clear All Caches
|
||||
</button>
|
||||
<div id="cache-status" class="mt-4 p-3 rounded hidden"></div>
|
||||
</div>
|
||||
|
||||
<!-- ServiceWorker Actions -->
|
||||
<div class="border-b border-border-light dark:border-border-dark pb-6">
|
||||
<h2 class="text-lg font-semibold mb-4">ServiceWorker Actions</h2>
|
||||
<div class="space-y-3">
|
||||
<button onclick="unregisterSW()" class="bg-orange-600 hover:bg-orange-700 text-white px-6 py-2 rounded-lg font-medium transition-colors">
|
||||
Unregister ServiceWorker
|
||||
</button>
|
||||
<button onclick="updateSW()" class="bg-blue-600 hover:bg-blue-700 text-white px-6 py-2 rounded-lg font-medium ml-2 transition-colors">
|
||||
Update ServiceWorker
|
||||
</button>
|
||||
</div>
|
||||
<div id="sw-action-status" class="mt-4 p-3 rounded hidden"></div>
|
||||
</div>
|
||||
|
||||
<!-- Hard Refresh Instructions -->
|
||||
<div>
|
||||
<h2 class="text-lg font-semibold mb-4">Manual Hard Refresh</h2>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400 mb-3">
|
||||
If clearing cache doesn't help, try a hard refresh:
|
||||
</p>
|
||||
<ul class="list-disc list-inside text-sm text-gray-600 dark:text-gray-400 space-y-1">
|
||||
<li><strong>Windows/Linux:</strong> Press <kbd class="px-2 py-1 bg-gray-200 dark:bg-gray-700 rounded">Ctrl + F5</kbd> or <kbd class="px-2 py-1 bg-gray-200 dark:bg-gray-700 rounded">Ctrl + Shift + R</kbd></li>
|
||||
<li><strong>Mac:</strong> Press <kbd class="px-2 py-1 bg-gray-200 dark:bg-gray-700 rounded">Cmd + Shift + R</kbd></li>
|
||||
<li><strong>Chrome DevTools:</strong> Right-click refresh button → "Empty Cache and Hard Reload"</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// Check ServiceWorker status on load
|
||||
async function checkSWStatus() {
|
||||
const statusDiv = document.getElementById('sw-status');
|
||||
|
||||
if (!('serviceWorker' in navigator)) {
|
||||
statusDiv.innerHTML = '<p class="text-yellow-600">ServiceWorker not supported in this browser</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const registration = await navigator.serviceWorker.getRegistration();
|
||||
|
||||
if (!registration) {
|
||||
statusDiv.innerHTML = '<p class="text-green-600">✓ No ServiceWorker registered</p>';
|
||||
return;
|
||||
}
|
||||
|
||||
const state = registration.active ? registration.active.state : 'none';
|
||||
const scope = registration.scope;
|
||||
|
||||
statusDiv.innerHTML = `
|
||||
<div class="space-y-2 text-sm">
|
||||
<p><strong>Status:</strong> <span class="text-blue-600">${state}</span></p>
|
||||
<p><strong>Scope:</strong> ${scope}</p>
|
||||
<p><strong>Version:</strong> v1.0.1 (with uploads fix)</p>
|
||||
</div>
|
||||
`;
|
||||
} catch (error) {
|
||||
statusDiv.innerHTML = `<p class="text-red-600">Error checking ServiceWorker: ${error.message}</p>`;
|
||||
}
|
||||
}
|
||||
|
||||
// Clear all caches
|
||||
async function clearAllCaches() {
|
||||
const statusDiv = document.getElementById('cache-status');
|
||||
statusDiv.classList.remove('hidden');
|
||||
statusDiv.className = 'mt-4 p-3 rounded bg-blue-50 dark:bg-blue-900/20 text-blue-800 dark:text-blue-200';
|
||||
statusDiv.textContent = 'Clearing caches...';
|
||||
|
||||
try {
|
||||
if ('serviceWorker' in navigator) {
|
||||
const registration = await navigator.serviceWorker.getRegistration();
|
||||
if (registration) {
|
||||
registration.active.postMessage({ type: 'CLEAR_CACHE' });
|
||||
}
|
||||
}
|
||||
|
||||
if ('caches' in window) {
|
||||
const cacheNames = await caches.keys();
|
||||
await Promise.all(cacheNames.map(name => caches.delete(name)));
|
||||
|
||||
statusDiv.className = 'mt-4 p-3 rounded bg-green-50 dark:bg-green-900/20 text-green-800 dark:text-green-200';
|
||||
statusDiv.textContent = `✓ Successfully cleared ${cacheNames.length} cache(s). Refresh the page to see changes.`;
|
||||
} else {
|
||||
statusDiv.className = 'mt-4 p-3 rounded bg-yellow-50 dark:bg-yellow-900/20 text-yellow-800 dark:text-yellow-200';
|
||||
statusDiv.textContent = 'Cache API not available in this browser';
|
||||
}
|
||||
} catch (error) {
|
||||
statusDiv.className = 'mt-4 p-3 rounded bg-red-50 dark:bg-red-900/20 text-red-800 dark:text-red-200';
|
||||
statusDiv.textContent = `Error clearing caches: ${error.message}`;
|
||||
}
|
||||
}
|
||||
|
||||
// Unregister ServiceWorker
|
||||
async function unregisterSW() {
|
||||
const statusDiv = document.getElementById('sw-action-status');
|
||||
statusDiv.classList.remove('hidden');
|
||||
statusDiv.className = 'mt-4 p-3 rounded bg-blue-50 dark:bg-blue-900/20 text-blue-800 dark:text-blue-200';
|
||||
statusDiv.textContent = 'Unregistering ServiceWorker...';
|
||||
|
||||
try {
|
||||
if (!('serviceWorker' in navigator)) {
|
||||
statusDiv.className = 'mt-4 p-3 rounded bg-yellow-50 dark:bg-yellow-900/20 text-yellow-800 dark:text-yellow-200';
|
||||
statusDiv.textContent = 'ServiceWorker not supported';
|
||||
return;
|
||||
}
|
||||
|
||||
const registration = await navigator.serviceWorker.getRegistration();
|
||||
|
||||
if (!registration) {
|
||||
statusDiv.className = 'mt-4 p-3 rounded bg-yellow-50 dark:bg-yellow-900/20 text-yellow-800 dark:text-yellow-200';
|
||||
statusDiv.textContent = 'No ServiceWorker registered';
|
||||
return;
|
||||
}
|
||||
|
||||
const success = await registration.unregister();
|
||||
|
||||
if (success) {
|
||||
statusDiv.className = 'mt-4 p-3 rounded bg-green-50 dark:bg-green-900/20 text-green-800 dark:text-green-200';
|
||||
statusDiv.textContent = '✓ ServiceWorker unregistered successfully. Refresh the page.';
|
||||
setTimeout(() => checkSWStatus(), 1000);
|
||||
} else {
|
||||
statusDiv.className = 'mt-4 p-3 rounded bg-red-50 dark:bg-red-900/20 text-red-800 dark:text-red-200';
|
||||
statusDiv.textContent = 'Failed to unregister ServiceWorker';
|
||||
}
|
||||
} catch (error) {
|
||||
statusDiv.className = 'mt-4 p-3 rounded bg-red-50 dark:bg-red-900/20 text-red-800 dark:text-red-200';
|
||||
statusDiv.textContent = `Error: ${error.message}`;
|
||||
}
|
||||
}
|
||||
|
||||
// Update ServiceWorker
|
||||
async function updateSW() {
|
||||
const statusDiv = document.getElementById('sw-action-status');
|
||||
statusDiv.classList.remove('hidden');
|
||||
statusDiv.className = 'mt-4 p-3 rounded bg-blue-50 dark:bg-blue-900/20 text-blue-800 dark:text-blue-200';
|
||||
statusDiv.textContent = 'Checking for ServiceWorker updates...';
|
||||
|
||||
try {
|
||||
if (!('serviceWorker' in navigator)) {
|
||||
statusDiv.className = 'mt-4 p-3 rounded bg-yellow-50 dark:bg-yellow-900/20 text-yellow-800 dark:text-yellow-200';
|
||||
statusDiv.textContent = 'ServiceWorker not supported';
|
||||
return;
|
||||
}
|
||||
|
||||
const registration = await navigator.serviceWorker.getRegistration();
|
||||
|
||||
if (!registration) {
|
||||
statusDiv.className = 'mt-4 p-3 rounded bg-yellow-50 dark:bg-yellow-900/20 text-yellow-800 dark:text-yellow-200';
|
||||
statusDiv.textContent = 'No ServiceWorker registered';
|
||||
return;
|
||||
}
|
||||
|
||||
await registration.update();
|
||||
|
||||
statusDiv.className = 'mt-4 p-3 rounded bg-green-50 dark:bg-green-900/20 text-green-800 dark:text-green-200';
|
||||
statusDiv.textContent = '✓ ServiceWorker update checked. Refresh the page to activate new version.';
|
||||
setTimeout(() => checkSWStatus(), 1000);
|
||||
} catch (error) {
|
||||
statusDiv.className = 'mt-4 p-3 rounded bg-red-50 dark:bg-red-900/20 text-red-800 dark:text-red-200';
|
||||
statusDiv.textContent = `Error: ${error.message}`;
|
||||
}
|
||||
}
|
||||
|
||||
// Check status on page load
|
||||
checkSWStatus();
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -218,13 +218,13 @@
|
||||
</div>
|
||||
<div class="p-6">
|
||||
<ul class="space-y-2 text-sm text-gray-600 dark:text-gray-400">
|
||||
<li><i class="fas fa-database text-blue-500 mr-2"></i>Complete database</li>
|
||||
<li><i class="fas fa-users text-blue-500 mr-2"></i>All users & permissions</li>
|
||||
<li><i class="fas fa-clock text-blue-500 mr-2"></i>All time entries</li>
|
||||
<li><i class="fas fa-project-diagram text-blue-500 mr-2"></i>Projects & tasks</li>
|
||||
<li><i class="fas fa-file-invoice text-blue-500 mr-2"></i>Invoices & expenses</li>
|
||||
<li><i class="fas fa-cog text-blue-500 mr-2"></i>System settings</li>
|
||||
<li><i class="fas fa-file-upload text-blue-500 mr-2"></i>Uploaded files</li>
|
||||
<li><i class="fas fa-database text-blue-500 dark:text-blue-400 mr-2"></i>Complete database</li>
|
||||
<li><i class="fas fa-users text-blue-500 dark:text-blue-400 mr-2"></i>All users & permissions</li>
|
||||
<li><i class="fas fa-clock text-blue-500 dark:text-blue-400 mr-2"></i>All time entries</li>
|
||||
<li><i class="fas fa-project-diagram text-blue-500 dark:text-blue-400 mr-2"></i>Projects & tasks</li>
|
||||
<li><i class="fas fa-file-invoice text-blue-500 dark:text-blue-400 mr-2"></i>Invoices & expenses</li>
|
||||
<li><i class="fas fa-cog text-blue-500 dark:text-blue-400 mr-2"></i>System settings</li>
|
||||
<li><i class="fas fa-file-upload text-blue-500 dark:text-blue-400 mr-2"></i>Uploaded files</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -137,9 +137,9 @@
|
||||
<a href="{{ url_for('permissions.view_role', role_id=role.id) }}" class="text-primary hover:underline text-sm">{{ _('View') }}</a>
|
||||
{% if not role.is_system_role and (current_user.is_admin or has_permission('manage_roles')) %}
|
||||
<a href="{{ url_for('permissions.edit_role', role_id=role.id) }}" class="text-primary hover:underline text-sm">{{ _('Edit') }}</a>
|
||||
<form action="{{ url_for('permissions.delete_role', role_id=role.id) }}" method="post" class="inline" onsubmit="return confirm('{{ _('Are you sure you want to delete this role?') }}');">
|
||||
<form id="deleteRoleForm-{{ role.id }}" action="{{ url_for('permissions.delete_role', role_id=role.id) }}" method="post" class="inline">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<button type="submit" class="text-red-600 hover:underline text-sm">{{ _('Delete') }}</button>
|
||||
<button type="button" onclick="confirmDeleteRole({{ role.id }}, '{{ role.name }}')" class="text-red-600 hover:underline text-sm">{{ _('Delete') }}</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
@@ -197,5 +197,22 @@
|
||||
{{ _('Roles are collections of permissions that can be assigned to users. System roles are predefined and cannot be deleted or renamed, but custom roles can be created for your specific needs.') }}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
async function confirmDeleteRole(roleId, roleName) {
|
||||
const confirmed = await showConfirm(
|
||||
'{{ _("Are you sure you want to delete the role") }} "' + roleName + '"?',
|
||||
{
|
||||
title: '{{ _("Delete Role") }}',
|
||||
confirmText: '{{ _("Delete") }}',
|
||||
cancelText: '{{ _("Cancel") }}',
|
||||
variant: 'danger'
|
||||
}
|
||||
);
|
||||
if (confirmed) {
|
||||
document.getElementById('deleteRoleForm-' + roleId).submit();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
|
||||
@@ -8,7 +8,8 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow">
|
||||
<!-- Main Settings Form -->
|
||||
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow mb-6">
|
||||
<form method="POST">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
|
||||
@@ -94,26 +95,6 @@
|
||||
<textarea name="company_bank_info" id="company_bank_info" rows="3" class="form-input">{{ settings.company_bank_info }}</textarea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Company Logo Upload -->
|
||||
<div class="mt-6">
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Company Logo</label>
|
||||
{% if settings.has_logo() %}
|
||||
<div class="mb-4">
|
||||
<img src="{{ settings.get_logo_url() }}" alt="Company Logo" class="max-h-24 mb-2">
|
||||
<form method="POST" action="{{ url_for('admin.remove_logo') }}" class="inline">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<button type="submit" class="text-sm text-red-600 hover:text-red-800 dark:text-red-400 dark:hover:text-red-300">Remove Logo</button>
|
||||
</form>
|
||||
</div>
|
||||
{% endif %}
|
||||
<form method="POST" action="{{ url_for('admin.upload_logo') }}" enctype="multipart/form-data" class="flex items-center gap-4">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<input type="file" name="logo" accept="image/png,image/jpeg,image/jpg,image/gif,image/svg+xml,image/webp" class="form-input flex-1">
|
||||
<button type="submit" class="bg-secondary text-white px-4 py-2 rounded-lg whitespace-nowrap">Upload Logo</button>
|
||||
</form>
|
||||
<p class="text-xs text-text-muted-light dark:text-text-muted-dark mt-1">Allowed formats: PNG, JPG, GIF, SVG, WEBP</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Invoice Defaults -->
|
||||
@@ -196,4 +177,119 @@
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Company Logo Upload - Separate Section -->
|
||||
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow">
|
||||
<h2 class="text-lg font-semibold mb-4">Company Logo</h2>
|
||||
|
||||
<!-- Current Logo Display -->
|
||||
{% if settings.has_logo() %}
|
||||
<div class="mb-6 p-4 bg-gray-50 dark:bg-gray-800 rounded-lg border-2 border-green-500 dark:border-green-600">
|
||||
<div class="flex items-start justify-between mb-3">
|
||||
<div>
|
||||
<h3 class="text-sm font-semibold text-green-700 dark:text-green-400 mb-1">✓ Current Company Logo</h3>
|
||||
<p class="text-xs text-gray-600 dark:text-gray-400">This logo appears on invoices, PDFs, and other documents</p>
|
||||
</div>
|
||||
<form id="removeLogoForm" method="POST" action="{{ url_for('admin.remove_logo') }}" class="inline">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<button type="button" class="text-sm text-red-600 hover:text-red-800 dark:text-red-400 dark:hover:text-red-300 font-medium" onclick="confirmRemoveLogo()">
|
||||
<svg class="w-4 h-4 inline mr-1" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"></path>
|
||||
</svg>
|
||||
Remove Logo
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
<div class="flex items-center justify-center bg-white dark:bg-gray-900 p-6 rounded border border-gray-200 dark:border-gray-700">
|
||||
<img src="{{ settings.get_logo_url() }}?v={{ range(1, 10000) | random }}" alt="Company Logo" class="max-h-32 max-w-full object-contain" onerror="this.onerror=null; this.parentElement.innerHTML='<p class=\'text-red-600 text-sm\'>Error loading logo</p>';">
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="mb-4 p-4 bg-gray-50 dark:bg-gray-800 rounded-lg border-2 border-dashed border-gray-300 dark:border-gray-600">
|
||||
<div class="text-center py-4">
|
||||
<svg class="mx-auto h-12 w-12 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z"></path>
|
||||
</svg>
|
||||
<p class="mt-2 text-sm text-gray-600 dark:text-gray-400">No company logo uploaded yet</p>
|
||||
<p class="text-xs text-gray-500 dark:text-gray-500 mt-1">Upload a logo to appear on invoices, PDFs, and documents</p>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
<!-- Upload Form -->
|
||||
<div class="p-4 bg-blue-50 dark:bg-blue-900/20 rounded-lg border border-blue-200 dark:border-blue-800">
|
||||
<h3 class="text-sm font-semibold text-blue-900 dark:text-blue-300 mb-3">Upload New Logo</h3>
|
||||
<form method="POST" action="{{ url_for('admin.upload_logo') }}" enctype="multipart/form-data" id="logoUploadForm">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<div class="space-y-3">
|
||||
<div>
|
||||
<input type="file" name="logo" id="logoFileInput" accept="image/png,image/jpeg,image/jpg,image/gif,image/svg+xml,image/webp" required
|
||||
class="block w-full text-sm text-gray-900 dark:text-gray-300 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg cursor-pointer focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
onchange="previewLogoBeforeUpload(this)">
|
||||
</div>
|
||||
|
||||
<!-- Preview of selected file before upload -->
|
||||
<div id="logoPreview" class="hidden bg-white dark:bg-gray-900 p-4 rounded border border-gray-200 dark:border-gray-700">
|
||||
<p class="text-xs text-gray-600 dark:text-gray-400 mb-2">Preview:</p>
|
||||
<div class="flex items-center justify-center">
|
||||
<img id="logoPreviewImage" src="" alt="Logo Preview" class="max-h-24 max-w-full object-contain">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex items-center gap-3">
|
||||
<button type="submit" class="bg-blue-600 hover:bg-blue-700 text-white px-6 py-2 rounded-lg font-medium transition-colors">
|
||||
<svg class="w-4 h-4 inline mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12"></path>
|
||||
</svg>
|
||||
Upload Logo
|
||||
</button>
|
||||
<button type="button" onclick="document.getElementById('logoFileInput').value = ''; document.getElementById('logoPreview').classList.add('hidden');"
|
||||
class="text-gray-600 dark:text-gray-400 hover:text-gray-800 dark:hover:text-gray-200 px-4 py-2 text-sm">
|
||||
Clear
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
<div class="mt-3 text-xs text-gray-600 dark:text-gray-400 space-y-1">
|
||||
<p><strong>Allowed formats:</strong> PNG, JPG, GIF, SVG, WEBP (max 5MB)</p>
|
||||
<p><strong>Recommended:</strong> Square or landscape logo, at least 200x200 pixels</p>
|
||||
<p><strong>Where it appears:</strong> PDF invoices, email templates, and exported documents</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function previewLogoBeforeUpload(input) {
|
||||
const preview = document.getElementById('logoPreview');
|
||||
const previewImage = document.getElementById('logoPreviewImage');
|
||||
|
||||
if (input.files && input.files[0]) {
|
||||
const reader = new FileReader();
|
||||
|
||||
reader.onload = function(e) {
|
||||
previewImage.src = e.target.result;
|
||||
preview.classList.remove('hidden');
|
||||
};
|
||||
|
||||
reader.readAsDataURL(input.files[0]);
|
||||
} else {
|
||||
preview.classList.add('hidden');
|
||||
}
|
||||
}
|
||||
|
||||
async function confirmRemoveLogo() {
|
||||
const confirmed = await showConfirm(
|
||||
'{{ _("Are you sure you want to remove the company logo?") }}',
|
||||
{
|
||||
title: '{{ _("Remove Logo") }}',
|
||||
confirmText: '{{ _("Remove") }}',
|
||||
cancelText: '{{ _("Cancel") }}',
|
||||
variant: 'danger'
|
||||
}
|
||||
);
|
||||
if (confirmed) {
|
||||
document.getElementById('removeLogoForm').submit();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -62,6 +62,12 @@
|
||||
<div class="flex gap-2">
|
||||
<a href="{{ url_for('admin.edit_user', user_id=user.id) }}" class="text-primary hover:underline text-sm">{{ _('Edit') }}</a>
|
||||
<a href="{{ url_for('permissions.manage_user_roles', user_id=user.id) }}" class="text-primary hover:underline text-sm">{{ _('Roles') }}</a>
|
||||
{% if user.id != current_user.id %}
|
||||
<button type="button" onclick="confirmDeleteUser('{{ user.id }}', '{{ user.username }}', {{ user.time_entries.count() }})" class="text-red-600 hover:text-red-800 text-sm">{{ _('Delete') }}</button>
|
||||
<form id="deleteUserForm-{{ user.id }}" method="POST" action="{{ url_for('admin.delete_user', user_id=user.id) }}" class="hidden">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}"/>
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -73,4 +79,38 @@
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function confirmDeleteUser(userId, username, timeEntriesCount) {
|
||||
// Check if user has time entries
|
||||
if (timeEntriesCount > 0) {
|
||||
const msg = {{ _('Cannot delete user "{name}" because they have {count} time entries. Users with existing time entries cannot be deleted.')|tojson }}.replace('{name}', username).replace('{count}', timeEntriesCount);
|
||||
if (window.showConfirm) {
|
||||
window.showConfirm(msg, {
|
||||
title: {{ _('Cannot Delete User')|tojson }},
|
||||
confirmText: {{ _('OK')|tojson }},
|
||||
variant: 'warning',
|
||||
showCancel: false
|
||||
});
|
||||
} else {
|
||||
alert(msg);
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// Show delete confirmation
|
||||
const msg = {{ _('Are you sure you want to delete user "{name}"? This action cannot be undone.')|tojson }}.replace('{name}', username);
|
||||
|
||||
window.showConfirm(msg, {
|
||||
title: {{ _('Delete User')|tojson }},
|
||||
confirmText: {{ _('Delete')|tojson }},
|
||||
cancelText: {{ _('Cancel')|tojson }},
|
||||
variant: 'danger'
|
||||
}).then(function(ok) {
|
||||
if (ok) {
|
||||
document.getElementById('deleteUserForm-' + userId).submit();
|
||||
}
|
||||
});
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -177,7 +177,7 @@
|
||||
<!-- Charts: Hours by Time of Day & Completion Rate -->
|
||||
<div class="grid grid-cols-1 lg:grid-cols-2 gap-6 mb-6">
|
||||
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow">
|
||||
<h3 class="font-semibold mb-3"><i class="fas fa-clock text-sky-600 mr-2"></i>{{ _('Hours by Time of Day') }}</h3>
|
||||
<h3 class="font-semibold mb-3"><i class="fas fa-clock text-sky-600 dark:text-sky-400 mr-2"></i>{{ _('Hours by Time of Day') }}</h3>
|
||||
<div class="relative h-[300px]"><canvas id="hourlyChart"></canvas></div>
|
||||
</div>
|
||||
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow">
|
||||
|
||||
+74
-16
@@ -90,9 +90,9 @@
|
||||
(function(){ try { if (localStorage.getItem('sidebar-collapsed') === 'true') { document.documentElement.classList.add('sidebar-collapsed'); } } catch(_) {} })();
|
||||
</script>
|
||||
<a href="#mainContentAnchor" class="sr-only focus:not-sr-only focus-ring absolute left-2 top-2 z-[1000] px-3 py-2 bg-card-light dark:bg-card-dark border border-border-light dark:border-border-dark rounded">Skip to content</a>
|
||||
<div id="appShell" class="flex h-screen">
|
||||
<div id="appShell" class="flex min-h-screen">
|
||||
<!-- Sidebar -->
|
||||
<aside id="sidebar" class="w-64 bg-card-light dark:bg-card-dark text-text-light dark:text-text-dark p-4 flex-col hidden lg:flex transition-all duration-200 ease-in-out relative">
|
||||
<aside id="sidebar" class="w-64 bg-card-light dark:bg-card-dark text-text-light dark:text-text-dark p-4 flex-col hidden lg:flex transition-all duration-200 ease-in-out fixed top-0 left-0 h-screen overflow-y-auto z-10">
|
||||
<div class="flex items-center mb-8">
|
||||
<img src="{{ url_for('static', filename='images/drytrix-logo.svg') }}" alt="Logo" class="h-10 w-10 mr-2">
|
||||
<h1 class="text-2xl font-bold text-primary sidebar-header-title"><a href="{{ url_for('main.dashboard') }}" class="no-underline">TimeTracker</a></h1>
|
||||
@@ -220,6 +220,9 @@
|
||||
<li>
|
||||
<a class="block px-2 py-1 rounded {% if ep == 'admin.settings' %}text-primary font-semibold bg-background-light dark:bg-background-dark{% else %}text-text-light dark:text-text-dark hover:bg-background-light dark:hover:bg-background-dark{% endif %}" href="{{ url_for('admin.settings') }}">{{ _('Settings') }}</a>
|
||||
</li>
|
||||
<li>
|
||||
<a class="block px-2 py-1 rounded {% if ep == 'admin.pdf_layout' %}text-primary font-semibold bg-background-light dark:bg-background-dark{% else %}text-text-light dark:text-text-dark hover:bg-background-light dark:hover:bg-background-dark{% endif %}" href="{{ url_for('admin.pdf_layout') }}">{{ _('PDF Layout') }}</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
{% if current_user.is_admin or has_permission('view_system_info') %}
|
||||
<li>
|
||||
@@ -273,7 +276,7 @@
|
||||
</aside>
|
||||
|
||||
<!-- Main content -->
|
||||
<div id="mainContent" class="flex-1 flex flex-col transition-all duration-200 ease-in-out">
|
||||
<div id="mainContent" class="flex-1 flex flex-col transition-all duration-200 ease-in-out lg:ml-64">
|
||||
<!-- Header -->
|
||||
<header class="bg-card-light dark:bg-card-dark p-4 border-b border-border-light dark:border-border-dark flex justify-between items-center">
|
||||
<!-- Mobile menu button -->
|
||||
@@ -366,7 +369,7 @@
|
||||
</div>
|
||||
|
||||
<!-- Main page content -->
|
||||
<main id="mainContentAnchor" class="flex-1 p-6 overflow-y-auto">
|
||||
<main id="mainContentAnchor" class="flex-1 p-6">
|
||||
{% block content %}{% endblock %}
|
||||
</main>
|
||||
</div>
|
||||
@@ -404,14 +407,27 @@
|
||||
<script src="{{ url_for('static', filename='enhanced-tables.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='interactions.js') }}"></script>
|
||||
<!-- Old command palette and keyboard navigation (restored) -->
|
||||
<script src="{{ url_for('static', filename='commands.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='commands.js') }}?v=2.0"></script>
|
||||
<script>
|
||||
// Minimal global shortcuts: Ctrl+/ (focus search), Ctrl+Shift+L (toggle theme), 't' (toggle timer)
|
||||
// Note: Ctrl+K is handled by keyboard-shortcuts-advanced.js for command palette
|
||||
(function(){
|
||||
function isTyping(e){
|
||||
const t = e.target; const tag = (t && t.tagName || '').toLowerCase();
|
||||
return tag === 'input' || tag === 'textarea' || tag === 'select' || (t && t.isContentEditable);
|
||||
const t = e.target;
|
||||
const tag = (t && t.tagName || '').toLowerCase();
|
||||
|
||||
// Check standard inputs
|
||||
if (tag === 'input' || tag === 'textarea' || tag === 'select' || (t && t.isContentEditable)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Check for rich text editors (Toast UI Editor, etc.)
|
||||
const editorSelectors = ['.toastui-editor', '.toastui-editor-contents', '.ProseMirror', '.CodeMirror', '.ql-editor', '.tox-edit-area', '.note-editable', '[contenteditable="true"]', '.toastui-editor-ww-container', '.toastui-editor-md-container'];
|
||||
for (let i = 0; i < editorSelectors.length; i++) {
|
||||
if (t.closest && t.closest(editorSelectors[i])) return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
document.addEventListener('keydown', (e) => {
|
||||
// Ctrl/Cmd + / -> focus search (removed Ctrl+K to avoid conflict with command palette)
|
||||
@@ -471,12 +487,18 @@
|
||||
appShell.classList.add('sidebar-collapsed');
|
||||
sidebar.classList.add('w-16');
|
||||
sidebar.classList.remove('w-64');
|
||||
// Adjust main content margin for collapsed sidebar
|
||||
main.classList.remove('lg:ml-64');
|
||||
main.classList.add('lg:ml-16');
|
||||
icon && icon.classList.remove('fa-angles-left');
|
||||
icon && icon.classList.add('fa-angles-right');
|
||||
} else {
|
||||
appShell.classList.remove('sidebar-collapsed');
|
||||
sidebar.classList.remove('w-16');
|
||||
sidebar.classList.add('w-64');
|
||||
// Adjust main content margin for expanded sidebar
|
||||
main.classList.remove('lg:ml-16');
|
||||
main.classList.add('lg:ml-64');
|
||||
icon && icon.classList.remove('fa-angles-right');
|
||||
icon && icon.classList.add('fa-angles-left');
|
||||
}
|
||||
@@ -500,12 +522,12 @@
|
||||
// On small screens we can temporarily show sidebar as overlay
|
||||
const showing = sidebar.classList.contains('hidden');
|
||||
sidebar.classList.toggle('hidden', !showing);
|
||||
sidebar.classList.toggle('fixed', showing);
|
||||
sidebar.classList.toggle('inset-y-0', showing);
|
||||
sidebar.classList.toggle('z-50', showing);
|
||||
// Position the toggle icon correctly when overlayed
|
||||
const iconBtn = document.getElementById('sidebarCollapseBtn');
|
||||
if (iconBtn){ iconBtn.classList.toggle('right-3', showing); iconBtn.classList.toggle('-right-3', !showing); }
|
||||
// Sidebar is already fixed, just need to adjust z-index for overlay
|
||||
if (showing) {
|
||||
sidebar.style.zIndex = '50';
|
||||
} else {
|
||||
sidebar.style.zIndex = '10';
|
||||
}
|
||||
});
|
||||
|
||||
// Flyout submenu when collapsed
|
||||
@@ -666,7 +688,43 @@
|
||||
|
||||
function toggleDropdown(id) {
|
||||
const dropdown = document.getElementById(id);
|
||||
dropdown.classList.toggle('hidden');
|
||||
const isCurrentlyHidden = dropdown.classList.contains('hidden');
|
||||
|
||||
// Close all other dropdowns in the sidebar (accordion behavior)
|
||||
const allSidebarDropdowns = ['workDropdown', 'financeDropdown', 'adminDropdown'];
|
||||
allSidebarDropdowns.forEach(dropdownId => {
|
||||
if (dropdownId !== id) {
|
||||
const otherDropdown = document.getElementById(dropdownId);
|
||||
if (otherDropdown) {
|
||||
otherDropdown.classList.add('hidden');
|
||||
// Rotate chevron back for closed dropdowns
|
||||
const otherBtn = document.querySelector(`[data-dropdown="${dropdownId}"]`);
|
||||
if (otherBtn) {
|
||||
const otherChevron = otherBtn.querySelector('.fa-chevron-down');
|
||||
if (otherChevron) {
|
||||
otherChevron.style.transform = 'rotate(0deg)';
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Toggle the clicked dropdown
|
||||
if (isCurrentlyHidden) {
|
||||
dropdown.classList.remove('hidden');
|
||||
} else {
|
||||
dropdown.classList.add('hidden');
|
||||
}
|
||||
|
||||
// Rotate chevron icon for visual feedback
|
||||
const btn = document.querySelector(`[data-dropdown="${id}"]`);
|
||||
if (btn) {
|
||||
const chevron = btn.querySelector('.fa-chevron-down');
|
||||
if (chevron) {
|
||||
chevron.style.transition = 'transform 0.2s ease';
|
||||
chevron.style.transform = isCurrentlyHidden ? 'rotate(180deg)' : 'rotate(0deg)';
|
||||
}
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -677,8 +735,8 @@
|
||||
<script src="{{ url_for('static', filename='onboarding.js') }}"></script>
|
||||
|
||||
<!-- Advanced Features -->
|
||||
<script src="{{ url_for('static', filename='keyboard-shortcuts-advanced.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='keyboard-shortcuts-enhanced.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='keyboard-shortcuts-advanced.js') }}?v=2.2"></script>
|
||||
<script src="{{ url_for('static', filename='keyboard-shortcuts-enhanced.js') }}?v=2.2"></script>
|
||||
<script src="{{ url_for('static', filename='quick-actions.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='smart-notifications.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='dashboard-widgets.js') }}"></script>
|
||||
|
||||
@@ -198,12 +198,11 @@
|
||||
{{ _('Edit') }}
|
||||
</a>
|
||||
{% if note.can_delete(current_user) %}
|
||||
<form method="POST"
|
||||
<form id="deleteNoteForm-{{ note.id }}" method="POST"
|
||||
action="{{ url_for('client_notes.delete_note', client_id=client.id, note_id=note.id) }}"
|
||||
class="inline"
|
||||
onsubmit="return confirm('{{ _('Are you sure you want to delete this note?') }}');">
|
||||
class="inline">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<button type="submit" class="text-sm text-red-600 dark:text-red-400 hover:underline">
|
||||
<button type="button" onclick="confirmDeleteNote({{ note.id }})" class="text-sm text-red-600 dark:text-red-400 hover:underline">
|
||||
{{ _('Delete') }}
|
||||
</button>
|
||||
</form>
|
||||
@@ -265,6 +264,21 @@ function toggleImportant(noteId, setImportant) {
|
||||
alert('{{ _('Error updating note') }}');
|
||||
});
|
||||
}
|
||||
|
||||
async function confirmDeleteNote(noteId) {
|
||||
const confirmed = await showConfirm(
|
||||
'{{ _("Are you sure you want to delete this note?") }}',
|
||||
{
|
||||
title: '{{ _("Delete Note") }}',
|
||||
confirmText: '{{ _("Delete") }}',
|
||||
cancelText: '{{ _("Cancel") }}',
|
||||
variant: 'danger'
|
||||
}
|
||||
);
|
||||
if (confirmed) {
|
||||
document.getElementById('deleteNoteForm-' + noteId).submit();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
{% if current_user.is_admin or has_permission('delete_clients') %}
|
||||
|
||||
@@ -93,7 +93,7 @@
|
||||
<!-- Timer -->
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold text-gray-900 dark:text-white mb-4">
|
||||
<i class="fas fa-clock mr-2 text-purple-600"></i>
|
||||
<i class="fas fa-clock mr-2 text-purple-600 dark:text-purple-400"></i>
|
||||
Timer
|
||||
</h3>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
|
||||
@@ -69,9 +69,9 @@
|
||||
<div class="flex items-center justify-between">
|
||||
<div>
|
||||
<p class="text-sm text-gray-600 dark:text-gray-400">Pending Approval</p>
|
||||
<p class="text-3xl font-bold text-yellow-600">{{ pending_count }}</p>
|
||||
<p class="text-3xl font-bold text-yellow-600 dark:text-yellow-400">{{ pending_count }}</p>
|
||||
</div>
|
||||
<div class="text-yellow-500 text-4xl">
|
||||
<div class="text-yellow-500 dark:text-yellow-400 text-4xl">
|
||||
<i class="fas fa-clock"></i>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -327,10 +327,9 @@
|
||||
</a>
|
||||
|
||||
{% if current_user.is_admin or (expense.user_id == current_user.id and expense.status == 'pending') %}
|
||||
<form method="POST" action="{{ url_for('expenses.delete_expense', expense_id=expense.id) }}"
|
||||
onsubmit="return confirm('Are you sure you want to delete this expense?');">
|
||||
<form id="deleteExpenseForm" method="POST" action="{{ url_for('expenses.delete_expense', expense_id=expense.id) }}">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<button type="submit" class="block w-full bg-red-600 text-white px-4 py-2 rounded-lg hover:bg-red-700 transition-colors">
|
||||
<button type="button" onclick="confirmDeleteExpense()" class="block w-full bg-red-600 text-white px-4 py-2 rounded-lg hover:bg-red-700 transition-colors">
|
||||
<i class="fas fa-trash mr-2"></i>Delete Expense
|
||||
</button>
|
||||
</form>
|
||||
@@ -390,6 +389,21 @@ document.getElementById('rejectModal').addEventListener('click', function(e) {
|
||||
hideRejectModal();
|
||||
}
|
||||
});
|
||||
|
||||
async function confirmDeleteExpense() {
|
||||
const confirmed = await showConfirm(
|
||||
'{{ _("Are you sure you want to delete this expense?") }}',
|
||||
{
|
||||
title: '{{ _("Delete Expense") }}',
|
||||
confirmText: '{{ _("Delete") }}',
|
||||
cancelText: '{{ _("Cancel") }}',
|
||||
variant: 'danger'
|
||||
}
|
||||
);
|
||||
if (confirmed) {
|
||||
document.getElementById('deleteExpenseForm').submit();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
{% endblock %}
|
||||
|
||||
@@ -93,6 +93,66 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-8 border-t border-border-light dark:border-border-dark pt-6">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<div>
|
||||
<h2 class="text-lg font-semibold flex items-center">
|
||||
<i class="fas fa-receipt mr-2 text-amber-600"></i>
|
||||
{{ _('Expenses') }}
|
||||
<span id="expenses-count" class="ml-2 px-2 py-0.5 text-xs bg-amber-100 dark:bg-amber-900/30 text-amber-700 dark:text-amber-300 rounded-full">0</span>
|
||||
</h2>
|
||||
<p class="text-sm text-text-muted-light dark:text-text-muted-dark mt-1">{{ _('Billable expenses such as travel, meals, and materials') }}</p>
|
||||
</div>
|
||||
<button type="button" id="add-expense" class="bg-amber-600 text-white px-4 py-2 rounded-lg hover:bg-amber-700 transition shadow-sm">
|
||||
<i class="fas fa-plus mr-2"></i>{{ _('Add Expense') }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Expenses header (desktop) -->
|
||||
<div class="hidden md:grid md:grid-cols-12 gap-3 mb-2 px-3 text-xs font-semibold text-text-muted-light dark:text-text-muted-dark uppercase">
|
||||
<div class="md:col-span-3">{{ _('Title') }}</div>
|
||||
<div class="md:col-span-3">{{ _('Description') }}</div>
|
||||
<div class="md:col-span-2">{{ _('Category') }}</div>
|
||||
<div class="md:col-span-2">{{ _('Amount') }}</div>
|
||||
<div class="md:col-span-1">{{ _('Date') }}</div>
|
||||
<div class="md:col-span-1 text-center">{{ _('Action') }}</div>
|
||||
</div>
|
||||
|
||||
<div id="invoice-expenses" class="space-y-2">
|
||||
{% for expense in invoice.expenses %}
|
||||
<div class="grid grid-cols-1 md:grid-cols-12 gap-3 p-3 rounded-lg bg-amber-50/50 dark:bg-amber-950/20 border border-amber-200/50 dark:border-amber-800/50 invoice-expense-row hover:shadow-sm transition">
|
||||
<input type="hidden" name="expense_id[]" value="{{ expense.id }}">
|
||||
<input type="text" name="expense_title[]" placeholder="{{ _('e.g., Travel to Client Meeting') }}" value="{{ expense.title }}" class="md:col-span-3 form-input" data-calc-trigger readonly title="{{ _('Linked expense - title cannot be edited') }}">
|
||||
<input type="text" name="expense_description[]" placeholder="{{ _('Description') }}" value="{{ expense.description or '' }}" class="md:col-span-3 form-input" readonly title="{{ _('Linked expense - description cannot be edited') }}">
|
||||
<select name="expense_category[]" class="md:col-span-2 form-input" disabled title="{{ _('Linked expense - category cannot be edited') }}">
|
||||
<option value="travel" {% if expense.category == 'travel' %}selected{% endif %}>{{ _('Travel') }}</option>
|
||||
<option value="meals" {% if expense.category == 'meals' %}selected{% endif %}>{{ _('Meals') }}</option>
|
||||
<option value="accommodation" {% if expense.category == 'accommodation' %}selected{% endif %}>{{ _('Accommodation') }}</option>
|
||||
<option value="supplies" {% if expense.category == 'supplies' %}selected{% endif %}>{{ _('Supplies') }}</option>
|
||||
<option value="software" {% if expense.category == 'software' %}selected{% endif %}>{{ _('Software') }}</option>
|
||||
<option value="equipment" {% if expense.category == 'equipment' %}selected{% endif %}>{{ _('Equipment') }}</option>
|
||||
<option value="services" {% if expense.category == 'services' %}selected{% endif %}>{{ _('Services') }}</option>
|
||||
<option value="marketing" {% if expense.category == 'marketing' %}selected{% endif %}>{{ _('Marketing') }}</option>
|
||||
<option value="training" {% if expense.category == 'training' %}selected{% endif %}>{{ _('Training') }}</option>
|
||||
<option value="other" {% if expense.category == 'other' %}selected{% endif %}>{{ _('Other') }}</option>
|
||||
</select>
|
||||
<input type="number" name="expense_amount[]" placeholder="{{ _('Amount') }}" value="{{ expense.total_amount }}" step="0.01" min="0" class="md:col-span-2 form-input expense-amount" data-calc-trigger readonly title="{{ _('Linked expense - amount cannot be edited') }}">
|
||||
<input type="date" name="expense_date[]" value="{{ expense.expense_date.strftime('%Y-%m-%d') if expense.expense_date else '' }}" class="md:col-span-1 form-input text-xs" readonly title="{{ _('Linked expense - date cannot be edited') }}">
|
||||
<button type="button" class="remove-expense md:col-span-1 bg-rose-100 text-rose-700 dark:bg-rose-900/30 dark:text-rose-300 px-3 py-2 rounded hover:bg-rose-200 dark:hover:bg-rose-900/50 transition" title="{{ _('Unlink expense from invoice') }}">
|
||||
<i class="fas fa-unlink"></i>
|
||||
</button>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
<div id="expenses-subtotal" class="mt-3 p-3 bg-amber-50/30 dark:bg-amber-950/10 rounded-lg border border-amber-200/30 dark:border-amber-800/30">
|
||||
<div class="flex justify-between items-center text-sm font-medium">
|
||||
<span class="text-text-muted-light dark:text-text-muted-dark">{{ _('Expenses Subtotal') }}:</span>
|
||||
<span class="text-lg font-bold text-amber-700 dark:text-amber-400">{{ invoice.currency_code }} <span id="expenses-subtotal-amount">0.00</span></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-8 border-t border-border-light dark:border-border-dark pt-6">
|
||||
<div class="flex items-center justify-between mb-4">
|
||||
<div>
|
||||
@@ -201,6 +261,10 @@
|
||||
<span class="text-text-muted-light dark:text-text-muted-dark">{{ _('Items') }} (<span id="preview-items-count">0</span>)</span>
|
||||
<span class="font-medium" id="preview-items-total">0.00</span>
|
||||
</div>
|
||||
<div class="flex justify-between text-sm">
|
||||
<span class="text-text-muted-light dark:text-text-muted-dark">{{ _('Expenses') }} (<span id="preview-expenses-count">0</span>)</span>
|
||||
<span class="font-medium" id="preview-expenses-total">0.00</span>
|
||||
</div>
|
||||
<div class="flex justify-between text-sm">
|
||||
<span class="text-text-muted-light dark:text-text-muted-dark">{{ _('Goods') }} (<span id="preview-goods-count">0</span>)</span>
|
||||
<span class="font-medium" id="preview-goods-total">0.00</span>
|
||||
@@ -239,7 +303,7 @@
|
||||
<h3 class="text-lg font-semibold mb-3">{{ _('Quick Actions') }}</h3>
|
||||
<div class="grid grid-cols-1 gap-2">
|
||||
<a href="{{ url_for('invoices.generate_from_time', invoice_id=invoice.id) }}" class="inline-flex items-center justify-center px-3 py-2 text-sm rounded-md border border-primary text-primary hover:bg-primary/10"><i class="fas fa-clock mr-2"></i>{{ _('Generate from Time/Costs') }}</a>
|
||||
<a href="{{ url_for('invoices.record_payment', invoice_id=invoice.id) }}" class="inline-flex items-center justify-center px-3 py-2 text-sm rounded-md bg-emerald-600 text-white hover:bg-emerald-700"><i class="fas fa-cash-register mr-2"></i>{{ _('Record Payment') }}</a>
|
||||
<a href="{{ url_for('payments.create_payment', invoice_id=invoice.id) }}" class="inline-flex items-center justify-center px-3 py-2 text-sm rounded-md bg-emerald-600 text-white hover:bg-emerald-700"><i class="fas fa-cash-register mr-2"></i>{{ _('Record Payment') }}</a>
|
||||
<a href="{{ url_for('invoices.export_invoice_pdf', invoice_id=invoice.id) }}" class="inline-flex items-center justify-center px-3 py-2 text-sm rounded-md border border-border-light dark:border-border-dark hover:bg-background-light dark:hover:bg-background-dark"><i class="fas fa-file-pdf mr-2"></i>{{ _('Export PDF') }}</a>
|
||||
<a href="{{ url_for('invoices.export_invoice_csv', invoice_id=invoice.id) }}" class="inline-flex items-center justify-center px-3 py-2 text-sm rounded-md border border-border-light dark:border-border-dark hover:bg-background-light dark:hover:bg-background-dark"><i class="fas fa-file-csv mr-2"></i>{{ _('Export CSV') }}</a>
|
||||
<a href="{{ url_for('invoices.duplicate_invoice', invoice_id=invoice.id) }}" class="inline-flex items-center justify-center px-3 py-2 text-sm rounded-md border border-border-light dark:border-border-dark hover:bg-background-light dark:hover:bg-background-dark"><i class="fas fa-clone mr-2"></i>{{ _('Duplicate Invoice') }}</a>
|
||||
@@ -253,6 +317,7 @@
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const itemsContainer = document.getElementById('invoice-items');
|
||||
const expensesContainer = document.getElementById('invoice-expenses');
|
||||
const goodsContainer = document.getElementById('invoice-goods');
|
||||
const taxRateInput = document.getElementById('tax_rate');
|
||||
|
||||
@@ -270,6 +335,17 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
}
|
||||
});
|
||||
|
||||
// Calculate expenses
|
||||
let expensesTotal = 0;
|
||||
let expensesCount = 0;
|
||||
document.querySelectorAll('.invoice-expense-row').forEach(row => {
|
||||
const amount = parseFloat(row.querySelector('.expense-amount')?.value || 0);
|
||||
if (amount > 0) {
|
||||
expensesTotal += amount;
|
||||
expensesCount++;
|
||||
}
|
||||
});
|
||||
|
||||
// Calculate goods
|
||||
let goodsTotal = 0;
|
||||
let goodsCount = 0;
|
||||
@@ -283,7 +359,7 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
});
|
||||
|
||||
// Calculate subtotal and tax
|
||||
const subtotal = itemsTotal + goodsTotal;
|
||||
const subtotal = itemsTotal + expensesTotal + goodsTotal;
|
||||
const taxRate = parseFloat(taxRateInput?.value || 0);
|
||||
const taxAmount = subtotal * (taxRate / 100);
|
||||
const total = subtotal + taxAmount;
|
||||
@@ -294,14 +370,18 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
|
||||
// Update displays
|
||||
document.getElementById('items-count').textContent = itemsCount;
|
||||
document.getElementById('expenses-count').textContent = expensesCount;
|
||||
document.getElementById('goods-count').textContent = goodsCount;
|
||||
document.getElementById('items-subtotal-amount').textContent = itemsTotal.toFixed(2);
|
||||
document.getElementById('expenses-subtotal-amount').textContent = expensesTotal.toFixed(2);
|
||||
document.getElementById('goods-subtotal-amount').textContent = goodsTotal.toFixed(2);
|
||||
|
||||
// Update preview panel
|
||||
document.getElementById('preview-items-count').textContent = itemsCount;
|
||||
document.getElementById('preview-expenses-count').textContent = expensesCount;
|
||||
document.getElementById('preview-goods-count').textContent = goodsCount;
|
||||
document.getElementById('preview-items-total').textContent = itemsTotal.toFixed(2);
|
||||
document.getElementById('preview-expenses-total').textContent = expensesTotal.toFixed(2);
|
||||
document.getElementById('preview-goods-total').textContent = goodsTotal.toFixed(2);
|
||||
document.getElementById('preview-subtotal').textContent = subtotal.toFixed(2);
|
||||
document.getElementById('preview-tax-rate').textContent = taxRate.toFixed(2);
|
||||
@@ -370,6 +450,38 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
}
|
||||
});
|
||||
|
||||
// Add expense button - redirect to generate from time page which includes expenses
|
||||
const addExpenseBtn = document.getElementById('add-expense');
|
||||
addExpenseBtn && addExpenseBtn.addEventListener('click', function() {
|
||||
window.location.href = '{{ url_for("invoices.generate_from_time", invoice_id=invoice.id) }}';
|
||||
});
|
||||
|
||||
// Remove expense handler (unlink from invoice)
|
||||
expensesContainer && expensesContainer.addEventListener('click', async function(e) {
|
||||
if (e.target.closest('.remove-expense')) {
|
||||
const row = e.target.closest('.invoice-expense-row');
|
||||
if (row) {
|
||||
const confirmed = await showConfirm(
|
||||
'{{ _("Are you sure you want to unlink this expense from the invoice?") }}',
|
||||
{
|
||||
title: '{{ _("Unlink Expense") }}',
|
||||
confirmText: '{{ _("Unlink") }}',
|
||||
cancelText: '{{ _("Cancel") }}',
|
||||
variant: 'warning'
|
||||
}
|
||||
);
|
||||
if (confirmed) {
|
||||
row.style.opacity = '0.5';
|
||||
row.style.transform = 'scale(0.95)';
|
||||
setTimeout(() => {
|
||||
row.remove();
|
||||
calculateTotals();
|
||||
}, 200);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Remove good handler
|
||||
goodsContainer && goodsContainer.addEventListener('click', function(e) {
|
||||
if (e.target.closest('.remove-good')) {
|
||||
@@ -399,11 +511,12 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
const form = document.getElementById('editInvoiceForm');
|
||||
form && form.addEventListener('submit', function(e) {
|
||||
const itemRows = document.querySelectorAll('.invoice-item-row');
|
||||
const expenseRows = document.querySelectorAll('.invoice-expense-row');
|
||||
const goodRows = document.querySelectorAll('.invoice-good-row');
|
||||
|
||||
if (itemRows.length === 0 && goodRows.length === 0) {
|
||||
if (itemRows.length === 0 && expenseRows.length === 0 && goodRows.length === 0) {
|
||||
e.preventDefault();
|
||||
alert('{{ _("Please add at least one item or extra good to the invoice") }}');
|
||||
alert('{{ _("Please add at least one item, expense, or extra good to the invoice") }}');
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
@@ -56,6 +56,29 @@
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="mb-6">
|
||||
<h2 class="text-lg font-semibold mb-3">{{ _('Uninvoiced Billable Expenses') }}</h2>
|
||||
{% if expenses %}
|
||||
<div class="space-y-2">
|
||||
{% for expense in expenses %}
|
||||
<label class="flex items-start gap-3 p-3 rounded border border-border-light dark:border-border-dark hover:bg-background-light dark:hover:bg-background-dark">
|
||||
<input type="checkbox" class="mt-1" name="expenses[]" value="{{ expense.id }}">
|
||||
<div class="flex-1 text-sm">
|
||||
<div class="font-medium">{{ expense.title }} ({{ expense.category|title }}) · {{ '%.2f'|format(expense.total_amount) }} {{ expense.currency_code }}</div>
|
||||
<div class="text-text-muted-light dark:text-text-muted-dark">
|
||||
{{ expense.expense_date.strftime('%Y-%m-%d') }}
|
||||
{% if expense.vendor %} · {{ _('Vendor') }}: {{ expense.vendor }}{% endif %}
|
||||
{% if expense.description %} · {{ expense.description[:80] }}{% if expense.description|length > 80 %}…{% endif %}{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</label>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="text-sm text-text-muted-light dark:text-text-muted-dark">{{ _('No uninvoiced billable expenses found for this project.') }}</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="mb-6">
|
||||
<h2 class="text-lg font-semibold mb-3">{{ _('Project Extra Goods') }}</h2>
|
||||
{% if extra_goods %}
|
||||
@@ -91,15 +114,17 @@
|
||||
<h3 class="text-lg font-semibold mb-3">{{ _('Selection Summary') }}</h3>
|
||||
<div class="text-sm text-text-muted-light dark:text-text-muted-dark">{{ _('Total available hours') }}: <span class="font-semibold text-text-light dark:text-text-dark">{{ '%.2f'|format(total_available_hours) }}</span></div>
|
||||
<div class="text-sm text-text-muted-light dark:text-text-muted-dark">{{ _('Total available costs') }}: <span class="font-semibold text-text-light dark:text-text-dark">{{ '%.2f'|format(total_available_costs) }} {{ currency }}</span></div>
|
||||
<div class="text-sm text-text-muted-light dark:text-text-muted-dark">{{ _('Total available expenses') }}: <span class="font-semibold text-text-light dark:text-text-dark">{{ '%.2f'|format(total_available_expenses) }} {{ currency }}</span></div>
|
||||
<div class="text-sm text-text-muted-light dark:text-text-muted-dark">{{ _('Total available goods') }}: <span class="font-semibold text-text-light dark:text-text-dark">{{ '%.2f'|format(total_available_goods) }} {{ currency }}</span></div>
|
||||
</div>
|
||||
|
||||
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow">
|
||||
<h3 class="text-lg font-semibold mb-3">{{ _('Tips') }}</h3>
|
||||
<ul class="list-disc pl-5 text-sm text-text-muted-light dark:text-text-muted-dark space-y-2">
|
||||
<li>{{ _('You can select multiple time entries, costs, and extra goods.') }}</li>
|
||||
<li>{{ _('You can select multiple time entries, costs, expenses, and extra goods.') }}</li>
|
||||
<li>{{ _('Time entries are grouped by task or project at item creation.') }}</li>
|
||||
<li>{{ _('Costs and extra goods are added as individual invoice items.') }}</li>
|
||||
<li>{{ _('Expenses are linked to the invoice and appear in a separate section.') }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
@@ -112,10 +137,10 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
const form = document.getElementById('generateFromTimeForm');
|
||||
if (!form) return;
|
||||
form.addEventListener('submit', function(e) {
|
||||
const anyChecked = form.querySelector('input[name="time_entries[]"]:checked, input[name="project_costs[]"]:checked, input[name="extra_goods[]"]:checked');
|
||||
const anyChecked = form.querySelector('input[name="time_entries[]"]:checked, input[name="project_costs[]"]:checked, input[name="expenses[]"]:checked, input[name="extra_goods[]"]:checked');
|
||||
if (!anyChecked) {
|
||||
e.preventDefault();
|
||||
try { window.toastManager && window.toastManager.warning('{{ _('Please select at least one time entry, cost, or extra good') }}'); } catch(_) {}
|
||||
try { window.toastManager && window.toastManager.warning('{{ _('Please select at least one time entry, cost, expense, or extra good') }}'); } catch(_) {}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -33,7 +33,13 @@
|
||||
<td class="p-2">{{ invoice.status }}</td>
|
||||
<td class="p-2">{{ "%.2f"|format(invoice.total_amount) }} {{ invoice.currency_code }}</td>
|
||||
<td class="p-2">
|
||||
<a href="{{ url_for('invoices.view_invoice', invoice_id=invoice.id) }}" class="text-primary">View</a>
|
||||
<a href="{{ url_for('invoices.view_invoice', invoice_id=invoice.id) }}" class="text-primary hover:text-primary-dark">View</a>
|
||||
<span class="mx-1">|</span>
|
||||
<a href="{{ url_for('invoices.edit_invoice', invoice_id=invoice.id) }}" class="text-secondary hover:text-secondary-dark">Edit</a>
|
||||
<span class="mx-1">|</span>
|
||||
<button type="button" onclick="showDeleteModal('{{ invoice.id }}', '{{ invoice.invoice_number }}')" class="text-red-500 hover:text-red-700">
|
||||
Delete
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
{% else %}
|
||||
@@ -44,4 +50,56 @@
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<!-- Delete Invoice Modal -->
|
||||
<div id="deleteInvoiceModal" class="hidden fixed inset-0 z-50" role="dialog" aria-modal="true" aria-labelledby="deleteModalTitle">
|
||||
<div class="absolute inset-0 bg-black/50" onclick="hideDeleteModal()"></div>
|
||||
<div class="relative max-w-lg mx-auto mt-24 bg-card-light dark:bg-card-dark text-text-light dark:text-text-dark rounded-lg shadow-xl ring-1 ring-border-light/60 dark:ring-border-dark/60">
|
||||
<div class="p-4 border-b border-border-light dark:border-border-dark flex items-center justify-between">
|
||||
<h5 id="deleteModalTitle" class="text-lg font-semibold flex items-center gap-2">
|
||||
<i class="fas fa-trash text-red-500"></i> {{ _('Delete Invoice') }}
|
||||
</h5>
|
||||
<button type="button" class="px-2 py-1 hover:bg-background-light dark:hover:bg-background-dark rounded text-2xl leading-none" aria-label="{{ _('Close') }}" onclick="hideDeleteModal()">×</button>
|
||||
</div>
|
||||
<div class="p-4">
|
||||
<div class="bg-yellow-50 dark:bg-yellow-900/30 text-yellow-900 dark:text-yellow-100 rounded-lg p-3 ring-1 ring-yellow-200/60 dark:ring-yellow-700/50 flex items-start gap-2">
|
||||
<i class="fas fa-exclamation-triangle mt-1"></i>
|
||||
<div>
|
||||
<strong>{{ _('Warning:') }}</strong> {{ _('This action cannot be undone.') }}
|
||||
</div>
|
||||
</div>
|
||||
<p class="mt-4">{{ _('Are you sure you want to delete invoice') }} <strong id="deleteInvoiceNumber"></strong>?</p>
|
||||
<p class="text-text-muted-light dark:text-text-muted-dark mt-2">
|
||||
<small>{{ _('All invoice items, extra goods, and payment records associated with this invoice will be permanently deleted.') }}</small>
|
||||
</p>
|
||||
</div>
|
||||
<div class="p-4 border-t border-border-light dark:border-border-dark flex items-center justify-between">
|
||||
<button type="button" class="inline-flex items-center gap-2 px-3 py-2 rounded border border-border-light dark:border-border-dark hover:bg-background-light dark:hover:bg-background-dark" onclick="hideDeleteModal()">
|
||||
<i class="fas fa-times"></i> {{ _('Cancel') }}
|
||||
</button>
|
||||
<form method="POST" id="deleteInvoiceForm" class="inline">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<button type="submit" class="inline-flex items-center gap-2 px-3 py-2 rounded bg-red-500 text-white hover:bg-red-600">
|
||||
<i class="fas fa-trash"></i> {{ _('Delete Invoice') }}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function showDeleteModal(invoiceId, invoiceNumber) {
|
||||
const numberEl = document.getElementById('deleteInvoiceNumber');
|
||||
const formEl = document.getElementById('deleteInvoiceForm');
|
||||
if (numberEl) numberEl.textContent = invoiceNumber || '';
|
||||
if (formEl) formEl.action = "{{ url_for('invoices.delete_invoice', invoice_id=0) }}".replace('0', invoiceId);
|
||||
const modal = document.getElementById('deleteInvoiceModal');
|
||||
if (modal) modal.classList.remove('hidden');
|
||||
}
|
||||
|
||||
function hideDeleteModal() {
|
||||
const modal = document.getElementById('deleteInvoiceModal');
|
||||
if (modal) modal.classList.add('hidden');
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -89,6 +89,28 @@
|
||||
<td class="num">{{ format_money(item.total_amount) }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
{% for expense in invoice.expenses %}
|
||||
<tr>
|
||||
<td>
|
||||
{{ expense.title|e }}
|
||||
{% if expense.description %}
|
||||
<br><small class="expense-description">{{ expense.description|e }}</small>
|
||||
{% endif %}
|
||||
{% if expense.category %}
|
||||
<br><small class="expense-category">{{ _('Expense') }}: {{ expense.category|title|e }}</small>
|
||||
{% endif %}
|
||||
{% if expense.vendor %}
|
||||
<br><small class="expense-vendor">{{ _('Vendor') }}: {{ expense.vendor|e }}</small>
|
||||
{% endif %}
|
||||
{% if expense.expense_date %}
|
||||
<br><small class="expense-date">{{ _('Date') }}: {{ expense.expense_date.strftime('%Y-%m-%d') }}</small>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="num">1</td>
|
||||
<td class="num">{{ format_money(expense.total_amount) }}</td>
|
||||
<td class="num">{{ format_money(expense.total_amount) }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
{% for good in invoice.extra_goods %}
|
||||
<tr>
|
||||
<td>
|
||||
|
||||
@@ -1,30 +0,0 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block content %}
|
||||
<div class="flex justify-between items-center mb-6">
|
||||
<h1 class="text-2xl font-bold">Record Payment for Invoice {{ invoice.invoice_number }}</h1>
|
||||
</div>
|
||||
|
||||
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow">
|
||||
<form method="POST">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<label for="amount" class="block text-sm font-medium text-gray-700 dark:text-gray-300">Amount</label>
|
||||
<input type="number" name="amount" id="amount" value="{{ invoice.outstanding_amount }}" step="0.01" required class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm dark:bg-gray-800 dark:border-gray-600">
|
||||
</div>
|
||||
<div>
|
||||
<label for="payment_date" class="block text-sm font-medium text-gray-700 dark:text-gray-300">Payment Date</label>
|
||||
<input type="date" name="payment_date" id="payment_date" value="{{ today }}" required class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm dark:bg-gray-800 dark:border-gray-600">
|
||||
</div>
|
||||
<div>
|
||||
<label for="payment_method" class="block text-sm font-medium text-gray-700 dark:text-gray-300">Payment Method</label>
|
||||
<input type="text" name="payment_method" id="payment_method" class="mt-1 block w-full rounded-md border-gray-300 shadow-sm focus:border-indigo-500 focus:ring-indigo-500 sm:text-sm dark:bg-gray-800 dark:border-gray-600">
|
||||
</div>
|
||||
</div>
|
||||
<div class="mt-6">
|
||||
<button type="submit" class="bg-primary text-white px-4 py-2 rounded-lg">Record Payment</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -3,10 +3,13 @@
|
||||
{% block content %}
|
||||
<div class="flex justify-between items-center mb-6">
|
||||
<h1 class="text-2xl font-bold">Invoice {{ invoice.invoice_number }}</h1>
|
||||
<div>
|
||||
<a href="{{ url_for('invoices.edit_invoice', invoice_id=invoice.id) }}" class="bg-primary text-white px-4 py-2 rounded-lg">Edit</a>
|
||||
<a href="{{ url_for('invoices.export_invoice_pdf', invoice_id=invoice.id) }}" class="bg-secondary text-white px-4 py-2 rounded-lg">Export PDF</a>
|
||||
<a href="{{ url_for('invoices.record_payment', invoice_id=invoice.id) }}" class="bg-green-500 text-white px-4 py-2 rounded-lg">Record Payment</a>
|
||||
<div class="flex gap-2">
|
||||
<a href="{{ url_for('invoices.edit_invoice', invoice_id=invoice.id) }}" class="bg-primary text-white px-4 py-2 rounded-lg hover:bg-primary-dark">Edit</a>
|
||||
<a 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>
|
||||
<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
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -50,6 +53,36 @@
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{% if invoice.expenses.count() > 0 %}
|
||||
<div class="mt-6">
|
||||
<h2 class="text-lg font-semibold mb-4">Expenses</h2>
|
||||
<table class="w-full text-left">
|
||||
<thead>
|
||||
<tr>
|
||||
<th class="p-2">Title</th>
|
||||
<th class="p-2">Description</th>
|
||||
<th class="p-2">Category</th>
|
||||
<th class="p-2">Date</th>
|
||||
<th class="p-2">Vendor</th>
|
||||
<th class="p-2">Amount</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for expense in invoice.expenses %}
|
||||
<tr class="border-b border-border-light dark:border-border-dark">
|
||||
<td class="p-2">{{ expense.title }}</td>
|
||||
<td class="p-2">{{ expense.description or '-' }}</td>
|
||||
<td class="p-2">{{ expense.category|capitalize }}</td>
|
||||
<td class="p-2">{{ expense.expense_date.strftime('%Y-%m-%d') if expense.expense_date else '-' }}</td>
|
||||
<td class="p-2">{{ expense.vendor or '-' }}</td>
|
||||
<td class="p-2">{{ "%.2f"|format(expense.total_amount) }} {{ expense.currency_code }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% endif %}
|
||||
|
||||
{% if invoice.extra_goods.count() > 0 %}
|
||||
<div class="mt-6">
|
||||
<h2 class="text-lg font-semibold mb-4">Extra Goods</h2>
|
||||
@@ -126,7 +159,7 @@
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for payment in invoice.payments.order_by('payment_date desc, created_at desc') %}
|
||||
{% for payment in invoice.sorted_payments %}
|
||||
<tr class="border-b border-border-light dark:border-border-dark hover:bg-gray-50 dark:hover:bg-gray-700">
|
||||
<td class="p-2">{{ payment.payment_date.strftime('%Y-%m-%d') if payment.payment_date else 'N/A' }}</td>
|
||||
<td class="p-2 font-semibold text-green-600 dark:text-green-400">
|
||||
@@ -178,4 +211,56 @@
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<!-- Delete Invoice Modal -->
|
||||
<div id="deleteInvoiceModal" class="hidden fixed inset-0 z-50" role="dialog" aria-modal="true" aria-labelledby="deleteModalTitle">
|
||||
<div class="absolute inset-0 bg-black/50" onclick="hideDeleteModal()"></div>
|
||||
<div class="relative max-w-lg mx-auto mt-24 bg-card-light dark:bg-card-dark text-text-light dark:text-text-dark rounded-lg shadow-xl ring-1 ring-border-light/60 dark:ring-border-dark/60">
|
||||
<div class="p-4 border-b border-border-light dark:border-border-dark flex items-center justify-between">
|
||||
<h5 id="deleteModalTitle" class="text-lg font-semibold flex items-center gap-2">
|
||||
<i class="fas fa-trash text-red-500"></i> {{ _('Delete Invoice') }}
|
||||
</h5>
|
||||
<button type="button" class="px-2 py-1 hover:bg-background-light dark:hover:bg-background-dark rounded text-2xl leading-none" aria-label="{{ _('Close') }}" onclick="hideDeleteModal()">×</button>
|
||||
</div>
|
||||
<div class="p-4">
|
||||
<div class="bg-yellow-50 dark:bg-yellow-900/30 text-yellow-900 dark:text-yellow-100 rounded-lg p-3 ring-1 ring-yellow-200/60 dark:ring-yellow-700/50 flex items-start gap-2">
|
||||
<i class="fas fa-exclamation-triangle mt-1"></i>
|
||||
<div>
|
||||
<strong>{{ _('Warning:') }}</strong> {{ _('This action cannot be undone.') }}
|
||||
</div>
|
||||
</div>
|
||||
<p class="mt-4">{{ _('Are you sure you want to delete invoice') }} <strong id="deleteInvoiceNumber"></strong>?</p>
|
||||
<p class="text-text-muted-light dark:text-text-muted-dark mt-2">
|
||||
<small>{{ _('All invoice items, extra goods, and payment records associated with this invoice will be permanently deleted.') }}</small>
|
||||
</p>
|
||||
</div>
|
||||
<div class="p-4 border-t border-border-light dark:border-border-dark flex items-center justify-between">
|
||||
<button type="button" class="inline-flex items-center gap-2 px-3 py-2 rounded border border-border-light dark:border-border-dark hover:bg-background-light dark:hover:bg-background-dark" onclick="hideDeleteModal()">
|
||||
<i class="fas fa-times"></i> {{ _('Cancel') }}
|
||||
</button>
|
||||
<form method="POST" id="deleteInvoiceForm" class="inline">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<button type="submit" class="inline-flex items-center gap-2 px-3 py-2 rounded bg-red-500 text-white hover:bg-red-600">
|
||||
<i class="fas fa-trash"></i> {{ _('Delete Invoice') }}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
function showDeleteModal(invoiceId, invoiceNumber) {
|
||||
const numberEl = document.getElementById('deleteInvoiceNumber');
|
||||
const formEl = document.getElementById('deleteInvoiceForm');
|
||||
if (numberEl) numberEl.textContent = invoiceNumber || '';
|
||||
if (formEl) formEl.action = "{{ url_for('invoices.delete_invoice', invoice_id=0) }}".replace('0', invoiceId);
|
||||
const modal = document.getElementById('deleteInvoiceModal');
|
||||
if (modal) modal.classList.remove('hidden');
|
||||
}
|
||||
|
||||
function hideDeleteModal() {
|
||||
const modal = document.getElementById('deleteInvoiceModal');
|
||||
if (modal) modal.classList.add('hidden');
|
||||
}
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -115,7 +115,11 @@
|
||||
<li>{{ _('Columns marked as "Complete" will mark tasks as completed when dragged to that column') }}</li>
|
||||
<li>{{ _('Inactive columns are hidden from the kanban board but tasks with that status remain accessible') }}</li>
|
||||
</ul>
|
||||
<!-- Accessible Tailwind Modal for Delete Confirmation -->
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Delete Column Modal -->
|
||||
<div id="deleteColumnModal" class="hidden fixed inset-0 z-50" role="dialog" aria-modal="true" aria-labelledby="deleteModalTitle">
|
||||
<div class="absolute inset-0 bg-black/50" onclick="hideDeleteModal()"></div>
|
||||
<div class="relative max-w-lg mx-auto mt-24 bg-card-light dark:bg-card-dark text-text-light dark:text-text-dark rounded-lg shadow-xl ring-1 ring-border-light/60 dark:ring-border-dark/60">
|
||||
@@ -153,46 +157,6 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Delete Column Modal -->
|
||||
<div class="modal fade" id="deleteColumnModal" tabindex="-1" aria-hidden="true">
|
||||
<div class="modal-dialog modal-dialog-centered">
|
||||
<div class="modal-content">
|
||||
<div class="modal-header">
|
||||
<h5 class="modal-title">
|
||||
<i class="fas fa-trash me-2 text-danger"></i>{{ _('Delete Kanban Column') }}
|
||||
</h5>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="{{ _('Close') }}"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<div class="alert alert-warning">
|
||||
<i class="fas fa-exclamation-triangle me-2"></i>
|
||||
<strong>{{ _('Warning:') }}</strong> {{ _('This action cannot be undone.') }}
|
||||
</div>
|
||||
<p>{{ _('Are you sure you want to delete the column') }} <strong id="deleteColumnLabel"></strong>?</p>
|
||||
<p class="text-muted mb-0">
|
||||
<small>{{ _('Key:') }} <code id="deleteColumnKey"></code></small>
|
||||
</p>
|
||||
<p class="text-muted mb-0 mt-2">
|
||||
<small>{{ _('Note: Tasks with this status will remain accessible but the column will no longer appear on the kanban board.') }}</small>
|
||||
</p>
|
||||
</div>
|
||||
<div class="modal-footer">
|
||||
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">
|
||||
<i class="fas fa-times me-1"></i>{{ _('Cancel') }}
|
||||
</button>
|
||||
<form method="POST" id="deleteColumnForm" class="d-inline">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<button type="submit" class="btn btn-danger">
|
||||
<i class="fas fa-trash me-2"></i>{{ _('Delete Column') }}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/sortablejs@1.15.0/Sortable.min.js"></script>
|
||||
<script>
|
||||
|
||||
@@ -167,7 +167,7 @@
|
||||
<p class="text-text-muted-light dark:text-text-muted-dark">{{ _('Save frequently used time entries as templates for quick reuse. Saves project, task, and notes.') }}</p>
|
||||
</div>
|
||||
<div class="bg-background-light dark:bg-background-dark/40 p-4 rounded border border-border-light dark:border-border-dark">
|
||||
<h5 class="font-semibold mb-2"><i class="fas fa-calendar text-sky-600 mr-2"></i>{{ _('Calendar View') }}</h5>
|
||||
<h5 class="font-semibold mb-2"><i class="fas fa-calendar text-sky-600 dark:text-sky-400 mr-2"></i>{{ _('Calendar View') }}</h5>
|
||||
<p class="text-text-muted-light dark:text-text-muted-dark">{{ _('Visualize your time entries on a calendar. Drag-and-drop to reschedule or click dates to add entries.') }}</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -182,9 +182,9 @@
|
||||
<h4 class="font-semibold mb-2">{{ _('Project Information') }}</h4>
|
||||
<ul class="space-y-1 text-text-muted-light dark:text-text-muted-dark">
|
||||
<li><i class="fas fa-tag text-primary mr-2"></i><strong>{{ _('Name') }}:</strong> {{ _('Descriptive project name') }}</li>
|
||||
<li><i class="fas fa-building text-green-600 mr-2"></i><strong>{{ _('Client') }}:</strong> {{ _('Associated client organization') }}</li>
|
||||
<li><i class="fas fa-align-left text-sky-600 mr-2"></i><strong>{{ _('Description') }}:</strong> {{ _('Project details and scope') }}</li>
|
||||
<li><i class="fas fa-calendar text-amber-600 mr-2"></i><strong>{{ _('Status') }}:</strong> {{ _('Active, completed, or archived') }}</li>
|
||||
<li><i class="fas fa-building text-green-600 dark:text-green-400 mr-2"></i><strong>{{ _('Client') }}:</strong> {{ _('Associated client organization') }}</li>
|
||||
<li><i class="fas fa-align-left text-sky-600 dark:text-sky-400 mr-2"></i><strong>{{ _('Description') }}:</strong> {{ _('Project details and scope') }}</li>
|
||||
<li><i class="fas fa-calendar text-amber-600 dark:text-amber-400 mr-2"></i><strong>{{ _('Status') }}:</strong> {{ _('Active, completed, or archived') }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
@@ -251,7 +251,7 @@
|
||||
<p class="text-xs text-text-muted-light dark:text-text-muted-dark">{{ _('Total and active projects') }}</p>
|
||||
</div>
|
||||
<div class="text-center p-4 rounded border border-border-light dark:border-border-dark">
|
||||
<i class="fas fa-clock text-green-600 mb-2"></i>
|
||||
<i class="fas fa-clock text-green-600 dark:text-green-400 mb-2"></i>
|
||||
<h5 class="font-semibold">{{ _('Time Tracking') }}</h5>
|
||||
<p class="text-xs text-text-muted-light dark:text-text-muted-dark">{{ _('Total hours worked') }}</p>
|
||||
</div>
|
||||
@@ -272,9 +272,9 @@
|
||||
<h4 class="font-semibold mb-2">{{ _('Task Properties') }}</h4>
|
||||
<ul class="space-y-1 text-text-muted-light dark:text-text-muted-dark">
|
||||
<li><i class="fas fa-tag text-primary mr-2"></i><strong>{{ _('Name & Description') }}:</strong> {{ _('Clear task identification') }}</li>
|
||||
<li><i class="fas fa-flag text-amber-600 mr-2"></i><strong>{{ _('Priority Levels') }}:</strong> {{ _('Low, Medium, High, Urgent') }}</li>
|
||||
<li><i class="fas fa-calendar text-sky-600 mr-2"></i><strong>{{ _('Due Dates') }}:</strong> {{ _('Deadline tracking') }}</li>
|
||||
<li><i class="fas fa-user text-green-600 mr-2"></i><strong>{{ _('Assignment') }}:</strong> {{ _('Task ownership') }}</li>
|
||||
<li><i class="fas fa-flag text-amber-600 dark:text-amber-400 mr-2"></i><strong>{{ _('Priority Levels') }}:</strong> {{ _('Low, Medium, High, Urgent') }}</li>
|
||||
<li><i class="fas fa-calendar text-sky-600 dark:text-sky-400 mr-2"></i><strong>{{ _('Due Dates') }}:</strong> {{ _('Deadline tracking') }}</li>
|
||||
<li><i class="fas fa-user text-green-600 dark:text-green-400 mr-2"></i><strong>{{ _('Assignment') }}:</strong> {{ _('Task ownership') }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
@@ -424,9 +424,9 @@
|
||||
<div class="bg-background-light dark:bg-background-dark/40 p-4 rounded border border-border-light dark:border-border-dark">
|
||||
<h4 class="font-semibold mb-1">{{ _('Time Integration') }}</h4>
|
||||
<ul class="space-y-1 text-text-muted-light dark:text-text-muted-dark">
|
||||
<li><i class="fas fa-clock text-amber-600 mr-2"></i>{{ _('Generate from time entries') }}</li>
|
||||
<li><i class="fas fa-layer-group text-sky-600 mr-2"></i>{{ _('Smart grouping by task/project') }}</li>
|
||||
<li><i class="fas fa-shield-alt text-green-600 mr-2"></i>{{ _('Prevent double-billing') }}</li>
|
||||
<li><i class="fas fa-clock text-amber-600 dark:text-amber-400 mr-2"></i>{{ _('Generate from time entries') }}</li>
|
||||
<li><i class="fas fa-layer-group text-sky-600 dark:text-sky-400 mr-2"></i>{{ _('Smart grouping by task/project') }}</li>
|
||||
<li><i class="fas fa-shield-alt text-green-600 dark:text-green-400 mr-2"></i>{{ _('Prevent double-billing') }}</li>
|
||||
<li><i class="fas fa-money-bill text-primary mr-2"></i>{{ _('Use project hourly rates') }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
@@ -600,9 +600,9 @@
|
||||
<h4 class="font-semibold mb-2">{{ _('General Settings') }}</h4>
|
||||
<ul class="space-y-1 text-text-muted-light dark:text-text-muted-dark">
|
||||
<li><i class="fas fa-globe text-primary mr-2"></i>{{ _('Timezone and locale settings') }}</li>
|
||||
<li><i class="fas fa-dollar-sign text-green-600 mr-2"></i>{{ _('Currency configuration') }}</li>
|
||||
<li><i class="fas fa-clock text-sky-600 mr-2"></i>{{ _('Time rounding rules') }}</li>
|
||||
<li><i class="fas fa-user-plus text-amber-600 mr-2"></i>{{ _('Self-registration settings') }}</li>
|
||||
<li><i class="fas fa-dollar-sign text-green-600 dark:text-green-400 mr-2"></i>{{ _('Currency configuration') }}</li>
|
||||
<li><i class="fas fa-clock text-sky-600 dark:text-sky-400 mr-2"></i>{{ _('Time rounding rules') }}</li>
|
||||
<li><i class="fas fa-user-plus text-amber-600 dark:text-amber-400 mr-2"></i>{{ _('Self-registration settings') }}</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
|
||||
@@ -115,9 +115,22 @@
|
||||
// Generic data-confirm handler
|
||||
document.addEventListener('DOMContentLoaded', function(){
|
||||
document.querySelectorAll('form[data-confirm]').forEach(function(f){
|
||||
f.addEventListener('submit', function(e){
|
||||
f.addEventListener('submit', async function(e){
|
||||
e.preventDefault();
|
||||
var msg = f.getAttribute('data-confirm') || '';
|
||||
if (msg && !confirm(msg)) { e.preventDefault(); return false; }
|
||||
if (msg) {
|
||||
const confirmed = await showConfirm(msg, {
|
||||
title: 'Confirm Action',
|
||||
confirmText: 'Confirm',
|
||||
cancelText: 'Cancel',
|
||||
variant: 'warning'
|
||||
});
|
||||
if (confirmed) {
|
||||
f.submit();
|
||||
}
|
||||
} else {
|
||||
f.submit();
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -236,24 +236,16 @@
|
||||
|
||||
{% block scripts_extra %}
|
||||
<script>
|
||||
function confirmDeletePayment() {
|
||||
async function confirmDeletePayment() {
|
||||
const message = 'Are you sure you want to delete this payment? This will affect the invoice payment status and cannot be undone.';
|
||||
if (window.showConfirm) {
|
||||
window.showConfirm(message, {
|
||||
title: 'Delete Payment',
|
||||
confirmText: 'Delete',
|
||||
cancelText: 'Cancel',
|
||||
variant: 'danger'
|
||||
}).then(function(confirmed) {
|
||||
if (confirmed) {
|
||||
document.getElementById('deletePaymentForm').submit();
|
||||
}
|
||||
});
|
||||
} else {
|
||||
// Fallback if showConfirm is not available
|
||||
if (confirm(message)) {
|
||||
document.getElementById('deletePaymentForm').submit();
|
||||
}
|
||||
const confirmed = await showConfirm(message, {
|
||||
title: 'Delete Payment',
|
||||
confirmText: 'Delete',
|
||||
cancelText: 'Cancel',
|
||||
variant: 'danger'
|
||||
});
|
||||
if (confirmed) {
|
||||
document.getElementById('deletePaymentForm').submit();
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -45,7 +45,7 @@
|
||||
{{ task.priority | capitalize }}
|
||||
</span>
|
||||
{% if task.due_date %}
|
||||
<span class="{{ 'text-red-500' if task.is_overdue else '' }}">
|
||||
<span class="{{ 'text-red-500 dark:text-red-400' if task.is_overdue else '' }}">
|
||||
<i class="fas fa-calendar-alt mr-1"></i>
|
||||
{{ task.due_date.strftime('%b %d') }}
|
||||
</span>
|
||||
|
||||
@@ -153,12 +153,18 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
}
|
||||
|
||||
// Sync markdown on form submit
|
||||
const form = document.querySelector('form[action*="projects/edit"]') || document.querySelector('form');
|
||||
form && form.addEventListener('submit', function(){
|
||||
if (mdEditor && descriptionInput) {
|
||||
try { descriptionInput.value = mdEditor.getMarkdown(); } catch (err) {}
|
||||
}
|
||||
});
|
||||
const form = document.querySelector('form[action*="/edit"]') || document.querySelector('form');
|
||||
if (form) {
|
||||
form.addEventListener('submit', function(){
|
||||
if (mdEditor && descriptionInput) {
|
||||
try {
|
||||
descriptionInput.value = mdEditor.getMarkdown();
|
||||
} catch (err) {
|
||||
console.error('Failed to sync markdown editor:', err);
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -33,7 +33,7 @@
|
||||
<!-- Date Range Section -->
|
||||
<div>
|
||||
<h3 class="text-lg font-semibold mb-4 text-gray-900 dark:text-white flex items-center">
|
||||
<i class="fas fa-calendar-alt mr-2 text-indigo-500"></i>
|
||||
<i class="fas fa-calendar-alt mr-2 text-indigo-500 dark:text-indigo-400"></i>
|
||||
Date Range
|
||||
</h3>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
|
||||
@@ -456,8 +456,17 @@ function customizeShortcut(normalizedKey) {
|
||||
}
|
||||
|
||||
// Reset to defaults
|
||||
function resetToDefaults() {
|
||||
if (confirm('This will reset all keyboard shortcuts to their default values. Continue?')) {
|
||||
async function resetToDefaults() {
|
||||
const confirmed = await showConfirm(
|
||||
'{{ _("This will reset all keyboard shortcuts to their default values. Continue?") }}',
|
||||
{
|
||||
title: '{{ _("Reset to Defaults") }}',
|
||||
confirmText: '{{ _("Reset") }}',
|
||||
cancelText: '{{ _("Cancel") }}',
|
||||
variant: 'warning'
|
||||
}
|
||||
);
|
||||
if (confirmed) {
|
||||
localStorage.removeItem('tt_shortcuts_custom_shortcuts');
|
||||
localStorage.removeItem('tt_shortcuts_disabled_shortcuts');
|
||||
location.reload();
|
||||
|
||||
@@ -218,9 +218,9 @@
|
||||
<small class="text-text-muted-light dark:text-text-muted-dark block mb-1">{{ _('Current Due Date') }}</small>
|
||||
<div class="flex items-center">
|
||||
<div class="rounded-full flex items-center justify-center mr-2 {% if task.is_overdue %}bg-rose-500/10{% else %}bg-slate-500/10{% endif %}" style="width: 24px; height: 24px;">
|
||||
<i class="fas fa-calendar {% if task.is_overdue %}text-rose-600{% else %}text-slate-500{% endif %} fa-xs"></i>
|
||||
<i class="fas fa-calendar {% if task.is_overdue %}text-rose-600 dark:text-rose-400{% else %}text-slate-500 dark:text-slate-400{% endif %} fa-xs"></i>
|
||||
</div>
|
||||
<span class="{% if task.is_overdue %}text-rose-600 font-semibold{% endif %}">
|
||||
<span class="{% if task.is_overdue %}text-rose-600 dark:text-rose-400 font-semibold{% endif %}">
|
||||
{{ task.due_date.strftime('%B %d, %Y') }}
|
||||
</span>
|
||||
</div>
|
||||
@@ -232,7 +232,7 @@
|
||||
<small class="text-text-muted-light dark:text-text-muted-dark block mb-1">{{ _('Current Estimate') }}</small>
|
||||
<div class="flex items-center">
|
||||
<div class="rounded-full flex items-center justify-center mr-2 bg-amber-500/10" style="width: 24px; height: 24px;">
|
||||
<i class="fas fa-clock text-amber-600 fa-xs"></i>
|
||||
<i class="fas fa-clock text-amber-600 dark:text-amber-400 fa-xs"></i>
|
||||
</div>
|
||||
<span>{{ task.estimated_hours }} {{ _('hours') }}</span>
|
||||
</div>
|
||||
|
||||
@@ -262,40 +262,27 @@ function confirmDeleteTask(taskId, taskName, hasTimeEntries) {
|
||||
}
|
||||
|
||||
const msg = (i18nTasksList.confirm_delete || 'Are you sure you want to delete the task "{name}"?').replace('{name}', taskName);
|
||||
if (window.showConfirm) {
|
||||
window.showConfirm(msg).then(function(ok){
|
||||
if (!ok) return;
|
||||
const form = document.createElement('form');
|
||||
form.method = 'POST';
|
||||
form.action = `/tasks/${taskId}/delete`;
|
||||
|
||||
// Add CSRF token
|
||||
const csrfInput = document.createElement('input');
|
||||
csrfInput.type = 'hidden';
|
||||
csrfInput.name = 'csrf_token';
|
||||
csrfInput.value = document.querySelector('meta[name="csrf-token"]').getAttribute('content') || '';
|
||||
form.appendChild(csrfInput);
|
||||
|
||||
document.body.appendChild(form);
|
||||
form.submit();
|
||||
});
|
||||
} else {
|
||||
if (confirm(msg)) {
|
||||
const form = document.createElement('form');
|
||||
form.method = 'POST';
|
||||
form.action = `/tasks/${taskId}/delete`;
|
||||
|
||||
// Add CSRF token
|
||||
const csrfInput = document.createElement('input');
|
||||
csrfInput.type = 'hidden';
|
||||
csrfInput.name = 'csrf_token';
|
||||
csrfInput.value = document.querySelector('meta[name="csrf-token"]').getAttribute('content') || '';
|
||||
form.appendChild(csrfInput);
|
||||
|
||||
document.body.appendChild(form);
|
||||
form.submit();
|
||||
}
|
||||
}
|
||||
window.showConfirm(msg, {
|
||||
title: '{{ _("Delete Task") }}',
|
||||
confirmText: '{{ _("Delete") }}',
|
||||
cancelText: '{{ _("Cancel") }}',
|
||||
variant: 'danger'
|
||||
}).then(function(ok){
|
||||
if (!ok) return;
|
||||
const form = document.createElement('form');
|
||||
form.method = 'POST';
|
||||
form.action = `/tasks/${taskId}/delete`;
|
||||
|
||||
// Add CSRF token
|
||||
const csrfInput = document.createElement('input');
|
||||
csrfInput.type = 'hidden';
|
||||
csrfInput.name = 'csrf_token';
|
||||
csrfInput.value = document.querySelector('meta[name="csrf-token"]').getAttribute('content') || '';
|
||||
form.appendChild(csrfInput);
|
||||
|
||||
document.body.appendChild(form);
|
||||
form.submit();
|
||||
});
|
||||
}
|
||||
|
||||
function toggleFilterVisibility() {
|
||||
@@ -303,14 +290,21 @@ function toggleFilterVisibility() {
|
||||
const toggleIcon = document.getElementById('filterToggleIcon');
|
||||
const toggleButton = document.getElementById('toggleFilters');
|
||||
if (!filterBody || !toggleIcon || !toggleButton) return;
|
||||
if (filterBody.classList.contains('filter-collapsed')) {
|
||||
|
||||
const isCollapsed = filterBody.classList.contains('filter-collapsed');
|
||||
|
||||
if (isCollapsed) {
|
||||
// Show filters
|
||||
filterBody.classList.remove('filter-collapsed');
|
||||
toggleIcon.className = 'fas fa-chevron-up';
|
||||
toggleIcon.classList.remove('fa-chevron-down');
|
||||
toggleIcon.classList.add('fa-chevron-up');
|
||||
toggleButton.title = '{{ _('Hide Filters') }}';
|
||||
localStorage.setItem('taskListFiltersVisible', 'true');
|
||||
} else {
|
||||
// Hide filters
|
||||
filterBody.classList.add('filter-collapsed');
|
||||
toggleIcon.className = 'fas fa-chevron-down';
|
||||
toggleIcon.classList.remove('fa-chevron-up');
|
||||
toggleIcon.classList.add('fa-chevron-down');
|
||||
toggleButton.title = '{{ _('Show Filters') }}';
|
||||
localStorage.setItem('taskListFiltersVisible', 'false');
|
||||
}
|
||||
@@ -321,11 +315,19 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
const toggleIcon = document.getElementById('filterToggleIcon');
|
||||
const toggleButton = document.getElementById('toggleFilters');
|
||||
if (!filterBody || !toggleIcon || !toggleButton) return;
|
||||
|
||||
const filtersVisible = localStorage.getItem('taskListFiltersVisible');
|
||||
if (filtersVisible === 'false') {
|
||||
filterBody.classList.add('filter-collapsed');
|
||||
toggleIcon.className = 'fas fa-chevron-down';
|
||||
toggleIcon.classList.remove('fa-chevron-up');
|
||||
toggleIcon.classList.add('fa-chevron-down');
|
||||
toggleButton.title = '{{ _('Show Filters') }}';
|
||||
} else {
|
||||
// Explicitly set the icon to chevron-up when filter is visible
|
||||
filterBody.classList.remove('filter-collapsed');
|
||||
toggleIcon.classList.remove('fa-chevron-down');
|
||||
toggleIcon.classList.add('fa-chevron-up');
|
||||
toggleButton.title = '{{ _('Hide Filters') }}';
|
||||
}
|
||||
setTimeout(() => { filterBody.classList.add('filter-toggle-transition'); }, 100);
|
||||
|
||||
|
||||
@@ -585,16 +585,20 @@ function toggleFilterVisibility() {
|
||||
const toggleIcon = document.getElementById('filterToggleIcon');
|
||||
const toggleButton = document.getElementById('toggleFilters');
|
||||
|
||||
if (filterBody.classList.contains('filter-collapsed')) {
|
||||
const isCollapsed = filterBody.classList.contains('filter-collapsed');
|
||||
|
||||
if (isCollapsed) {
|
||||
// Show filters
|
||||
filterBody.classList.remove('filter-collapsed');
|
||||
toggleIcon.className = 'fas fa-chevron-up';
|
||||
toggleIcon.classList.remove('fa-chevron-down');
|
||||
toggleIcon.classList.add('fa-chevron-up');
|
||||
toggleButton.title = '{{ _('Hide Filters') }}';
|
||||
localStorage.setItem('myTaskFiltersVisible', 'true');
|
||||
} else {
|
||||
// Hide filters
|
||||
filterBody.classList.add('filter-collapsed');
|
||||
toggleIcon.className = 'fas fa-chevron-down';
|
||||
toggleIcon.classList.remove('fa-chevron-up');
|
||||
toggleIcon.classList.add('fa-chevron-down');
|
||||
toggleButton.title = '{{ _('Show Filters') }}';
|
||||
localStorage.setItem('myTaskFiltersVisible', 'false');
|
||||
}
|
||||
@@ -610,8 +614,15 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
const filtersVisible = localStorage.getItem('myTaskFiltersVisible');
|
||||
if (filtersVisible === 'false') {
|
||||
filterBody.classList.add('filter-collapsed');
|
||||
toggleIcon.className = 'fas fa-chevron-down';
|
||||
toggleIcon.classList.remove('fa-chevron-up');
|
||||
toggleIcon.classList.add('fa-chevron-down');
|
||||
toggleButton.title = '{{ _('Show Filters') }}';
|
||||
} else {
|
||||
// Explicitly set the icon to chevron-up when filter is visible
|
||||
filterBody.classList.remove('filter-collapsed');
|
||||
toggleIcon.classList.remove('fa-chevron-down');
|
||||
toggleIcon.classList.add('fa-chevron-up');
|
||||
toggleButton.title = '{{ _('Hide Filters') }}';
|
||||
}
|
||||
|
||||
// Add transition class after initial setup
|
||||
|
||||
@@ -162,24 +162,42 @@ var i18n_overdue = (function(){
|
||||
catch(e) { return {}; }
|
||||
})();
|
||||
|
||||
function extendDueDates() {
|
||||
async function extendDueDates() {
|
||||
const newDate = prompt(i18n_overdue.enter_new_due_date || 'Enter new due date (YYYY-MM-DD):', new Date().toISOString().split('T')[0]);
|
||||
if (newDate) {
|
||||
var p = i18n_overdue.confirm_extend_prefix || 'Are you sure you want to extend the due date to';
|
||||
var s = i18n_overdue.confirm_extend_suffix || 'for all overdue tasks?';
|
||||
if (confirm(p + ' ' + newDate + ' ' + s)) {
|
||||
const confirmed = await showConfirm(
|
||||
p + ' ' + newDate + ' ' + s,
|
||||
{
|
||||
title: 'Extend Due Dates',
|
||||
confirmText: 'Extend',
|
||||
cancelText: 'Cancel',
|
||||
variant: 'primary'
|
||||
}
|
||||
);
|
||||
if (confirmed) {
|
||||
// This would need to be implemented with a bulk update endpoint
|
||||
alert(i18n_overdue.feature_coming || 'Bulk due date update feature coming soon!');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function adjustPriorities() {
|
||||
async function adjustPriorities() {
|
||||
const newPriority = prompt(i18n_overdue.enter_new_priority || 'Enter new priority (low/medium/high/urgent):', 'high');
|
||||
if (newPriority && ['low', 'medium', 'high', 'urgent'].includes(newPriority.toLowerCase())) {
|
||||
var p2 = i18n_overdue.confirm_set_priority_prefix || 'Are you sure you want to set priority to';
|
||||
var s2 = i18n_overdue.confirm_set_priority_suffix || 'for all overdue tasks?';
|
||||
if (confirm(p2 + ' ' + newPriority + ' ' + s2)) {
|
||||
const confirmed = await showConfirm(
|
||||
p2 + ' ' + newPriority + ' ' + s2,
|
||||
{
|
||||
title: 'Adjust Priorities',
|
||||
confirmText: 'Update',
|
||||
cancelText: 'Cancel',
|
||||
variant: 'primary'
|
||||
}
|
||||
);
|
||||
if (confirmed) {
|
||||
// This would need to be implemented with a bulk update endpoint
|
||||
alert(i18n_overdue.feature_priority_coming || 'Bulk priority update feature coming soon!');
|
||||
}
|
||||
|
||||
@@ -40,7 +40,7 @@
|
||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-md p-6">
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<h3 class="text-sm font-medium text-gray-600 dark:text-gray-400">{{ _('Actual Hours') }}</h3>
|
||||
<i class="fas fa-clock text-2xl text-green-500"></i>
|
||||
<i class="fas fa-clock text-2xl text-green-500 dark:text-green-400"></i>
|
||||
</div>
|
||||
<p class="text-3xl font-bold text-gray-900 dark:text-white">{{ goal.actual_hours }}h</p>
|
||||
</div>
|
||||
@@ -49,13 +49,13 @@
|
||||
<div class="flex items-center justify-between mb-2">
|
||||
<h3 class="text-sm font-medium text-gray-600 dark:text-gray-400">{{ _('Status') }}</h3>
|
||||
{% if goal.status == 'completed' %}
|
||||
<i class="fas fa-check-circle text-2xl text-green-500"></i>
|
||||
<i class="fas fa-check-circle text-2xl text-green-500 dark:text-green-400"></i>
|
||||
{% elif goal.status == 'active' %}
|
||||
<i class="fas fa-clock text-2xl text-blue-500"></i>
|
||||
<i class="fas fa-clock text-2xl text-blue-500 dark:text-blue-400"></i>
|
||||
{% elif goal.status == 'failed' %}
|
||||
<i class="fas fa-times-circle text-2xl text-red-500"></i>
|
||||
<i class="fas fa-times-circle text-2xl text-red-500 dark:text-red-400"></i>
|
||||
{% else %}
|
||||
<i class="fas fa-ban text-2xl text-gray-500"></i>
|
||||
<i class="fas fa-ban text-2xl text-gray-500 dark:text-gray-400"></i>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% if goal.status == 'completed' %}
|
||||
|
||||
@@ -59,6 +59,9 @@ def register_context_processors(app):
|
||||
env_version = os.getenv('APP_VERSION')
|
||||
# If running in GitHub Actions build, prefer tag-like versions
|
||||
version_value = env_version or getattr(Config, 'APP_VERSION', None) or 'dev-0'
|
||||
# Strip any leading 'v' prefix to avoid double 'v' in template (e.g., vv3.5.0)
|
||||
if version_value and version_value.startswith('v'):
|
||||
version_value = version_value[1:]
|
||||
except Exception:
|
||||
version_value = 'dev-0'
|
||||
|
||||
|
||||
+34
-21
@@ -62,43 +62,56 @@ class InvoicePDFGenerator:
|
||||
css = _render_tpl('invoices/pdf_styles_default.css')
|
||||
except Exception:
|
||||
css = self._generate_css()
|
||||
# Import helper functions for template
|
||||
from app.utils.template_filters import get_logo_base64
|
||||
from babel.dates import format_date as babel_format_date
|
||||
|
||||
def format_date(value, format='medium'):
|
||||
"""Format date for template"""
|
||||
if babel_format_date:
|
||||
return babel_format_date(value, format=format)
|
||||
return value.strftime('%Y-%m-%d') if value else ''
|
||||
|
||||
def format_money(value):
|
||||
"""Format money for template"""
|
||||
try:
|
||||
return f"{float(value):,.2f}"
|
||||
except Exception:
|
||||
return str(value)
|
||||
|
||||
try:
|
||||
# Render using Flask's Jinja environment to include app filters and _()
|
||||
if html_template:
|
||||
from flask import render_template_string
|
||||
html = render_template_string(html_template, invoice=self.invoice, settings=self.settings, Path=Path)
|
||||
except Exception:
|
||||
html = render_template_string(html_template,
|
||||
invoice=self.invoice,
|
||||
settings=self.settings,
|
||||
Path=Path,
|
||||
get_logo_base64=get_logo_base64,
|
||||
format_date=format_date,
|
||||
format_money=format_money,
|
||||
now=datetime.now())
|
||||
except Exception as e:
|
||||
# Log the exception for debugging
|
||||
import traceback
|
||||
print(f"Error rendering custom PDF template: {e}")
|
||||
print(traceback.format_exc())
|
||||
html = ''
|
||||
|
||||
if not html:
|
||||
try:
|
||||
# Import helper functions for template
|
||||
from app.utils.template_filters import get_logo_base64
|
||||
from babel.dates import format_date as babel_format_date
|
||||
|
||||
def format_date(value, format='medium'):
|
||||
"""Format date for template"""
|
||||
if babel_format_date:
|
||||
return babel_format_date(value, format=format)
|
||||
return value.strftime('%Y-%m-%d') if value else ''
|
||||
|
||||
def format_money(value):
|
||||
"""Format money for template"""
|
||||
try:
|
||||
return f"{float(value):,.2f}"
|
||||
except Exception:
|
||||
return str(value)
|
||||
|
||||
html = render_template('invoices/pdf_default.html',
|
||||
invoice=self.invoice,
|
||||
settings=self.settings,
|
||||
Path=Path,
|
||||
get_logo_base64=get_logo_base64,
|
||||
format_date=format_date,
|
||||
format_money=format_money)
|
||||
format_money=format_money,
|
||||
now=datetime.now())
|
||||
except Exception as e:
|
||||
# Log the exception for debugging
|
||||
import traceback
|
||||
print(f"Error rendering PDF template: {e}")
|
||||
print(f"Error rendering default PDF template: {e}")
|
||||
print(traceback.format_exc())
|
||||
html = f"<html><body><h1>{_('Invoice')} {self.invoice.invoice_number}</h1></body></html>"
|
||||
return html, css
|
||||
|
||||
@@ -161,3 +161,31 @@ def register_template_filters(app):
|
||||
else:
|
||||
y = int(years)
|
||||
return f"{y} year{'s' if y != 1 else ''} ago"
|
||||
|
||||
|
||||
def get_logo_base64(logo_path):
|
||||
"""Convert logo file to base64 data URI for PDF embedding"""
|
||||
if not logo_path:
|
||||
return ''
|
||||
|
||||
import os
|
||||
if not os.path.exists(logo_path):
|
||||
return ''
|
||||
|
||||
try:
|
||||
import base64
|
||||
import mimetypes
|
||||
|
||||
with open(logo_path, 'rb') as logo_file:
|
||||
logo_data = base64.b64encode(logo_file.read()).decode('utf-8')
|
||||
|
||||
# Detect MIME type
|
||||
mime_type, _ = mimetypes.guess_type(logo_path)
|
||||
if not mime_type:
|
||||
# Default to PNG if can't detect
|
||||
mime_type = 'image/png'
|
||||
|
||||
return f'data:{mime_type};base64,{logo_data}'
|
||||
except Exception as e:
|
||||
print(f"Error converting logo to base64: {e}")
|
||||
return ''
|
||||
|
||||
@@ -37,6 +37,7 @@ services:
|
||||
- "8080:8080"
|
||||
volumes:
|
||||
- app_data:/data
|
||||
- app_uploads:/app/app/static/uploads
|
||||
- ./logs:/app/logs
|
||||
depends_on:
|
||||
db:
|
||||
@@ -70,6 +71,8 @@ services:
|
||||
volumes:
|
||||
app_data:
|
||||
driver: local
|
||||
app_uploads:
|
||||
driver: local
|
||||
db_data:
|
||||
driver: local
|
||||
|
||||
|
||||
@@ -33,6 +33,8 @@ services:
|
||||
volumes:
|
||||
# Mount data directory for SQLite database and uploads
|
||||
- app_data_local_test:/data
|
||||
# Mount uploads directory for logos and avatars
|
||||
- app_uploads_local_test:/app/app/static/uploads
|
||||
# Mount logs directory for easier debugging
|
||||
- ./logs:/app/logs
|
||||
restart: unless-stopped
|
||||
@@ -50,3 +52,5 @@ services:
|
||||
volumes:
|
||||
app_data_local_test:
|
||||
driver: local
|
||||
app_uploads_local_test:
|
||||
driver: local
|
||||
|
||||
@@ -33,6 +33,7 @@ services:
|
||||
- "8080:8080"
|
||||
volumes:
|
||||
- app_data_remote_dev:/data
|
||||
- app_uploads_remote_dev:/app/app/static/uploads
|
||||
- ./logs:/app/logs
|
||||
depends_on:
|
||||
db:
|
||||
@@ -66,6 +67,8 @@ services:
|
||||
volumes:
|
||||
app_data_remote_dev:
|
||||
driver: local
|
||||
app_uploads_remote_dev:
|
||||
driver: local
|
||||
db_data_remote_dev:
|
||||
driver: local
|
||||
|
||||
|
||||
@@ -60,6 +60,7 @@ services:
|
||||
ports: []
|
||||
volumes:
|
||||
- app_data_remote:/data
|
||||
- app_uploads_remote:/app/app/static/uploads
|
||||
- ./logs:/app/logs
|
||||
depends_on:
|
||||
db:
|
||||
@@ -93,5 +94,7 @@ services:
|
||||
volumes:
|
||||
app_data_remote:
|
||||
driver: local
|
||||
app_uploads_remote:
|
||||
driver: local
|
||||
db_data_remote:
|
||||
driver: local
|
||||
|
||||
@@ -85,6 +85,7 @@ services:
|
||||
volumes:
|
||||
- app_data:/data
|
||||
- app_logs:/app/logs
|
||||
- app_uploads:/app/app/static/uploads
|
||||
depends_on:
|
||||
db:
|
||||
condition: service_healthy
|
||||
@@ -179,6 +180,8 @@ volumes:
|
||||
driver: local
|
||||
app_logs:
|
||||
driver: local
|
||||
app_uploads:
|
||||
driver: local
|
||||
db_data:
|
||||
driver: local
|
||||
prometheus_data:
|
||||
|
||||
@@ -0,0 +1,216 @@
|
||||
# Invoice Expenses Feature
|
||||
|
||||
## Overview
|
||||
|
||||
The TimeTracker application now supports adding expenses to invoices. This feature allows you to track billable expenses such as travel, meals, accommodation, and other project-related costs, and include them directly in client invoices.
|
||||
|
||||
## Key Features
|
||||
|
||||
- **Link expenses to invoices**: Associate billable expenses with specific invoices
|
||||
- **Automatic total calculation**: Expenses are automatically included in invoice subtotals and tax calculations
|
||||
- **Multiple expense categories**: Support for travel, meals, accommodation, supplies, software, equipment, services, marketing, training, and other categories
|
||||
- **Expense tracking**: Track vendor information, receipts, dates, and detailed descriptions
|
||||
- **PDF and CSV export**: Expenses are included in invoice PDF and CSV exports
|
||||
- **Flexible workflow**: Add expenses during invoice creation or edit existing invoices to add/remove expenses
|
||||
|
||||
## How It Works
|
||||
|
||||
### 1. Creating Billable Expenses
|
||||
|
||||
Expenses must be created and marked as billable before they can be added to invoices. To create a billable expense:
|
||||
|
||||
1. Navigate to the Expenses section
|
||||
2. Click "Add Expense"
|
||||
3. Fill in the expense details:
|
||||
- **Title**: Short description (e.g., "Travel to Client Meeting")
|
||||
- **Description**: Detailed information about the expense
|
||||
- **Category**: Select from available categories (travel, meals, accommodation, etc.)
|
||||
- **Amount**: The expense amount (excluding tax)
|
||||
- **Tax Amount**: If applicable, the tax amount
|
||||
- **Date**: When the expense was incurred
|
||||
- **Vendor**: Who you paid (optional)
|
||||
- **Billable**: Check this box to make the expense available for invoicing
|
||||
4. Save the expense
|
||||
|
||||
### 2. Adding Expenses to Invoices
|
||||
|
||||
There are two ways to add expenses to invoices:
|
||||
|
||||
#### Method 1: Generate from Time, Costs & Goods
|
||||
|
||||
1. Open an existing invoice or create a new one
|
||||
2. Click "Generate from Time/Costs" in the Quick Actions panel
|
||||
3. In the "Uninvoiced Billable Expenses" section, select the expenses you want to add
|
||||
4. You can also select time entries, project costs, and extra goods at the same time
|
||||
5. Click "Add Selected to Invoice"
|
||||
|
||||
The selected expenses will be linked to the invoice and appear in the Expenses section.
|
||||
|
||||
#### Method 2: Direct Edit
|
||||
|
||||
1. Open an invoice in edit mode
|
||||
2. Navigate to the "Expenses" section
|
||||
3. Click "Add Expense" to go to the Generate from Time/Costs page
|
||||
4. Alternatively, expenses can be managed through the invoice edit form
|
||||
|
||||
### 3. Viewing Expenses on Invoices
|
||||
|
||||
Expenses appear in several places:
|
||||
|
||||
**Invoice Edit View**:
|
||||
- Expenses section shows all linked expenses
|
||||
- Read-only fields display expense details
|
||||
- Unlink button to remove expenses from the invoice
|
||||
|
||||
**Invoice View**:
|
||||
- Dedicated "Expenses" table showing:
|
||||
- Title
|
||||
- Description
|
||||
- Category
|
||||
- Date
|
||||
- Vendor
|
||||
- Amount
|
||||
|
||||
**Live Preview Panel**:
|
||||
- Shows expense count and total
|
||||
- Updates in real-time as you add/remove expenses
|
||||
|
||||
### 4. Invoice Calculations
|
||||
|
||||
Expenses affect invoice totals as follows:
|
||||
|
||||
1. **Subtotal**: Sum of all items + expenses + extra goods
|
||||
2. **Tax**: Applied to the subtotal (including expenses)
|
||||
3. **Total**: Subtotal + Tax
|
||||
|
||||
Example:
|
||||
```
|
||||
Items: $1,000.00
|
||||
Expenses: $ 165.00 (includes $15 expense tax)
|
||||
Goods: $ 500.00
|
||||
----------------------------
|
||||
Subtotal: $1,665.00
|
||||
Tax (10%): $ 166.50
|
||||
----------------------------
|
||||
Total: $1,831.50
|
||||
```
|
||||
|
||||
### 5. Unlinking Expenses
|
||||
|
||||
To remove an expense from an invoice:
|
||||
|
||||
1. Open the invoice in edit mode
|
||||
2. Find the expense in the Expenses section
|
||||
3. Click the "Unlink" button (shows an unlink icon)
|
||||
4. Confirm the action
|
||||
5. Save the invoice
|
||||
|
||||
**Note**: Unlinking an expense does NOT delete it; it simply removes the association with the invoice. The expense will become available for other invoices again.
|
||||
|
||||
## Expense States
|
||||
|
||||
Expenses can be in the following states:
|
||||
|
||||
- **Pending**: Waiting for approval
|
||||
- **Approved**: Approved and ready for invoicing (if billable)
|
||||
- **Rejected**: Not approved
|
||||
- **Reimbursed**: Already paid back to the employee
|
||||
- **Invoiced**: Linked to a client invoice
|
||||
|
||||
Only **approved, billable, and uninvoiced** expenses can be added to invoices.
|
||||
|
||||
## PDF and CSV Exports
|
||||
|
||||
### PDF Export
|
||||
|
||||
Expenses are displayed in the invoice PDF with the following information:
|
||||
- Expense title
|
||||
- Description (if available)
|
||||
- Category
|
||||
- Vendor (if available)
|
||||
- Date
|
||||
- Total amount (including tax)
|
||||
|
||||
### CSV Export
|
||||
|
||||
Expenses are included in invoice CSV exports with:
|
||||
- Title with category in parentheses
|
||||
- Quantity: 1
|
||||
- Unit price: Total expense amount
|
||||
- Total: Total expense amount
|
||||
|
||||
## API Integration
|
||||
|
||||
If you're using the TimeTracker API, you can work with expenses through the following endpoints:
|
||||
|
||||
- `GET /api/expenses` - Get all expenses
|
||||
- `POST /api/expenses` - Create a new expense
|
||||
- `GET /api/expenses/{id}` - Get expense details
|
||||
- `PUT /api/expenses/{id}` - Update an expense
|
||||
- `DELETE /api/expenses/{id}` - Delete an expense
|
||||
- `POST /api/expenses/{id}/mark-as-invoiced` - Link expense to invoice
|
||||
- `POST /api/expenses/{id}/unmark-as-invoiced` - Unlink expense from invoice
|
||||
|
||||
## Database Schema
|
||||
|
||||
The expense-invoice relationship uses the following fields in the `expenses` table:
|
||||
|
||||
```sql
|
||||
invoiced BOOLEAN DEFAULT FALSE -- Whether the expense is linked to an invoice
|
||||
invoice_id INTEGER -- Foreign key to invoices table
|
||||
billable BOOLEAN DEFAULT FALSE -- Whether the expense can be invoiced
|
||||
```
|
||||
|
||||
The relationship is defined in the models as:
|
||||
- `Invoice.expenses` - Dynamic relationship to get all expenses for an invoice
|
||||
- `Expense.invoice` - Relationship to get the invoice for an expense
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Mark expenses as billable**: Only expenses marked as billable will appear in the invoice generation interface
|
||||
2. **Approve expenses first**: It's recommended to approve expenses before adding them to invoices
|
||||
3. **Include detailed descriptions**: Add vendor and description information to help clients understand the charges
|
||||
4. **Use appropriate categories**: Categorize expenses correctly for better reporting and clarity
|
||||
5. **Track receipts**: Upload receipts for expenses to maintain proper documentation
|
||||
6. **Review before finalizing**: Check the expense section in the live preview before sending invoices
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
**Q: I don't see any expenses when generating from time/costs**
|
||||
|
||||
A: Ensure that:
|
||||
- The expenses are marked as billable
|
||||
- The expenses are approved
|
||||
- The expenses are associated with the correct project
|
||||
- The expenses haven't already been invoiced
|
||||
|
||||
**Q: The invoice total doesn't include my expense**
|
||||
|
||||
A: Make sure you've saved the invoice after adding the expense. The totals are recalculated when you save.
|
||||
|
||||
**Q: Can I edit expense details from the invoice?**
|
||||
|
||||
A: No, expense details are read-only when viewing them on an invoice. You must edit the expense directly from the Expenses section.
|
||||
|
||||
**Q: What happens if I delete an invoice with expenses?**
|
||||
|
||||
A: The expenses are automatically unlinked and become available for other invoices. The expenses themselves are not deleted.
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
Potential future improvements to the expense feature:
|
||||
|
||||
- Bulk expense import
|
||||
- Expense approval workflow integration
|
||||
- Multi-currency expense support
|
||||
- Expense templates
|
||||
- Automated expense categorization
|
||||
- Integration with accounting systems
|
||||
|
||||
## Related Documentation
|
||||
|
||||
- [Expense Management Guide](EXPENSE_MANAGEMENT.md)
|
||||
- [Invoice Creation Guide](INVOICE_CREATION.md)
|
||||
- [PDF Customization](PDF_LAYOUT_CUSTOMIZATION.md)
|
||||
- [API Documentation](API_DOCUMENTATION.md)
|
||||
|
||||
@@ -0,0 +1,278 @@
|
||||
# Enhanced PDF Invoice Editor with Konva.js
|
||||
|
||||
## Overview
|
||||
|
||||
The PDF Invoice Editor has been significantly enhanced to use Konva.js, providing a powerful drag-and-drop interface for designing custom invoice layouts. Users can now add, position, and customize any element with an intuitive visual editor.
|
||||
|
||||
## New Features
|
||||
|
||||
### 1. Expanded Element Library
|
||||
|
||||
The editor now includes a comprehensive set of draggable elements organized into categories:
|
||||
|
||||
#### Basic Elements
|
||||
- **Text**: Generic text field for custom content
|
||||
- **Heading**: Large, bold text for titles
|
||||
- **Line**: Horizontal divider line
|
||||
- **Rectangle**: Customizable rectangular shape
|
||||
- **Circle**: Customizable circular shape
|
||||
|
||||
#### Company Information Elements
|
||||
- **Company Logo**: Displays uploaded company logo
|
||||
- **Company Name**: Formatted company name
|
||||
- **Company Details**: Combined address, email, and phone
|
||||
- **Company Address**: Dedicated address field
|
||||
- **Company Email**: Email address with label
|
||||
- **Company Phone**: Phone number with label
|
||||
- **Company Website**: Website URL
|
||||
- **Company Tax ID**: Tax identification number
|
||||
|
||||
#### Invoice Data Elements
|
||||
- **Invoice Number**: Auto-formatted invoice number
|
||||
- **Invoice Date**: Issue date
|
||||
- **Due Date**: Payment due date
|
||||
- **Invoice Status**: Current status (draft, sent, paid, etc.)
|
||||
- **Client Info**: Combined client information block
|
||||
- **Client Name**: Client name only
|
||||
- **Client Address**: Client address only
|
||||
- **Items Table**: Dynamic table of invoice items
|
||||
- **Subtotal**: Pre-tax amount
|
||||
- **Tax**: Tax amount with rate
|
||||
- **Total Amount**: Final total
|
||||
- **Notes**: Invoice notes field
|
||||
- **Terms**: Payment terms
|
||||
|
||||
#### Advanced Elements
|
||||
- **QR Code**: QR code placeholder (for invoice number/link)
|
||||
- **Barcode**: Barcode placeholder
|
||||
- **Page Number**: Page numbering
|
||||
- **Current Date**: Auto-updating current date
|
||||
- **Watermark**: Large, semi-transparent text overlay
|
||||
|
||||
### 2. Properties Panel
|
||||
|
||||
The right sidebar now features a comprehensive properties panel that displays editable properties for the selected element:
|
||||
|
||||
#### Text Element Properties
|
||||
- **Position X/Y**: Precise positioning
|
||||
- **Text Content**: Edit text inline
|
||||
- **Font Size**: Size in pixels
|
||||
- **Font Family**: Choose from 6 fonts (Arial, Times New Roman, Courier New, Georgia, Verdana, Helvetica)
|
||||
- **Font Style**: Normal, Bold, or Italic
|
||||
- **Text Color**: Color picker
|
||||
- **Width**: Text box width
|
||||
- **Opacity**: Transparency slider (0-100%)
|
||||
|
||||
#### Shape Element Properties (Rectangle/Circle)
|
||||
- **Position X/Y**: Precise positioning
|
||||
- **Fill Color**: Interior color
|
||||
- **Stroke Color**: Border color
|
||||
- **Stroke Width**: Border thickness
|
||||
- **Dimensions**: Width/Height for rectangles, Radius for circles
|
||||
|
||||
#### Line Element Properties
|
||||
- **Stroke Color**: Line color
|
||||
- **Stroke Width**: Line thickness
|
||||
|
||||
#### All Elements
|
||||
- **Layer Order Controls**: Move up/down/top/bottom in z-index
|
||||
|
||||
### 3. Canvas Toolbar
|
||||
|
||||
Enhanced toolbar with powerful editing tools:
|
||||
|
||||
- **Zoom In/Out**: Scale the canvas view
|
||||
- **Delete**: Remove selected element
|
||||
- **Align Left/Center/Right**: Horizontal alignment
|
||||
- **Align Top/Middle/Bottom**: Vertical alignment
|
||||
|
||||
### 4. Keyboard Shortcuts
|
||||
|
||||
For power users, the editor supports keyboard shortcuts:
|
||||
|
||||
- **Delete/Backspace**: Remove selected element
|
||||
- **Ctrl+C**: Copy selected element
|
||||
- **Ctrl+V**: Paste copied element (offset by 20px)
|
||||
- **Ctrl+D**: Duplicate selected element
|
||||
- **Arrow Keys**: Move element by 1px
|
||||
- **Shift+Arrow Keys**: Move element by 10px
|
||||
- **Click Background**: Deselect all
|
||||
|
||||
### 5. Visual Feedback
|
||||
|
||||
- **Transform Handles**: Resize and rotate elements with intuitive handles
|
||||
- **Real-time Updates**: See changes immediately on the canvas
|
||||
- **Selection Indicator**: Visual highlight of selected elements
|
||||
- **Snap to Pixel**: Automatic pixel-perfect positioning
|
||||
|
||||
### 6. Advanced Canvas Features
|
||||
|
||||
#### Layer Management
|
||||
- Move elements forward/backward in z-index
|
||||
- Bring to front/send to back
|
||||
- Visual layer indicators in properties panel
|
||||
|
||||
#### Alignment Tools
|
||||
- Align to left/center/right edge
|
||||
- Align to top/middle/bottom
|
||||
- Center elements on canvas
|
||||
|
||||
#### Copy/Paste/Duplicate
|
||||
- Copy elements to clipboard
|
||||
- Paste with automatic offset
|
||||
- Duplicate with keyboard shortcut
|
||||
|
||||
## Technical Implementation
|
||||
|
||||
### Architecture
|
||||
|
||||
The enhanced editor uses:
|
||||
- **Konva.js 9.x**: Canvas-based rendering engine
|
||||
- **HTML5 Canvas**: High-performance graphics
|
||||
- **Dynamic Properties Panel**: React-like property binding
|
||||
- **JSON State Management**: Serialize/deserialize designs
|
||||
|
||||
### Code Generation
|
||||
|
||||
The editor generates clean HTML and CSS:
|
||||
|
||||
```html
|
||||
<!-- Text elements become divs -->
|
||||
<div class="element text-element" style="position:absolute;left:50px;top:30px;...">
|
||||
Invoice Text
|
||||
</div>
|
||||
|
||||
<!-- Shapes become styled divs -->
|
||||
<div class="rectangle-element" style="position:absolute;..."></div>
|
||||
|
||||
<!-- Images use Jinja2 templates -->
|
||||
<img src="{{ get_logo_base64(settings.get_logo_path()) }}" style="..." alt="Logo">
|
||||
```
|
||||
|
||||
### State Persistence
|
||||
|
||||
Designs are saved as:
|
||||
1. **JSON**: Complete Konva.js stage state (for editing)
|
||||
2. **HTML**: Generated template markup
|
||||
3. **CSS**: Corresponding styles
|
||||
|
||||
## Usage Guide
|
||||
|
||||
### Creating a New Layout
|
||||
|
||||
1. Navigate to **Admin → PDF Layout Designer**
|
||||
2. Click elements from the left sidebar to add them to the canvas
|
||||
3. Click an element to select it
|
||||
4. Use the properties panel (right) to customize:
|
||||
- Position, size, colors
|
||||
- Text content and fonts
|
||||
- Layer order
|
||||
5. Use toolbar buttons for alignment and zoom
|
||||
6. Click **Generate Preview** to see the rendered result
|
||||
7. Click **Save Design** to persist changes
|
||||
|
||||
### Editing Existing Layouts
|
||||
|
||||
1. Existing elements are loaded automatically from saved JSON
|
||||
2. Click any element to edit its properties
|
||||
3. Use keyboard shortcuts for faster editing
|
||||
4. Preview changes before saving
|
||||
|
||||
### Best Practices
|
||||
|
||||
1. **Start with Structure**: Add heading, company info, and major sections first
|
||||
2. **Use Alignment Tools**: Keep elements properly aligned
|
||||
3. **Test with Real Data**: Use "Generate Preview" to see actual invoice data
|
||||
4. **Layer Management**: Keep important elements on top
|
||||
5. **Save Frequently**: Use "Save Design" to preserve work
|
||||
|
||||
## API Integration
|
||||
|
||||
### Backend Routes
|
||||
|
||||
The editor integrates with existing routes:
|
||||
|
||||
- `GET /admin/pdf-layout`: Load editor with current design
|
||||
- `POST /admin/pdf-layout`: Save design (HTML, CSS, JSON)
|
||||
- `POST /admin/pdf-layout/preview`: Generate live preview
|
||||
- `POST /admin/pdf-layout/reset`: Reset to defaults
|
||||
|
||||
### Data Flow
|
||||
|
||||
```
|
||||
User Action → Konva.js Canvas → generateCode() → HTML/CSS → Backend → Database
|
||||
↓
|
||||
Preview Generation
|
||||
```
|
||||
|
||||
## Extensibility
|
||||
|
||||
### Adding New Element Types
|
||||
|
||||
To add a new element type:
|
||||
|
||||
1. Add to sidebar HTML:
|
||||
```html
|
||||
<div class="element-item" data-type="new-element">
|
||||
<i class="fas fa-icon"></i>
|
||||
<span>New Element</span>
|
||||
</div>
|
||||
```
|
||||
|
||||
2. Add to templates object:
|
||||
```javascript
|
||||
'new-element': { text: 'Default Text', fontSize: 14, ... }
|
||||
```
|
||||
|
||||
3. Handle in `addElement()` if special rendering needed
|
||||
|
||||
4. Update `generateCode()` for HTML output
|
||||
|
||||
### Custom Properties
|
||||
|
||||
Add custom properties by:
|
||||
|
||||
1. Extending `updatePropertiesPanel()`
|
||||
2. Adding input fields for new properties
|
||||
3. Attaching listeners in `attachPropertyListeners()`
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Element Not Appearing
|
||||
- Check console for errors
|
||||
- Verify element type in templates object
|
||||
- Ensure layer.draw() is called
|
||||
|
||||
### Properties Not Updating
|
||||
- Verify event listeners are attached
|
||||
- Check selectedElement is not null
|
||||
- Ensure layer.draw() after changes
|
||||
|
||||
### Preview Not Generating
|
||||
- Check network tab for API errors
|
||||
- Verify HTML/CSS generation
|
||||
- Check backend template rendering
|
||||
|
||||
## Performance Considerations
|
||||
|
||||
- Canvas is rendered at 595x842px (A4 size at 72dpi)
|
||||
- Large designs with many elements remain performant
|
||||
- Transformer handles are optimized for smooth interaction
|
||||
- Properties panel updates use debouncing
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
Potential additions:
|
||||
- Image upload for custom backgrounds
|
||||
- Grid/snap to grid functionality
|
||||
- Undo/redo history
|
||||
- Templates/presets library
|
||||
- Multi-page support
|
||||
- Export to multiple formats
|
||||
|
||||
## Related Documentation
|
||||
|
||||
- [PDF Layout Customization Guide](./PDF_LAYOUT_CUSTOMIZATION.md)
|
||||
- [Invoice System Overview](./ENHANCED_INVOICE_SYSTEM_README.md)
|
||||
- [Admin Settings Guide](./SETTINGS.md)
|
||||
|
||||
@@ -0,0 +1,288 @@
|
||||
# PDF Invoice Editor - Quick Start Guide
|
||||
|
||||
## Getting Started in 5 Minutes
|
||||
|
||||
This guide will help you create your first custom invoice layout using the enhanced Konva.js-based PDF editor.
|
||||
|
||||
## Step 1: Access the Editor
|
||||
|
||||
1. Log in as an admin user
|
||||
2. Navigate to **Admin Panel** → **PDF Layout Designer**
|
||||
3. You'll see three main sections:
|
||||
- **Left**: Element library
|
||||
- **Center**: Canvas workspace
|
||||
- **Right**: Properties panel
|
||||
|
||||
## Step 2: Understanding the Interface
|
||||
|
||||
### Element Library (Left Sidebar)
|
||||
|
||||
Elements are organized into groups:
|
||||
- **Basic Elements**: Text, headings, shapes
|
||||
- **Company Info**: Logo, name, address, contact details
|
||||
- **Invoice Data**: Numbers, dates, client info, totals
|
||||
- **Advanced**: QR codes, watermarks, page numbers
|
||||
|
||||
### Canvas (Center)
|
||||
|
||||
- The white canvas represents your invoice page (A4 size)
|
||||
- Elements can be clicked, dragged, and resized
|
||||
- Use toolbar buttons for zoom and alignment
|
||||
|
||||
### Properties Panel (Right)
|
||||
|
||||
- Shows properties of selected element
|
||||
- Edit text, colors, fonts, positions
|
||||
- Control layer order (z-index)
|
||||
|
||||
## Step 3: Add Your First Element
|
||||
|
||||
1. Click **"Heading"** from Basic Elements
|
||||
2. The element appears on the canvas
|
||||
3. Click it to select (you'll see resize handles)
|
||||
4. In the properties panel (right):
|
||||
- Change text to "INVOICE"
|
||||
- Set font size to 32
|
||||
- Choose a color
|
||||
|
||||
## Step 4: Build Your Layout
|
||||
|
||||
### Add Company Header
|
||||
|
||||
1. Click **"Company Logo"** (if you've uploaded one)
|
||||
2. Position it in the top-left (drag or use X/Y properties)
|
||||
3. Click **"Company Name"** and position below logo
|
||||
4. Add **"Company Details"** for contact info
|
||||
|
||||
### Add Invoice Details
|
||||
|
||||
1. Click **"Invoice Number"** - place top-right
|
||||
2. Click **"Invoice Date"** - place below number
|
||||
3. Click **"Due Date"** - place below date
|
||||
|
||||
### Add Client Information
|
||||
|
||||
1. Click **"Client Info"** - place left side, below company info
|
||||
2. Adjust position to your liking
|
||||
|
||||
### Add Items Table
|
||||
|
||||
1. Click **"Items Table"**
|
||||
2. Position in the middle of the page
|
||||
3. Resize if needed using handles
|
||||
|
||||
### Add Totals
|
||||
|
||||
1. Click **"Subtotal"** - place below items table
|
||||
2. Click **"Tax"** - place below subtotal
|
||||
3. Click **"Total Amount"** - place below tax
|
||||
|
||||
## Step 5: Customize with Shapes
|
||||
|
||||
### Add a Header Background
|
||||
|
||||
1. Click **"Rectangle"** from Basic Elements
|
||||
2. Position at the very top
|
||||
3. In properties:
|
||||
- Set Fill Color to a light color (e.g., #f3f4f6)
|
||||
- Set Stroke Width to 0
|
||||
- Adjust width to full page (595px)
|
||||
- Set height to 100px
|
||||
4. Click the down arrow in Layer Order to send behind text
|
||||
|
||||
### Add Divider Lines
|
||||
|
||||
1. Click **"Line"** from Basic Elements
|
||||
2. Position where you want a separator
|
||||
3. Adjust stroke width and color in properties
|
||||
4. Resize by dragging endpoints
|
||||
|
||||
## Step 6: Use Keyboard Shortcuts
|
||||
|
||||
Speed up your workflow:
|
||||
|
||||
- **Arrow Keys**: Move selected element (1px)
|
||||
- **Shift+Arrows**: Move selected element (10px)
|
||||
- **Ctrl+D**: Duplicate selected element
|
||||
- **Delete**: Remove selected element
|
||||
|
||||
## Step 7: Align Elements
|
||||
|
||||
1. Select an element
|
||||
2. Use toolbar alignment buttons:
|
||||
- Left/Center/Right for horizontal
|
||||
- Top/Middle/Bottom for vertical
|
||||
|
||||
## Step 8: Preview Your Design
|
||||
|
||||
1. Click **"Generate Preview"** button (top)
|
||||
2. Preview appears in the right panel (below properties)
|
||||
3. Review how it looks with actual data
|
||||
4. Make adjustments as needed
|
||||
|
||||
## Step 9: Save Your Design
|
||||
|
||||
1. Click **"Save Design"** button (top)
|
||||
2. Your layout is saved and will be used for all invoices
|
||||
3. You can come back anytime to edit
|
||||
|
||||
## Step 10: Test with Real Invoice
|
||||
|
||||
1. Go to **Invoices** → Create a new invoice
|
||||
2. Fill in details and add items
|
||||
3. Click **"Preview"** or **"Generate PDF"**
|
||||
4. See your custom layout in action!
|
||||
|
||||
## Common Tasks
|
||||
|
||||
### Changing Text Content
|
||||
|
||||
1. Select text element
|
||||
2. In properties panel, find "Text Content"
|
||||
3. Edit the text directly
|
||||
4. Note: Keep Jinja2 variables (e.g., `{{ invoice.invoice_number }}`)
|
||||
|
||||
### Changing Colors
|
||||
|
||||
1. Select element
|
||||
2. Find color picker in properties
|
||||
3. Click to open color selector
|
||||
4. Choose your color
|
||||
|
||||
### Resizing Elements
|
||||
|
||||
**Method 1**: Visual
|
||||
- Click element to select
|
||||
- Drag corner handles
|
||||
|
||||
**Method 2**: Precise
|
||||
- Select element
|
||||
- Use Width/Height fields in properties
|
||||
|
||||
### Moving Elements Precisely
|
||||
|
||||
1. Select element
|
||||
2. In properties panel:
|
||||
- Set exact X position (horizontal)
|
||||
- Set exact Y position (vertical)
|
||||
|
||||
### Creating a Watermark
|
||||
|
||||
1. Click **"Watermark"** from Advanced
|
||||
2. Position in center of page
|
||||
3. In properties:
|
||||
- Set large font size (60-80)
|
||||
- Set opacity to 0.1-0.2
|
||||
- Choose light gray color
|
||||
4. Send to back using Layer Order buttons
|
||||
|
||||
### Duplicating Sections
|
||||
|
||||
1. Select element (e.g., a line)
|
||||
2. Press **Ctrl+D** to duplicate
|
||||
3. Move to new position
|
||||
4. Repeat as needed
|
||||
|
||||
## Tips & Tricks
|
||||
|
||||
### Tip 1: Use Alignment Tools
|
||||
- Select multiple elements
|
||||
- Use alignment buttons to line them up perfectly
|
||||
|
||||
### Tip 2: Work in Layers
|
||||
- Background elements (shapes, watermarks) go to back
|
||||
- Text and important info stay on top
|
||||
- Use Layer Order buttons frequently
|
||||
|
||||
### Tip 3: Keep It Simple
|
||||
- Don't overcrowd the layout
|
||||
- Use whitespace effectively
|
||||
- Test with real data before finalizing
|
||||
|
||||
### Tip 4: Font Consistency
|
||||
- Stick to 2-3 fonts maximum
|
||||
- Use font sizes consistently:
|
||||
- Heading: 24-32px
|
||||
- Subheading: 16-20px
|
||||
- Body: 12-14px
|
||||
- Fine print: 10-11px
|
||||
|
||||
### Tip 5: Color Harmony
|
||||
- Use your brand colors
|
||||
- Keep contrast high for readability
|
||||
- Avoid too many colors (3-4 max)
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Element Won't Move
|
||||
- Make sure it's selected (should see handles)
|
||||
- Try clicking it again
|
||||
- Use X/Y properties for precise positioning
|
||||
|
||||
### Can't See Element
|
||||
- Check if it's hidden behind another element
|
||||
- Use Layer Order to bring to front
|
||||
- Check if opacity is too low
|
||||
|
||||
### Text is Cut Off
|
||||
- Increase width in properties
|
||||
- Reduce font size
|
||||
- Enable text wrapping
|
||||
|
||||
### Preview Shows Wrong Data
|
||||
- Preview uses last invoice in database
|
||||
- Create a test invoice with realistic data
|
||||
- Generate preview again
|
||||
|
||||
## Keyboard Shortcuts Reference
|
||||
|
||||
| Shortcut | Action |
|
||||
|----------|--------|
|
||||
| Delete/Backspace | Remove selected element |
|
||||
| Ctrl+C | Copy element |
|
||||
| Ctrl+V | Paste element |
|
||||
| Ctrl+D | Duplicate element |
|
||||
| Arrow Keys | Move 1px |
|
||||
| Shift+Arrows | Move 10px |
|
||||
| Click Background | Deselect |
|
||||
|
||||
## Next Steps
|
||||
|
||||
Once comfortable with the basics:
|
||||
|
||||
1. Explore all element types
|
||||
2. Create multiple layout variations
|
||||
3. Use shapes for creative designs
|
||||
4. Add QR codes for payment links
|
||||
5. Experiment with opacity and layers
|
||||
|
||||
## Getting Help
|
||||
|
||||
- See [Full Feature Documentation](./PDF_EDITOR_ENHANCED_FEATURES.md)
|
||||
- Check [PDF Layout Customization](./PDF_LAYOUT_CUSTOMIZATION.md)
|
||||
- Review [Invoice System Guide](./ENHANCED_INVOICE_SYSTEM_README.md)
|
||||
|
||||
## Example Layout Ideas
|
||||
|
||||
### Minimal Layout
|
||||
- Simple text elements only
|
||||
- Clean lines
|
||||
- Lots of whitespace
|
||||
|
||||
### Professional Layout
|
||||
- Company logo in header
|
||||
- Colored header background
|
||||
- Clear sections with dividers
|
||||
|
||||
### Creative Layout
|
||||
- Circular logo frame
|
||||
- Angled divider lines
|
||||
- Watermark in background
|
||||
|
||||
### Modern Layout
|
||||
- Bold typography
|
||||
- Minimal colors
|
||||
- QR code for payments
|
||||
|
||||
Happy designing! 🎨
|
||||
|
||||
@@ -0,0 +1,616 @@
|
||||
# PDF Layout Customization Guide
|
||||
|
||||
## Overview
|
||||
|
||||
TimeTracker provides a powerful system-wide PDF layout editor that allows administrators to customize the appearance of invoice PDFs. This feature enables you to:
|
||||
|
||||
- Customize the HTML structure of invoice PDFs
|
||||
- Apply custom CSS styling
|
||||
- Use Jinja2 template variables to display dynamic data
|
||||
- Preview changes in real-time
|
||||
- Save and reuse custom templates across all invoices
|
||||
|
||||
## Accessing the PDF Layout Editor
|
||||
|
||||
### Admin Access Required
|
||||
|
||||
To access the PDF layout editor:
|
||||
|
||||
1. Log in as an administrator
|
||||
2. Navigate to **Admin** → **PDF Layout** in the sidebar
|
||||
3. The PDF Layout Editor page will open
|
||||
|
||||
**URL:** `/admin/pdf-layout`
|
||||
|
||||
**Required Permission:** `manage_settings` or admin role
|
||||
|
||||
## Using the PDF Layout Editor
|
||||
|
||||
### Interface Overview
|
||||
|
||||
The PDF Layout Editor is powered by **Konva.js** and consists of three main sections:
|
||||
|
||||
1. **Element Library (Left Sidebar)**: Drag-and-drop elements organized by category
|
||||
- Basic Elements (text, shapes, lines)
|
||||
- Company Information (logo, name, address, contact details)
|
||||
- Invoice Data (numbers, dates, client info, totals)
|
||||
- Advanced Elements (QR codes, watermarks, page numbers)
|
||||
|
||||
2. **Canvas Workspace (Center)**: Visual canvas representing your invoice page (A4 size)
|
||||
- Click elements from sidebar to add to canvas
|
||||
- Drag elements to reposition
|
||||
- Resize using transform handles
|
||||
- Toolbar with zoom, delete, and alignment tools
|
||||
|
||||
3. **Properties Panel (Right Sidebar)**: Edit properties of selected element
|
||||
- Position (X/Y coordinates)
|
||||
- Text content and styling (font, size, color)
|
||||
- Shape properties (fill, stroke, dimensions)
|
||||
- Layer order controls (z-index)
|
||||
- Live preview of generated PDF
|
||||
|
||||
### Editing Workflow
|
||||
|
||||
1. **Add Elements**: Click elements from left sidebar to add to canvas
|
||||
2. **Position**: Drag elements to desired locations or use X/Y properties
|
||||
3. **Customize**: Select elements and edit properties in right panel
|
||||
4. **Align**: Use toolbar alignment tools for precise positioning
|
||||
5. **Layer**: Manage z-index with layer order controls
|
||||
6. **Preview**: Click "Generate Preview" to see rendered result
|
||||
7. **Save**: Click "Save Design" to apply system-wide
|
||||
8. **Reset**: If needed, click "Reset" to restore defaults
|
||||
|
||||
### Quick Start
|
||||
|
||||
For a beginner-friendly guide, see [PDF Editor Quick Start](./PDF_EDITOR_QUICK_START.md)
|
||||
|
||||
For comprehensive feature documentation, see [Enhanced PDF Editor Features](./PDF_EDITOR_ENHANCED_FEATURES.md)
|
||||
|
||||
## Available Template Variables
|
||||
|
||||
### Invoice Variables
|
||||
|
||||
```jinja
|
||||
{{ invoice.invoice_number }} # Invoice number (e.g., "INV-2024-001")
|
||||
{{ invoice.issue_date }} # Issue date
|
||||
{{ invoice.due_date }} # Due date
|
||||
{{ invoice.status }} # Status (draft, sent, paid, etc.)
|
||||
{{ invoice.client_name }} # Client name
|
||||
{{ invoice.client_email }} # Client email
|
||||
{{ invoice.client_address }} # Client address
|
||||
{{ invoice.subtotal }} # Subtotal amount
|
||||
{{ invoice.tax_rate }} # Tax rate percentage
|
||||
{{ invoice.tax_amount }} # Tax amount
|
||||
{{ invoice.total_amount }} # Total amount
|
||||
{{ invoice.notes }} # Invoice notes
|
||||
{{ invoice.terms }} # Invoice terms
|
||||
```
|
||||
|
||||
### Project Variables
|
||||
|
||||
```jinja
|
||||
{{ invoice.project.name }} # Project name
|
||||
{{ invoice.project.description }} # Project description
|
||||
```
|
||||
|
||||
### Invoice Items Loop
|
||||
|
||||
```jinja
|
||||
{% for item in invoice.items %}
|
||||
{{ item.description }} # Item description
|
||||
{{ item.quantity }} # Quantity (hours or units)
|
||||
{{ item.unit_price }} # Unit price
|
||||
{{ item.total_amount }} # Line total
|
||||
{{ item.time_entry_ids }} # Associated time entry IDs
|
||||
{% endfor %}
|
||||
```
|
||||
|
||||
### Extra Goods Loop
|
||||
|
||||
```jinja
|
||||
{% for good in invoice.extra_goods %}
|
||||
{{ good.name }} # Good/product name
|
||||
{{ good.description }} # Description
|
||||
{{ good.sku }} # SKU code
|
||||
{{ good.category }} # Category
|
||||
{{ good.quantity }} # Quantity
|
||||
{{ good.unit_price }} # Unit price
|
||||
{{ good.total_amount }} # Line total
|
||||
{% endfor %}
|
||||
```
|
||||
|
||||
### Settings Variables
|
||||
|
||||
```jinja
|
||||
{{ settings.company_name }} # Your company name
|
||||
{{ settings.company_address }} # Your company address
|
||||
{{ settings.company_email }} # Your company email
|
||||
{{ settings.company_phone }} # Your company phone
|
||||
{{ settings.company_website }} # Your company website
|
||||
{{ settings.company_tax_id }} # Your tax ID
|
||||
{{ settings.company_bank_info }} # Bank information
|
||||
{{ settings.currency }} # Currency code (e.g., "USD")
|
||||
{{ settings.invoice_terms }} # Default invoice terms
|
||||
```
|
||||
|
||||
### Helper Functions
|
||||
|
||||
```jinja
|
||||
{{ format_date(invoice.issue_date) }} # Format date using Babel
|
||||
{{ format_money(invoice.total_amount) }} # Format money with currency
|
||||
{{ get_logo_base64(logo_path) }} # Get logo as base64 data URI
|
||||
{{ _('Label') }} # Translate text (i18n)
|
||||
```
|
||||
|
||||
### Conditional Rendering
|
||||
|
||||
```jinja
|
||||
{% if settings.has_logo() %}
|
||||
<img src="{{ get_logo_base64(settings.get_logo_path()) }}" alt="Company Logo">
|
||||
{% endif %}
|
||||
|
||||
{% if invoice.tax_rate > 0 %}
|
||||
<tr>
|
||||
<td>Tax ({{ invoice.tax_rate }}%):</td>
|
||||
<td>{{ format_money(invoice.tax_amount) }}</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
|
||||
{% if invoice.notes %}
|
||||
<div class="notes">{{ invoice.notes }}</div>
|
||||
{% endif %}
|
||||
```
|
||||
|
||||
## Example Templates
|
||||
|
||||
### Basic Invoice Template
|
||||
|
||||
```html
|
||||
<div class="wrapper">
|
||||
<div class="invoice-header">
|
||||
<h1 class="company-name">{{ settings.company_name }}</h1>
|
||||
<div class="invoice-title">INVOICE</div>
|
||||
</div>
|
||||
|
||||
<div class="meta">
|
||||
<p><strong>Invoice #:</strong> {{ invoice.invoice_number }}</p>
|
||||
<p><strong>Date:</strong> {{ format_date(invoice.issue_date) }}</p>
|
||||
<p><strong>Due:</strong> {{ format_date(invoice.due_date) }}</p>
|
||||
</div>
|
||||
|
||||
<div class="client-info">
|
||||
<h3>Bill To:</h3>
|
||||
<p><strong>{{ invoice.client_name }}</strong></p>
|
||||
{% if invoice.client_email %}
|
||||
<p>{{ invoice.client_email }}</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<table>
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Description</th>
|
||||
<th>Quantity</th>
|
||||
<th>Price</th>
|
||||
<th>Total</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for item in invoice.items %}
|
||||
<tr>
|
||||
<td>{{ item.description }}</td>
|
||||
<td>{{ "%.2f"|format(item.quantity) }}</td>
|
||||
<td>{{ format_money(item.unit_price) }}</td>
|
||||
<td>{{ format_money(item.total_amount) }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr>
|
||||
<td colspan="3">Subtotal:</td>
|
||||
<td>{{ format_money(invoice.subtotal) }}</td>
|
||||
</tr>
|
||||
{% if invoice.tax_rate > 0 %}
|
||||
<tr>
|
||||
<td colspan="3">Tax ({{ invoice.tax_rate }}%):</td>
|
||||
<td>{{ format_money(invoice.tax_amount) }}</td>
|
||||
</tr>
|
||||
{% endif %}
|
||||
<tr>
|
||||
<td colspan="3"><strong>Total:</strong></td>
|
||||
<td><strong>{{ format_money(invoice.total_amount) }}</strong></td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
|
||||
<div class="footer">
|
||||
<p><strong>{{ _('Terms & Conditions:') }}</strong> {{ settings.invoice_terms }}</p>
|
||||
</div>
|
||||
</div>
|
||||
```
|
||||
|
||||
### Basic CSS Template
|
||||
|
||||
```css
|
||||
@page {
|
||||
size: A4;
|
||||
margin: 2cm;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: Arial, sans-serif;
|
||||
font-size: 12pt;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.wrapper {
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
.invoice-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
border-bottom: 2px solid #007bff;
|
||||
padding-bottom: 15px;
|
||||
margin-bottom: 20px;
|
||||
}
|
||||
|
||||
.company-name {
|
||||
font-size: 24pt;
|
||||
color: #007bff;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.invoice-title {
|
||||
font-size: 28pt;
|
||||
font-weight: bold;
|
||||
color: #007bff;
|
||||
}
|
||||
|
||||
table {
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
margin: 20px 0;
|
||||
}
|
||||
|
||||
th, td {
|
||||
border: 1px solid #ddd;
|
||||
padding: 10px;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
th {
|
||||
background-color: #f8f9fa;
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
tfoot td {
|
||||
font-weight: bold;
|
||||
}
|
||||
|
||||
.footer {
|
||||
margin-top: 30px;
|
||||
padding-top: 15px;
|
||||
border-top: 1px solid #ddd;
|
||||
}
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
### 1. Test Your Templates
|
||||
|
||||
Always preview your templates with real invoice data before saving:
|
||||
- Create a test invoice with various items
|
||||
- Use the preview function to check rendering
|
||||
- Test with and without optional fields (logo, notes, etc.)
|
||||
|
||||
### 2. Keep It Simple
|
||||
|
||||
- Start with the default template and modify incrementally
|
||||
- Avoid overly complex layouts that may not render properly in PDF
|
||||
- Test with different amounts of data (few items vs. many items)
|
||||
|
||||
### 3. Use CSS for Styling
|
||||
|
||||
- Keep HTML semantic and clean
|
||||
- Apply all styling through CSS
|
||||
- Use CSS variables for easy color/font customization
|
||||
|
||||
### 4. Handle Missing Data Gracefully
|
||||
|
||||
```jinja
|
||||
{% if invoice.client_email %}
|
||||
<p>Email: {{ invoice.client_email }}</p>
|
||||
{% endif %}
|
||||
```
|
||||
|
||||
### 5. Maintain Consistent Branding
|
||||
|
||||
- Use company colors from your settings
|
||||
- Include your logo using the `get_logo_base64()` helper
|
||||
- Match font styles to your company branding
|
||||
|
||||
### 6. Consider Print Layout
|
||||
|
||||
```css
|
||||
@page {
|
||||
size: A4;
|
||||
margin: 2cm;
|
||||
@bottom-center {
|
||||
content: "Page " counter(page) " of " counter(pages);
|
||||
}
|
||||
}
|
||||
|
||||
/* Avoid page breaks inside elements */
|
||||
tr, td, th {
|
||||
page-break-inside: avoid;
|
||||
}
|
||||
```
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Template Not Rendering
|
||||
|
||||
**Issue:** Template shows blank or errors in preview
|
||||
|
||||
**Solutions:**
|
||||
- Check Jinja2 syntax for typos
|
||||
- Ensure all `{% %}` blocks are properly closed
|
||||
- Verify variable names match documentation
|
||||
- Check browser console for JavaScript errors
|
||||
|
||||
### Variables Not Displaying
|
||||
|
||||
**Issue:** Variables show as `{{ variable_name }}` instead of actual values
|
||||
|
||||
**Solutions:**
|
||||
- Ensure you're using correct variable names
|
||||
- Check if the data exists (use `{% if variable %}` checks)
|
||||
- Verify the variable is in scope for the template
|
||||
|
||||
### CSS Not Applied
|
||||
|
||||
**Issue:** Styling doesn't appear in preview or PDF
|
||||
|
||||
**Solutions:**
|
||||
- Verify CSS syntax is valid
|
||||
- Check for CSS selector specificity issues
|
||||
- Ensure CSS is saved in the CSS field, not HTML
|
||||
- Test CSS separately in preview
|
||||
|
||||
### Logo Not Displaying
|
||||
|
||||
**Issue:** Company logo doesn't appear in PDF
|
||||
|
||||
**Solutions:**
|
||||
- Verify logo is uploaded in Settings
|
||||
- Use `get_logo_base64()` helper function for reliable embedding
|
||||
- Check logo file format (PNG, JPG, GIF supported)
|
||||
- Ensure logo file size is reasonable (< 2MB)
|
||||
|
||||
### Rate Limiting Errors
|
||||
|
||||
**Issue:** Preview or save fails with "Too Many Requests"
|
||||
|
||||
**Solution:**
|
||||
- Wait a minute before trying again
|
||||
- Rate limits: 60 previews/minute, 30 saves/minute
|
||||
|
||||
## API Endpoints
|
||||
|
||||
### GET `/admin/pdf-layout`
|
||||
Display the PDF layout editor interface.
|
||||
|
||||
**Permissions:** Admin or `manage_settings`
|
||||
|
||||
### POST `/admin/pdf-layout`
|
||||
Save custom PDF template.
|
||||
|
||||
**Parameters:**
|
||||
- `invoice_pdf_template_html`: Custom HTML template
|
||||
- `invoice_pdf_template_css`: Custom CSS styles
|
||||
|
||||
**Permissions:** Admin or `manage_settings`
|
||||
|
||||
### GET `/admin/pdf-layout/default`
|
||||
Get default HTML and CSS templates.
|
||||
|
||||
**Response:** JSON with `html` and `css` keys
|
||||
|
||||
**Permissions:** Admin or `manage_settings`
|
||||
|
||||
### POST `/admin/pdf-layout/preview`
|
||||
Generate preview of custom template.
|
||||
|
||||
**Parameters:**
|
||||
- `html`: HTML template to preview
|
||||
- `css`: CSS styles to apply
|
||||
- `invoice_id` (optional): Specific invoice to preview
|
||||
|
||||
**Response:** Rendered HTML preview
|
||||
|
||||
**Permissions:** Admin or `manage_settings`
|
||||
|
||||
### POST `/admin/pdf-layout/reset`
|
||||
Reset templates to defaults (clear custom templates).
|
||||
|
||||
**Permissions:** Admin or `manage_settings`
|
||||
|
||||
## Technical Details
|
||||
|
||||
### Template Rendering
|
||||
|
||||
1. **Priority**: Custom templates take precedence over defaults
|
||||
2. **Engine**: Jinja2 template engine with Flask context
|
||||
3. **PDF Generation**: WeasyPrint (fallback to ReportLab if unavailable)
|
||||
4. **Storage**: Templates stored in Settings table in database
|
||||
|
||||
### Security Considerations
|
||||
|
||||
- All templates are sanitized before rendering
|
||||
- CSRF protection on all POST endpoints
|
||||
- Rate limiting prevents abuse
|
||||
- Only admin users can modify templates
|
||||
- Templates are executed server-side in controlled environment
|
||||
|
||||
### Performance
|
||||
|
||||
- Templates are cached per invoice generation
|
||||
- Preview uses same rendering engine as PDF generation
|
||||
- Large templates may take longer to render
|
||||
- Optimize images and avoid external resources
|
||||
|
||||
## Internationalization (i18n)
|
||||
|
||||
Use the `_()` function to translate text:
|
||||
|
||||
```jinja
|
||||
<th>{{ _('Description') }}</th>
|
||||
<th>{{ _('Quantity') }}</th>
|
||||
<th>{{ _('Price') }}</th>
|
||||
```
|
||||
|
||||
Supported languages:
|
||||
- English (en)
|
||||
- German (de)
|
||||
- French (fr)
|
||||
- Italian (it)
|
||||
- Dutch (nl)
|
||||
- Finnish (fi)
|
||||
|
||||
## Advanced Features
|
||||
|
||||
### Page Numbers
|
||||
|
||||
```css
|
||||
@page {
|
||||
@bottom-center {
|
||||
content: "Page " counter(page) " of " counter(pages);
|
||||
font-size: 10pt;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Conditional Styling
|
||||
|
||||
```jinja
|
||||
<div class="status-{{ invoice.status }}">
|
||||
Status: {{ invoice.status|title }}
|
||||
</div>
|
||||
```
|
||||
|
||||
```css
|
||||
.status-paid { color: green; }
|
||||
.status-overdue { color: red; }
|
||||
.status-draft { color: gray; }
|
||||
```
|
||||
|
||||
### Custom Filters
|
||||
|
||||
```jinja
|
||||
{{ invoice.client_name|upper }}
|
||||
{{ invoice.total_amount|round(2) }}
|
||||
{{ invoice.issue_date|string }}
|
||||
```
|
||||
|
||||
## Migration from Old Templates
|
||||
|
||||
If you have existing invoice templates:
|
||||
|
||||
1. **Backup**: Export your current template code
|
||||
2. **Test**: Create test invoices to validate
|
||||
3. **Convert**: Adapt any custom logic to new format
|
||||
4. **Preview**: Use preview function extensively
|
||||
5. **Deploy**: Save and test with real invoices
|
||||
6. **Monitor**: Check generated PDFs for issues
|
||||
|
||||
## Support and Resources
|
||||
|
||||
- **Default Template**: View source at `app/templates/invoices/pdf_default.html`
|
||||
- **Default CSS**: View source at `templates/invoices/pdf_styles_default.css`
|
||||
- **Jinja2 Documentation**: https://jinja.palletsprojects.com/
|
||||
- **WeasyPrint Documentation**: https://weasyprint.org/
|
||||
- **CSS Print Styles**: https://www.smashingmagazine.com/2015/01/designing-for-print-with-css/
|
||||
|
||||
## Konva.js Visual Editor Features
|
||||
|
||||
### Keyboard Shortcuts
|
||||
|
||||
The visual editor supports these keyboard shortcuts:
|
||||
|
||||
- **Delete/Backspace**: Remove selected element
|
||||
- **Ctrl+C**: Copy selected element
|
||||
- **Ctrl+V**: Paste copied element (offset by 20px)
|
||||
- **Ctrl+D**: Duplicate selected element
|
||||
- **Arrow Keys**: Move element by 1px
|
||||
- **Shift+Arrow Keys**: Move element by 10px
|
||||
|
||||
### Element Types
|
||||
|
||||
#### Text Elements
|
||||
All text elements support:
|
||||
- Custom text content (with Jinja2 variables)
|
||||
- Font family (6 fonts available)
|
||||
- Font size (pixels)
|
||||
- Font style (normal, bold, italic)
|
||||
- Text color (color picker)
|
||||
- Width (for text wrapping)
|
||||
- Opacity (0-100%)
|
||||
|
||||
#### Shape Elements
|
||||
Rectangles and circles support:
|
||||
- Fill color (interior)
|
||||
- Stroke color (border)
|
||||
- Stroke width (border thickness)
|
||||
- Dimensions (width/height for rectangles, radius for circles)
|
||||
- Opacity
|
||||
|
||||
#### Special Elements
|
||||
- **Logo**: Displays uploaded company logo (if available)
|
||||
- **Items Table**: Dynamic table with headers and item rows
|
||||
- **QR Code**: Placeholder for QR code generation
|
||||
- **Barcode**: Placeholder for barcode generation
|
||||
- **Watermark**: Large, semi-transparent text overlay
|
||||
|
||||
### Alignment Tools
|
||||
|
||||
Use toolbar buttons to align selected elements:
|
||||
- **Align Left**: Move to left edge
|
||||
- **Center Horizontally**: Center on canvas
|
||||
- **Align Right**: Move to right edge
|
||||
- **Align Top**: Move to top edge
|
||||
- **Center Vertically**: Center vertically
|
||||
- **Align Bottom**: Move to bottom edge
|
||||
|
||||
### Layer Management
|
||||
|
||||
Control element stacking order:
|
||||
- **Move Up**: Bring forward one layer
|
||||
- **Move Down**: Send back one layer
|
||||
- **Bring to Top**: Bring to front
|
||||
- **Send to Bottom**: Send to back
|
||||
|
||||
## Changelog
|
||||
|
||||
### Version 2.0 (Current - Enhanced with Konva.js)
|
||||
- **New**: Konva.js-powered visual editor
|
||||
- **New**: Drag-and-drop element library with 30+ elements
|
||||
- **New**: Real-time properties panel
|
||||
- **New**: Shape elements (rectangles, circles)
|
||||
- **New**: Alignment tools
|
||||
- **New**: Layer management (z-index controls)
|
||||
- **New**: Keyboard shortcuts
|
||||
- **New**: Copy/paste/duplicate functionality
|
||||
- **New**: Transform handles for resizing
|
||||
- **New**: Live canvas editing with instant visual feedback
|
||||
- **Improved**: Enhanced preview integration
|
||||
- **Improved**: Better element positioning and sizing
|
||||
|
||||
### Version 1.0
|
||||
- Initial PDF layout customization system
|
||||
- GrapesJS visual editor (deprecated)
|
||||
- Real-time preview
|
||||
- System-wide template storage
|
||||
- Jinja2 template variables
|
||||
- Rate limiting and security features
|
||||
|
||||
@@ -0,0 +1,389 @@
|
||||
# Uploads Persistence in TimeTracker
|
||||
|
||||
## Overview
|
||||
|
||||
This document explains how TimeTracker handles persistent file uploads (company logos and user avatars) across container rebuilds and restarts.
|
||||
|
||||
## Problem Statement
|
||||
|
||||
Prior to this implementation, uploaded files (company logos and user avatars) were stored directly in the container's filesystem at `app/static/uploads/`. When containers were rebuilt or redeployed, these files were lost because they were not stored in a persistent volume.
|
||||
|
||||
## Solution
|
||||
|
||||
We've implemented Docker volume persistence for the uploads directory, ensuring that all uploaded files persist across:
|
||||
- Container rebuilds
|
||||
- Container restarts
|
||||
- Application updates
|
||||
- Docker Compose down/up cycles
|
||||
|
||||
## Technical Implementation
|
||||
|
||||
### Directory Structure
|
||||
|
||||
```
|
||||
app/static/uploads/
|
||||
├── logos/ # Company logo files
|
||||
│ ├── .gitkeep
|
||||
│ └── [uploaded logo files]
|
||||
└── avatars/ # User avatar files
|
||||
├── .gitkeep
|
||||
└── [uploaded avatar files]
|
||||
```
|
||||
|
||||
### Docker Volume Configuration
|
||||
|
||||
All Docker Compose files have been updated to include the `app_uploads` volume:
|
||||
|
||||
```yaml
|
||||
services:
|
||||
app:
|
||||
volumes:
|
||||
- app_data:/data
|
||||
- app_logs:/app/logs
|
||||
- app_uploads:/app/app/static/uploads # Persistent uploads volume
|
||||
|
||||
volumes:
|
||||
app_data:
|
||||
driver: local
|
||||
app_uploads:
|
||||
driver: local
|
||||
```
|
||||
|
||||
### Updated Docker Compose Files
|
||||
|
||||
The following Docker Compose configurations have been updated:
|
||||
|
||||
1. **docker-compose.yml** - Main production configuration
|
||||
2. **docker-compose.example.yml** - Example configuration for new users
|
||||
3. **docker-compose.remote.yml** - Remote deployment configuration
|
||||
4. **docker-compose.local-test.yml** - Local testing with SQLite
|
||||
5. **docker-compose.remote-dev.yml** - Remote development configuration
|
||||
|
||||
Note: Overlay files (`docker-compose.analytics.yml`, `docker-compose.https-*.yml`) don't need changes as they extend the base configuration.
|
||||
|
||||
## Migration Guide
|
||||
|
||||
### For New Installations
|
||||
|
||||
If you're setting up TimeTracker for the first time, the uploads persistence is automatically configured. Simply run:
|
||||
|
||||
```bash
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
### For Existing Installations
|
||||
|
||||
If you're upgrading from a version without uploads persistence, follow these steps:
|
||||
|
||||
#### Step 1: Backup Existing Uploads (if any)
|
||||
|
||||
```bash
|
||||
# Create a backup of existing uploads
|
||||
docker cp timetracker-app:/app/app/static/uploads ./uploads_backup
|
||||
```
|
||||
|
||||
#### Step 2: Update Docker Compose Configuration
|
||||
|
||||
Pull the latest changes:
|
||||
|
||||
```bash
|
||||
git pull origin main
|
||||
# or
|
||||
git pull origin develop
|
||||
```
|
||||
|
||||
#### Step 3: Run Migration Script
|
||||
|
||||
```bash
|
||||
python migrations/ensure_uploads_persistence.py
|
||||
```
|
||||
|
||||
The migration script will:
|
||||
- Create the required directory structure
|
||||
- Set proper permissions (755)
|
||||
- Create `.gitkeep` files for git tracking
|
||||
- Verify Docker volume configuration
|
||||
|
||||
#### Step 4: Restart Containers
|
||||
|
||||
```bash
|
||||
# Stop containers
|
||||
docker-compose down
|
||||
|
||||
# Start with new configuration
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
#### Step 5: Restore Backups (if needed)
|
||||
|
||||
If you had existing uploads, restore them:
|
||||
|
||||
```bash
|
||||
# Copy logos back
|
||||
docker cp ./uploads_backup/logos/. timetracker-app:/app/app/static/uploads/logos/
|
||||
|
||||
# Copy avatars back (if any)
|
||||
docker cp ./uploads_backup/avatars/. timetracker-app:/app/app/static/uploads/avatars/
|
||||
|
||||
# Fix permissions
|
||||
docker exec timetracker-app chown -R timetracker:timetracker /app/app/static/uploads
|
||||
```
|
||||
|
||||
#### Step 6: Verify
|
||||
|
||||
1. Log in to TimeTracker
|
||||
2. Go to Admin → Settings
|
||||
3. Check if your company logo is still displayed
|
||||
4. Try uploading a new logo to test the functionality
|
||||
|
||||
## File Upload Locations
|
||||
|
||||
### Company Logo
|
||||
|
||||
- **Storage Path**: `/app/app/static/uploads/logos/`
|
||||
- **URL Path**: `/uploads/logos/{filename}`
|
||||
- **Database Field**: `settings.company_logo_filename`
|
||||
- **Max Size**: 5MB
|
||||
- **Supported Formats**: PNG, JPG, JPEG, GIF, SVG, WEBP
|
||||
|
||||
### User Avatars
|
||||
|
||||
- **Storage Path**: `/app/app/static/uploads/avatars/`
|
||||
- **URL Path**: `/uploads/avatars/{filename}`
|
||||
- **Database Field**: `users.avatar_filename`
|
||||
- **Max Size**: 2MB (configurable)
|
||||
- **Supported Formats**: PNG, JPG, JPEG, GIF, WEBP
|
||||
|
||||
## Volume Management
|
||||
|
||||
### Inspecting the Volume
|
||||
|
||||
```bash
|
||||
# List all volumes
|
||||
docker volume ls
|
||||
|
||||
# Inspect the uploads volume
|
||||
docker volume inspect timetracker_app_uploads
|
||||
|
||||
# View volume contents
|
||||
docker run --rm -v timetracker_app_uploads:/data alpine ls -lah /data
|
||||
```
|
||||
|
||||
### Backing Up the Volume
|
||||
|
||||
```bash
|
||||
# Create a backup archive
|
||||
docker run --rm \
|
||||
-v timetracker_app_uploads:/data \
|
||||
-v $(pwd):/backup \
|
||||
alpine tar czf /backup/uploads_backup_$(date +%Y%m%d_%H%M%S).tar.gz -C /data .
|
||||
```
|
||||
|
||||
### Restoring from Backup
|
||||
|
||||
```bash
|
||||
# Restore from backup archive
|
||||
docker run --rm \
|
||||
-v timetracker_app_uploads:/data \
|
||||
-v $(pwd):/backup \
|
||||
alpine sh -c "cd /data && tar xzf /backup/uploads_backup_YYYYMMDD_HHMMSS.tar.gz"
|
||||
```
|
||||
|
||||
### Removing the Volume (⚠️ Caution)
|
||||
|
||||
```bash
|
||||
# Stop containers first
|
||||
docker-compose down
|
||||
|
||||
# Remove the volume (this will delete all uploaded files!)
|
||||
docker volume rm timetracker_app_uploads
|
||||
|
||||
# Recreate with new containers
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
## Permissions
|
||||
|
||||
The uploads directory uses the following permissions:
|
||||
|
||||
- **Directory Permission**: `755` (rwxr-xr-x)
|
||||
- Owner (timetracker): Read, Write, Execute
|
||||
- Group: Read, Execute
|
||||
- Others: Read, Execute
|
||||
|
||||
- **File Permission**: `644` (rw-r--r--)
|
||||
- Owner (timetracker): Read, Write
|
||||
- Group: Read
|
||||
- Others: Read
|
||||
|
||||
## Security Considerations
|
||||
|
||||
### File Type Validation
|
||||
|
||||
All uploaded files are validated to ensure they match allowed file types:
|
||||
|
||||
- **Logos**: PNG, JPG, JPEG, GIF, SVG, WEBP
|
||||
- **Avatars**: PNG, JPG, JPEG, GIF, WEBP
|
||||
|
||||
### File Size Limits
|
||||
|
||||
- **Company Logo**: 5MB maximum
|
||||
- **User Avatar**: 2MB maximum (configurable)
|
||||
|
||||
### Filename Sanitization
|
||||
|
||||
All uploaded files are renamed with UUID-based filenames to:
|
||||
- Prevent path traversal attacks
|
||||
- Avoid filename collisions
|
||||
- Remove potentially malicious filenames
|
||||
|
||||
### Access Control
|
||||
|
||||
- Logo uploads: Admin users only (or users with `manage_settings` permission)
|
||||
- Avatar uploads: Authenticated users (their own avatar only)
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Uploaded Files Disappear After Restart
|
||||
|
||||
**Symptom**: Files uploaded before the persistence update are lost after container restart.
|
||||
|
||||
**Solution**:
|
||||
1. Verify the uploads volume is properly mounted:
|
||||
```bash
|
||||
docker inspect timetracker-app | grep -A 10 Mounts
|
||||
```
|
||||
2. Ensure the volume exists:
|
||||
```bash
|
||||
docker volume ls | grep uploads
|
||||
```
|
||||
3. Check if files are in the volume:
|
||||
```bash
|
||||
docker exec timetracker-app ls -lah /app/app/static/uploads/logos/
|
||||
```
|
||||
|
||||
### Permission Denied Errors
|
||||
|
||||
**Symptom**: "Permission denied" when uploading files.
|
||||
|
||||
**Solution**:
|
||||
1. Check directory permissions:
|
||||
```bash
|
||||
docker exec timetracker-app ls -ld /app/app/static/uploads/
|
||||
```
|
||||
2. Fix permissions if needed:
|
||||
```bash
|
||||
docker exec timetracker-app chown -R timetracker:timetracker /app/app/static/uploads
|
||||
docker exec timetracker-app chmod -R 755 /app/app/static/uploads
|
||||
```
|
||||
|
||||
### Files Not Accessible via Web
|
||||
|
||||
**Symptom**: Uploaded files return 404 errors when accessed via browser.
|
||||
|
||||
**Solution**:
|
||||
1. Verify Flask is serving static files correctly
|
||||
2. Check if files exist:
|
||||
```bash
|
||||
docker exec timetracker-app ls /app/app/static/uploads/logos/
|
||||
```
|
||||
3. Check file permissions allow reading:
|
||||
```bash
|
||||
docker exec timetracker-app ls -l /app/app/static/uploads/logos/
|
||||
```
|
||||
|
||||
### Volume Not Created
|
||||
|
||||
**Symptom**: Volume doesn't exist after `docker-compose up`.
|
||||
|
||||
**Solution**:
|
||||
1. Stop all containers:
|
||||
```bash
|
||||
docker-compose down
|
||||
```
|
||||
2. Verify your docker-compose.yml has the uploads volume defined
|
||||
3. Start containers again:
|
||||
```bash
|
||||
docker-compose up -d
|
||||
```
|
||||
4. Check if volume was created:
|
||||
```bash
|
||||
docker volume ls | grep uploads
|
||||
```
|
||||
|
||||
## Testing
|
||||
|
||||
### Manual Testing
|
||||
|
||||
1. **Upload a logo**:
|
||||
- Log in as admin
|
||||
- Go to Admin → Settings
|
||||
- Upload a company logo
|
||||
- Note the logo filename from the database
|
||||
|
||||
2. **Restart container**:
|
||||
```bash
|
||||
docker-compose restart app
|
||||
```
|
||||
|
||||
3. **Verify persistence**:
|
||||
- Refresh the Settings page
|
||||
- Verify the logo is still displayed
|
||||
|
||||
4. **Rebuild container**:
|
||||
```bash
|
||||
docker-compose down
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
5. **Verify persistence after rebuild**:
|
||||
- Log in again
|
||||
- Verify the logo is still displayed
|
||||
|
||||
### Automated Testing
|
||||
|
||||
Run the persistence tests:
|
||||
|
||||
```bash
|
||||
# Run all tests
|
||||
pytest tests/test_uploads_persistence.py -v
|
||||
|
||||
# Run specific test
|
||||
pytest tests/test_uploads_persistence.py::test_logo_upload_creates_file -v
|
||||
```
|
||||
|
||||
## Best Practices
|
||||
|
||||
1. **Regular Backups**: Schedule regular backups of the uploads volume
|
||||
2. **Volume Naming**: Use consistent volume names across environments
|
||||
3. **Monitoring**: Monitor volume disk usage to prevent out-of-space issues
|
||||
4. **Documentation**: Keep documentation updated when modifying upload behavior
|
||||
5. **Testing**: Test file uploads after any infrastructure changes
|
||||
|
||||
## Related Documentation
|
||||
|
||||
- [Company Logo Upload System](./LOGO_UPLOAD_SYSTEM_README.md)
|
||||
- [Logo Upload Implementation Summary](./LOGO_UPLOAD_IMPLEMENTATION_SUMMARY.md)
|
||||
- [Docker Deployment Guide](../DEPLOYMENT_GUIDE.md)
|
||||
|
||||
## Version History
|
||||
|
||||
- **v1.0.0** (2024): Initial implementation of uploads persistence
|
||||
- Added Docker volume support for uploads directory
|
||||
- Updated all Docker Compose configurations
|
||||
- Created migration scripts and documentation
|
||||
- Added automated tests for persistence verification
|
||||
|
||||
## Support
|
||||
|
||||
If you encounter issues with uploads persistence:
|
||||
|
||||
1. Check the [Troubleshooting](#troubleshooting) section above
|
||||
2. Review the logs: `docker-compose logs app`
|
||||
3. Check volume status: `docker volume inspect timetracker_app_uploads`
|
||||
4. Open an issue on GitHub with:
|
||||
- Docker version
|
||||
- Docker Compose version
|
||||
- Relevant log output
|
||||
- Steps to reproduce the issue
|
||||
|
||||
@@ -0,0 +1,327 @@
|
||||
# User Deletion Feature
|
||||
|
||||
## Overview
|
||||
|
||||
The user deletion feature allows administrators to permanently delete user accounts from the system. This feature includes comprehensive safety checks to prevent accidental deletion of critical data or system administrators.
|
||||
|
||||
## Feature Implementation Date
|
||||
|
||||
**Date**: October 29, 2025
|
||||
**Version**: Latest
|
||||
**Status**: ✅ Complete
|
||||
|
||||
## Access Control
|
||||
|
||||
### Who Can Delete Users?
|
||||
|
||||
- **Admin users**: Full access to delete any user (except themselves if they're the last admin)
|
||||
- **Regular users**: No access to user deletion functionality
|
||||
- **Permissions**: Requires `delete_users` permission
|
||||
|
||||
### Who Cannot Be Deleted?
|
||||
|
||||
1. **The last active administrator**: The system prevents deletion of the last active admin to ensure the system remains manageable
|
||||
2. **Users with time entries**: Users who have logged time entries cannot be deleted to preserve data integrity
|
||||
3. **Current logged-in user**: Users cannot delete their own account from the user list view
|
||||
|
||||
## User Interface
|
||||
|
||||
### Location
|
||||
|
||||
The delete functionality is accessible from the **Admin Panel → Manage Users** page:
|
||||
|
||||
```
|
||||
/admin/users
|
||||
```
|
||||
|
||||
### UI Elements
|
||||
|
||||
1. **Delete Button**: Appears next to each user (except current user) in the user list
|
||||
2. **Confirmation Dialog**: Shows before deletion with appropriate warnings
|
||||
3. **Error Messages**: Clear feedback when deletion is not allowed
|
||||
|
||||
### Delete Button Behavior
|
||||
|
||||
- **Visible**: For all users except the currently logged-in admin
|
||||
- **Click Action**: Opens a confirmation dialog
|
||||
- **Confirmation**: Shows user's name and warning about permanent deletion
|
||||
- **With Time Entries**: Shows a special warning that the user cannot be deleted
|
||||
|
||||
## Safety Checks
|
||||
|
||||
### Pre-Deletion Validation
|
||||
|
||||
The system performs the following checks before allowing deletion:
|
||||
|
||||
#### 1. Admin Protection
|
||||
```python
|
||||
# Don't allow deleting the last admin
|
||||
if user.is_admin:
|
||||
admin_count = User.query.filter_by(role='admin', is_active=True).count()
|
||||
if admin_count <= 1:
|
||||
flash('Cannot delete the last administrator', 'error')
|
||||
return redirect(url_for('admin.list_users'))
|
||||
```
|
||||
|
||||
#### 2. Data Integrity Protection
|
||||
```python
|
||||
# Don't allow deleting users with time entries
|
||||
if user.time_entries.count() > 0:
|
||||
flash('Cannot delete user with existing time entries', 'error')
|
||||
return redirect(url_for('admin.list_users'))
|
||||
```
|
||||
|
||||
### Frontend Validation
|
||||
|
||||
JavaScript validation checks time entry count before submitting the form:
|
||||
|
||||
```javascript
|
||||
function confirmDeleteUser(userId, username, timeEntriesCount) {
|
||||
// Check if user has time entries
|
||||
if (timeEntriesCount > 0) {
|
||||
// Show warning dialog (cannot delete)
|
||||
showConfirm('Cannot delete user...', {
|
||||
variant: 'warning',
|
||||
showCancel: false
|
||||
});
|
||||
return false;
|
||||
}
|
||||
|
||||
// Show confirmation dialog
|
||||
showConfirm('Are you sure...', {
|
||||
variant: 'danger'
|
||||
}).then(function(ok) {
|
||||
if (ok) {
|
||||
// Submit delete form
|
||||
}
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
## Database Cascading Behavior
|
||||
|
||||
When a user is deleted, the following related data is automatically handled:
|
||||
|
||||
### ✅ Cascaded (Deleted)
|
||||
|
||||
1. **Time Entries**: All time entries are deleted (but deletion is blocked if any exist)
|
||||
2. **Project Costs**: User-specific project cost records are deleted
|
||||
3. **Favorite Projects**: User's favorite project associations are removed
|
||||
|
||||
### ⚠️ Nullified (Set to NULL)
|
||||
|
||||
1. **Task Assignments**: Tasks assigned to the user have `assigned_to` set to NULL
|
||||
2. **User Roles**: Many-to-many role associations are removed
|
||||
|
||||
### ❌ Protected (Prevents Deletion)
|
||||
|
||||
1. **Created Tasks**: Users who created tasks cannot be deleted (enforced by database constraint)
|
||||
2. **Time Entries**: Users with time entries cannot be deleted (enforced by application logic)
|
||||
|
||||
## API Endpoints
|
||||
|
||||
### Delete User
|
||||
|
||||
**Endpoint**: `POST /admin/users/<user_id>/delete`
|
||||
|
||||
**Authentication**: Required (Admin only)
|
||||
|
||||
**Parameters**:
|
||||
- `user_id` (path parameter): ID of the user to delete
|
||||
|
||||
**Response Codes**:
|
||||
- `200`: Success (redirects to user list with success message)
|
||||
- `302`: Redirects with error message if deletion is not allowed
|
||||
- `404`: User not found
|
||||
- `403`: Insufficient permissions
|
||||
|
||||
**Example Usage**:
|
||||
```python
|
||||
# Via route
|
||||
url_for('admin.delete_user', user_id=123)
|
||||
|
||||
# Expected redirect
|
||||
→ /admin/users (with flash message)
|
||||
```
|
||||
|
||||
## Testing
|
||||
|
||||
The feature includes comprehensive tests:
|
||||
|
||||
### Unit Tests (`tests/test_admin_users.py`)
|
||||
|
||||
- ✅ Test successful user deletion
|
||||
- ✅ Test deletion with time entries (should fail)
|
||||
- ✅ Test deletion of last admin (should fail)
|
||||
- ✅ Test deletion by non-admin (should be denied)
|
||||
- ✅ Test deletion of non-existent user (404)
|
||||
- ✅ Test UI shows/hides delete buttons appropriately
|
||||
|
||||
### Model Tests (`tests/test_models_comprehensive.py`)
|
||||
|
||||
- ✅ Test user deletion without relationships
|
||||
- ✅ Test cascading to project costs
|
||||
- ✅ Test cascading to time entries
|
||||
- ✅ Test removal from favorite projects
|
||||
- ✅ Test task assignment nullification
|
||||
- ✅ Test protection for task creators
|
||||
|
||||
### Smoke Tests (`tests/test_admin_users.py`)
|
||||
|
||||
- ✅ End-to-end deletion workflow
|
||||
- ✅ Critical safety checks
|
||||
- ✅ UI accessibility tests
|
||||
- ✅ Permission enforcement
|
||||
|
||||
### Running Tests
|
||||
|
||||
```bash
|
||||
# Run all admin user tests
|
||||
pytest tests/test_admin_users.py -v
|
||||
|
||||
# Run only smoke tests
|
||||
pytest tests/test_admin_users.py -v -m smoke
|
||||
|
||||
# Run all user deletion model tests
|
||||
pytest tests/test_models_comprehensive.py::test_user_deletion -v
|
||||
```
|
||||
|
||||
## Error Messages
|
||||
|
||||
| Scenario | Message | Action |
|
||||
|----------|---------|--------|
|
||||
| User has time entries | "Cannot delete user with existing time entries" | Show error, prevent deletion |
|
||||
| Last administrator | "Cannot delete the last administrator" | Show error, prevent deletion |
|
||||
| User not found | 404 error page | Show not found |
|
||||
| No permission | Redirect to dashboard | Show access denied |
|
||||
| Success | "User '[username]' deleted successfully" | Redirect to user list |
|
||||
|
||||
## Implementation Details
|
||||
|
||||
### Backend Route
|
||||
|
||||
**File**: `app/routes/admin.py`
|
||||
|
||||
**Function**: `delete_user(user_id)`
|
||||
|
||||
**Decorators**:
|
||||
- `@admin_bp.route('/admin/users/<int:user_id>/delete', methods=['POST'])`
|
||||
- `@login_required`
|
||||
- `@admin_or_permission_required('delete_users')`
|
||||
|
||||
### Template
|
||||
|
||||
**File**: `app/templates/admin/users.html`
|
||||
|
||||
**Components**:
|
||||
1. Delete button (conditional rendering)
|
||||
2. Hidden form for DELETE request
|
||||
3. JavaScript confirmation handler
|
||||
4. Internationalized error messages
|
||||
|
||||
## Security Considerations
|
||||
|
||||
### CSRF Protection
|
||||
|
||||
- All delete requests use POST method
|
||||
- CSRF tokens are required (Flask-WTF)
|
||||
- Forms include CSRF token validation
|
||||
|
||||
### Permission Checks
|
||||
|
||||
- Route-level permission enforcement via `@admin_or_permission_required`
|
||||
- Additional checks in function body for special cases
|
||||
- Session-based authentication required
|
||||
|
||||
### Data Integrity
|
||||
|
||||
- Database-level foreign key constraints
|
||||
- Application-level validation before deletion
|
||||
- Transaction rollback on errors
|
||||
|
||||
## Best Practices for Administrators
|
||||
|
||||
### Before Deleting a User
|
||||
|
||||
1. **Check Time Entries**: Verify if the user has logged any time
|
||||
2. **Transfer Data**: If needed, reassign tasks to other users
|
||||
3. **Export Data**: Consider exporting user's data before deletion
|
||||
4. **Notify Stakeholders**: Inform team members if the user was involved in active projects
|
||||
|
||||
### When Deletion Fails
|
||||
|
||||
1. **Time Entries Present**:
|
||||
- Option 1: Keep the user as inactive instead of deleting
|
||||
- Option 2: Archive time entries if appropriate
|
||||
|
||||
2. **Last Admin**:
|
||||
- Promote another user to admin role first
|
||||
- Then delete the admin if still needed
|
||||
|
||||
### Alternative to Deletion
|
||||
|
||||
Instead of deleting users, consider:
|
||||
|
||||
1. **Deactivate User**: Set `is_active = False`
|
||||
- Preserves all data and relationships
|
||||
- User cannot log in
|
||||
- Can be reactivated if needed
|
||||
|
||||
2. **Archive Projects**: Archive or complete any active projects first
|
||||
|
||||
## Future Enhancements
|
||||
|
||||
Potential improvements for this feature:
|
||||
|
||||
- [ ] Soft delete option (mark as deleted but keep in database)
|
||||
- [ ] Bulk user deletion
|
||||
- [ ] User deletion audit log
|
||||
- [ ] Export user data before deletion
|
||||
- [ ] Reassign user's data to another user
|
||||
- [ ] Deletion confirmation via email
|
||||
- [ ] Admin approval workflow for user deletion
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Issue: Cannot delete user
|
||||
|
||||
**Cause**: User has time entries or is the last admin
|
||||
|
||||
**Solution**:
|
||||
1. Check error message for specific reason
|
||||
2. For time entries: Consider deactivating instead
|
||||
3. For last admin: Create another admin first
|
||||
|
||||
### Issue: Delete button not showing
|
||||
|
||||
**Cause**: May be the current logged-in user or permission issue
|
||||
|
||||
**Solution**:
|
||||
1. Verify you're logged in as admin
|
||||
2. Check if trying to delete your own account
|
||||
3. Verify `delete_users` permission
|
||||
|
||||
### Issue: Permission denied
|
||||
|
||||
**Cause**: User doesn't have admin rights or `delete_users` permission
|
||||
|
||||
**Solution**:
|
||||
1. Log in as an administrator
|
||||
2. Check role assignments in permission system
|
||||
|
||||
## Related Documentation
|
||||
|
||||
- [User Management](../admin/USER_MANAGEMENT.md)
|
||||
- [Permissions System](../security/PERMISSIONS.md)
|
||||
- [Admin Panel](../admin/ADMIN_PANEL.md)
|
||||
- [Testing Guide](../development/TESTING.md)
|
||||
|
||||
## Changelog
|
||||
|
||||
### Version 1.0 (October 29, 2025)
|
||||
- ✅ Initial implementation of user deletion feature
|
||||
- ✅ UI integration with user list page
|
||||
- ✅ Safety checks for admin and data protection
|
||||
- ✅ Comprehensive test coverage
|
||||
- ✅ Documentation completed
|
||||
|
||||
@@ -86,3 +86,19 @@
|
||||
{"asctime": "2025-10-27 15:09:02,194", "levelname": "INFO", "name": "timetracker", "message": "auth.login", "taskName": null, "request_id": "fca17491-6c73-40c8-b36b-e02b48934493", "event": "auth.login", "user_id": 1, "auth_method": "local"}
|
||||
{"asctime": "2025-10-27 15:09:03,259", "levelname": "INFO", "name": "timetracker", "message": "auth.login", "taskName": null, "request_id": "253718af-c020-476a-a41f-0b4b93011644", "event": "auth.login", "user_id": 1, "auth_method": "local"}
|
||||
{"asctime": "2025-10-27 15:09:05,332", "levelname": "INFO", "name": "timetracker", "message": "auth.login", "taskName": null, "request_id": "507e45da-62b4-49c2-8430-ce293ccb61f3", "event": "auth.login", "user_id": 1, "auth_method": "local"}
|
||||
{"asctime": "2025-10-29 08:54:54,842", "levelname": "INFO", "name": "timetracker", "message": "project.favorited", "taskName": null, "request_id": "df528552-5d52-4840-a3f1-b7b0856461b9", "event": "project.favorited", "user_id": 1, "project_id": 1}
|
||||
{"asctime": "2025-10-29 08:54:55,974", "levelname": "INFO", "name": "timetracker", "message": "project.unfavorited", "taskName": null, "request_id": "41bbaefb-287f-4bf0-9e38-01da740cf548", "event": "project.unfavorited", "user_id": 1, "project_id": 1}
|
||||
{"asctime": "2025-10-29 08:55:37,589", "levelname": "INFO", "name": "timetracker", "message": "project.archived", "taskName": null, "request_id": "bb66bbbc-bc45-4154-a1ad-b632fc04494c", "event": "project.archived", "user_id": 1, "project_id": 1, "reason": "Project completed successfully"}
|
||||
{"asctime": "2025-10-29 08:55:39,880", "levelname": "INFO", "name": "timetracker", "message": "project.archived", "taskName": null, "request_id": "84cb06e3-e26f-40b5-942c-cfe35cb73fca", "event": "project.archived", "user_id": 1, "project_id": 1, "reason": null}
|
||||
{"asctime": "2025-10-29 08:55:41,442", "levelname": "INFO", "name": "timetracker", "message": "project.unarchived", "taskName": null, "request_id": "68c1b2bf-b7f0-4554-9bd4-f8a60396c687", "event": "project.unarchived", "user_id": 1, "project_id": 1}
|
||||
{"asctime": "2025-10-29 08:55:43,167", "levelname": "INFO", "name": "timetracker", "message": "project.status_changed_archived", "taskName": null, "request_id": "8336746f-195c-4f18-a128-6e4a0b548695", "event": "project.status_changed_archived", "user_id": 1, "project_id": 1}
|
||||
{"asctime": "2025-10-29 08:55:43,173", "levelname": "INFO", "name": "timetracker", "message": "project.status_changed_archived", "taskName": null, "request_id": "8336746f-195c-4f18-a128-6e4a0b548695", "event": "project.status_changed_archived", "user_id": 1, "project_id": 2}
|
||||
{"asctime": "2025-10-29 08:55:52,925", "levelname": "INFO", "name": "timetracker", "message": "project.archived", "taskName": null, "request_id": "65a474e8-6415-48f4-a7c3-c0492cfee88a", "event": "project.archived", "user_id": 1, "project_id": 1, "reason": "Project completed"}
|
||||
{"asctime": "2025-10-29 08:55:54,112", "levelname": "INFO", "name": "timetracker", "message": "project.unarchived", "taskName": null, "request_id": "89b64b10-55c3-4e01-adfe-af96c1419fc6", "event": "project.unarchived", "user_id": 1, "project_id": 1}
|
||||
{"asctime": "2025-10-29 08:55:59,266", "levelname": "INFO", "name": "timetracker", "message": "project.archived", "taskName": null, "request_id": "98b2916b-c41e-4412-be72-0e6ced617314", "event": "project.archived", "user_id": 1, "project_id": 1, "reason": "Complete smoke test"}
|
||||
{"asctime": "2025-10-29 08:55:59,351", "levelname": "INFO", "name": "timetracker", "message": "project.unarchived", "taskName": null, "request_id": "2beada5f-3fa1-4fef-ab8e-27b45f02384a", "event": "project.unarchived", "user_id": 1, "project_id": 1}
|
||||
{"asctime": "2025-10-29 08:57:06,857", "levelname": "INFO", "name": "timetracker", "message": "project.deactivated", "taskName": null, "request_id": "37e8671d-3392-4989-aabd-8f3470e0e832", "event": "project.deactivated", "user_id": 1, "project_id": 1}
|
||||
{"asctime": "2025-10-29 08:57:07,928", "levelname": "INFO", "name": "timetracker", "message": "project.activated", "taskName": null, "request_id": "14a50afd-54b7-419c-8cf1-c12ad3a1be16", "event": "project.activated", "user_id": 1, "project_id": 1}
|
||||
{"asctime": "2025-10-29 08:57:21,949", "levelname": "INFO", "name": "timetracker", "message": "task.updated", "taskName": null, "request_id": "b91eb6e3-4229-4e57-a38c-a50a0d8d4fc8", "event": "task.updated", "user_id": 1, "task_id": 1, "project_id": 2}
|
||||
{"asctime": "2025-10-29 08:57:25,166", "levelname": "INFO", "name": "timetracker", "message": "timer.duplicated", "taskName": null, "request_id": "bb3a4bdf-773c-4a92-85bb-fd93b838e50c", "event": "timer.duplicated", "user_id": 1, "time_entry_id": 1, "project_id": 1, "task_id": 1}
|
||||
{"asctime": "2025-10-29 08:57:26,120", "levelname": "INFO", "name": "timetracker", "message": "timer.duplicated", "taskName": null, "request_id": "e1f0e4ad-5de0-40cc-9630-20fc944ed3b7", "event": "timer.duplicated", "user_id": 1, "time_entry_id": 1, "project_id": 1, "task_id": null}
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Migration script to ensure uploads directory structure for persistence.
|
||||
|
||||
This migration:
|
||||
1. Creates the uploads directory structure if it doesn't exist
|
||||
2. Ensures proper permissions for the uploads directories
|
||||
3. Verifies that logos and avatars subdirectories exist
|
||||
4. Creates .gitkeep files to preserve directory structure in git
|
||||
|
||||
Run this script to prepare the application for persistent file uploads.
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
import stat
|
||||
|
||||
def ensure_uploads_directories():
|
||||
"""Ensure uploads directory structure exists with proper permissions"""
|
||||
print("=== Ensuring Uploads Directory Structure ===")
|
||||
|
||||
# Define the upload directories that need to exist
|
||||
# Support both /app/app/static/uploads (container) and app/static/uploads (local)
|
||||
possible_base_paths = [
|
||||
'/app/app/static/uploads', # Docker container path
|
||||
'app/static/uploads', # Local development path
|
||||
]
|
||||
|
||||
# Try to find the correct base path
|
||||
base_path = None
|
||||
for path in possible_base_paths:
|
||||
parent = os.path.dirname(path)
|
||||
if os.path.exists(parent) or path.startswith('/app'):
|
||||
base_path = path
|
||||
break
|
||||
|
||||
if not base_path:
|
||||
print("⚠ Could not determine base path. Using default: app/static/uploads")
|
||||
base_path = 'app/static/uploads'
|
||||
|
||||
print(f"Using base path: {base_path}")
|
||||
|
||||
# Define subdirectories
|
||||
subdirectories = ['logos', 'avatars']
|
||||
|
||||
try:
|
||||
# Create main uploads directory
|
||||
if not os.path.exists(base_path):
|
||||
os.makedirs(base_path, mode=0o755, exist_ok=True)
|
||||
print(f"✓ Created uploads directory: {base_path}")
|
||||
else:
|
||||
print(f"✓ Uploads directory exists: {base_path}")
|
||||
|
||||
# Set permissions on uploads directory
|
||||
try:
|
||||
os.chmod(base_path, stat.S_IRWXU | stat.S_IRGRP | stat.S_IXGRP | stat.S_IROTH | stat.S_IXOTH)
|
||||
print(f"✓ Set permissions (755) on: {base_path}")
|
||||
except Exception as e:
|
||||
print(f"⚠ Could not set permissions on {base_path}: {e}")
|
||||
|
||||
# Create subdirectories
|
||||
for subdir in subdirectories:
|
||||
subdir_path = os.path.join(base_path, subdir)
|
||||
if not os.path.exists(subdir_path):
|
||||
os.makedirs(subdir_path, mode=0o755, exist_ok=True)
|
||||
print(f"✓ Created subdirectory: {subdir_path}")
|
||||
else:
|
||||
print(f"✓ Subdirectory exists: {subdir_path}")
|
||||
|
||||
# Set permissions on subdirectory
|
||||
try:
|
||||
os.chmod(subdir_path, stat.S_IRWXU | stat.S_IRGRP | stat.S_IXGRP | stat.S_IROTH | stat.S_IXOTH)
|
||||
print(f"✓ Set permissions (755) on: {subdir_path}")
|
||||
except Exception as e:
|
||||
print(f"⚠ Could not set permissions on {subdir_path}: {e}")
|
||||
|
||||
# Create .gitkeep file to preserve directory in git
|
||||
gitkeep_path = os.path.join(subdir_path, '.gitkeep')
|
||||
if not os.path.exists(gitkeep_path):
|
||||
try:
|
||||
with open(gitkeep_path, 'w') as f:
|
||||
f.write('# This file ensures the directory is tracked by git\n')
|
||||
print(f"✓ Created .gitkeep in: {subdir_path}")
|
||||
except Exception as e:
|
||||
print(f"⚠ Could not create .gitkeep in {subdir_path}: {e}")
|
||||
|
||||
# Test write permissions
|
||||
print("\nTesting write permissions...")
|
||||
for subdir in subdirectories:
|
||||
subdir_path = os.path.join(base_path, subdir)
|
||||
test_file = os.path.join(subdir_path, '.test_write_permissions')
|
||||
try:
|
||||
with open(test_file, 'w') as f:
|
||||
f.write('test')
|
||||
os.remove(test_file)
|
||||
print(f"✓ Write permission test passed: {subdir_path}")
|
||||
except Exception as e:
|
||||
print(f"⚠ Write permission test failed for {subdir_path}: {e}")
|
||||
|
||||
print("\n=== Uploads Directory Structure Ready ===")
|
||||
print("\nDirectory structure:")
|
||||
print(f" {base_path}/")
|
||||
for subdir in subdirectories:
|
||||
print(f" ├── {subdir}/")
|
||||
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
print(f"\n✗ Error ensuring uploads directory structure: {e}")
|
||||
return False
|
||||
|
||||
|
||||
def verify_docker_volume_config():
|
||||
"""Verify that Docker volume configuration is present"""
|
||||
print("\n=== Verifying Docker Volume Configuration ===")
|
||||
|
||||
compose_files = [
|
||||
'docker-compose.yml',
|
||||
'docker-compose.example.yml',
|
||||
'docker-compose.remote.yml',
|
||||
'docker-compose.local-test.yml',
|
||||
'docker-compose.remote-dev.yml',
|
||||
]
|
||||
|
||||
for compose_file in compose_files:
|
||||
if os.path.exists(compose_file):
|
||||
with open(compose_file, 'r') as f:
|
||||
content = f.read()
|
||||
if 'app_uploads' in content or 'uploads' in content:
|
||||
print(f"✓ {compose_file} has uploads volume configured")
|
||||
else:
|
||||
print(f"⚠ {compose_file} may be missing uploads volume configuration")
|
||||
else:
|
||||
print(f" {compose_file} not found (optional)")
|
||||
|
||||
print("\n=== Volume Configuration Verification Complete ===")
|
||||
|
||||
|
||||
def main():
|
||||
"""Main migration function"""
|
||||
print("\n" + "="*60)
|
||||
print(" Uploads Persistence Migration")
|
||||
print("="*60 + "\n")
|
||||
|
||||
success = True
|
||||
|
||||
# Ensure directory structure
|
||||
if not ensure_uploads_directories():
|
||||
success = False
|
||||
|
||||
# Verify Docker configuration
|
||||
verify_docker_volume_config()
|
||||
|
||||
if success:
|
||||
print("\n" + "="*60)
|
||||
print(" ✓ Migration completed successfully!")
|
||||
print("="*60)
|
||||
print("\nNext steps:")
|
||||
print("1. If using Docker, rebuild your containers:")
|
||||
print(" docker-compose down")
|
||||
print(" docker-compose up -d")
|
||||
print("\n2. Your uploaded logos and avatars will now persist")
|
||||
print(" between container rebuilds.")
|
||||
print("\n3. Existing uploaded files should remain intact.")
|
||||
print("="*60 + "\n")
|
||||
return 0
|
||||
else:
|
||||
print("\n" + "="*60)
|
||||
print(" ⚠ Migration completed with warnings")
|
||||
print("="*60)
|
||||
print("\nSome steps failed, but the application may still work.")
|
||||
print("Check the warnings above for details.")
|
||||
print("="*60 + "\n")
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
sys.exit(main())
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
"""add invoice_pdf_design_json to settings
|
||||
|
||||
Revision ID: 036_add_pdf_design_json
|
||||
Revises: 035_enhance_payments
|
||||
Create Date: 2025-10-29 12:00:00
|
||||
"""
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
|
||||
|
||||
# revision identifiers, used by Alembic.
|
||||
revision = '036_add_pdf_design_json'
|
||||
down_revision = '035_enhance_payments'
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
inspector = sa.inspect(bind)
|
||||
if 'settings' not in inspector.get_table_names():
|
||||
return
|
||||
columns = {c['name'] for c in inspector.get_columns('settings')}
|
||||
if 'invoice_pdf_design_json' not in columns:
|
||||
op.add_column('settings', sa.Column('invoice_pdf_design_json', sa.Text(), nullable=True))
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
inspector = sa.inspect(bind)
|
||||
if 'settings' not in inspector.get_table_names():
|
||||
return
|
||||
columns = {c['name'] for c in inspector.get_columns('settings')}
|
||||
if 'invoice_pdf_design_json' in columns:
|
||||
try:
|
||||
op.drop_column('settings', 'invoice_pdf_design_json')
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
@@ -39,6 +39,7 @@ markers =
|
||||
utils: Utility/helper function tests
|
||||
security: Security-related tests
|
||||
invoices: Invoice-related tests
|
||||
admin: Admin panel and settings tests
|
||||
performance: Performance and load tests
|
||||
slow: Slow running tests
|
||||
requires_db: Tests that require database connection
|
||||
|
||||
@@ -7,7 +7,7 @@ from setuptools import setup, find_packages
|
||||
|
||||
setup(
|
||||
name='timetracker',
|
||||
version='3.5.0',
|
||||
version='3.5.1',
|
||||
packages=find_packages(),
|
||||
include_package_data=True,
|
||||
install_requires=[
|
||||
|
||||
+2298
-178
File diff suppressed because it is too large
Load Diff
@@ -841,7 +841,16 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
// Set up edit and delete buttons
|
||||
document.getElementById('editEventBtn').href = `/timer/edit/${event.id}`;
|
||||
document.getElementById('deleteEventBtn').onclick = async () => {
|
||||
if (confirm('{{ _("Are you sure you want to delete this entry?") }}')) {
|
||||
const confirmed = await showConfirm(
|
||||
'{{ _("Are you sure you want to delete this entry?") }}',
|
||||
{
|
||||
title: '{{ _("Delete Entry") }}',
|
||||
confirmText: '{{ _("Delete") }}',
|
||||
cancelText: '{{ _("Cancel") }}',
|
||||
variant: 'danger'
|
||||
}
|
||||
);
|
||||
if (confirmed) {
|
||||
await deleteEvent(event.id);
|
||||
}
|
||||
};
|
||||
@@ -1046,7 +1055,16 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
};
|
||||
|
||||
window.deleteRecurring = async function(blockId) {
|
||||
if (!confirm('{{ _("Are you sure you want to delete this recurring block?") }}')) return;
|
||||
const confirmed = await showConfirm(
|
||||
'{{ _("Are you sure you want to delete this recurring block?") }}',
|
||||
{
|
||||
title: '{{ _("Delete Recurring Block") }}',
|
||||
confirmText: '{{ _("Delete") }}',
|
||||
cancelText: '{{ _("Cancel") }}',
|
||||
variant: 'danger'
|
||||
}
|
||||
);
|
||||
if (!confirmed) return;
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/recurring-blocks/${blockId}`, { method: 'DELETE' });
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user