mirror of
https://github.com/DRYTRIX/TimeTracker.git
synced 2026-05-05 11:59:42 -05:00
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:
@@ -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",
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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))
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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.') %}
|
||||
|
||||
@@ -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') }}
|
||||
|
||||
@@ -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());
|
||||
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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. -->
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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')
|
||||
|
||||
Reference in New Issue
Block a user