Merge pull request #178 from DRYTRIX/RC

Rc
This commit is contained in:
Dries Peeters
2025-10-29 19:15:52 +01:00
committed by GitHub
136 changed files with 9763 additions and 701 deletions
-30
View File
@@ -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
View File
@@ -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
+3
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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');
+11 -2
View File
@@ -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();
}
+69 -27
View File
@@ -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;
}
/**
+59 -12
View File
@@ -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;
}
/**
+57 -13
View File
@@ -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() {
+11 -2
View File
@@ -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();
}
}
+7 -1
View File
@@ -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));
+20 -2
View File
@@ -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;
}
+201
View File
@@ -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 %}
+7 -7
View File
@@ -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>
+19 -2
View File
@@ -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 %}
+117 -21
View File
@@ -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 %}
+40
View File
@@ -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
View File
@@ -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>
+18 -4
View File
@@ -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">
+2 -2
View File
@@ -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>
+17 -3
View File
@@ -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 %}
+117 -4
View File
@@ -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;
}
});
+28 -3
View File
@@ -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(_) {}
}
});
});
+59 -1
View File
@@ -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()">&times;</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 %}
+22
View File
@@ -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 %}
+90 -5
View File
@@ -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()">&times;</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 %}
+5 -41
View File
@@ -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>
+14 -14
View File
@@ -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>
+15 -2
View File
@@ -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();
}
});
});
});
+9 -17
View File
@@ -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>
+1 -1
View File
@@ -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>
+12 -6
View File
@@ -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 %}
+1 -1
View File
@@ -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">
+11 -2
View File
@@ -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();
+3 -3
View File
@@ -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>
+40 -38
View File
@@ -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);
+15 -4
View File
@@ -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
+22 -4
View File
@@ -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!');
}
+5 -5
View File
@@ -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' %}
+3
View File
@@ -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
View File
@@ -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
+28
View File
@@ -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 ''
+3
View File
@@ -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
+4
View File
@@ -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
+3
View File
@@ -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
+3
View File
@@ -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
+3
View File
@@ -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:
+216
View File
@@ -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)
+278
View File
@@ -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)
+288
View File
@@ -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! 🎨
+616
View File
@@ -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
+389
View File
@@ -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
+327
View File
@@ -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
+16
View File
@@ -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}
+179
View File
@@ -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
+1
View File
@@ -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
+1 -1
View File
@@ -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=[
File diff suppressed because it is too large Load Diff
+20 -2
View File
@@ -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' });

Some files were not shown because too many files have changed in this diff Show More