Add project custom fields and file attachments for projects and clients

This commit introduces two major features:

1. Project Custom Fields: Add custom_fields JSON column to projects table (migration 085), support for flexible custom data storage, display and edit in project views

2. File Attachments System: Add project_attachments and client_attachments tables (migration 086), new ProjectAttachment and ClientAttachment models, full CRUD operations, file upload/download/delete, client-visible attachments support

Additional improvements: Enhanced data tables, updated project/client/invoice/timer views, improved UI for attachments and custom fields management
This commit is contained in:
Dries Peeters
2025-12-03 08:30:15 +01:00
parent 86b3498f05
commit f3a3a40480
21 changed files with 1945 additions and 39 deletions
+4
View File
@@ -41,6 +41,8 @@ from .invoice_email import InvoiceEmail
from .webhook import Webhook, WebhookDelivery
from .quote import Quote, QuoteItem, QuotePDFTemplate
from .quote_attachment import QuoteAttachment
from .project_attachment import ProjectAttachment
from .client_attachment import ClientAttachment
from .quote_template import QuoteTemplate
from .quote_version import QuoteVersion
from .warehouse import Warehouse
@@ -125,6 +127,8 @@ __all__ = [
"QuoteItem",
"QuotePDFTemplate",
"QuoteAttachment",
"ProjectAttachment",
"ClientAttachment",
"QuoteTemplate",
"QuoteVersion",
"Warehouse",
+133
View File
@@ -0,0 +1,133 @@
from datetime import datetime
from app import db
from app.utils.timezone import now_in_app_timezone
import os
def local_now():
"""Get current time in local timezone as naive datetime (for database storage)"""
return now_in_app_timezone().replace(tzinfo=None)
class ClientAttachment(db.Model):
"""Model for client file attachments"""
__tablename__ = "client_attachments"
id = db.Column(db.Integer, primary_key=True)
client_id = db.Column(db.Integer, db.ForeignKey("clients.id", ondelete="CASCADE"), nullable=False, index=True)
# File information
filename = db.Column(db.String(255), nullable=False)
original_filename = db.Column(db.String(255), nullable=False)
file_path = db.Column(db.String(500), nullable=False)
file_size = db.Column(db.Integer, nullable=False) # Size in bytes
mime_type = db.Column(db.String(100), nullable=True)
# Metadata
description = db.Column(db.Text, nullable=True)
is_visible_to_client = db.Column(
db.Boolean, default=False, nullable=False
) # Whether attachment is visible in client portal
# Upload information
uploaded_by = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=False, index=True)
uploaded_at = db.Column(db.DateTime, default=local_now, nullable=False)
# Relationships
client = db.relationship("Client", backref="attachments")
uploader = db.relationship("User", backref="uploaded_client_attachments")
def __init__(self, client_id, filename, original_filename, file_path, file_size, uploaded_by, **kwargs):
self.client_id = client_id
self.filename = filename
self.original_filename = original_filename
self.file_path = file_path
self.file_size = file_size
self.uploaded_by = uploaded_by
self.mime_type = kwargs.get("mime_type")
self.description = kwargs.get("description", "").strip() if kwargs.get("description") else None
self.is_visible_to_client = kwargs.get("is_visible_to_client", False)
def __repr__(self):
return f"<ClientAttachment {self.original_filename} for Client {self.client_id}>"
@property
def file_size_mb(self):
"""Get file size in megabytes"""
return round(self.file_size / (1024 * 1024), 2)
@property
def file_size_kb(self):
"""Get file size in kilobytes"""
return round(self.file_size / 1024, 2)
@property
def file_size_display(self):
"""Get human-readable file size"""
if self.file_size < 1024:
return f"{self.file_size} B"
elif self.file_size < 1024 * 1024:
return f"{self.file_size_kb} KB"
else:
return f"{self.file_size_mb} MB"
@property
def file_extension(self):
"""Get file extension"""
return os.path.splitext(self.original_filename)[1].lower()
@property
def is_image(self):
"""Check if file is an image"""
return self.file_extension in [".jpg", ".jpeg", ".png", ".gif", ".webp", ".svg"]
@property
def is_pdf(self):
"""Check if file is a PDF"""
return self.file_extension == ".pdf"
@property
def is_document(self):
"""Check if file is a document"""
return self.file_extension in [".doc", ".docx", ".txt", ".rtf"]
@property
def download_url(self):
"""Get URL for downloading the attachment"""
from flask import url_for
return url_for("clients.download_attachment", attachment_id=self.id)
def to_dict(self):
"""Convert attachment to dictionary for API responses"""
return {
"id": self.id,
"client_id": self.client_id,
"filename": self.filename,
"original_filename": self.original_filename,
"file_size": self.file_size,
"file_size_display": self.file_size_display,
"mime_type": self.mime_type,
"description": self.description,
"is_visible_to_client": self.is_visible_to_client,
"uploaded_by": self.uploaded_by,
"uploader": self.uploader.username if self.uploader else None,
"uploaded_at": self.uploaded_at.isoformat() if self.uploaded_at else None,
"file_extension": self.file_extension,
"is_image": self.is_image,
"is_pdf": self.is_pdf,
"is_document": self.is_document,
"download_url": self.download_url,
}
@classmethod
def get_client_attachments(cls, client_id, include_client_visible=True):
"""Get all attachments for a client"""
query = cls.query.filter_by(client_id=client_id)
if not include_client_visible:
query = query.filter_by(is_visible_to_client=False)
return query.order_by(cls.uploaded_at.desc()).all()
+46
View File
@@ -23,6 +23,7 @@ class Project(db.Model):
estimated_hours = db.Column(db.Float, nullable=True)
budget_amount = db.Column(db.Numeric(10, 2), nullable=True)
budget_threshold_percent = db.Column(db.Integer, nullable=False, default=80) # alert when exceeded
custom_fields = db.Column(db.JSON, nullable=True)
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
# Archiving metadata
@@ -321,6 +322,50 @@ class Project(db.Model):
return self.favorited_by.filter_by(id=user.id).count() > 0
return False
def get_custom_field(self, key, default=None):
"""Get a custom field value"""
if not self.custom_fields:
return default
return self.custom_fields.get(key, default)
def set_custom_field(self, key, value):
"""Set a custom field value"""
if self.custom_fields is None:
self.custom_fields = {}
self.custom_fields[key] = value
self.updated_at = datetime.utcnow()
def remove_custom_field(self, key):
"""Remove a custom field"""
if self.custom_fields and key in self.custom_fields:
del self.custom_fields[key]
self.updated_at = datetime.utcnow()
def get_rendered_links(self):
"""Get all rendered links from active link templates that match this project's custom fields"""
from .link_template import LinkTemplate
if not self.custom_fields:
return []
links = []
templates = LinkTemplate.get_active_templates()
for template in templates:
if template.field_key in self.custom_fields:
field_value = self.custom_fields[template.field_key]
if field_value:
rendered_url = template.render_url(field_value)
if rendered_url:
links.append({
"name": template.name,
"url": rendered_url,
"icon": template.icon,
"description": template.description
})
return links
def to_dict(self, user=None):
"""Convert project to dictionary for API responses"""
data = {
@@ -347,6 +392,7 @@ class Project(db.Model):
"total_costs": self.total_costs,
"total_billable_costs": self.total_billable_costs,
"total_project_value": self.total_project_value,
"custom_fields": self.custom_fields or {},
# Archiving metadata
"is_archived": self.is_archived,
"archived_at": self.archived_at.isoformat() if self.archived_at else None,
+133
View File
@@ -0,0 +1,133 @@
from datetime import datetime
from app import db
from app.utils.timezone import now_in_app_timezone
import os
def local_now():
"""Get current time in local timezone as naive datetime (for database storage)"""
return now_in_app_timezone().replace(tzinfo=None)
class ProjectAttachment(db.Model):
"""Model for project file attachments"""
__tablename__ = "project_attachments"
id = db.Column(db.Integer, primary_key=True)
project_id = db.Column(db.Integer, db.ForeignKey("projects.id", ondelete="CASCADE"), nullable=False, index=True)
# File information
filename = db.Column(db.String(255), nullable=False)
original_filename = db.Column(db.String(255), nullable=False)
file_path = db.Column(db.String(500), nullable=False)
file_size = db.Column(db.Integer, nullable=False) # Size in bytes
mime_type = db.Column(db.String(100), nullable=True)
# Metadata
description = db.Column(db.Text, nullable=True)
is_visible_to_client = db.Column(
db.Boolean, default=False, nullable=False
) # Whether attachment is visible in client portal
# Upload information
uploaded_by = db.Column(db.Integer, db.ForeignKey("users.id"), nullable=False, index=True)
uploaded_at = db.Column(db.DateTime, default=local_now, nullable=False)
# Relationships
project = db.relationship("Project", backref=db.backref("attachments", lazy="noload"))
uploader = db.relationship("User", backref="uploaded_project_attachments")
def __init__(self, project_id, filename, original_filename, file_path, file_size, uploaded_by, **kwargs):
self.project_id = project_id
self.filename = filename
self.original_filename = original_filename
self.file_path = file_path
self.file_size = file_size
self.uploaded_by = uploaded_by
self.mime_type = kwargs.get("mime_type")
self.description = kwargs.get("description", "").strip() if kwargs.get("description") else None
self.is_visible_to_client = kwargs.get("is_visible_to_client", False)
def __repr__(self):
return f"<ProjectAttachment {self.original_filename} for Project {self.project_id}>"
@property
def file_size_mb(self):
"""Get file size in megabytes"""
return round(self.file_size / (1024 * 1024), 2)
@property
def file_size_kb(self):
"""Get file size in kilobytes"""
return round(self.file_size / 1024, 2)
@property
def file_size_display(self):
"""Get human-readable file size"""
if self.file_size < 1024:
return f"{self.file_size} B"
elif self.file_size < 1024 * 1024:
return f"{self.file_size_kb} KB"
else:
return f"{self.file_size_mb} MB"
@property
def file_extension(self):
"""Get file extension"""
return os.path.splitext(self.original_filename)[1].lower()
@property
def is_image(self):
"""Check if file is an image"""
return self.file_extension in [".jpg", ".jpeg", ".png", ".gif", ".webp", ".svg"]
@property
def is_pdf(self):
"""Check if file is a PDF"""
return self.file_extension == ".pdf"
@property
def is_document(self):
"""Check if file is a document"""
return self.file_extension in [".doc", ".docx", ".txt", ".rtf"]
@property
def download_url(self):
"""Get URL for downloading the attachment"""
from flask import url_for
return url_for("projects.download_attachment", attachment_id=self.id)
def to_dict(self):
"""Convert attachment to dictionary for API responses"""
return {
"id": self.id,
"project_id": self.project_id,
"filename": self.filename,
"original_filename": self.original_filename,
"file_size": self.file_size,
"file_size_display": self.file_size_display,
"mime_type": self.mime_type,
"description": self.description,
"is_visible_to_client": self.is_visible_to_client,
"uploaded_by": self.uploaded_by,
"uploader": self.uploader.username if self.uploader else None,
"uploaded_at": self.uploaded_at.isoformat() if self.uploaded_at else None,
"file_extension": self.file_extension,
"is_image": self.is_image,
"is_pdf": self.is_pdf,
"is_document": self.is_document,
"download_url": self.download_url,
}
@classmethod
def get_project_attachments(cls, project_id, include_client_visible=True):
"""Get all attachments for a project"""
query = cls.query.filter_by(project_id=project_id)
if not include_client_visible:
query = query.filter_by(is_visible_to_client=False)
return query.order_by(cls.uploaded_at.desc()).all()
+199 -2
View File
@@ -2,8 +2,8 @@ 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
import app as app_module
from app import db
from app.models import Client, Project, Contact, TimeEntry, CustomFieldDefinition
from app import db, log_event, track_event
from app.models import Client, Project, Contact, TimeEntry, CustomFieldDefinition, ClientAttachment
from datetime import datetime, timedelta
from decimal import Decimal, InvalidOperation
from app.utils.db import safe_commit
@@ -414,6 +414,24 @@ def view_client(client_id):
recent_time_entries = time_entries_query.all()
# Get attachments for this client (if attachments table exists)
attachments = []
try:
attachments = ClientAttachment.get_client_attachments(client_id)
except ProgrammingError as e:
# Handle case where client_attachments table doesn't exist (migration not run)
if "does not exist" in str(e.orig) or "relation" in str(e.orig).lower():
current_app.logger.warning(
"client_attachments table does not exist. Run migration: flask db upgrade"
)
attachments = []
else:
raise
except Exception as e:
# Handle any other errors gracefully
current_app.logger.warning(f"Could not load attachments for client {client_id}: {e}")
attachments = []
return render_template(
"clients/view.html",
client=client,
@@ -421,6 +439,7 @@ def view_client(client_id):
contacts=contacts,
primary_contact=primary_contact,
prepaid_overview=prepaid_overview,
attachments=attachments,
recent_time_entries=recent_time_entries,
link_templates_by_field=link_templates_by_field,
custom_field_definitions_by_key=custom_field_definitions_by_key,
@@ -1005,3 +1024,181 @@ def api_clients():
for c in clients
]
}
# Client attachment routes
@clients_bp.route("/clients/<int:client_id>/attachments/upload", methods=["POST"])
@login_required
@admin_or_permission_required("edit_clients")
def upload_client_attachment(client_id):
"""Upload an attachment to a client"""
from werkzeug.utils import secure_filename
from flask import send_file
import os
from datetime import datetime
client = Client.query.get_or_404(client_id)
# File upload configuration
ALLOWED_EXTENSIONS = {"png", "jpg", "jpeg", "gif", "pdf", "doc", "docx", "txt", "xls", "xlsx", "zip", "rar"}
UPLOAD_FOLDER = "uploads/client_attachments"
MAX_FILE_SIZE = 10 * 1024 * 1024 # 10 MB
def allowed_file(filename):
return "." in filename and filename.rsplit(".", 1)[1].lower() in ALLOWED_EXTENSIONS
if "file" not in request.files:
flash(_("No file provided"), "error")
return redirect(url_for("clients.view_client", client_id=client_id))
file = request.files["file"]
if file.filename == "":
flash(_("No file selected"), "error")
return redirect(url_for("clients.view_client", client_id=client_id))
if not allowed_file(file.filename):
flash(_("File type not allowed"), "error")
return redirect(url_for("clients.view_client", client_id=client_id))
# Check file size
file.seek(0, os.SEEK_END)
file_size = file.tell()
file.seek(0)
if file_size > MAX_FILE_SIZE:
flash(_("File size exceeds maximum allowed size (10 MB)"), "error")
return redirect(url_for("clients.view_client", client_id=client_id))
# Save file
original_filename = secure_filename(file.filename)
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
filename = f"{client_id}_{timestamp}_{original_filename}"
# Ensure upload directory exists
upload_dir = os.path.join(current_app.root_path, "..", UPLOAD_FOLDER)
os.makedirs(upload_dir, exist_ok=True)
file_path = os.path.join(upload_dir, filename)
file.save(file_path)
# Get file info
mime_type = file.content_type or "application/octet-stream"
description = request.form.get("description", "").strip() or None
is_visible_to_client = request.form.get("is_visible_to_client", "false").lower() == "true"
# Create attachment record
attachment = ClientAttachment(
client_id=client_id,
filename=filename,
original_filename=original_filename,
file_path=os.path.join(UPLOAD_FOLDER, filename),
file_size=file_size,
uploaded_by=current_user.id,
mime_type=mime_type,
description=description,
is_visible_to_client=is_visible_to_client,
)
db.session.add(attachment)
try:
if not safe_commit("upload_client_attachment", {"client_id": client_id, "attachment_id": attachment.id}):
flash(_("Could not upload attachment due to a database error. Please check server logs."), "error")
# Clean up uploaded file
try:
os.remove(file_path)
except:
pass
return redirect(url_for("clients.view_client", client_id=client_id))
except Exception as e:
# Check if it's a table doesn't exist error
from sqlalchemy.exc import ProgrammingError
error_str = str(e)
if "does not exist" in error_str or "relation" in error_str.lower() or isinstance(e, ProgrammingError):
flash(_("The attachments feature requires a database migration. Please run: flask db upgrade"), "error")
current_app.logger.error(f"client_attachments table does not exist. Migration required: {e}")
else:
flash(_("Could not upload attachment due to a database error. Please check server logs."), "error")
current_app.logger.error(f"Error uploading client attachment: {e}")
# Clean up uploaded file
try:
os.remove(file_path)
except:
pass
return redirect(url_for("clients.view_client", client_id=client_id))
log_event(
"client.attachment.uploaded",
user_id=current_user.id,
client_id=client_id,
attachment_id=attachment.id,
filename=original_filename,
)
track_event(
current_user.id,
"client.attachment.uploaded",
{"client_id": client_id, "attachment_id": attachment.id, "filename": original_filename},
)
flash(_("Attachment uploaded successfully"), "success")
return redirect(url_for("clients.view_client", client_id=client_id))
@clients_bp.route("/clients/attachments/<int:attachment_id>/download")
@login_required
def download_client_attachment(attachment_id):
"""Download a client attachment"""
from flask import send_file
import os
attachment = ClientAttachment.query.get_or_404(attachment_id)
client = attachment.client
# Build file path
file_path = os.path.join(current_app.root_path, "..", attachment.file_path)
if not os.path.exists(file_path):
flash(_("File not found"), "error")
return redirect(url_for("clients.view_client", client_id=client.id))
return send_file(
file_path, as_attachment=True, download_name=attachment.original_filename, mimetype=attachment.mime_type
)
@clients_bp.route("/clients/attachments/<int:attachment_id>/delete", methods=["POST"])
@login_required
@admin_or_permission_required("edit_clients")
def delete_client_attachment(attachment_id):
"""Delete a client attachment"""
import os
attachment = ClientAttachment.query.get_or_404(attachment_id)
client = attachment.client
# Delete file
file_path = os.path.join(current_app.root_path, "..", attachment.file_path)
if os.path.exists(file_path):
try:
os.remove(file_path)
except Exception as e:
current_app.logger.error(f"Failed to delete attachment file: {e}")
# Delete database record
attachment_id_for_log = attachment.id
client_id = client.id
db.session.delete(attachment)
if not safe_commit("delete_client_attachment", {"attachment_id": attachment_id_for_log}):
flash(_("Could not delete attachment due to a database error. Please check server logs."), "error")
return redirect(url_for("clients.view_client", client_id=client_id))
log_event(
"client.attachment.deleted", user_id=current_user.id, client_id=client_id, attachment_id=attachment_id_for_log
)
track_event(
current_user.id, "client.attachment.deleted", {"client_id": client_id, "attachment_id": attachment_id_for_log}
)
flash(_("Attachment deleted successfully"), "success")
return redirect(url_for("clients.view_client", client_id=client_id))
+23
View File
@@ -220,12 +220,31 @@ def view_invoice(invoice_id):
approval_service = InvoiceApprovalService()
approval = approval_service.get_invoice_approval(invoice_id)
# Get link templates for payment_reference (for clickable values)
from app.models import LinkTemplate
from sqlalchemy.exc import ProgrammingError
link_templates_by_field = {}
try:
for template in LinkTemplate.get_active_templates():
if template.field_key == 'payment_reference':
link_templates_by_field['payment_reference'] = template
except ProgrammingError as e:
# Handle case where link_templates table doesn't exist (migration not run)
if "does not exist" in str(e.orig) or "relation" in str(e.orig).lower():
current_app.logger.warning(
"link_templates table does not exist. Run migration: flask db upgrade"
)
link_templates_by_field = {}
else:
raise
return render_template(
"invoices/view.html",
invoice=invoice,
email_templates=email_templates,
email_history=email_history,
approval=approval,
link_templates_by_field=link_templates_by_field,
)
@@ -602,6 +621,7 @@ def bulk_update_status():
"""Update status for multiple invoices at once"""
invoice_ids = request.form.getlist("invoice_ids[]")
new_status = request.form.get("status", "").strip()
invoice_reference = request.form.get("invoice_reference", "").strip()
if not invoice_ids:
flash(_("No invoices selected"), "warning")
@@ -637,6 +657,9 @@ def bulk_update_status():
invoice.payment_status = "fully_paid"
if not invoice.payment_date:
invoice.payment_date = datetime.utcnow().date()
# Set invoice reference if provided
if invoice_reference:
invoice.payment_reference = invoice_reference
updated_count += 1
+290 -2
View File
@@ -23,6 +23,7 @@ from app.models import (
ExtraGood,
Activity,
UserFavoriteProject,
ProjectAttachment,
)
from datetime import datetime
from decimal import Decimal
@@ -326,6 +327,30 @@ def create_project():
project = result["project"]
# Parse custom fields from global definitions
# Format: custom_field_<field_key> = value
from app.models import CustomFieldDefinition
custom_fields = {}
active_definitions = CustomFieldDefinition.get_active_definitions()
for definition in active_definitions:
field_value = request.form.get(f"custom_field_{definition.field_key}", "").strip()
if field_value:
custom_fields[definition.field_key] = field_value
elif definition.is_mandatory:
# Validate mandatory fields
flash(_("Custom field '%(field)s' is required", field=definition.label), "error")
custom_field_definitions = CustomFieldDefinition.get_active_definitions()
return render_template("projects/create.html", clients=Client.get_active_clients(), custom_field_definitions=custom_field_definitions)
# Set custom fields if any
if custom_fields:
project.custom_fields = custom_fields
if not safe_commit("create_project_custom_fields", {"project_id": project.id}):
flash(_("Could not save project custom fields due to a database error"), "error")
custom_field_definitions = CustomFieldDefinition.get_active_definitions()
return render_template("projects/create.html", clients=Client.get_active_clients(), custom_field_definitions=custom_field_definitions)
# Track project created event
log_event(
"project.created",
@@ -399,7 +424,9 @@ def create_project():
flash(f'Project "{name}" created successfully', "success")
return redirect(url_for("projects.view_project", project_id=project.id))
return render_template("projects/create.html", clients=Client.get_active_clients())
from app.models import CustomFieldDefinition
custom_field_definitions = CustomFieldDefinition.get_active_definitions()
return render_template("projects/create.html", clients=Client.get_active_clients(), custom_field_definitions=custom_field_definitions)
@projects_bp.route("/projects/<int:project_id>")
@@ -420,6 +447,54 @@ def view_project(project_id):
flash(_("Project not found"), "error")
return redirect(url_for("projects.list_projects"))
# Get custom field definitions and link templates
from app.models import CustomFieldDefinition, LinkTemplate
from sqlalchemy.exc import ProgrammingError
custom_field_definitions_by_key = {}
try:
for definition in CustomFieldDefinition.get_active_definitions():
custom_field_definitions_by_key[definition.field_key] = definition
except ProgrammingError as e:
if "does not exist" in str(e.orig) or "relation" in str(e.orig).lower():
current_app.logger.warning(
"custom_field_definitions table does not exist. Run migration: flask db upgrade"
)
custom_field_definitions_by_key = {}
else:
raise
link_templates_by_field = {}
try:
for template in LinkTemplate.get_active_templates():
link_templates_by_field[template.field_key] = template
except ProgrammingError as e:
if "does not exist" in str(e.orig) or "relation" in str(e.orig).lower():
current_app.logger.warning(
"link_templates table does not exist. Run migration: flask db upgrade"
)
link_templates_by_field = {}
else:
raise
# Get attachments for this project (if attachments table exists)
attachments = []
try:
attachments = ProjectAttachment.get_project_attachments(project_id)
except ProgrammingError as e:
# Handle case where project_attachments table doesn't exist (migration not run)
if "does not exist" in str(e.orig) or "relation" in str(e.orig).lower():
current_app.logger.warning(
"project_attachments table does not exist. Run migration: flask db upgrade"
)
attachments = []
else:
raise
except Exception as e:
# Handle any other errors gracefully
current_app.logger.warning(f"Could not load attachments for project {project_id}: {e}")
attachments = []
# Prevent browser caching of kanban board
response = render_template(
"projects/view.html",
@@ -432,6 +507,9 @@ def view_project(project_id):
recent_costs=result["recent_costs"],
total_costs_count=result["total_costs_count"],
kanban_columns=result["kanban_columns"],
custom_field_definitions_by_key=custom_field_definitions_by_key,
link_templates_by_field=link_templates_by_field,
attachments=attachments,
)
resp = make_response(response)
resp.headers["Cache-Control"] = "no-cache, no-store, must-revalidate, max-age=0"
@@ -702,6 +780,35 @@ def edit_project(project_id):
project = result["project"]
# Parse custom fields from global definitions
# Format: custom_field_<field_key> = value
from app.models import CustomFieldDefinition
custom_fields = {}
active_definitions = CustomFieldDefinition.get_active_definitions()
for definition in active_definitions:
field_value = request.form.get(f"custom_field_{definition.field_key}", "").strip()
if field_value:
custom_fields[definition.field_key] = field_value
elif definition.is_mandatory:
# Validate mandatory fields
flash(_("Custom field '%(field)s' is required", field=definition.label), "error")
custom_field_definitions = CustomFieldDefinition.get_active_definitions()
return render_template("projects/edit.html", project=project, clients=Client.get_active_clients(), custom_field_definitions=custom_field_definitions)
# Update custom fields
if custom_fields:
project.custom_fields = custom_fields
else:
# Clear custom fields if all are empty
project.custom_fields = {}
# Commit custom fields changes
if not safe_commit("update_project_custom_fields", {"project_id": project.id}):
flash(_("Could not update project custom fields due to a database error"), "error")
custom_field_definitions = CustomFieldDefinition.get_active_definitions()
return render_template("projects/edit.html", project=project, clients=Client.get_active_clients(), custom_field_definitions=custom_field_definitions)
# Log activity
Activity.log(
user_id=current_user.id,
@@ -717,7 +824,9 @@ def edit_project(project_id):
flash(f'Project "{name}" updated successfully', "success")
return redirect(url_for("projects.view_project", project_id=project.id))
return render_template("projects/edit.html", project=project, clients=Client.get_active_clients())
from app.models import CustomFieldDefinition
custom_field_definitions = CustomFieldDefinition.get_active_definitions()
return render_template("projects/edit.html", project=project, clients=Client.get_active_clients(), custom_field_definitions=custom_field_definitions)
@projects_bp.route("/projects/<int:project_id>/archive", methods=["GET", "POST"])
@@ -1613,3 +1722,182 @@ def api_project_goods(project_id):
"count": len(goods),
}
)
# Project attachment routes
@projects_bp.route("/projects/<int:project_id>/attachments/upload", methods=["POST"])
@login_required
@admin_or_permission_required("edit_projects")
def upload_project_attachment(project_id):
"""Upload an attachment to a project"""
from werkzeug.utils import secure_filename
from flask import current_app, send_file
import os
from datetime import datetime
project = Project.query.get_or_404(project_id)
# File upload configuration
ALLOWED_EXTENSIONS = {"png", "jpg", "jpeg", "gif", "pdf", "doc", "docx", "txt", "xls", "xlsx", "zip", "rar"}
UPLOAD_FOLDER = "uploads/project_attachments"
MAX_FILE_SIZE = 10 * 1024 * 1024 # 10 MB
def allowed_file(filename):
return "." in filename and filename.rsplit(".", 1)[1].lower() in ALLOWED_EXTENSIONS
if "file" not in request.files:
flash(_("No file provided"), "error")
return redirect(url_for("projects.view_project", project_id=project_id))
file = request.files["file"]
if file.filename == "":
flash(_("No file selected"), "error")
return redirect(url_for("projects.view_project", project_id=project_id))
if not allowed_file(file.filename):
flash(_("File type not allowed"), "error")
return redirect(url_for("projects.view_project", project_id=project_id))
# Check file size
file.seek(0, os.SEEK_END)
file_size = file.tell()
file.seek(0)
if file_size > MAX_FILE_SIZE:
flash(_("File size exceeds maximum allowed size (10 MB)"), "error")
return redirect(url_for("projects.view_project", project_id=project_id))
# Save file
original_filename = secure_filename(file.filename)
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
filename = f"{project_id}_{timestamp}_{original_filename}"
# Ensure upload directory exists
upload_dir = os.path.join(current_app.root_path, "..", UPLOAD_FOLDER)
os.makedirs(upload_dir, exist_ok=True)
file_path = os.path.join(upload_dir, filename)
file.save(file_path)
# Get file info
mime_type = file.content_type or "application/octet-stream"
description = request.form.get("description", "").strip() or None
is_visible_to_client = request.form.get("is_visible_to_client", "false").lower() == "true"
# Create attachment record
attachment = ProjectAttachment(
project_id=project_id,
filename=filename,
original_filename=original_filename,
file_path=os.path.join(UPLOAD_FOLDER, filename),
file_size=file_size,
uploaded_by=current_user.id,
mime_type=mime_type,
description=description,
is_visible_to_client=is_visible_to_client,
)
db.session.add(attachment)
try:
if not safe_commit("upload_project_attachment", {"project_id": project_id, "attachment_id": attachment.id}):
flash(_("Could not upload attachment due to a database error. Please check server logs."), "error")
# Clean up uploaded file
try:
os.remove(file_path)
except:
pass
return redirect(url_for("projects.view_project", project_id=project_id))
except Exception as e:
# Check if it's a table doesn't exist error
from sqlalchemy.exc import ProgrammingError
error_str = str(e)
if "does not exist" in error_str or "relation" in error_str.lower() or isinstance(e, ProgrammingError):
flash(_("The attachments feature requires a database migration. Please run: flask db upgrade"), "error")
current_app.logger.error(f"project_attachments table does not exist. Migration required: {e}")
else:
flash(_("Could not upload attachment due to a database error. Please check server logs."), "error")
current_app.logger.error(f"Error uploading project attachment: {e}")
# Clean up uploaded file
try:
os.remove(file_path)
except:
pass
return redirect(url_for("projects.view_project", project_id=project_id))
log_event(
"project.attachment.uploaded",
user_id=current_user.id,
project_id=project_id,
attachment_id=attachment.id,
filename=original_filename,
)
track_event(
current_user.id,
"project.attachment.uploaded",
{"project_id": project_id, "attachment_id": attachment.id, "filename": original_filename},
)
flash(_("Attachment uploaded successfully"), "success")
return redirect(url_for("projects.view_project", project_id=project_id))
@projects_bp.route("/projects/attachments/<int:attachment_id>/download")
@login_required
def download_project_attachment(attachment_id):
"""Download a project attachment"""
from flask import current_app, send_file
import os
attachment = ProjectAttachment.query.get_or_404(attachment_id)
project = attachment.project
# Build file path
file_path = os.path.join(current_app.root_path, "..", attachment.file_path)
if not os.path.exists(file_path):
flash(_("File not found"), "error")
return redirect(url_for("projects.view_project", project_id=project.id))
return send_file(
file_path, as_attachment=True, download_name=attachment.original_filename, mimetype=attachment.mime_type
)
@projects_bp.route("/projects/attachments/<int:attachment_id>/delete", methods=["POST"])
@login_required
@admin_or_permission_required("edit_projects")
def delete_project_attachment(attachment_id):
"""Delete a project attachment"""
from flask import current_app
import os
attachment = ProjectAttachment.query.get_or_404(attachment_id)
project = attachment.project
# Delete file
file_path = os.path.join(current_app.root_path, "..", attachment.file_path)
if os.path.exists(file_path):
try:
os.remove(file_path)
except Exception as e:
current_app.logger.error(f"Failed to delete attachment file: {e}")
# Delete database record
attachment_id_for_log = attachment.id
project_id = project.id
db.session.delete(attachment)
if not safe_commit("delete_project_attachment", {"attachment_id": attachment_id_for_log}):
flash(_("Could not delete attachment due to a database error. Please check server logs."), "error")
return redirect(url_for("projects.view_project", project_id=project_id))
log_event(
"project.attachment.deleted", user_id=current_user.id, project_id=project_id, attachment_id=attachment_id_for_log
)
track_event(
current_user.id, "project.attachment.deleted", {"project_id": project_id, "attachment_id": attachment_id_for_log}
)
flash(_("Attachment deleted successfully"), "success")
return redirect(url_for("projects.view_project", project_id=project_id))
+153 -2
View File
@@ -667,6 +667,39 @@ def edit_timer(timer_id):
return render_template("timer/edit_timer.html", timer=timer, projects=projects, tasks=tasks)
@timer_bp.route("/timer/view/<int:timer_id>")
@login_required
def view_timer(timer_id):
"""View a time entry (read-only)"""
timer = TimeEntry.query.get_or_404(timer_id)
# Check if user can view this timer
can_view_all = current_user.is_admin or current_user.has_permission("view_all_time_entries")
if not can_view_all and timer.user_id != current_user.id:
flash(_("You do not have permission to view this time entry"), "error")
return redirect(url_for("main.dashboard"))
# Get link templates for invoice_number (for clickable values)
from app.models import LinkTemplate
from sqlalchemy.exc import ProgrammingError
link_templates_by_field = {}
try:
for template in LinkTemplate.get_active_templates():
if template.field_key == 'invoice_number':
link_templates_by_field['invoice_number'] = template
except ProgrammingError as e:
# Handle case where link_templates table doesn't exist (migration not run)
if "does not exist" in str(e.orig) or "relation" in str(e.orig).lower():
current_app.logger.warning(
"link_templates table does not exist. Run migration: flask db upgrade"
)
link_templates_by_field = {}
else:
raise
return render_template("timer/view_timer.html", timer=timer, link_templates_by_field=link_templates_by_field)
@timer_bp.route("/timer/delete/<int:timer_id>", methods=["POST"])
@login_required
def delete_timer(timer_id):
@@ -772,6 +805,100 @@ def delete_timer(timer_id):
return redirect(redirect_url)
@timer_bp.route("/time-entries/bulk-delete", methods=["POST"])
@login_required
def bulk_delete_time_entries():
"""Bulk delete time entries"""
from app.utils.db import safe_commit
entry_ids = request.form.getlist("entry_ids[]")
if not entry_ids:
flash(_("No time entries selected"), "warning")
return redirect(url_for("timer.time_entries_overview"))
# Load entries
entry_ids_int = [int(eid) for eid in entry_ids if eid.isdigit()]
if not entry_ids_int:
flash(_("Invalid entry IDs"), "error")
return redirect(url_for("timer.time_entries_overview"))
entries = TimeEntry.query.filter(TimeEntry.id.in_(entry_ids_int)).all()
if not entries:
flash(_("No time entries found"), "error")
return redirect(url_for("timer.time_entries_overview"))
# Permission check
can_view_all = current_user.is_admin or current_user.has_permission("view_all_time_entries")
deleted_count = 0
skipped_count = 0
for entry in entries:
# Check permissions
if not can_view_all and entry.user_id != current_user.id:
skipped_count += 1
continue
# Don't allow deletion of active timers
if entry.is_active:
skipped_count += 1
continue
# Delete the entry
db.session.delete(entry)
deleted_count += 1
# Log activity
Activity.log(
user_id=current_user.id,
action="deleted",
entity_type="time_entry",
entity_id=entry.id,
entity_name=f"Time entry #{entry.id}",
description=f"Deleted time entry",
extra_data={"project_id": entry.project_id, "client_id": entry.client_id, "duration": entry.duration_formatted},
ip_address=request.remote_addr,
user_agent=request.headers.get("User-Agent"),
)
if deleted_count > 0:
if not safe_commit("bulk_delete_time_entries", {"count": deleted_count}):
flash(_("Could not delete time entries due to a database error. Please check server logs."), "error")
return redirect(url_for("timer.time_entries_overview"))
flash(
_("Successfully deleted %(count)d time entry/entries", count=deleted_count),
"success"
)
if skipped_count > 0:
flash(
_("Skipped %(count)d time entry/entries (no permission or active timer)", count=skipped_count),
"warning"
)
# Track event
track_event(
current_user.id,
"time_entries.bulk_delete",
{"count": deleted_count}
)
# Preserve filters in redirect
redirect_url = url_for("timer.time_entries_overview")
filters = {}
for key in ["user_id", "project_id", "client_id", "start_date", "end_date", "paid", "billable", "search", "page"]:
value = request.form.get(key) or request.args.get(key)
if value:
filters[key] = value
if filters:
redirect_url += "?" + "&".join(f"{k}={v}" for k, v in filters.items())
return redirect(redirect_url)
@timer_bp.route("/timer/manual", methods=["GET", "POST"])
@login_required
def manual_entry():
@@ -1834,6 +1961,24 @@ def time_entries_overview():
from app.models import CustomFieldDefinition
custom_field_definitions = CustomFieldDefinition.get_active_definitions()
# Get link templates for invoice_number (for clickable values)
from app.models import LinkTemplate
from sqlalchemy.exc import ProgrammingError
link_templates_by_field = {}
try:
for template in LinkTemplate.get_active_templates():
if template.field_key == 'invoice_number':
link_templates_by_field['invoice_number'] = template
except ProgrammingError as e:
# Handle case where link_templates table doesn't exist (migration not run)
if "does not exist" in str(e.orig) or "relation" in str(e.orig).lower():
current_app.logger.warning(
"link_templates table does not exist. Run migration: flask db upgrade"
)
link_templates_by_field = {}
else:
raise
# Check if this is an AJAX request
if request.headers.get("X-Requested-With") == "XMLHttpRequest":
# Return only the time entries list HTML for AJAX requests
@@ -1845,6 +1990,7 @@ def time_entries_overview():
can_view_all=can_view_all,
filters=filters_dict,
custom_field_definitions=custom_field_definitions,
link_templates_by_field=link_templates_by_field,
))
response.headers["Cache-Control"] = "no-cache, no-store, must-revalidate"
return response
@@ -1859,6 +2005,7 @@ def time_entries_overview():
can_view_all=can_view_all,
filters=filters_dict,
custom_field_definitions=custom_field_definitions,
link_templates_by_field=link_templates_by_field,
totals={
"total_hours": round(total_hours, 2),
"total_billable_hours": round(total_billable_hours, 2),
@@ -1876,6 +2023,7 @@ def bulk_mark_paid():
entry_ids = request.form.getlist("entry_ids[]")
paid_status = request.form.get("paid", "").strip().lower()
invoice_reference = request.form.get("invoice_reference", "").strip()
if not entry_ids:
flash(_("No time entries selected"), "warning")
@@ -1915,8 +2063,11 @@ def bulk_mark_paid():
skipped_count += 1
continue
# Update paid status
entry.set_paid(is_paid)
# Update paid status with invoice reference if provided
if is_paid and invoice_reference:
entry.set_paid(is_paid, invoice_number=invoice_reference)
else:
entry.set_paid(is_paid)
updated_count += 1
# Log activity
+38 -3
View File
@@ -256,7 +256,8 @@
btn.setAttribute('aria-label', 'Toggle column visibility');
const dropdown = document.createElement('div');
dropdown.className = 'column-visibility-dropdown hidden absolute right-0 mt-2 w-56 bg-card-light dark:bg-card-dark border border-border-light dark:border-border-dark rounded-md shadow-lg z-50 p-2';
dropdown.className = 'column-visibility-dropdown hidden absolute right-0 mt-2 w-56 bg-card-light dark:bg-card-dark border border-border-light dark:border-border-dark rounded-md shadow-lg p-2';
dropdown.style.zIndex = '9999';
btn.addEventListener('click', (e) => {
e.stopPropagation();
@@ -269,8 +270,42 @@
btnContainer.appendChild(btn);
btnContainer.appendChild(dropdown);
// If existing toolbar (Finance tables), append to the right side container
if (existingToolbarRight) {
// Check if this is the projects table - if so, add to right side next to bulk actions
const isProjectsTable = this.table.closest('#projectsContainer') || this.table.closest('#projectsListContainer');
if (isProjectsTable) {
// For projects table, add to right side next to bulk actions
const projectsListContainer = this.table.closest('#projectsListContainer');
if (projectsListContainer) {
// Find the flex container with the header
const headerContainer = projectsListContainer.querySelector('.flex.justify-between.items-center');
if (headerContainer) {
// Find the right container (last child div with flex class)
const rightContainer = headerContainer.querySelector('div:last-child.flex');
if (rightContainer) {
// Insert before bulk actions button if it exists
const bulkActionsBtn = rightContainer.querySelector('#bulkActionsBtn');
if (bulkActionsBtn && bulkActionsBtn.parentElement) {
rightContainer.insertBefore(btnContainer, bulkActionsBtn.parentElement);
} else {
rightContainer.appendChild(btnContainer);
}
} else {
// Fallback: append to header
headerContainer.appendChild(btnContainer);
}
} else {
// Fallback: insert before the table
const tableWrapper = this.table.closest('div') || this.table.parentElement;
if (tableWrapper) {
const rightToolbar = document.createElement('div');
rightToolbar.className = 'table-toolbar-right flex items-center gap-2 mb-4';
rightToolbar.appendChild(btnContainer);
tableWrapper.insertBefore(rightToolbar, this.table);
}
}
}
} else if (existingToolbarRight) {
// Insert at the beginning of the button group (before Export and Bulk Actions)
existingToolbarRight.insertBefore(btnContainer, existingToolbarRight.firstChild);
} else {
+1 -1
View File
@@ -364,7 +364,7 @@
<i class="fas fa-chevron-down ml-auto sidebar-label"></i>
</button>
<ul id="financeDropdown" class="{% if not finance_open %}hidden {% endif %}mt-2 space-y-2 ml-6">
{% set nav_active_reports = ep.startswith('reports.') or ep.startswith('scheduled_reports.') %}
{% set nav_active_reports = ep.startswith('reports.') and not ep.startswith('scheduled_reports.') %}
{% set nav_active_invoices = ep.startswith('invoices.') %}
{% set nav_active_invoice_approvals = ep.startswith('invoice_approvals.') %}
{% set nav_active_payment_gateways = ep.startswith('payment_gateways.') %}
+111 -9
View File
@@ -79,33 +79,39 @@
</a>
{% endif %}
</div>
{% if client.contact_person or client.email or client.phone or client.address %}
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow">
<h2 class="text-lg font-semibold mb-4">{{ _('Legacy Contact Info') }}</h2>
<div class="space-y-4">
{% if client.contact_person %}
<div>
<h3 class="text-sm font-medium text-text-muted-light dark:text-text-muted-dark">Contact Person</h3>
<p>{{ client.contact_person or 'N/A' }}</p>
<p>{{ client.contact_person }}</p>
</div>
{% endif %}
{% if client.email %}
<div>
<h3 class="text-sm font-medium text-text-muted-light dark:text-text-muted-dark">Email</h3>
<p>
{% if client.email %}
<a href="mailto:{{ client.email }}" class="text-primary hover:underline">{{ client.email }}</a>
{% else %}
N/A
{% endif %}
<a href="mailto:{{ client.email }}" class="text-primary hover:underline">{{ client.email }}</a>
</p>
</div>
{% endif %}
{% if client.phone %}
<div>
<h3 class="text-sm font-medium text-text-muted-light dark:text-text-muted-dark">Phone</h3>
<p>{{ client.phone or 'N/A' }}</p>
<p>{{ client.phone }}</p>
</div>
{% endif %}
{% if client.address %}
<div>
<h3 class="text-sm font-medium text-text-muted-light dark:text-text-muted-dark">Address</h3>
<p>{{ client.address or 'N/A' }}</p>
<p>{{ client.address }}</p>
</div>
{% endif %}
</div>
</div>
{% endif %}
{% if client.custom_fields %}
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow">
<h2 class="text-lg font-semibold mb-4">{{ _('Custom Fields') }}</h2>
@@ -171,6 +177,102 @@
</ul>
</div>
{% endif %}
<!-- Attachments Section -->
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow">
<div class="flex justify-between items-center mb-4">
<h2 class="text-lg font-semibold">
<i class="fas fa-paperclip mr-2"></i>{{ _('Attachments') }}
{% if attachments %}
<span class="text-sm font-normal text-gray-500 dark:text-gray-400">({{ attachments|length }})</span>
{% endif %}
</h2>
{% if current_user.is_admin or has_permission('edit_clients') %}
<button type="button" onclick="document.getElementById('upload-attachment-form').classList.toggle('hidden')" class="bg-primary text-white px-3 py-1.5 rounded-lg hover:bg-primary-dark text-sm">
<i class="fas fa-plus mr-1"></i>{{ _('Upload') }}
</button>
{% endif %}
</div>
<!-- Upload Form -->
{% if current_user.is_admin or has_permission('edit_clients') %}
<div id="upload-attachment-form" class="hidden mb-4 p-4 bg-gray-50 dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700">
<form method="POST" action="{{ url_for('clients.upload_client_attachment', client_id=client.id) }}" enctype="multipart/form-data" class="space-y-3">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div>
<label for="attachment-file" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">{{ _('File') }}</label>
<input type="file" name="file" id="attachment-file" class="w-full form-input" required>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">{{ _('Max size: 10 MB. Allowed: images, PDFs, documents, spreadsheets, archives') }}</p>
</div>
<div>
<label for="attachment-description" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">{{ _('Description (optional)') }}</label>
<input type="text" name="description" id="attachment-description" class="w-full form-input" placeholder="{{ _('e.g., Contract, Agreement, etc.') }}">
</div>
<div class="flex items-center">
<label class="flex items-center">
<input type="checkbox" name="is_visible_to_client" value="true" class="mr-2">
<span class="text-sm text-gray-700 dark:text-gray-300">{{ _('Visible to client in portal') }}</span>
</label>
</div>
<div class="flex gap-2">
<button type="submit" class="bg-primary text-white px-4 py-2 rounded-lg hover:bg-primary-dark text-sm">
<i class="fas fa-upload mr-1"></i>{{ _('Upload') }}
</button>
<button type="button" onclick="document.getElementById('upload-attachment-form').classList.add('hidden')" class="bg-gray-500 text-white px-4 py-2 rounded-lg hover:bg-gray-600 text-sm">
{{ _('Cancel') }}
</button>
</div>
</form>
</div>
{% endif %}
<!-- Attachments List -->
<div class="space-y-2">
{% if attachments %}
{% for attachment in attachments %}
<div class="flex items-center justify-between p-3 bg-gray-50 dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700">
<div class="flex items-center gap-3 flex-1 min-w-0">
<div class="flex-shrink-0">
{% if attachment.is_pdf %}
<i class="fas fa-file-pdf text-red-500 text-xl"></i>
{% elif attachment.is_image %}
<i class="fas fa-file-image text-blue-500 text-xl"></i>
{% elif attachment.is_document %}
<i class="fas fa-file-word text-blue-600 text-xl"></i>
{% else %}
<i class="fas fa-file text-gray-500 text-xl"></i>
{% endif %}
</div>
<div class="flex-1 min-w-0">
<a href="{{ url_for('clients.download_client_attachment', attachment_id=attachment.id) }}" class="text-primary hover:underline font-medium block truncate" title="{{ attachment.original_filename }}">
{{ attachment.original_filename }}
</a>
{% if attachment.description %}
<p class="text-xs text-gray-500 dark:text-gray-400 truncate">{{ attachment.description }}</p>
{% endif %}
<p class="text-xs text-gray-500 dark:text-gray-400">
{{ attachment.file_size_display }} • {{ attachment.uploaded_at|user_datetime('%Y-%m-%d %H:%M') if attachment.uploaded_at else '' }}
{% if attachment.is_visible_to_client %}
<span class="ml-2 px-1.5 py-0.5 bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300 rounded text-xs">{{ _('Client Visible') }}</span>
{% endif %}
</p>
</div>
</div>
{% if current_user.is_admin or has_permission('edit_clients') %}
<form method="POST" action="{{ url_for('clients.delete_client_attachment', attachment_id=attachment.id) }}" class="ml-2" onsubmit="event.preventDefault(); window.showConfirm('{{ _('Are you sure you want to delete this attachment?') }}', { title: '{{ _('Delete Attachment') }}', confirmText: '{{ _('Delete') }}', variant: 'danger' }).then(ok=>{ if(ok) this.submit(); });">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="text-red-500 hover:text-red-700 p-1" title="{{ _('Delete') }}">
<i class="fas fa-trash"></i>
</button>
</form>
{% endif %}
</div>
{% endfor %}
{% else %}
<p class="text-sm text-gray-500 dark:text-gray-400 text-center py-4">{{ _('No attachments yet') }}</p>
{% endif %}
</div>
</div>
</div>
<!-- Right Column: Projects -->
@@ -238,7 +340,7 @@
</thead>
<tbody>
{% for entry in recent_time_entries %}
<tr class="border-b border-border-light dark:border-border-dark hover:bg-background-light dark:hover:bg-background-dark">
<tr class="border-b border-border-light dark:border-border-dark hover:bg-background-light dark:hover:bg-background-dark cursor-pointer" onclick="window.location.href='{{ url_for('timer.view_timer', timer_id=entry.id) }}'">
<td class="p-3">
<div class="text-sm">
{{ entry.start_time|user_datetime('%Y-%m-%d') }}
+57 -8
View File
@@ -123,14 +123,18 @@
<div class="bg-card-light dark:bg-card-dark rounded-lg shadow-xl max-w-md w-full mx-4">
<div class="p-6">
<h3 class="text-lg font-semibold mb-4">{{ _('Change Status for Selected Invoices') }}</h3>
<label for="bulkStatusSelect" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Select Status</label>
<select id="bulkStatusSelect" class="form-input w-full mb-4">
<option value="">-- Select Status --</option>
<option value="draft">Draft</option>
<option value="sent">Sent</option>
<option value="paid">Paid</option>
<option value="overdue">Overdue</option>
<label for="bulkStatusSelect" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">{{ _('Select Status') }}</label>
<select id="bulkStatusSelect" class="form-input w-full mb-4" onchange="toggleInvoiceReferenceField()">
<option value="">-- {{ _('Select Status') }} --</option>
<option value="draft">{{ _('Draft') }}</option>
<option value="sent">{{ _('Sent') }}</option>
<option value="paid">{{ _('Paid') }}</option>
<option value="overdue">{{ _('Overdue') }}</option>
</select>
<div id="invoiceReferenceField" class="hidden mb-4">
<label for="bulkInvoiceReference" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">{{ _('Invoice Reference') }} <span class="text-text-muted-light dark:text-text-muted-dark text-xs">({{ _('Optional') }})</span></label>
<input type="text" id="bulkInvoiceReference" class="form-input w-full" placeholder="{{ _('e.g., Payment confirmation number') }}">
</div>
<div class="flex justify-end gap-2">
<button type="button" onclick="closeBulkStatusDialog()" class="px-4 py-2 text-sm bg-gray-200 dark:bg-gray-700 text-gray-800 dark:text-gray-200 rounded-lg hover:bg-gray-300 dark:hover:bg-gray-600">Cancel</button>
<button type="button" onclick="submitBulkStatus()" class="px-4 py-2 text-sm bg-primary text-white rounded-lg hover:bg-primary/90">{{ _('Update Status') }}</button>
@@ -616,12 +620,41 @@ function submitBulkDelete() {
}
function showBulkStatusDialog() {
document.getElementById('bulkStatusDialog').classList.remove('hidden');
const dialog = document.getElementById('bulkStatusDialog');
if (dialog) {
dialog.classList.remove('hidden');
// Reset form fields
const statusSelect = document.getElementById('bulkStatusSelect');
const referenceField = document.getElementById('invoiceReferenceField');
const referenceInput = document.getElementById('bulkInvoiceReference');
if (statusSelect) statusSelect.value = '';
if (referenceField) referenceField.classList.add('hidden');
if (referenceInput) referenceInput.value = '';
}
return false;
}
function closeBulkStatusDialog() {
document.getElementById('bulkStatusDialog').classList.add('hidden');
// Reset form fields
const statusSelect = document.getElementById('bulkStatusSelect');
const referenceField = document.getElementById('invoiceReferenceField');
const referenceInput = document.getElementById('bulkInvoiceReference');
if (statusSelect) statusSelect.value = '';
if (referenceField) referenceField.classList.add('hidden');
if (referenceInput) referenceInput.value = '';
}
function toggleInvoiceReferenceField() {
const statusSelect = document.getElementById('bulkStatusSelect');
const referenceField = document.getElementById('invoiceReferenceField');
if (!statusSelect || !referenceField) return;
if (statusSelect.value === 'paid') {
referenceField.classList.remove('hidden');
} else {
referenceField.classList.add('hidden');
}
}
function submitBulkStatus() {
@@ -642,6 +675,22 @@ function submitBulkStatus() {
statusValueInput.value = status;
}
// Clear existing invoice_reference input if any
const existingRefInput = form.querySelector('input[name="invoice_reference"]');
if (existingRefInput) {
existingRefInput.remove();
}
// Add invoice reference if provided and status is paid
const referenceInput = document.getElementById('bulkInvoiceReference');
if (status === 'paid' && referenceInput && referenceInput.value.trim()) {
const refInput = document.createElement('input');
refInput.type = 'hidden';
refInput.name = 'invoice_reference';
refInput.value = referenceInput.value.trim();
form.appendChild(refInput);
}
// Clear existing hidden inputs
form.querySelectorAll('input[name="invoice_ids[]"]').forEach(input => input.remove());
+58 -1
View File
@@ -62,6 +62,34 @@
<p><strong>Issue Date:</strong> {{ invoice.issue_date.strftime('%Y-%m-%d') }}</p>
<p><strong>Due Date:</strong> {{ invoice.due_date.strftime('%Y-%m-%d') }}</p>
<p><strong>Status:</strong> {{ invoice.status }}</p>
{% if invoice.payment_reference %}
<p><strong>{{ _('Payment Reference') }}:</strong>
{% set link_template = link_templates_by_field.get('payment_reference') if link_templates_by_field else None %}
{% if link_template %}
{% set rendered_url = link_template.render_url(invoice.payment_reference) %}
{% if rendered_url %}
<a href="{{ rendered_url }}" target="_blank" rel="noopener noreferrer" class="text-primary hover:underline break-all">
{{ invoice.payment_reference }}
<i class="fas fa-external-link-alt ml-1 text-xs"></i>
</a>
{% else %}
{{ invoice.payment_reference }}
{% endif %}
{% elif invoice.payment_reference is string and (invoice.payment_reference.startswith('http://') or invoice.payment_reference.startswith('https://')) %}
<a href="{{ invoice.payment_reference }}" target="_blank" rel="noopener noreferrer" class="text-primary hover:underline break-all">
{{ invoice.payment_reference }}
<i class="fas fa-external-link-alt ml-1 text-xs"></i>
</a>
{% elif invoice.payment_reference is string and invoice.payment_reference.startswith('www.') %}
<a href="https://{{ invoice.payment_reference }}" target="_blank" rel="noopener noreferrer" class="text-primary hover:underline break-all">
{{ invoice.payment_reference }}
<i class="fas fa-external-link-alt ml-1 text-xs"></i>
</a>
{% else %}
{{ invoice.payment_reference }}
{% endif %}
</p>
{% endif %}
{% if invoice.approvals %}
{% set latest_approval = invoice.approvals|sort(attribute='created_at', reverse=true)|first %}
{% if latest_approval %}
@@ -234,7 +262,36 @@
{% endif %}
</td>
<td class="p-2">{{ payment.method or 'N/A' }}</td>
<td class="p-2 text-sm">{{ payment.reference or '-' }}</td>
<td class="p-2 text-sm">
{% if payment.reference %}
{% set link_template = link_templates_by_field.get('payment_reference') if link_templates_by_field else None %}
{% if link_template %}
{% set rendered_url = link_template.render_url(payment.reference) %}
{% if rendered_url %}
<a href="{{ rendered_url }}" target="_blank" rel="noopener noreferrer" class="text-primary hover:underline break-all">
{{ payment.reference }}
<i class="fas fa-external-link-alt ml-1 text-xs"></i>
</a>
{% else %}
{{ payment.reference }}
{% endif %}
{% elif payment.reference is string and (payment.reference.startswith('http://') or payment.reference.startswith('https://')) %}
<a href="{{ payment.reference }}" target="_blank" rel="noopener noreferrer" class="text-primary hover:underline break-all">
{{ payment.reference }}
<i class="fas fa-external-link-alt ml-1 text-xs"></i>
</a>
{% elif payment.reference is string and payment.reference.startswith('www.') %}
<a href="https://{{ payment.reference }}" target="_blank" rel="noopener noreferrer" class="text-primary hover:underline break-all">
{{ payment.reference }}
<i class="fas fa-external-link-alt ml-1 text-xs"></i>
</a>
{% else %}
{{ payment.reference }}
{% endif %}
{% else %}
{% endif %}
</td>
<td class="p-2">
{% if payment.status == 'completed' %}
<span class="px-2 py-1 text-xs font-semibold rounded-full bg-green-100 text-green-800 dark:bg-green-800 dark:text-green-100">
+31
View File
@@ -88,6 +88,37 @@
</div>
</div>
{% if custom_field_definitions %}
<div class="mt-6 border-t border-border-light dark:border-border-dark pt-6">
<h3 class="text-lg font-semibold mb-4">{{ _('Custom Fields') }}</h3>
<p class="text-sm text-text-muted-light dark:text-text-muted-dark mb-4">
{{ _('These custom fields are defined globally and available for all projects.') }}
</p>
<div class="space-y-4">
{% for definition in custom_field_definitions %}
<div>
<label for="custom_field_{{ definition.field_key }}" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
{{ definition.label }}
{% if definition.is_mandatory %}<span class="text-red-600 dark:text-red-400">*</span>{% endif %}
</label>
<input
type="text"
id="custom_field_{{ definition.field_key }}"
name="custom_field_{{ definition.field_key }}"
value="{{ request.form.get('custom_field_' + definition.field_key, '') }}"
placeholder="{{ definition.description or _('Enter value') }}"
class="form-input w-full"
{% if definition.is_mandatory %}required{% endif %}
>
{% if definition.description %}
<p class="text-xs text-text-muted-light dark:text-text-muted-dark mt-1">{{ definition.description }}</p>
{% endif %}
</div>
{% endfor %}
</div>
</div>
{% endif %}
<div class="mt-6 flex justify-end gap-3 border-t border-border-light dark:border-border-dark pt-4">
<a href="{{ url_for('projects.list_projects') }}" class="bg-gray-200 dark:bg-gray-700 px-4 py-2 rounded-lg">{{ _('Cancel') }}</a>
<button type="submit" class="bg-primary text-white px-4 py-2 rounded-lg">{{ _('Create Project') }}</button>
+31
View File
@@ -79,6 +79,37 @@
</div>
</div>
{% if custom_field_definitions %}
<div class="mt-6 border-t border-border-light dark:border-border-dark pt-6">
<h3 class="text-lg font-semibold mb-4">{{ _('Custom Fields') }}</h3>
<p class="text-sm text-text-muted-light dark:text-text-muted-dark mb-4">
{{ _('These custom fields are defined globally and available for all projects.') }}
</p>
<div class="space-y-4">
{% for definition in custom_field_definitions %}
<div>
<label for="custom_field_{{ definition.field_key }}" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">
{{ definition.label }}
{% if definition.is_mandatory %}<span class="text-red-600 dark:text-red-400">*</span>{% endif %}
</label>
<input
type="text"
id="custom_field_{{ definition.field_key }}"
name="custom_field_{{ definition.field_key }}"
value="{{ request.form.get('custom_field_' + definition.field_key, project.get_custom_field(definition.field_key, '') if project.custom_fields else '') }}"
placeholder="{{ definition.description or _('Enter value') }}"
class="form-input w-full"
{% if definition.is_mandatory %}required{% endif %}
>
{% if definition.description %}
<p class="text-xs text-text-muted-light dark:text-text-muted-dark mt-1">{{ definition.description }}</p>
{% endif %}
</div>
{% endfor %}
</div>
</div>
{% endif %}
<div class="mt-6 flex justify-end gap-3 border-t border-border-light dark:border-border-dark pt-4">
<a href="{{ url_for('projects.view_project', project_id=project.id) }}" class="bg-gray-200 dark:bg-gray-700 px-4 py-2 rounded-lg">{{ _('Cancel') }}</a>
<button type="submit" class="bg-primary text-white px-4 py-2 rounded-lg">{{ _('Save Changes') }}</button>
+163
View File
@@ -121,6 +121,73 @@
</div>
</div>
<!-- Custom Fields Section -->
{% if project.custom_fields %}
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow">
<h2 class="text-lg font-semibold mb-4">{{ _('Custom Fields') }}</h2>
<div class="space-y-3">
{% for key, value in project.custom_fields.items() %}
<div>
{% set field_defs = custom_field_definitions_by_key|default({}) %}
{% set field_definition = field_defs[key] if key in field_defs else None %}
<h3 class="text-sm font-medium text-text-muted-light dark:text-text-muted-dark">
{% if field_definition %}{{ field_definition.label }}{% else %}{{ key }}{% endif %}
</h3>
<p class="text-text-light dark:text-text-dark">
{% set link_template = link_templates_by_field.get(key) if link_templates_by_field else None %}
{% if link_template and value %}
{# Render as clickable link using link template #}
{% set rendered_url = link_template.render_url(value) %}
{% if rendered_url %}
<a href="{{ rendered_url }}" target="_blank" rel="noopener noreferrer" class="text-primary hover:underline break-all">
{{ value }}
<i class="fas fa-external-link-alt ml-1 text-xs"></i>
</a>
{% else %}
{{ value }}
{% endif %}
{% elif value is string and (value.startswith('http://') or value.startswith('https://')) %}
{# Fallback: If the value looks like a URL, render it as a clickable link #}
<a href="{{ value }}" target="_blank" rel="noopener noreferrer" class="text-primary hover:underline break-all">
{{ value }}
<i class="fas fa-external-link-alt ml-1 text-xs"></i>
</a>
{% elif value is string and value.startswith('www.') %}
{# Handle www. URLs #}
<a href="https://{{ value }}" target="_blank" rel="noopener noreferrer" class="text-primary hover:underline break-all">
{{ value }}
<i class="fas fa-external-link-alt ml-1 text-xs"></i>
</a>
{% else %}
{{ value }}
{% endif %}
</p>
</div>
{% endfor %}
</div>
</div>
{% endif %}
<!-- Rendered Links from Link Templates -->
{% set rendered_links = project.get_rendered_links() %}
{% if rendered_links %}
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow">
<h2 class="text-lg font-semibold mb-4">{{ _('Quick Links') }}</h2>
<div class="space-y-2">
{% for link in rendered_links %}
<a href="{{ link.url }}" target="_blank" rel="noopener noreferrer" class="flex items-center gap-2 text-primary hover:text-primary-dark hover:underline">
{% if link.icon %}
<i class="{{ link.icon }}"></i>
{% else %}
<i class="fas fa-external-link-alt"></i>
{% endif %}
<span>{{ link.name }}</span>
</a>
{% endfor %}
</div>
</div>
{% endif %}
<!-- Budget Overview Card -->
{% if project.budget_amount %}
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow animated-card">
@@ -218,6 +285,102 @@
{% endfor %}
</ul>
</div>
<!-- Attachments Section -->
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow">
<div class="flex justify-between items-center mb-4">
<h2 class="text-lg font-semibold">
<i class="fas fa-paperclip mr-2"></i>{{ _('Attachments') }}
{% if attachments %}
<span class="text-sm font-normal text-gray-500 dark:text-gray-400">({{ attachments|length }})</span>
{% endif %}
</h2>
{% if current_user.is_admin or has_permission('edit_projects') %}
<button type="button" onclick="document.getElementById('upload-attachment-form').classList.toggle('hidden')" class="bg-primary text-white px-3 py-1.5 rounded-lg hover:bg-primary-dark text-sm">
<i class="fas fa-plus mr-1"></i>{{ _('Upload') }}
</button>
{% endif %}
</div>
<!-- Upload Form -->
{% if current_user.is_admin or has_permission('edit_projects') %}
<div id="upload-attachment-form" class="hidden mb-4 p-4 bg-gray-50 dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700">
<form method="POST" action="{{ url_for('projects.upload_project_attachment', project_id=project.id) }}" enctype="multipart/form-data" class="space-y-3">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<div>
<label for="attachment-file" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">{{ _('File') }}</label>
<input type="file" name="file" id="attachment-file" class="w-full form-input" required>
<p class="text-xs text-gray-500 dark:text-gray-400 mt-1">{{ _('Max size: 10 MB. Allowed: images, PDFs, documents, spreadsheets, archives') }}</p>
</div>
<div>
<label for="attachment-description" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">{{ _('Description (optional)') }}</label>
<input type="text" name="description" id="attachment-description" class="w-full form-input" placeholder="{{ _('e.g., Contract, Specification, etc.') }}">
</div>
<div class="flex items-center">
<label class="flex items-center">
<input type="checkbox" name="is_visible_to_client" value="true" class="mr-2">
<span class="text-sm text-gray-700 dark:text-gray-300">{{ _('Visible to client in portal') }}</span>
</label>
</div>
<div class="flex gap-2">
<button type="submit" class="bg-primary text-white px-4 py-2 rounded-lg hover:bg-primary-dark text-sm">
<i class="fas fa-upload mr-1"></i>{{ _('Upload') }}
</button>
<button type="button" onclick="document.getElementById('upload-attachment-form').classList.add('hidden')" class="bg-gray-500 text-white px-4 py-2 rounded-lg hover:bg-gray-600 text-sm">
{{ _('Cancel') }}
</button>
</div>
</form>
</div>
{% endif %}
<!-- Attachments List -->
<div class="space-y-2">
{% if attachments %}
{% for attachment in attachments %}
<div class="flex items-center justify-between p-3 bg-gray-50 dark:bg-gray-800 rounded-lg border border-gray-200 dark:border-gray-700">
<div class="flex items-center gap-3 flex-1 min-w-0">
<div class="flex-shrink-0">
{% if attachment.is_pdf %}
<i class="fas fa-file-pdf text-red-500 text-xl"></i>
{% elif attachment.is_image %}
<i class="fas fa-file-image text-blue-500 text-xl"></i>
{% elif attachment.is_document %}
<i class="fas fa-file-word text-blue-600 text-xl"></i>
{% else %}
<i class="fas fa-file text-gray-500 text-xl"></i>
{% endif %}
</div>
<div class="flex-1 min-w-0">
<a href="{{ url_for('projects.download_project_attachment', attachment_id=attachment.id) }}" class="text-primary hover:underline font-medium block truncate" title="{{ attachment.original_filename }}">
{{ attachment.original_filename }}
</a>
{% if attachment.description %}
<p class="text-xs text-gray-500 dark:text-gray-400 truncate">{{ attachment.description }}</p>
{% endif %}
<p class="text-xs text-gray-500 dark:text-gray-400">
{{ attachment.file_size_display }} • {{ attachment.uploaded_at|user_datetime('%Y-%m-%d %H:%M') if attachment.uploaded_at else '' }}
{% if attachment.is_visible_to_client %}
<span class="ml-2 px-1.5 py-0.5 bg-blue-100 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300 rounded text-xs">{{ _('Client Visible') }}</span>
{% endif %}
</p>
</div>
</div>
{% if current_user.is_admin or has_permission('edit_projects') %}
<form method="POST" action="{{ url_for('projects.delete_project_attachment', attachment_id=attachment.id) }}" class="ml-2" onsubmit="event.preventDefault(); window.showConfirm('{{ _('Are you sure you want to delete this attachment?') }}', { title: '{{ _('Delete Attachment') }}', confirmText: '{{ _('Delete') }}', variant: 'danger' }).then(ok=>{ if(ok) this.submit(); });">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<button type="submit" class="text-red-500 hover:text-red-700 p-1" title="{{ _('Delete') }}">
<i class="fas fa-trash"></i>
</button>
</form>
{% endif %}
</div>
{% endfor %}
{% else %}
<p class="text-sm text-gray-500 dark:text-gray-400 text-center py-4">{{ _('No attachments yet') }}</p>
{% endif %}
</div>
</div>
</div>
<!-- Right Column: Tabs for Tasks, Entries, etc. -->
+53 -8
View File
@@ -10,9 +10,16 @@
</span>
</div>
<div class="flex items-center gap-2">
<button type="button" id="bulkActionsBtn" class="px-4 py-2 bg-primary text-white rounded-lg hover:bg-primary/90 transition-colors disabled:opacity-50 disabled:cursor-not-allowed" onclick="showBulkPaidDialog()" disabled>
<i class="fas fa-tasks mr-2"></i>{{ _('Bulk Actions') }}
</button>
<div class="relative">
<button type="button" id="bulkActionsBtn" class="px-4 py-2 bg-primary text-white rounded-lg hover:bg-primary/90 transition-colors disabled:opacity-50 disabled:cursor-not-allowed" onclick="openBulkActionsMenu()" disabled>
<i class="fas fa-tasks mr-2"></i>{{ _('Bulk Actions') }}
</button>
<ul id="bulkActionsMenu" class="hidden absolute right-0 mt-2 w-56 bg-card-light dark:bg-card-dark border border-border-light dark:border-border-dark rounded-md shadow-lg z-50">
<li><a class="block px-4 py-2 text-sm text-text-light dark:text-text-dark hover:bg-gray-100 dark:hover:bg-gray-700" href="#" onclick="return showBulkPaidDialog()"><i class="fas fa-check-circle mr-2"></i>{{ _('Mark Paid/Unpaid') }}</a></li>
<li><hr class="my-1 border-border-light dark:border-border-dark"></li>
<li><a class="block px-4 py-2 text-sm text-rose-600 hover:bg-gray-100 dark:hover:bg-gray-700" href="#" onclick="return showBulkDeleteConfirm()"><i class="fas fa-trash mr-2"></i>{{ _('Delete') }}</a></li>
</ul>
</div>
</div>
</div>
@@ -32,6 +39,7 @@
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">{{ _('Duration') }}</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">{{ _('Notes') }}</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">{{ _('Tags') }}</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">{{ _('Invoice Ref') }}</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">{{ _('Status') }}</th>
<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 dark:text-gray-400 uppercase tracking-wider">{{ _('Actions') }}</th>
</tr>
@@ -44,7 +52,9 @@
<input type="checkbox" name="entry_ids[]" value="{{ entry.id }}" class="entry-checkbox h-4 w-4 rounded border-gray-300 text-primary focus:ring-primary" onchange="updateBulkActions()">
</td>
<td class="px-4 py-3 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100">
{{ entry.start_time.strftime('%Y-%m-%d %H:%M') if entry.start_time else '-' }}
<a href="{{ url_for('timer.view_timer', timer_id=entry.id) }}" class="text-primary hover:underline">
{{ entry.start_time.strftime('%Y-%m-%d %H:%M') if entry.start_time else '-' }}
</a>
</td>
{% if can_view_all %}
<td class="px-4 py-3 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100">
@@ -88,6 +98,36 @@
-
{% endif %}
</td>
<td class="px-4 py-3 whitespace-nowrap text-sm text-gray-900 dark:text-gray-100">
{% if entry.invoice_number %}
{% set link_template = link_templates_by_field.get('invoice_number') if link_templates_by_field else None %}
{% if link_template %}
{% set rendered_url = link_template.render_url(entry.invoice_number) %}
{% if rendered_url %}
<a href="{{ rendered_url }}" target="_blank" rel="noopener noreferrer" class="text-primary hover:underline break-all">
{{ entry.invoice_number }}
<i class="fas fa-external-link-alt ml-1 text-xs"></i>
</a>
{% else %}
{{ entry.invoice_number }}
{% endif %}
{% elif entry.invoice_number is string and (entry.invoice_number.startswith('http://') or entry.invoice_number.startswith('https://')) %}
<a href="{{ entry.invoice_number }}" target="_blank" rel="noopener noreferrer" class="text-primary hover:underline break-all">
{{ entry.invoice_number }}
<i class="fas fa-external-link-alt ml-1 text-xs"></i>
</a>
{% elif entry.invoice_number is string and entry.invoice_number.startswith('www.') %}
<a href="https://{{ entry.invoice_number }}" target="_blank" rel="noopener noreferrer" class="text-primary hover:underline break-all">
{{ entry.invoice_number }}
<i class="fas fa-external-link-alt ml-1 text-xs"></i>
</a>
{% else %}
{{ entry.invoice_number }}
{% endif %}
{% else %}
-
{% endif %}
</td>
<td class="px-4 py-3 whitespace-nowrap">
{% if entry.paid %}
<span class="inline-flex items-center px-2 py-1 rounded-full text-xs font-medium bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200">
@@ -105,15 +145,20 @@
{% endif %}
</td>
<td class="px-4 py-3 whitespace-nowrap text-sm">
<a href="{{ url_for('timer.edit_timer', timer_id=entry.id) }}" class="text-primary hover:text-primary/80 mr-2" title="{{ _('Edit') }}">
<i class="fas fa-edit"></i>
</a>
<div class="flex items-center gap-2">
<a href="{{ url_for('timer.view_timer', timer_id=entry.id) }}" class="text-primary hover:text-primary/80" title="{{ _('View') }}">
<i class="fas fa-eye"></i>
</a>
<a href="{{ url_for('timer.edit_timer', timer_id=entry.id) }}" class="text-primary hover:text-primary/80" title="{{ _('Edit') }}">
<i class="fas fa-edit"></i>
</a>
</div>
</td>
</tr>
{% endfor %}
{% else %}
<tr>
<td colspan="{% if can_view_all %}10{% else %}9{% endif %}" class="px-4 py-8 text-center text-gray-500 dark:text-gray-400">
<td colspan="{% if can_view_all %}11{% else %}10{% endif %}" class="px-4 py-8 text-center text-gray-500 dark:text-gray-400">
{{ _('No time entries found') }}
</td>
</tr>
+117 -3
View File
@@ -106,7 +106,7 @@
</div>
</div>
<!-- Bulk Actions Form -->
<!-- Bulk Actions Forms -->
<form id="bulkPaidForm" method="POST" action="{{ url_for('timer.bulk_mark_paid') }}" class="hidden">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
<input type="hidden" name="paid" id="bulkPaidValue">
@@ -117,6 +117,15 @@
{% endfor %}
</form>
<form id="bulkDeleteForm" method="POST" action="{{ url_for('timer.bulk_delete_time_entries') }}" class="hidden">
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
{% for key, value in filters.items() %}
{% if value %}
<input type="hidden" name="{{ key }}" value="{{ value }}">
{% endif %}
{% endfor %}
</form>
<!-- Time Entries Table -->
<div class="bg-card-light dark:bg-card-dark rounded-lg shadow overflow-hidden">
{% include 'timer/_time_entries_list.html' %}
@@ -128,10 +137,14 @@
<div class="p-6">
<h3 class="text-lg font-semibold mb-4">{{ _('Mark Selected Entries as Paid/Unpaid') }}</h3>
<label for="bulkPaidSelect" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">{{ _('Status') }}</label>
<select id="bulkPaidSelect" class="form-input w-full mb-4">
<select id="bulkPaidSelect" class="form-input w-full mb-4" onchange="toggleInvoiceReferenceField()">
<option value="true">{{ _('Paid') }}</option>
<option value="false">{{ _('Unpaid') }}</option>
</select>
<div id="invoiceReferenceField" class="hidden mb-4">
<label for="bulkInvoiceReference" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">{{ _('Invoice Reference') }} <span class="text-text-muted-light dark:text-text-muted-dark text-xs">({{ _('Optional') }})</span></label>
<input type="text" id="bulkInvoiceReference" class="form-input w-full" placeholder="{{ _('e.g., Invoice number or payment reference') }}">
</div>
<div class="flex justify-end gap-3 mt-6">
<button type="button" class="px-4 py-2 bg-gray-200 dark:bg-gray-700 text-gray-800 dark:text-gray-200 rounded-lg hover:bg-gray-300 dark:hover:bg-gray-600 transition-colors" onclick="closeBulkPaidDialog()">
{{ _('Cancel') }}
@@ -182,16 +195,64 @@ function updateBulkActions() {
}
}
function openBulkActionsMenu() {
const menu = document.getElementById('bulkActionsMenu');
if (menu) {
menu.classList.toggle('hidden');
}
}
// Close menu when clicking outside
document.addEventListener('click', function(e) {
const menu = document.getElementById('bulkActionsMenu');
const btn = document.getElementById('bulkActionsBtn');
if (menu && btn && !menu.contains(e.target) && !btn.contains(e.target)) {
menu.classList.add('hidden');
}
});
function toggleInvoiceReferenceField() {
const statusSelect = document.getElementById('bulkPaidSelect');
const referenceField = document.getElementById('invoiceReferenceField');
if (!statusSelect || !referenceField) return;
if (statusSelect.value === 'true') {
referenceField.classList.remove('hidden');
} else {
referenceField.classList.add('hidden');
const referenceInput = document.getElementById('bulkInvoiceReference');
if (referenceInput) referenceInput.value = '';
}
}
function showBulkPaidDialog() {
const checkboxes = document.querySelectorAll('.entry-checkbox:checked');
if (checkboxes.length === 0) {
return;
alert('{{ _("Please select at least one time entry") }}');
return false;
}
document.getElementById('bulkPaidDialog').classList.remove('hidden');
// Reset form fields
const statusSelect = document.getElementById('bulkPaidSelect');
const referenceField = document.getElementById('invoiceReferenceField');
const referenceInput = document.getElementById('bulkInvoiceReference');
if (statusSelect) statusSelect.value = 'true';
if (referenceField) referenceField.classList.remove('hidden');
if (referenceInput) referenceInput.value = '';
// Close the menu
document.getElementById('bulkActionsMenu').classList.add('hidden');
return false;
}
function closeBulkPaidDialog() {
document.getElementById('bulkPaidDialog').classList.add('hidden');
// Reset form fields
const statusSelect = document.getElementById('bulkPaidSelect');
const referenceField = document.getElementById('invoiceReferenceField');
const referenceInput = document.getElementById('bulkInvoiceReference');
if (statusSelect) statusSelect.value = 'true';
if (referenceField) referenceField.classList.add('hidden');
if (referenceInput) referenceInput.value = '';
}
function submitBulkPaid() {
@@ -199,6 +260,10 @@ function submitBulkPaid() {
const form = document.getElementById('bulkPaidForm');
const paidValue = document.getElementById('bulkPaidSelect').value;
// Clear existing entry IDs and invoice reference
form.querySelectorAll('input[name="entry_ids[]"]').forEach(input => input.remove());
form.querySelectorAll('input[name="invoice_reference"]').forEach(input => input.remove());
// Add selected entry IDs to form
checkboxes.forEach(cb => {
const input = document.createElement('input');
@@ -208,10 +273,59 @@ function submitBulkPaid() {
form.appendChild(input);
});
// Add invoice reference if provided and status is paid
const referenceInput = document.getElementById('bulkInvoiceReference');
if (paidValue === 'true' && referenceInput && referenceInput.value.trim()) {
const refInput = document.createElement('input');
refInput.type = 'hidden';
refInput.name = 'invoice_reference';
refInput.value = referenceInput.value.trim();
form.appendChild(refInput);
}
document.getElementById('bulkPaidValue').value = paidValue;
form.submit();
}
function showBulkDeleteConfirm() {
const checkboxes = document.querySelectorAll('.entry-checkbox:checked');
if (checkboxes.length === 0) {
alert('{{ _("Please select at least one time entry") }}');
return false;
}
const count = checkboxes.length;
const msg = `{{ _("Are you sure you want to delete") }} ${count} {{ _("time entry/entries") }}? {{ _("This action cannot be undone.") }}`;
if (window.showConfirm) {
window.showConfirm(msg, { title: '{{ _("Delete Time Entries") }}', confirmText: '{{ _("Delete") }}', variant: 'danger' }).then(function(ok) {
if (ok) submitBulkDelete();
});
} else if (confirm(msg)) {
submitBulkDelete();
}
// Close the menu
document.getElementById('bulkActionsMenu').classList.add('hidden');
return false;
}
function submitBulkDelete() {
const checkboxes = document.querySelectorAll('.entry-checkbox:checked');
const form = document.getElementById('bulkDeleteForm');
// Clear existing entry IDs
form.querySelectorAll('input[name="entry_ids[]"]').forEach(input => input.remove());
// Add selected entry IDs to form
checkboxes.forEach(cb => {
const input = document.createElement('input');
input.type = 'hidden';
input.name = 'entry_ids[]';
input.value = cb.value;
form.appendChild(input);
});
form.submit();
}
// Time Entries Filter Handler - AJAX filtering
(function() {
'use strict';
+177
View File
@@ -0,0 +1,177 @@
{% extends "base.html" %}
{% from "components/ui.html" import page_header %}
{% block title %}{{ _('Time Entry') }} - {{ app_name }}{% endblock %}
{% block content %}
{% set breadcrumbs = [
{'text': _('Time Entries'), 'url': url_for('timer.time_entries_overview')},
{'text': _('View Entry')}
] %}
{{ page_header(
icon_class='fas fa-clock',
title_text=_('Time Entry'),
subtitle_text=_('View time entry details'),
breadcrumbs=breadcrumbs,
actions_html='<a href="' + url_for("timer.edit_timer", timer_id=timer.id) + '" class="bg-primary text-white px-4 py-2 rounded-lg hover:bg-primary/90 transition-colors"><i class="fas fa-edit mr-2"></i>' + _('Edit') + '</a>'
) }}
<div class="grid grid-cols-1 lg:grid-cols-3 gap-6">
<!-- Main Content -->
<div class="lg:col-span-2 space-y-6">
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow">
<h2 class="text-lg font-semibold mb-4">{{ _('Entry Details') }}</h2>
<div class="space-y-4">
<div class="grid grid-cols-2 gap-4">
<div>
<h3 class="text-sm font-medium text-text-muted-light dark:text-text-muted-dark mb-1">{{ _('Start Time') }}</h3>
<p class="text-text-light dark:text-text-dark">
{{ timer.start_time|user_datetime('%Y-%m-%d %H:%M:%S') if timer.start_time else '-' }}
</p>
</div>
<div>
<h3 class="text-sm font-medium text-text-muted-light dark:text-text-muted-dark mb-1">{{ _('End Time') }}</h3>
<p class="text-text-light dark:text-text-dark">
{{ timer.end_time|user_datetime('%Y-%m-%d %H:%M:%S') if timer.end_time else '-' }}
</p>
</div>
</div>
<div>
<h3 class="text-sm font-medium text-text-muted-light dark:text-text-muted-dark mb-1">{{ _('Duration') }}</h3>
<p class="text-text-light dark:text-text-dark font-semibold text-lg">{{ timer.duration_formatted }}</p>
</div>
{% if timer.notes %}
<div>
<h3 class="text-sm font-medium text-text-muted-light dark:text-text-muted-dark mb-1">{{ _('Notes') }}</h3>
<p class="text-text-light dark:text-text-dark whitespace-pre-wrap">{{ timer.notes }}</p>
</div>
{% endif %}
{% if timer.tags %}
<div>
<h3 class="text-sm font-medium text-text-muted-light dark:text-text-muted-dark mb-1">{{ _('Tags') }}</h3>
<div class="flex flex-wrap gap-2">
{% for tag in timer.tag_list %}
<span class="inline-block bg-gray-100 dark:bg-gray-700 text-gray-800 dark:text-gray-200 px-2 py-1 rounded text-sm">{{ tag }}</span>
{% endfor %}
</div>
</div>
{% endif %}
</div>
</div>
</div>
<!-- Sidebar -->
<div class="lg:col-span-1 space-y-6">
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow">
<h2 class="text-lg font-semibold mb-4">{{ _('Project & Client') }}</h2>
<div class="space-y-4">
{% if timer.project %}
<div>
<h3 class="text-sm font-medium text-text-muted-light dark:text-text-muted-dark mb-1">{{ _('Project') }}</h3>
<p class="text-text-light dark:text-text-dark">
<a href="{{ url_for('projects.view_project', project_id=timer.project.id) }}" class="text-primary hover:underline">
{{ timer.project.name }}
</a>
</p>
</div>
{% endif %}
{% if timer.client %}
<div>
<h3 class="text-sm font-medium text-text-muted-light dark:text-text-muted-dark mb-1">{{ _('Client') }}</h3>
<p class="text-text-light dark:text-text-dark">
<a href="{{ url_for('clients.view_client', client_id=timer.client.id) }}" class="text-primary hover:underline">
{{ timer.client.name }}
</a>
</p>
</div>
{% endif %}
{% if timer.task %}
<div>
<h3 class="text-sm font-medium text-text-muted-light dark:text-text-muted-dark mb-1">{{ _('Task') }}</h3>
<p class="text-text-light dark:text-text-dark">
<a href="{{ url_for('tasks.view_task', task_id=timer.task.id) }}" class="text-primary hover:underline">
{{ timer.task.name }}
</a>
</p>
</div>
{% endif %}
</div>
</div>
<div class="bg-card-light dark:bg-card-dark p-6 rounded-lg shadow">
<h2 class="text-lg font-semibold mb-4">{{ _('Status & Billing') }}</h2>
<div class="space-y-4">
<div>
<h3 class="text-sm font-medium text-text-muted-light dark:text-text-muted-dark mb-1">{{ _('User') }}</h3>
<p class="text-text-light dark:text-text-dark">{{ timer.user.display_name if timer.user else '-' }}</p>
</div>
<div>
<h3 class="text-sm font-medium text-text-muted-light dark:text-text-muted-dark mb-1">{{ _('Billable') }}</h3>
<p class="text-text-light dark:text-text-dark">
{% if timer.billable %}
<span class="px-2 py-1 rounded-full text-xs font-medium bg-emerald-100 text-emerald-700 dark:bg-emerald-900/30 dark:text-emerald-300">
<i class="fas fa-dollar-sign mr-1"></i>{{ _('Billable') }}
</span>
{% else %}
<span class="px-2 py-1 rounded-full text-xs font-medium bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-200">
{{ _('Non-billable') }}
</span>
{% endif %}
</p>
</div>
<div>
<h3 class="text-sm font-medium text-text-muted-light dark:text-text-muted-dark mb-1">{{ _('Paid') }}</h3>
<p class="text-text-light dark:text-text-dark">
{% if timer.paid %}
<span class="px-2 py-1 rounded-full text-xs font-medium bg-green-100 text-green-700 dark:bg-green-900/30 dark:text-green-300">
<i class="fas fa-check-circle mr-1"></i>{{ _('Paid') }}
</span>
{% else %}
<span class="px-2 py-1 rounded-full text-xs font-medium bg-gray-100 text-gray-700 dark:bg-gray-700 dark:text-gray-200">
<i class="fas fa-clock mr-1"></i>{{ _('Unpaid') }}
</span>
{% endif %}
</p>
</div>
{% if timer.invoice_number %}
<div>
<h3 class="text-sm font-medium text-text-muted-light dark:text-text-muted-dark mb-1">{{ _('Invoice Reference') }}</h3>
<p class="text-text-light dark:text-text-dark">
{% set link_template = link_templates_by_field.get('invoice_number') if link_templates_by_field else None %}
{% if link_template %}
{% set rendered_url = link_template.render_url(timer.invoice_number) %}
{% if rendered_url %}
<a href="{{ rendered_url }}" target="_blank" rel="noopener noreferrer" class="text-primary hover:underline break-all">
{{ timer.invoice_number }}
<i class="fas fa-external-link-alt ml-1 text-xs"></i>
</a>
{% else %}
{{ timer.invoice_number }}
{% endif %}
{% elif timer.invoice_number is string and (timer.invoice_number.startswith('http://') or timer.invoice_number.startswith('https://')) %}
<a href="{{ timer.invoice_number }}" target="_blank" rel="noopener noreferrer" class="text-primary hover:underline break-all">
{{ timer.invoice_number }}
<i class="fas fa-external-link-alt ml-1 text-xs"></i>
</a>
{% elif timer.invoice_number is string and timer.invoice_number.startswith('www.') %}
<a href="https://{{ timer.invoice_number }}" target="_blank" rel="noopener noreferrer" class="text-primary hover:underline break-all">
{{ timer.invoice_number }}
<i class="fas fa-external-link-alt ml-1 text-xs"></i>
</a>
{% else %}
{{ timer.invoice_number }}
{% endif %}
</p>
</div>
{% endif %}
<div>
<h3 class="text-sm font-medium text-text-muted-light dark:text-text-muted-dark mb-1">{{ _('Source') }}</h3>
<p class="text-text-light dark:text-text-dark">{{ timer.source|title if timer.source else '-' }}</p>
</div>
</div>
</div>
</div>
</div>
{% endblock %}
@@ -0,0 +1,55 @@
"""Add custom fields to projects
Revision ID: 085_add_project_custom_fields
Revises: 084_add_custom_field_definitions
Create Date: 2025-01-28
This migration adds:
- custom_fields JSON column to projects table for flexible custom data storage
"""
from alembic import op
import sqlalchemy as sa
from sqlalchemy.dialects import postgresql
# revision identifiers, used by Alembic.
revision = '085_add_project_custom_fields'
down_revision = '084_custom_field_definitions'
branch_labels = None
depends_on = None
def _has_column(inspector, table_name: str, column_name: str) -> bool:
"""Check if a column exists in a table"""
try:
return column_name in [col['name'] for col in inspector.get_columns(table_name)]
except Exception:
return False
def upgrade():
"""Add custom_fields to projects table"""
bind = op.get_bind()
inspector = sa.inspect(bind)
# Add custom_fields column to projects table if it doesn't exist
if 'projects' in inspector.get_table_names():
if not _has_column(inspector, 'projects', 'custom_fields'):
# Use JSONB for PostgreSQL, JSON for SQLite
try:
op.add_column('projects', sa.Column('custom_fields', postgresql.JSONB(astext_type=sa.Text()), nullable=True))
except Exception:
# Fallback to JSON for SQLite
op.add_column('projects', sa.Column('custom_fields', sa.JSON(), nullable=True))
def downgrade():
"""Remove custom_fields from projects table"""
bind = op.get_bind()
inspector = sa.inspect(bind)
# Remove custom_fields column from projects table
if 'projects' in inspector.get_table_names():
if _has_column(inspector, 'projects', 'custom_fields'):
op.drop_column('projects', 'custom_fields')
@@ -0,0 +1,72 @@
"""Add project and client attachments tables
Revision ID: 086_project_client_attachments
Revises: 085_add_project_custom_fields
Create Date: 2025-01-29
This migration adds:
- project_attachments table for file attachments to projects
- client_attachments table for file attachments to clients
"""
from alembic import op
import sqlalchemy as sa
# revision identifiers, used by Alembic.
revision = '086_project_client_attachments'
down_revision = '085_add_project_custom_fields'
branch_labels = None
depends_on = None
def upgrade():
"""Create project_attachments and client_attachments tables"""
# Create project_attachments table
op.create_table('project_attachments',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('project_id', sa.Integer(), nullable=False),
sa.Column('filename', sa.String(length=255), nullable=False),
sa.Column('original_filename', sa.String(length=255), nullable=False),
sa.Column('file_path', sa.String(length=500), nullable=False),
sa.Column('file_size', sa.Integer(), nullable=False),
sa.Column('mime_type', sa.String(length=100), nullable=True),
sa.Column('description', sa.Text(), nullable=True),
sa.Column('is_visible_to_client', sa.Boolean(), nullable=False, server_default='false'),
sa.Column('uploaded_by', sa.Integer(), nullable=False),
sa.Column('uploaded_at', sa.DateTime(), nullable=False),
sa.ForeignKeyConstraint(['project_id'], ['projects.id'], ondelete='CASCADE'),
sa.ForeignKeyConstraint(['uploaded_by'], ['users.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id')
)
op.create_index('ix_project_attachments_project_id', 'project_attachments', ['project_id'], unique=False)
op.create_index('ix_project_attachments_uploaded_by', 'project_attachments', ['uploaded_by'], unique=False)
# Create client_attachments table
op.create_table('client_attachments',
sa.Column('id', sa.Integer(), nullable=False),
sa.Column('client_id', sa.Integer(), nullable=False),
sa.Column('filename', sa.String(length=255), nullable=False),
sa.Column('original_filename', sa.String(length=255), nullable=False),
sa.Column('file_path', sa.String(length=500), nullable=False),
sa.Column('file_size', sa.Integer(), nullable=False),
sa.Column('mime_type', sa.String(length=100), nullable=True),
sa.Column('description', sa.Text(), nullable=True),
sa.Column('is_visible_to_client', sa.Boolean(), nullable=False, server_default='false'),
sa.Column('uploaded_by', sa.Integer(), nullable=False),
sa.Column('uploaded_at', sa.DateTime(), nullable=False),
sa.ForeignKeyConstraint(['client_id'], ['clients.id'], ondelete='CASCADE'),
sa.ForeignKeyConstraint(['uploaded_by'], ['users.id'], ondelete='CASCADE'),
sa.PrimaryKeyConstraint('id')
)
op.create_index('ix_client_attachments_client_id', 'client_attachments', ['client_id'], unique=False)
op.create_index('ix_client_attachments_uploaded_by', 'client_attachments', ['uploaded_by'], unique=False)
def downgrade():
"""Drop project_attachments and client_attachments tables"""
op.drop_index('ix_client_attachments_uploaded_by', table_name='client_attachments')
op.drop_index('ix_client_attachments_client_id', table_name='client_attachments')
op.drop_table('client_attachments')
op.drop_index('ix_project_attachments_uploaded_by', table_name='project_attachments')
op.drop_index('ix_project_attachments_project_id', table_name='project_attachments')
op.drop_table('project_attachments')